diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 3f00b58538..2539fbfcbf 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -351,9 +351,9 @@ function runNpmSelfUpdate() { } if (historyRestoreIncomplete()) { console.warn( - "opencodex: WARNING — Codex resume history was NOT restored (history DB locked; Codex app/IDE open?).\n" + - " Routed threads stay hidden in the native Codex app until restored.\n" + - " After the update: close the Codex app, then run 'ocx stop' once to restore.", + "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + + " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + + " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", ); } } diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 52dc472be2..6b81c25608 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -186,11 +186,16 @@ le proxy renvoie `426` et Codex se rabat sur HTTP/SSE. ## Identité et historique du fil de discussion La configuration de bouclage par défaut conserve l'étiquette du fournisseur natif `openai` de Codex sur les -nouveaux fils ; la reprise normale de l'historique ne nécessite donc aucun remappage. Lors de la première -synchronisation, elle remplace également par `openai` les étiquettes créées par d'anciennes versions -d'opencodex. Hors bouclage, le mode fournisseur dédié continue de refléter l'historique sous le fournisseur -`opencodex` tant qu'il est actif, puis restaure les métadonnées sauvegardées lorsqu'il prend fin. Définissez -`syncResumeHistory: false` pour ne pas modifier l'historique. +nouveaux fils ; la reprise normale de l'historique ne nécessite donc aucun remappage. La synchronisation et la +restauration n'appliquent qu'un manifeste de sauvegarde correspondant et rétablissent exactement le fournisseur, +la source et l'indicateur d'événement d'origine. Une ligne `opencodex` sans manifeste reste inchangée ; utilisez +`ocx recover-history --legacy-openai --yes` uniquement pour forcer explicitement ce réétiquetage hérité. Cette commande +est volontairement large : elle réétiquette en `openai` chaque fil contenant un message utilisateur et actuellement +marqué `opencodex`, normalise `exec` en `cli` et active l'indicateur d'événement — y compris l'historique légitime +d'un fournisseur dédié. Sauvegardez l'état et ne l'utilisez que si vous souhaitez cette portée complète. Hors bouclage, +le mode fournisseur dédié continue de refléter l'historique sous le fournisseur `opencodex` tant qu'il est actif, +puis restaure les métadonnées sauvegardées lorsqu'il prend fin. Définissez `syncResumeHistory: false` pour ne pas +modifier l'historique. ## Synchronisation du catalogue de modèles @@ -282,7 +287,7 @@ d'affichage personnalisé. Si `config.toml` sélectionne déjà un fournisseur autre que `openai` ou `opencodex`, OpenCodex ne modifie pas le fichier. Il ignore également l'écriture des profils, l'actualisation du catalogue et du cache, ainsi que la -migration immédiate ou en arrière-plan de l'historique Codex. Les outils qui gèrent un fournisseur personnalisé +restauration immédiate ou en arrière-plan des métadonnées de l'historique Codex. Les outils qui gèrent un fournisseur personnalisé étiquettent souvent les sessions existantes avec son identifiant ; remplacer l'identifiant actif peut faire disparaître ces sessions pourtant intactes de la vue d'historique de Codex. La même protection s'applique à un fournisseur externe sélectionné par un ancien profil racine. diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index c63d3065a6..772e80556f 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -47,10 +47,12 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` Récupération explicite destinée aux anciennes versions de développement qui remappaient l’historique de Codex App avant l’ajout des sauvegardes réversibles. Fermez d’abord Codex si sa base de données d’historique est verrouillée. +Il s'agit d'un réétiquetage large et destructif : chaque fil contenant un message utilisateur et actuellement marqué `opencodex` passe à `openai`, `exec` est normalisé en `cli` et l'indicateur d'événement est activé. L'historique légitime d'un fournisseur dédié est également concerné. Sauvegardez l'état et n'exécutez la commande que si vous souhaitez cette portée complète. + ### `ocx uninstall` · `ocx remove` Arrête le service et le proxy, supprime le service et le shim Codex, rétablit le fonctionnement natif de Codex, puis supprime la configuration locale d’opencodex uniquement si toutes les étapes de restauration ont réussi. `remove` est un alias de `uninstall`. Le nettoyage de la configuration exige les métadonnées de propriété créées par une installation récente ; les répertoires anciens ou partagés sont conservés. diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index de7256b2b9..071c8d86d5 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -31,7 +31,8 @@ exécute des fonctionnalités d'assistance autour des demandes du fournisseur. | `images?` | `OcxImagesConfig` | sélection automatique OpenAI | Options de relais d'images autonomes pour Codex `image_gen`. | Si une ancienne version de développement a modifié les métadonnées de l'historique de reprise avant que la prise en charge de la sauvegarde n'existe, exécutez -`ocx recover-history --legacy-openai` pour forcer la récupération du fournisseur natif. +`ocx recover-history --legacy-openai --yes` pour forcer la récupération du fournisseur natif. +La commande réétiquette chaque ligne `opencodex` contenant un message utilisateur, y compris l'historique légitime d'un fournisseur dédié ; consultez l'avertissement sur la portée complète dans la référence du cycle de vie avant de l'exécuter. ## Accès à distance diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 80e1c152dd..1209f96b44 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -179,9 +179,14 @@ HTTP/SSE. ## Thread identity and history The default loopback form keeps new threads tagged with Codex's native `openai` provider, so normal -resume history needs no remapping. On first sync it also migrates threads tagged by older opencodex -builds back to `openai`. Non-loopback dedicated-provider mode still mirrors history under the -`opencodex` provider while active and restores the backed-up metadata on exit. Set +resume history needs no remapping. Sync and restore apply only a matching backup manifest and +restore each thread's exact original provider, source, and event marker. A bare `opencodex` row with +no manifest is left unchanged; use `ocx recover-history --legacy-openai --yes` only when you explicitly +intend to force that legacy relabel. The command is intentionally broad: it rewrites every thread +with a user message currently tagged `opencodex` to `openai`, normalizes `exec` to `cli`, and sets +the event marker—including legitimate dedicated-provider history. Back up the state and use it only +when that full scope is intended. Non-loopback dedicated-provider mode still mirrors history +under the `opencodex` provider while active and restores the backed-up metadata on exit. Set `syncResumeHistory: false` to leave history untouched. ## Model catalog sync @@ -293,7 +298,7 @@ name. If `config.toml` already selects a provider other than `openai` or `opencodex`, OpenCodex leaves the file unchanged and skips profile writes, catalog/cache refresh, and both immediate and background -Codex history migration. Tools that manage a custom provider often tag existing sessions with that +Codex history metadata restoration. Tools that manage a custom provider often tag existing sessions with that provider id; replacing the active id can make those intact sessions disappear from Codex's history view. The same protection applies to an external provider selected by a legacy root profile. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index ced7a0a112..a4e7816b27 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -116,7 +116,7 @@ Windows では、ChatGPT/Codex アプリが `%USERPROFILE%\\.codex` を読み取 ## スレッドのアイデンティティと履歴 -デフォルトのループバック形式では、Codex のネイティブ `openai` プロバイダーでタグ付けされた新しいスレッドが維持されるため、通常の再開履歴には再マッピングが必要ありません。最初の同期時に、古い opencodex ビルドでタグ付けされたスレッドも `openai` に移行されます。非ループバック専用プロバイダー モードでは、アクティブな間は `opencodex` プロバイダーの下で履歴がミラーリングされ、終了時にバックアップされたメタデータが復元されます。履歴を残さないように `syncResumeHistory: false` を設定します。 +デフォルトのループバック形式では、Codex のネイティブ `openai` プロバイダーでタグ付けされた新しいスレッドが維持されるため、通常の再開履歴には再マッピングが必要ありません。同期と復元は、一致するバックアップマニフェストだけを適用し、各スレッドの元のプロバイダー、ソース、イベントマーカーを正確に復元します。マニフェストのない `opencodex` 行は変更されません。従来の再ラベル付けを明示的に強制する場合にだけ `ocx recover-history --legacy-openai --yes` を使用してください。このコマンドは意図的に広範囲です。ユーザーメッセージを持ち、現在 `opencodex` とタグ付けされているすべてのスレッドを `openai` に変更し、`exec` を `cli` に正規化してイベントマーカーを設定します。正当な専用プロバイダー履歴も対象です。状態をバックアップし、この全範囲を意図する場合にのみ使用してください。非ループバック専用プロバイダー モードでは、アクティブな間は `opencodex` プロバイダーの下で履歴がミラーリングされ、終了時にバックアップされたメタデータが復元されます。履歴を変更しないように `syncResumeHistory: false` を設定します。 ## モデルカタログの同期 @@ -182,7 +182,7 @@ ocx sync-cache ### 外部プロバイダーマネージャー -`config.toml` がすでに `openai` または `opencodex` 以外のプロバイダーを選択している場合、OpenCodex はファイルを変更しないままにし、プロファイルの書き込み、カタログ/キャッシュの更新、および即時およびバックグラウンドの両方の Codex 履歴の移行をスキップします。カスタム プロバイダーを管理するツールは、多くの場合、既存のセッションにそのプロバイダー ID をタグ付けします。アクティブな ID を置き換えると、それらの無傷のセッションが Codex の履歴ビューから消える可能性があります。同じ保護が、レガシー ルート プロファイルによって選択された外部プロバイダーにも適用されます。 +`config.toml` がすでに `openai` または `opencodex` 以外のプロバイダーを選択している場合、OpenCodex はファイルを変更しないままにし、プロファイルの書き込み、カタログ/キャッシュの更新、および即時およびバックグラウンドの両方の Codex 履歴メタデータの復元をスキップします。カスタム プロバイダーを管理するツールは、多くの場合、既存のセッションにそのプロバイダー ID をタグ付けします。アクティブな ID を置き換えると、それらの無傷のセッションが Codex の履歴ビューから消える可能性があります。同じ保護が、レガシー ルート プロファイルによって選択された外部プロバイダーにも適用されます。 1 つのツールを Codex プロバイダー設定の所有者として保持します。既存のプロバイダー マネージャーの背後で OpenCodex を使用するには、チャット完了変換ではなく、応答パススルー (Codex TOML では `wire_api = "responses"`) を使用して、そのプロバイダーを `http://127.0.0.1:10100/v1` に指定します。プロキシ API 認証が有効な場合は、上記の非ループバック プロバイダー フォームと一致して、`OPENCODEX_API_AUTH_TOKEN` から `x-opencodex-api-key` も渡します。 OpenCodex にルーティングを直接挿入させるには、まず Codex を組み込みの `openai` プロバイダーに戻し、ユーザー所有のルート `openai_base_url` を削除してから、`ocx start` を再実行します。 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 0ee91c6351..277c78a373 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -47,10 +47,12 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` 可逆バックアップ サポートが存在する前に Codex App 履歴を再マップした古い開発ビルドの明示的なリカバリ。履歴データベースがロックされている場合は、まず Codex を閉じてください。 +これは広範囲で破壊的な再ラベル付けです。ユーザーメッセージを持ち、現在 `opencodex` とタグ付けされているすべてのスレッドを `openai` に変更し、`exec` を `cli` に正規化してイベントマーカーを設定します。正当な専用プロバイダー履歴も対象です。状態をバックアップし、この全範囲を意図する場合にのみ実行してください。 + ### `ocx uninstall`・`ocx remove` すべての復元手順が成功した場合にのみ、サービスとプロキシを停止し、サービスと Codex シムを削除し、ネイティブ Codex を復元してから、opencodex ローカル設定を削除します。 `remove` は `uninstall` の別名です。設定のクリーンアップには、新規インストールによって作成された所有権メタデータが必要です。従来のディレクトリまたは共有ディレクトリはそのまま残ります。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index cacde58f65..72e372e8f2 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -29,7 +29,8 @@ description: リスナー、リモート アクセス、アドミッション | `visionSidecar?` | `OcxVisionSidecarConfig` |使用可能な場合はオン |画像説明サイドカー オプション。 | | `images?` | `OcxImagesConfig` | OpenAI の自動選択 | Codex `image_gen` のスタンドアロン イメージ リレー オプション。 | -バックアップ サポートが存在する前に古い開発ビルドで再開履歴メタデータが変更された場合は、`ocx recover-history --legacy-openai` を実行してネイティブ プロバイダーの回復を強制します。 +バックアップ サポートが存在する前に古い開発ビルドで再開履歴メタデータが変更された場合は、`ocx recover-history --legacy-openai --yes` を実行してネイティブ プロバイダーの回復を強制します。 +このコマンドは、正当な専用プロバイダー履歴を含む、ユーザーメッセージを持つすべての `opencodex` 行を再ラベル付けします。実行前にライフサイクル リファレンスの全範囲に関する警告を確認してください。 ## リモートアクセス diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index b1a15ea4c2..5c008ff1a8 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -108,7 +108,7 @@ Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 ## 스레드 식별자와 대화 기록 -기본 loopback 형식은 새 thread에 네이티브 `openai` provider 태그를 유지하므로 일반적인 resume history는 다시 매핑할 필요가 없습니다. 첫 sync 때는 더 오래된 opencodex 빌드가 태그를 붙인 thread도 `openai`로 이관합니다. non-loopback 전용 provider 모드는 활성 상태일 때만 history를 `opencodex` provider 아래로 미러링하고, 종료할 때는 백업된 메타데이터를 복원합니다. history를 건드리지 않으려면 `syncResumeHistory: false`로 설정하세요. +기본 loopback 형식은 새 thread에 네이티브 `openai` provider 태그를 유지하므로 일반적인 resume history는 다시 매핑할 필요가 없습니다. sync와 restore는 일치하는 백업 manifest만 적용하여 각 thread의 원래 provider, source, event marker를 정확히 복원합니다. manifest가 없는 `opencodex` row는 변경하지 않으며, legacy 재태깅을 명시적으로 강제하려는 경우에만 `ocx recover-history --legacy-openai --yes`를 사용합니다. 이 명령은 의도적으로 범위가 넓습니다. 사용자 메시지가 있고 현재 `opencodex`로 표시된 모든 thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 사용하세요. non-loopback 전용 provider 모드는 활성 상태일 때만 history를 `opencodex` provider 아래로 미러링하고, 종료할 때는 백업된 메타데이터를 복원합니다. history를 건드리지 않으려면 `syncResumeHistory: false`로 설정하세요. ## 모델 카탈로그 동기화 @@ -172,7 +172,7 @@ ocx sync-cache ### 외부 provider manager -`config.toml`이 이미 `openai`나 `opencodex`가 아닌 provider를 선택하고 있으면, OpenCodex는 그 파일을 그대로 두고 profile write, catalog/cache refresh, 즉시 및 background Codex history migration을 건너뜁니다. custom provider를 관리하는 도구는 기존 session에 그 provider id를 붙이는 경우가 많고, 활성 id를 바꾸면 그 온전한 session이 Codex의 history view에서 사라질 수 있습니다. 이 보호는 legacy root profile이 선택한 외부 provider에도 동일하게 적용됩니다. +`config.toml`이 이미 `openai`나 `opencodex`가 아닌 provider를 선택하고 있으면, OpenCodex는 그 파일을 그대로 두고 profile write, catalog/cache refresh, 즉시 및 background Codex history metadata 복원을 건너뜁니다. custom provider를 관리하는 도구는 기존 session에 그 provider id를 붙이는 경우가 많고, 활성 id를 바꾸면 그 온전한 session이 Codex의 history view에서 사라질 수 있습니다. 이 보호는 legacy root profile이 선택한 외부 provider에도 동일하게 적용됩니다. Codex provider configuration의 소유자는 한 도구만 맡게 하세요. 기존 provider manager 뒤에서 OpenCodex를 쓰려면, 그 provider를 `http://127.0.0.1:10100/v1`로 향하게 하고 Responses passthrough를 쓰세요(`wire_api = "responses"` in Codex TOML). Chat Completions translation은 쓰지 않습니다. proxy API auth가 켜져 있으면, 위의 non-loopback provider 형식과 맞추어 `OPENCODEX_API_AUTH_TOKEN`에서 `x-opencodex-api-key`도 함께 전달하세요. OpenCodex가 routing을 직접 주입하게 하려면 먼저 Codex를 built-in `openai` provider로 되돌리고, 사용자가 소유한 root `openai_base_url`을 지운 다음, `ocx start`를 다시 실행하세요. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 1444cdb2be..94c6fd2c00 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -62,11 +62,15 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` 역방향 복구 지원이 생기기 전, 초기 개발 빌드에서 Codex App 기록을 재매핑하던 오래된 빌드를 위한 명시적 복구 명령입니다. 기록 데이터베이스가 잠겨 있으면 먼저 Codex를 종료해 주세요. +이 명령은 광범위하고 파괴적인 재태깅입니다. 사용자 메시지가 있고 현재 `opencodex`로 표시된 모든 +thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 +dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 실행하세요. + ### `ocx uninstall` · `ocx remove` 서비스와 프록시를 중지하고, 서비스와 Codex shim을 제거한 뒤, 기본 Codex를 복원합니다. 그 다음 diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index fe3526537f..28a358862a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -29,7 +29,8 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | 이미지 설명 사이드카 옵션입니다. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Codex `image_gen`용 독립형 Images 릴레이 옵션입니다. | -오래된 개발 빌드가 백업 지원이 생기기 전에 resume-history 메타데이터를 바꿨다면, native-provider 복구를 강제로 수행하려면 `ocx recover-history --legacy-openai`를 실행합니다. +오래된 개발 빌드가 백업 지원이 생기기 전에 resume-history 메타데이터를 바꿨다면, native-provider 복구를 강제로 수행하려면 `ocx recover-history --legacy-openai --yes`를 실행합니다. +이 명령은 정상적인 dedicated-provider history를 포함해 사용자 메시지가 있는 모든 `opencodex` row를 재태깅합니다. 실행하기 전에 lifecycle reference의 전체 범위 경고를 확인하세요. ## Remote access diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index bcc3a340ff..f406c3cea4 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -63,11 +63,16 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` Explicit recovery for older development builds that remapped Codex App history before reversible backup support existed. Close Codex first if its history database is locked. +This is a broad, destructive relabel: every user-message thread currently tagged `opencodex` is +changed to `openai`, `exec` is normalized to `cli`, and the event marker is set. That includes +legitimate dedicated-provider history. Back up the state and run it only when that full scope is +intended. + ### `ocx uninstall` · `ocx remove` Stop the service and proxy, remove the service and Codex shim, restore native Codex, then remove diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 5ddf5588bc..fe12dc58a3 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -31,7 +31,9 @@ runs helper features around provider requests. | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. | If an older development build changed resume-history metadata before backup support existed, run -`ocx recover-history --legacy-openai` to force native-provider recovery. +`ocx recover-history --legacy-openai --yes` to force native-provider recovery. +It force-relabels every user-message `opencodex` row, including legitimate dedicated-provider +history; review the full-scope warning in the lifecycle reference before running it. ## Remote access diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 118c663870..395413fd84 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -177,11 +177,17 @@ loopback встроенный провайдер Codex может сначала ## Идентичность тредов и история Форма loopback по умолчанию сохраняет новые треды помеченными нативным провайдером Codex -`openai`, поэтому обычной resume-history не нужен никакой remap. При первом sync она также -перемещает треды, помеченные более старыми сборками opencodex, обратно на `openai`. В режиме -выделенного не-loopback-провайдера история во время работы зеркалируется под провайдером -`opencodex` и при выходе восстанавливает сохранённые метаданные. Задайте -`syncResumeHistory: false`, если не хотите трогать историю. +`openai`, поэтому обычной resume-history не нужен никакой remap. Sync и restore применяют только +соответствующий backup manifest и точно восстанавливают исходные provider, source и event marker. +Строка `opencodex` без manifest остаётся неизменной; используйте +`ocx recover-history --legacy-openai --yes` только для явного принудительного legacy-переименования. +Команда намеренно имеет широкий охват: она меняет на `openai` все треды с пользовательским +сообщением, которые сейчас помечены `opencodex`, нормализует `exec` в `cli` и устанавливает event +marker — включая корректную историю выделенного провайдера. Сначала сделайте резервную копию и +запускайте команду только если нужен весь этот охват. В +режиме выделенного не-loopback-провайдера история во время работы зеркалируется под провайдером +`opencodex` и при выходе восстанавливает сохранённые метаданные. Задайте `syncResumeHistory: false`, +если не хотите трогать историю. ## Синхронизация каталога моделей @@ -271,7 +277,7 @@ upstream snapshot и никогда не перекрываются пользо Если `config.toml` уже выбирает провайдера, отличного от `openai` или `opencodex`, OpenCodex оставляет файл без изменений и пропускает запись profile, обновление catalog/cache и как -немедленную, так и фоновую миграцию истории Codex. Инструменты, управляющие custom-провайдером, +немедленное, так и фоновое восстановление метаданных истории Codex. Инструменты, управляющие custom-провайдером, часто помечают существующие сессии своим provider id; замена активного id может привести к тому, что рабочие сессии просто исчезнут из history view Codex. Та же защита действует и для внешнего провайдера, выбранного через legacy root profile. diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index ed4a42a785..d165a1574f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -68,12 +68,17 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` Явное восстановление для старых development-сборок, которые переназначали историю Codex App ещё до появления обратимого backup-механизма. Если база истории Codex заблокирована, сначала закройте Codex. +Это широкое и разрушительное переименование: все треды с пользовательским сообщением, которые +сейчас помечены `opencodex`, меняются на `openai`, `exec` нормализуется в `cli`, а event marker +устанавливается. Корректная история выделенного провайдера тоже входит в охват. Сначала сделайте +резервную копию и запускайте команду только если нужен весь этот охват. + ### `ocx uninstall` · `ocx remove` Остановить службу и прокси, удалить службу и Codex shim, восстановить native Codex, а затем diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 64d7ee8e3d..d8d7b5d6f9 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -31,8 +31,9 @@ description: Listener, удалённый доступ, admission key, тайм | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Настройки standalone Images relay для Codex `image_gen`. | Если более старая development-сборка изменила metadata resume-history до появления резервного -backup'а, выполните `ocx recover-history --legacy-openai`, чтобы принудительно вернуть +backup'а, выполните `ocx recover-history --legacy-openai --yes`, чтобы принудительно вернуть native-provider history. +Команда переименовывает все строки `opencodex` с пользовательским сообщением, включая корректную историю выделенного провайдера; перед запуском прочитайте предупреждение о полном охвате в справочнике lifecycle. ## Удалённый доступ diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index ba513713f6..ebd67f9af2 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -208,11 +208,18 @@ sağlayıcısı önce WebSocket'i deneyebilir ve devre dışı bırakılmış bi Varsayılan geri döngü formu yeni iş parçacıklarının Codex'in yerel `openai` sağlayıcısıyla etiketlenmesini sağlar, böylece normal devam etme geçmişinin -yeniden eşlenmesi gerekmez. İlk senkronizasyonda daha eski opencodex derlemeleri -tarafından etiketlenen iş parçacıklarını da `openai`'ye geri geçirir. Geri döngü -olmayan özel sağlayıcı modu etkinken geçmişi yine de `opencodex` sağlayıcısı -altında yansıtır ve çıkışta yedeklenen meta verileri geri yükler. Geçmişe -dokunulmadan bırakmak için `syncResumeHistory: false` ayarlayın. +yeniden eşlenmesi gerekmez. Sync ve restore yalnızca eşleşen bir yedek manifestini +uygular ve her iş parçacığının özgün provider, source ve event marker değerlerini +tam olarak geri yükler. Manifesti olmayan bir `opencodex` satırı değişmeden kalır; +legacy yeniden etiketlemeyi açıkça zorlamak istediğinizde yalnızca +`ocx recover-history --legacy-openai --yes` kullanın. Bu komut bilinçli olarak geniş kapsamlıdır: +kullanıcı iletisi bulunan ve şu anda `opencodex` olarak etiketlenmiş her thread'i `openai` +olarak yeniden etiketler, `exec` değerini `cli` olarak normalleştirir ve event marker'ı ayarlar; +geçerli dedicated-provider geçmişi de buna dahildir. Durumu yedekleyin ve yalnızca bu kapsamın +tamamını istiyorsanız kullanın. Geri döngü olmayan özel sağlayıcı +modu etkinken geçmişi yine de `opencodex` sağlayıcısı altında yansıtır ve çıkışta +yedeklenen meta verileri geri yükler. Geçmişe dokunulmadan bırakmak için +`syncResumeHistory: false` ayarlayın. ## Model kataloğu senkronizasyonu @@ -316,7 +323,7 @@ tarafından asla geçersiz kılınmaz. `config.toml` zaten `openai` veya `opencodex` dışında bir sağlayıcı seçiyorsa OpenCodex dosyayı değiştirmeden bırakır ve profil yazmalarını, katalog/önbellek -yenilemesini ve hem anlık hem de arka plan Codex geçmiş geçişini atlar. Özel bir +yenilemesini ve Codex geçmiş meta verilerinin hem anlık hem de arka planda geri yüklenmesini atlar. Özel bir sağlayıcıyı yöneten araçlar genellikle mevcut oturumları bu sağlayıcı kimliğiyle etiketler; etkin kimliği değiştirmek bu bozulmamış oturumların Codex'in geçmiş görünümünden kaybolmasına neden olabilir. Aynı koruma eski bir kök profil @@ -430,5 +437,3 @@ opencodex yönetilen bir [arka plan servisi](/tr/reference/cli/#ocx-service) olarak çalıştığında `OCX_SERVICE=1` ayarlar, böylece servis odaklı bir yeniden başlatma Codex yapılandırmasını **bozmaz** — yalnızca açık bir `ocx stop` / `ocx service stop` yerel Codex'i geri yükler. - - diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 624c4174fb..125077b568 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -74,12 +74,17 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` Tersine çevrilebilir yedekleme desteği var olmadan önce Codex App geçmişini yeniden eşleyen eski geliştirme derlemeleri için açık kurtarma. Geçmiş veritabanı kilitliyse önce Codex'i kapatın. +Bu, geniş kapsamlı ve yıkıcı bir yeniden etiketlemedir: kullanıcı iletisi bulunan ve şu anda +`opencodex` olarak etiketlenmiş her thread `openai` olarak değiştirilir, `exec` değeri `cli` +olarak normalleştirilir ve event marker ayarlanır. Geçerli dedicated-provider geçmişi de kapsama +dahildir. Durumu yedekleyin ve yalnızca bu kapsamın tamamını istiyorsanız çalıştırın. + ### `ocx uninstall` · `ocx remove` Servisi ve proxy'yi durdurun, servisi ve Codex dolgusunu kaldırın, yerel Codex'i diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 25da923a29..9462983969 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -33,7 +33,8 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. Daha eski bir geliştirme derlemesi yedekleme desteği var olmadan önce devam geçmişi meta verilerini değiştirdiyse yerel sağlayıcı kurtarmasını zorlamak için -`ocx recover-history --legacy-openai` çalıştırın. +`ocx recover-history --legacy-openai --yes` çalıştırın. +Komut, geçerli dedicated-provider geçmişi de dahil olmak üzere kullanıcı iletisi bulunan tüm `opencodex` satırlarını yeniden etiketler; çalıştırmadan önce lifecycle başvurusundaki tam kapsam uyarısını okuyun. ## Uzaktan erişim diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index ef693616f6..69e829046f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -159,9 +159,13 @@ opencodex 也会通过 WebSocket 提供 `/v1/responses`。专用 provider 只有 ## 线程标识与历史记录 默认的 loopback 形式会让新线程继续标记为 Codex 原生的 `openai` provider,因此正常的 resume history 不需要 -重映射。首次 sync 时,它还会把旧版 opencodex 标记过的线程迁回 `openai`。非 loopback 的专用 provider 模式 -在运行期间仍会把历史记录镜像到 `opencodex` provider 名下,并在退出时恢复已备份的 metadata。 -如需保持历史记录完全不变,请设置 `syncResumeHistory: false`。 +重映射。sync 和 restore 只应用与当前状态数据库匹配的备份 manifest,并精确恢复每个线程原来的 provider、 +source 和 event marker。没有 manifest 的 `opencodex` 行会保持不变;只有明确要强制执行旧式重标记时,才使用 +`ocx recover-history --legacy-openai --yes`。此命令的作用范围有意设置得很广:它会把所有包含用户消息且当前标记为 +`opencodex` 的线程改标为 `openai`,将 `exec` 规范化为 `cli`,并设置事件标记;正常的专用提供方历史记录也在 +范围内。请先备份状态,并且仅在确实需要这一完整范围时使用。非 loopback 的专用 provider 模式在运行期间仍会把历史记录镜像到 +`opencodex` provider 名下,并在退出时恢复已备份的 metadata。如需保持历史记录完全不变,请设置 +`syncResumeHistory: false`。 ## 模型目录同步 @@ -235,7 +239,7 @@ display name 是 **仅用于显示且在重新生成时保持稳定的**。每 ### 外部 provider 管理器 如果 `config.toml` 已经选择了 `openai` 或 `opencodex` 之外的 provider,OpenCodex 会保持文件不变, -并跳过 profile 写入、catalog/cache 刷新,以及立即和后台两种 Codex 历史迁移。管理自定义 provider 的工具 +并跳过 profile 写入、catalog/cache 刷新,以及立即和后台两种 Codex 历史元数据恢复。管理自定义 provider 的工具 通常会把现有会话标记为那个 provider id;如果替换活动 id,Codex 历史视图里那些完整会话可能会消失。 同样的保护也适用于由旧版 root profile 选择的外部 provider。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 964172ec9a..ff3446c381 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -47,10 +47,12 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` 为更早期的开发构建提供显式恢复,这些构建在可逆备份支持存在之前就重映射了 Codex App 历史记录。如果其历史数据库已被锁定,请先关闭 Codex。 +这是范围很广且具有破坏性的重标记:所有包含用户消息且当前标记为 `opencodex` 的线程都会改标为 `openai`,`exec` 会规范化为 `cli`,并设置事件标记。正常的专用提供方历史记录也在范围内。请先备份状态,并且仅在确实需要这一完整范围时执行。 + ### `ocx uninstall` · `ocx remove` 停止服务和代理,移除服务和 Codex shim,恢复原生 Codex,然后仅在所有恢复步骤都成功时才删除 opencodex 本地配置。`remove` 是 `uninstall` 的别名。配置清理需要由全新安装创建的所有权元数据;旧版或共享目录会保留原样。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 36f467f437..d4a6a3fc64 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -31,7 +31,8 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `images?` | `OcxImagesConfig` | 自动选择 OpenAI | 用于 Codex `image_gen` 的独立 Images 转发选项。 | 如果较旧的开发版本在尚未提供备份支持之前修改过了 resume-history 元数据,请运行 -`ocx recover-history --legacy-openai` 强制使用原生提供方恢复。 +`ocx recover-history --legacy-openai --yes` 强制使用原生提供方恢复。 +此命令会重标所有包含用户消息的 `opencodex` 行,其中包括正常的专用提供方历史记录;执行前请查看生命周期参考中的完整范围警告。 ## 远程访问 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index e12850aec1..24767de948 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -165,9 +165,13 @@ Codex 保持一致。opencodex 也透過 WebSocket 提供 `/v1/responses`。專 ## Thread identity 與歷史記錄 預設 loopback 形式會讓新 thread 保持使用 Codex 原生的 `openai` provider 標記,因此一般 resume -history 不需要重新對映。第一次同步時,也會把舊版 opencodex 改過標記的 thread 遷回 `openai`。 -non-loopback 專用 provider 模式在啟用期間仍會把歷史映射到 `opencodex` provider,退出時再恢復已備份的 -metadata。設定 `syncResumeHistory: false` 可完全不修改歷史。 +history 不需要重新對映。sync 與 restore 只套用和目前狀態資料庫相符的備份 manifest,並精確恢復每個 +thread 原本的 provider、source 與 event marker。沒有 manifest 的 `opencodex` row 會保持不變;只有在明確 +要強制執行舊式重新標記時才使用 `ocx recover-history --legacy-openai --yes`。此命令的作用範圍刻意很廣:它會把所有 +含有使用者訊息且目前標記為 `opencodex` 的 thread 改標為 `openai`,將 `exec` 正規化為 `cli`,並設定 event +marker;正常的專用 provider 歷史也包含在內。請先備份狀態,而且只有在確實需要這個完整範圍時才使用。non-loopback 專用 provider 模式在 +啟用期間仍會把歷史映射到 `opencodex` provider,退出時再恢復已備份的 metadata。設定 +`syncResumeHistory: false` 可完全不修改歷史。 ## 模型目錄同步 @@ -242,7 +246,7 @@ slug。受管服務重啟後,也會在 proxy bind 後盡力同步一次。若 ### 外部 provider 管理器 若 `config.toml` 已選用非 `openai` 或 `opencodex` 的 provider,OpenCodex 會保持檔案不變,並跳過 -profile 寫入、目錄/cache refresh,以及立即與背景的 Codex 歷史遷移。管理自訂 provider 的工具常會把 +profile 寫入、目錄/cache refresh,以及立即與背景的 Codex 歷史中繼資料還原。管理自訂 provider 的工具常會把 既有 session 標上該 provider id;直接替換 active id 可能讓這些完好的 session 從 Codex 歷史檢視消失。 由舊版根級 profile 選到的外部 provider 也有同樣保護。 @@ -325,4 +329,4 @@ ocx restore back # 讓普通 Codex 再次指向仍在執行的 proxy 當 opencodex 作為受管的 [背景服務](/zh-tw/reference/cli/#ocx-service) 執行時,會設定 `OCX_SERVICE=1`, 因此 service 驅動的 restart **不會**反覆改寫 Codex 設定;只有明確執行 `ocx stop` 或 -`ocx service stop` 才會恢復原生 Codex。 \ No newline at end of file +`ocx service stop` 才會恢復原生 Codex。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index c5c13cb623..d1a49b3c3b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -46,10 +46,12 @@ ocx restore back ocx eject back ``` -### `ocx recover-history --legacy-openai` +### `ocx recover-history --legacy-openai --yes` 針對在可逆備份支援存在前、重新對應 Codex App 歷史的舊開發組建進行明確復原。若其歷史資料庫被鎖定,請先關閉 Codex。 +這是範圍很廣且具破壞性的重新標記:所有含有使用者訊息且目前標記為 `opencodex` 的 thread 都會改標為 `openai`,`exec` 會正規化為 `cli`,並設定 event marker。正常的專用 provider 歷史也包含在內。請先備份狀態,而且只有在確實需要這個完整範圍時才執行。 + ### `ocx uninstall` · `ocx remove` 停止服務與代理、移除服務與 Codex shim、還原原生 Codex,然後僅在所有還原步驟成功時移除 opencodex 本機設定。`remove` 是 `uninstall` 的別名。設定清理需要由全新安裝建立的擁有權中繼資料;舊版或共享目錄會被原樣保留。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 6df1d0f7be..f8e7689c2c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -29,7 +29,8 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | `visionSidecar?` | `OcxVisionSidecarConfig` | 可用時開啟 | 圖片描述 sidecar 選項。 | | `images?` | `OcxImagesConfig` | 自動 OpenAI 選擇 | Codex `image_gen` 的獨立 Images 中繼選項。 | -若較舊的開發組建在備份支援存在前變更了 resume-history 中繼資料,請執行 `ocx recover-history --legacy-openai` 以強制原生供應商復原。 +若較舊的開發組建在備份支援存在前變更了 resume-history 中繼資料,請執行 `ocx recover-history --legacy-openai --yes` 以強制原生供應商復原。 +此命令會重新標記所有含有使用者訊息的 `opencodex` row,其中也包含正常的專用 provider 歷史;執行前請查看 lifecycle reference 中的完整範圍警告。 ## 遠端存取 diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index bda01e00cb..ec81f3f3a1 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1072,10 +1072,10 @@ export async function runDoctor(args: string[] = []): Promise { console.log(` ${probe.ok ? "ok " : "-- "} ${WHAM_USAGE_URL}`); console.log(` ${detail}, ${probe.durationMs}ms, ${probe.authenticated ? "authenticated" : "unauthenticated"}`); - // Design B upgrade visibility: threads still tagged opencodex are invisible to the native - // Codex app until the one-time migration lands. Read-only probe (readonly sqlite, 100ms - // busy timeout) — reports state, never mutates. - console.log("\nCodex history migration"); + // Design B upgrade visibility: only the backup manifest authorizes restoring provider + // metadata. Bare routed rows have unknown provenance and remain unchanged. This read-only + // probe reports manifest work and database readability; it never mutates. + console.log("\nCodex history metadata restore"); // The history failure messages point here; make the visit worthwhile by // probing the coordinator namespace the locks live in. The probe exercises // identity, runtime-root, and permission checks without taking any lock or @@ -1096,11 +1096,17 @@ export async function runDoctor(args: string[] = []): Promise { for (const line of formatCoordinatorDoctorLines(inspectCodexCoordinator())) console.log(line); const pending = countPendingOpencodexHistory(); if (pending.failed) { - console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown"); + if (pending.failureReason === "busy") { + console.log(" -- history database, backup manifest, or rollout file is busy — exact metadata restore is pending"); + } else if (pending.failureReason === "permission") { + console.log(" -- state DB or backup manifest access was denied — restore state unknown"); + } else { + console.log(" -- backup manifest or restore target failed integrity checks — manual review required"); + } } else if (pending.pendingRows === 0 && pending.backupEntries === 0) { - console.log(" ok no legacy opencodex-tagged threads pending"); + console.log(" ok no manifest-backed provider metadata pending; untracked routed history is unchanged"); } else { - console.log(` -- ${pending.pendingRows} thread(s) still tagged opencodex, ${pending.backupEntries} backup manifest entr${pending.backupEntries === 1 ? "y" : "ies"}`); + console.log(` -- ${pending.backupEntries} backup manifest entr${pending.backupEntries === 1 ? "y" : "ies"} pending exact metadata restore`); } console.log("\nProject Codex configs"); @@ -1185,8 +1191,14 @@ export async function runDoctor(args: string[] = []): Promise { } } } - if (pending.failed || pending.pendingRows > 0 || pending.backupEntries > 0) { - hints.push("Legacy chat threads are still tagged opencodex (or the DB was locked). The running proxy retries the migration automatically; to force it now, close the Codex app and run 'ocx sync'."); + if (pending.failed && pending.failureReason === "busy") { + hints.push("Backed-up history metadata is pending or its state is unreadable. The running proxy retries exact restoration automatically; to force it now, close the Codex app and run 'ocx sync'. Untracked routed history is not relabeled."); + } else if (pending.failed && pending.failureReason === "permission") { + hints.push("Backed-up history metadata could not be inspected because access was denied. Fix access to the reported Codex history paths, then run 'ocx sync'; repeated retries do not repair permissions."); + } else if (pending.failed) { + hints.push("The history manifest or its target is invalid or changed. Preserve both, inspect the manifest/database/rollout identity, and do not repeatedly run 'ocx sync' until the mismatch is understood. Untracked routed history is not relabeled."); + } else if (pending.backupEntries > 0) { + hints.push("Backed-up history metadata is pending. The running proxy retries exact restoration automatically; to force it now, close the Codex app and run 'ocx sync'. Untracked routed history is not relabeled."); } if (dual.dualInstall && !dual.effectiveIsWindowsMount) { hints.push(`Codex is installed on BOTH WSL and Windows. Each side keeps its own ~/.codex (logins, config, catalog are separate); ocx here manages the Linux one. To share a single home, set CODEX_HOME=${dual.windowsCodexHomes[0] ?? `${dual.automountRoot}/c/Users//.codex`} in WSL (drvfs file locking is less reliable).`); diff --git a/src/cli/help.ts b/src/cli/help.ts index 5387c8ab60..c0408e659a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -24,8 +24,8 @@ Usage: ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx restore back Re-point codex at the running proxy (undo restore) - ocx recover-history --legacy-openai - Explicitly recover pre-backup syncResumeHistory rows + ocx recover-history --legacy-openai --yes + Force all user-message opencodex rows to OpenAI (legacy recovery) ocx uninstall Remove service/shim/config and restore native Codex (alias: remove) ocx service [sub] Run as a background service (default: install/update/start) ocx codex-shim Auto-start proxy when \`codex\` launches (install|status|uninstall|remove) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1b2c6b5668..16c34565f1 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -905,8 +905,13 @@ async function handleStatus() { async function handleRecoverHistory() { if (args[1] !== "--legacy-openai") { - console.error("Usage: ocx recover-history --legacy-openai"); - console.error("Only use this if an older syncResumeHistory build already remapped OpenAI Codex App history to opencodex before backup support existed."); + console.error("Usage: ocx recover-history --legacy-openai --yes"); + console.error("This force-relabels every user-message opencodex row to OpenAI, including legitimate dedicated-provider history. Back up first and use it only for pre-backup legacy recovery."); + process.exit(1); + } + console.error("WARNING: this force-relabels every user-message opencodex row to OpenAI, normalizes exec to cli, and includes legitimate dedicated-provider history."); + if (args.length !== 3 || args[2] !== "--yes") { + console.error("Re-run with explicit confirmation: ocx recover-history --legacy-openai --yes"); process.exit(1); } // Manifest-independent legacy ejection, serialized like every other history diff --git a/src/cli/registry.ts b/src/cli/registry.ts index ffeee18df3..85bb3959e5 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -36,8 +36,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "recover-history", - usage: "ocx recover-history --legacy-openai", - summary: "Explicitly recover pre-backup syncResumeHistory rows.", + usage: "ocx recover-history --legacy-openai --yes", + summary: "Force all user-message opencodex rows to OpenAI for legacy recovery.", }, { name: "uninstall", diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index 1ffd51f1a8..b52e57dccc 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -109,7 +109,8 @@ export type CodexHistoryJobOutcome = | { readonly kind: "skipped" } | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout"; - readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason }; + readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason; + readonly rows?: number; readonly files?: number }; /** * Derive the durable history operation from admitted intent. @@ -174,7 +175,14 @@ function isPlausibleWorkerResult( || message.reason === "desired_disabled" || message.reason === "desired_enabled"; case "error": return typeof message.message === "string" - && (message.reason === undefined || message.reason === "busy" || message.reason === "permission"); + && (message.rows === undefined || (Number.isSafeInteger(message.rows) && Number(message.rows) >= 0)) + && (message.files === undefined || (Number.isSafeInteger(message.files) && Number(message.files) >= 0)) + && ((message.rows === undefined && message.files === undefined) + || (message.rows !== undefined && message.files !== undefined)) + && (message.reason === undefined + || message.reason === "busy" + || message.reason === "permission" + || message.reason === "integrity"); default: return false; } @@ -224,6 +232,7 @@ export function describeHistoryJobFailure( : surface === "recover-legacy" ? "the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command." : "the Codex app appears to be holding the history database. Close Codex and run `ocx restore` again."; + const busyStateText = "Codex history state is busy (database, backup manifest, or rollout file); this is not enough evidence to blame the Codex app. It is retried automatically while the proxy runs; run 'ocx doctor' before forcing another attempt."; if (outcome.kind === "blocked") { if (outcome.reason === "busy") return busyText; switch (outcome.reason) { @@ -237,10 +246,22 @@ export function describeHistoryJobFailure( return "Codex integration is enabled, so the history operation was skipped."; } } - if (outcome.historyFailureReason === "busy") return busyText; + const partiallyChanged = (outcome.rows ?? 0) > 0 || (outcome.files ?? 0) > 0; + if (partiallyChanged && outcome.historyFailureReason === "busy") { + return "Codex history metadata changed but did not converge because manifest finalization remained busy; the manifest was retained for review and safe retry. Run 'ocx doctor'."; + } + if (partiallyChanged && outcome.historyFailureReason === "permission") { + return "Codex history metadata changed but did not converge because permission was denied while finalizing the manifest; the manifest was retained for review and safe retry. Run 'ocx doctor'."; + } + if (outcome.historyFailureReason === "busy") return busyStateText; if (outcome.historyFailureReason === "permission") { return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'."; } + if (outcome.historyFailureReason === "integrity") { + return partiallyChanged + ? "the history backup or its restore target changed after a partial restore; the manifest was retained for review and safe retry. Run 'ocx doctor'." + : "the history backup or its restore target failed integrity checks; no unverified provider metadata was applied. Run 'ocx doctor'."; + } switch (outcome.reason) { case "worker-error": return `the history worker failed (${outcome.message}). Run 'ocx doctor'.`; @@ -277,6 +298,9 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco reason: "worker-error", message: redactWorkerMessage(result.message), ...(result.reason ? { historyFailureReason: result.reason } : {}), + ...(result.rows !== undefined && result.files !== undefined + ? { rows: result.rows, files: result.files } + : {}), }; } return result.outcome === "skipped" @@ -284,6 +308,11 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco : { kind: "converged", rows: result.rows, files: result.files, ...(result.proof ? { proof: result.proof } : {}) }; } +/** Test seam for the parent-side Worker result classification contract. */ +export function classifyWorkerResultForTests(result: HistoryWorkerResult): CodexHistoryJobOutcome { + return classifyWorkerResult(result); +} + /** * Run one history unit in a Worker and join it before returning. * diff --git a/src/codex/history-migration-guardian.ts b/src/codex/history-migration-guardian.ts index 68d52f8374..64dfd3747f 100644 --- a/src/codex/history-migration-guardian.ts +++ b/src/codex/history-migration-guardian.ts @@ -2,11 +2,11 @@ import { migrateHistoryToOpenai } from "./history-provider"; import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "./history-job"; /** - * Daemon-side retry for the one-time Design-B history migration. + * Daemon-side retry for the one-time Design-B history metadata restoration. * * Most upgrades run `ocx start` while the Codex app still holds `state_5.sqlite`, - * so the inject-time migration often fails on the FIRST start — exactly the moment - * every legacy thread is still tagged `opencodex` and invisible to the app. Instead + * so the inject-time restore can fail on the FIRST start while manifest-backed original + * metadata is still pending. Instead * of asking the user to close the app and rerun start, this guardian keeps retrying * in the background until the migration lands. * @@ -77,7 +77,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps if (!result.failed) { const moved = result.rows + ((result as { ejectedRows?: number }).ejectedRows ?? 0); if (moved > 0) { - log.log(`🩹 history-migration: ${moved} legacy opencodex thread(s) migrated back to openai.`); + log.log(`🩹 history-migration: restored original provider metadata for ${moved} manifest-backed thread(s).`); } // Zero mutations are authoritative only when the worker verified the // exact DB/manifest state while H was held. @@ -92,7 +92,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps } if (ticks >= maxTicks) { stopped = true; - log.log("⚠️ history-migration: Could not verify that legacy threads were migrated; the history database may be busy, unavailable, or not yet ready. Run 'ocx sync' (or check 'ocx doctor')."); + log.log("⚠️ history-migration: Could not verify that backed-up provider metadata was restored; the history database may be busy, unavailable, or not yet ready. Run 'ocx sync' (or check 'ocx doctor')."); return; } schedule(); diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index f2150c8414..2d2855089a 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { zstdDecompressSync } from "node:zlib"; import { Database } from "bun:sqlite"; import { resolveCodexStateDbPath } from "./paths"; @@ -69,10 +69,10 @@ function openStateDb(stateDbPath: string): Database { * concurrently. We do not touch mtime: a fresh mtime is correct here (the app uses mtime as the * rollout's updated_at), and forcing it backwards could hide a real edit from list ordering. */ -function appendRolloutLine(path: string, line: string): void { +function appendRolloutLine(path: string, line: string): Buffer { const fd = openSync(path, "a"); + const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8"); try { - const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8"); let offset = 0; while (offset < buf.length) { offset += writeSync(fd, buf, offset, buf.length - offset, null); @@ -81,6 +81,7 @@ function appendRolloutLine(path: string, line: string): void { } finally { closeSync(fd); } + return buf; } /** @@ -98,65 +99,104 @@ function appendRolloutLine(path: string, line: string): void { * insignificant JSON whitespace. We therefore replace the provider value and pad the removed bytes * with spaces so the line's byte length is unchanged. Equal length means we can write at offset 0 * with no truncate and no inode swap, so this composes safely with the app's cached append handle. - * Only length-preserving shrinks are handled (e.g. "opencodex" -> "openai"); callers that would - * grow the value fall back to append-only, which is correct for the opencodex direction. + * A previous shrink leaves JSON whitespace in the token slot. That padding is part of the slot, + * so an exact restore can later grow "openai" back to "opencodex" without moving any bytes. * - * Returns true when line 1 was patched, false when it could not be done safely (missing file, - * non-`session_meta` first line, id mismatch, value already correct, or a length-growing change). + * Distinguishes an already-correct line from an unsafe one so exact restore never consumes its + * manifest after only the trailing metadata was repaired. */ -function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): boolean { - if (!existsSync(path)) return false; - const fd = openSync(path, "r+"); +type FirstLineProviderResult = "current" | "patched" | "unsafe"; + +type FirstLineProviderPlan = + | { readonly state: "current" } + | { readonly state: "patchable"; readonly patchedLine: string } + | { readonly state: "unsafe" }; + +function readFirstRolloutLine(fd: number): string | null { + // session_meta lines embed base_instructions and can be tens of KB; grow until the line + // actually ends rather than imposing a small fixed probe that would reject valid history. + const CHUNK = 1 << 16; + const MAX_FIRST_LINE = 1 << 24; + let collected = Buffer.alloc(0); + let nlIndex = -1; + let pos = 0; + while (nlIndex === -1) { + const chunk = Buffer.alloc(CHUNK); + const read = readSync(fd, chunk, 0, CHUNK, pos); + if (read === 0) break; + collected = Buffer.concat([collected, chunk.subarray(0, read)]); + nlIndex = collected.indexOf(0x0a); + pos += read; + if (collected.length > MAX_FIRST_LINE) return null; + } + return nlIndex === -1 ? null : collected.subarray(0, nlIndex).toString("utf8"); +} + +function planFirstLineProvider(firstLine: string, expectedId: string, provider: string): FirstLineProviderPlan { + const meta = parseSessionMetaLine(firstLine); + if (!meta || meta.record.payload.id !== expectedId) return { state: "unsafe" }; + if (meta.record.payload.model_provider === provider) return { state: "current" }; + + // Include JSON whitespace after the value. A prior length-preserving shrink stores its spare + // bytes there, so the exact reverse restore may grow back into that padding. + const match = firstLine.match(/"model_provider"\s*:\s*"([^"\\]*)"[ \t]*/); + if (!match || match.index === undefined) return { state: "unsafe" }; + const oldToken = match[0]; + const newCore = `"model_provider":"${provider}"`; + if (Buffer.byteLength(newCore, "utf8") > Buffer.byteLength(oldToken, "utf8")) return { state: "unsafe" }; + const pad = " ".repeat(Buffer.byteLength(oldToken, "utf8") - Buffer.byteLength(newCore, "utf8")); + const patchedLine = firstLine.slice(0, match.index) + newCore + pad + firstLine.slice(match.index + oldToken.length); + if (Buffer.byteLength(patchedLine, "utf8") !== Buffer.byteLength(firstLine, "utf8")) return { state: "unsafe" }; + const reparsed = parseSessionMetaLine(patchedLine); + if (!reparsed + || reparsed.record.payload.id !== expectedId + || reparsed.record.payload.model_provider !== provider) return { state: "unsafe" }; + return { state: "patchable", patchedLine }; +} + +function inspectFirstLineProvider(path: string, expectedId: string, provider: string): "current" | "patchable" | "unsafe" { + if (!existsSync(path)) return "unsafe"; + const fd = openSync(path, "r"); try { - // Read the first line by growing the probe until we hit a newline. session_meta lines embed - // base_instructions and can be tens of KB; a fixed cap would silently skip the in-place patch - // (and fall back to append-only, re-opening the first-line-clone resurrection gap), so we read - // until the line actually ends rather than guessing a ceiling. - const CHUNK = 1 << 16; - const MAX_FIRST_LINE = 1 << 24; // 16 MiB hard stop so a newline-less/corrupt file can't OOM us. - let collected = Buffer.alloc(0); - let nlIndex = -1; - let pos = 0; - while (nlIndex === -1) { - const chunk = Buffer.alloc(CHUNK); - const read = readSync(fd, chunk, 0, CHUNK, pos); - if (read === 0) break; // EOF with no newline: single-line file, skip - collected = Buffer.concat([collected, chunk.subarray(0, read)]); - nlIndex = collected.indexOf(0x0a); - pos += read; - if (collected.length > MAX_FIRST_LINE) return false; - } - if (nlIndex === -1) return false; // no newline anywhere: skip - const firstLine = collected.subarray(0, nlIndex).toString("utf8"); + const firstLine = readFirstRolloutLine(fd); + return firstLine === null ? "unsafe" : planFirstLineProvider(firstLine, expectedId, provider).state; + } finally { + closeSync(fd); + } +} +function readFirstLineProviderValue(path: string, expectedId: string): string | null { + if (!existsSync(path)) return null; + const fd = openSync(path, "r"); + try { + const firstLine = readFirstRolloutLine(fd); + if (firstLine === null) return null; const meta = parseSessionMetaLine(firstLine); - if (!meta) return false; - if (meta.record.payload.id !== expectedId) return false; - if (meta.record.payload.model_provider === provider) return false; - - // Locate the exact `"model_provider":""` token (allowing whitespace after the colon). - const match = firstLine.match(/"model_provider"\s*:\s*"([^"\\]*)"/); - if (!match || match.index === undefined) return false; - const oldToken = match[0]; - const newCore = `"model_provider":"${provider}"`; - if (Buffer.byteLength(newCore, "utf8") > Buffer.byteLength(oldToken, "utf8")) return false; // grow: not length-preserving - const pad = " ".repeat(Buffer.byteLength(oldToken, "utf8") - Buffer.byteLength(newCore, "utf8")); - const newToken = `${newCore}${pad}`; - - const patchedLine = firstLine.slice(0, match.index) + newToken + firstLine.slice(match.index + oldToken.length); - // Length must be identical so the trailing bytes (newline + rest of file) are untouched. - if (Buffer.byteLength(patchedLine, "utf8") !== Buffer.byteLength(firstLine, "utf8")) return false; - // Sanity: the patched line must still parse and carry the new provider. - const reparsed = parseSessionMetaLine(patchedLine); - if (!reparsed || reparsed.record.payload.model_provider !== provider) return false; - - const out = Buffer.from(patchedLine, "utf8"); + if (!meta || meta.record.payload.id !== expectedId) return null; + return typeof meta.record.payload.model_provider === "string" + ? meta.record.payload.model_provider + : null; + } finally { + closeSync(fd); + } +} + +function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): FirstLineProviderResult { + if (!existsSync(path)) return "unsafe"; + const fd = openSync(path, "r+"); + try { + const firstLine = readFirstRolloutLine(fd); + if (firstLine === null) return "unsafe"; + const plan = planFirstLineProvider(firstLine, expectedId, provider); + if (plan.state === "unsafe") return "unsafe"; + if (plan.state === "current") return "current"; + const out = Buffer.from(plan.patchedLine, "utf8"); let offset = 0; while (offset < out.length) { offset += writeSync(fd, out, offset, out.length - offset, offset); } try { fsyncSync(fd); } catch { /* best-effort durability */ } - return true; + return "patched"; } finally { closeSync(fd); } @@ -164,9 +204,29 @@ function patchFirstLineProviderInPlace(path: string, expectedId: string, provide export type CodexHistoryProvider = "openai" | "opencodex"; -export type CodexHistoryFailureReason = "busy" | "permission"; +export type CodexHistoryFailureReason = "busy" | "permission" | "integrity"; + +class CodexHistoryIntegrityError extends Error { + constructor( + code: string, + readonly progress: { readonly rows: number; readonly files: number } = { rows: 0, files: 0 }, + ) { + super(code); + this.name = "CodexHistoryIntegrityError"; + } +} + +function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistorySyncResult { + return { + rows: error.progress.rows, + files: error.progress.files, + failed: true, + failureReason: "integrity", + }; +} export interface CodexHistorySyncResult { + /** Rows/files changed before a last-moment integrity race; may be nonzero with `failed`. */ rows: number; files: number; ejectedRows?: number; @@ -184,6 +244,18 @@ interface ThreadRow { has_user_event: number; } +interface RestoreRowSnapshot extends ThreadRow { + first_user_message: string | null; +} + +interface ApplyRowSnapshot extends ThreadRow { + first_user_message: string | null; +} + +function hasFirstUserMessage(value: string | null): boolean { + return typeof value === "string" && value.trim().length > 0; +} + interface BackupEntry { id: string; rolloutPath: string; @@ -232,19 +304,52 @@ export type CodexHistoryNoopSnapshot = type StrictBackupInspection = | { readonly kind: "known"; readonly present: boolean; readonly entries: number; readonly fingerprint: string } - | { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign" }; + | { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign"; + readonly failureReason?: "busy" | "permission" }; + +type StrictBackupRead = + | { + readonly kind: "known"; + readonly present: boolean; + readonly manifest: BackupManifest; + readonly fingerprint: string; + } + | { + readonly kind: "unknown"; + readonly present: true; + readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign"; + readonly failureReason?: "busy" | "permission"; + }; let afterNoopPendingCountForTests: (() => void) | undefined; +let beforeHistoryBackupConsumeForTests: (() => void) | undefined; +let beforeStrictHistoryRolloutAppendForTests: (() => void) | undefined; +let afterStrictHistoryRolloutAppendForTests: (() => void) | undefined; +let beforeHistoryApplyTransactionForTests: (() => void) | undefined; /** Test seam: runs after the pending count and before stability validation. */ export function setAfterNoopPendingCountForTests(hook: (() => void) | undefined): void { afterNoopPendingCountForTests = hook; } -interface NativeRestoreTarget { - modelProvider: string; - source: string; - hasUserEvent: number; +/** Test seam: runs after exact DB/rollout readback and before manifest fingerprint CAS. */ +export function setBeforeHistoryBackupConsumeForTests(hook: (() => void) | undefined): void { + beforeHistoryBackupConsumeForTests = hook; +} + +/** Test seam: models a same-file append after strict snapshot validation but before our append. */ +export function setBeforeStrictHistoryRolloutAppendForTests(hook: (() => void) | undefined): void { + beforeStrictHistoryRolloutAppendForTests = hook; +} + +/** Test seam: models a write/finalization failure after the strict append reached disk. */ +export function setAfterStrictHistoryRolloutAppendForTests(hook: (() => void) | undefined): void { + afterStrictHistoryRolloutAppendForTests = hook; +} + +/** Test seam: runs after manifest snapshot publication and before apply's database CAS. */ +export function setBeforeHistoryApplyTransactionForTests(hook: (() => void) | undefined): void { + beforeHistoryApplyTransactionForTests = hook; } function samePath(a: string, b: string): boolean { @@ -253,12 +358,45 @@ function samePath(a: string, b: string): boolean { return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; } -function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection { - if (!existsSync(path)) return { kind: "known", present: false, entries: 0, fingerprint: "absent" }; - let parsed: unknown; +function readBackupStrict(path: string, stateDbPath: string): StrictBackupRead { + let pathStat: ReturnType; + try { + pathStat = lstatSync(path); + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; + if (code !== "ENOENT") { + const failureReason = classifyRecoverableHistoryError(error); + return { + kind: "unknown", + present: true, + reason: "manifest-read", + ...(failureReason === "busy" || failureReason === "permission" ? { failureReason } : {}), + }; + } + return { + kind: "known", + present: false, + manifest: { version: 1, stateDbPath, entries: {} }, + fingerprint: "absent", + }; + } + if (!pathStat.isFile() || pathStat.isSymbolicLink()) { + return { kind: "unknown", present: true, reason: "manifest-read" }; + } let raw: string; try { raw = readFileSync(path, "utf8"); + } catch (error) { + const failureReason = classifyRecoverableHistoryError(error); + return { + kind: "unknown", + present: true, + reason: "manifest-read", + ...(failureReason === "busy" || failureReason === "permission" ? { failureReason } : {}), + }; + } + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { return { kind: "unknown", present: true, reason: "manifest-read" }; @@ -267,7 +405,10 @@ function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupIn return { kind: "unknown", present: true, reason: "manifest-schema" }; } const manifest = parsed as Partial; - if (manifest.version !== 1 || typeof manifest.stateDbPath !== "string") { + if (manifest.version !== 1 + || typeof manifest.stateDbPath !== "string" + || !manifest.stateDbPath.trim() + || !isAbsolute(manifest.stateDbPath)) { return { kind: "unknown", present: true, reason: "manifest-schema" }; } if (!samePath(manifest.stateDbPath, stateDbPath)) { @@ -281,22 +422,45 @@ function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupIn return { kind: "unknown", present: true, reason: "manifest-schema" }; } const entry = value as Partial; - if (entry.id !== id + if (!id + || entry.id !== id || typeof entry.rolloutPath !== "string" + || !entry.rolloutPath.trim() + || !isAbsolute(entry.rolloutPath) || typeof entry.modelProvider !== "string" + || !entry.modelProvider.trim() || typeof entry.source !== "string" - || typeof entry.hasUserEvent !== "number") { + || !entry.source.trim() + || typeof entry.hasUserEvent !== "number" + || !Number.isSafeInteger(entry.hasUserEvent) + || (entry.hasUserEvent !== 0 && entry.hasUserEvent !== 1) + || !( + (entry.modelProvider === "openai" && RESUMABLE_SOURCES.includes(entry.source as (typeof RESUMABLE_SOURCES)[number])) + || (entry.modelProvider === "opencodex" && entry.source === "exec") + )) { return { kind: "unknown", present: true, reason: "manifest-schema" }; } } return { kind: "known", present: true, - entries: Object.keys(manifest.entries).length, + manifest: manifest as BackupManifest, fingerprint: createHash("sha256").update(raw).digest("hex"), }; } +function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection { + const read = readBackupStrict(path, stateDbPath); + return read.kind === "unknown" + ? read + : { + kind: "known", + present: read.present, + entries: Object.keys(read.manifest.entries).length, + fingerprint: read.fingerprint, + }; +} + function historyFileIdentity(path: string): string | null { try { const stat = statSync(path); @@ -312,20 +476,28 @@ function readHistoryDataVersion(db: Database): number | null { return typeof value === "number" && Number.isSafeInteger(value) ? value : null; } -function readBackup(path: string, stateDbPath?: string): BackupManifest { - if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} }; - try { - const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; - if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") { - return { version: 1, stateDbPath, entries: {} }; +function readBackup(path: string, stateDbPath: string): Extract { + const read = readBackupStrict(path, stateDbPath); + if (read.kind === "unknown") { + if (read.failureReason) { + throw Object.assign( + new Error(read.failureReason === "busy" ? "history backup is busy" : "history backup permission denied"), + { code: read.failureReason === "busy" ? "EBUSY" : "EACCES" }, + ); } - if (stateDbPath && typeof parsed.stateDbPath === "string" && !samePath(parsed.stateDbPath, stateDbPath)) { - return { version: 1, stateDbPath, entries: {} }; - } - return { version: 1, stateDbPath: parsed.stateDbPath ?? stateDbPath, entries: parsed.entries }; - } catch { - return { version: 1, stateDbPath, entries: {} }; + throw new CodexHistoryIntegrityError(`history_backup_${read.reason.replaceAll("-", "_")}`); } + return read; +} + +function consumeBackupIfUnchanged(path: string, stateDbPath: string, expectedFingerprint: string): void { + const current = readBackupStrict(path, stateDbPath); + if (current.kind !== "known" + || !current.present + || current.fingerprint !== expectedFingerprint) { + throw new CodexHistoryIntegrityError("history_backup_changed_during_restore"); + } + unlinkSync(path); } function writeBackup(path: string, manifest: BackupManifest, stateDbPath?: string): void { @@ -344,10 +516,174 @@ function rememberOriginal(manifest: BackupManifest, row: ThreadRow): void { rolloutPath: row.rollout_path, modelProvider: row.model_provider, source: row.source, - hasUserEvent: Number(row.has_user_event) || 0, + hasUserEvent: Number(row.has_user_event) === 1 ? 1 : 0, + }; +} + +function rowMatchesRestoreTuple( + row: RestoreRowSnapshot, + modelProvider: string, + source: string, + hasUserEvent: number, +): boolean { + return row.model_provider === modelProvider + && row.source === source + && row.has_user_event === hasUserEvent; +} + +function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: BackupEntry): boolean { + if (entry.modelProvider === "openai") { + const postHasUserEvent = hasFirstUserMessage(row.first_user_message) ? 1 : entry.hasUserEvent; + return rowMatchesRestoreTuple(row, "opencodex", entry.source, postHasUserEvent); + } + return hasFirstUserMessage(row.first_user_message) + && ( + rowMatchesRestoreTuple(row, "opencodex", "cli", 1) + // Older restore code coerced an opencodex/exec original into this exact tuple before + // consuming its manifest. Accept that one known post-image so an interrupted old restore + // can use its preserved first-line padding to recover exact provenance. + || rowMatchesRestoreTuple(row, "openai", "cli", 1) + ); +} + +interface RestoreRolloutSnapshot { + readonly identity: string; + readonly latestProvider: string; + readonly latestSource: string; +} + +function normalizedSessionMetaTuple(meta: ParsedSessionMeta): { provider: string; source: string } { + const payload = meta.record.payload; + return { + provider: typeof payload.model_provider === "string" && payload.model_provider + ? payload.model_provider + : "openai", + source: typeof payload.source === "string" && payload.source ? payload.source : "cli", + }; +} + +function rolloutMatchesRestoreTuple( + meta: ParsedSessionMeta, + entry: BackupEntry, + provider: string, + source: string, +): boolean { + const tuple = normalizedSessionMetaTuple(meta); + return meta.record.payload.id === entry.id + && tuple.provider === provider + && tuple.source === source; +} + +function rolloutMatchesExpectedPostImage(meta: ParsedSessionMeta, entry: BackupEntry): boolean { + if (entry.modelProvider === "openai") { + const tuple = normalizedSessionMetaTuple(meta); + const rawSource = meta.record.payload.source; + return meta.record.payload.id === entry.id + && tuple.provider === "opencodex" + // Older/native session_meta records can omit source even when SQLite identifies the + // surface as vscode. Apply changes only the provider, so absence is a valid post-image; + // restore appends the exact manifest source before consuming provenance. + && ((typeof rawSource !== "string" || !rawSource) || tuple.source === entry.source); + } + return rolloutMatchesRestoreTuple(meta, entry, "opencodex", "cli") + // Keep the same one-version recovery bridge as the database tuple check: older forced + // restore code could already have produced openai/cli before consuming this manifest. + || rolloutMatchesRestoreTuple(meta, entry, "openai", "cli"); +} + +function snapshotRolloutForRestore(entry: BackupEntry): RestoreRolloutSnapshot { + const identityBefore = historyFileIdentity(entry.rolloutPath); + if (identityBefore === null) { + throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable"); + } + const latest = readLatestSessionMeta(entry.rolloutPath); + if (!latest + || (!rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source) + && !rolloutMatchesExpectedPostImage(latest, entry))) { + throw new CodexHistoryIntegrityError("history_backup_rollout_postimage_mismatch"); + } + const firstProvider = readFirstLineProviderValue(entry.rolloutPath, entry.id); + if (firstProvider !== "openai" && firstProvider !== "opencodex") { + throw new CodexHistoryIntegrityError("history_backup_rollout_postimage_mismatch"); + } + if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) === "unsafe") { + throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable"); + } + if (historyFileIdentity(entry.rolloutPath) !== identityBefore) { + throw new CodexHistoryIntegrityError("history_backup_rollout_changed_during_restore"); + } + const tuple = normalizedSessionMetaTuple(latest); + return { + identity: identityBefore, + latestProvider: tuple.provider, + latestSource: tuple.source, }; } +interface RestoreTargetPreflight { + readonly snapshots: Map; + readonly rolloutSnapshots: Map; +} + +/** + * Read-only authority shared by restore and status/doctor. Every manifest entry must still + * identify either its exact target tuple or the one OpenCodex post-image, and its rollout + * must be present, stable, same-id, and durably restorable before callers call it pending. + */ +function preflightRestoreTargets( + getCurrent: (id: string) => RestoreRowSnapshot | null, + entries: BackupEntry[], +): RestoreTargetPreflight { + const snapshots = preflightRestoreRows(getCurrent, entries); + const rolloutSnapshots = new Map(); + for (const entry of entries) { + // Validate every rollout before the first mutation. A later missing, foreign, or + // unpatchable entry must not leave an earlier file partially restored. + rolloutSnapshots.set(entry.id, snapshotRolloutForRestore(entry)); + } + return { snapshots, rolloutSnapshots }; +} + +/** Cheap manifest-to-database authority check used by recurring no-op probes. */ +function preflightRestoreRows( + getCurrent: (id: string) => RestoreRowSnapshot | null, + entries: BackupEntry[], +): Map { + const snapshots = new Map(); + for (const entry of entries) { + const row = getCurrent(entry.id); + if (!row || typeof row.rollout_path !== "string" || !samePath(row.rollout_path, entry.rolloutPath)) { + throw new CodexHistoryIntegrityError("history_backup_target_mismatch"); + } + if (!rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent) + && !rowMatchesExpectedPostImage(row, entry)) { + throw new CodexHistoryIntegrityError("history_backup_postimage_mismatch"); + } + snapshots.set(entry.id, row); + } + return snapshots; +} + +function assertRestoreReadback( + getCurrent: (id: string) => RestoreRowSnapshot | null, + entries: BackupEntry[], +): void { + for (const entry of entries) { + const row = getCurrent(entry.id); + if (!row + || !samePath(row.rollout_path, entry.rolloutPath) + || !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) { + throw new CodexHistoryIntegrityError("history_backup_database_readback_mismatch"); + } + const latest = readLatestSessionMeta(entry.rolloutPath); + if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) !== "current" + || !latest + || !rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)) { + throw new CodexHistoryIntegrityError("history_backup_rollout_readback_mismatch"); + } + } +} + interface ParsedSessionMeta { record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record }; } @@ -373,6 +709,10 @@ function parseSessionMetaLine(line: string): ParsedSessionMeta | null { */ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null { const raw = readFileSync(path, "utf8"); + return readLatestSessionMetaFromText(raw); +} + +function readLatestSessionMetaFromText(raw: string): ParsedSessionMeta | null { const lines = raw.split("\n"); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; @@ -384,6 +724,39 @@ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null { return null; } +function readLatestSessionMetaForIdFromText(raw: string, expectedId: string): ParsedSessionMeta | null { + const lines = raw.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line || !line.includes("\"session_meta\"")) continue; + const meta = parseSessionMetaLine(line); + if (meta?.record.payload.id === expectedId) return meta; + } + return null; +} + +function compensateConcurrentSessionMetaAppend( + path: string, + expectedId: string, + appended: Buffer, + minimumOffset: number, +): void { + try { + const raw = readFileSync(path); + const ownOffset = raw.lastIndexOf(appended); + if (ownOffset < minimumOffset) return; + const withoutOwnAppend = Buffer.concat([ + raw.subarray(0, ownOffset), + raw.subarray(ownOffset + appended.length), + ]).toString("utf8"); + const prior = readLatestSessionMetaForIdFromText(withoutOwnAppend, expectedId); + if (prior) appendRolloutLine(path, JSON.stringify(prior.record)); + } catch { + // The caller reports an integrity conflict and retains the manifest. Compensation is + // best-effort because a second write failure must not erase the original failure evidence. + } +} + /** * Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a * Phase-2 quarantine predates full `satellite-backup.json` thread snapshots. @@ -523,15 +896,34 @@ function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | nu /** * Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line, * rather than rewriting line 1. The appended line clones the latest metadata payload (so no field - * is accidentally reset to empty) and applies only the requested changes. Returns false when the - * rollout is missing, has no parseable `session_meta`, its latest `session_meta` belongs to a - * different thread id, or it already matches the desired values. + * is accidentally reset to empty) and applies only the requested changes. `durableProvider` + * reports whether line 1 already carried (or was safely patched to) the requested provider. */ -function updateSessionMeta(path: string, expectedId: string, patch: { provider?: string; source?: string }): boolean { - if (!path || !existsSync(path)) return false; +interface SessionMetaUpdateResult { + changed: boolean; + durableProvider: boolean; + conflict?: true; +} + +function updateSessionMeta( + path: string, + expectedId: string, + patch: { provider?: string; source?: string }, + options: { + requireDurableProvider?: boolean; + expectedFileIdentity?: string; + expectedLatestProvider?: string; + expectedLatestSource?: string; + } = {}, +): SessionMetaUpdateResult { + if (!path || !existsSync(path)) return { changed: false, durableProvider: false }; + if (options.expectedFileIdentity !== undefined + && historyFileIdentity(path) !== options.expectedFileIdentity) { + return { changed: false, durableProvider: false, conflict: true }; + } const latest = readLatestSessionMeta(path); - if (!latest) return false; + if (!latest) return { changed: false, durableProvider: false }; const record = latest.record; // The app ignores `session_meta` lines whose payload id != the canonical thread id @@ -539,8 +931,23 @@ function updateSessionMeta(path: string, expectedId: string, patch: { provider?: // metadata, so an id-mismatched latest line means we'd be cloning the wrong thread's meta and // appending a line the app would discard. Skip rather than write a no-op/misleading line. const payloadId = record.payload.id; - if (typeof payloadId !== "string" || payloadId !== expectedId) return false; + if (typeof payloadId !== "string" || payloadId !== expectedId) { + return { changed: false, durableProvider: false }; + } + const latestProvider = typeof record.payload.model_provider === "string" && record.payload.model_provider + ? record.payload.model_provider + : "openai"; + const latestSource = typeof record.payload.source === "string" && record.payload.source + ? record.payload.source + : "cli"; + if ((options.expectedLatestProvider !== undefined && latestProvider !== options.expectedLatestProvider) + || (options.expectedLatestSource !== undefined && latestSource !== options.expectedLatestSource) + || (options.expectedFileIdentity !== undefined + && historyFileIdentity(path) !== options.expectedFileIdentity)) { + return { changed: false, durableProvider: false, conflict: true }; + } + const previousRecord = JSON.stringify(record); let changed = false; if (patch.provider !== undefined && record.payload.model_provider !== patch.provider) { record.payload.model_provider = patch.provider; @@ -550,40 +957,85 @@ function updateSessionMeta(path: string, expectedId: string, patch: { provider?: record.payload.source = patch.source; changed = true; } - if (!changed) return false; + const strictRestore = options.expectedFileIdentity !== undefined; + if (strictRestore) { + if (historyFileIdentity(path) !== options.expectedFileIdentity) { + return { changed: false, durableProvider: false, conflict: true }; + } + + let appended: Buffer | null = null; + let beforeSize = 0; + if (changed) { + beforeSize = statSync(path).size; + beforeStrictHistoryRolloutAppendForTests?.(); + record.timestamp = new Date().toISOString(); + appended = appendRolloutLine(path, JSON.stringify(record)); + afterStrictHistoryRolloutAppendForTests?.(); + let cleanAppend = false; + try { + const raw = readFileSync(path); + cleanAppend = raw.length === beforeSize + appended.length + && raw.subarray(beforeSize).equals(appended); + } catch { + cleanAppend = false; + } + if (!cleanAppend) { + compensateConcurrentSessionMetaAppend(path, expectedId, appended, beforeSize); + return { changed: true, durableProvider: false, conflict: true }; + } + } + + let firstLine: FirstLineProviderResult = "current"; + if (patch.provider !== undefined) { + try { + firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider); + } catch { + firstLine = "unsafe"; + } + } + if (options.requireDurableProvider && firstLine === "unsafe") { + // Restore the pre-operation last-writer-wins tuple after an append succeeded but the + // first-line durability repair failed. The extra lines remain audit evidence; the manifest + // remains authoritative and the retry cannot mistake this for convergence. + if (appended) appendRolloutLine(path, previousRecord); + return { + changed: changed || historyFileIdentity(path) !== options.expectedFileIdentity, + durableProvider: false, + }; + } + return { + changed: changed || firstLine === "patched", + durableProvider: firstLine !== "unsafe", + }; + } // Cover Codex's *other* provider reader: `read_session_meta_line` reads only line 1, and the // app clones it when writing later git/memory-mode metadata. Appending alone leaves a stale - // line-1 provider that the clone would re-append, so for a length-preserving provider change we - // also patch line 1 in place (no inode swap, no truncate). Best-effort: when it can't be done - // safely (e.g. a length-growing change), the trailing append below is still correct for the - // SQLite replay path. + // line-1 provider that the clone would re-append. Exact restore requires this repair before it + // may update SQLite or consume the only provenance manifest; forward routing remains best-effort. + let firstLine: FirstLineProviderResult = "current"; if (patch.provider !== undefined) { - try { patchFirstLineProviderInPlace(path, expectedId, patch.provider); } catch { /* best-effort line-1 patch */ } + try { + firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider); + } catch { + firstLine = "unsafe"; + } + if (options.requireDurableProvider && firstLine === "unsafe") { + return { changed: false, durableProvider: false }; + } } - // Refresh the line timestamp so the appended record reads as the newest metadata. + const firstLineChanged = firstLine === "patched"; + if (!changed) return { changed: firstLineChanged, durableProvider: firstLine !== "unsafe" }; + + // Forward/legacy mode remains best-effort. Strict manifest restore uses the CAS-style append + // branch above so a concurrent same-id provider decision cannot be overwritten. record.timestamp = new Date().toISOString(); appendRolloutLine(path, JSON.stringify(record)); - return true; -} - -function toNativeRestoreTarget(entry: BackupEntry): NativeRestoreTarget { - if (entry.modelProvider !== "opencodex") { - return { - modelProvider: entry.modelProvider, - source: entry.source, - hasUserEvent: entry.hasUserEvent, - }; - } - return { - modelProvider: "openai", - source: entry.source === "exec" ? "cli" : entry.source, - hasUserEvent: 1, - }; + return { changed: true, durableProvider: firstLine !== "unsafe" }; } -function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: number } { +function relabelAllRoutedHistoryToOpenai(db: Database): { rows: number; files: number } { const rows = db .query(` SELECT id, rollout_path, model_provider, source, has_user_event @@ -599,9 +1051,9 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu if (updateSessionMeta(row.rollout_path, row.id, { provider: "openai", source: row.source === "exec" ? "cli" : undefined, - })) files++; + }).changed) files++; } catch { - /* native restore should continue even if an old rollout is missing */ + /* explicit legacy recovery still relabels the DB when an old rollout is missing */ } } @@ -673,8 +1125,10 @@ export function withHistoryRetry(fn: () => T, io: { sleepFn?: (ms: number) => } /** - * True when a READONLY probe proves the openai-direction restore would be a no-op: - * zero threads still tagged opencodex AND an empty backup manifest. Used to skip the + * True when a READONLY probe proves the native-direction restore would be a no-op: + * the history database is readable and the backup manifest has no restore entries. Bare + * opencodex-tagged rows are not actionable: without a manifest their original provider is + * unknown, so only the explicit legacy recovery command may relabel them. Used to skip the * write-open entirely in the Design B steady state — on Windows the Codex app holds * `state_5.sqlite` (WAL, busy_timeout 5s), so an unnecessary write open can stall for * seconds and surface a false lock warning, while WAL always admits readers. A failed @@ -682,7 +1136,9 @@ export function withHistoryRetry(fn: () => T, io: { sleepFn?: (ms: number) => * to the write attempt and keep today's behavior for genuinely unknown state. */ function openaiRestoreIsNoop(stateDbPath: string, backupPath: string): boolean { - const pending = countPendingOpencodexHistory(stateDbPath, backupPath); + const pending = countPendingOpencodexHistory(stateDbPath, backupPath, { + validateRestoreTargets: false, + }); return !pending.failed && pending.pendingRows === 0 && pending.backupEntries === 0; } @@ -698,28 +1154,41 @@ export function syncCodexHistoryProvider( && openaiRestoreIsNoop(stateDbPath, backupPath)) { return { rows: 0, files: 0 }; } - const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath)); - return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; + try { + const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath)); + return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; + } catch (error) { + if (error instanceof CodexHistoryIntegrityError) { + return integrityFailureResult(error); + } + throw error; + } } function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult { - if (!existsSync(stateDbPath)) return { rows: 0, files: 0 }; + if (!existsSync(stateDbPath)) { + const backup = readBackup(backupPath, stateDbPath); + if (provider === "openai" && Object.keys(backup.manifest.entries).length > 0) { + throw new CodexHistoryIntegrityError("history_state_database_missing"); + } + return { rows: 0, files: 0 }; + } if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath); const db = openStateDb(stateDbPath); try { const placeholders = RESUMABLE_SOURCES.map(() => "?").join(","); const openaiRows = db - .query(` - SELECT id, rollout_path, model_provider, source, has_user_event + .query(` + SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message FROM threads WHERE model_provider = 'openai' AND source IN (${placeholders}) `) .all(...RESUMABLE_SOURCES); const execRows = db - .query(` - SELECT id, rollout_path, model_provider, source, has_user_event + .query(` + SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message FROM threads WHERE model_provider = 'opencodex' AND source = 'exec' @@ -727,49 +1196,92 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP `) .all(); - const manifest = readBackup(backupPath, stateDbPath); + const manifest = readBackup(backupPath, stateDbPath).manifest; for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row); writeBackup(backupPath, manifest, stateDbPath); let files = 0; - for (const row of openaiRows) { - try { - if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" })) files++; - } catch { - /* best-effort; keep DB migration moving even if one old rollout is malformed */ - } - } - for (const row of execRows) { - try { - if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" })) files++; - } catch { - /* best-effort; keep DB migration moving even if one old rollout is malformed */ - } - } - const update = db.transaction(() => { - const markUserEvent = db.query(` + const routeOpenai = db.query(` UPDATE threads - SET has_user_event = 1 + SET model_provider = 'opencodex', + has_user_event = ? WHERE id = ? - AND trim(coalesce(first_user_message, '')) != '' + AND rollout_path = ? + AND model_provider = ? + AND source = ? + AND has_user_event = ? + AND first_user_message IS ? `); - for (const row of [...openaiRows, ...execRows]) markUserEvent.run(row.id); - db.query(` + const routeExec = db.query(` UPDATE threads - SET model_provider = 'opencodex' - WHERE model_provider = 'openai' - AND source IN (${placeholders}) - `).run(...RESUMABLE_SOURCES); - db.query(` - UPDATE threads - SET source = 'cli' - WHERE model_provider = 'opencodex' - AND source = 'exec' + SET source = 'cli', + has_user_event = 1 + WHERE id = ? + AND rollout_path = ? + AND model_provider = ? + AND source = ? + AND has_user_event = ? + AND first_user_message IS ? AND trim(coalesce(first_user_message, '')) != '' - `).run(); + `); + // CAS only the rows that were recorded in this manifest. A thread inserted after the + // snapshot must stay native rather than becoming an untracked bare routed row. + for (const row of openaiRows) { + const targetEvent = hasFirstUserMessage(row.first_user_message) ? 1 : row.has_user_event; + const result = routeOpenai.run( + targetEvent, + row.id, + row.rollout_path, + row.model_provider, + row.source, + row.has_user_event, + row.first_user_message, + ); + if (result.changes !== 1) { + throw new CodexHistoryIntegrityError("history_apply_database_changed_during_route"); + } + } + for (const row of execRows) { + const result = routeExec.run( + row.id, + row.rollout_path, + row.model_provider, + row.source, + row.has_user_event, + row.first_user_message, + ); + if (result.changes !== 1) { + throw new CodexHistoryIntegrityError("history_apply_database_changed_during_route"); + } + } + + // File metadata remains best-effort, but only after every database CAS matched. Thus a + // stale snapshot or a newly inserted row cannot be routed before its provenance exists. + for (const row of openaiRows) { + try { + if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" }).changed) files++; + } catch { + /* keep DB migration moving; the manifest still carries exact original metadata */ + } + } + for (const row of execRows) { + try { + if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" }).changed) files++; + } catch { + /* keep DB migration moving; the manifest still carries exact original metadata */ + } + } }); - update(); + try { + beforeHistoryApplyTransactionForTests?.(); + update(); + } catch (error) { + if (files > 0) { + throw new CodexHistoryIntegrityError("history_apply_partial_route", { rows: 0, files }); + } + throw error; + } return { rows: openaiRows.length + execRows.length, files }; } finally { @@ -778,26 +1290,24 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP } function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): CodexHistorySyncResult { - const manifest = readBackup(backupPath, stateDbPath); + const backup = readBackup(backupPath, stateDbPath); + const manifest = backup.manifest; const entries = Object.values(manifest.entries); const db = openStateDb(stateDbPath); try { - if (entries.length === 0) { - const ejected = ejectRemainingOpencodexHistory(db); - return ejected.rows > 0 ? { rows: 0, files: ejected.files, ejectedRows: ejected.rows } : { rows: 0, files: 0 }; - } + if (entries.length === 0) return { rows: 0, files: 0 }; + + // Validate the whole manifest-to-database target set before touching a rollout. Only the + // OpenCodex post-image (or an already-restored target from an interrupted retry) is owned by + // this manifest. Any other tuple is a newer/foreign provider decision and must win. + const current = db.query(` + SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message + FROM threads WHERE id = ? + `); + const { snapshots, rolloutSnapshots } = preflightRestoreTargets(id => current.get(id), entries); let files = 0; - for (const entry of entries) { - const target = toNativeRestoreTarget(entry); - try { - if (updateSessionMeta(entry.rolloutPath, entry.id, { provider: target.modelProvider, source: target.source })) files++; - } catch { - /* best-effort; keep DB restore moving even if one rollout disappeared */ - } - } - const restore = db.transaction(() => { const update = db.query(` UPDATE threads @@ -805,18 +1315,101 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C source = ?, has_user_event = ? WHERE id = ? + AND rollout_path = ? + AND model_provider = ? + AND source = ? + AND has_user_event = ? + AND first_user_message IS ? `); for (const entry of entries) { - const target = toNativeRestoreTarget(entry); - update.run(target.modelProvider, target.source, target.hasUserEvent, entry.id); + const before = snapshots.get(entry.id); + if (!before) throw new CodexHistoryIntegrityError("history_backup_snapshot_missing"); + const result = update.run( + entry.modelProvider, + entry.source, + entry.hasUserEvent, + entry.id, + before.rollout_path, + before.model_provider, + before.source, + before.has_user_event, + before.first_user_message, + ); + if (result.changes !== 1) { + throw new CodexHistoryIntegrityError("history_backup_database_changed_during_restore"); + } + } + // Only after every database CAS matched may a rollout move. Keeping the SQLite + // transaction open means a file-side refusal rolls the database back, while the manifest + // remains the durable retry journal for an exceptional I/O failure. + for (const entry of entries) { + const before = rolloutSnapshots.get(entry.id); + if (!before) throw new CodexHistoryIntegrityError("history_backup_rollout_snapshot_missing"); + let updated: SessionMetaUpdateResult; + try { + updated = updateSessionMeta( + entry.rolloutPath, + entry.id, + { provider: entry.modelProvider, source: entry.source }, + { + requireDurableProvider: true, + expectedFileIdentity: before.identity, + expectedLatestProvider: before.latestProvider, + expectedLatestSource: before.latestSource, + }, + ); + } catch (error) { + if (historyFileIdentity(entry.rolloutPath) !== before.identity) files++; + throw error; + } + if (updated.changed) files++; + if (updated.conflict) { + throw new CodexHistoryIntegrityError("history_backup_rollout_changed_during_restore"); + } + if (!updated.durableProvider) { + throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable"); + } } }); - restore(); - writeBackup(backupPath, { version: 1, stateDbPath, entries: {} }, stateDbPath); - const ejected = ejectRemainingOpencodexHistory(db); - return ejected.rows > 0 - ? { rows: entries.length, files: files + ejected.files, ejectedRows: ejected.rows } - : { rows: entries.length, files }; + try { + restore(); + } catch (error) { + if (files > 0) { + throw new CodexHistoryIntegrityError("history_backup_partial_restore", { rows: 0, files }); + } + throw error; + } + + try { + const getCurrent = (id: string) => current.get(id); + assertRestoreReadback(getCurrent, entries); + beforeHistoryBackupConsumeForTests?.(); + // The hook models the exact last-moment race: neither a newer database decision nor a + // same-id foreign session_meta may be hidden by deleting the only provenance manifest. + assertRestoreReadback(getCurrent, entries); + consumeBackupIfUnchanged(backupPath, stateDbPath, backup.fingerprint); + } catch (error) { + if (error instanceof CodexHistoryIntegrityError) { + throw new CodexHistoryIntegrityError(error.message, { rows: entries.length, files }); + } + const failureReason = classifyRecoverableHistoryError(error); + if (failureReason) { + return { + rows: entries.length, + files, + failed: true, + failureReason, + }; + } + // Once exact targets were written, an unclassified finalization failure is an + // applied-but-not-converged integrity state. Preserve that progress instead of + // reporting a zero-change failure that invites an unsafe blind retry. + throw new CodexHistoryIntegrityError("history_backup_finalization_failed", { + rows: entries.length, + files, + }); + } + return { rows: entries.length, files }; } finally { db.close(); } @@ -827,7 +1420,7 @@ export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath const retried = withHistoryRetryResult(() => { const db = openStateDb(stateDbPath); try { - return ejectRemainingOpencodexHistory(db); + return relabelAllRoutedHistoryToOpenai(db); } finally { db.close(); } @@ -836,8 +1429,9 @@ export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath } /** - * One-time Design-B migration: restore backed-up originals, then eject any remaining - * opencodex-tagged threads to openai. Thin wrapper over the restore path with a + * One-time Design-B migration: restore only manifest-backed originals. Untracked + * opencodex-tagged threads have unknown provider provenance and remain routed unless the + * user explicitly invokes legacy OpenAI recovery. Thin wrapper over the restore path with a * configurable retry budget — the daemon migration guardian uses `{ attempts: 1 }` * per tick so a locked DB never stalls the event loop beyond one sqlite busy wait. */ @@ -846,14 +1440,20 @@ export function migrateHistoryToOpenai( backupPath = historyBackupPathFor(stateDbPath), opts: { attempts?: number; delayMs?: number; sleepFn?: (ms: number) => void } = {}, ): CodexHistorySyncResult { - if (!existsSync(stateDbPath)) return { rows: 0, files: 0 }; // Steady-state gate: this migration is Design-B-specific (inject + guardian callers), // and after the one-time migration every start would otherwise write-open the DB for // nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate // (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds. if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 }; - const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts); - return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; + try { + const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts); + return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; + } catch (error) { + if (error instanceof CodexHistoryIntegrityError) { + return integrityFailureResult(error); + } + throw error; + } } /** @@ -894,7 +1494,9 @@ export function snapshotCodexHistoryNoop( if (dataVersionBefore === null) { return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" }; } - const pending = countPendingOpencodexHistory(stateDbPath, backupPath); + const pending = countPendingOpencodexHistory(stateDbPath, backupPath, { + validateRestoreTargets: false, + }); if (pending.failed) { return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" }; } @@ -932,48 +1534,71 @@ export function snapshotCodexHistoryNoop( } export interface PendingHistoryCount { - /** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */ + /** Compatibility field; bare routed rows are never automatic restore work. */ pendingRows: number; /** Entries still recorded in the backup manifest (restore targets). */ backupEntries: number; - /** Set when the DB could not be opened/read (locked); counts are then unknown, not zero. */ + /** Set when the DB/manifest could not be read or their bound identity is invalid. */ failed?: true; + /** Distinguishes retryable contention/access from a manifest or target that needs review. */ + failureReason?: CodexHistoryFailureReason; } /** * Read-only migration progress probe for the guardian and `ocx doctor`. Opens sqlite - * readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick. The - * pending predicate mirrors ejectRemainingOpencodexHistory exactly — rows eject ignores - * (empty first_user_message) are not counted, so 0 really means "migration done". + * readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick. Only a + * valid, database-bound backup manifest is actionable work; bare routed rows remain + * untouched because their original provider is not known. Operator diagnostics keep the + * default deep rollout validation. Recurring no-op probes explicitly opt out because any + * nonempty manifest already prevents a no-op and the mutation path always preflights files. */ export function countPendingOpencodexHistory( stateDbPath = resolveCodexStateDbPath(), backupPath = historyBackupPathFor(stateDbPath), + opts: { validateRestoreTargets?: boolean } = {}, ): PendingHistoryCount { - let backupEntries = 0; - try { - const manifest = readBackup(backupPath, stateDbPath); - backupEntries = Object.keys(manifest.entries).length; - } catch { /* unreadable manifest counts as 0 — restore treats it the same way */ } + const backup = readBackupStrict(backupPath, stateDbPath); + if (backup.kind === "unknown") { + return { + pendingRows: 0, + backupEntries: 0, + failed: true, + failureReason: backup.failureReason ?? "integrity", + }; + } + const entries = Object.values(backup.manifest.entries); + const backupEntries = entries.length; - if (!existsSync(stateDbPath)) return { pendingRows: 0, backupEntries }; + if (!existsSync(stateDbPath)) { + return backupEntries > 0 + ? { pendingRows: 0, backupEntries, failed: true, failureReason: "integrity" } + : { pendingRows: 0, backupEntries }; + } try { const db = new Database(stateDbPath, { readonly: true }); try { db.exec("PRAGMA busy_timeout = 100"); - const row = db.query<{ n: number }, []>(` - SELECT count(*) AS n - FROM threads - WHERE model_provider = 'opencodex' - AND trim(coalesce(first_user_message, '')) != '' - `).get(); - return { pendingRows: row?.n ?? 0, backupEntries }; + // Prove the expected history schema is readable without counting unowned routed rows. + db.query("SELECT 1 FROM threads LIMIT 1").get(); + if (entries.length > 0) { + const current = db.query(` + SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message + FROM threads WHERE id = ? + `); + if (opts.validateRestoreTargets === false) { + preflightRestoreRows(id => current.get(id), entries); + } else { + preflightRestoreTargets(id => current.get(id), entries); + } + } + return { pendingRows: 0, backupEntries }; } finally { db.close(); } } catch (error) { - if (isRecoverableHistoryError(error)) return { pendingRows: 0, backupEntries, failed: true }; + const reason = classifyRecoverableHistoryError(error); + if (reason) return { pendingRows: 0, backupEntries, failed: true, failureReason: reason }; // Schema drift (e.g. a future codex renames the table) is a "cannot know" too, not a crash. - return { pendingRows: 0, backupEntries, failed: true }; + return { pendingRows: 0, backupEntries, failed: true, failureReason: "integrity" }; } } diff --git a/src/codex/history-worker.ts b/src/codex/history-worker.ts index c0ce2dcf19..52151a89fb 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -4,8 +4,8 @@ * Everything mutable about history happens here, behind H: the SQLite rows, the * backup manifest, and every rollout patch. Those three do not share a * transaction — sync writes the manifest before its database transaction, and - * restore writes rollouts, then the database, then the manifest - * (`src/codex/history-provider.ts:606-648,656-698`) — so a busy timeout only + * restore preflights every rollout, applies database CAS before rollout changes inside one + * SQLite transaction, then consumes the manifest after exact readback — so a busy timeout only * ever serialized a third of a state transition. Holding H across the whole unit * is what stops an opposite-direction process overtaking through the other two. * @@ -74,7 +74,8 @@ export type HistoryWorkerResult = | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly type: "error"; readonly requestId: string; readonly jobId: string; - readonly message: string; readonly reason?: CodexHistoryFailureReason }; + readonly message: string; readonly reason?: CodexHistoryFailureReason; + readonly rows?: number; readonly files?: number }; const OPERATIONS: ReadonlySet = new Set([ "skip", @@ -142,8 +143,9 @@ export function runHistoryUnitUnderLock( const proof = snapshotCodexHistoryNoop(message.canonicalStateDbPath, message.canonicalBackupPath); if (proof.kind === "verified-noop") return { verifiedNoop: proof } as const; } - // apply-opencodex routes history to opencodex; migrate/restore return it to - // native. The provider is derived from the operation, never from a caller. + // apply-opencodex routes history to opencodex; migrate/restore recover only + // manifest-backed original metadata. The provider is derived from the operation, + // never from a caller; only recover-legacy-openai force-labels bare routed rows. const provider = operation === "apply-opencodex" ? "opencodex" : "openai"; return writeHistoryProviderTransition(permit, target, provider); }, @@ -179,6 +181,7 @@ export function runHistoryUnitUnderLock( jobId, message: "history_transition_failed", ...(result.failureReason ? { reason: result.failureReason } : {}), + ...(result.rows > 0 || result.files > 0 ? { rows: result.rows, files: result.files } : {}), }; } return { diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 1d2de29486..f7c5748e74 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1092,8 +1092,8 @@ export async function injectCodexConfig( : legacyMode ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n` : migratedRows > 0 - ? ` Codex resume history: ${migratedRows} legacy opencodex-tagged thread(s) migrated back to openai (one-time).\n` - : ` Codex resume history: untouched (threads keep their native openai tag).\n`; + ? ` Codex resume history: restored original provider metadata for ${migratedRows} manifest-backed thread(s) (one-time).\n` + : ` Codex resume history: no backed-up metadata pending; untracked routed history left unchanged.\n`; // A user-owned root openai_base_url means we did NOT install routing — say so honestly // instead of claiming the proxy route is active (catalog/fast_mode were still written). if (keptUserBaseUrl) { @@ -1332,18 +1332,33 @@ export interface CodexNativeRestoreResult { }; } -function failedHistoryRestore(reason?: CodexHistoryFailureReason, detail?: string): CodexRestoreHistoryResult { +function failedHistoryRestore( + reason?: CodexHistoryFailureReason, + detail?: string, + progress: { rows?: number; files?: number } = {}, +): CodexRestoreHistoryResult { + const rows = progress.rows ?? 0; + const files = progress.files ?? 0; + const changed = rows > 0 || files > 0; return { state: "failed", - changed: false, + changed, ...(reason ? { reason } : {}), - rows: 0, - files: 0, + rows, + files, ejectedRows: 0, message: reason === "permission" - ? "Codex resume history could NOT be restored because permission was denied." + ? changed + ? "Codex resume history changed but did NOT converge because permission was denied while finalizing the backup manifest; the manifest was retained for review and safe retry." + : "Codex resume history could NOT be restored because permission was denied." : reason === "busy" - ? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database." + ? changed + ? "Codex resume history changed but did NOT converge because backup-manifest finalization remained busy; the manifest was retained for review and safe retry." + : detail ?? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database." + : reason === "integrity" + ? changed + ? "Codex resume history changed but did NOT converge because the backup or target changed; the manifest was retained for review and safe retry." + : "Codex resume history could NOT be restored because the backup or restore target failed integrity checks; unverified provider metadata was left unchanged." : detail ? `Codex resume history could NOT be restored: ${detail}` : "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.", @@ -1365,9 +1380,18 @@ export function failedHistoryRestoreFromOutcome( outcome: Extract, ): CodexRestoreHistoryResult { if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy"); - if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") return failedHistoryRestore("busy"); + if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") { + return failedHistoryRestore( + "busy", + describeHistoryJobFailure(outcome, "restore"), + { rows: outcome.rows, files: outcome.files }, + ); + } if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") { - return failedHistoryRestore("permission"); + return failedHistoryRestore("permission", undefined, { rows: outcome.rows, files: outcome.files }); + } + if (outcome.kind === "failed" && outcome.historyFailureReason === "integrity") { + return failedHistoryRestore("integrity", undefined, { rows: outcome.rows, files: outcome.files }); } return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore")); } @@ -1607,10 +1631,10 @@ export async function restoreNativeCodexAsync( } const history: CodexRestoreHistoryResult = outcome.kind === "converged" ? { - state: "ok", changed: outcome.rows > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0, + state: "ok", changed: outcome.rows > 0 || outcome.files > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0, message: outcome.rows > 0 - ? `Resume history restored from opencodex backup (${outcome.rows} thread(s)).` - : "Codex resume history was already native.", + ? `Resume history metadata restored from opencodex backup (${outcome.rows} thread(s)); original providers preserved.` + : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.", } : outcome.kind === "skipped" ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." } @@ -1672,16 +1696,16 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD const history: CodexRestoreHistoryResult = options.skipHistory ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." } : rawHistory.failed - ? failedHistoryRestore(rawHistory.failureReason) + ? failedHistoryRestore(rawHistory.failureReason, undefined, rawHistory) : { state: "ok", - changed: rawHistory.rows > 0 || (rawHistory.ejectedRows ?? 0) > 0, + changed: rawHistory.rows > 0 || rawHistory.files > 0 || (rawHistory.ejectedRows ?? 0) > 0, rows: rawHistory.rows, files: rawHistory.files, ejectedRows: rawHistory.ejectedRows ?? 0, message: rawHistory.rows > 0 - ? `Resume history restored from opencodex backup (${rawHistory.rows} thread(s)).` - : "Codex resume history was already native.", + ? `Resume history metadata restored from opencodex backup (${rawHistory.rows} thread(s)); original providers preserved.` + : "No backed-up resume-history metadata was pending; untracked routed history was left unchanged.", }; const message = catalog.removed > 0 ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` @@ -1711,10 +1735,14 @@ export function formatApplyHistoryFailure(outcome: CodexHistoryJobOutcome, legac const busy = (outcome.kind === "blocked" && outcome.reason === "busy") || (outcome.kind === "failed" && outcome.historyFailureReason === "busy"); - const headline = legacyMode - ? "Codex resume history sync SKIPPED" - : busy - ? "Codex resume history migration deferred" + const partiallyChanged = outcome.kind === "failed" + && ((outcome.rows ?? 0) > 0 || (outcome.files ?? 0) > 0); + const headline = partiallyChanged + ? "Codex resume history changed but did not converge" + : legacyMode + ? "Codex resume history sync SKIPPED" + : busy + ? "Codex resume history metadata restore deferred" : "Codex resume history NOT changed"; return ` ⚠️ ${headline}: ${describeHistoryJobFailure(outcome, "apply", legacyMode)}\n`; } diff --git a/src/codex/internal/history-writer.ts b/src/codex/internal/history-writer.ts index 7ae98dfe82..06af277989 100644 --- a/src/codex/internal/history-writer.ts +++ b/src/codex/internal/history-writer.ts @@ -5,9 +5,10 @@ * This module exists because the mutations are spread across three surfaces that * do not share a transaction: SQLite rows, the backup manifest, and the rollout * files. `syncCodexHistoryProvider` writes the manifest BEFORE its database - * transaction and patches rollouts inside it; restore writes rollouts, then the - * database, then the manifest, then ejects again - * (`src/codex/history-provider.ts:606-648,656-698`). A SQLite busy timeout + * transaction and patches rollouts inside it; restore preflights every rollout, + * applies database CAS before file changes in one SQLite transaction, then consumes the + * manifest after exact readback (`src/codex/history-provider.ts`). A + * SQLite busy timeout * serializes exactly one of those three, which is why an opposite-direction * process could overtake through the other two. * diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index c7db494def..98ff31c4fc 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -11,7 +11,7 @@ import { statSync, } from "node:fs"; import type { Stats } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -559,18 +559,22 @@ function classifyHistoryDatabase(path: string): NativeRoutedResidueResult { return indeterminate("history", resolved.path, "history row has no provider metadata"); } } + // A bare opencodex row is not proof that OpenCodex owns a reversible transition: it may + // belong to any routed provider and has no native target without the backup manifest. + // Keep detecting interrupted metadata on native rows, but let the manifest classifier + // below be the authority for provenance-backed routed rows. const rollouts = classifyReferencedRollouts( "history", - rows.map(row => ({ id: row.id, path: row.rollout_path })), + rows + .filter(row => row.model_provider !== "opencodex") + .map(row => ({ id: row.id, path: row.rollout_path })), ); if (rollouts.kind !== "clean") return rollouts; const after = statSync(resolved.path); if (!sameStat(resolved.stat, after)) { return indeterminate("history", resolved.path, "history database changed while it was being observed"); } - return rows.some(row => row.model_provider === "opencodex") - ? { kind: "residue", surface: "history", path: resolved.path } - : { kind: "clean" }; + return { kind: "clean" }; } catch (error) { return indeterminate("history", resolved.path, `unreadable history database: ${errorReason(error)}`); } finally { @@ -600,26 +604,46 @@ function classifyHistoryBackup(path: string, stateDatabasePath: string): NativeR return indeterminate("history-backup", read.path, "history backup has an unknown shape"); } const manifest = parsed as Record; - if (manifest.version !== 1 || !manifest.entries || typeof manifest.entries !== "object" || Array.isArray(manifest.entries)) { + if (manifest.version !== 1 + || typeof manifest.stateDbPath !== "string" + || !manifest.stateDbPath.trim() + || !isAbsolute(manifest.stateDbPath) + || !manifest.entries + || typeof manifest.entries !== "object" + || Array.isArray(manifest.entries)) { return indeterminate("history-backup", read.path, "history backup has an unknown shape"); } - if (typeof manifest.stateDbPath === "string") { - const expected = process.platform === "win32" ? resolve(stateDatabasePath).toLowerCase() : resolve(stateDatabasePath); - const actual = process.platform === "win32" ? resolve(manifest.stateDbPath).toLowerCase() : resolve(manifest.stateDbPath); - if (actual !== expected) { - return indeterminate("history-backup", read.path, "history backup names a different state database"); - } + const expected = process.platform === "win32" ? resolve(stateDatabasePath).toLowerCase() : resolve(stateDatabasePath); + const actual = process.platform === "win32" ? resolve(manifest.stateDbPath).toLowerCase() : resolve(manifest.stateDbPath); + if (actual !== expected) { + return indeterminate("history-backup", read.path, "history backup names a different state database"); } - const entries = Object.values(manifest.entries as Record); + const entries = Object.entries(manifest.entries as Record); const references: RolloutReference[] = []; - for (const entry of entries) { + for (const [id, entry] of entries) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return indeterminate("history-backup", read.path, "history backup entry has an unknown shape"); } const candidate = entry as Record; - if (typeof candidate.id !== "string" || !candidate.id - || typeof candidate.rolloutPath !== "string" || !candidate.rolloutPath) { - return indeterminate("history-backup", read.path, "history backup entry has an unknown rollout reference"); + if (!id + || typeof candidate.id !== "string" + || candidate.id !== id + || typeof candidate.rolloutPath !== "string" + || !candidate.rolloutPath + || !isAbsolute(candidate.rolloutPath) + || typeof candidate.modelProvider !== "string" + || !candidate.modelProvider + || typeof candidate.source !== "string" + || !candidate.source + || typeof candidate.hasUserEvent !== "number" + || !Number.isSafeInteger(candidate.hasUserEvent) + || (candidate.hasUserEvent !== 0 && candidate.hasUserEvent !== 1) + || !( + (candidate.modelProvider === "openai" + && (candidate.source === "cli" || candidate.source === "vscode")) + || (candidate.modelProvider === "opencodex" && candidate.source === "exec") + )) { + return indeterminate("history-backup", read.path, "history backup entry has invalid provenance metadata"); } references.push({ id: candidate.id, path: candidate.rolloutPath }); } diff --git a/src/update/index.ts b/src/update/index.ts index 17b71ff95c..35f7a46d22 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -267,9 +267,9 @@ export async function runUpdate(): Promise { } if (historyRestoreIncomplete()) { console.warn( - "⚠️ Codex resume history was NOT restored (history DB locked — Codex app/IDE open?).\n" + - " Your routed threads stay hidden in the native Codex app until restored.\n" + - " After the update: close the Codex app, then run 'ocx stop' once to restore.", + "⚠️ Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + + " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + + " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", ); } } diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 26a279f592..d761ec68ce 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -200,10 +200,12 @@ openai_base_url = "http://127.0.0.1:10100/v1" ``` Codex keeps the native `openai` provider id, so new threads stay under that identity instead of -being re-tagged. History that an earlier legacy injection re-tagged as `opencodex` is migrated back -to `openai` once, as restore machinery — a no-op when there is nothing to migrate. A user-owned root -`openai_base_url` is preserved instead of overwritten, and that case also blocks managed sub-agent -defaults rather than fighting the user for ownership. +being re-tagged. History restore is manifest-authoritative: only rows whose original provider, +source, and event marker were backed up for the same state database are restored exactly. A bare +`opencodex` row is never assumed to have originated at OpenAI; it stays unchanged unless the user +explicitly runs legacy OpenAI recovery. A user-owned root `openai_base_url` is preserved instead of +overwritten, and that case also blocks managed sub-agent defaults rather than fighting the user for +ownership. **API auth header (non-loopback).** The built-in `openai` provider cannot carry the `x-opencodex-api-key` env header, so this form re-tags the root provider and appends the table: @@ -232,10 +234,10 @@ overwritten. Disabling the option and fallback restore remove only marker-owned restore must preserve later user edits while stripping those managed values. If the root config selects a provider other than `openai` or `opencodex`, injection must leave the -config byte-for-byte unchanged and skip profile creation/updates and history migration. External +config byte-for-byte unchanged and skip profile creation/updates and history metadata restoration. External provider managers own that routing configuration, and replacing their provider id can hide otherwise intact Codex sessions. This ownership check must run before catalog/cache refresh, -journal creation, and the background history migration guardian. +journal creation, and the background history restoration guardian. `ocx sync` and `ocx restore back` run the injector's non-writing preflight before provider discovery or catalog/cache replacement. Deterministic config and ownership refusals therefore diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 8998d644a3..4fa55e2da1 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -4,6 +4,7 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { Database } from "bun:sqlite"; import { EXPORT_CLIENT_IDS } from "../src/clients/config-export"; import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; @@ -257,8 +258,8 @@ describe("CLI subcommand help", () => { expectSpawnFinished(result, "ocx recover-history --help"); expect(result.status).toBe(0); - expect(result.stdout).toContain("Usage: ocx recover-history --legacy-openai"); - expect(result.stdout).toContain("Explicitly recover pre-backup syncResumeHistory rows."); + expect(result.stdout).toContain("Usage: ocx recover-history --legacy-openai --yes"); + expect(result.stdout).toContain("Force all user-message opencodex rows to OpenAI"); expect(result.stdout).not.toContain("Recovered"); expect(result.stderr).toBe(""); expect(existsSync(statePath)).toBe(false); @@ -267,6 +268,53 @@ describe("CLI subcommand help", () => { } }); + test("recover-history requires exact confirmation before mutating history", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-recover-confirm-")); + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-recover-confirm-state-")); + try { + const rollout = join(codexHome, "rollout.jsonl"); + writeFileSync(rollout, `${JSON.stringify({ + type: "session_meta", + payload: { id: "thread-1", model_provider: "opencodex", source: "exec" }, + })}\n`); + const statePath = join(codexHome, "state_5.sqlite"); + const db = new Database(statePath, { create: true }); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, rollout_path TEXT, model_provider TEXT, + source TEXT, has_user_event INTEGER, first_user_message TEXT + )`); + db.run("INSERT INTO threads VALUES ('thread-1', ?, 'opencodex', 'exec', 1, 'legacy')", [rollout]); + db.close(); + const databaseBefore = readFileSync(statePath); + const rolloutBefore = readFileSync(rollout); + const env = { CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }; + + for (const command of [ + ["recover-history", "--legacy-openai"], + ["recover-history", "--legacy-openai", "--yes", "--extra"], + ]) { + const refused = runCli(command, env); + expectSpawnFinished(refused, `ocx ${command.join(" ")}`); + expect(refused.status).toBe(1); + expect(refused.stderr).toContain("--legacy-openai --yes"); + expect(readFileSync(statePath).equals(databaseBefore)).toBe(true); + expect(readFileSync(rollout).equals(rolloutBefore)).toBe(true); + } + + const confirmed = runCli(["recover-history", "--legacy-openai", "--yes"], env); + expectSpawnFinished(confirmed, "ocx recover-history --legacy-openai --yes"); + expect(confirmed.status).toBe(0); + expect(confirmed.stdout).toContain("Recovered 1 legacy thread(s)"); + const restored = new Database(statePath, { readonly: true }); + expect(restored.query("SELECT model_provider, source FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", source: "cli" }); + restored.close(); + } finally { + rmSync(opencodexHome, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }); + test("start rejects unknown and partially numeric port arguments", () => { const cases = [ { args: ["start", "--port", "123abc"], expected: "Invalid port number" }, diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 29950f68b1..0b6cfcefe8 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -711,6 +711,22 @@ describe("WP13 composed toggle acceptance", () => { seeded.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, source TEXT NOT NULL, first_user_message TEXT NOT NULL, has_user_event INTEGER NOT NULL)"); seeded.run("INSERT INTO threads VALUES ('restore-1', ?, 'opencodex', 'cli', 'hello', 1)", [rollout]); seeded.close(); + const canonicalStateDb = join(realpathSync.native(fx.codex), "state_5.sqlite"); + const normalizedDb = process.platform === "win32" ? resolve(canonicalStateDb).toLowerCase() : resolve(canonicalStateDb); + const backupId = createHash("sha256").update(normalizedDb).digest("hex").slice(0, 16); + writeFileSync(join(fx.ocx, `codex-history-backup-${backupId}.json`), JSON.stringify({ + version: 1, + stateDbPath: canonicalStateDb, + entries: { + "restore-1": { + id: "restore-1", + rolloutPath: rollout, + modelProvider: "openai", + source: "cli", + hasUserEvent: 1, + }, + }, + })); const historyBefore = readFileSync(stateDb); const held = join(fx.root, "history-held"); const release = join(fx.root, "history-release"); @@ -733,7 +749,7 @@ describe("WP13 composed toggle acceptance", () => { // (dev CI run 31105071651). Give the wait its budget plus real headroom; // the case's own 45 s test timeout still bounds it. const blocked = await fx.runCli(["restore", "--json"], fx.homeA, fx.userprofileA, watchdogMs(30_000)); - expect(blocked.exitCode).toBe(1); + expect(blocked.exitCode, JSON.stringify(blocked)).toBe(1); const envelope = JSON.parse(blocked.stdout) as { success: boolean; artifacts: { history: { state: string; reason?: string } } }; expect(envelope).toMatchObject({ success: false, artifacts: { history: { state: "failed", reason: "busy" } } }); expect(readFileSync(join(fx.codex, "config.toml"), "utf8")).toBe(original); diff --git a/tests/codex-history-job.test.ts b/tests/codex-history-job.test.ts index 62eaab7df2..f6262a71c5 100644 --- a/tests/codex-history-job.test.ts +++ b/tests/codex-history-job.test.ts @@ -1,8 +1,9 @@ import { afterEach, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -97,10 +98,10 @@ test("the failure wording names the real reason instead of always blaming the Co message: "database is locked", historyFailureReason: "busy", } as const; - expect(describeHistoryJobFailure(workerBusy, "apply", false)).toContain("history DB is locked"); + expect(describeHistoryJobFailure(workerBusy, "apply", false)).toContain("history state is busy"); expect(describeHistoryJobFailure(workerBusy, "apply", false)).toContain("retried automatically"); - expect(describeHistoryJobFailure(workerBusy, "restore")).toContain("holding the history database"); - expect(describeHistoryJobFailure(workerBusy, "recover-legacy")).toContain("locked"); + expect(describeHistoryJobFailure(workerBusy, "restore")).not.toContain("holding the history database"); + expect(describeHistoryJobFailure(workerBusy, "recover-legacy")).toContain("history state is busy"); const unsafe = { kind: "blocked", reason: "unsafe-path" } as const; const unsafeText = describeHistoryJobFailure(unsafe, "apply"); @@ -119,6 +120,25 @@ test("the failure wording names the real reason instead of always blaming the Co expect(describeHistoryJobFailure(permission, "apply")).toContain("permission was denied"); expect(describeHistoryJobFailure(permission, "apply")).toContain("'ocx doctor'"); + const integrity = { + kind: "failed", + reason: "worker-error", + message: "history_transition_failed", + historyFailureReason: "integrity", + } as const; + expect(describeHistoryJobFailure(integrity, "restore")).toContain("failed integrity checks"); + expect(describeHistoryJobFailure(integrity, "restore")).not.toContain("holding the history database"); + const partialIntegrity = { ...integrity, rows: 1, files: 1 } as const; + expect(describeHistoryJobFailure(partialIntegrity, "restore")).toContain("partial restore"); + expect(describeHistoryJobFailure(partialIntegrity, "restore")).toContain("manifest was retained"); + + const partialPermission = { ...permission, rows: 1, files: 1 } as const; + expect(describeHistoryJobFailure(partialPermission, "apply")).toContain("changed but did not converge"); + expect(describeHistoryJobFailure(partialPermission, "apply")).toContain("manifest was retained"); + const partialBusy = { ...workerBusy, rows: 1, files: 1 } as const; + expect(describeHistoryJobFailure(partialBusy, "restore")).toContain("changed but did not converge"); + expect(describeHistoryJobFailure(partialBusy, "restore")).toContain("manifest was retained"); + const workerError = { kind: "failed", reason: "worker-error", message: "unable to open database file" } as const; expect(describeHistoryJobFailure(workerError, "apply")).toContain("unable to open database file"); expect(describeHistoryJobFailure(workerError, "apply")).toContain("'ocx doctor'"); @@ -252,8 +272,8 @@ test("a hard history error reaches the caller with its real message", async () = * Proven by BEHAVIOR in a child process. The provider resolves its state * database from a module-load constant, so the fixture `CODEX_HOME` must be in * the environment before the module loads — a spawned child gives exactly that. - * The fixture DB holds a restorable opencodex-tagged row; `skipHistory: true` - * must leave it tagged, and the default must restore it. + * The fixture DB holds a manifest-backed OpenCodex post-image; `skipHistory: true` + * must leave it tagged, and the default must restore its exact original tuple. */ test("the synchronous restore body is gated on skipHistory", () => { const repoRoot = join(import.meta.dir, ".."); @@ -276,6 +296,22 @@ test("the synchronous restore body is gated on skipHistory", () => { source TEXT NOT NULL, first_user_message TEXT NOT NULL, has_user_event INTEGER NOT NULL DEFAULT 0)`); db.run(`INSERT INTO threads VALUES ('thread-1', ?, 'opencodex', 'cli', 'hello', 1)`, rollout); db.close(); + const canonicalDbPath = join(realpathSync.native(fixtureCodexHome), "state_5.sqlite"); + const normalizedDb = process.platform === "win32" ? resolve(canonicalDbPath).toLowerCase() : resolve(canonicalDbPath); + const backupId = createHash("sha256").update(normalizedDb).digest("hex").slice(0, 16); + writeFileSync(join(fixtureOcxHome, `codex-history-backup-${backupId}.json`), JSON.stringify({ + version: 1, + stateDbPath: canonicalDbPath, + entries: { + "thread-1": { + id: "thread-1", + rolloutPath: rollout, + modelProvider: "openai", + source: "cli", + hasUserEvent: 1, + }, + }, + })); const runRestore = (optionsLiteral: string) => spawnSync(process.execPath, ["--eval", [ 'const { restoreNativeCodex } = require("./src/codex/inject");', @@ -309,4 +345,4 @@ test("the synchronous restore body is gated on skipHistory", () => { } finally { rmSync(root, { recursive: true, force: true }); } -}); +}, 30_000); diff --git a/tests/codex-history-provider.test.ts b/tests/codex-history-provider.test.ts index 485ac16f60..1f8a1aac4b 100644 --- a/tests/codex-history-provider.test.ts +++ b/tests/codex-history-provider.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setAfterNoopPendingCountForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; +import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setAfterNoopPendingCountForTests, setAfterStrictHistoryRolloutAppendForTests, setBeforeHistoryApplyTransactionForTests, setBeforeHistoryBackupConsumeForTests, setBeforeStrictHistoryRolloutAppendForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping // bun's 5s default per-test timeout by itself. Fail fast into withHistoryRetry instead. @@ -14,6 +14,10 @@ setDefaultTimeout(30_000); const noopSnapshotArtifacts = new Set(); afterEach(() => { + setBeforeHistoryBackupConsumeForTests(undefined); + setBeforeStrictHistoryRolloutAppendForTests(undefined); + setAfterStrictHistoryRolloutAppendForTests(undefined); + setBeforeHistoryApplyTransactionForTests(undefined); for (const path of noopSnapshotArtifacts) rmSync(path, { recursive: true, force: true }); noopSnapshotArtifacts.clear(); }); @@ -71,7 +75,7 @@ function makeFixture({ includeExec = false, includeLegacy = false } = {}) { rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, source TEXT NOT NULL, - first_user_message TEXT NOT NULL, + first_user_message TEXT, has_user_event INTEGER NOT NULL DEFAULT 0 ) `); @@ -109,6 +113,60 @@ describe("Codex history provider sync", () => { expect(latestSessionMetaPayload(rollout).model_provider).toBe("opencodex"); }); + test("routes and exactly restores a resumable row with a null first-user message", () => { + const fixture = makeFixture(); + const db = new Database(fixture.dbPath); + db.run("UPDATE threads SET first_user_message = NULL, has_user_event = 0 WHERE id = 'thread-1'"); + db.close(); + + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const routed = new Database(fixture.dbPath, { readonly: true }); + expect(routed.query("SELECT model_provider, first_user_message, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "opencodex", first_user_message: null, has_user_event: 0 }); + routed.close(); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const restored = new Database(fixture.dbPath, { readonly: true }); + expect(restored.query("SELECT model_provider, source, first_user_message, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", source: "vscode", first_user_message: null, has_user_event: 0 }); + restored.close(); + expect(existsSync(fixture.backupPath)).toBe(false); + }); + + test("does not route a new database row that was not captured in the manifest snapshot", () => { + const fixture = makeFixture(); + const lateRollout = join(fixture.rollout, "..", "late-rollout.jsonl"); + writeFileSync(lateRollout, JSON.stringify({ + type: "session_meta", + payload: { id: "thread-late", model_provider: "openai", source: "cli" }, + }) + "\n"); + setBeforeHistoryApplyTransactionForTests(() => { + const late = new Database(fixture.dbPath); + late.run("INSERT INTO threads VALUES ('thread-late', ?, 'openai', 'cli', 'late', 1)", lateRollout); + late.close(); + }); + + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const manifest = JSON.parse(readFileSync(fixture.backupPath, "utf8")); + expect(Object.keys(manifest.entries)).toEqual(["thread-1"]); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-late'").get()) + .toEqual({ model_provider: "openai" }); + db.close(); + expect(latestSessionMetaPayload(lateRollout).model_provider).toBe("openai"); + + setBeforeHistoryApplyTransactionForTests(undefined); + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toEqual({ rows: 1, files: 1 }); + const restored = new Database(fixture.dbPath, { readonly: true }); + expect(restored.query("SELECT model_provider FROM threads WHERE id = 'thread-late'").get()) + .toEqual({ model_provider: "openai" }); + restored.close(); + }); + test("appends a new session_meta instead of rewriting line 1, preserving inode and prior content", () => { const { dbPath, backupPath, rollout } = makeFixture(); const inodeBefore = statSync(rollout).ino; @@ -151,13 +209,13 @@ describe("Codex history provider sync", () => { }); test("rewrites line 1 in place (length-preserving) when reverting an opencodex-origin rollout, so a later first-line clone cannot resurrect opencodex", () => { - const { dbPath, backupPath, legacyRollout } = makeFixture({ includeLegacy: true }); - // thread-3 / legacyRollout is an opencodex-origin row with no backup -> eject path (revert to openai). + const { dbPath, legacyRollout } = makeFixture({ includeLegacy: true }); + // Only the explicit legacy recovery command may force a bare routed row to OpenAI. const firstLineBefore = readFileSync(legacyRollout, "utf8").split("\n")[0]; const inodeBefore = statSync(legacyRollout).ino; - const result = syncCodexHistoryProvider("openai", dbPath, backupPath); - expect(result.ejectedRows).toBe(1); + const result = restoreLegacyOpenaiHistory(dbPath); + expect(result.rows).toBe(1); const afterRestore = readFileSync(legacyRollout, "utf8"); const firstLineAfter = afterRestore.split("\n")[0]; @@ -185,14 +243,13 @@ describe("Codex history provider sync", () => { JSON.stringify({ type: "event_msg", timestamp: "2026-01-01T00:00:00.000Z", payload: { message: "live turn keep me" } }), ].join("\n") + "\n"); const dbPath = join(dir, "state_5.sqlite"); - const backupPath = join(dir, "bk.json"); const db = new Database(dbPath); db.run(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, source TEXT NOT NULL, first_user_message TEXT NOT NULL, has_user_event INTEGER NOT NULL DEFAULT 0)`); db.run(`INSERT INTO threads VALUES ('big-1', ?, 'opencodex', 'cli', 'hi', 1)`, rollout); db.close(); const firstLineBefore = readFileSync(rollout, "utf8").split("\n")[0]; - syncCodexHistoryProvider("openai", dbPath, backupPath); + restoreLegacyOpenaiHistory(dbPath); const firstLineAfter = readFileSync(rollout, "utf8").split("\n")[0]; expect(JSON.parse(firstLineAfter).payload.model_provider).toBe("openai"); @@ -208,7 +265,11 @@ describe("Codex history provider sync", () => { expect(result).toEqual({ rows: 1, files: 1 }); const db = new Database(dbPath); - expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "openai" }); + expect(db.query("SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-1'").get()).toEqual({ + model_provider: "openai", + source: "vscode", + has_user_event: 0, + }); db.close(); expect(latestSessionMetaPayload(rollout).model_provider).toBe("openai"); expect(existsSync(backupPath)).toBe(false); @@ -219,16 +280,18 @@ describe("Codex history provider sync", () => { const second = makeFixture(); syncCodexHistoryProvider("opencodex", first.dbPath, first.backupPath); - const result = syncCodexHistoryProvider("openai", second.dbPath, first.backupPath); + const manifestBefore = readFileSync(first.backupPath); + expect(syncCodexHistoryProvider("openai", second.dbPath, first.backupPath)) + .toMatchObject({ failed: true, failureReason: "integrity" }); - expect(result).toEqual({ rows: 0, files: 0 }); expect(existsSync(first.backupPath)).toBe(true); + expect(readFileSync(first.backupPath).equals(manifestBefore)).toBe(true); const db = new Database(second.dbPath); expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "openai" }); db.close(); }); - test("promotes opencodex exec threads to app-visible cli source and restores from backup", () => { + test("promotes opencodex exec threads to app-visible cli source and restores their exact routed provenance", () => { const { dbPath, backupPath, execRollout } = makeFixture({ includeExec: true }); const result = syncCodexHistoryProvider("opencodex", dbPath, backupPath); @@ -248,31 +311,364 @@ describe("Codex history provider sync", () => { expect(restore).toEqual({ rows: 2, files: 2 }); db = new Database(dbPath); expect(db.query("SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-2'").get()).toEqual({ - model_provider: "openai", - source: "cli", - has_user_event: 1, + model_provider: "opencodex", + source: "exec", + has_user_event: 0, }); db.close(); - expect(latestSessionMetaPayload(execRollout).model_provider).toBe("openai"); - expect(latestSessionMetaPayload(execRollout).source).toBe("cli"); + expect(latestSessionMetaPayload(execRollout).model_provider).toBe("opencodex"); + expect(latestSessionMetaPayload(execRollout).source).toBe("exec"); expect(existsSync(backupPath)).toBe(false); + expect(countPendingOpencodexHistory(dbPath, backupPath)).toEqual({ pendingRows: 0, backupEntries: 0 }); }); - test("ejects no-backup opencodex interactive rows to openai during native restore", () => { - const { dbPath, backupPath } = makeFixture({ includeLegacy: true }); + test("leaves no-backup routed-provider history byte-identical during native restore", () => { + const { dbPath, backupPath, legacyRollout } = makeFixture({ includeLegacy: true }); + const databaseBefore = readFileSync(dbPath); + const rolloutBefore = readFileSync(legacyRollout); const result = syncCodexHistoryProvider("openai", dbPath, backupPath); - expect(result).toEqual({ rows: 0, files: 1, ejectedRows: 1 }); - const db = new Database(dbPath); + expect(result).toEqual({ rows: 0, files: 0 }); + const db = new Database(dbPath, { readonly: true }); expect(db.query("SELECT model_provider, source FROM threads WHERE id = 'thread-3'").get()).toEqual({ - model_provider: "openai", + model_provider: "opencodex", source: "cli", }); db.close(); + expect(readFileSync(dbPath).equals(databaseBefore)).toBe(true); + expect(readFileSync(legacyRollout).equals(rolloutBefore)).toBe(true); expect(existsSync(backupPath)).toBe(false); }); + test("uses preserved JSON padding to restore a first-line provider after an older forced relabel", () => { + const { dbPath, backupPath, legacyRollout } = makeFixture({ includeLegacy: true }); + restoreLegacyOpenaiHistory(dbPath); + + // Simulate a surviving manifest from an interrupted old restore. Its trailing metadata was + // already repaired, but line 1 still carries the shortened OpenAI provider plus padding. + appendFileSync(legacyRollout, JSON.stringify({ + type: "session_meta", + timestamp: "2026-02-02T00:00:00.000Z", + payload: { id: "thread-3", model_provider: "opencodex", source: "exec" }, + }) + "\n"); + writeFileSync(backupPath, JSON.stringify({ + version: 1, + stateDbPath: dbPath, + entries: { + "thread-3": { + id: "thread-3", + rolloutPath: legacyRollout, + modelProvider: "opencodex", + source: "exec", + hasUserEvent: 1, + }, + }, + })); + + expect(syncCodexHistoryProvider("openai", dbPath, backupPath)).toEqual({ rows: 1, files: 1 }); + const first = JSON.parse(readFileSync(legacyRollout, "utf8").split("\n")[0]); + expect(first.payload.model_provider).toBe("opencodex"); + const db = new Database(dbPath, { readonly: true }); + expect(db.query("SELECT model_provider, source FROM threads WHERE id = 'thread-3'").get()) + .toEqual({ model_provider: "opencodex", source: "exec" }); + db.close(); + expect(existsSync(backupPath)).toBe(false); + }); + + test("fails closed before mutation for malformed or mismatched backup provenance", () => { + const malformed = makeFixture({ includeLegacy: true }); + const malformedDbBefore = readFileSync(malformed.dbPath); + const malformedRolloutBefore = readFileSync(malformed.legacyRollout); + writeFileSync(malformed.backupPath, JSON.stringify({ + version: 1, + stateDbPath: malformed.dbPath, + entries: { + "thread-3": { + id: "thread-3", + rolloutPath: malformed.legacyRollout, + modelProvider: "opencodex", + hasUserEvent: 1, + }, + }, + })); + const malformedManifestBefore = readFileSync(malformed.backupPath); + + expect(syncCodexHistoryProvider("openai", malformed.dbPath, malformed.backupPath)) + .toMatchObject({ failed: true, failureReason: "integrity" }); + expect(readFileSync(malformed.dbPath).equals(malformedDbBefore)).toBe(true); + expect(readFileSync(malformed.legacyRollout).equals(malformedRolloutBefore)).toBe(true); + expect(readFileSync(malformed.backupPath).equals(malformedManifestBefore)).toBe(true); + + const mismatched = makeFixture({ includeLegacy: true }); + writeFileSync(mismatched.backupPath, JSON.stringify({ + version: 1, + stateDbPath: mismatched.dbPath, + entries: { + "thread-3": { + id: "thread-3", + rolloutPath: mismatched.rollout, + modelProvider: "opencodex", + source: "cli", + hasUserEvent: 1, + }, + }, + })); + const mismatchedDbBefore = readFileSync(mismatched.dbPath); + const mismatchedRolloutBefore = readFileSync(mismatched.rollout); + const mismatchedManifestBefore = readFileSync(mismatched.backupPath); + expect(syncCodexHistoryProvider("openai", mismatched.dbPath, mismatched.backupPath)) + .toMatchObject({ failed: true, failureReason: "integrity" }); + expect(readFileSync(mismatched.dbPath).equals(mismatchedDbBefore)).toBe(true); + expect(readFileSync(mismatched.rollout).equals(mismatchedRolloutBefore)).toBe(true); + expect(readFileSync(mismatched.backupPath).equals(mismatchedManifestBefore)).toBe(true); + + const forward = makeFixture(); + writeFileSync(forward.backupPath, "{not-json"); + const forwardDbBefore = readFileSync(forward.dbPath); + const forwardRolloutBefore = readFileSync(forward.rollout); + const forwardManifestBefore = readFileSync(forward.backupPath); + expect(syncCodexHistoryProvider("opencodex", forward.dbPath, forward.backupPath)) + .toMatchObject({ failed: true, failureReason: "integrity" }); + expect(readFileSync(forward.dbPath).equals(forwardDbBefore)).toBe(true); + expect(readFileSync(forward.rollout).equals(forwardRolloutBefore)).toBe(true); + expect(readFileSync(forward.backupPath).equals(forwardManifestBefore)).toBe(true); + + const missingRollout = makeFixture({ includeLegacy: true }); + rmSync(missingRollout.legacyRollout); + writeFileSync(missingRollout.backupPath, JSON.stringify({ + version: 1, + stateDbPath: missingRollout.dbPath, + entries: { + "thread-3": { + id: "thread-3", + rolloutPath: missingRollout.legacyRollout, + modelProvider: "opencodex", + source: "exec", + hasUserEvent: 1, + }, + }, + })); + const missingDbBefore = readFileSync(missingRollout.dbPath); + const missingManifestBefore = readFileSync(missingRollout.backupPath); + expect(syncCodexHistoryProvider("openai", missingRollout.dbPath, missingRollout.backupPath)) + .toMatchObject({ failed: true, failureReason: "integrity" }); + expect(readFileSync(missingRollout.dbPath).equals(missingDbBefore)).toBe(true); + expect(readFileSync(missingRollout.backupPath).equals(missingManifestBefore)).toBe(true); + }); + + test("refuses a manifest whose current row is neither its OpenCodex post-image nor its target", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + const changed = new Database(fixture.dbPath); + changed.run("UPDATE threads SET model_provider = 'other' WHERE id = 'thread-1'"); + changed.close(); + const databaseBefore = readFileSync(fixture.dbPath); + const rolloutBefore = readFileSync(fixture.rollout); + const manifestBefore = readFileSync(fixture.backupPath); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ failed: true, failureReason: "integrity" }); + expect(readFileSync(fixture.dbPath).equals(databaseBefore)).toBe(true); + expect(readFileSync(fixture.rollout).equals(rolloutBefore)).toBe(true); + expect(readFileSync(fixture.backupPath).equals(manifestBefore)).toBe(true); + }); + + test("preserves a newer same-id rollout provider decision instead of overwriting it", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + appendFileSync(fixture.rollout, JSON.stringify({ + type: "session_meta", + timestamp: "2026-03-01T00:00:00.000Z", + payload: { id: "thread-1", model_provider: "custom", source: "vscode" }, + }) + "\n"); + const databaseBefore = readFileSync(fixture.dbPath); + const rolloutBefore = readFileSync(fixture.rollout); + const manifestBefore = readFileSync(fixture.backupPath); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 0, files: 0, failed: true, failureReason: "integrity" }); + expect(readFileSync(fixture.dbPath).equals(databaseBefore)).toBe(true); + expect(readFileSync(fixture.rollout).equals(rolloutBefore)).toBe(true); + expect(readFileSync(fixture.backupPath).equals(manifestBefore)).toBe(true); + expect(latestSessionMetaPayload(fixture.rollout).model_provider).toBe("custom"); + }); + + test("compensates a same-id provider append that races strict restore's own append", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + appendFileSync(fixture.rollout, JSON.stringify({ + type: "event_msg", + timestamp: "2026-02-28T00:00:00.000Z", + payload: { type: "user_message", message: "비ASCII 경합 기준 🧪" }, + }) + "\n"); + setBeforeStrictHistoryRolloutAppendForTests(() => { + appendFileSync(fixture.rollout, JSON.stringify({ + type: "session_meta", + timestamp: "2026-03-01T00:00:00.000Z", + payload: { id: "thread-1", model_provider: "custom", source: "vscode" }, + }) + "\n"); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 0, files: 1, failed: true, failureReason: "integrity" }); + expect(existsSync(fixture.backupPath)).toBe(true); + expect(latestSessionMetaPayload(fixture.rollout).model_provider).toBe("custom"); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "opencodex" }); + db.close(); + }); + + test("reports ambiguous file progress when a strict append lands before a write fault", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + setAfterStrictHistoryRolloutAppendForTests(() => { + throw Object.assign(new Error("append finalization failed"), { code: "EPERM" }); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 0, files: 1, failed: true, failureReason: "integrity" }); + expect(existsSync(fixture.backupPath)).toBe(true); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "opencodex" }); + db.close(); + }); + + test("preflights every rollout before mutating the first entry of a multi-entry restore", () => { + const fixture = makeFixture({ includeExec: true }); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + rmSync(fixture.execRollout); + const databaseBefore = readFileSync(fixture.dbPath); + const firstRolloutBefore = readFileSync(fixture.rollout); + const manifestBefore = readFileSync(fixture.backupPath); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 0, files: 0, failed: true, failureReason: "integrity" }); + expect(readFileSync(fixture.dbPath).equals(databaseBefore)).toBe(true); + expect(readFileSync(fixture.rollout).equals(firstRolloutBefore)).toBe(true); + expect(readFileSync(fixture.backupPath).equals(manifestBefore)).toBe(true); + }); + + test("keeps provenance when the conditional database restore loses its compare-and-swap", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + const guarded = new Database(fixture.dbPath); + guarded.exec(` + CREATE TRIGGER ignore_history_restore + BEFORE UPDATE OF model_provider, source, has_user_event ON threads + BEGIN + SELECT RAISE(IGNORE); + END; + `); + guarded.close(); + const manifestBefore = readFileSync(fixture.backupPath); + const rolloutBefore = readFileSync(fixture.rollout); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ failed: true, failureReason: "integrity" }); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "opencodex" }); + db.close(); + expect(readFileSync(fixture.rollout).equals(rolloutBefore)).toBe(true); + expect(readFileSync(fixture.backupPath).equals(manifestBefore)).toBe(true); + }); + + test("does not delete a manifest replaced after exact restore readback", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + const replacement = JSON.parse(readFileSync(fixture.backupPath, "utf8")); + replacement.revision = "newer"; + setBeforeHistoryBackupConsumeForTests(() => { + writeFileSync(fixture.backupPath, JSON.stringify(replacement)); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 1, files: 1, failed: true, failureReason: "integrity" }); + expect(JSON.parse(readFileSync(fixture.backupPath, "utf8")).revision).toBe("newer"); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai", source: "vscode", has_user_event: 0 }); + db.close(); + }); + + test("keeps the manifest when the database target changes after restore readback", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + setBeforeHistoryBackupConsumeForTests(() => { + const changed = new Database(fixture.dbPath); + changed.run("UPDATE threads SET model_provider = 'custom' WHERE id = 'thread-1'"); + changed.close(); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 1, files: 1, failed: true, failureReason: "integrity" }); + expect(existsSync(fixture.backupPath)).toBe(true); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "custom" }); + db.close(); + }); + + test("keeps the manifest when a newer same-id rollout provider lands after readback", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + setBeforeHistoryBackupConsumeForTests(() => { + appendFileSync(fixture.rollout, JSON.stringify({ + type: "session_meta", + timestamp: "2026-03-02T00:00:00.000Z", + payload: { id: "thread-1", model_provider: "custom", source: "vscode" }, + }) + "\n"); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 1, files: 1, failed: true, failureReason: "integrity" }); + expect(existsSync(fixture.backupPath)).toBe(true); + expect(latestSessionMetaPayload(fixture.rollout).model_provider).toBe("custom"); + }); + + test("reports applied permission progress when manifest finalization is denied", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + setBeforeHistoryBackupConsumeForTests(() => { + throw Object.assign(new Error("finalization denied"), { code: "EPERM" }); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 1, files: 1, failed: true, failureReason: "permission" }); + expect(existsSync(fixture.backupPath)).toBe(true); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "openai" }); + db.close(); + }); + + test("reports applied busy progress when manifest finalization cannot complete", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + setBeforeHistoryBackupConsumeForTests(() => { + throw Object.assign(new Error("finalization busy"), { code: "EBUSY" }); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 1, files: 1, failed: true, failureReason: "busy" }); + expect(existsSync(fixture.backupPath)).toBe(true); + }); + + test("reports applied integrity progress for an unclassified finalization failure", () => { + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + setBeforeHistoryBackupConsumeForTests(() => { + throw new Error("finalization failed without a recoverable code"); + }); + + expect(syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath)) + .toMatchObject({ rows: 1, files: 1, failed: true, failureReason: "integrity" }); + expect(existsSync(fixture.backupPath)).toBe(true); + }); + test("explicitly recovers legacy opencodex user rows to openai", () => { const { dbPath, execRollout, legacyRollout } = makeFixture({ includeExec: true, includeLegacy: true }); @@ -341,9 +737,9 @@ describe("history lock retry", () => { }); test("syncCodexHistoryProvider reports why the retry budget died", () => { - // A pending opencodex row makes the eject path actually write; with no rows - // the restore transaction never starts and nothing contends. - const fixture = makeFixture({ includeLegacy: true }); + // Only manifest-backed work writes. Seed one real routed transition before holding SQLite. + const fixture = makeFixture(); + syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); const holder = new Database(fixture.dbPath); holder.exec("BEGIN IMMEDIATE"); try { @@ -396,6 +792,69 @@ describe("Design B migration helpers", () => { entries: { "thread-1": { id: "wrong-id", rolloutPath: "r", modelProvider: "openai", source: "cli", hasUserEvent: 1 } }, })); expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ kind: "unknown", reason: "manifest-schema" }); + rmSync(backupPath, { force: true }); + mkdirSync(backupPath); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ kind: "unknown", reason: "manifest-read" }); + }); + + test("strict no-op snapshots reject every invalid provenance shape", () => { + const dir = join(tmpdir(), `ocx-history-noop-schema-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "state_5.sqlite"); + const backupPath = historyBackupPathFor(dbPath); + const rolloutPath = join(dir, "rollout.jsonl"); + noopSnapshotArtifacts.add(backupPath); + noopSnapshotArtifacts.add(dir); + type MutableManifest = { + version: number; + stateDbPath?: unknown; + entries: Record; + }; + const cases: Array<{ name: string; mutate: (manifest: MutableManifest) => void }> = [ + { name: "blank stateDbPath", mutate: manifest => { manifest.stateDbPath = ""; } }, + { name: "relative stateDbPath", mutate: manifest => { manifest.stateDbPath = "state.sqlite"; } }, + { name: "mismatched id", mutate: manifest => { manifest.entries["thread-1"].id = "thread-2"; } }, + { name: "missing rolloutPath", mutate: manifest => { delete manifest.entries["thread-1"].rolloutPath; } }, + { name: "blank rolloutPath", mutate: manifest => { manifest.entries["thread-1"].rolloutPath = ""; } }, + { name: "relative rolloutPath", mutate: manifest => { manifest.entries["thread-1"].rolloutPath = "rollout.jsonl"; } }, + { name: "missing modelProvider", mutate: manifest => { delete manifest.entries["thread-1"].modelProvider; } }, + { name: "mistyped modelProvider", mutate: manifest => { manifest.entries["thread-1"].modelProvider = 7; } }, + { name: "unsupported modelProvider", mutate: manifest => { manifest.entries["thread-1"].modelProvider = "other"; } }, + { name: "missing source", mutate: manifest => { delete manifest.entries["thread-1"].source; } }, + { name: "mistyped source", mutate: manifest => { manifest.entries["thread-1"].source = 7; } }, + { name: "invalid provider/source tuple", mutate: manifest => { manifest.entries["thread-1"].source = "cli"; } }, + { name: "missing hasUserEvent", mutate: manifest => { delete manifest.entries["thread-1"].hasUserEvent; } }, + { name: "mistyped hasUserEvent", mutate: manifest => { manifest.entries["thread-1"].hasUserEvent = "0"; } }, + { name: "non-boolean hasUserEvent", mutate: manifest => { manifest.entries["thread-1"].hasUserEvent = 2; } }, + ]; + + for (const invalid of cases) { + const manifest: MutableManifest = { + version: 1, + stateDbPath: dbPath, + entries: { + "thread-1": { + id: "thread-1", + rolloutPath, + modelProvider: "opencodex", + source: "exec", + hasUserEvent: 0, + }, + }, + }; + invalid.mutate(manifest); + writeFileSync(backupPath, JSON.stringify(manifest)); + expect(snapshotCodexHistoryNoop(dbPath, backupPath), invalid.name) + .toMatchObject({ kind: "unknown", reason: "manifest-schema" }); + expect(countPendingOpencodexHistory(dbPath, backupPath), invalid.name) + .toEqual({ pendingRows: 0, backupEntries: 0, failed: true, failureReason: "integrity" }); + } }); test("a missing database with a valid nonempty manifest remains pending", () => { @@ -500,16 +959,21 @@ describe("Design B migration helpers", () => { expect(sleeps.length).toBe(0); }); - test("countPendingOpencodexHistory mirrors the eject predicate and reaches 0 after migration", () => { - const { dbPath, backupPath } = makeFixture({ includeExec: true, includeLegacy: true }); + test("countPendingOpencodexHistory excludes unknown-provenance routed rows from automatic work", () => { + const { dbPath, backupPath, execRollout, legacyRollout } = makeFixture({ includeExec: true, includeLegacy: true }); + const databaseBefore = readFileSync(dbPath); + const execBefore = readFileSync(execRollout); + const legacyBefore = readFileSync(legacyRollout); const before = countPendingOpencodexHistory(dbPath, backupPath); expect(before.failed).toBeUndefined(); - expect(before.pendingRows).toBe(2); // exec + legacy rows, both with non-empty first_user_message + expect(before).toEqual({ pendingRows: 0, backupEntries: 0 }); const migrated = migrateHistoryToOpenai(dbPath, backupPath); - expect(migrated.failed).toBeUndefined(); - expect((migrated.rows ?? 0) + (migrated.ejectedRows ?? 0)).toBeGreaterThan(0); + expect(migrated).toEqual({ rows: 0, files: 0 }); + expect(readFileSync(dbPath).equals(databaseBefore)).toBe(true); + expect(readFileSync(execRollout).equals(execBefore)).toBe(true); + expect(readFileSync(legacyRollout).equals(legacyBefore)).toBe(true); const after = countPendingOpencodexHistory(dbPath, backupPath); expect(after.pendingRows).toBe(0); @@ -521,6 +985,38 @@ describe("Design B migration helpers", () => { expect(again.ejectedRows ?? 0).toBe(0); }); + test("countPendingOpencodexHistory classifies changed manifest targets as integrity failures", () => { + const databaseFixture = makeFixture(); + syncCodexHistoryProvider("opencodex", databaseFixture.dbPath, databaseFixture.backupPath); + const changed = new Database(databaseFixture.dbPath); + changed.run("UPDATE threads SET model_provider = 'custom' WHERE id = 'thread-1'"); + changed.close(); + expect(countPendingOpencodexHistory(databaseFixture.dbPath, databaseFixture.backupPath)) + .toEqual({ pendingRows: 0, backupEntries: 1, failed: true, failureReason: "integrity" }); + + const rolloutFixture = makeFixture(); + syncCodexHistoryProvider("opencodex", rolloutFixture.dbPath, rolloutFixture.backupPath); + appendFileSync(rolloutFixture.rollout, JSON.stringify({ + type: "session_meta", + timestamp: "2026-03-01T00:00:00.000Z", + payload: { id: "thread-1", model_provider: "custom", source: "vscode" }, + }) + "\n"); + expect(countPendingOpencodexHistory(rolloutFixture.dbPath, rolloutFixture.backupPath)) + .toEqual({ pendingRows: 0, backupEntries: 1, failed: true, failureReason: "integrity" }); + expect(countPendingOpencodexHistory( + rolloutFixture.dbPath, + rolloutFixture.backupPath, + { validateRestoreTargets: false }, + )).toEqual({ pendingRows: 0, backupEntries: 1 }); + const canonicalBackupPath = historyBackupPathFor(rolloutFixture.dbPath); + noopSnapshotArtifacts.add(canonicalBackupPath); + writeFileSync(canonicalBackupPath, readFileSync(rolloutFixture.backupPath)); + expect(snapshotCodexHistoryNoop(rolloutFixture.dbPath, canonicalBackupPath)) + .toMatchObject({ kind: "work-pending", pendingRows: 0, backupEntries: 1 }); + expect(syncCodexHistoryProvider("openai", rolloutFixture.dbPath, rolloutFixture.backupPath)) + .toMatchObject({ rows: 0, files: 0, failed: true, failureReason: "integrity" }); + }); + test("countPendingOpencodexHistory returns zeros for a missing DB", () => { const missing = join(tmpdir(), `ocx-none-${Date.now()}`, "state_5.sqlite"); const result = countPendingOpencodexHistory(missing, join(tmpdir(), "no-backup.json")); @@ -541,15 +1037,20 @@ describe("Design B migration helpers", () => { expect(readFileSync(dbPath).equals(dbBefore)).toBe(true); }); - test("migrateHistoryToOpenai still migrates through the steady-state gate when work is pending", () => { - const { dbPath, backupPath } = makeFixture({ includeLegacy: true }); + test("migrateHistoryToOpenai restores only manifest-backed pending metadata", () => { + const { dbPath, backupPath } = makeFixture(); + syncCodexHistoryProvider("opencodex", dbPath, backupPath); const result = migrateHistoryToOpenai(dbPath, backupPath); expect(result.failed).toBeUndefined(); - expect(result.ejectedRows).toBe(1); - const db = new Database(dbPath); - expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-3'").get()).toEqual({ model_provider: "openai" }); + expect(result.rows).toBe(1); + const db = new Database(dbPath, { readonly: true }); + expect(db.query("SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-1'").get()).toEqual({ + model_provider: "openai", + source: "vscode", + has_user_event: 0, + }); db.close(); }); @@ -560,15 +1061,17 @@ describe("Design B migration helpers", () => { const backupPath = join(dir, "codex-history-backup.json"); writeFileSync(backupPath, JSON.stringify({ version: 1, + stateDbPath: missingDb, entries: { "thread-1": { id: "thread-1", rolloutPath: join(dir, "r.jsonl"), modelProvider: "openai", source: "cli", hasUserEvent: 1 } }, })); const pending = countPendingOpencodexHistory(missingDb, backupPath); expect(pending.backupEntries).toBe(1); // gate must see this and NOT report a provable no-op + expect(pending).toMatchObject({ failed: true, failureReason: "integrity" }); - // migrateHistoryToOpenai keeps its missing-DB early return (no crash, no manifest consumption). + // Work cannot converge without its bound database; the manifest remains retry evidence. const result = migrateHistoryToOpenai(missingDb, backupPath); - expect(result).toEqual({ rows: 0, files: 0 }); + expect(result).toEqual({ rows: 0, files: 0, failed: true, failureReason: "integrity" }); expect(existsSync(backupPath)).toBe(true); }); @@ -579,11 +1082,12 @@ describe("Design B migration helpers", () => { expect(skipped).toEqual({ rows: 0, files: 0 }); expect(readFileSync(steady.rollout, "utf8")).toBe(steadyBefore); - const pending = makeFixture({ includeLegacy: true }); + const pending = makeFixture(); + syncCodexHistoryProvider("opencodex", pending.dbPath, pending.backupPath); const restored = syncCodexHistoryProvider("openai", pending.dbPath, pending.backupPath, { skipWhenProvablyNoop: true }); - expect(restored.ejectedRows).toBe(1); - const db = new Database(pending.dbPath); - expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-3'").get()).toEqual({ model_provider: "openai" }); + expect(restored.rows).toBe(1); + const db = new Database(pending.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "openai" }); db.close(); }); }); diff --git a/tests/codex-history-worker-boundary.test.ts b/tests/codex-history-worker-boundary.test.ts index b29c345efa..a3e6aa8e6a 100644 --- a/tests/codex-history-worker-boundary.test.ts +++ b/tests/codex-history-worker-boundary.test.ts @@ -75,6 +75,26 @@ describe("the result validator", () => { expect(isPlausibleWorkerResultForTests( { requestId: "r", jobId: "OTHER", type: "done", outcome: "converged", rows: 3, files: 1 }, "r", "j", )).toBe(false); + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "error", message: "history_transition_failed", reason: "integrity" }, + "r", "j", + )).toBe(true); + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "error", message: "history_transition_failed", reason: "integrity", rows: 1, files: 2 }, + "r", "j", + )).toBe(true); + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "error", message: "history_transition_failed", reason: "integrity", rows: 1 }, + "r", "j", + )).toBe(false); + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "error", message: "history_transition_failed", reason: "integrity", rows: -1, files: 0 }, + "r", "j", + )).toBe(false); + expect(isPlausibleWorkerResultForTests( + { requestId: "r", jobId: "j", type: "error", message: "history_transition_failed", reason: "invented" }, + "r", "j", + )).toBe(false); const target = { canonicalStateDbPath: "/state/state_5.sqlite", canonicalBackupPath: "/state/state_5.sqlite.ocx-backup.json", @@ -120,4 +140,24 @@ describe("the result validator", () => { { requestId: "r", jobId: "j", type: "blocked", reason: "invented" }, "r", "j", )).toBe(false); }); + + test("the parent preserves partial progress from a valid worker error", async () => { + const { classifyWorkerResultForTests } = await import("../src/codex/history-job"); + expect(classifyWorkerResultForTests({ + requestId: "r", + jobId: "j", + type: "error", + message: "history_transition_failed", + reason: "integrity", + rows: 1, + files: 2, + })).toEqual({ + kind: "failed", + reason: "worker-error", + message: "history_transition_failed", + historyFailureReason: "integrity", + rows: 1, + files: 2, + }); + }); }); diff --git a/tests/codex-history-worker.test.ts b/tests/codex-history-worker.test.ts index 4c09c7299c..f4d5427df0 100644 --- a/tests/codex-history-worker.test.ts +++ b/tests/codex-history-worker.test.ts @@ -1,11 +1,11 @@ import { afterEach, expect, setDefaultTimeout, test } from "bun:test"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; -import { historyBackupPathFor, setHistoryDbBusyTimeoutForTests } from "../src/codex/history-provider"; +import { historyBackupPathFor, setBeforeHistoryBackupConsumeForTests, setHistoryDbBusyTimeoutForTests } from "../src/codex/history-provider"; import { isHistoryWorkerRunMessage, runHistoryUnitUnderLock, @@ -22,6 +22,7 @@ const sandboxes: string[] = []; const backupArtifacts: string[] = []; afterEach(() => { + setBeforeHistoryBackupConsumeForTests(undefined); for (const root of sandboxes.splice(0)) rmSync(root, { recursive: true, force: true }); for (const path of backupArtifacts.splice(0)) rmSync(path, { force: true }); }); @@ -154,10 +155,8 @@ test("migrate-openai returns a verified no-op only after entering H", () => { const fixture = makeFixture("ocx-history-worker-noop-"); const backup = historyBackupPathFor(fixture.stateDb); backupArtifacts.push(backup); - const db = new Database(fixture.stateDb); - db.run("UPDATE threads SET model_provider = 'openai', source = 'cli' WHERE id = 'thread-1'"); - db.close(); - const before = readFileSync(fixture.rollout, "utf8"); + const databaseBefore = readFileSync(fixture.stateDb); + const rolloutBefore = readFileSync(fixture.rollout); const result = runHistoryUnitUnderLock(runMessage(fixture, { operation: "migrate-openai", @@ -179,8 +178,142 @@ test("migrate-openai returns a verified no-op only after entering H", () => { backupPresent: false, }, }); - expect(readFileSync(fixture.rollout, "utf8")).toBe(before); + expect(readFileSync(fixture.stateDb).equals(databaseBefore)).toBe(true); + expect(readFileSync(fixture.rollout).equals(rolloutBefore)).toBe(true); + expect(existsSync(backup)).toBe(false); +}); + +test("restore-openai leaves bare routed history byte-identical", () => { + const fixture = makeFixture("ocx-history-worker-restore-noop-"); + const databaseBefore = readFileSync(fixture.stateDb); + const rolloutBefore = readFileSync(fixture.rollout); + + const result = runHistoryUnitUnderLock(runMessage(fixture, { operation: "restore-openai" })); + + expect(result).toMatchObject({ type: "done", outcome: "converged", rows: 0, files: 0 }); + expect(readFileSync(fixture.stateDb).equals(databaseBefore)).toBe(true); + expect(readFileSync(fixture.rollout).equals(rolloutBefore)).toBe(true); + expect(existsSync(fixture.backup)).toBe(false); +}); + +test("manifest-backed restore preserves routed provenance and the next migrate is a verified no-op", () => { + const fixture = makeFixture("ocx-history-worker-exact-"); + const backup = historyBackupPathFor(fixture.stateDb); + backupArtifacts.push(backup); + mkdirSync(dirname(backup), { recursive: true }); + const db = new Database(fixture.stateDb); + db.run("UPDATE threads SET source = 'cli', has_user_event = 1 WHERE id = 'thread-1'"); + db.close(); + appendFileSync(fixture.rollout, `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-05T00:00:00.000Z", + payload: { id: "thread-1", model_provider: "opencodex", source: "cli" }, + })}\n`); + writeFileSync(backup, JSON.stringify({ + version: 1, + stateDbPath: fixture.stateDb, + entries: { + "thread-1": { + id: "thread-1", + rolloutPath: fixture.rollout, + modelProvider: "opencodex", + source: "exec", + hasUserEvent: 0, + }, + }, + })); + + const restored = runHistoryUnitUnderLock(runMessage(fixture, { + operation: "restore-openai", + canonicalBackupPath: backup, + })); + expect(restored).toMatchObject({ type: "done", outcome: "converged", rows: 1, files: 1 }); + const restoredDb = new Database(fixture.stateDb, { readonly: true }); + expect(restoredDb.query("SELECT model_provider, source, has_user_event FROM threads WHERE id = 'thread-1'").get()) + .toEqual({ model_provider: "opencodex", source: "exec", has_user_event: 0 }); + restoredDb.close(); + expect(JSON.parse(readFileSync(fixture.rollout, "utf8").trim().split("\n").at(-1)!).payload) + .toMatchObject({ id: "thread-1", model_provider: "opencodex", source: "exec" }); expect(existsSync(backup)).toBe(false); + + const again = runHistoryUnitUnderLock(runMessage(fixture, { + operation: "migrate-openai", + canonicalBackupPath: backup, + })); + expect(again).toMatchObject({ + type: "done", + outcome: "converged", + rows: 0, + files: 0, + proof: { kind: "verified-noop", pendingRows: 0, backupEntries: 0 }, + }); +}); + +test("a late permission failure reports already-applied row and file progress", () => { + const fixture = makeFixture("ocx-history-worker-partial-"); + const backup = historyBackupPathFor(fixture.stateDb); + backupArtifacts.push(backup); + mkdirSync(dirname(backup), { recursive: true }); + const db = new Database(fixture.stateDb); + db.run("UPDATE threads SET source = 'cli', has_user_event = 1 WHERE id = 'thread-1'"); + db.close(); + appendFileSync(fixture.rollout, `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-05T00:00:00.000Z", + payload: { id: "thread-1", model_provider: "opencodex", source: "cli" }, + })}\n`); + writeFileSync(backup, JSON.stringify({ + version: 1, + stateDbPath: fixture.stateDb, + entries: { + "thread-1": { + id: "thread-1", + rolloutPath: fixture.rollout, + modelProvider: "opencodex", + source: "exec", + hasUserEvent: 0, + }, + }, + })); + setBeforeHistoryBackupConsumeForTests(() => { + throw Object.assign(new Error("manifest finalization denied"), { code: "EPERM" }); + }); + + const result = runHistoryUnitUnderLock(runMessage(fixture, { + operation: "restore-openai", + canonicalBackupPath: backup, + })); + + expect(result).toMatchObject({ + type: "error", + reason: "permission", + rows: 1, + files: 1, + }); + expect(existsSync(backup)).toBe(true); +}); + +test("malformed manifest blocks restore without changing the database, rollout, or manifest", () => { + const fixture = makeFixture("ocx-history-worker-malformed-"); + const backup = historyBackupPathFor(fixture.stateDb); + backupArtifacts.push(backup); + mkdirSync(dirname(backup), { recursive: true }); + writeFileSync(backup, JSON.stringify({ + version: 1, + stateDbPath: fixture.stateDb, + entries: { "thread-1": { id: "thread-1", rolloutPath: fixture.rollout } }, + })); + const databaseBefore = readFileSync(fixture.stateDb); + const rolloutBefore = readFileSync(fixture.rollout); + const manifestBefore = readFileSync(backup); + + expect(runHistoryUnitUnderLock(runMessage(fixture, { + operation: "restore-openai", + canonicalBackupPath: backup, + }))).toMatchObject({ type: "error", reason: "integrity" }); + expect(readFileSync(fixture.stateDb).equals(databaseBefore)).toBe(true); + expect(readFileSync(fixture.rollout).equals(rolloutBefore)).toBe(true); + expect(readFileSync(backup).equals(manifestBefore)).toBe(true); }); /** diff --git a/tests/codex-inject-history-wording.test.ts b/tests/codex-inject-history-wording.test.ts index a42e8e308d..ab867a6767 100644 --- a/tests/codex-inject-history-wording.test.ts +++ b/tests/codex-inject-history-wording.test.ts @@ -1,13 +1,23 @@ import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { failedHistoryRestoreFromOutcome, formatApplyHistoryFailure, } from "../src/codex/inject"; +const injectSource = readFileSync(resolve(import.meta.dir, "../src/codex/inject.ts"), "utf8"); +const doctorSource = readFileSync(resolve(import.meta.dir, "../src/cli/doctor.ts"), "utf8"); +const cliSource = readFileSync(resolve(import.meta.dir, "../src/cli/index.ts"), "utf8"); +const integrationGuide = readFileSync( + resolve(import.meta.dir, "../docs-site/src/content/docs/guides/codex-integration.md"), + "utf8", +); + test("apply keeps the deferred headline for busy outcomes from either half", () => { const blockedBusy = { kind: "blocked", reason: "busy" } as const; - expect(formatApplyHistoryFailure(blockedBusy, false)).toContain("migration deferred"); + expect(formatApplyHistoryFailure(blockedBusy, false)).toContain("metadata restore deferred"); expect(formatApplyHistoryFailure(blockedBusy, false)).toContain("history DB is locked"); const workerBusy = { @@ -16,7 +26,7 @@ test("apply keeps the deferred headline for busy outcomes from either half", () message: "database is locked", historyFailureReason: "busy", } as const; - expect(formatApplyHistoryFailure(workerBusy, false)).toContain("migration deferred"); + expect(formatApplyHistoryFailure(workerBusy, false)).toContain("metadata restore deferred"); expect(formatApplyHistoryFailure(workerBusy, false)).toContain("retried automatically"); }); @@ -28,6 +38,15 @@ test("apply says NOT changed for non-busy failures", () => { const workerError = { kind: "failed", reason: "worker-error", message: "unable to open database file" } as const; expect(formatApplyHistoryFailure(workerError, false)).toContain("NOT changed"); expect(formatApplyHistoryFailure(workerError, false)).toContain("unable to open database file"); + + const partial = { + kind: "failed", reason: "worker-error", message: "history_transition_failed", + historyFailureReason: "integrity", rows: 1, files: 1, + } as const; + expect(formatApplyHistoryFailure(partial, false)).toContain("changed but did not converge"); + expect(formatApplyHistoryFailure(partial, false)).not.toContain("NOT changed"); + expect(formatApplyHistoryFailure(partial, true)).toContain("changed but did not converge"); + expect(formatApplyHistoryFailure(partial, true)).not.toContain("sync SKIPPED"); }); test("restore blames the Codex app only for genuine busy reasons", () => { @@ -40,7 +59,14 @@ test("restore blames the Codex app only for genuine busy reasons", () => { message: "database is locked", historyFailureReason: "busy", } as const; - expect(failedHistoryRestoreFromOutcome(workerBusy).message).toContain("holding the history database"); + expect(failedHistoryRestoreFromOutcome(workerBusy).message).toContain("history state is busy"); + expect(failedHistoryRestoreFromOutcome(workerBusy).message).not.toContain("holding the history database"); + const partialBusy = failedHistoryRestoreFromOutcome({ ...workerBusy, rows: 1, files: 1 }); + expect(partialBusy.changed).toBe(true); + expect(partialBusy.rows).toBe(1); + expect(partialBusy.files).toBe(1); + expect(partialBusy.message).toContain("finalization remained busy"); + expect(partialBusy.message).toContain("manifest was retained"); }); test("restore names other reasons instead of a lock", () => { @@ -56,9 +82,62 @@ test("restore names other reasons instead of a lock", () => { historyFailureReason: "permission", } as const; expect(failedHistoryRestoreFromOutcome(permission).message).toContain("permission was denied"); + const partialPermission = failedHistoryRestoreFromOutcome({ ...permission, rows: 1, files: 1 }); + expect(partialPermission.changed).toBe(true); + expect(partialPermission.rows).toBe(1); + expect(partialPermission.files).toBe(1); + expect(partialPermission.message).toContain("permission was denied"); + expect(partialPermission.message).toContain("manifest was retained"); const workerError = { kind: "failed", reason: "worker-error", message: "unable to open database file" } as const; const workerMessage = failedHistoryRestoreFromOutcome(workerError).message; expect(workerMessage).toContain("unable to open database file"); expect(workerMessage).not.toContain("holding the history database"); + + const integrity = { + kind: "failed", + reason: "worker-error", + message: "history_transition_failed", + historyFailureReason: "integrity", + } as const; + const integrityResult = failedHistoryRestoreFromOutcome(integrity); + expect(integrityResult.reason).toBe("integrity"); + expect(integrityResult.message).toContain("failed integrity checks"); + expect(integrityResult.message).not.toContain("holding the history database"); + + const partialIntegrity = failedHistoryRestoreFromOutcome({ ...integrity, rows: 1, files: 1 }); + expect(partialIntegrity.changed).toBe(true); + expect(partialIntegrity.rows).toBe(1); + expect(partialIntegrity.files).toBe(1); + expect(partialIntegrity.message).toContain("changed but did NOT converge"); + expect(partialIntegrity.message).toContain("manifest was retained"); +}); + +test("success and no-op surfaces describe exact manifest restoration without provider assumptions", () => { + expect(injectSource).toContain("changed: rawHistory.rows > 0 || rawHistory.files > 0"); + expect(injectSource).toContain("restored original provider metadata for ${migratedRows} manifest-backed thread(s)"); + expect(injectSource).toContain("original providers preserved"); + expect(injectSource).toContain("No backed-up resume-history metadata was pending; untracked routed history was left unchanged."); + expect(injectSource).not.toContain("migrated back to openai"); + expect(injectSource).not.toContain("Codex resume history was already native"); +}); + +test("doctor distinguishes zero, pending, retryable, and integrity restore states", () => { + expect(doctorSource).toContain("no manifest-backed provider metadata pending; untracked routed history is unchanged"); + expect(doctorSource).toContain("backup manifest entr${pending.backupEntries === 1 ? \"y\" : \"ies\"} pending exact metadata restore"); + expect(doctorSource).toContain("history database, backup manifest, or rollout file is busy — exact metadata restore is pending"); + expect(doctorSource).toContain("backup manifest or restore target failed integrity checks — manual review required"); + expect(doctorSource).toContain("do not repeatedly run 'ocx sync' until the mismatch is understood"); + expect(doctorSource).toContain("Untracked routed history is not relabeled."); + expect(doctorSource).not.toContain("no legacy opencodex-tagged threads pending"); +}); + +test("legacy recovery surfaces its full destructive scope before execution", () => { + expect(cliSource).toContain("every user-message opencodex row"); + expect(integrationGuide).toContain("every thread"); + expect(integrationGuide).toContain("currently tagged `opencodex`"); + expect(cliSource).toContain("dedicated-provider history"); + expect(integrationGuide).toContain("dedicated-provider history"); + expect(cliSource).toContain("normalizes exec to cli"); + expect(integrationGuide).toContain("normalizes `exec` to `cli`"); }); diff --git a/tests/codex-native-residue.test.ts b/tests/codex-native-residue.test.ts index 5fd0fb52e3..f7408bf12e 100644 --- a/tests/codex-native-residue.test.ts +++ b/tests/codex-native-residue.test.ts @@ -166,11 +166,6 @@ const residueFixtures: Array<{ timestamp: "2026-08-04T00:00:00.000Z", })), }, - { - name: "history database row", - surface: "history", - arrange: () => createHistoryDatabase("opencodex"), - }, { name: "history backup entry", surface: "history-backup", @@ -203,6 +198,16 @@ for (const fixture of residueFixtures) { }); } +test("a bare routed history row and its matching rollout are not managed residue", () => { + createHistoryDatabase("opencodex"); + + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); + expect(readCodexTransitionState()).toMatchObject({ + kind: "ready", + state: { nativeGeneration: 0, currentTxId: null }, + }); +}); + test("an OpenCodex atomic-write artifact is indeterminate", () => { writeFileSync(pathInCodexHome("config.toml.ocx.123.1.tmp"), "partial"); expect(classifyNativeRoutedResidue()).toMatchObject({ @@ -830,6 +835,70 @@ test("a missing manifest-referenced rollout is indeterminate", () => { }); }); +type MutableHistoryBackupFixture = { + version: number; + stateDbPath?: unknown; + entries: Record; +}; + +const invalidHistoryBackupFixtures: Array<{ + name: string; + mutate: (manifest: MutableHistoryBackupFixture) => void; +}> = [ + { name: "missing state database identity", mutate: manifest => { delete manifest.stateDbPath; } }, + { name: "blank state database identity", mutate: manifest => { manifest.stateDbPath = ""; } }, + { name: "relative state database identity", mutate: manifest => { manifest.stateDbPath = "state.sqlite"; } }, + { name: "mismatched entry id", mutate: manifest => { manifest.entries["thread-1"].id = "thread-2"; } }, + { name: "missing rollout path", mutate: manifest => { delete manifest.entries["thread-1"].rolloutPath; } }, + { name: "blank rollout path", mutate: manifest => { manifest.entries["thread-1"].rolloutPath = ""; } }, + { name: "relative rollout path", mutate: manifest => { manifest.entries["thread-1"].rolloutPath = "rollout.jsonl"; } }, + { name: "missing model provider", mutate: manifest => { delete manifest.entries["thread-1"].modelProvider; } }, + { name: "mistyped model provider", mutate: manifest => { manifest.entries["thread-1"].modelProvider = 7; } }, + { name: "unsupported model provider", mutate: manifest => { manifest.entries["thread-1"].modelProvider = "other"; } }, + { name: "missing source", mutate: manifest => { delete manifest.entries["thread-1"].source; } }, + { name: "mistyped source", mutate: manifest => { manifest.entries["thread-1"].source = 7; } }, + { name: "invalid provider/source tuple", mutate: manifest => { manifest.entries["thread-1"].modelProvider = "opencodex"; } }, + { name: "missing event marker", mutate: manifest => { delete manifest.entries["thread-1"].hasUserEvent; } }, + { name: "mistyped event marker", mutate: manifest => { manifest.entries["thread-1"].hasUserEvent = "1"; } }, + { name: "non-boolean event marker", mutate: manifest => { manifest.entries["thread-1"].hasUserEvent = 2; } }, +]; + +for (const fixture of invalidHistoryBackupFixtures) { + test(`a history backup with ${fixture.name} is indeterminate and not adopted`, () => { + writeFileSync(pathInCodexHome("rollout.jsonl"), sessionMeta("thread-1", "opencodex") + "\n"); + const manifest: MutableHistoryBackupFixture = { + version: 1, + stateDbPath: join(realpathSync.native(codexHome), "state_5.sqlite"), + entries: { + "thread-1": { + id: "thread-1", + rolloutPath: pathInCodexHome("rollout.jsonl"), + modelProvider: "openai", + source: "cli", + hasUserEvent: 1, + }, + }, + }; + fixture.mutate(manifest); + writeFileSync(historyBackupPath(), JSON.stringify(manifest)); + + expect(classifyNativeRoutedResidue()).toMatchObject({ + kind: "indeterminate", + surface: "history-backup", + }); + expect(readCodexTransitionState()).toEqual({ + kind: "legacy-ambiguous", + message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", + }); + }); +} + const indeterminateFixtures: Array<{ name: string; surface: string; diff --git a/tests/history-migration-guardian.test.ts b/tests/history-migration-guardian.test.ts index d54ee015ed..65b90cc7ed 100644 --- a/tests/history-migration-guardian.test.ts +++ b/tests/history-migration-guardian.test.ts @@ -52,7 +52,7 @@ describe("history migration guardian", () => { attempts++; return attempts < 3 ? { rows: 0, files: 0, failed: true as const } - : { rows: 2, files: 2, ejectedRows: 1 }; + : { rows: 2, files: 2 }; }, log: { log: (msg: string) => logs.push(msg) }, scheduleFn: sched.scheduleFn, @@ -62,7 +62,7 @@ describe("history migration guardian", () => { expect(await sched.runNext()).toBe(true); // tick 2: locked expect(await sched.runNext()).toBe(true); // tick 3: success expect(attempts).toBe(3); - expect(logs.some(l => l.includes("3 legacy opencodex thread(s) migrated"))).toBe(true); + expect(logs.some(l => l.includes("restored original provider metadata for 2 manifest-backed thread(s)"))).toBe(true); expect(sched.size).toBe(0); // stopped after success }); @@ -80,6 +80,7 @@ describe("history migration guardian", () => { expect(await sched.runNext()).toBe(true); expect(sched.size).toBe(0); // budget exhausted — no reschedule expect(logs.some(l => l.includes("Could not verify"))).toBe(true); + expect(logs.some(l => l.includes("backed-up provider metadata"))).toBe(true); expect(logs.some(l => l.includes("stayed locked"))).toBe(false); }); diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index 78fa26e71a..c21cdf92fc 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -227,21 +227,23 @@ esac 60_000, ); - test("both update paths surface a skipped history restore after the stop", () => { - // A codex-history-backup-*.json surviving `ocx stop` means the native-history restore - // was skipped (locked state DB) — users must be told or their threads silently stay - // hidden in the Codex app. + test("both update paths surface an incomplete manifest-backed history restore after the stop", () => { + // A codex-history-backup-*.json surviving `ocx stop` means exact metadata restoration + // remains pending. It can be contention or an integrity refusal, so neither update path + // may claim a DB lock or that every routed thread is hidden. expect(updateSource).toContain("export function historyRestoreIncomplete("); expect(updateSource).toContain('name.startsWith("codex-history-backup-") && name.endsWith(".json")'); expect(updateSource).toContain("if (historyRestoreIncomplete())"); expect(launcherSource).toContain("function historyRestoreIncomplete()"); expect(launcherSource).toContain('name.startsWith("codex-history-backup-") && name.endsWith(".json")'); expect(launcherSource).toContain("if (historyRestoreIncomplete())"); - const warnAt = launcherSource.indexOf("Codex resume history was NOT restored"); + const warnAt = launcherSource.indexOf("Codex resume-history metadata restore is incomplete"); const installAt = launcherSource.indexOf("transactionalNpmUpdate({"); expect(warnAt).toBeGreaterThan(-1); expect(installAt).toBeGreaterThan(-1); expect(warnAt).toBeLessThan(installAt); + expect(updateSource).toContain("manifest/target may need review"); + expect(launcherSource).toContain("untracked routed history is intentionally unchanged"); }); test("the stop gate covers service-managed and orphaned proxies whose pid file is stale/missing", () => {