chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:29 +08:00
commit d03c025041
70 changed files with 14796 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
name: Bug 报告
description: 工具本身报错、崩溃、同步结果异常时使用
title: "bug: "
labels:
- bug
- needs-info
body:
- type: markdown
attributes:
value: |
请尽量完整填写。缺少版本、系统、状态输出或日志时,通常无法判断是工具问题、Codex Desktop 问题,还是 encrypted_content / SQLite 损坏等边界情况。
- type: input
id: version
attributes:
label: codex-provider-sync 版本
description: 例如 v0.2.4。GUI 版本请填写 Release 版本;CLI 版本请填写 `npm list -g codex-provider-sync` 或安装来源。
placeholder: v0.2.4
validations:
required: true
- type: dropdown
id: entrypoint
attributes:
label: 使用方式
options:
- Windows GUI EXE
- npm 全局 CLI
- 仓库源码运行
- 其它
validations:
required: true
- type: input
id: os
attributes:
label: 系统版本
placeholder: Windows 11 24H2 / Windows 10 / macOS 15.x
validations:
required: true
- type: input
id: codex
attributes:
label: Codex 使用方式和版本
description: 例如 Codex Desktop / Codex CLI,以及版本号或大概更新时间。
placeholder: Codex Desktop,版本未知
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤
description: 从 provider 切换、Refresh、Execute、sync/switch 命令开始写,尽量按时间顺序。
placeholder: |
1. 关闭 Codex Desktop
2. 切换 provider 到 ...
3. 打开 CodexProviderSync.exe,点击 Refresh
4. 点击 Execute 后出现 ...
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际结果
description: 报错文本、截图文字、终端输出都可以贴这里。
validations:
required: true
- type: textarea
id: expected
attributes:
label: 预期结果
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: status / GUI Refresh 诊断文本
description: 优先粘贴完整输出。CLI 可运行 `codex-provider status`GUI 请复制 Refresh 后状态区文本。
render: text
validations:
required: true
- type: textarea
id: logs
attributes:
label: 日志或错误堆栈
description: EXE 启动问题请贴 `%AppData%\codex-provider-sync\startup-error.log`CLI 问题请贴终端完整输出。
render: text
validations:
required: false
- type: checkboxes
id: boundary
attributes:
label: 确认事项
options:
- label: 我理解本工具只同步可见性 metadata,不处理登录、认证或 auth.json。
required: true
- label: 我理解含 encrypted_content 的历史会话跨 provider/account 后可能不能继续对话。
required: true
- label: 我理解本工具不会修改 updated_at,也不会通过改变排序来修复 Desktop 显示。
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 下载最新版本
url: https://github.com/Dailin521/codex-provider-sync/releases/latest
about: 先确认你正在使用最新版本。
- name: 阅读中文 README
url: https://github.com/Dailin521/codex-provider-sync/blob/main/README.md
about: 查看 sync / switch / restore 的区别和工具能力边界。
@@ -0,0 +1,85 @@
name: Desktop 历史会话不显示
description: CLI 能看到,但 Codex Desktop 项目侧显示“暂无对话”或历史会话缺失时使用
title: "desktop: "
labels:
- upstream-desktop
- needs-info
body:
- type: markdown
attributes:
value: |
这类问题经常和 Codex Desktop 自身的项目筛选、分页加载、cwd 路径匹配或 SQLite 状态有关。请完整提供诊断文本,不要只贴“暂无对话”截图。
- type: input
id: version
attributes:
label: codex-provider-sync 版本
placeholder: v0.2.4
validations:
required: true
- type: input
id: os
attributes:
label: 系统版本
placeholder: Windows 11 24H2
validations:
required: true
- type: input
id: provider
attributes:
label: 当前 provider
description: 填写你希望 Desktop 当前显示历史会话的 provider,例如 openai / dal / apigather。
placeholder: dal
validations:
required: true
- type: input
id: project_path
attributes:
label: 不显示历史的项目路径
placeholder: D:\GitHubProject\oss-maintainer-hub
validations:
required: true
- type: dropdown
id: cli_visible
attributes:
label: CLI 是否能看到这些历史会话
options:
- 是,codex resume 能看到
- 否,CLI 也看不到
- 不确定
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: status / GUI Refresh 完整诊断文本
description: 必填。需要包含 Current provider、Rollout files、SQLite state、Project visibility diagnostics。
render: text
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: Desktop 截图或现象说明
description: 可贴项目侧“暂无对话”、Recent、CLI resume 的截图。
validations:
required: false
- type: textarea
id: sync_steps
attributes:
label: 你执行过的同步步骤
placeholder: |
1. 关闭 Codex Desktop
2. 切换 provider 到 ...
3. 运行 sync / switch ...
4. 重启 Desktop 后 ...
validations:
required: true
- type: checkboxes
id: boundary
attributes:
label: 确认事项
options:
- label: 我理解 CLI 可见但 Desktop 不显示时,可能是 Codex Desktop 上游显示逻辑问题。
required: true
- label: 我理解本工具不会修改 updated_at,也不会通过改变排序来让旧会话进入前排。
required: true
+60
View File
@@ -0,0 +1,60 @@
name: 使用问题 / 切换咨询
description: 不确定该运行 sync、switch、restore,或需要确认工具边界时使用
title: "support: "
labels:
- support
- needs-info
body:
- type: markdown
attributes:
value: |
如果是崩溃、报错或明确 bug,请优先使用 Bug 报告模板。如果是 Codex Desktop 项目侧不显示历史会话,请使用 Desktop 历史会话不显示模板。
- type: input
id: version
attributes:
label: codex-provider-sync 版本
placeholder: v0.2.4
validations:
required: true
- type: dropdown
id: goal
attributes:
label: 你想做什么
options:
- 当前 provider 不变,只恢复历史会话可见性
- 切换 provider 并同步历史会话
- 从备份恢复
- 不确定应该怎么选
validations:
required: true
- type: input
id: current_provider
attributes:
label: 当前 provider
placeholder: openai / dal / apigather
validations:
required: false
- type: textarea
id: diagnostics
attributes:
label: status / GUI Refresh 诊断文本
description: 有输出请完整粘贴;没有输出请说明卡在哪一步。
render: text
validations:
required: false
- type: textarea
id: question
attributes:
label: 具体问题
placeholder: 描述你现在的状态、执行过什么、想达到什么结果。
validations:
required: true
- type: checkboxes
id: boundary
attributes:
label: 确认事项
options:
- label: 我理解 sync 不会切换登录账号,也不会处理 auth.json。
required: true
- label: 我理解 encrypted_content 跨 provider/account 后可能不能继续对话。
required: true
+25
View File
@@ -0,0 +1,25 @@
name: ci
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
node-version:
- "16"
- "24"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test
+75
View File
@@ -0,0 +1,75 @@
name: publish
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- run: npm ci
- run: npm test
- run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj
- name: Build Windows GUI
shell: pwsh
run: ./scripts/publish-gui.ps1 -Output artifacts/win-x64
- name: Package release assets
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ github.ref_name }}".TrimStart("v")
$assetRoot = "artifacts/release"
New-Item -ItemType Directory -Force -Path $assetRoot | Out-Null
$exe = "artifacts/win-x64/CodexProviderSync.exe"
$zip = Join-Path $assetRoot "codex-provider-sync-v$version-win-x64.zip"
Compress-Archive -Path "artifacts/win-x64/*" -DestinationPath $zip -Force
Copy-Item $exe (Join-Path $assetRoot "CodexProviderSync.exe") -Force
$assets = Get-ChildItem -File $assetRoot | Sort-Object Name
$checksums = foreach ($asset in $assets) {
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset.FullName).Hash.ToLowerInvariant()
"$hash $($asset.Name)"
Set-Content -LiteralPath "$($asset.FullName).sha256" -Value "$hash $($asset.Name)" -Encoding ASCII
}
Set-Content -LiteralPath (Join-Path $assetRoot "checksums.txt") -Value $checksums -Encoding ASCII
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/release/*
body: |
## Windows SmartScreen 提示
当前 Windows GUI 未做代码签名,首次运行时可能提示“发布者未知”或“Windows 已保护你的电脑”。这是未签名 EXE 的常见提示。
请确认下载来源是本项目 GitHub Releases,并可用随附的 SHA256 文件校验后运行。
## 安全边界
- 不会修改 auth.json
- 不会改写对话正文或消息历史
- 不会修改 updated_at 来改变排序
- 不会重新加密 encrypted_content
## 回滚
可使用 `codex-provider restore <backup-dir>` 恢复本工具创建的备份。
## 已知事项
含 encrypted_content 的历史会话跨 provider/account 后,通常只能恢复列表可见性;继续对话或 compact 仍可能失败。
+9
View File
@@ -0,0 +1,9 @@
node_modules/
coverage/
*.tgz
artifacts/
desktop/**/bin/
desktop/**/obj/
*.csproj.user
test/*.exe
WORKLOG*.md
+169
View File
@@ -0,0 +1,169 @@
# AI Operator Guide
This file is for AI assistants, coding agents, and automation tools.
## Goal
Help the user make historical Codex sessions visible again after switching `model_provider`.
For normal Windows users, prefer the GUI app when it is available. Use the CLI when:
- the user explicitly wants commands
- the task is automated
- the GUI EXE is unavailable
The tool works by updating both:
- rollout metadata under `~/.codex/sessions` and `~/.codex/archived_sessions`
- SQLite thread metadata in the detected Codex state database, normally
`~/.codex/sqlite/state_5.sqlite` with legacy fallback to
`~/.codex/state_5.sqlite`
Do not solve this by manually editing rollout files only unless the user explicitly asks for manual intervention.
## Preferred Flow
Use this order by default:
1. If the GUI is available and the user is not asking for terminal commands, open `CodexProviderSync.exe`
2. Refresh and inspect the current provider plus rollout/SQLite distribution
3. Decide whether the user needs sync, switch-like behavior, or restore
4. Execute the action
5. Report whether the result is complete or partially skipped due to locked files
CLI fallback flow:
1. Run `codex-provider status`
2. Read `Current provider`, the displayed SQLite database path, and compare rollout/SQLite distribution
3. Decide whether the user needs `sync`, `switch`, or `restore`
4. Run the command
5. Report whether the result is complete or partially skipped due to locked files
## Command Selection
Use `codex-provider sync` when:
- the user already switched auth/provider using another tool
- the current `config.toml` root `model_provider` is already correct
- the user says things like:
- "make my old sessions visible again"
- "resync my Codex history"
- "I already switched provider"
Use `codex-provider switch <provider-id>` when:
- the user wants to change the root `model_provider`
- the user wants one command to both switch provider and resync history
Use `codex-provider restore <backup-dir>` when:
- the user wants to roll back a previous sync
- the user synced to the wrong provider
Use `codex-provider status` only when:
- the user asks for inspection only
- you need a safe first step before deciding what to do
## GUI Selection
Use the GUI app when:
- the user wants a double-click tool
- the user does not want to install Node/npm
- the user wants to visually inspect providers and backups
GUI mapping:
- `Refresh` = inspect current status
- `Execute` without config checkbox = `sync --provider <selected>`
- `Execute` with config checkbox = switch-like behavior
- `Restore Backup` = restore a previous backup
- backup retention defaults to 5 and can be customized in the GUI
- `Clean Old Backups` = prune managed backups down to the selected retention count
## Important Behavior
- `sync` uses the current root `model_provider` from `~/.codex/config.toml`
- if root `model_provider` is missing, `sync` falls back to `openai`
- `switch` changes root `model_provider`, then runs a sync
- built-in `openai` is always valid
- custom providers must already exist in `config.toml`
- the tool does not log the user in and does not manage `auth.json`
- sync and switch create a backup first, then automatically prune older managed backups
- backup pruning only touches backups created by this tool under `backups_state/provider-sync`
## Error Handling
If the output says `state_5.sqlite is currently in use`:
- tell the user to close Codex, Codex App, and app-server
- then rerun the same command
If sync reports `Skipped locked rollout files`:
- treat the sync as mostly successful
- explain that the active session still holds one or more rollout files open
- tell the user to rerun `codex-provider sync` after that session ends if they want a full rewrite
If `switch <provider-id>` fails because the provider is missing:
- tell the user to define it in `config.toml` or switch via their existing provider tool first
- then run `codex-provider sync`
## Safe Defaults
- default Codex home: `~/.codex`
- detect the SQLite DB before reasoning about SQLite counts; recent Codex uses
`~/.codex/sqlite/state_5.sqlite`, while older layouts may use
`~/.codex/state_5.sqlite`
- prefer `status` before destructive-looking operations, even though this tool only edits metadata
- by default the tool keeps the most recent 5 managed backups
- use GUI retention settings or CLI `--keep <n>` when the user wants a different retention count
- do not edit `state_5.sqlite` or rollout files manually if the tool can do it
- GUI settings live in `%AppData%\codex-provider-sync\settings.json`
## Recommended Commands
```bash
codex-provider status
codex-provider sync
codex-provider sync --keep 5
codex-provider sync --provider openai
codex-provider switch apigather
codex-provider prune-backups --keep 5
codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\20260319T042708906Z
```
With an explicit Codex home:
```bash
codex-provider status --codex-home C:\Users\you\.codex
codex-provider sync --codex-home C:\Users\you\.codex
codex-provider switch openai --codex-home C:\Users\you\.codex
```
## One-Shot Prompt Template
Use this prompt in another AI tool if the user wants one-step handling:
```text
I use codex-provider-sync. Please help me fix Codex session visibility under my current provider.
Steps:
1. Run `codex-provider status`.
2. If my current provider is already correct, run `codex-provider sync`.
3. If I explicitly tell you to switch provider, run `codex-provider switch <provider-id>` instead.
4. If SQLite is locked, tell me to close Codex / Codex App / app-server and retry.
5. If rollout files are skipped because they are locked, tell me which ones were skipped and remind me to rerun sync later.
6. Summarize the final state of rollout files and SQLite after the command finishes.
```
## User-Facing Summary Style
When reporting results back to the user:
- state the current provider
- state whether rollout files and SQLite are aligned
- mention backup location if a sync or switch was executed
- call out partial success clearly if locked rollout files were skipped
+69
View File
@@ -0,0 +1,69 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "desktop", "desktop", "{7A1224A0-F22D-34FF-DE0A-A058FAFFD90C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.App", "desktop\CodexProviderSync.App\CodexProviderSync.App.csproj", "{046C07E6-8117-4F19-B2CF-683D62D944C9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.Mac", "desktop\CodexProviderSync.Mac\CodexProviderSync.Mac.csproj", "{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexProviderSync.Core", "desktop\CodexProviderSync.Core\CodexProviderSync.Core.csproj", "{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Debug|x64.ActiveCfg = Debug|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Debug|x64.Build.0 = Debug|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Debug|x86.ActiveCfg = Debug|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Debug|x86.Build.0 = Debug|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Release|Any CPU.Build.0 = Release|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Release|x64.ActiveCfg = Release|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Release|x64.Build.0 = Release|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Release|x86.ActiveCfg = Release|Any CPU
{046C07E6-8117-4F19-B2CF-683D62D944C9}.Release|x86.Build.0 = Release|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Debug|x64.ActiveCfg = Debug|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Debug|x64.Build.0 = Debug|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Debug|x86.ActiveCfg = Debug|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Debug|x86.Build.0 = Debug|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Release|Any CPU.Build.0 = Release|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Release|x64.ActiveCfg = Release|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Release|x64.Build.0 = Release|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Release|x86.ActiveCfg = Release|Any CPU
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901}.Release|x86.Build.0 = Release|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Debug|x64.ActiveCfg = Debug|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Debug|x64.Build.0 = Debug|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Debug|x86.ActiveCfg = Debug|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Debug|x86.Build.0 = Debug|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|Any CPU.Build.0 = Release|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|x64.ActiveCfg = Release|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|x64.Build.0 = Release|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|x86.ActiveCfg = Release|Any CPU
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{046C07E6-8117-4F19-B2CF-683D62D944C9} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C}
{2F00CBA5-C5E9-42BC-9F25-01641E0B8901} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C}
{0BAD4EAB-F18E-405C-AA1F-0D08B03B599B} = {7A1224A0-F22D-34FF-DE0A-A058FAFFD90C}
EndGlobalSection
EndGlobal
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dailin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+118
View File
@@ -0,0 +1,118 @@
<div align="center">
# codex-provider-sync
### 切换 provider 后,让 Codex 历史会话重新可见
[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml)
[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/Dailin521/codex-provider-sync)
[![Node](https://img.shields.io/badge/node-16%2B-brightgreen.svg)](https://nodejs.org/)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
中文 | [English](docs/README_EN.md)
</div>
## 解决什么问题
Codex 切换 `model_provider` 后,旧会话可能在 Desktop 或 `/resume` 里不可见。原因通常不是会话文件丢了,而是 rollout 文件、SQLite 线程表、项目路径缓存里的 provider / 可见性 metadata 不一致。
本工具同步这些位置:
- `~/.codex/sessions`
- `~/.codex/archived_sessions`
- `~/.codex/state_5.sqlite`
- `.codex-global-state.json` 中的项目根路径缓存
## 快速使用
Windows 用户优先下载 Release 里的 `CodexProviderSync.exe`
1. 打开 `CodexProviderSync.exe`
2. 点击 `Refresh`
3. 选择目标 Provider
4. 点击 `Execute`
macOS 用户可使用 Avalonia 桌面版,构建说明见 [README_MAC_GUI_ZH.md](docs/README_MAC_GUI_ZH.md)。
其它环境使用 CLI
```bash
npm install -g git+https://github.com/Dailin521/codex-provider-sync.git
codex-provider sync
```
CLI 需要 Node.js `16+`。Node 24+ 会优先使用内置 `node:sqlite`;旧版 Node 会使用可选依赖 `better-sqlite3`
更多 CLI 常用命令:
```bash
codex-provider status
codex-provider sync
codex-provider sync --provider openai
codex-provider switch apigather
codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\<timestamp>
codex-provider prune-backups --keep 5
```
命令含义:
- `status`:只检查当前 provider、rollout、SQLite、项目可见性诊断。
- `sync`:不切换登录状态,只把历史会话 metadata 同步到当前 provider。
- `switch <provider-id>`:修改 `config.toml` 根级 `model_provider`,然后执行同步。
- `restore <backup-dir>`:从备份恢复,支持 `--no-config``--no-db``--no-sessions`
- `prune-backups --keep <n>`:只清理本工具创建的旧备份。
## 能力边界
本工具只修复“历史会话可见性”相关 metadata,不修改会话内容。
- 不处理登录、认证、`auth.json` 或第三方切号工具。
- 不修改消息历史、会话标题、对话内容。
- 不修改 `updated_at`,不通过改变历史排序来修复 Desktop 显示。
- 不把旧会话里的 `encrypted_content` 重新加密到另一个 provider / account。
-`encrypted_content` 的旧会话跨 provider/account 后,通常只能恢复列表可见性,继续对话或 compact 仍可能报 `invalid_encrypted_content`
## Codex Desktop 最近 50 条限制
目前 Codex Desktop 的 Recent/项目侧会话列表存在一个上游显示限制:前端首屏只拉取最近 `50` 条会话。
影响:
- CLI `/resume` 能看到的旧会话,Desktop 项目侧可能仍显示“暂无对话”。
- 旧项目会话如果排在全局最近 50 条之后,Desktop 首屏可能不会展示。
- `codex-provider-sync status` / GUI Refresh 会显示 `first page 0/50``ranks 64-77` 这类诊断,帮助判断是不是这个问题。
本工具不会通过修改 `updated_at` 或文件时间把旧会话强行挤进前 50。这个问题应由 Codex Desktop 上游改成按项目分页加载,或提高/开放首屏加载数量。
## 安全与排障
每次 `sync` / `switch` 前都会备份到:
```text
~/.codex/backups_state/provider-sync/<timestamp>
```
注意:
- 如果 `state_5.sqlite` 被占用,关闭 Codex / Codex App / app-server 后重试。
- 如果 `state_5.sqlite` 损坏,工具会提示 malformed/unreadable 并停止同步。
- 如果活跃会话锁住 rollout 文件,工具会跳过该文件并继续处理其它历史会话。
- 如果 EXE 双击无反应,先确认已解压,再查看 `%AppData%\codex-provider-sync\startup-error.log`,或在 PowerShell 里运行 `./CodexProviderSync.exe`
Windows GUI 说明见 [README_GUI_ZH.md](docs/README_GUI_ZH.md)macOS GUI 说明见 [README_MAC_GUI_ZH.md](docs/README_MAC_GUI_ZH.md)。AI / Agent 说明见 [AGENTS.md](AGENTS.md)。
## 开发
```bash
git clone https://github.com/Dailin521/codex-provider-sync.git
cd codex-provider-sync
npm test
dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj
pwsh ./scripts/publish-gui.ps1
./scripts/publish-gui-macos.sh
```
## License
MIT
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`Dailin521/codex-provider-sync`
- 原始仓库:https://github.com/Dailin521/codex-provider-sync
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\CodexProviderSync.Core\CodexProviderSync.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>CodexProviderSync</AssemblyName>
<Product>Codex Provider Sync</Product>
<Company>Dailin521</Company>
<Version>0.2.9</Version>
<AssemblyVersion>0.2.9.0</AssemblyVersion>
<FileVersion>0.2.9.0</FileVersion>
</PropertyGroup>
</Project>
@@ -0,0 +1,121 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
namespace CodexProviderSync.App;
/// <summary>
/// Cross-process focus broker: the first instance of CodexProviderSync runs
/// this server on a per-user named pipe. Subsequent instances connect, send
/// the "FOCUS" command, and exit; the server invokes the registered handler
/// on the main thread, which is expected to bring the existing window to
/// the foreground.
/// </summary>
public sealed class FocusRequestServer : IDisposable
{
private const string PipeNamePrefix = "CodexProviderSync.Focus";
private readonly Action _onFocusRequested;
private readonly CancellationTokenSource _shutdown = new();
private Task? _loop;
private bool _disposed;
public FocusRequestServer(Action onFocusRequested)
{
_onFocusRequested = onFocusRequested ?? throw new ArgumentNullException(nameof(onFocusRequested));
}
public string PipeName { get; } = BuildPipeName();
public void Start()
{
if (_loop is not null)
{
return;
}
_loop = Task.Run(RunAsync);
}
public async Task<bool> SendFocusRequestAsync(TimeSpan timeout)
{
try
{
using NamedPipeClientStream client = new(".", PipeName, PipeDirection.Out, PipeOptions.None);
await client.ConnectAsync((int)timeout.TotalMilliseconds).ConfigureAwait(false);
using StreamWriter writer = new(client) { AutoFlush = true };
await writer.WriteLineAsync("FOCUS").ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
}
catch (IOException)
{
return false;
}
}
private async Task RunAsync()
{
while (!_shutdown.IsCancellationRequested)
{
try
{
using NamedPipeServerStream server = new(
PipeName,
PipeDirection.In,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(_shutdown.Token).ConfigureAwait(false);
using StreamReader reader = new(server);
string? line = await reader.ReadLineAsync().ConfigureAwait(false);
if (string.Equals(line, "FOCUS", StringComparison.Ordinal))
{
_onFocusRequested();
}
}
catch (OperationCanceledException)
{
return;
}
catch (IOException)
{
// Pipe was broken mid-handshake; loop and create a fresh one.
}
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_shutdown.Cancel();
try
{
_loop?.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
// The loop is allowed to fault on cancellation; nothing actionable.
}
_shutdown.Dispose();
}
private static string BuildPipeName()
{
// Per-user scope so two users on the same Windows machine do not
// collide. Identity.Name is "DOMAIN\User" on Windows; the backslash
// is illegal in pipe names so we swap it out.
string identity = WindowsIdentity.GetCurrent().Name ?? "default";
string sanitized = identity.Replace('\\', '_').Replace('/', '_').Replace(' ', '_');
return $"{PipeNamePrefix}.{sanitized}";
}
}
+900
View File
@@ -0,0 +1,900 @@
using System.Diagnostics;
using CodexProviderSync.Core;
namespace CodexProviderSync.App;
public sealed class MainForm : Form
{
private readonly CodexSyncService _syncService = new();
private readonly SettingsService _settingsService = new();
private readonly ComboBox _codexHomeCombo = new() { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDown };
private readonly Button _browseButton = new() { Text = "Browse...", AutoSize = true };
private readonly Button _refreshButton = new() { Text = "Refresh", AutoSize = true };
private readonly RichTextBox _statusBox = new()
{
Dock = DockStyle.Fill,
ReadOnly = true,
WordWrap = false,
Font = new Font("Consolas", 10F),
BackColor = SystemColors.Window
};
private readonly ListView _providerList = new()
{
Dock = DockStyle.Fill,
View = View.Details,
FullRowSelect = true,
HideSelection = false,
MultiSelect = false
};
private readonly TextBox _manualProviderText = new() { Dock = DockStyle.Fill };
private readonly Button _addProviderButton = new() { Text = "添加", AutoSize = true };
private readonly Button _removeProviderButton = new() { Text = "删除手动项", AutoSize = true };
private readonly Label _selectedProviderValue = new()
{
AutoSize = false,
Dock = DockStyle.Fill,
Height = 32,
Font = new Font(SystemFonts.DefaultFont, FontStyle.Bold),
TextAlign = ContentAlignment.MiddleLeft
};
private readonly CheckBox _updateConfigCheck = new()
{
AutoSize = true,
Margin = new Padding(0, 4, 8, 0)
};
private readonly CheckBox _restoreConfigCheck = new() { Text = "恢复配置文件(config.toml", Checked = false, AutoSize = true };
private readonly CheckBox _restoreDatabaseCheck = new() { Text = "恢复线程数据库(SQLite", Checked = true, AutoSize = true };
private readonly CheckBox _restoreSessionsCheck = new() { Text = "恢复会话文件元数据(rollout", Checked = true, AutoSize = true };
private readonly Label _updateConfigLabel = new()
{
AutoSize = false,
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleLeft,
Text = "同步时改写 config.toml"
};
private readonly NumericUpDown _backupRetentionInput = new()
{
Minimum = 1,
Maximum = 100000,
Value = AppConstants.DefaultBackupRetentionCount,
Width = 72
};
private readonly Button _executeButton = new() { Text = "立即同步", Dock = DockStyle.Fill, Height = 40 };
private readonly Button _restoreButton = new() { Text = "恢复备份", Dock = DockStyle.Fill, Height = 40 };
private readonly Button _openBackupButton = new() { Text = "打开备份目录", Dock = DockStyle.Fill, Height = 40 };
private readonly Button _pruneBackupsButton = new() { Text = "清理旧备份", Dock = DockStyle.Fill, Height = 40 };
private readonly Label _busyLabel = new() { AutoSize = true, ForeColor = Color.DarkGreen, Text = "Ready" };
private readonly Label _warningLine1 = new()
{
AutoSize = false,
Dock = DockStyle.Fill,
BackColor = Color.FromArgb(255, 244, 214),
ForeColor = Color.FromArgb(120, 53, 15),
TextAlign = ContentAlignment.MiddleLeft,
Text = "执行前先关闭 Codex CLI / App"
};
private readonly Label _warningLine2 = new()
{
AutoSize = false,
Dock = DockStyle.Fill,
BackColor = Color.FromArgb(255, 244, 214),
ForeColor = Color.FromArgb(120, 53, 15),
TextAlign = ContentAlignment.MiddleLeft,
Text = "以及 app-server / 相关终端"
};
private readonly TextBox _logBox = new()
{
Dock = DockStyle.Fill,
ReadOnly = true,
Multiline = true,
ScrollBars = ScrollBars.Both,
Font = new Font("Consolas", 10F),
BackColor = SystemColors.Window
};
private AppSettings _settings = new();
private StatusSnapshot? _currentStatus;
private bool _loadingSettings;
public MainForm()
{
Text = "Codex Provider Sync";
MinimumSize = new Size(1180, 760);
StartPosition = FormStartPosition.CenterScreen;
_providerList.Columns.Add("Provider", 180);
_providerList.Columns.Add("来源", 180);
_providerList.Columns.Add("当前", 70);
_providerList.Columns.Add("手动", 70);
_providerList.Columns.Add("已保存", 70);
BuildLayout();
WireEvents();
}
protected override async void OnLoad(EventArgs e)
{
base.OnLoad(e);
await LoadStateAsync();
}
/// <summary>
/// Brings the running form back to the foreground. Invoked by the
/// single-instance focus broker when a second copy of CodexProviderSync
/// is launched and asks the first copy to take focus.
/// </summary>
public new void BringToFront()
{
if (InvokeRequired)
{
BeginInvoke(new Action(BringToFront));
return;
}
if (WindowState == FormWindowState.Minimized)
{
WindowState = FormWindowState.Normal;
}
Show();
Activate();
TopMost = true;
TopMost = false;
Focus();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
PersistUiState();
base.OnFormClosing(e);
}
private void BuildLayout()
{
TableLayoutPanel root = new()
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
Padding = new Padding(12)
};
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 68));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 32));
root.Controls.Add(BuildTopPanel(), 0, 0);
root.Controls.Add(BuildMainPanel(), 0, 1);
root.Controls.Add(BuildLogPanel(), 0, 2);
Controls.Add(root);
}
private Control BuildTopPanel()
{
GroupBox group = new()
{
Text = "Codex Home",
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink
};
TableLayoutPanel panel = new()
{
Dock = DockStyle.Top,
ColumnCount = 4,
AutoSize = true,
Padding = new Padding(10)
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
Label recentLabel = new()
{
Text = "最近使用会自动保留在下拉列表中",
AutoSize = true,
ForeColor = SystemColors.GrayText,
Anchor = AnchorStyles.Left
};
panel.Controls.Add(_codexHomeCombo, 0, 0);
panel.Controls.Add(_browseButton, 1, 0);
panel.Controls.Add(_refreshButton, 2, 0);
panel.Controls.Add(recentLabel, 3, 0);
group.Controls.Add(panel);
return group;
}
private Control BuildMainPanel()
{
TableLayoutPanel main = new()
{
Dock = DockStyle.Fill,
ColumnCount = 3,
RowCount = 1,
Margin = new Padding(0, 12, 0, 12)
};
main.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
main.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
main.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 380));
main.Controls.Add(BuildStatusGroup(), 0, 0);
main.Controls.Add(BuildProviderGroup(), 1, 0);
main.Controls.Add(BuildActionGroup(), 2, 0);
return main;
}
private Control BuildStatusGroup()
{
GroupBox group = new() { Text = "当前状态", Dock = DockStyle.Fill };
group.Controls.Add(_statusBox);
return group;
}
private Control BuildProviderGroup()
{
GroupBox group = new() { Text = "Provider 列表", Dock = DockStyle.Fill };
TableLayoutPanel panel = new()
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
Padding = new Padding(8)
};
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
FlowLayoutPanel addPanel = new()
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
AutoSize = true,
WrapContents = false
};
addPanel.Controls.Add(new Label { Text = "手动添加:", AutoSize = true, Margin = new Padding(0, 8, 8, 0) });
_manualProviderText.Width = 220;
addPanel.Controls.Add(_manualProviderText);
addPanel.Controls.Add(_addProviderButton);
FlowLayoutPanel hintPanel = new()
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
AutoSize = true,
WrapContents = false
};
hintPanel.Controls.Add(_removeProviderButton);
hintPanel.Controls.Add(new Label
{
Text = "Refresh 时会把扫描到的新 Provider 自动并入持久化列表",
AutoSize = true,
Margin = new Padding(12, 8, 0, 0),
ForeColor = SystemColors.GrayText
});
panel.Controls.Add(_providerList, 0, 0);
panel.Controls.Add(addPanel, 0, 1);
panel.Controls.Add(hintPanel, 0, 2);
group.Controls.Add(panel);
return group;
}
private Control BuildActionGroup()
{
GroupBox group = new() { Text = "执行", Dock = DockStyle.Fill, MinimumSize = new Size(420, 0) };
TableLayoutPanel panel = new()
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 11,
Padding = new Padding(12)
};
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.Controls.Add(new Label { Text = "目标 Provider", AutoSize = true }, 0, 0);
panel.Controls.Add(_selectedProviderValue, 0, 1);
panel.Controls.Add(BuildUpdateConfigPanel(), 0, 2);
panel.Controls.Add(BuildWarningPanel(), 0, 3);
panel.Controls.Add(BuildBackupRetentionPanel(), 0, 4);
panel.Controls.Add(_executeButton, 0, 5);
panel.Controls.Add(BuildRestoreOptionsPanel(), 0, 6);
panel.Controls.Add(_restoreButton, 0, 7);
panel.Controls.Add(_openBackupButton, 0, 8);
panel.Controls.Add(_pruneBackupsButton, 0, 9);
panel.Controls.Add(_busyLabel, 0, 10);
group.Controls.Add(panel);
return group;
}
private Control BuildUpdateConfigPanel()
{
TableLayoutPanel panel = new()
{
Dock = DockStyle.Fill,
ColumnCount = 2,
Margin = new Padding(0, 2, 0, 8)
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.Controls.Add(_updateConfigCheck, 0, 0);
panel.Controls.Add(_updateConfigLabel, 1, 0);
return panel;
}
private Control BuildWarningPanel()
{
Panel panel = new()
{
Dock = DockStyle.Fill,
Height = 86,
Padding = new Padding(10, 8, 10, 8),
Margin = new Padding(0, 4, 0, 8),
BackColor = Color.FromArgb(255, 244, 214)
};
TableLayoutPanel textLayout = new()
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
BackColor = Color.FromArgb(255, 244, 214),
Margin = new Padding(0),
Padding = new Padding(0)
};
textLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
textLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
textLayout.Controls.Add(_warningLine1, 0, 0);
textLayout.Controls.Add(_warningLine2, 0, 1);
panel.Controls.Add(textLayout);
return panel;
}
private Control BuildBackupRetentionPanel()
{
TableLayoutPanel panel = new()
{
Dock = DockStyle.Fill,
ColumnCount = 3,
Margin = new Padding(0, 2, 0, 8),
AutoSize = true
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.Controls.Add(new Label
{
Text = "自动保留最近",
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(0, 6, 8, 0)
}, 0, 0);
panel.Controls.Add(_backupRetentionInput, 1, 0);
panel.Controls.Add(new Label
{
Text = "份备份",
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(8, 6, 0, 0)
}, 2, 0);
return panel;
}
private Control BuildRestoreOptionsPanel()
{
TableLayoutPanel panel = new()
{
Dock = DockStyle.Fill,
ColumnCount = 1,
Margin = new Padding(0, 0, 0, 6),
AutoSize = true
};
panel.RowCount = 4;
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.Controls.Add(new Label
{
Text = "恢复内容",
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(0, 2, 0, 4)
}, 0, 0);
panel.Controls.Add(_restoreConfigCheck, 0, 1);
panel.Controls.Add(_restoreDatabaseCheck, 0, 2);
panel.Controls.Add(_restoreSessionsCheck, 0, 3);
return panel;
}
private Control BuildLogPanel()
{
GroupBox group = new() { Text = "执行日志", Dock = DockStyle.Fill };
group.Controls.Add(_logBox);
return group;
}
private void WireEvents()
{
_browseButton.Click += async (_, _) => await BrowseCodexHomeAsync();
_refreshButton.Click += async (_, _) => await RefreshStatusAsync();
_addProviderButton.Click += async (_, _) => await AddManualProviderAsync();
_removeProviderButton.Click += async (_, _) => await RemoveManualProviderAsync();
_backupRetentionInput.ValueChanged += async (_, _) => await PersistBackupRetentionAsync();
_executeButton.Click += async (_, _) => await ExecuteSyncOrSwitchAsync();
_restoreButton.Click += async (_, _) => await RestoreBackupAsync();
_openBackupButton.Click += (_, _) => OpenBackupFolder();
_pruneBackupsButton.Click += async (_, _) => await PruneBackupsAsync();
_providerList.SelectedIndexChanged += (_, _) => UpdateSelectionLabel();
_codexHomeCombo.Leave += async (_, _) => await PersistHomeSelectionAsync();
}
private async Task LoadStateAsync()
{
_loadingSettings = true;
_settings = await _settingsService.LoadAsync();
ApplyWindowBounds(_settings.WindowBounds);
ReloadRecentHomes();
_codexHomeCombo.Text = _settings.LastCodexHome ?? AppConstants.DefaultCodexHome();
_backupRetentionInput.Value = Math.Max(_backupRetentionInput.Minimum, Math.Min(_backupRetentionInput.Maximum, _settings.BackupRetentionCount));
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已加载设置: {_settingsService.SettingsPath}");
_loadingSettings = false;
await RefreshStatusAsync();
}
private async Task RefreshStatusAsync()
{
string codexHome = CurrentCodexHome();
await RunBusyAsync("Refreshing...", () => RefreshStatusCoreAsync(codexHome));
}
private async Task BrowseCodexHomeAsync()
{
using FolderBrowserDialog dialog = new()
{
Description = "选择 .codex 目录",
UseDescriptionForTitle = true,
InitialDirectory = Directory.Exists(CurrentCodexHome()) ? CurrentCodexHome() : AppConstants.DefaultCodexHome(),
ShowNewFolderButton = false
};
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
_codexHomeCombo.Text = dialog.SelectedPath;
await PersistHomeSelectionAsync();
await RefreshStatusAsync();
}
private async Task PersistHomeSelectionAsync()
{
string codexHome = CurrentCodexHome();
if (string.IsNullOrWhiteSpace(codexHome))
{
return;
}
_settings = _settingsService.RecordCodexHome(_settings, codexHome);
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), _settings.LastBackupDirectory, CaptureWindowBounds(), CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
ReloadRecentHomes();
}
private async Task PersistBackupRetentionAsync()
{
if (_loadingSettings)
{
return;
}
_settings = _settingsService.UpdateState(
_settings,
SelectedProvider(),
_settings.LastBackupDirectory,
CaptureWindowBounds(),
CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已更新自动保留数: {CurrentBackupRetentionCount()}");
}
private async Task AddManualProviderAsync()
{
string provider = _manualProviderText.Text.Trim();
if (string.IsNullOrWhiteSpace(provider))
{
MessageBox.Show(this, "请输入要添加的 Provider ID。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
_settings = _settingsService.AddManualProvider(_settings, provider);
await _settingsService.SaveAsync(_settings);
_manualProviderText.Clear();
ReloadProviderList();
SelectProvider(provider);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已添加手动 Provider: {provider}");
}
private async Task RemoveManualProviderAsync()
{
string? provider = SelectedProvider();
if (string.IsNullOrWhiteSpace(provider))
{
MessageBox.Show(this, "请先选择要删除的 Provider。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
_settings = _settingsService.RemoveManualProvider(_settings, provider);
await _settingsService.SaveAsync(_settings);
ReloadProviderList();
SelectProvider(_currentStatus?.CurrentProvider.Provider);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已删除手动 Provider: {provider}");
}
private async Task ExecuteSyncOrSwitchAsync()
{
string? provider = SelectedProvider();
if (string.IsNullOrWhiteSpace(provider))
{
MessageBox.Show(this, "请先选择目标 Provider。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (!ConfirmCodexClosed("执行同步前,请先关闭已打开的 Codex CLI、Codex App、app-server 和相关终端。是否继续?"))
{
return;
}
await RunBusyAsync("Executing...", async () =>
{
string codexHome = CurrentCodexHome();
int backupRetentionCount = CurrentBackupRetentionCount();
SyncResult result = _updateConfigCheck.Checked
? await Task.Run(async () => await _syncService.RunSwitchAsync(codexHome, provider, backupRetentionCount))
: await Task.Run(async () => await _syncService.RunSyncAsync(codexHome, provider: provider, keepCount: backupRetentionCount));
_settings = _settingsService.UpdateState(_settings, provider, result.BackupDir, CaptureWindowBounds(), backupRetentionCount);
await _settingsService.SaveAsync(_settings);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 执行完成");
AppendLog(TextFormatter.FormatSyncResult(result, _updateConfigCheck.Checked ? "已切换并同步" : "已同步"));
AppendLog(string.Empty);
await RefreshStatusCoreAsync(codexHome);
SelectProvider(provider);
});
}
private async Task RestoreBackupAsync()
{
string backupRoot = _currentStatus?.BackupRoot ?? AppConstants.DefaultBackupRoot(CurrentCodexHome());
string initialBackupDir = Directory.Exists(_settings.LastBackupDirectory)
? _settings.LastBackupDirectory!
: backupRoot;
using FolderBrowserDialog dialog = new()
{
Description = "选择要恢复的 backup 目录",
UseDescriptionForTitle = true,
InitialDirectory = initialBackupDir,
ShowNewFolderButton = false
};
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
if (!_restoreConfigCheck.Checked && !_restoreDatabaseCheck.Checked && !_restoreSessionsCheck.Checked)
{
MessageBox.Show(this, "请至少选择一种要恢复的内容。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
string restoreTargets = string.Join("、", new[]
{
_restoreConfigCheck.Checked ? "配置文件(config.toml" : null,
_restoreDatabaseCheck.Checked ? "线程数据库(SQLite" : null,
_restoreSessionsCheck.Checked ? "会话文件元数据(rollout" : null
}.Where(static item => item is not null));
DialogResult confirm = MessageBox.Show(
this,
$"确认恢复以下备份?{Environment.NewLine}{Environment.NewLine}{dialog.SelectedPath}{Environment.NewLine}{Environment.NewLine}将覆盖当前的: {restoreTargets}。",
Text,
MessageBoxButtons.OKCancel,
MessageBoxIcon.Warning);
if (confirm != DialogResult.OK)
{
return;
}
if (!ConfirmCodexClosed("恢复 backup 前,请先关闭已打开的 Codex CLI、Codex App、app-server 和相关终端。是否继续?"))
{
return;
}
await RunBusyAsync("Restoring...", async () =>
{
string codexHome = CurrentCodexHome();
RestoreResult result = await Task.Run(async () => await _syncService.RunRestoreAsync(
codexHome,
dialog.SelectedPath,
new RestoreBackupOptions
{
RestoreConfig = _restoreConfigCheck.Checked,
RestoreDatabase = _restoreDatabaseCheck.Checked,
RestoreSessions = _restoreSessionsCheck.Checked
}));
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), dialog.SelectedPath, CaptureWindowBounds(), CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 恢复完成");
AppendLog(TextFormatter.FormatRestoreResult(result));
AppendLog(string.Empty);
await RefreshStatusCoreAsync(codexHome);
});
}
private void OpenBackupFolder()
{
string path = _currentStatus?.BackupRoot ?? AppConstants.DefaultBackupRoot(CurrentCodexHome());
Directory.CreateDirectory(path);
Process.Start(new ProcessStartInfo
{
FileName = path,
UseShellExecute = true
});
}
private async Task PruneBackupsAsync()
{
if (!ConfirmBackupPrune())
{
return;
}
await RunBusyAsync("Cleaning backups...", async () =>
{
string codexHome = CurrentCodexHome();
BackupPruneResult result = await Task.Run(async () => await _syncService.RunPruneBackupsAsync(codexHome, CurrentBackupRetentionCount()));
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 旧备份清理完成");
AppendLog(TextFormatter.FormatBackupPruneResult(result));
AppendLog(string.Empty);
await RefreshStatusCoreAsync(codexHome);
});
}
private void ReloadRecentHomes()
{
string currentText = _codexHomeCombo.Text;
_codexHomeCombo.BeginUpdate();
_codexHomeCombo.Items.Clear();
foreach (string home in _settings.RecentCodexHomes)
{
_codexHomeCombo.Items.Add(home);
}
_codexHomeCombo.EndUpdate();
if (!string.IsNullOrWhiteSpace(currentText))
{
_codexHomeCombo.Text = currentText;
}
}
private void ReloadProviderList()
{
_providerList.BeginUpdate();
_providerList.Items.Clear();
if (_currentStatus is not null)
{
foreach (ProviderOption option in _syncService.BuildProviderOptions(_currentStatus, _settings))
{
ListViewItem item = new(option.Id)
{
Tag = option.Id
};
item.SubItems.Add(TextFormatter.FormatProviderSources(option));
item.SubItems.Add(option.IsCurrentProvider ? "Yes" : string.Empty);
item.SubItems.Add(option.IsManual ? "Yes" : string.Empty);
item.SubItems.Add(option.IsSaved ? "Yes" : string.Empty);
_providerList.Items.Add(item);
}
}
_providerList.EndUpdate();
SelectProvider(_settings.LastSelectedProvider ?? _currentStatus?.CurrentProvider.Provider);
UpdateSelectionLabel();
}
private void SelectProvider(string? provider)
{
if (string.IsNullOrWhiteSpace(provider))
{
return;
}
_providerList.SelectedItems.Clear();
foreach (ListViewItem item in _providerList.Items)
{
if (string.Equals(item.Tag as string, provider, StringComparison.Ordinal))
{
item.Selected = true;
item.Focused = true;
item.EnsureVisible();
break;
}
}
}
private void UpdateSelectionLabel()
{
string? provider = SelectedProvider();
_selectedProviderValue.Text = string.IsNullOrWhiteSpace(provider) ? "未选择" : provider;
}
private string? SelectedProvider()
{
return _providerList.SelectedItems.Count == 0 ? null : _providerList.SelectedItems[0].Tag as string;
}
private string CurrentCodexHome()
{
string text = _codexHomeCombo.Text.Trim();
return string.IsNullOrWhiteSpace(text) ? AppConstants.DefaultCodexHome() : text;
}
private int CurrentBackupRetentionCount()
{
return Decimal.ToInt32(_backupRetentionInput.Value);
}
private void PersistUiState()
{
try
{
_settings = _settingsService.RecordCodexHome(_settings, CurrentCodexHome());
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), _settings.LastBackupDirectory, CaptureWindowBounds(), CurrentBackupRetentionCount());
_settingsService.Save(_settings);
}
catch
{
// Ignore shutdown persistence failures.
}
}
private async Task RefreshStatusCoreAsync(string codexHome)
{
_currentStatus = await Task.Run(async () => await _syncService.GetStatusAsync(codexHome));
_settings = _settingsService.RecordCodexHome(_settings, _currentStatus.CodexHome);
_settings = _settingsService.MergeDetectedProviders(_settings, _syncService.ExtractDetectedProviderIds(_currentStatus));
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), _settings.LastBackupDirectory, CaptureWindowBounds(), CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
_statusBox.Text = TextFormatter.FormatStatus(_currentStatus);
ReloadRecentHomes();
ReloadProviderList();
_codexHomeCombo.Text = _currentStatus.CodexHome;
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 已刷新: {_currentStatus.CodexHome}");
}
private WindowBoundsState CaptureWindowBounds()
{
Rectangle bounds = WindowState == FormWindowState.Normal ? Bounds : RestoreBounds;
return new WindowBoundsState
{
X = bounds.X,
Y = bounds.Y,
Width = bounds.Width,
Height = bounds.Height,
Maximized = WindowState == FormWindowState.Maximized
};
}
private void ApplyWindowBounds(WindowBoundsState? bounds)
{
if (bounds is null || bounds.Width < 800 || bounds.Height < 600)
{
Size = new Size(1280, 820);
return;
}
StartPosition = FormStartPosition.Manual;
Bounds = new Rectangle(bounds.X, bounds.Y, bounds.Width, bounds.Height);
if (bounds.Maximized)
{
WindowState = FormWindowState.Maximized;
}
}
private async Task RunBusyAsync(string stateText, Func<Task> action)
{
SetBusy(true, stateText);
try
{
await action();
}
catch (Exception error)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] 错误: {error}");
MessageBox.Show(this, error.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
SetBusy(false, "Ready");
}
}
private void SetBusy(bool busy, string stateText)
{
UseWaitCursor = busy;
_busyLabel.Text = stateText;
_busyLabel.ForeColor = busy ? Color.DarkOrange : Color.DarkGreen;
_browseButton.Enabled = !busy;
_refreshButton.Enabled = !busy;
_addProviderButton.Enabled = !busy;
_removeProviderButton.Enabled = !busy;
_updateConfigCheck.Enabled = !busy;
_backupRetentionInput.Enabled = !busy;
_restoreConfigCheck.Enabled = !busy;
_restoreDatabaseCheck.Enabled = !busy;
_restoreSessionsCheck.Enabled = !busy;
_executeButton.Enabled = !busy;
_restoreButton.Enabled = !busy;
_openBackupButton.Enabled = !busy;
_pruneBackupsButton.Enabled = !busy;
_providerList.Enabled = !busy;
_manualProviderText.Enabled = !busy;
_codexHomeCombo.Enabled = !busy;
}
private void AppendLog(string message)
{
if (_logBox.TextLength > 0)
{
_logBox.AppendText(Environment.NewLine);
}
_logBox.AppendText(message);
_logBox.SelectionStart = _logBox.TextLength;
_logBox.ScrollToCaret();
}
private bool ConfirmCodexClosed(string message)
{
return MessageBox.Show(
this,
message,
Text,
MessageBoxButtons.OKCancel,
MessageBoxIcon.Warning) == DialogResult.OK;
}
private bool ConfirmBackupPrune()
{
string message =
$"确认清理旧备份?{Environment.NewLine}{Environment.NewLine}" +
$"将只保留最近 {CurrentBackupRetentionCount()} 份受本工具管理的备份。{Environment.NewLine}" +
"被删除的旧备份无法直接恢复。";
return MessageBox.Show(
this,
message,
Text,
MessageBoxButtons.OKCancel,
MessageBoxIcon.Warning) == DialogResult.OK;
}
}
+68
View File
@@ -0,0 +1,68 @@
using CodexProviderSync.Core;
namespace CodexProviderSync.App;
static class Program
{
[STAThread]
static void Main()
{
try
{
SingleInstanceGuard guard = new();
using SingleInstanceAcquisition acquisition = guard.Acquire("codex-provider-sync");
if (!acquisition.IsOwner)
{
FocusExistingInstanceAndExit(acquisition);
return;
}
ApplicationConfiguration.Initialize();
MainForm mainForm = new();
using FocusRequestServer focusServer = new(mainForm.BringToFront);
focusServer.Start();
Application.Run(mainForm);
}
catch (Exception error)
{
string logDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"codex-provider-sync");
Directory.CreateDirectory(logDir);
string logPath = Path.Combine(logDir, "startup-error.log");
File.WriteAllText(logPath, error.ToString());
MessageBox.Show(
$"Codex Provider Sync failed to start.\n\n{error.Message}\n\nDetails were written to:\n{logPath}",
"Codex Provider Sync",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
private static void FocusExistingInstanceAndExit(SingleInstanceAcquisition acquisition)
{
string detail = acquisition.ExistingOwner is { } owner
? $"pid={owner.ProcessId}, started={owner.StartedAt:O}"
: "no owner metadata available";
Console.WriteLine(
$"Another Codex Provider Sync instance is already running ({detail}). Forwarding focus request and exiting.");
try
{
using FocusRequestServer client = new(() => { });
bool delivered = client
.SendFocusRequestAsync(TimeSpan.FromSeconds(2))
.GetAwaiter()
.GetResult();
if (!delivered)
{
Console.WriteLine(
"Focus request timed out; the existing instance may be busy. It will be brought to the foreground when it next becomes idle.");
}
}
catch (Exception error)
{
Console.Error.WriteLine($"Failed to forward focus request: {error.Message}");
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CodexProviderSync.Core\CodexProviderSync.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,963 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace CodexProviderSync.Core.Tests;
public sealed class CoreIntegrationTests
{
[Fact]
public async Task RunSync_RewritesRolloutFilesAndSqlite_ThenRestoreRevertsBoth()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
string archivedPath = fixture.RolloutPath("archived_sessions", "rollout-b.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteRolloutAsync(archivedPath, "thread-b", "newapi");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false),
("thread-b", "newapi", true)
]);
CodexSyncService service = new();
SyncResult syncResult = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal("openai", syncResult.TargetProvider);
Assert.Equal(2, syncResult.ChangedSessionFiles);
Assert.Empty(syncResult.SkippedLockedRolloutFiles);
Assert.Empty(syncResult.SkippedUnreadableRolloutFiles);
Assert.Equal(2, syncResult.SqliteRowsUpdated);
BackupMetadataFile backupMetadata = JsonSerializer.Deserialize<BackupMetadataFile>(
await File.ReadAllTextAsync(Path.Combine(syncResult.BackupDir, "metadata.json")),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!;
Assert.Equal(
[
Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename)
],
backupMetadata.DbFiles);
string syncedSession = await File.ReadAllTextAsync(sessionPath);
string syncedArchived = await File.ReadAllTextAsync(archivedPath);
Assert.Contains("\"model_provider\":\"openai\"", syncedSession);
Assert.Contains("\"model_provider\":\"openai\"", syncedArchived);
await using (SqliteConnection connection = fixture.OpenSqliteConnection())
{
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, model_provider FROM threads ORDER BY id";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
List<(string Id, string Provider)> rows = [];
while (await reader.ReadAsync())
{
rows.Add((reader.GetString(0), reader.GetString(1)));
}
Assert.Equal(
[
("thread-a", "openai"),
("thread-b", "openai")
], rows);
}
RestoreResult restoreResult = await service.RunRestoreAsync(fixture.CodexHome, syncResult.BackupDir);
Assert.Equal("openai", restoreResult.TargetProvider);
string restoredSession = await File.ReadAllTextAsync(sessionPath);
string restoredArchived = await File.ReadAllTextAsync(archivedPath);
Assert.Contains("\"model_provider\":\"apigather\"", restoredSession);
Assert.Contains("\"model_provider\":\"newapi\"", restoredArchived);
}
[Fact]
public async Task RunSync_UpdatesLegacyRootSqliteDatabase_WhenSqliteDirStateIsStale()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-active-a.jsonl");
string archivedPath = fixture.RolloutPath("archived_sessions", "rollout-active-b.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-active-a", "dal");
await fixture.WriteRolloutAsync(archivedPath, "thread-active-b", "dal");
await fixture.WriteStateDbAsync(
[
("thread-active-a", "dal", false)
]);
await fixture.WriteLegacyStateDbAsync(
[
("thread-active-a", "dal", false),
("thread-active-b", "dal", true)
]);
CodexSyncService service = new();
SyncResult syncResult = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(2, syncResult.SqliteRowsUpdated);
BackupMetadataFile backupMetadata = JsonSerializer.Deserialize<BackupMetadataFile>(
await File.ReadAllTextAsync(Path.Combine(syncResult.BackupDir, "metadata.json")),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!;
Assert.Equal([AppConstants.DbFileBasename], backupMetadata.DbFiles);
await using (SqliteConnection connection = fixture.OpenLegacySqliteConnection())
{
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, model_provider FROM threads ORDER BY id";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
List<(string Id, string Provider)> rows = [];
while (await reader.ReadAsync())
{
rows.Add((reader.GetString(0), reader.GetString(1)));
}
Assert.Equal(
[
("thread-active-a", "openai"),
("thread-active-b", "openai")
], rows);
}
await using (SqliteConnection connection = fixture.OpenSqliteConnection())
{
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, model_provider FROM threads ORDER BY id";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
List<(string Id, string Provider)> rows = [];
while (await reader.ReadAsync())
{
rows.Add((reader.GetString(0), reader.GetString(1)));
}
Assert.Equal([("thread-active-a", "dal")], rows);
}
}
[Fact]
public async Task RunSwitch_UpdatesConfigAndSyncsProviderMetadata()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "openai");
await fixture.WriteStateDbAsync(
[
("thread-a", "openai", false)
]);
CodexSyncService service = new();
SyncResult result = await service.RunSwitchAsync(fixture.CodexHome, "apigather");
Assert.Equal("apigather", result.TargetProvider);
Assert.True(result.ConfigUpdated);
string configText = await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml"));
Assert.Contains("model_provider = \"apigather\"", configText);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
}
[Fact]
public async Task RunSync_RepairsSqliteHasUserEventFromRolloutUserMessages()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "openai");
await fixture.WriteStateDbWithUserEventColumnAsync(
[
("thread-a", "openai", false, false)
]);
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal(1, result.SqliteUserEventRowsUpdated);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT has_user_event FROM threads WHERE id = 'thread-a'";
long hasUserEvent = (long)(await command.ExecuteScalarAsync())!;
Assert.Equal(1, hasUserEvent);
}
[Fact]
public async Task RunSync_RepairsSqliteCwdFromRolloutSessionMetadata()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-cwd.jsonl");
await fixture.WriteRolloutAsync(
sessionPath,
"thread-cwd",
"openai",
@"D:\GitHubProject\oss-maintainer-hub");
await fixture.WriteStateDbWithCwdAsync(
[
("thread-cwd", "openai", false, @"\\?\D:\GitHubProject\oss-maintainer-hub")
]);
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal(1, result.SqliteCwdRowsUpdated);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT cwd FROM threads WHERE id = 'thread-cwd'";
string cwd = (string)(await command.ExecuteScalarAsync())!;
Assert.Equal(@"D:\GitHubProject\oss-maintainer-hub", cwd);
}
[Fact]
public async Task RunSync_NormalizesExtendedRolloutCwd_BeforeRepairingSqlite()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-cwd-extended.jsonl");
await fixture.WriteRolloutAsync(
sessionPath,
"thread-cwd-extended",
"openai",
@"\\?\E:\GitHubProject\lin-framework");
await fixture.WriteStateDbWithCwdAsync(
[
("thread-cwd-extended", "openai", false, @"\\?\E:\GitHubProject\lin-framework")
]);
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal(1, result.SqliteCwdRowsUpdated);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT cwd FROM threads WHERE id = 'thread-cwd-extended'";
string cwd = (string)(await command.ExecuteScalarAsync())!;
Assert.Equal(@"E:\GitHubProject\lin-framework", cwd);
}
[Fact]
public async Task RunSync_RestoresWorkspaceRootsFromProjectOrder_NormalizesForDesktop_AndRestoreRevertsGlobalState()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
await fixture.WriteGlobalStateAsync(new Dictionary<string, object?>
{
["electron-saved-workspace-roots"] = new[]
{
@"\\?\D:\GitHubProject\codex-provider-sync"
},
["project-order"] = new[]
{
@"\\?\D:\GitHubProject\codex-provider-sync",
@"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets"
},
["active-workspace-roots"] = new[]
{
@"\\?\D:\GitHubProject\codex-provider-sync"
},
["electron-workspace-root-labels"] = new Dictionary<string, string>
{
[@"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets"] = "BrainLifeAssets"
}
});
await fixture.WriteStateDbWithCwdAsync(
[
("thread-a", "openai", false, @"\\?\D:\GitHubProject\codex-provider-sync"),
("thread-b", "openai", false, @"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets")
]);
CodexSyncService service = new();
SyncResult syncResult = await service.RunSyncAsync(fixture.CodexHome);
Assert.Equal(2, syncResult.UpdatedWorkspaceRoots);
JsonDocument syncedState = JsonDocument.Parse(
await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename)));
Assert.Equal(
[
@"D:\GitHubProject\codex-provider-sync",
@"E:\NewRich\BrainLife\Code\BrainLife\Assets"
],
syncedState.RootElement.GetProperty("electron-saved-workspace-roots").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
Assert.Equal(
[
@"D:\GitHubProject\codex-provider-sync",
@"E:\NewRich\BrainLife\Code\BrainLife\Assets"
],
syncedState.RootElement.GetProperty("project-order").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
Assert.Equal(
@"D:\GitHubProject\codex-provider-sync",
syncedState.RootElement.GetProperty("active-workspace-roots")[0].GetString());
Assert.Equal(
"BrainLifeAssets",
syncedState.RootElement.GetProperty("electron-workspace-root-labels")
.GetProperty(@"E:\NewRich\BrainLife\Code\BrainLife\Assets")
.GetString());
await service.RunRestoreAsync(fixture.CodexHome, syncResult.BackupDir);
JsonDocument restoredState = JsonDocument.Parse(
await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, AppConstants.GlobalStateFileBasename)));
Assert.Equal(
[
@"\\?\D:\GitHubProject\codex-provider-sync"
],
restoredState.RootElement.GetProperty("electron-saved-workspace-roots").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
Assert.Equal(
[
@"\\?\D:\GitHubProject\codex-provider-sync",
@"\\?\E:\NewRich\BrainLife\Code\BrainLife\Assets"
],
restoredState.RootElement.GetProperty("project-order").EnumerateArray().Select(static entry => entry.GetString()!).ToArray());
}
[Fact]
public async Task GetStatus_ReportsImplicitDefaultProviderAndCounts()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
string archivedPath = fixture.RolloutPath("archived_sessions", "rollout-b.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteRolloutAsync(archivedPath, "thread-b", "openai");
long backupOneBytes = await fixture.WriteBackupAsync("20260319T000000000Z", ("note.txt", "backup-one"));
long backupTwoBytes = await fixture.WriteBackupAsync("20260320T000000000Z", ("note.txt", "backup-two"));
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false),
("thread-b", "openai", true)
]);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.Equal("openai", status.CurrentProvider.Provider);
Assert.True(status.CurrentProvider.Implicit);
Assert.Equal(1, status.RolloutCounts.Sessions["apigather"]);
Assert.Equal(1, status.SqliteCounts!.ArchivedSessions["openai"]);
Assert.NotNull(status.StateDbLocation);
Assert.Equal("sqlite-dir", status.StateDbLocation!.Source);
Assert.Equal(fixture.StateDbPath(), status.StateDbLocation.Path);
Assert.Equal(2, status.BackupSummary.Count);
Assert.Equal(backupOneBytes + backupTwoBytes, status.BackupSummary.TotalBytes);
Assert.Contains($"database: {fixture.StateDbPath()}", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task GetStatus_FallsBackToLegacyRootSqliteDatabase()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
await using (SqliteConnection connection = fixture.OpenLegacySqliteConnection())
{
await connection.OpenAsync();
SqliteCommand create = connection.CreateCommand();
create.CommandText = """
CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
archived INTEGER NOT NULL DEFAULT 0
)
""";
await create.ExecuteNonQueryAsync();
SqliteCommand insert = connection.CreateCommand();
insert.CommandText = "INSERT INTO threads (id, model_provider, archived) VALUES ('legacy-thread', 'openai', 0)";
await insert.ExecuteNonQueryAsync();
}
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.NotNull(status.StateDbLocation);
Assert.Equal("legacy-root", status.StateDbLocation!.Source);
Assert.Equal(fixture.LegacyStateDbPath(), status.StateDbLocation.Path);
Assert.Equal(1, status.SqliteCounts!.Sessions["openai"]);
Assert.Contains("legacy root", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task GetStatus_ChoosesLegacyRootSqliteDatabase_WhenSqliteDirStateIsStale()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
await fixture.WriteRolloutAsync(
fixture.RolloutPath("sessions", "rollout-active-a.jsonl"),
"thread-active-a",
"openai");
await fixture.WriteRolloutAsync(
fixture.RolloutPath("sessions", "rollout-active-b.jsonl"),
"thread-active-b",
"openai");
await fixture.WriteRolloutAsync(
fixture.RolloutPath("archived_sessions", "rollout-active-c.jsonl"),
"thread-active-c",
"openai");
await fixture.WriteStateDbAsync(
[
("thread-active-a", "custom", false)
]);
await fixture.WriteLegacyStateDbAsync(
[
("thread-active-a", "openai", false),
("thread-active-b", "openai", false),
("thread-active-c", "openai", true)
]);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.NotNull(status.StateDbLocation);
Assert.Equal("legacy-root", status.StateDbLocation!.Source);
Assert.Equal(fixture.LegacyStateDbPath(), status.StateDbLocation.Path);
Assert.Equal(2, status.SqliteCounts!.Sessions["openai"]);
Assert.Equal(1, status.SqliteCounts.ArchivedSessions["openai"]);
Assert.Contains("legacy root", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task GetStatus_ReportsPendingSqliteUserEventAndCwdRepairs()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-repair-status.jsonl");
await fixture.WriteRolloutAsync(
sessionPath,
"thread-repair-status",
"openai",
@"E:\GitHubProject\lin-framework");
await fixture.WriteStateDbWithUserEventAndCwdAsync(
[
("thread-repair-status", "openai", false, false, @"\\?\E:\GitHubProject\lin-framework")
]);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.NotNull(status.SqliteRepairStats);
Assert.Equal(1, status.SqliteRepairStats!.UserEventRowsNeedingRepair);
Assert.Equal(1, status.SqliteRepairStats.CwdRowsNeedingRepair);
string formatted = TextFormatter.FormatStatus(status);
Assert.Contains("user-event flags needing repair: 1", formatted);
Assert.Contains("cwd paths needing repair: 1", formatted);
}
[Fact]
public async Task GetStatus_ReportsProjectVisibilityRanksAndCwdExactMatchDiagnostics()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"dal\"");
await fixture.WriteGlobalStateAsync(new Dictionary<string, object?>
{
["electron-saved-workspace-roots"] = new[]
{
@"E:\GitHubProject\lin-framework"
}
});
List<(string Id, string ModelProvider, string Cwd, string Source, bool Archived, string FirstUserMessage, long UpdatedAtMs)> rows = [];
for (int index = 0; index < 51; index += 1)
{
rows.Add(($"thread-other-{index:00}", "dal", @"D:\OtherProject", "cli", false, "hello", 1000 - index));
}
rows.Add(("thread-lin", "dal", @"\\?\E:\GitHubProject\lin-framework", "cli", false, "hello", 1));
await fixture.WriteStateDbForProjectVisibilityAsync(rows);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
ProjectThreadVisibility project = Assert.Single(status.ProjectThreadVisibility);
Assert.Equal(@"E:\GitHubProject\lin-framework", project.Root);
Assert.Equal(1, project.InteractiveThreads);
Assert.Equal(0, project.FirstPageThreads);
Assert.Equal([52], project.Ranks);
Assert.Equal(0, project.ExactCwdMatches);
Assert.Equal(1, project.VerbatimCwdRows);
string formatted = TextFormatter.FormatStatus(status);
Assert.Contains("Project visibility:", formatted);
Assert.Contains("first page 0/50, ranks 52, exact cwd 0/1, verbatim cwd 1", formatted);
}
[Fact]
public async Task RunSwitch_RejectsUnknownCustomProviders()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync(string.Empty);
CodexSyncService service = new();
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.RunSwitchAsync(fixture.CodexHome, "missing"));
Assert.Contains("Provider \"missing\" is not available", error.Message);
}
[Fact]
public async Task RunSync_LeavesRolloutsUntouched_WhenSqliteIsLocked()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
CodexSyncService service = new();
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand begin = connection.CreateCommand();
begin.CommandText = "BEGIN IMMEDIATE";
await begin.ExecuteNonQueryAsync();
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.RunSyncAsync(fixture.CodexHome, sqliteBusyTimeoutMs: 0));
Assert.Contains("state_5.sqlite is currently in use", error.Message);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
}
[Fact]
public async Task RunSync_SkipsLockedRolloutFiles_AndStillUpdatesSqlite()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
CodexSyncService service = new();
SyncResult result;
using (FileStream lockStream = new(sessionPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
result = await service.RunSyncAsync(fixture.CodexHome, sqliteBusyTimeoutMs: 0);
}
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal([sessionPath], result.SkippedLockedRolloutFiles);
Assert.Empty(result.SkippedUnreadableRolloutFiles);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
await using SqliteConnection connection = fixture.OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT model_provider FROM threads WHERE id = 'thread-a'";
string provider = (string)(await command.ExecuteScalarAsync())!;
Assert.Equal("openai", provider);
}
[Fact]
public async Task ApplySessionChanges_SkipsFile_WhenRolloutChangesAfterCollection()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
await File.AppendAllTextAsync(
sessionPath,
"{\"timestamp\":\"2026-03-19T00:00:01.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"assistant_message\",\"message\":\"later\"}}\n");
SessionApplyResult result = await service.ApplySessionChangesAsync(collected.Changes);
Assert.Equal(0, result.AppliedCount);
Assert.Equal([sessionPath], result.SkippedPaths);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
Assert.Contains("\"message\":\"later\"", rollout);
}
[Fact]
public async Task ApplySessionChanges_RewritesFile_WhenRolloutIsUnchanged()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
SessionApplyResult result = await service.ApplySessionChangesAsync(collected.Changes);
Assert.Equal(1, result.AppliedCount);
Assert.Empty(result.SkippedPaths);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"openai\"", rollout);
}
[Fact]
public async Task RestoreBackup_OnlyRestoresRolloutFilesThatWereActuallyApplied()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string configPath = Path.Combine(fixture.CodexHome, "config.toml");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
SessionRolloutService sessionService = new();
SessionChangeCollection collected = await sessionService.CollectSessionChangesAsync(fixture.CodexHome, "openai");
BackupService backupService = new(sessionService, new SqliteStateService());
string backupDir = await backupService.CreateBackupAsync(
fixture.CodexHome,
"openai",
collected.Changes,
configPath);
await backupService.UpdateSessionBackupManifestAsync(backupDir, []);
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "manual");
await backupService.RestoreBackupAsync(
backupDir,
fixture.CodexHome,
new RestoreBackupOptions
{
RestoreConfig = false,
RestoreDatabase = false,
RestoreSessions = true
});
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"manual\"", rollout);
}
[Fact]
public async Task RunSync_SkipsRolloutFile_WhenAnotherWriterAllowsSharing()
{
if (!OperatingSystem.IsWindows())
{
return;
}
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
CodexSyncService service = new();
SyncResult result;
using (FileStream writer = new(sessionPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete))
{
result = await service.RunSyncAsync(fixture.CodexHome, sqliteBusyTimeoutMs: 0);
}
Assert.Equal(0, result.ChangedSessionFiles);
Assert.Equal(1, result.SqliteRowsUpdated);
Assert.Equal([sessionPath], result.SkippedLockedRolloutFiles);
Assert.Empty(result.SkippedUnreadableRolloutFiles);
string rollout = await File.ReadAllTextAsync(sessionPath);
Assert.Contains("\"model_provider\":\"apigather\"", rollout);
}
[Fact]
public async Task Status_SkipsLockedRolloutFile_WhenAnotherWriterAllowsSharing()
{
if (!OperatingSystem.IsWindows())
{
return;
}
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-status-locked.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-status-locked", "openai");
CodexSyncService service = new();
using FileStream writer = new(sessionPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete);
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.Equal([sessionPath], status.LockedRolloutFiles);
Assert.Empty(status.UnreadableRolloutFiles);
Assert.Contains("Locked rollout files skipped during status scan: 1", TextFormatter.FormatStatus(status));
}
[Fact]
public void FormatStatus_ReportsUnreadableRolloutFiles()
{
StatusSnapshot status = new()
{
CodexHome = @"C:\Users\test\.codex",
CurrentProvider = new CurrentProviderInfo("openai", false),
ConfiguredProviders = ["openai"],
RolloutCounts = new ProviderCounts(),
LockedRolloutFiles = [],
UnreadableRolloutFiles = [@"C:\Users\test\.codex\sessions\rollout-bad.jsonl"],
EncryptedContentCounts = new ProviderCounts(),
SqliteCounts = null,
BackupRoot = @"C:\Users\test\.codex\backups_state\provider-sync",
BackupSummary = new BackupSummary
{
Count = 0,
TotalBytes = 0
}
};
Assert.Contains("Unreadable rollout files skipped during status scan: 1", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task RunPruneBackups_RemovesOldestBackupDirectories()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
long oldestBytes = await fixture.WriteBackupAsync(
"20260319T000000000Z",
("note.txt", "oldest"),
("db/state_5.sqlite", "sqlite"));
await fixture.WriteBackupAsync("20260320T000000000Z", ("note.txt", "middle"));
await fixture.WriteBackupAsync("20260321T000000000Z", ("note.txt", "newest"));
CodexSyncService service = new();
BackupPruneResult result = await service.RunPruneBackupsAsync(fixture.CodexHome, 2);
Assert.Equal(fixture.BackupRoot(), result.BackupRoot);
Assert.Equal(1, result.DeletedCount);
Assert.Equal(2, result.RemainingCount);
Assert.Equal(oldestBytes, result.FreedBytes);
Assert.False(Directory.Exists(fixture.BackupPath("20260319T000000000Z")));
Assert.True(Directory.Exists(fixture.BackupPath("20260320T000000000Z")));
Assert.True(Directory.Exists(fixture.BackupPath("20260321T000000000Z")));
}
[Fact]
public async Task RunPruneBackups_IgnoresDirectoriesWithoutManagedBackupMetadata()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
await fixture.WriteBackupAsync(
"20260320T000000000Z",
("metadata.json", $$"""
{
"version": 1,
"namespace": "provider-sync",
"codexHome": "{{fixture.CodexHome.Replace("\\", "\\\\")}}",
"targetProvider": "openai",
"createdAt": "2026-03-24T00:00:00.0000000+00:00",
"dbFiles": [],
"changedSessionFiles": 0
}
"""));
string junkDirectory = fixture.BackupPath("manual-notes");
Directory.CreateDirectory(junkDirectory);
await File.WriteAllTextAsync(Path.Combine(junkDirectory, "readme.txt"), "keep me");
CodexSyncService service = new();
BackupPruneResult result = await service.RunPruneBackupsAsync(fixture.CodexHome, 0);
Assert.Equal(1, result.DeletedCount);
Assert.Equal(0, result.RemainingCount);
Assert.True(Directory.Exists(junkDirectory));
}
[Fact]
public async Task RunSync_AutoPrunesBackupsToDefaultRetention()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
for (int index = 0; index < AppConstants.DefaultBackupRetentionCount; index += 1)
{
await fixture.WriteBackupAsync(
$"20240101T0000{index:00}000Z",
("note.txt", $"backup-{index}"));
}
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome);
string[] backupDirs = Directory.GetDirectories(fixture.BackupRoot());
Assert.Equal(AppConstants.DefaultBackupRetentionCount, backupDirs.Length);
Assert.True(Directory.Exists(result.BackupDir));
Assert.NotNull(result.AutoPruneResult);
Assert.Equal(1, result.AutoPruneResult!.DeletedCount);
Assert.Equal(AppConstants.DefaultBackupRetentionCount, result.AutoPruneResult.RemainingCount);
Assert.True(string.IsNullOrWhiteSpace(result.AutoPruneWarning));
}
[Fact]
public async Task RunSync_UsesCustomAutomaticBackupRetentionCount()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-a.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-a", "apigather");
await fixture.WriteStateDbAsync(
[
("thread-a", "apigather", false)
]);
for (int index = 0; index < 4; index += 1)
{
await fixture.WriteBackupAsync(
$"20240101T0000{index:00}000Z",
("note.txt", $"backup-{index}"));
}
CodexSyncService service = new();
SyncResult result = await service.RunSyncAsync(fixture.CodexHome, keepCount: 2);
string[] backupDirs = Directory.GetDirectories(fixture.BackupRoot());
Assert.Equal(2, backupDirs.Length);
Assert.True(Directory.Exists(result.BackupDir));
Assert.NotNull(result.AutoPruneResult);
Assert.Equal(3, result.AutoPruneResult!.DeletedCount);
Assert.Equal(2, result.AutoPruneResult.RemainingCount);
}
[Fact]
public async Task ApplySessionChanges_RestoresOriginalLastWriteTime()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
string sessionPath = fixture.RolloutPath("sessions", "rollout-mtime.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-mtime", "apigather");
DateTime originalTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
File.SetLastWriteTimeUtc(sessionPath, originalTime);
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
SessionApplyResult result = await service.ApplySessionChangesAsync(collected.Changes);
Assert.Equal(1, result.AppliedCount);
Assert.Equal(originalTime, File.GetLastWriteTimeUtc(sessionPath));
}
[Fact]
public async Task Status_ReportsEncryptedContentCountsAndWarning()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-enc.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-enc", "apigather");
await fixture.AppendEncryptedContentAsync(sessionPath);
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.Equal(1, status.EncryptedContentCounts.Sessions["apigather"]);
Assert.Contains("invalid_encrypted_content", status.EncryptedContentWarning);
}
[Fact]
public async Task CollectSessionChanges_StreamsLargeRolloutContent()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
string sessionPath = fixture.RolloutPath("sessions", "rollout-streamed.jsonl");
object payload = new
{
id = "thread-streamed",
timestamp = "2026-03-19T00:00:00.000Z",
cwd = "C:\\AITemp",
source = "cli",
cli_version = "0.115.0",
model_provider = "apigather"
};
string firstLine = JsonSerializer.Serialize(new
{
timestamp = "2026-03-19T00:00:00.000Z",
type = "session_meta",
payload
});
await File.WriteAllTextAsync(sessionPath, firstLine + "\n");
const int chunkBytes = 1024 * 1024;
const string tokenPrefix = "encrypted_";
string userEvent = JsonSerializer.Serialize(new
{
type = "event_msg",
payload = new
{
type = "user_message",
message = "after large content"
}
});
await File.AppendAllTextAsync(
sessionPath,
$"{new string('x', chunkBytes - tokenPrefix.Length)}{tokenPrefix}content\n{userEvent}\n");
SessionRolloutService service = new();
SessionChangeCollection collected = await service.CollectSessionChangesAsync(fixture.CodexHome, "openai");
Assert.Equal(1, collected.EncryptedContentCounts.Sessions["apigather"]);
Assert.Contains("thread-streamed", collected.UserEventThreadIds);
}
[Fact]
public async Task Status_ReturnsMalformedSqliteAsUnreadable()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
Directory.CreateDirectory(Path.GetDirectoryName(fixture.StateDbPath())!);
await File.WriteAllTextAsync(fixture.StateDbPath(), "not sqlite");
CodexSyncService service = new();
StatusSnapshot status = await service.GetStatusAsync(fixture.CodexHome);
Assert.True(status.SqliteCounts!.Unreadable);
Assert.Contains("malformed", TextFormatter.FormatStatus(status));
}
[Fact]
public async Task RestoreBackup_CanSkipConfigDatabaseAndSessions()
{
TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync();
await fixture.WriteConfigAsync("model_provider = \"openai\"");
string sessionPath = fixture.RolloutPath("sessions", "rollout-skip.jsonl");
await fixture.WriteRolloutAsync(sessionPath, "thread-skip", "apigather");
SessionRolloutService sessionService = new();
SessionChangeCollection collected = await sessionService.CollectSessionChangesAsync(fixture.CodexHome, "openai");
BackupService backupService = new(sessionService, new SqliteStateService());
string backupDir = await backupService.CreateBackupAsync(
fixture.CodexHome,
"openai",
collected.Changes,
Path.Combine(fixture.CodexHome, "config.toml"));
await fixture.WriteConfigAsync("model_provider = \"manual\"");
await fixture.WriteRolloutAsync(sessionPath, "thread-skip", "manual");
await backupService.RestoreBackupAsync(
backupDir,
fixture.CodexHome,
new RestoreBackupOptions
{
RestoreConfig = false,
RestoreDatabase = false,
RestoreSessions = false
});
Assert.Contains("model_provider = \"manual\"", await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml")));
Assert.Contains("\"model_provider\":\"manual\"", await File.ReadAllTextAsync(sessionPath));
}
}
@@ -0,0 +1,70 @@
namespace CodexProviderSync.Core.Tests;
public sealed class LockServiceTests
{
[Fact]
public async Task AcquireLockAsync_CreatesAndReleasesLockDirectory()
{
string codexHome = Path.Combine(Path.GetTempPath(), $"codex-provider-lock-{Guid.NewGuid():N}");
Directory.CreateDirectory(codexHome);
string lockPath = AppConstants.LockPath(codexHome);
await using (await new LockService().AcquireLockAsync(codexHome, "test"))
{
Assert.True(Directory.Exists(lockPath));
Assert.True(File.Exists(Path.Combine(lockPath, "owner.json")));
}
Assert.False(Directory.Exists(lockPath));
}
[Fact]
public async Task CreateLockDirectoryAsync_RetriesTransientAccessDeniedErrors()
{
int attempts = 0;
List<int> delays = [];
await LockService.CreateLockDirectoryAsync(
@"C:\temp\provider-sync.lock",
retryCount: 3,
retryDelayMs: 75,
delayAsync: delay =>
{
delays.Add(delay);
return Task.CompletedTask;
},
tryCreateDirectory: _ =>
{
attempts += 1;
return attempts < 3 ? 5 : 0;
});
Assert.Equal(3, attempts);
Assert.Equal([75, 75], delays);
}
[Fact]
public async Task CreateLockDirectoryAsync_ThrowsWhenLockAlreadyExists()
{
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(
() => LockService.CreateLockDirectoryAsync(
@"C:\temp\provider-sync.lock",
tryCreateDirectory: _ => 183));
Assert.Contains("Lock already exists", error.Message);
}
[Fact]
public async Task CreateLockDirectoryAsync_ThrowsAfterTransientRetryBudgetIsExhausted()
{
IOException error = await Assert.ThrowsAsync<IOException>(
() => LockService.CreateLockDirectoryAsync(
@"C:\temp\provider-sync.lock",
retryCount: 2,
retryDelayMs: 10,
delayAsync: _ => Task.CompletedTask,
tryCreateDirectory: _ => 5));
Assert.Contains("Win32 error: 5", error.Message);
}
}
@@ -0,0 +1,79 @@
namespace CodexProviderSync.Core.Tests;
public sealed class SettingsAndDiscoveryTests
{
[Fact]
public async Task SettingsService_PersistsRecentPathsAndProviders()
{
string uniqueSettingsRoot = Path.Combine(Path.GetTempPath(), $"codex-provider-settings-{Guid.NewGuid():N}");
SettingsService service = new(Path.Combine(uniqueSettingsRoot, "settings.json"));
AppSettings settings = new()
{
RecentCodexHomes = ["C:\\Users\\Administrator\\.codex"],
SavedProviders = ["apigather"],
ManualProviders = ["custom-a"],
LastSelectedProvider = "apigather",
BackupRetentionCount = 7,
UiLanguage = "zh-Hans"
};
await service.SaveAsync(settings);
AppSettings loaded = await service.LoadAsync();
Assert.Contains("apigather", loaded.SavedProviders);
Assert.Contains("custom-a", loaded.ManualProviders);
Assert.Equal("apigather", loaded.LastSelectedProvider);
Assert.Equal(7, loaded.BackupRetentionCount);
Assert.Equal("zh-Hans", loaded.UiLanguage);
}
[Fact]
public void ProviderDiscovery_MergesDetectedAndManualProviders()
{
ProviderDiscoveryService service = new();
StatusSnapshot status = new()
{
CodexHome = "C:\\Users\\Administrator\\.codex",
CurrentProvider = new CurrentProviderInfo("openai", false),
ConfiguredProviders = ["apigather", "openai"],
RolloutCounts = new ProviderCounts
{
Sessions = new Dictionary<string, int>(StringComparer.Ordinal)
{
["newapi"] = 2
},
ArchivedSessions = new Dictionary<string, int>(StringComparer.Ordinal)
},
LockedRolloutFiles = [],
UnreadableRolloutFiles = [],
EncryptedContentCounts = new ProviderCounts(),
SqliteCounts = new ProviderCounts
{
Sessions = new Dictionary<string, int>(StringComparer.Ordinal),
ArchivedSessions = new Dictionary<string, int>(StringComparer.Ordinal)
{
["azure"] = 1
}
},
BackupRoot = "C:\\Users\\Administrator\\.codex\\backups_state\\provider-sync",
BackupSummary = new BackupSummary
{
Count = 2,
TotalBytes = 1024
}
};
AppSettings settings = new()
{
SavedProviders = ["saved-only"],
ManualProviders = ["manual-only"]
};
IReadOnlyList<ProviderOption> options = service.BuildProviderOptions(status, settings);
Assert.Contains(options, option => option.Id == "openai" && option.IsCurrentProvider);
Assert.Contains(options, option => option.Id == "apigather" && option.Sources.Contains(ProviderSource.Config));
Assert.Contains(options, option => option.Id == "newapi" && option.Sources.Contains(ProviderSource.Rollout));
Assert.Contains(options, option => option.Id == "azure" && option.Sources.Contains(ProviderSource.Sqlite));
Assert.Contains(options, option => option.Id == "manual-only" && option.IsManual);
}
}
@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace CodexProviderSync.Core.Tests;
public sealed class SingleInstanceGuardTests
{
private static (string lockDir, string settingsRoot) NewIsolatedLockDir()
{
string root = Path.Combine(Path.GetTempPath(), $"codex-provider-singleton-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
string lockDir = Path.Combine(root, "singleton");
return (lockDir, root);
}
private static void Cleanup(string settingsRoot)
{
try
{
if (Directory.Exists(settingsRoot))
{
Directory.Delete(settingsRoot, recursive: true);
}
}
catch
{
// best-effort
}
}
[Fact]
public void Acquire_FirstCallerIsOwner_AndLockDirectoryExists()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
SingleInstanceGuard guard = new(_ => false, lockDir);
using SingleInstanceAcquisition acquisition = guard.Acquire("test");
Assert.True(acquisition.IsOwner);
Assert.Null(acquisition.ExistingOwner);
Assert.Equal(lockDir, guard.LockDirectory);
Assert.True(Directory.Exists(lockDir));
Assert.True(File.Exists(Path.Combine(lockDir, "owner.json")));
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_SecondCallerSeesExistingOwner_WhenFirstIsAlive()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
// First call takes the lock. We then mutate owner.json to
// advertise a different (synthetic) pid so the test simulates a
// different process holding the lock — within a single test
// process we cannot Acquire twice as owner (the guard's
// stale-recovery path would treat the first as a self-stale).
SingleInstanceGuard first = new(_ => true, lockDir);
using SingleInstanceAcquisition firstAcq = first.Acquire("first");
Assert.True(firstAcq.IsOwner);
string ownerPath = Path.Combine(lockDir, "owner.json");
string ownerJson = File.ReadAllText(ownerPath);
File.WriteAllText(ownerPath, ownerJson.Replace(
$"\"processId\": {Environment.ProcessId}",
"\"processId\": 99999"));
SingleInstanceGuard second = new(_ => true, lockDir);
using SingleInstanceAcquisition secondAcq = second.Acquire("second");
Assert.False(secondAcq.IsOwner);
Assert.NotNull(secondAcq.ExistingOwner);
Assert.Equal(99999, secondAcq.ExistingOwner!.ProcessId);
Assert.Equal("first", secondAcq.ExistingOwner.Label);
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_RecoversFromStaleLock_WhenPreviousOwnerIsDead()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
// First acquisition is "abandoned" without being disposed.
SingleInstanceGuard stale = new(_ => true, lockDir);
SingleInstanceAcquisition staleAcq = stale.Acquire("stale");
Assert.True(staleAcq.IsOwner);
// Pretend the stale owner died by leaving the dir on disk.
staleAcq.Dispose();
// New acquisition must observe a dead owner (probe returns false) and
// recover by taking the lock itself.
SingleInstanceGuard fresh = new(_ => false, lockDir);
using SingleInstanceAcquisition freshAcq = fresh.Acquire("fresh");
Assert.True(freshAcq.IsOwner);
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Dispose_RemovesLockDirectoryForOwner()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
SingleInstanceGuard guard = new(_ => false, lockDir);
using (guard.Acquire("owner"))
{
Assert.True(Directory.Exists(lockDir));
}
Assert.False(Directory.Exists(lockDir));
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_RecoversWhenPreviousLockContainsLeftoverFile()
{
(string lockDir, string settingsRoot) = NewIsolatedLockDir();
try
{
// First call takes the lock with a live probe, then we abandon it
// (Dispose). Dispose() removes the lock directory, so we recreate
// it manually and drop a stray file inside to simulate a partial
// cleanup. The second call has the probe say "dead", so the guard
// has to clear the entire tree (including the stray file) and
// reclaim the lock.
SingleInstanceGuard first = new(_ => true, lockDir);
SingleInstanceAcquisition firstAcq = first.Acquire("first");
firstAcq.Dispose();
Directory.CreateDirectory(lockDir);
File.WriteAllText(Path.Combine(lockDir, "BLOCK"), "leftover");
SingleInstanceGuard second = new(_ => false, lockDir);
using SingleInstanceAcquisition secondAcq = second.Acquire("second");
Assert.True(secondAcq.IsOwner);
Assert.False(File.Exists(Path.Combine(lockDir, "BLOCK")));
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_FirstCallOnSharedParentDirCreatesLockDirAtomically_AndSecondCallSeesExistingOwner()
{
// Regression guard: previously `Acquire` called
// `Directory.CreateDirectory(LockDirectory)` first, which
// recursively creates the lock dir on the very first
// launch. The follow-up atomic `TryCreateDirectory(LockDirectory)`
// then always returned `Win32ErrorAlreadyExists` and
// routed the first caller into the stale-lock recovery
// branch. With the fix, only the parent directory is
// pre-created; the lock dir itself is created atomically
// by `TryCreateDirectory`, so the first launch takes the
// owner branch directly without touching the recovery
// path. We verify both halves of that contract here:
//
// 1. The very first call writes `owner.json` (i.e.
// took the atomic-create success branch, not the
// stale-recovery branch).
// 2. A second call, when the lock dir already exists
// and owner.json names a live pid, returns
// `IsOwner == false` instead of clearing the lock
// and reclaiming it.
//
// We cannot use two callers in the same test process
// because `Environment.ProcessId` is constant for the
// whole process — a second in-process `Acquire` would
// see its own pid in the just-written `owner.json` and
// fall into the stale-recovery branch (a Windows pid
// recycle scenario, not a real concurrent-launch race).
// Instead, we seed `owner.json` with a fictitious live
// pid and let the second caller observe it. This is
// exactly what a concurrent launch from another process
// looks like at the filesystem level.
string settingsRoot = Path.Combine(Path.GetTempPath(), $"codex-provider-singleton-{Guid.NewGuid():N}");
Directory.CreateDirectory(settingsRoot);
string lockDir = Path.Combine(settingsRoot, "singleton");
try
{
// First caller: lockDir does not exist yet, the
// atomic `TryCreateDirectory` must succeed and we
// must take the owner branch.
SingleInstanceGuard firstGuard = new(static _ => true, lockDir);
using SingleInstanceAcquisition firstAcq = firstGuard.Acquire("first");
Assert.True(firstAcq.IsOwner);
Assert.Null(firstAcq.ExistingOwner);
Assert.True(File.Exists(Path.Combine(lockDir, "owner.json")),
"first caller must take the atomic-create success branch and write owner.json directly");
firstAcq.Dispose();
// Seed `owner.json` with a fictitious live pid so
// the second caller observes the existing-owner
// branch instead of the pid-recycle stale branch.
// We use `pid = 1` (system init / PID 1 is always
// alive on Windows/Linux/macOS for the duration of
// any test run) and a probe that returns true for
// any pid.
Directory.CreateDirectory(lockDir);
File.WriteAllText(Path.Combine(lockDir, "owner.json"),
"{\"processId\":1,\"startedAt\":\"2026-01-01T00:00:00+00:00\",\"label\":\"existing\",\"currentDirectory\":\"C:\\\\\"}");
SingleInstanceGuard secondGuard = new(static _ => true, lockDir);
using SingleInstanceAcquisition secondAcq = secondGuard.Acquire("second");
Assert.False(secondAcq.IsOwner);
Assert.NotNull(secondAcq.ExistingOwner);
Assert.Equal(1, secondAcq.ExistingOwner!.ProcessId);
Assert.True(File.Exists(Path.Combine(lockDir, "owner.json")),
"second caller must not delete the lock directory when the owner is still alive");
}
finally
{
Cleanup(settingsRoot);
}
}
[Fact]
public void Acquire_PidRecycleInOwnerJson_TreatedAsStaleAndReclaimed()
{
// Companion regression guard: when `owner.json`
// contains a pid that matches the current process but
// the process did NOT actually take the lock (e.g.,
// Windows reused our pid for a brand-new process while
// the previous owner crashed mid-write), the guard
// must treat that as stale and reclaim the lock. The
// signal we use is: the lock dir contains an
// `owner.json` with our pid but no leftover file from
// a partial write, and a probe that says the pid is
// dead. We hand-craft that scenario here.
string settingsRoot = Path.Combine(Path.GetTempPath(), $"codex-provider-singleton-{Guid.NewGuid():N}");
Directory.CreateDirectory(settingsRoot);
string lockDir = Path.Combine(settingsRoot, "singleton");
try
{
Directory.CreateDirectory(lockDir);
File.WriteAllText(Path.Combine(lockDir, "owner.json"),
$"{{\"processId\":{Environment.ProcessId},\"startedAt\":\"2026-01-01T00:00:00+00:00\",\"label\":\"dead-recycle\",\"currentDirectory\":\"C:\\\\\"}}");
SingleInstanceGuard guard = new(static _ => false, lockDir);
using SingleInstanceAcquisition acq = guard.Acquire("recycled");
Assert.True(acq.IsOwner);
Assert.Null(acq.ExistingOwner);
}
finally
{
Cleanup(settingsRoot);
}
}
}
@@ -0,0 +1,324 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace CodexProviderSync.Core.Tests;
internal sealed class TestCodexHomeFixture
{
private TestCodexHomeFixture(string root, string codexHome)
{
Root = root;
CodexHome = codexHome;
}
public string Root { get; }
public string CodexHome { get; }
public static async Task<TestCodexHomeFixture> CreateAsync()
{
string root = Path.Combine(Path.GetTempPath(), $"codex-provider-sync-{Guid.NewGuid():N}");
string codexHome = Path.Combine(root, ".codex");
Directory.CreateDirectory(Path.Combine(codexHome, "sessions", "2026", "03", "19"));
Directory.CreateDirectory(Path.Combine(codexHome, "archived_sessions", "2026", "03", "18"));
return await Task.FromResult(new TestCodexHomeFixture(root, codexHome));
}
public string RolloutPath(string directory, string fileName)
{
return Path.Combine(CodexHome, directory, "2026", "03", directory == "sessions" ? "19" : "18", fileName);
}
public string BackupRoot()
{
return Path.Combine(CodexHome, "backups_state", AppConstants.BackupNamespace);
}
public string BackupPath(string directoryName)
{
return Path.Combine(BackupRoot(), directoryName);
}
public string StateDbPath()
{
return Path.Combine(CodexHome, AppConstants.SqliteDirBasename, AppConstants.DbFileBasename);
}
public string LegacyStateDbPath()
{
return Path.Combine(CodexHome, AppConstants.DbFileBasename);
}
public async Task WriteConfigAsync(string modelProviderLine)
{
string prefix = string.IsNullOrWhiteSpace(modelProviderLine) ? string.Empty : modelProviderLine + "\n";
string configText = $"{prefix}sandbox_mode = \"danger-full-access\"\n\n[model_providers.apigather]\nbase_url = \"https://example.com\"\n";
await File.WriteAllTextAsync(Path.Combine(CodexHome, "config.toml"), configText);
}
public async Task WriteGlobalStateAsync(object state)
{
string json = JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true }) + "\n";
await File.WriteAllTextAsync(Path.Combine(CodexHome, AppConstants.GlobalStateFileBasename), json);
await File.WriteAllTextAsync(Path.Combine(CodexHome, AppConstants.GlobalStateBackupFileBasename), json);
}
public async Task WriteRolloutAsync(string filePath, string id, string provider, string cwd = "C:\\AITemp")
{
object payload = new
{
id,
timestamp = "2026-03-19T00:00:00.000Z",
cwd,
source = "cli",
cli_version = "0.115.0",
model_provider = provider
};
string first = JsonSerializer.Serialize(new
{
timestamp = "2026-03-19T00:00:00.000Z",
type = "session_meta",
payload
});
string second = JsonSerializer.Serialize(new
{
timestamp = "2026-03-19T00:00:00.000Z",
type = "event_msg",
payload = new
{
type = "user_message",
message = "hi"
}
});
await File.WriteAllTextAsync(filePath, $"{first}\n{second}\n");
}
public async Task AppendEncryptedContentAsync(string filePath)
{
await File.AppendAllTextAsync(filePath, "{\"type\":\"event_msg\",\"payload\":{\"encrypted_content\":\"gAAA\"}}\n");
}
public async Task<long> WriteBackupAsync(string directoryName, params (string RelativePath, string Content)[] files)
{
string backupDir = BackupPath(directoryName);
Directory.CreateDirectory(backupDir);
long totalBytes = 0;
if (!files.Any(file => string.Equals(file.RelativePath, "metadata.json", StringComparison.Ordinal)))
{
string metadataContent = $$"""
{
"version": 1,
"namespace": "provider-sync",
"codexHome": "{{CodexHome.Replace("\\", "\\\\")}}",
"targetProvider": "openai",
"createdAt": "2026-03-24T00:00:00.0000000+00:00",
"dbFiles": [],
"changedSessionFiles": 0
}
""";
string metadataPath = Path.Combine(backupDir, "metadata.json");
await File.WriteAllTextAsync(metadataPath, metadataContent);
totalBytes += new FileInfo(metadataPath).Length;
}
foreach ((string relativePath, string content) in files)
{
string fullPath = Path.Combine(backupDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, content);
totalBytes += new FileInfo(fullPath).Length;
}
return totalBytes;
}
public async Task WriteStateDbAsync(IEnumerable<(string Id, string ModelProvider, bool Archived)> rows)
{
await WriteStateDbAtAsync(StateDbPath(), rows);
}
public async Task WriteLegacyStateDbAsync(IEnumerable<(string Id, string ModelProvider, bool Archived)> rows)
{
await WriteStateDbAtAsync(LegacyStateDbPath(), rows);
}
private static async Task WriteStateDbAtAsync(string dbPath, IEnumerable<(string Id, string ModelProvider, bool Archived)> rows)
{
await using SqliteConnection connection = OpenSqliteConnection(dbPath);
await connection.OpenAsync();
SqliteCommand create = connection.CreateCommand();
create.CommandText = """
CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
first_user_message TEXT NOT NULL DEFAULT ''
)
""";
await create.ExecuteNonQueryAsync();
foreach ((string id, string modelProvider, bool archived) in rows)
{
SqliteCommand insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO threads (id, model_provider, cwd, archived, first_user_message)
VALUES ($id, $provider, 'C:\AITemp', $archived, 'hello')
""";
insert.Parameters.AddWithValue("$id", id);
insert.Parameters.AddWithValue("$provider", modelProvider);
insert.Parameters.AddWithValue("$archived", archived ? 1 : 0);
await insert.ExecuteNonQueryAsync();
}
}
public async Task WriteStateDbWithUserEventColumnAsync(IEnumerable<(string Id, string ModelProvider, bool Archived, bool HasUserEvent)> rows)
{
await using SqliteConnection connection = OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand create = connection.CreateCommand();
create.CommandText = """
CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
first_user_message TEXT NOT NULL DEFAULT ''
)
""";
await create.ExecuteNonQueryAsync();
foreach ((string id, string modelProvider, bool archived, bool hasUserEvent) in rows)
{
SqliteCommand insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO threads (id, model_provider, cwd, archived, has_user_event, first_user_message)
VALUES ($id, $provider, 'C:\AITemp', $archived, $hasUserEvent, 'hello')
""";
insert.Parameters.AddWithValue("$id", id);
insert.Parameters.AddWithValue("$provider", modelProvider);
insert.Parameters.AddWithValue("$archived", archived ? 1 : 0);
insert.Parameters.AddWithValue("$hasUserEvent", hasUserEvent ? 1 : 0);
await insert.ExecuteNonQueryAsync();
}
}
public async Task WriteStateDbWithUserEventAndCwdAsync(IEnumerable<(string Id, string ModelProvider, bool Archived, bool HasUserEvent, string Cwd)> rows)
{
await using SqliteConnection connection = OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand create = connection.CreateCommand();
create.CommandText = """
CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
first_user_message TEXT NOT NULL DEFAULT ''
)
""";
await create.ExecuteNonQueryAsync();
foreach ((string id, string modelProvider, bool archived, bool hasUserEvent, string cwd) in rows)
{
SqliteCommand insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO threads (id, model_provider, cwd, archived, has_user_event, first_user_message)
VALUES ($id, $provider, $cwd, $archived, $hasUserEvent, 'hello')
""";
insert.Parameters.AddWithValue("$id", id);
insert.Parameters.AddWithValue("$provider", modelProvider);
insert.Parameters.AddWithValue("$cwd", cwd);
insert.Parameters.AddWithValue("$archived", archived ? 1 : 0);
insert.Parameters.AddWithValue("$hasUserEvent", hasUserEvent ? 1 : 0);
await insert.ExecuteNonQueryAsync();
}
}
public async Task WriteStateDbWithCwdAsync(IEnumerable<(string Id, string ModelProvider, bool Archived, string Cwd)> rows)
{
await using SqliteConnection connection = OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand create = connection.CreateCommand();
create.CommandText = """
CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
first_user_message TEXT NOT NULL DEFAULT ''
)
""";
await create.ExecuteNonQueryAsync();
foreach ((string id, string modelProvider, bool archived, string cwd) in rows)
{
SqliteCommand insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO threads (id, model_provider, cwd, archived, first_user_message)
VALUES ($id, $provider, $cwd, $archived, 'hello')
""";
insert.Parameters.AddWithValue("$id", id);
insert.Parameters.AddWithValue("$provider", modelProvider);
insert.Parameters.AddWithValue("$cwd", cwd);
insert.Parameters.AddWithValue("$archived", archived ? 1 : 0);
await insert.ExecuteNonQueryAsync();
}
}
public async Task WriteStateDbForProjectVisibilityAsync(IEnumerable<(string Id, string ModelProvider, string Cwd, string Source, bool Archived, string FirstUserMessage, long UpdatedAtMs)> rows)
{
await using SqliteConnection connection = OpenSqliteConnection();
await connection.OpenAsync();
SqliteCommand create = connection.CreateCommand();
create.CommandText = """
CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'cli',
archived INTEGER NOT NULL DEFAULT 0,
first_user_message TEXT NOT NULL DEFAULT '',
updated_at_ms INTEGER NOT NULL DEFAULT 0
)
""";
await create.ExecuteNonQueryAsync();
foreach ((string id, string modelProvider, string cwd, string source, bool archived, string firstUserMessage, long updatedAtMs) in rows)
{
SqliteCommand insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO threads (id, model_provider, cwd, source, archived, first_user_message, updated_at_ms)
VALUES ($id, $provider, $cwd, $source, $archived, $firstUserMessage, $updatedAtMs)
""";
insert.Parameters.AddWithValue("$id", id);
insert.Parameters.AddWithValue("$provider", modelProvider);
insert.Parameters.AddWithValue("$cwd", cwd);
insert.Parameters.AddWithValue("$source", source);
insert.Parameters.AddWithValue("$archived", archived ? 1 : 0);
insert.Parameters.AddWithValue("$firstUserMessage", firstUserMessage);
insert.Parameters.AddWithValue("$updatedAtMs", updatedAtMs);
await insert.ExecuteNonQueryAsync();
}
}
public SqliteConnection OpenSqliteConnection()
{
return OpenSqliteConnection(StateDbPath());
}
public SqliteConnection OpenLegacySqliteConnection()
{
return OpenSqliteConnection(LegacyStateDbPath());
}
private static SqliteConnection OpenSqliteConnection(string dbPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
return new SqliteConnection($"Data Source={dbPath};Mode=ReadWriteCreate;Pooling=False");
}
}
@@ -0,0 +1,45 @@
using System;
using System.IO;
namespace CodexProviderSync.Core;
public static class AppConstants
{
public const string DefaultProvider = "openai";
public const string BackupNamespace = "provider-sync";
public const string DbFileBasename = "state_5.sqlite";
public const string SqliteDirBasename = "sqlite";
public const string GlobalStateFileBasename = ".codex-global-state.json";
public const string GlobalStateBackupFileBasename = ".codex-global-state.json.bak";
public const int DefaultBackupRetentionCount = 5;
public static readonly string[] SessionDirectories = ["sessions", "archived_sessions"];
public static string DefaultCodexHome()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codex");
}
public static string DefaultBackupRoot(string codexHome)
{
return Path.Combine(codexHome, "backups_state", BackupNamespace);
}
public static string SettingsDirectory()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"codex-provider-sync");
}
public static string SettingsPath()
{
return Path.Combine(SettingsDirectory(), "settings.json");
}
public static string LockPath(string codexHome)
{
return Path.Combine(codexHome, "tmp", "provider-sync.lock");
}
}
@@ -0,0 +1,386 @@
using System.Text.Json;
namespace CodexProviderSync.Core;
public sealed class BackupService
{
private readonly SessionRolloutService _sessionRolloutService;
private readonly SqliteStateService _sqliteStateService;
public BackupService(SessionRolloutService sessionRolloutService, SqliteStateService sqliteStateService)
{
_sessionRolloutService = sessionRolloutService;
_sqliteStateService = sqliteStateService;
}
public async Task<string> CreateBackupAsync(
string codexHome,
string targetProvider,
IReadOnlyList<SessionChange> sessionChanges,
string configPath,
string? configBackupText = null)
{
string backupRoot = AppConstants.DefaultBackupRoot(codexHome);
string backupDir = Path.Combine(backupRoot, DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmssfff'Z'"));
string dbDir = Path.Combine(backupDir, "db");
Directory.CreateDirectory(dbDir);
List<string> copiedDbFiles = [];
StateDbLocation? stateDb = _sqliteStateService.DetectStateDb(codexHome);
if (stateDb is not null)
{
foreach (string suffix in new[] { string.Empty, "-shm", "-wal" })
{
string relativePath = DbBackupRelativePath(codexHome, stateDb.Path, suffix);
if (await CopyIfPresentAsync(stateDb.Path + suffix, Path.Combine(dbDir, relativePath), overwrite: false))
{
copiedDbFiles.Add(relativePath);
}
}
}
string configBackupPath = Path.Combine(backupDir, "config.toml");
if (configBackupText is not null)
{
await File.WriteAllTextAsync(configBackupPath, configBackupText);
}
else
{
await CopyIfPresentAsync(configPath, configBackupPath, overwrite: false);
}
await CopyIfPresentAsync(
Path.Combine(codexHome, AppConstants.GlobalStateFileBasename),
Path.Combine(backupDir, AppConstants.GlobalStateFileBasename),
overwrite: false);
await CopyIfPresentAsync(
Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename),
Path.Combine(backupDir, AppConstants.GlobalStateBackupFileBasename),
overwrite: false);
DateTimeOffset createdAt = DateTimeOffset.UtcNow;
SessionBackupManifest sessionManifest = new()
{
Version = 1,
Namespace = AppConstants.BackupNamespace,
CodexHome = codexHome,
TargetProvider = targetProvider,
CreatedAt = createdAt,
Files = sessionChanges.Select(static change => new SessionBackupManifestEntry
{
Path = change.Path,
OriginalFirstLine = change.OriginalFirstLine,
OriginalSeparator = change.OriginalSeparator,
OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks
}).ToList()
};
await File.WriteAllTextAsync(
Path.Combine(backupDir, "session-meta-backup.json"),
JsonSerializer.Serialize(sessionManifest, JsonOptions()));
BackupMetadataFile metadata = new()
{
Version = 1,
Namespace = AppConstants.BackupNamespace,
CodexHome = codexHome,
TargetProvider = targetProvider,
CreatedAt = createdAt,
DbFiles = copiedDbFiles,
ChangedSessionFiles = sessionChanges.Count
};
await File.WriteAllTextAsync(
Path.Combine(backupDir, "metadata.json"),
JsonSerializer.Serialize(metadata, JsonOptions()));
return backupDir;
}
public async Task<RestoreResult> RestoreBackupAsync(
string backupDir,
string codexHome,
RestoreBackupOptions? options = null)
{
options ??= new RestoreBackupOptions();
string normalizedBackupDir = Path.GetFullPath(backupDir);
BackupMetadataFile metadata = JsonSerializer.Deserialize<BackupMetadataFile>(
await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "metadata.json")),
JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}");
if (!string.Equals(metadata.CodexHome, codexHome, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Backup was created for {metadata.CodexHome}, not {codexHome}.");
}
SessionBackupManifest? sessionManifest = null;
if (options.RestoreSessions)
{
sessionManifest = JsonSerializer.Deserialize<SessionBackupManifest>(
await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "session-meta-backup.json")),
JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}");
await _sessionRolloutService.AssertSessionFilesWritableAsync(
sessionManifest.Files.Select(static entry => entry.Path));
}
if (options.RestoreConfig)
{
await CopyIfPresentAsync(
Path.Combine(normalizedBackupDir, "config.toml"),
Path.Combine(codexHome, "config.toml"),
overwrite: true);
await CopyIfPresentAsync(
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename),
Path.Combine(codexHome, AppConstants.GlobalStateFileBasename),
overwrite: true);
await CopyIfPresentAsync(
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename),
Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename),
overwrite: true);
}
if (options.RestoreDatabase)
{
await _sqliteStateService.AssertSqliteWritableAsync(codexHome);
string dbDir = Path.Combine(normalizedBackupDir, "db");
HashSet<string> backedUpFiles = new(metadata.DbFiles, StringComparer.Ordinal);
foreach (string baseFile in metadata.DbFiles.Where(static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename))
{
string basePath = RestoreDbTargetPath(codexHome, baseFile);
foreach (string suffix in new[] { "-shm", "-wal" })
{
string sidecarFile = baseFile + suffix;
string sidecarPath = basePath + suffix;
if (!backedUpFiles.Contains(sidecarFile) && File.Exists(sidecarPath))
{
File.Delete(sidecarPath);
}
}
}
foreach (string fileName in metadata.DbFiles)
{
await CopyIfPresentAsync(Path.Combine(dbDir, fileName), RestoreDbTargetPath(codexHome, fileName), overwrite: true);
}
}
if (options.RestoreSessions && sessionManifest is not null)
{
await _sessionRolloutService.RestoreSessionChangesAsync(sessionManifest.Files);
}
return new RestoreResult
{
CodexHome = codexHome,
BackupDir = normalizedBackupDir,
TargetProvider = metadata.TargetProvider,
CreatedAt = metadata.CreatedAt,
ChangedSessionFiles = metadata.ChangedSessionFiles
};
}
public async Task UpdateSessionBackupManifestAsync(string backupDir, IReadOnlyList<SessionChange> sessionChanges)
{
string normalizedBackupDir = Path.GetFullPath(backupDir);
string manifestPath = Path.Combine(normalizedBackupDir, "session-meta-backup.json");
string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json");
SessionBackupManifest sessionManifest = JsonSerializer.Deserialize<SessionBackupManifest>(
await File.ReadAllTextAsync(manifestPath),
JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}");
BackupMetadataFile metadata = JsonSerializer.Deserialize<BackupMetadataFile>(
await File.ReadAllTextAsync(metadataPath),
JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}");
sessionManifest = new SessionBackupManifest
{
Version = sessionManifest.Version,
Namespace = sessionManifest.Namespace,
CodexHome = sessionManifest.CodexHome,
TargetProvider = sessionManifest.TargetProvider,
CreatedAt = sessionManifest.CreatedAt,
Files = sessionChanges.Select(static change => new SessionBackupManifestEntry
{
Path = change.Path,
OriginalFirstLine = change.OriginalFirstLine,
OriginalSeparator = change.OriginalSeparator,
OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks
}).ToList()
};
metadata = new BackupMetadataFile
{
Version = metadata.Version,
Namespace = metadata.Namespace,
CodexHome = metadata.CodexHome,
TargetProvider = metadata.TargetProvider,
CreatedAt = metadata.CreatedAt,
DbFiles = metadata.DbFiles,
ChangedSessionFiles = sessionChanges.Count
};
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(sessionManifest, JsonOptions()));
await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(metadata, JsonOptions()));
}
public async Task RestoreGlobalStateFilesAsync(string backupDir, string codexHome)
{
string normalizedBackupDir = Path.GetFullPath(backupDir);
await CopyIfPresentAsync(
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename),
Path.Combine(codexHome, AppConstants.GlobalStateFileBasename),
overwrite: true);
await CopyIfPresentAsync(
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename),
Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename),
overwrite: true);
}
public Task<BackupSummary> GetBackupSummaryAsync(string codexHome)
{
string backupRoot = AppConstants.DefaultBackupRoot(codexHome);
return Task.Run(() =>
{
if (!Directory.Exists(backupRoot))
{
return new BackupSummary
{
Count = 0,
TotalBytes = 0
};
}
List<DirectoryInfo> entries = GetManagedBackupDirectories(backupRoot);
long totalBytes = entries.Sum(static entry => GetDirectorySize(entry.FullName));
return new BackupSummary
{
Count = entries.Count,
TotalBytes = totalBytes
};
});
}
public Task<BackupPruneResult> PruneBackupsAsync(string codexHome, int keepCount = AppConstants.DefaultBackupRetentionCount)
{
if (keepCount < 0)
{
throw new ArgumentOutOfRangeException(nameof(keepCount), keepCount, "keepCount must be 0 or greater.");
}
string backupRoot = AppConstants.DefaultBackupRoot(codexHome);
return Task.Run(() =>
{
if (!Directory.Exists(backupRoot))
{
return new BackupPruneResult
{
BackupRoot = backupRoot,
DeletedCount = 0,
RemainingCount = 0,
FreedBytes = 0
};
}
List<DirectoryInfo> entries = GetManagedBackupDirectories(backupRoot);
List<DirectoryInfo> toDelete = entries.Skip(keepCount).ToList();
long freedBytes = 0;
foreach (DirectoryInfo entry in toDelete)
{
freedBytes += GetDirectorySize(entry.FullName);
entry.Delete(recursive: true);
}
return new BackupPruneResult
{
BackupRoot = backupRoot,
DeletedCount = toDelete.Count,
RemainingCount = entries.Count - toDelete.Count,
FreedBytes = freedBytes
};
});
}
private static async Task<bool> CopyIfPresentAsync(string sourcePath, string destinationPath, bool overwrite)
{
if (!File.Exists(sourcePath))
{
return false;
}
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(sourcePath, destinationPath, overwrite);
await Task.CompletedTask;
return true;
}
private static string DbBackupRelativePath(string codexHome, string dbPath, string suffix)
{
string relativePath = Path.GetRelativePath(codexHome, dbPath + suffix);
return !relativePath.StartsWith("..", StringComparison.Ordinal)
&& !Path.IsPathRooted(relativePath)
? relativePath
: AppConstants.DbFileBasename + suffix;
}
private static string RestoreDbTargetPath(string codexHome, string relativePath)
{
if (Path.IsPathRooted(relativePath)
|| relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..", StringComparer.Ordinal))
{
throw new InvalidOperationException($"Invalid database backup path: {relativePath}");
}
return Path.Combine(codexHome, relativePath);
}
private static JsonSerializerOptions JsonOptions()
{
return new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
}
private static long GetDirectorySize(string directoryPath)
{
if (!Directory.Exists(directoryPath))
{
return 0;
}
return Directory
.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)
.Sum(static filePath => new FileInfo(filePath).Length);
}
private static List<DirectoryInfo> GetManagedBackupDirectories(string backupRoot)
{
return new DirectoryInfo(backupRoot)
.EnumerateDirectories()
.Where(static entry => IsManagedBackupDirectory(entry.FullName))
.OrderByDescending(static entry => entry.Name, StringComparer.Ordinal)
.ToList();
}
private static bool IsManagedBackupDirectory(string backupDirectoryPath)
{
string metadataPath = Path.Combine(backupDirectoryPath, "metadata.json");
if (!File.Exists(metadataPath))
{
return false;
}
try
{
BackupMetadataFile? metadata = JsonSerializer.Deserialize<BackupMetadataFile>(
File.ReadAllText(metadataPath),
JsonOptions());
return string.Equals(metadata?.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal);
}
catch
{
return false;
}
}
}
@@ -0,0 +1,35 @@
using System.IO;
using System.Threading.Tasks;
namespace CodexProviderSync.Core;
public sealed class CodexHomeService
{
public string NormalizeCodexHome(string? explicitCodexHome)
{
return Path.GetFullPath(
string.IsNullOrWhiteSpace(explicitCodexHome)
? AppConstants.DefaultCodexHome()
: explicitCodexHome.Trim());
}
public Task EnsureCodexHomeAsync(string codexHome)
{
if (!Directory.Exists(codexHome))
{
throw new DirectoryNotFoundException($"Codex home was not found: {codexHome}");
}
return Task.CompletedTask;
}
public string ConfigPath(string codexHome)
{
return Path.Combine(codexHome, "config.toml");
}
public string BackupRoot(string codexHome)
{
return AppConstants.DefaultBackupRoot(codexHome);
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.2.9</Version>
<AssemblyVersion>0.2.9.0</AssemblyVersion>
<FileVersion>0.2.9.0</FileVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
</ItemGroup>
</Project>
@@ -0,0 +1,344 @@
namespace CodexProviderSync.Core;
public sealed class CodexSyncService
{
private readonly CodexHomeService _codexHomeService;
private readonly ConfigFileService _configFileService;
private readonly SessionRolloutService _sessionRolloutService;
private readonly SqliteStateService _sqliteStateService;
private readonly GlobalStateService _globalStateService;
private readonly BackupService _backupService;
private readonly LockService _lockService;
private readonly ProviderDiscoveryService _providerDiscoveryService;
public CodexSyncService()
: this(
new CodexHomeService(),
new ConfigFileService(),
new SessionRolloutService(),
new SqliteStateService(),
new GlobalStateService(),
new LockService(),
new ProviderDiscoveryService())
{
}
public CodexSyncService(
CodexHomeService codexHomeService,
ConfigFileService configFileService,
SessionRolloutService sessionRolloutService,
SqliteStateService sqliteStateService,
GlobalStateService globalStateService,
LockService lockService,
ProviderDiscoveryService providerDiscoveryService)
{
_codexHomeService = codexHomeService;
_configFileService = configFileService;
_sessionRolloutService = sessionRolloutService;
_sqliteStateService = sqliteStateService;
_globalStateService = globalStateService;
_lockService = lockService;
_providerDiscoveryService = providerDiscoveryService;
_backupService = new BackupService(sessionRolloutService, sqliteStateService);
}
public async Task<StatusSnapshot> GetStatusAsync(string? explicitCodexHome = null)
{
string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome);
await _codexHomeService.EnsureCodexHomeAsync(codexHome);
string configText = await _configFileService.ReadConfigTextAsync(_codexHomeService.ConfigPath(codexHome));
CurrentProviderInfo currentProvider = _configFileService.ReadCurrentProviderFromConfigText(configText);
IReadOnlyList<string> configuredProviders = _configFileService.ListConfiguredProviderIds(configText);
SessionChangeCollection rolloutInfo = await _sessionRolloutService.CollectSessionChangesAsync(codexHome, "__status_only__", skipLockedReads: true);
StateDbLocation? stateDbLocation = _sqliteStateService.DetectStateDb(codexHome);
ProviderCounts? sqliteCounts = await _sqliteStateService.ReadSqliteProviderCountsAsync(codexHome);
SqliteRepairStats? sqliteRepairStats = sqliteCounts is not null && !sqliteCounts.Unreadable
? await _sqliteStateService.ReadSqliteRepairStatsAsync(
codexHome,
rolloutInfo.UserEventThreadIds,
rolloutInfo.ThreadCwdsById)
: null;
IReadOnlyList<ProjectThreadVisibility> projectThreadVisibility = sqliteCounts?.Unreadable == true
? []
: await _globalStateService.ReadProjectThreadVisibilityAsync(codexHome);
BackupSummary backupSummary = await _backupService.GetBackupSummaryAsync(codexHome);
return new StatusSnapshot
{
CodexHome = codexHome,
CurrentProvider = currentProvider,
ConfiguredProviders = configuredProviders,
RolloutCounts = rolloutInfo.ProviderCounts,
LockedRolloutFiles = rolloutInfo.LockedPaths,
UnreadableRolloutFiles = rolloutInfo.UnreadablePaths,
EncryptedContentCounts = rolloutInfo.EncryptedContentCounts,
EncryptedContentWarning = BuildEncryptedContentWarning(rolloutInfo.EncryptedContentCounts, currentProvider.Provider),
SqliteCounts = sqliteCounts,
StateDbLocation = stateDbLocation,
SqliteRepairStats = sqliteRepairStats,
ProjectThreadVisibility = projectThreadVisibility,
BackupRoot = _codexHomeService.BackupRoot(codexHome),
BackupSummary = backupSummary
};
}
public IReadOnlyList<ProviderOption> BuildProviderOptions(StatusSnapshot status, AppSettings settings)
{
return _providerDiscoveryService.BuildProviderOptions(status, settings);
}
public IReadOnlyList<string> ExtractDetectedProviderIds(StatusSnapshot status)
{
return _providerDiscoveryService.ExtractDetectedProviderIds(status);
}
public async Task<SyncResult> RunSyncAsync(
string? explicitCodexHome = null,
string? provider = null,
string? configBackupText = null,
int keepCount = AppConstants.DefaultBackupRetentionCount,
int? sqliteBusyTimeoutMs = null)
{
if (keepCount < 1)
{
throw new ArgumentOutOfRangeException(nameof(keepCount), keepCount, "keepCount must be 1 or greater for automatic cleanup.");
}
string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome);
await _codexHomeService.EnsureCodexHomeAsync(codexHome);
string configPath = _codexHomeService.ConfigPath(codexHome);
string configText = await _configFileService.ReadConfigTextAsync(configPath);
CurrentProviderInfo current = _configFileService.ReadCurrentProviderFromConfigText(configText);
string targetProvider = provider ?? current.Provider ?? AppConstants.DefaultProvider;
await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "sync");
SessionChangeCollection sessionInfo = await _sessionRolloutService.CollectSessionChangesAsync(codexHome, targetProvider, skipLockedReads: true);
IReadOnlyList<ThreadCwdStat> workspaceCwdStats = await _globalStateService.ReadThreadCwdStatsAsync(codexHome);
string? encryptedContentWarning = BuildEncryptedContentWarning(sessionInfo.EncryptedContentCounts, targetProvider);
(IReadOnlyList<SessionChange> writableChanges, IReadOnlyList<SessionChange> lockedChanges) =
await _sessionRolloutService.SplitLockedSessionChangesAsync(sessionInfo.Changes);
List<string> skippedRolloutFiles = [.. sessionInfo.LockedPaths, .. lockedChanges.Select(static change => change.Path)];
IReadOnlyList<string> skippedUnreadableRolloutFiles = sessionInfo.UnreadablePaths
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToList();
await _sqliteStateService.AssertSqliteWritableAsync(codexHome, sqliteBusyTimeoutMs);
string backupDir = await _backupService.CreateBackupAsync(codexHome, targetProvider, writableChanges, configPath, configBackupText);
bool sessionRestoreNeeded = false;
List<SessionChange> appliedSessionChanges = [];
bool globalStateRestoreNeeded = false;
WorkspaceRootSyncResult workspaceRootResult = new()
{
Present = false,
Updated = false,
UpdatedWorkspaceRoots = 0,
SavedWorkspaceRootCount = 0
};
try
{
SessionApplyResult? applyResult = null;
(int updatedRows, int providerRowsUpdated, int userEventRowsUpdated, int cwdRowsUpdated, bool databasePresent) = await _sqliteStateService.UpdateSqliteProviderAsync(
codexHome,
targetProvider,
async _ =>
{
if (writableChanges.Count > 0)
{
applyResult = await _sessionRolloutService.ApplySessionChangesAsync(writableChanges);
HashSet<string> appliedPathSet = new(applyResult.AppliedPaths, StringComparer.Ordinal);
appliedSessionChanges = writableChanges.Where(change => appliedPathSet.Contains(change.Path)).ToList();
sessionRestoreNeeded = appliedSessionChanges.Count > 0;
await _backupService.UpdateSessionBackupManifestAsync(backupDir, appliedSessionChanges);
}
workspaceRootResult = await _globalStateService.SyncWorkspaceRootsAsync(codexHome, workspaceCwdStats);
globalStateRestoreNeeded = workspaceRootResult.Updated;
},
sqliteBusyTimeoutMs,
sessionInfo.UserEventThreadIds,
sessionInfo.ThreadCwdsById);
skippedRolloutFiles.AddRange(applyResult?.SkippedPaths ?? []);
skippedRolloutFiles = skippedRolloutFiles.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList();
BackupPruneResult? autoPruneResult = null;
string? autoPruneWarning = null;
try
{
autoPruneResult = await _backupService.PruneBackupsAsync(codexHome, keepCount);
}
catch (Exception error)
{
autoPruneWarning = $"Automatic backup cleanup failed: {error.Message}";
}
return new SyncResult
{
CodexHome = codexHome,
TargetProvider = targetProvider,
PreviousProvider = current.Provider ?? AppConstants.DefaultProvider,
BackupDir = backupDir,
ChangedSessionFiles = applyResult?.AppliedCount ?? 0,
SkippedLockedRolloutFiles = skippedRolloutFiles,
SkippedUnreadableRolloutFiles = skippedUnreadableRolloutFiles,
SqliteRowsUpdated = updatedRows,
SqliteProviderRowsUpdated = providerRowsUpdated,
SqliteUserEventRowsUpdated = userEventRowsUpdated,
SqliteCwdRowsUpdated = cwdRowsUpdated,
UpdatedWorkspaceRoots = workspaceRootResult.UpdatedWorkspaceRoots,
SavedWorkspaceRootCount = workspaceRootResult.SavedWorkspaceRootCount,
SqlitePresent = databasePresent,
RolloutCountsBefore = sessionInfo.ProviderCounts,
EncryptedContentCounts = sessionInfo.EncryptedContentCounts,
EncryptedContentWarning = encryptedContentWarning,
AutoPruneResult = autoPruneResult,
AutoPruneWarning = autoPruneWarning
};
}
catch (Exception error)
{
List<string> restoreFailures = [];
if (sessionRestoreNeeded)
{
try
{
await _sessionRolloutService.RestoreSessionChangesAsync(appliedSessionChanges);
}
catch (Exception restoreError)
{
restoreFailures.Add($"rollout files: {restoreError.Message}");
}
}
if (globalStateRestoreNeeded)
{
try
{
await _backupService.RestoreGlobalStateFilesAsync(backupDir, codexHome);
}
catch (Exception restoreError)
{
restoreFailures.Add($"global state: {restoreError.Message}");
}
}
if (restoreFailures.Count > 0)
{
throw new InvalidOperationException(
$"Failed to restore state after sync error. Original error: {error.Message}. Restore error: {string.Join("; ", restoreFailures)}",
error);
}
throw;
}
}
public async Task<SyncResult> RunSwitchAsync(
string? explicitCodexHome,
string provider,
int keepCount = AppConstants.DefaultBackupRetentionCount)
{
if (string.IsNullOrWhiteSpace(provider))
{
throw new InvalidOperationException("Missing provider id. Usage: codex-provider switch <provider-id>");
}
string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome);
await _codexHomeService.EnsureCodexHomeAsync(codexHome);
string configPath = _codexHomeService.ConfigPath(codexHome);
string originalConfigText = await _configFileService.ReadConfigTextAsync(configPath);
if (!_configFileService.ConfigDeclaresProvider(originalConfigText, provider))
{
string configuredProviders = string.Join(", ", _configFileService.ListConfiguredProviderIds(originalConfigText));
throw new InvalidOperationException(
$"Provider \"{provider}\" is not available in config.toml. Configure it first or use one of: {configuredProviders}");
}
string nextConfigText = _configFileService.SetRootProviderInConfigText(originalConfigText, provider);
await _configFileService.WriteConfigTextAsync(configPath, nextConfigText);
try
{
SyncResult result = await RunSyncAsync(codexHome, provider, originalConfigText, keepCount);
return new SyncResult
{
CodexHome = result.CodexHome,
TargetProvider = result.TargetProvider,
PreviousProvider = result.PreviousProvider,
BackupDir = result.BackupDir,
ChangedSessionFiles = result.ChangedSessionFiles,
SkippedLockedRolloutFiles = result.SkippedLockedRolloutFiles,
SkippedUnreadableRolloutFiles = result.SkippedUnreadableRolloutFiles,
SqliteRowsUpdated = result.SqliteRowsUpdated,
SqliteProviderRowsUpdated = result.SqliteProviderRowsUpdated,
SqliteUserEventRowsUpdated = result.SqliteUserEventRowsUpdated,
SqliteCwdRowsUpdated = result.SqliteCwdRowsUpdated,
UpdatedWorkspaceRoots = result.UpdatedWorkspaceRoots,
SavedWorkspaceRootCount = result.SavedWorkspaceRootCount,
SqlitePresent = result.SqlitePresent,
RolloutCountsBefore = result.RolloutCountsBefore,
EncryptedContentCounts = result.EncryptedContentCounts,
EncryptedContentWarning = result.EncryptedContentWarning,
ConfigUpdated = true,
AutoPruneResult = result.AutoPruneResult,
AutoPruneWarning = result.AutoPruneWarning
};
}
catch
{
await _configFileService.WriteConfigTextAsync(configPath, originalConfigText);
throw;
}
}
public async Task<RestoreResult> RunRestoreAsync(string? explicitCodexHome, string backupDir)
{
return await RunRestoreAsync(explicitCodexHome, backupDir, new RestoreBackupOptions());
}
public async Task<RestoreResult> RunRestoreAsync(string? explicitCodexHome, string backupDir, RestoreBackupOptions options)
{
if (string.IsNullOrWhiteSpace(backupDir))
{
throw new InvalidOperationException("Missing backup path. Usage: codex-provider restore <backup-dir>");
}
string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome);
await _codexHomeService.EnsureCodexHomeAsync(codexHome);
await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "restore");
return await _backupService.RestoreBackupAsync(Path.GetFullPath(backupDir), codexHome, options);
}
public async Task<BackupPruneResult> RunPruneBackupsAsync(
string? explicitCodexHome = null,
int keepCount = AppConstants.DefaultBackupRetentionCount)
{
string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome);
await _codexHomeService.EnsureCodexHomeAsync(codexHome);
await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "prune-backups");
return await _backupService.PruneBackupsAsync(codexHome, keepCount);
}
private static string? BuildEncryptedContentWarning(ProviderCounts encryptedContentCounts, string targetProvider)
{
int total = encryptedContentCounts.Sessions.Values.Sum() + encryptedContentCounts.ArchivedSessions.Values.Sum();
List<string> riskyProviders = encryptedContentCounts.Sessions
.Concat(encryptedContentCounts.ArchivedSessions)
.Where(pair => pair.Value > 0 && !string.Equals(pair.Key, targetProvider, StringComparison.Ordinal))
.Select(static pair => pair.Key)
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToList();
if (riskyProviders.Count == 0)
{
return null;
}
return $"Encrypted content warning: {total} rollout file(s) contain encrypted_content from provider(s) {string.Join(", ", riskyProviders)}. Visibility metadata can be synchronized to {targetProvider}, but continuing or compacting those histories may fail with invalid_encrypted_content. Return to the original provider/account or start a new session if you need reliable continuation.";
}
}
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace CodexProviderSync.Core;
public sealed partial class ConfigFileService
{
[GeneratedRegex("""^\[model_providers\.([A-Za-z0-9_.-]+)]\s*$""", RegexOptions.Multiline)]
private static partial Regex ProviderRegex();
public Task<string> ReadConfigTextAsync(string configPath)
{
return File.ReadAllTextAsync(configPath);
}
public async Task WriteConfigTextAsync(string configPath, string configText)
{
await File.WriteAllTextAsync(configPath, configText);
}
public CurrentProviderInfo ReadCurrentProviderFromConfigText(string configText)
{
foreach (string rawLine in SplitLines(configText))
{
string trimmed = rawLine.Trim();
if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('#'))
{
continue;
}
if (trimmed.StartsWith('['))
{
break;
}
Match match = Regex.Match(trimmed, "^model_provider\\s*=\\s*\"([^\"]+)\"\\s*$");
if (match.Success)
{
return new CurrentProviderInfo(match.Groups[1].Value, false);
}
}
return new CurrentProviderInfo(AppConstants.DefaultProvider, true);
}
public IReadOnlyList<string> ListConfiguredProviderIds(string configText)
{
HashSet<string> providerIds = new(StringComparer.Ordinal)
{
AppConstants.DefaultProvider
};
foreach (Match match in ProviderRegex().Matches(configText))
{
providerIds.Add(match.Groups[1].Value);
}
return providerIds.OrderBy(static value => value, StringComparer.Ordinal).ToList();
}
public bool ConfigDeclaresProvider(string configText, string provider)
{
return ListConfiguredProviderIds(configText).Contains(provider, StringComparer.Ordinal);
}
public string SetRootProviderInConfigText(string configText, string provider)
{
string newline = DetectNewline(configText);
List<string> lines = SplitLines(configText).ToList();
int insertIndex = lines.Count;
for (int index = 0; index < lines.Count; index += 1)
{
string trimmed = lines[index].Trim();
if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('#'))
{
insertIndex = index + 1;
continue;
}
if (trimmed.StartsWith('['))
{
insertIndex = index;
break;
}
if (trimmed.StartsWith("model_provider =", StringComparison.Ordinal))
{
lines[index] = $"model_provider = \"{EscapeTomlString(provider)}\"";
return string.Join(newline, lines) + (configText.EndsWith(newline, StringComparison.Ordinal) ? newline : string.Empty);
}
insertIndex = index + 1;
}
lines.Insert(insertIndex, $"model_provider = \"{EscapeTomlString(provider)}\"");
string nextText = string.Join(newline, lines);
return configText.EndsWith(newline, StringComparison.Ordinal) ? nextText + newline : nextText;
}
private static IEnumerable<string> SplitLines(string text)
{
return text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
}
private static string DetectNewline(string text)
{
return text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
}
private static string EscapeTomlString(string value)
{
return value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal);
}
}
@@ -0,0 +1,526 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Data.Sqlite;
namespace CodexProviderSync.Core;
public sealed class GlobalStateService
{
private readonly SqliteStateService _sqliteStateService = new();
public string StatePath(string codexHome)
{
return Path.Combine(codexHome, AppConstants.GlobalStateFileBasename);
}
public string BackupPath(string codexHome)
{
return Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename);
}
public async Task<IReadOnlyList<ThreadCwdStat>> ReadThreadCwdStatsAsync(string codexHome)
{
string? dbPath = _sqliteStateService.ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return [];
}
try
{
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly);
await connection.OpenAsync();
HashSet<string> columns = await ReadThreadTableColumnsAsync(connection);
if (!columns.Contains("cwd"))
{
return [];
}
string updatedAtExpression = columns.Contains("updated_at_ms")
? (columns.Contains("updated_at")
? "COALESCE(MAX(updated_at_ms), MAX(updated_at) * 1000, 0)"
: "COALESCE(MAX(updated_at_ms), 0)")
: (columns.Contains("updated_at")
? "COALESCE(MAX(updated_at) * 1000, 0)"
: "0");
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"""
SELECT
cwd,
COUNT(*) AS count,
{updatedAtExpression} AS updated_at_ms
FROM threads
WHERE cwd IS NOT NULL AND cwd <> ''
GROUP BY cwd
ORDER BY count DESC, updated_at_ms DESC, cwd
""";
List<ThreadCwdStat> rows = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string cwd = reader.GetString(0);
string? normalized = NormalizeComparablePath(cwd);
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
rows.Add(new ThreadCwdStat
{
Cwd = cwd,
NormalizedCwd = normalized,
Count = reader.GetInt64(1),
UpdatedAtMs = reader.GetInt64(2)
});
}
return rows;
}
catch (Exception error)
{
throw SqliteStateService.WrapSqliteMalformedError(
SqliteStateService.WrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata");
}
}
public async Task<WorkspaceRootSyncResult> SyncWorkspaceRootsAsync(
string codexHome,
IReadOnlyList<ThreadCwdStat>? cwdStats = null)
{
string statePath = StatePath(codexHome);
if (!File.Exists(statePath))
{
return new WorkspaceRootSyncResult
{
Present = false,
Updated = false,
UpdatedWorkspaceRoots = 0,
SavedWorkspaceRootCount = 0
};
}
JsonObject state = JsonNode.Parse(await File.ReadAllTextAsync(statePath))?.AsObject()
?? throw new InvalidOperationException($"Global state file is invalid: {statePath}");
IReadOnlyList<ThreadCwdStat> effectiveCwdStats = cwdStats ?? await ReadThreadCwdStatsAsync(codexHome);
List<string> existingSavedRoots = ToPathList(state["electron-saved-workspace-roots"]);
List<string> existingProjectOrder = ToPathList(state["project-order"]);
List<string> existingActiveRoots = ToPathList(state["active-workspace-roots"]);
JsonNode? originalActiveRoots = state["active-workspace-roots"]?.DeepClone();
JsonNode? originalLabels = state["electron-workspace-root-labels"]?.DeepClone();
JsonNode? originalOpenTargets = state["open-in-target-preferences"]?.DeepClone();
IEnumerable<string> savedRootCandidates = existingProjectOrder.Count > 0
? existingProjectOrder.Concat(existingSavedRoots).Concat(existingActiveRoots)
: existingSavedRoots.Concat(existingActiveRoots);
List<string> nextSavedRoots = DedupePaths(
savedRootCandidates.Select(value => ResolveStoredPath(value, effectiveCwdStats)));
IEnumerable<string> projectOrderCandidates = existingProjectOrder.Count > 0
? existingProjectOrder.Concat(existingSavedRoots)
: nextSavedRoots;
List<string> nextProjectOrder = DedupePaths(
projectOrderCandidates.Select(value => ResolveStoredPath(value, effectiveCwdStats)));
List<string> nextActiveRoots = DedupePaths(existingActiveRoots.Select(value => ResolveStoredPath(value, effectiveCwdStats)));
JsonNode? nextActiveRootsNode = state["active-workspace-roots"] is JsonArray
? ToJsonArray(nextActiveRoots)
: (nextActiveRoots.Count > 0 ? JsonValue.Create(nextActiveRoots[0]) : null);
JsonObject? nextLabels = CopyResolvedObjectKeys(state["electron-workspace-root-labels"] as JsonObject, effectiveCwdStats);
JsonObject? nextOpenTargets = state["open-in-target-preferences"] as JsonObject;
if (nextOpenTargets is not null)
{
nextOpenTargets = (JsonObject)nextOpenTargets.DeepClone();
if (nextOpenTargets["perPath"] is JsonObject perPath)
{
nextOpenTargets["perPath"] = CopyResolvedObjectKeys(perPath, effectiveCwdStats);
}
}
bool savedRootsChanged = !existingSavedRoots.SequenceEqual(nextSavedRoots, StringComparer.Ordinal);
bool projectOrderChanged = !existingProjectOrder.SequenceEqual(nextProjectOrder, StringComparer.Ordinal);
bool activeRootsChanged = !JsonNode.DeepEquals(originalActiveRoots, nextActiveRootsNode);
bool labelsChanged = !JsonNode.DeepEquals(originalLabels, nextLabels);
bool openTargetsChanged = !JsonNode.DeepEquals(originalOpenTargets, nextOpenTargets);
bool backupMissing = !File.Exists(BackupPath(codexHome));
state["electron-saved-workspace-roots"] = ToJsonArray(nextSavedRoots);
state["project-order"] = ToJsonArray(nextProjectOrder);
state["active-workspace-roots"] = nextActiveRootsNode;
if (nextLabels is not null)
{
state["electron-workspace-root-labels"] = nextLabels;
}
if (nextOpenTargets is not null)
{
state["open-in-target-preferences"] = nextOpenTargets;
}
bool updated = savedRootsChanged || projectOrderChanged || activeRootsChanged || labelsChanged || openTargetsChanged || backupMissing;
if (updated)
{
string json = state.ToJsonString(JsonOptions()) + Environment.NewLine;
await File.WriteAllTextAsync(statePath, json);
await File.WriteAllTextAsync(BackupPath(codexHome), json);
}
return new WorkspaceRootSyncResult
{
Present = true,
Updated = updated,
UpdatedWorkspaceRoots = CountArrayChanges(existingSavedRoots, nextSavedRoots),
SavedWorkspaceRootCount = nextSavedRoots.Count
};
}
public async Task<IReadOnlyList<ProjectThreadVisibility>> ReadProjectThreadVisibilityAsync(
string codexHome,
int pageSize = 50)
{
string statePath = StatePath(codexHome);
if (!File.Exists(statePath))
{
return [];
}
JsonObject state = JsonNode.Parse(await File.ReadAllTextAsync(statePath))?.AsObject()
?? throw new InvalidOperationException($"Global state file is invalid: {statePath}");
List<string> roots = ReadWorkspaceRootsFromState(state);
if (roots.Count == 0)
{
return [];
}
string? dbPath = _sqliteStateService.ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return roots
.Select(static root => new ProjectThreadVisibility
{
Root = root,
InteractiveThreads = 0,
FirstPageThreads = 0,
ExactCwdMatches = 0,
VerbatimCwdRows = 0,
Ranks = [],
RankPreview = string.Empty,
ProviderCounts = new Dictionary<string, int>(StringComparer.Ordinal)
})
.ToList();
}
try
{
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly);
await connection.OpenAsync();
HashSet<string> columns = await ReadThreadTableColumnsAsync(connection);
if (!columns.Contains("cwd"))
{
return [];
}
string sourceFilter = columns.Contains("source") ? "AND source IN ('cli', 'vscode')" : string.Empty;
string archivedFilter = columns.Contains("archived") ? "AND archived = 0" : string.Empty;
string firstUserFilter = columns.Contains("first_user_message") ? "AND first_user_message <> ''" : string.Empty;
string providerExpression = columns.Contains("model_provider") ? "model_provider" : "'' AS model_provider";
string timeExpression = BuildTimeExpression(columns);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"""
SELECT
id,
cwd,
{providerExpression},
{timeExpression} AS sort_ts
FROM threads
WHERE cwd IS NOT NULL AND cwd <> ''
{archivedFilter}
{firstUserFilter}
{sourceFilter}
ORDER BY sort_ts DESC, id DESC
""";
List<(string Cwd, string DesktopCwd, string? NormalizedCwd, string Provider, int Rank)> rows = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
int rank = 1;
while (await reader.ReadAsync())
{
string cwd = reader.GetString(1);
string provider = reader.IsDBNull(2) || string.IsNullOrWhiteSpace(reader.GetString(2))
? "(missing)"
: reader.GetString(2);
rows.Add((cwd, ToDesktopWorkspacePath(cwd), NormalizeComparablePath(cwd), provider, rank));
rank += 1;
}
List<ProjectThreadVisibility> result = [];
foreach (string root in roots)
{
string exactRoot = ToDesktopWorkspacePath(root);
string? normalizedRoot = NormalizeComparablePath(root);
List<(string Cwd, string DesktopCwd, string? NormalizedCwd, string Provider, int Rank)> matchingRows = rows
.Where(row => string.Equals(row.NormalizedCwd, normalizedRoot, StringComparison.Ordinal))
.ToList();
List<int> ranks = matchingRows.Select(static row => row.Rank).ToList();
Dictionary<string, int> providerCounts = new(StringComparer.Ordinal);
foreach (var (_, _, _, provider, _) in matchingRows)
{
providerCounts[provider] = providerCounts.GetValueOrDefault(provider) + 1;
}
result.Add(new ProjectThreadVisibility
{
Root = exactRoot,
InteractiveThreads = matchingRows.Count,
FirstPageThreads = ranks.Count(value => value <= pageSize),
ExactCwdMatches = matchingRows.Count(row => string.Equals(row.Cwd, exactRoot, StringComparison.Ordinal)),
VerbatimCwdRows = matchingRows.Count(row => row.Cwd.StartsWith(@"\\?\", StringComparison.Ordinal)),
Ranks = ranks,
RankPreview = FormatRankPreview(ranks),
ProviderCounts = providerCounts
});
}
return result;
}
catch (Exception error)
{
throw SqliteStateService.WrapSqliteMalformedError(
SqliteStateService.WrapSqliteBusyError(error, "read project thread visibility diagnostics"),
"read project thread visibility diagnostics");
}
}
private static async Task<HashSet<string>> ReadThreadTableColumnsAsync(SqliteConnection connection)
{
HashSet<string> columns = new(StringComparer.Ordinal);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "PRAGMA table_info(\"threads\")";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
columns.Add(reader.GetString(1));
}
return columns;
}
private static string BuildTimeExpression(HashSet<string> columns)
{
List<string> expressions = [];
if (columns.Contains("updated_at_ms"))
{
expressions.Add("updated_at_ms");
}
if (columns.Contains("updated_at"))
{
expressions.Add("updated_at * 1000");
}
if (columns.Contains("created_at_ms"))
{
expressions.Add("created_at_ms");
}
if (columns.Contains("created_at"))
{
expressions.Add("created_at * 1000");
}
expressions.Add("0");
return $"COALESCE({string.Join(", ", expressions)})";
}
private static List<string> ReadWorkspaceRootsFromState(JsonObject state)
{
List<string> savedRoots = ToPathList(state["electron-saved-workspace-roots"]);
List<string> projectOrder = ToPathList(state["project-order"]);
List<string> activeRoots = ToPathList(state["active-workspace-roots"]);
IEnumerable<string> candidates = projectOrder.Count > 0
? projectOrder.Concat(savedRoots).Concat(activeRoots)
: savedRoots.Concat(activeRoots);
return DedupePaths(candidates.Select(ToDesktopWorkspacePath));
}
private static string FormatRankPreview(IReadOnlyList<int> ranks, int maxCount = 12)
{
string preview = string.Join(", ", ranks.Take(maxCount));
int remaining = ranks.Count - Math.Min(ranks.Count, maxCount);
return remaining > 0 ? $"{preview} (+{remaining} more)" : preview;
}
private static SqliteConnection OpenConnection(string dbPath, SqliteOpenMode mode)
{
SqliteConnectionStringBuilder builder = new()
{
DataSource = dbPath,
Mode = mode,
Pooling = false
};
return new SqliteConnection(builder.ConnectionString);
}
private static string ResolveStoredPath(string value, IReadOnlyList<ThreadCwdStat> cwdStats)
{
string? comparable = NormalizeComparablePath(value);
if (string.IsNullOrWhiteSpace(comparable))
{
return value;
}
ThreadCwdStat? match = cwdStats
.Where(entry => string.Equals(entry.NormalizedCwd, comparable, StringComparison.Ordinal))
.OrderByDescending(entry => entry.Count)
.ThenByDescending(entry => entry.UpdatedAtMs)
.ThenBy(entry => entry.Cwd, StringComparer.Ordinal)
.FirstOrDefault();
return ToDesktopWorkspacePath(match?.Cwd ?? value);
}
private static List<string> ToPathList(JsonNode? node)
{
if (node is JsonArray array)
{
return array
.Select(static entry => entry?.GetValue<string>())
.Where(static entry => !string.IsNullOrWhiteSpace(entry))
.Cast<string>()
.ToList();
}
if (node is JsonValue value)
{
string? text = value.GetValue<string>();
return string.IsNullOrWhiteSpace(text) ? [] : [text];
}
return [];
}
private static JsonArray ToJsonArray(IEnumerable<string> values)
{
JsonArray array = [];
foreach (string value in values)
{
array.Add(value);
}
return array;
}
private static List<string> DedupePaths(IEnumerable<string> values)
{
HashSet<string> seen = new(StringComparer.Ordinal);
List<string> result = [];
foreach (string value in values)
{
string? comparable = NormalizeComparablePath(value);
if (string.IsNullOrWhiteSpace(comparable) || !seen.Add(comparable))
{
continue;
}
result.Add(value);
}
return result;
}
private static JsonObject? CopyResolvedObjectKeys(JsonObject? source, IReadOnlyList<ThreadCwdStat> cwdStats)
{
if (source is null)
{
return null;
}
JsonObject result = [];
foreach ((string key, JsonNode? value) in source)
{
string resolved = ResolveStoredPath(key, cwdStats);
if (!result.ContainsKey(resolved) || string.Equals(resolved, key, StringComparison.Ordinal))
{
result[resolved] = value?.DeepClone();
}
}
return result;
}
private static string ToDesktopWorkspacePath(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
string trimmed = value.Trim();
if (trimmed.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
{
return @"\\" + trimmed[8..].Replace('/', '\\');
}
if (trimmed.StartsWith(@"\\?\", StringComparison.Ordinal))
{
string withoutPrefix = trimmed[4..].Replace('/', '\\');
if (withoutPrefix.Length == 2 && char.IsLetter(withoutPrefix[0]) && withoutPrefix[1] == ':')
{
return withoutPrefix + "\\";
}
return withoutPrefix;
}
return value;
}
private static string? NormalizeComparablePath(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string normalized = value.Trim();
if (normalized.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
{
normalized = @"\\" + normalized[8..];
}
else if (normalized.StartsWith(@"\\?\", StringComparison.Ordinal))
{
normalized = normalized[4..];
}
normalized = normalized.Replace('/', '\\').TrimEnd('\\');
if (normalized.Length == 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')
{
normalized += "\\";
}
return normalized.ToLowerInvariant();
}
private static int CountArrayChanges(IReadOnlyList<string> previous, IReadOnlyList<string> next)
{
int compared = Math.Max(previous.Count, next.Count);
int changed = 0;
for (int index = 0; index < compared; index += 1)
{
string? left = index < previous.Count ? previous[index] : null;
string? right = index < next.Count ? next[index] : null;
if (!string.Equals(left, right, StringComparison.Ordinal))
{
changed += 1;
}
}
return changed;
}
private static JsonSerializerOptions JsonOptions()
{
return new JsonSerializerOptions
{
WriteIndented = true
};
}
}
@@ -0,0 +1,153 @@
using System.Runtime.InteropServices;
using System.Text.Json;
namespace CodexProviderSync.Core;
public sealed class LockService
{
private const int Win32ErrorAlreadyExists = 183;
private const int Win32ErrorAccessDenied = 5;
private const int DefaultLockCreateRetryCount = 3;
private const int DefaultLockCreateRetryDelayMs = 75;
public async Task<LockHandle> AcquireLockAsync(string codexHome, string label = "codex-provider-sync")
{
string lockPath = AppConstants.LockPath(codexHome);
Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!);
await CreateLockDirectoryAsync(lockPath);
try
{
LockOwner owner = new()
{
ProcessId = Environment.ProcessId,
StartedAt = DateTimeOffset.UtcNow,
Label = label,
CurrentDirectory = Environment.CurrentDirectory
};
await File.WriteAllTextAsync(
Path.Combine(lockPath, "owner.json"),
JsonSerializer.Serialize(owner, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
}));
return new LockHandle(lockPath);
}
catch
{
Directory.Delete(lockPath, recursive: true);
throw;
}
}
internal static async Task CreateLockDirectoryAsync(
string lockPath,
int retryCount = DefaultLockCreateRetryCount,
int retryDelayMs = DefaultLockCreateRetryDelayMs,
Func<int, Task>? delayAsync = null,
Func<string, int>? tryCreateDirectory = null)
{
delayAsync ??= static delay => Task.Delay(delay);
tryCreateDirectory ??= TryCreateDirectory;
int attempts = 0;
while (true)
{
int errorCode = tryCreateDirectory(lockPath);
if (errorCode == 0)
{
return;
}
if (errorCode == Win32ErrorAlreadyExists)
{
throw new InvalidOperationException(
$"Lock already exists at {lockPath}. Close Codex/App and retry, or remove the stale lock if you are sure no sync is running.");
}
if (!IsTransientLockCreateError(errorCode) || attempts >= retryCount)
{
throw new IOException($"Unable to create lock directory at {lockPath}. Win32 error: {errorCode}");
}
attempts += 1;
await delayAsync(retryDelayMs);
}
}
private static bool IsTransientLockCreateError(int errorCode)
{
return errorCode == Win32ErrorAccessDenied;
}
private static int TryCreateDirectory(string lockPath)
{
return OperatingSystem.IsWindows()
? TryCreateDirectoryWindows(lockPath)
: TryCreateDirectoryUnix(lockPath);
}
private static int TryCreateDirectoryWindows(string lockPath)
{
return CreateDirectory(lockPath, IntPtr.Zero) ? 0 : Marshal.GetLastWin32Error();
}
private static int TryCreateDirectoryUnix(string lockPath)
{
if (Mkdir(lockPath, 448) == 0)
{
return 0;
}
int errorCode = Marshal.GetLastWin32Error();
return errorCode switch
{
17 => Win32ErrorAlreadyExists,
1 or 13 => Win32ErrorAccessDenied,
_ => errorCode
};
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateDirectory(string lpPathName, IntPtr lpSecurityAttributes);
[DllImport("libc", SetLastError = true, EntryPoint = "mkdir")]
private static extern int Mkdir(string pathname, uint mode);
private sealed class LockOwner
{
public required int ProcessId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public required string Label { get; init; }
public required string CurrentDirectory { get; init; }
}
}
public sealed class LockHandle : IAsyncDisposable
{
private readonly string _lockPath;
private bool _released;
public LockHandle(string lockPath)
{
_lockPath = lockPath;
}
public ValueTask DisposeAsync()
{
if (_released)
{
return ValueTask.CompletedTask;
}
_released = true;
if (Directory.Exists(_lockPath))
{
Directory.Delete(_lockPath, recursive: true);
}
return ValueTask.CompletedTask;
}
}
+222
View File
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
namespace CodexProviderSync.Core;
public sealed record CurrentProviderInfo(string Provider, bool Implicit);
public sealed class ProviderCounts
{
public Dictionary<string, int> Sessions { get; init; } = new(StringComparer.Ordinal);
public Dictionary<string, int> ArchivedSessions { get; init; } = new(StringComparer.Ordinal);
public bool Unreadable { get; init; }
public string? Error { get; init; }
}
public sealed class StatusSnapshot
{
public required string CodexHome { get; init; }
public required CurrentProviderInfo CurrentProvider { get; init; }
public required IReadOnlyList<string> ConfiguredProviders { get; init; }
public required ProviderCounts RolloutCounts { get; init; }
public required IReadOnlyList<string> LockedRolloutFiles { get; init; }
public required IReadOnlyList<string> UnreadableRolloutFiles { get; init; }
public required ProviderCounts EncryptedContentCounts { get; init; }
public string? EncryptedContentWarning { get; init; }
public required ProviderCounts? SqliteCounts { get; init; }
public StateDbLocation? StateDbLocation { get; init; }
public SqliteRepairStats? SqliteRepairStats { get; init; }
public IReadOnlyList<ProjectThreadVisibility> ProjectThreadVisibility { get; init; } = [];
public required string BackupRoot { get; init; }
public required BackupSummary BackupSummary { get; init; }
}
public sealed record StateDbLocation(string Path, string RelativePath, string Source);
public sealed class SqliteRepairStats
{
public required int UserEventRowsNeedingRepair { get; init; }
public required int CwdRowsNeedingRepair { get; init; }
}
public sealed class ProjectThreadVisibility
{
public required string Root { get; init; }
public required int InteractiveThreads { get; init; }
public required int FirstPageThreads { get; init; }
public required int ExactCwdMatches { get; init; }
public required int VerbatimCwdRows { get; init; }
public required IReadOnlyList<int> Ranks { get; init; }
public required string RankPreview { get; init; }
public required Dictionary<string, int> ProviderCounts { get; init; }
}
public sealed class BackupSummary
{
public required int Count { get; init; }
public required long TotalBytes { get; init; }
}
public sealed class BackupPruneResult
{
public required string BackupRoot { get; init; }
public required int DeletedCount { get; init; }
public required int RemainingCount { get; init; }
public required long FreedBytes { get; init; }
}
public sealed class SessionChange
{
public required string Path { get; init; }
public string? ThreadId { get; init; }
public required string Directory { get; init; }
public required string OriginalFirstLine { get; init; }
public required string OriginalSeparator { get; init; }
public required int OriginalOffset { get; init; }
public required long OriginalFileLength { get; init; }
public required long OriginalLastWriteTimeUtcTicks { get; init; }
public required string OriginalProvider { get; init; }
public required string UpdatedFirstLine { get; init; }
}
public sealed class SessionChangeCollection
{
public required IReadOnlyList<SessionChange> Changes { get; init; }
public required IReadOnlyList<string> LockedPaths { get; init; }
public required IReadOnlyList<string> UnreadablePaths { get; init; }
public required ProviderCounts ProviderCounts { get; init; }
public required ProviderCounts EncryptedContentCounts { get; init; }
public required IReadOnlyCollection<string> UserEventThreadIds { get; init; }
public required IReadOnlyDictionary<string, string> ThreadCwdsById { get; init; }
}
public sealed class SyncResult
{
public required string CodexHome { get; init; }
public required string TargetProvider { get; init; }
public required string PreviousProvider { get; init; }
public required string BackupDir { get; init; }
public required int ChangedSessionFiles { get; init; }
public required IReadOnlyList<string> SkippedLockedRolloutFiles { get; init; }
public required IReadOnlyList<string> SkippedUnreadableRolloutFiles { get; init; }
public required int SqliteRowsUpdated { get; init; }
public int SqliteProviderRowsUpdated { get; init; }
public int SqliteUserEventRowsUpdated { get; init; }
public int SqliteCwdRowsUpdated { get; init; }
public int UpdatedWorkspaceRoots { get; init; }
public int SavedWorkspaceRootCount { get; init; }
public required bool SqlitePresent { get; init; }
public required ProviderCounts RolloutCountsBefore { get; init; }
public required ProviderCounts EncryptedContentCounts { get; init; }
public string? EncryptedContentWarning { get; init; }
public bool ConfigUpdated { get; init; }
public BackupPruneResult? AutoPruneResult { get; init; }
public string? AutoPruneWarning { get; init; }
}
public sealed class SessionApplyResult
{
public required int AppliedCount { get; init; }
public required IReadOnlyList<string> AppliedPaths { get; init; }
public required IReadOnlyList<string> SkippedPaths { get; init; }
}
public sealed class RestoreResult
{
public required string CodexHome { get; init; }
public required string BackupDir { get; init; }
public required string TargetProvider { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public int ChangedSessionFiles { get; init; }
}
public enum ProviderSource
{
Config,
Rollout,
Sqlite,
Manual
}
public sealed class ProviderOption
{
public required string Id { get; init; }
public required IReadOnlyList<ProviderSource> Sources { get; init; }
public bool IsCurrentProvider { get; init; }
public bool IsManual { get; init; }
public bool IsSaved { get; init; }
}
public sealed class WindowBoundsState
{
public int X { get; init; }
public int Y { get; init; }
public int Width { get; init; }
public int Height { get; init; }
public bool Maximized { get; init; }
}
public sealed class AppSettings
{
public List<string> RecentCodexHomes { get; init; } = [];
public string? LastCodexHome { get; init; }
public List<string> SavedProviders { get; init; } = [];
public List<string> ManualProviders { get; init; } = [];
public string? LastSelectedProvider { get; init; }
public string? LastBackupDirectory { get; init; }
public int BackupRetentionCount { get; init; } = AppConstants.DefaultBackupRetentionCount;
public string UiLanguage { get; init; } = "en";
public WindowBoundsState? WindowBounds { get; init; }
}
public sealed class RestoreBackupOptions
{
public bool RestoreConfig { get; init; } = true;
public bool RestoreDatabase { get; init; } = true;
public bool RestoreSessions { get; init; } = true;
}
internal sealed class BackupMetadataFile
{
public int Version { get; init; }
public required string Namespace { get; init; }
public required string CodexHome { get; init; }
public required string TargetProvider { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required List<string> DbFiles { get; init; }
public int ChangedSessionFiles { get; init; }
}
internal sealed class SessionBackupManifest
{
public int Version { get; init; }
public required string Namespace { get; init; }
public required string CodexHome { get; init; }
public required string TargetProvider { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required List<SessionBackupManifestEntry> Files { get; init; }
}
internal sealed class SessionBackupManifestEntry
{
public required string Path { get; init; }
public required string OriginalFirstLine { get; init; }
public required string OriginalSeparator { get; init; }
public long? OriginalLastWriteTimeUtcTicks { get; init; }
}
public sealed class WorkspaceRootSyncResult
{
public required bool Present { get; init; }
public required bool Updated { get; init; }
public required int UpdatedWorkspaceRoots { get; init; }
public required int SavedWorkspaceRootCount { get; init; }
}
public sealed class ThreadCwdStat
{
public required string Cwd { get; init; }
public required string NormalizedCwd { get; init; }
public required long Count { get; init; }
public required long UpdatedAtMs { get; init; }
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("CodexProviderSync.Core.Tests")]
@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
namespace CodexProviderSync.Core;
public sealed class ProviderDiscoveryService
{
public IReadOnlyList<ProviderOption> BuildProviderOptions(StatusSnapshot status, AppSettings settings)
{
Dictionary<string, HashSet<ProviderSource>> sources = new(StringComparer.Ordinal);
void AddSources(IEnumerable<string> providerIds, ProviderSource source)
{
foreach (string providerId in providerIds.Where(static value => !string.IsNullOrWhiteSpace(value)))
{
if (!sources.TryGetValue(providerId, out HashSet<ProviderSource>? bucket))
{
bucket = [];
sources[providerId] = bucket;
}
bucket.Add(source);
}
}
AddSources(status.ConfiguredProviders, ProviderSource.Config);
AddSources(status.RolloutCounts.Sessions.Keys, ProviderSource.Rollout);
AddSources(status.RolloutCounts.ArchivedSessions.Keys, ProviderSource.Rollout);
if (status.SqliteCounts is not null)
{
AddSources(status.SqliteCounts.Sessions.Keys, ProviderSource.Sqlite);
AddSources(status.SqliteCounts.ArchivedSessions.Keys, ProviderSource.Sqlite);
}
AddSources(settings.SavedProviders, ProviderSource.Manual);
AddSources(settings.ManualProviders, ProviderSource.Manual);
AddSources([status.CurrentProvider.Provider], ProviderSource.Config);
HashSet<string> manualProviders = new(settings.ManualProviders, StringComparer.Ordinal);
HashSet<string> savedProviders = new(settings.SavedProviders, StringComparer.Ordinal);
return sources
.OrderByDescending(pair => string.Equals(pair.Key, status.CurrentProvider.Provider, StringComparison.Ordinal))
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => new ProviderOption
{
Id = pair.Key,
Sources = pair.Value.Order().ToList(),
IsCurrentProvider = string.Equals(pair.Key, status.CurrentProvider.Provider, StringComparison.Ordinal),
IsManual = manualProviders.Contains(pair.Key),
IsSaved = savedProviders.Contains(pair.Key)
})
.ToList();
}
public IReadOnlyList<string> ExtractDetectedProviderIds(StatusSnapshot status)
{
HashSet<string> providers = new(StringComparer.Ordinal);
foreach (string provider in status.ConfiguredProviders)
{
providers.Add(provider);
}
foreach (string provider in status.RolloutCounts.Sessions.Keys)
{
providers.Add(provider);
}
foreach (string provider in status.RolloutCounts.ArchivedSessions.Keys)
{
providers.Add(provider);
}
if (status.SqliteCounts is not null)
{
foreach (string provider in status.SqliteCounts.Sessions.Keys)
{
providers.Add(provider);
}
foreach (string provider in status.SqliteCounts.ArchivedSessions.Keys)
{
providers.Add(provider);
}
}
providers.Add(status.CurrentProvider.Provider);
return providers.Order(StringComparer.Ordinal).ToList();
}
}
@@ -0,0 +1,783 @@
using System.Buffers;
using System.Text;
using System.Text.Json.Nodes;
namespace CodexProviderSync.Core;
public sealed class SessionRolloutService
{
private const string StatusOnlyProvider = "__status_only__";
private const int ScanBufferSize = 1024 * 1024;
public async Task<SessionChangeCollection> CollectSessionChangesAsync(
string codexHome,
string targetProvider,
bool skipLockedReads = false)
{
List<SessionChange> changes = [];
List<string> lockedPaths = [];
List<string> unreadablePaths = [];
Dictionary<string, int> sessionCounts = new(StringComparer.Ordinal);
Dictionary<string, int> archivedCounts = new(StringComparer.Ordinal);
Dictionary<string, int> encryptedSessionCounts = new(StringComparer.Ordinal);
Dictionary<string, int> encryptedArchivedCounts = new(StringComparer.Ordinal);
HashSet<string> userEventThreadIds = new(StringComparer.Ordinal);
Dictionary<string, string> threadCwdsById = new(StringComparer.Ordinal);
foreach (string dirName in AppConstants.SessionDirectories)
{
string rootDir = Path.Combine(codexHome, dirName);
if (!Directory.Exists(rootDir))
{
continue;
}
foreach (string rolloutPath in Directory.EnumerateFiles(rootDir, "rollout-*.jsonl", SearchOption.AllDirectories))
{
FirstLineRecord record;
try
{
record = await ReadFirstLineRecordAsync(rolloutPath);
}
catch (Exception error) when (skipLockedReads && IsRolloutFileBusyError(error))
{
lockedPaths.Add(rolloutPath);
continue;
}
catch (Exception error) when (skipLockedReads && IsRolloutFileUnreadableError(error))
{
unreadablePaths.Add(rolloutPath);
continue;
}
if (!TryParseSessionMetaRecord(record.FirstLine, out JsonObject? root, out JsonObject? payload))
{
continue;
}
string currentProvider = payload!["model_provider"]?.GetValue<string>() ?? "(missing)";
Dictionary<string, int> bucket = dirName == "archived_sessions" ? archivedCounts : sessionCounts;
bucket[currentProvider] = bucket.TryGetValue(currentProvider, out int count) ? count + 1 : 1;
if (payload["id"]?.GetValue<string>() is string metadataThreadId
&& !string.IsNullOrWhiteSpace(metadataThreadId)
&& payload["cwd"]?.GetValue<string>() is string metadataCwd
&& !string.IsNullOrWhiteSpace(metadataCwd))
{
threadCwdsById[metadataThreadId] = ToDesktopWorkspacePath(metadataCwd);
}
bool hasEncryptedContent;
try
{
hasEncryptedContent = await FileHasEncryptedContentAsync(rolloutPath, record.FirstLine, record.Offset);
if (payload["id"]?.GetValue<string>() is string threadId
&& await FileHasUserEventAsync(rolloutPath, record.FirstLine, record.Offset))
{
userEventThreadIds.Add(threadId);
}
}
catch (Exception error) when (skipLockedReads && IsRolloutFileBusyError(error))
{
lockedPaths.Add(rolloutPath);
continue;
}
catch (Exception error) when (skipLockedReads && IsRolloutFileUnreadableError(error))
{
unreadablePaths.Add(rolloutPath);
continue;
}
if (hasEncryptedContent)
{
Dictionary<string, int> encryptedBucket = dirName == "archived_sessions" ? encryptedArchivedCounts : encryptedSessionCounts;
encryptedBucket[currentProvider] = encryptedBucket.TryGetValue(currentProvider, out int encryptedCount) ? encryptedCount + 1 : 1;
}
if (!string.Equals(targetProvider, StatusOnlyProvider, StringComparison.Ordinal)
&& !string.Equals(currentProvider, targetProvider, StringComparison.Ordinal))
{
FileSnapshot snapshot = GetFileSnapshot(rolloutPath);
payload["model_provider"] = targetProvider;
changes.Add(new SessionChange
{
Path = rolloutPath,
ThreadId = payload["id"]?.GetValue<string>(),
Directory = dirName,
OriginalFirstLine = record.FirstLine,
OriginalSeparator = record.Separator,
OriginalOffset = record.Offset,
OriginalFileLength = snapshot.Length,
OriginalLastWriteTimeUtcTicks = snapshot.LastWriteTimeUtcTicks,
OriginalProvider = currentProvider,
UpdatedFirstLine = root!.ToJsonString()
});
}
}
}
return new SessionChangeCollection
{
Changes = changes,
LockedPaths = lockedPaths.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(),
UnreadablePaths = unreadablePaths.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(),
ProviderCounts = new ProviderCounts
{
Sessions = sessionCounts,
ArchivedSessions = archivedCounts
},
EncryptedContentCounts = new ProviderCounts
{
Sessions = encryptedSessionCounts,
ArchivedSessions = encryptedArchivedCounts
},
UserEventThreadIds = userEventThreadIds,
ThreadCwdsById = threadCwdsById
};
}
public async Task<SessionApplyResult> ApplySessionChangesAsync(IEnumerable<SessionChange> changes)
{
int appliedCount = 0;
List<string> appliedPaths = [];
List<string> skippedPaths = [];
foreach (SessionChange change in changes)
{
if (await TryRewriteCollectedSessionChangeAsync(change))
{
TryRestoreLastWriteTimeUtc(change.Path, change.OriginalLastWriteTimeUtcTicks);
appliedCount += 1;
appliedPaths.Add(change.Path);
}
else
{
skippedPaths.Add(change.Path);
}
}
appliedPaths.Sort(StringComparer.Ordinal);
skippedPaths.Sort(StringComparer.Ordinal);
return new SessionApplyResult
{
AppliedCount = appliedCount,
AppliedPaths = appliedPaths,
SkippedPaths = skippedPaths
};
}
public async Task AssertSessionFilesWritableAsync(IEnumerable<string> filePaths)
{
List<string> lockedPaths = await FindLockedFilesAsync(filePaths);
if (lockedPaths.Count == 0)
{
return;
}
string preview = string.Join(", ", lockedPaths.Take(5));
int extraCount = lockedPaths.Count - Math.Min(lockedPaths.Count, 5);
string suffix = extraCount > 0 ? $" (+{extraCount} more)" : string.Empty;
throw new InvalidOperationException(
$"Unable to rewrite rollout files because {lockedPaths.Count} file(s) are currently in use. Close Codex and the Codex app, then retry. Locked file(s): {preview}{suffix}");
}
public async Task<(IReadOnlyList<SessionChange> WritableChanges, IReadOnlyList<SessionChange> LockedChanges)> SplitLockedSessionChangesAsync(
IEnumerable<SessionChange> changes)
{
List<SessionChange> changeList = changes.ToList();
List<string> lockedPaths = await FindLockedFilesAsync(changeList.Select(static change => change.Path));
if (lockedPaths.Count == 0)
{
return (changeList, []);
}
HashSet<string> lockedSet = new(lockedPaths, StringComparer.Ordinal);
List<SessionChange> writable = [];
List<SessionChange> locked = [];
foreach (SessionChange change in changeList)
{
if (lockedSet.Contains(change.Path))
{
locked.Add(change);
}
else
{
writable.Add(change);
}
}
return (writable, locked);
}
internal async Task RestoreSessionChangesAsync(IEnumerable<SessionBackupManifestEntry> manifestEntries)
{
foreach (SessionBackupManifestEntry entry in manifestEntries)
{
await RewriteFirstLineAsync(entry.Path, entry.OriginalFirstLine, entry.OriginalSeparator);
TryRestoreLastWriteTimeUtc(entry.Path, entry.OriginalLastWriteTimeUtcTicks);
}
}
internal Task RestoreSessionChangesAsync(IEnumerable<SessionChange> changes)
{
return RestoreSessionChangesAsync(
changes.Select(static change => new SessionBackupManifestEntry
{
Path = change.Path,
OriginalFirstLine = change.OriginalFirstLine,
OriginalSeparator = change.OriginalSeparator,
OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks
}));
}
private static bool TryParseSessionMetaRecord(
string firstLine,
out JsonObject? root,
out JsonObject? payload)
{
root = null;
payload = null;
if (string.IsNullOrWhiteSpace(firstLine))
{
return false;
}
try
{
root = JsonNode.Parse(firstLine) as JsonObject;
if (root?["type"]?.GetValue<string>() != "session_meta")
{
return false;
}
payload = root["payload"] as JsonObject;
return payload is not null;
}
catch
{
return false;
}
}
private async Task<FirstLineRecord> ReadFirstLineRecordAsync(string filePath)
{
try
{
await using FileStream stream = new(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return await ReadFirstLineRecordAsync(stream);
}
catch (Exception error)
{
throw WrapRolloutFileBusyError(error, filePath, "read");
}
}
private async Task<bool> TryRewriteCollectedSessionChangeAsync(SessionChange change)
{
try
{
await using FileStream sourceStream = OpenExclusiveRewriteStream(change.Path);
if (sourceStream.Length != change.OriginalFileLength)
{
return false;
}
FirstLineRecord current = await ReadFirstLineRecordAsync(sourceStream);
if (!string.Equals(current.FirstLine, change.OriginalFirstLine, StringComparison.Ordinal)
|| current.Offset != change.OriginalOffset)
{
return false;
}
await RewriteFirstLineAsync(
sourceStream,
change.Path,
change.UpdatedFirstLine,
change.OriginalSeparator,
change.OriginalOffset,
headerOnly: change.OriginalOffset >= change.OriginalFileLength);
return true;
}
catch (Exception error) when (IsRolloutFileBusyError(error))
{
return false;
}
}
private async Task RewriteFirstLineAsync(string filePath, string nextFirstLine, string separator)
{
try
{
await using FileStream sourceStream = OpenExclusiveRewriteStream(filePath);
FirstLineRecord current = await ReadFirstLineRecordAsync(sourceStream);
bool headerOnly = string.IsNullOrEmpty(current.Separator)
&& current.Offset == Encoding.UTF8.GetByteCount(current.FirstLine);
await RewriteFirstLineAsync(sourceStream, filePath, nextFirstLine, separator, current.Offset, headerOnly);
}
catch (Exception error)
{
throw WrapRolloutFileBusyError(error, filePath, "rewrite");
}
}
private static FileStream OpenExclusiveRewriteStream(string filePath)
{
try
{
return new FileStream(
filePath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None,
64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
}
catch (Exception error)
{
throw WrapRolloutFileBusyError(error, filePath, "rewrite");
}
}
private static async Task<FirstLineRecord> ReadFirstLineRecordAsync(FileStream stream)
{
stream.Seek(0, SeekOrigin.Begin);
byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
using MemoryStream collected = new();
while (true)
{
int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length));
if (bytesRead == 0)
{
break;
}
await collected.WriteAsync(buffer.AsMemory(0, bytesRead));
ReadOnlySpan<byte> current = collected.GetBuffer().AsSpan(0, (int)collected.Length);
int newlineIndex = current.IndexOf((byte)'\n');
if (newlineIndex >= 0)
{
bool crlf = newlineIndex > 0 && current[newlineIndex - 1] == '\r';
int lineLength = crlf ? newlineIndex - 1 : newlineIndex;
string firstLine = Encoding.UTF8.GetString(current[..lineLength]);
return new FirstLineRecord(firstLine, crlf ? "\r\n" : "\n", newlineIndex + 1);
}
}
string text = Encoding.UTF8.GetString(collected.GetBuffer(), 0, (int)collected.Length);
return new FirstLineRecord(text, string.Empty, (int)collected.Length);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static async Task RewriteFirstLineAsync(
FileStream sourceStream,
string filePath,
string nextFirstLine,
string separator,
int sourceOffset,
bool headerOnly)
{
string tempPath = $"{filePath}.provider-sync.{Environment.ProcessId}.{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.tmp";
try
{
await using (FileStream writer = new(
tempPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
byte[] firstLineBytes = Encoding.UTF8.GetBytes(nextFirstLine);
await writer.WriteAsync(firstLineBytes);
if (!string.IsNullOrEmpty(separator))
{
byte[] separatorBytes = Encoding.UTF8.GetBytes(separator);
await writer.WriteAsync(separatorBytes);
}
if (!headerOnly)
{
sourceStream.Seek(sourceOffset, SeekOrigin.Begin);
await sourceStream.CopyToAsync(writer);
}
}
await using (FileStream tempReader = new(
tempPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
sourceStream.SetLength(0);
sourceStream.Seek(0, SeekOrigin.Begin);
await tempReader.CopyToAsync(sourceStream);
await sourceStream.FlushAsync();
}
File.Delete(tempPath);
}
catch
{
try
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
catch
{
// Ignore cleanup failures and surface the original error.
}
throw;
}
}
private static FileSnapshot GetFileSnapshot(string filePath)
{
FileInfo fileInfo = new(filePath);
return new FileSnapshot(fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
}
private static async Task<bool> FileContainsTextAsync(string filePath, string text, int startOffset)
{
byte[] needle = Encoding.UTF8.GetBytes(text);
byte[] buffer = ArrayPool<byte>.Shared.Rent(ScanBufferSize);
byte[] tail = [];
try
{
await using FileStream stream = new(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
ScanBufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
if (startOffset > 0)
{
stream.Seek(startOffset, SeekOrigin.Begin);
}
while (true)
{
int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, ScanBufferSize));
if (bytesRead == 0)
{
return false;
}
byte[] haystack = buffer;
int haystackLength = bytesRead;
if (tail.Length > 0)
{
haystackLength = tail.Length + bytesRead;
haystack = ArrayPool<byte>.Shared.Rent(haystackLength);
Buffer.BlockCopy(tail, 0, haystack, 0, tail.Length);
Buffer.BlockCopy(buffer, 0, haystack, tail.Length, bytesRead);
}
try
{
if (ContainsNeedle(haystack, haystackLength, needle))
{
return true;
}
int keepBytes = Math.Min(Math.Max(0, needle.Length - 1), haystackLength);
if (keepBytes == 0)
{
tail = [];
}
else
{
tail = new byte[keepBytes];
Buffer.BlockCopy(haystack, haystackLength - keepBytes, tail, 0, keepBytes);
}
}
finally
{
if (!ReferenceEquals(haystack, buffer))
{
ArrayPool<byte>.Shared.Return(haystack);
}
}
}
}
catch (Exception error)
{
throw WrapRolloutFileBusyError(error, filePath, "scan");
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static bool ContainsNeedle(byte[] haystack, int haystackLength, byte[] needle)
{
if (needle.Length == 0)
{
return true;
}
if (haystackLength < needle.Length)
{
return false;
}
int lastStart = haystackLength - needle.Length;
for (int index = 0; index <= lastStart; index += 1)
{
bool match = true;
for (int needleIndex = 0; needleIndex < needle.Length; needleIndex += 1)
{
if (haystack[index + needleIndex] != needle[needleIndex])
{
match = false;
break;
}
}
if (match)
{
return true;
}
}
return false;
}
private static async Task<bool> FileHasEncryptedContentAsync(string filePath, string firstLine, int startOffset)
{
if (firstLine.Contains("encrypted_content", StringComparison.Ordinal))
{
return true;
}
return await FileContainsTextAsync(filePath, "encrypted_content", startOffset);
}
private static async Task<bool> FileHasUserEventAsync(string filePath, string firstLine, int startOffset)
{
try
{
if (RecordHasUserEvent(JsonNode.Parse(firstLine)))
{
return true;
}
}
catch
{
// Keep scanning the rest of the rollout below.
}
try
{
await using FileStream stream = new(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
if (startOffset > 0)
{
stream.Seek(startOffset, SeekOrigin.Begin);
}
using StreamReader reader = new(
stream,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
bufferSize: 64 * 1024,
leaveOpen: false);
while (await reader.ReadLineAsync() is string rawLine)
{
if (string.IsNullOrWhiteSpace(rawLine))
{
continue;
}
try
{
if (RecordHasUserEvent(JsonNode.Parse(rawLine)))
{
return true;
}
}
catch
{
// Ignore malformed non-metadata lines; provider sync only needs positive evidence.
}
}
return false;
}
catch (Exception error)
{
throw WrapRolloutFileBusyError(error, filePath, "scan");
}
}
private static bool RecordHasUserEvent(JsonNode? record)
{
if (record is not JsonObject root)
{
return false;
}
if (string.Equals(GetString(root["type"]), "event_msg", StringComparison.Ordinal)
&& root["payload"] is JsonObject eventPayload
&& string.Equals(GetString(eventPayload["type"]), "user_message", StringComparison.Ordinal))
{
return true;
}
foreach (string key in new[] { "payload", "item", "msg" })
{
if (root[key] is JsonObject value
&& string.Equals(GetString(value["type"]), "message", StringComparison.Ordinal)
&& string.Equals(GetString(value["role"]), "user", StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string? GetString(JsonNode? node)
{
try
{
return node?.GetValue<string>();
}
catch
{
return null;
}
}
private static string ToDesktopWorkspacePath(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
string trimmed = value.Trim();
if (trimmed.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
{
return @"\\" + trimmed[8..].Replace('/', '\\');
}
if (trimmed.StartsWith(@"\\?\", StringComparison.Ordinal))
{
string withoutPrefix = trimmed[4..].Replace('/', '\\');
if (withoutPrefix.Length == 2 && char.IsLetter(withoutPrefix[0]) && withoutPrefix[1] == ':')
{
return withoutPrefix + "\\";
}
return withoutPrefix;
}
return value;
}
private static void TryRestoreLastWriteTimeUtc(string filePath, long? ticks)
{
if (ticks is null)
{
return;
}
try
{
File.SetLastWriteTimeUtc(filePath, new DateTime(ticks.Value, DateTimeKind.Utc));
}
catch
{
// Best effort only; rewriting metadata is still the primary operation.
}
}
private static async Task<List<string>> FindLockedFilesAsync(IEnumerable<string> filePaths)
{
List<string> lockedPaths = [];
foreach (string filePath in filePaths.Distinct(StringComparer.Ordinal))
{
try
{
await using FileStream stream = new(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
}
catch (Exception error) when (IsRolloutFileBusyError(error))
{
lockedPaths.Add(filePath);
}
}
lockedPaths.Sort(StringComparer.Ordinal);
return lockedPaths;
}
private static bool IsRolloutFileBusyError(Exception error)
{
if (error.InnerException is not null && IsRolloutFileBusyError(error.InnerException))
{
return true;
}
if (error is IOException ioException)
{
int code = ioException.HResult & 0xFFFF;
return code is 32 or 33 or 35;
}
return false;
}
private static bool IsRolloutFileUnreadableError(Exception error)
{
if (error.InnerException is not null && IsRolloutFileUnreadableError(error.InnerException))
{
return true;
}
return error is IOException or UnauthorizedAccessException;
}
private static Exception WrapRolloutFileBusyError(Exception error, string filePath, string action)
{
if (!IsRolloutFileBusyError(error))
{
return error;
}
return new IOException(
$"Unable to {action} rollout file because it is currently in use. Close Codex and the Codex app, then retry. Locked file: {filePath}",
error);
}
private readonly record struct FirstLineRecord(string FirstLine, string Separator, int Offset);
private readonly record struct FileSnapshot(long Length, long LastWriteTimeUtcTicks);
}
@@ -0,0 +1,202 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace CodexProviderSync.Core;
public sealed class SettingsService
{
public SettingsService(string? settingsPath = null)
{
SettingsPath = string.IsNullOrWhiteSpace(settingsPath)
? AppConstants.SettingsPath()
: Path.GetFullPath(settingsPath);
}
public string SettingsPath { get; }
public async Task<AppSettings> LoadAsync()
{
if (!File.Exists(SettingsPath))
{
return new AppSettings();
}
try
{
AppSettings? settings = JsonSerializer.Deserialize<AppSettings>(
await File.ReadAllTextAsync(SettingsPath),
JsonSerializerOptions());
return Normalize(settings ?? new AppSettings());
}
catch
{
return new AppSettings();
}
}
public async Task SaveAsync(AppSettings settings)
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
string json = JsonSerializer.Serialize(Normalize(settings), JsonSerializerOptions());
await File.WriteAllTextAsync(SettingsPath, json);
}
public void Save(AppSettings settings)
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
string json = JsonSerializer.Serialize(Normalize(settings), JsonSerializerOptions());
File.WriteAllText(SettingsPath, json);
}
public AppSettings RecordCodexHome(AppSettings settings, string codexHome)
{
List<string> recents = Deduplicate([codexHome, .. settings.RecentCodexHomes.Select(Path.GetFullPath)])
.Take(10)
.ToList();
return new AppSettings
{
RecentCodexHomes = recents,
LastCodexHome = Path.GetFullPath(codexHome),
SavedProviders = Deduplicate(settings.SavedProviders).ToList(),
ManualProviders = Deduplicate(settings.ManualProviders).ToList(),
LastSelectedProvider = settings.LastSelectedProvider,
LastBackupDirectory = settings.LastBackupDirectory,
BackupRetentionCount = NormalizeBackupRetentionCount(settings.BackupRetentionCount),
UiLanguage = NormalizeUiLanguage(settings.UiLanguage),
WindowBounds = settings.WindowBounds
};
}
public AppSettings MergeDetectedProviders(AppSettings settings, IEnumerable<string> providerIds)
{
return new AppSettings
{
RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(),
LastCodexHome = settings.LastCodexHome,
SavedProviders = Deduplicate([.. settings.SavedProviders, .. providerIds]).ToList(),
ManualProviders = Deduplicate(settings.ManualProviders).ToList(),
LastSelectedProvider = settings.LastSelectedProvider,
LastBackupDirectory = settings.LastBackupDirectory,
BackupRetentionCount = NormalizeBackupRetentionCount(settings.BackupRetentionCount),
UiLanguage = NormalizeUiLanguage(settings.UiLanguage),
WindowBounds = settings.WindowBounds
};
}
public AppSettings AddManualProvider(AppSettings settings, string providerId)
{
return new AppSettings
{
RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(),
LastCodexHome = settings.LastCodexHome,
SavedProviders = Deduplicate([.. settings.SavedProviders, providerId]).ToList(),
ManualProviders = Deduplicate([.. settings.ManualProviders, providerId]).ToList(),
LastSelectedProvider = providerId,
LastBackupDirectory = settings.LastBackupDirectory,
BackupRetentionCount = NormalizeBackupRetentionCount(settings.BackupRetentionCount),
UiLanguage = NormalizeUiLanguage(settings.UiLanguage),
WindowBounds = settings.WindowBounds
};
}
public AppSettings RemoveManualProvider(AppSettings settings, string providerId)
{
return new AppSettings
{
RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(),
LastCodexHome = settings.LastCodexHome,
SavedProviders = settings.SavedProviders.Where(provider => !string.Equals(provider, providerId, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList(),
ManualProviders = settings.ManualProviders.Where(provider => !string.Equals(provider, providerId, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList(),
LastSelectedProvider = string.Equals(settings.LastSelectedProvider, providerId, StringComparison.Ordinal) ? null : settings.LastSelectedProvider,
LastBackupDirectory = settings.LastBackupDirectory,
BackupRetentionCount = NormalizeBackupRetentionCount(settings.BackupRetentionCount),
UiLanguage = NormalizeUiLanguage(settings.UiLanguage),
WindowBounds = settings.WindowBounds
};
}
public AppSettings UpdateUiLanguage(AppSettings settings, string uiLanguage)
{
return new AppSettings
{
RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(),
LastCodexHome = settings.LastCodexHome,
SavedProviders = Deduplicate(settings.SavedProviders).ToList(),
ManualProviders = Deduplicate(settings.ManualProviders).ToList(),
LastSelectedProvider = settings.LastSelectedProvider,
LastBackupDirectory = settings.LastBackupDirectory,
BackupRetentionCount = NormalizeBackupRetentionCount(settings.BackupRetentionCount),
UiLanguage = NormalizeUiLanguage(uiLanguage),
WindowBounds = settings.WindowBounds
};
}
public AppSettings UpdateState(
AppSettings settings,
string? selectedProvider,
string? backupDirectory,
WindowBoundsState? bounds = null,
int? backupRetentionCount = null)
{
return new AppSettings
{
RecentCodexHomes = Deduplicate(settings.RecentCodexHomes).ToList(),
LastCodexHome = settings.LastCodexHome,
SavedProviders = Deduplicate(settings.SavedProviders).ToList(),
ManualProviders = Deduplicate(settings.ManualProviders).ToList(),
LastSelectedProvider = string.IsNullOrWhiteSpace(selectedProvider) ? settings.LastSelectedProvider : selectedProvider.Trim(),
LastBackupDirectory = string.IsNullOrWhiteSpace(backupDirectory) ? settings.LastBackupDirectory : Path.GetFullPath(backupDirectory),
BackupRetentionCount = NormalizeBackupRetentionCount(backupRetentionCount ?? settings.BackupRetentionCount),
UiLanguage = NormalizeUiLanguage(settings.UiLanguage),
WindowBounds = bounds ?? settings.WindowBounds
};
}
private static AppSettings Normalize(AppSettings settings)
{
return new AppSettings
{
RecentCodexHomes = Deduplicate(settings.RecentCodexHomes.Select(Path.GetFullPath)).Take(10).ToList(),
LastCodexHome = string.IsNullOrWhiteSpace(settings.LastCodexHome) ? null : Path.GetFullPath(settings.LastCodexHome),
SavedProviders = Deduplicate(settings.SavedProviders).ToList(),
ManualProviders = Deduplicate(settings.ManualProviders).ToList(),
LastSelectedProvider = string.IsNullOrWhiteSpace(settings.LastSelectedProvider) ? null : settings.LastSelectedProvider.Trim(),
LastBackupDirectory = string.IsNullOrWhiteSpace(settings.LastBackupDirectory) ? null : Path.GetFullPath(settings.LastBackupDirectory),
BackupRetentionCount = NormalizeBackupRetentionCount(settings.BackupRetentionCount),
UiLanguage = NormalizeUiLanguage(settings.UiLanguage),
WindowBounds = settings.WindowBounds
};
}
private static IEnumerable<string> Deduplicate(IEnumerable<string> values)
{
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal);
}
private static JsonSerializerOptions JsonSerializerOptions()
{
return new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
}
private static int NormalizeBackupRetentionCount(int value)
{
return value < 1 ? AppConstants.DefaultBackupRetentionCount : value;
}
private static string NormalizeUiLanguage(string? value)
{
return string.Equals(value, "zh-Hans", StringComparison.Ordinal) ? "zh-Hans" : "en";
}
}
@@ -0,0 +1,403 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace CodexProviderSync.Core;
/// <summary>
/// Cross-platform single-instance guard. The first process to call
/// <see cref="Acquire"/> owns the lock; subsequent callers get back a
/// <see cref="SingleInstanceAcquisition"/> with <c>IsOwner == false</c> and
/// the metadata of the existing owner so the caller can route a "focus"
/// request to it.
/// </summary>
public sealed class SingleInstanceGuard
{
private const int Win32ErrorAlreadyExists = 183;
private const int Win32ErrorAccessDenied = 5;
private const int Win32ErrorSharingViolation = 32;
private const int Win32ErrorLockViolation = 33;
// The previous default budget (3 × 75 ms = 225 ms) was too tight for
// Windows Defender / OneDrive / antivirus file-locking drivers which
// can hold the directory open for several seconds on first launch.
// 30 × 100 ms = 3 s gives the OS enough breathing room to release
// the lock without making genuine failures drag on indefinitely.
private const int DefaultCreateRetryCount = 30;
private const int DefaultCreateRetryDelayMs = 100;
private const int RaceWindowRetryCount = 20;
private const int RaceWindowRetryDelayMs = 25;
private static readonly JsonSerializerOptions OwnerJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public SingleInstanceGuard() : this(StandardOwnerProbe)
{
}
/// <summary>
/// Test-only constructor: lets callers inject a probe for "is this PID
/// still alive?" so unit tests do not depend on a real running process.
/// </summary>
internal SingleInstanceGuard(Func<int, bool> isProcessAlive)
: this(isProcessAlive, DefaultLockDirectory())
{
}
/// <summary>
/// Test-only constructor: lets callers inject the lock directory and the
/// process probe. Use this to point the guard at a temp folder instead
/// of the user's real <c>%APPDATA%/codex-provider-sync/singleton</c>.
/// </summary>
internal SingleInstanceGuard(Func<int, bool> isProcessAlive, string lockDirectory)
{
IsProcessAlive = isProcessAlive;
LockDirectory = lockDirectory;
}
internal Func<int, bool> IsProcessAlive { get; }
public string LockDirectory { get; }
private static string DefaultLockDirectory()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"codex-provider-sync",
"singleton");
}
public SingleInstanceAcquisition Acquire(string label = "codex-provider-sync")
{
// The lock directory itself is the contested resource, so it
// must be created atomically below. Only its parent path
// should be pre-created — using `Directory.CreateDirectory`
// on the lock directory here would silently succeed on the
// first launch, which then makes the atomic `TryCreateDirectory`
// always report `Win32ErrorAlreadyExists` and route the first
// caller into the stale-lock recovery branch. Two concurrent
// launches could then race deleting each other's lock dir and
// violate the single-instance guarantee.
string? parentDirectory = Path.GetDirectoryName(LockDirectory);
if (!string.IsNullOrEmpty(parentDirectory))
{
Directory.CreateDirectory(parentDirectory);
}
int attempts = 0;
while (true)
{
int errorCode = TryCreateDirectory(LockDirectory);
if (errorCode == 0)
{
WriteOwnerMetadata(label);
return new SingleInstanceAcquisition(
isOwner: true,
existingOwner: null,
lockDirectory: LockDirectory,
guard: this);
}
if (errorCode != Win32ErrorAlreadyExists)
{
if (!IsTransientLockCreateError(errorCode) || attempts >= DefaultCreateRetryCount)
{
throw new IOException(
$"Unable to acquire single-instance lock at {LockDirectory}. Win32 error: {errorCode}");
}
attempts += 1;
System.Threading.Thread.Sleep(DefaultCreateRetryDelayMs);
continue;
}
// Lock directory already exists. Inspect the owner and either
// (a) refuse to start because the owner is still alive, or
// (b) clean up and retry if the previous owner has died.
SingleInstanceOwner? owner = ReadOwnerMetadata();
if (owner is not null
&& owner.ProcessId != Environment.ProcessId
&& IsProcessAlive(owner.ProcessId))
{
return new SingleInstanceAcquisition(
isOwner: false,
existingOwner: owner,
lockDirectory: LockDirectory,
guard: this);
}
// If the lock dir exists but `owner.json` is missing
// we have two possible cases:
// (a) race window — the winning process atomically created
// the lock dir but has not yet finished writing
// `owner.json`. Do NOT delete the directory here; that
// would race the winner's write and produce a
// stale-lock IOException in both processes. Back off
// briefly and let the winner finish.
// (b) stale leftover — the previous owner died (or crashed)
// and left behind arbitrary files (e.g., an unowned
// `BLOCK` file from an interrupted cleanup). The lock
// directory contains entries that are not `owner.json`,
// so we can safely delete and reclaim.
// We disambiguate by checking the lock dir's contents: an
// empty lock dir signals the race window; a non-empty lock
// dir with no `owner.json` signals stale leftover. We give
// the race-window case a much larger retry budget (20 × 25 ms
// = 500 ms) than the default because the winner's write of a
// few-KB JSON file under anti-virus / filesystem contention
// can easily exceed a couple hundred milliseconds on Windows.
if (owner is null)
{
bool lockDirHasLeftovers = HasLockDirLeftovers();
if (!lockDirHasLeftovers)
{
attempts += 1;
if (attempts >= RaceWindowRetryCount)
{
throw new IOException(
$"Single-instance lock at {LockDirectory} exists but its owner metadata has not appeared yet. The owning process may be hung or under load.");
}
System.Threading.Thread.Sleep(RaceWindowRetryDelayMs);
continue;
}
// Fall through to the stale-leftover delete branch.
}
// Stale lock (previous owner died or wrote no metadata). Best-effort
// cleanup; if the delete loses a race, the next iteration will retry.
try
{
Directory.Delete(LockDirectory, recursive: true);
}
catch (IOException)
{
attempts += 1;
if (attempts >= DefaultCreateRetryCount)
{
throw new IOException(
$"Single-instance lock at {LockDirectory} is held by a stale owner and could not be cleared. Remove the directory manually and retry.");
}
System.Threading.Thread.Sleep(DefaultCreateRetryDelayMs);
}
}
}
private void WriteOwnerMetadata(string label)
{
SingleInstanceOwner owner = new()
{
ProcessId = Environment.ProcessId,
StartedAt = DateTimeOffset.UtcNow,
Label = label,
CurrentDirectory = Environment.CurrentDirectory
};
File.WriteAllText(
Path.Combine(LockDirectory, "owner.json"),
JsonSerializer.Serialize(owner, OwnerJsonOptions));
}
internal SingleInstanceOwner? ReadOwnerMetadata()
{
string path = Path.Combine(LockDirectory, "owner.json");
if (!File.Exists(path))
{
return null;
}
try
{
string text = File.ReadAllText(path);
return JsonSerializer.Deserialize<SingleInstanceOwner>(text, OwnerJsonOptions);
}
catch (Exception)
{
// Corrupt metadata; treat as no owner so the caller can decide.
return null;
}
}
internal static int TryCreateDirectory(string path)
{
return OperatingSystem.IsWindows()
? TryCreateDirectoryWindows(path)
: TryCreateDirectoryPosix(path);
}
internal static int TryCreateDirectoryWindows(string path)
{
return CreateDirectory(path, IntPtr.Zero) ? 0 : Marshal.GetLastWin32Error();
}
internal static int TryCreateDirectoryPosix(string path)
{
// 448 = 0700 octal; only the owner needs to read/write/execute the
// singleton directory.
if (PosixMkdir(path, 448) == 0)
{
return 0;
}
int errorCode = Marshal.GetLastWin32Error();
return errorCode switch
{
17 => Win32ErrorAlreadyExists, // EEXIST
1 or 13 => Win32ErrorAccessDenied, // EPERM / EACCES
_ => errorCode
};
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateDirectory(string lpPathName, IntPtr lpSecurityAttributes);
// Linux: glibc ships mkdir in libc. macOS: the same symbol resolves
// through libSystem. Pin the EntryPoint and let the runtime pick the
// right library on each platform via the DllImportResolver. Without
// this, macOS hits an unresolved-symbol DllNotFoundException the
// moment we call into this path.
[DllImport("libc", EntryPoint = "mkdir", SetLastError = true)]
private static extern int LibcMkdir(string pathname, uint mode);
[DllImport("libSystem", EntryPoint = "mkdir", SetLastError = true)]
private static extern int LibSystemMkdir(string pathname, uint mode);
private static int PosixMkdir(string path, uint mode)
{
return OperatingSystem.IsMacOS()
? LibSystemMkdir(path, mode)
: LibcMkdir(path, mode);
}
private static bool IsTransientLockCreateError(int errorCode)
{
// Access-denied, sharing-violation, and lock-violation are
// all transient Windows errors that mean "the lock directory
// is currently held by another process". Anti-virus and
// cloud-sync drivers often hold these for a few seconds on
// first launch; retrying through the default budget lets the
// OS release them without surfacing an error to the user.
return errorCode == Win32ErrorAccessDenied
|| errorCode == Win32ErrorSharingViolation
|| errorCode == Win32ErrorLockViolation;
}
// True when the lock directory exists, `owner.json` is not
// present, and there is at least one other entry inside the
// lock directory. Used to tell apart the "race window"
// (lock dir is empty, winner is mid-write) from the
// "stale leftover" (previous owner crashed and left
// arbitrary files behind) cases without trusting timing.
private bool HasLockDirLeftovers()
{
try
{
if (!Directory.Exists(LockDirectory))
{
return false;
}
foreach (string entry in Directory.EnumerateFileSystemEntries(LockDirectory))
{
string name = Path.GetFileName(entry);
if (!string.Equals(name, "owner.json", StringComparison.Ordinal))
{
return true;
}
}
return false;
}
catch (IOException)
{
// Treat unreadable lock dirs as "leftovers" so the
// caller can attempt the stale-delete branch rather
// than spinning in the race-window backoff.
return true;
}
catch (UnauthorizedAccessException)
{
return true;
}
}
internal static bool StandardOwnerProbe(int processId)
{
if (processId <= 0)
{
return false;
}
try
{
using Process probe = Process.GetProcessById(processId);
return !probe.HasExited;
}
catch (ArgumentException)
{
// No process with that id.
return false;
}
catch (InvalidOperationException)
{
// The process has already exited by the time we touched it.
return false;
}
catch (System.ComponentModel.Win32Exception)
{
// On Linux the owner may belong to another user; we cannot
// query it, so treat it as "alive" to be safe and avoid
// stealing the lock out from under another user.
return true;
}
}
}
public sealed class SingleInstanceAcquisition : IDisposable
{
private readonly SingleInstanceGuard _guard;
private bool _disposed;
internal SingleInstanceAcquisition(
bool isOwner,
SingleInstanceOwner? existingOwner,
string lockDirectory,
SingleInstanceGuard guard)
{
IsOwner = isOwner;
ExistingOwner = existingOwner;
LockDirectory = lockDirectory;
_guard = guard;
}
public bool IsOwner { get; }
public SingleInstanceOwner? ExistingOwner { get; }
public string LockDirectory { get; }
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (IsOwner && Directory.Exists(LockDirectory))
{
try
{
Directory.Delete(LockDirectory, recursive: true);
}
catch (IOException)
{
// Best-effort cleanup; the OS will release the directory on
// process exit anyway.
}
}
}
}
public sealed class SingleInstanceOwner
{
public int ProcessId { get; set; }
public DateTimeOffset StartedAt { get; set; }
public string Label { get; set; } = string.Empty;
public string CurrentDirectory { get; set; } = string.Empty;
}
@@ -0,0 +1,568 @@
using Microsoft.Data.Sqlite;
namespace CodexProviderSync.Core;
public sealed class SqliteStateService
{
private const int DefaultBusyTimeoutMs = 5000;
private sealed record StateDbCandidateStats(
StateDbLocation Location,
int Priority,
long ThreadCount,
long MaxThreadTimestampMs,
long LastWriteTimeUtcTicks,
long RolloutDistance);
static SqliteStateService()
{
SQLitePCL.Batteries_V2.Init();
}
public string StateDbPath(string codexHome)
{
return Path.Combine(codexHome, AppConstants.SqliteDirBasename, AppConstants.DbFileBasename);
}
public string LegacyStateDbPath(string codexHome)
{
return Path.Combine(codexHome, AppConstants.DbFileBasename);
}
public IReadOnlyList<StateDbLocation> StateDbCandidates(string codexHome)
{
return
[
new StateDbLocation(
StateDbPath(codexHome),
Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename),
"sqlite-dir"),
new StateDbLocation(
LegacyStateDbPath(codexHome),
AppConstants.DbFileBasename,
"legacy-root")
];
}
public StateDbLocation? DetectStateDb(string codexHome)
{
List<(StateDbLocation Location, int Priority)> existingCandidates = [];
IReadOnlyList<StateDbLocation> candidates = StateDbCandidates(codexHome);
for (int index = 0; index < candidates.Count; index += 1)
{
StateDbLocation candidate = candidates[index];
if (File.Exists(candidate.Path))
{
existingCandidates.Add((candidate, index));
}
}
if (existingCandidates.Count == 0)
{
return null;
}
long rolloutCount = CountRolloutFiles(codexHome);
List<StateDbCandidateStats> readableCandidates = [];
foreach ((StateDbLocation candidate, int priority) in existingCandidates)
{
try
{
StateDbCandidateStats stats = ReadStateDbCandidateStats(candidate, priority, rolloutCount);
readableCandidates.Add(stats);
}
catch
{
// Keep unreadable candidates as a fallback so existing status/error
// handling still points at state_5.sqlite when no usable DB exists.
}
}
if (readableCandidates.Count == 0)
{
return existingCandidates[0].Location;
}
return readableCandidates
.OrderBy(static candidate => candidate.RolloutDistance)
.ThenByDescending(static candidate => candidate.ThreadCount)
.ThenByDescending(static candidate => candidate.MaxThreadTimestampMs)
.ThenByDescending(static candidate => candidate.LastWriteTimeUtcTicks)
.ThenBy(static candidate => candidate.Priority)
.First()
.Location;
}
public string? ExistingStateDbPath(string codexHome)
{
return DetectStateDb(codexHome)?.Path;
}
public async Task<ProviderCounts?> ReadSqliteProviderCountsAsync(string codexHome)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return null;
}
try
{
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly);
await connection.OpenAsync();
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT
CASE
WHEN model_provider IS NULL OR model_provider = '' THEN '(missing)'
ELSE model_provider
END AS model_provider,
archived,
COUNT(*) AS count
FROM threads
GROUP BY model_provider, archived
ORDER BY archived, model_provider
""";
Dictionary<string, int> sessions = new(StringComparer.Ordinal);
Dictionary<string, int> archivedSessions = new(StringComparer.Ordinal);
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string provider = reader.GetString(0);
bool archived = reader.GetInt64(1) != 0;
int count = reader.GetInt32(2);
Dictionary<string, int> bucket = archived ? archivedSessions : sessions;
bucket[provider] = count;
}
return new ProviderCounts
{
Sessions = sessions,
ArchivedSessions = archivedSessions
};
}
catch (Exception error) when (IsSqliteMalformedError(error))
{
return new ProviderCounts
{
Unreadable = true,
Error = "state_5.sqlite is malformed or unreadable"
};
}
catch (Exception error) when (IsSqliteBusyError(error))
{
return new ProviderCounts
{
Unreadable = true,
Error = "state_5.sqlite is currently in use"
};
}
}
public async Task<SqliteRepairStats?> ReadSqliteRepairStatsAsync(
string codexHome,
IReadOnlyCollection<string>? userEventThreadIds = null,
IReadOnlyDictionary<string, string>? threadCwdsById = null)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return null;
}
try
{
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly);
await connection.OpenAsync();
int userEventRowsNeedingRepair = 0;
if (userEventThreadIds?.Count > 0 && await TableHasColumnAsync(connection, "threads", "has_user_event"))
{
await using SqliteCommand userEventCommand = connection.CreateCommand();
userEventCommand.CommandText = "SELECT has_user_event FROM threads WHERE id = $id";
SqliteParameter idParameter = userEventCommand.Parameters.Add("$id", SqliteType.Text);
foreach (string threadId in userEventThreadIds)
{
idParameter.Value = threadId;
object? value = await userEventCommand.ExecuteScalarAsync();
if (value is not null && value is not DBNull && Convert.ToInt64(value) != 1)
{
userEventRowsNeedingRepair += 1;
}
}
}
int cwdRowsNeedingRepair = 0;
if (threadCwdsById?.Count > 0 && await TableHasColumnAsync(connection, "threads", "cwd"))
{
await using SqliteCommand cwdCommand = connection.CreateCommand();
cwdCommand.CommandText = "SELECT cwd FROM threads WHERE id = $id";
SqliteParameter idParameter = cwdCommand.Parameters.Add("$id", SqliteType.Text);
foreach ((string threadId, string expectedCwd) in threadCwdsById)
{
if (string.IsNullOrWhiteSpace(threadId) || string.IsNullOrWhiteSpace(expectedCwd))
{
continue;
}
idParameter.Value = threadId;
object? value = await cwdCommand.ExecuteScalarAsync();
if (value is not null
&& value is not DBNull
&& !string.Equals(Convert.ToString(value), expectedCwd, StringComparison.Ordinal))
{
cwdRowsNeedingRepair += 1;
}
}
}
return new SqliteRepairStats
{
UserEventRowsNeedingRepair = userEventRowsNeedingRepair,
CwdRowsNeedingRepair = cwdRowsNeedingRepair
};
}
catch (Exception error)
{
throw WrapSqliteMalformedError(
WrapSqliteBusyError(error, "read SQLite repair diagnostics"),
"read SQLite repair diagnostics");
}
}
public async Task<bool> AssertSqliteWritableAsync(string codexHome, int? busyTimeoutMs = null)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return false;
}
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadWriteCreate);
try
{
await connection.OpenAsync();
await SetBusyTimeoutAsync(connection, busyTimeoutMs);
await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE");
await ExecuteNonQueryAsync(connection, "ROLLBACK");
return true;
}
catch (Exception error)
{
throw WrapSqliteMalformedError(
WrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata");
}
}
public async Task<(int UpdatedRows, int ProviderRowsUpdated, int UserEventRowsUpdated, int CwdRowsUpdated, bool DatabasePresent)> UpdateSqliteProviderAsync(
string codexHome,
string targetProvider,
Func<(int UpdatedRows, int ProviderRowsUpdated, int UserEventRowsUpdated, int CwdRowsUpdated, bool DatabasePresent), Task>? afterUpdate = null,
int? busyTimeoutMs = null,
IReadOnlyCollection<string>? userEventThreadIds = null,
IReadOnlyDictionary<string, string>? threadCwdsById = null)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
if (afterUpdate is not null)
{
await afterUpdate((0, 0, 0, 0, false));
}
return (0, 0, 0, 0, false);
}
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadWriteCreate);
bool transactionOpen = false;
try
{
await connection.OpenAsync();
await SetBusyTimeoutAsync(connection, busyTimeoutMs);
await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE");
transactionOpen = true;
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE threads
SET model_provider = $provider
WHERE COALESCE(model_provider, '') <> $provider
""";
command.Parameters.AddWithValue("$provider", targetProvider);
int providerRowsUpdated = await command.ExecuteNonQueryAsync();
int userEventRowsUpdated = 0;
if (userEventThreadIds?.Count > 0 && await TableHasColumnAsync(connection, "threads", "has_user_event"))
{
await using SqliteCommand userEventCommand = connection.CreateCommand();
userEventCommand.CommandText = """
UPDATE threads
SET has_user_event = 1
WHERE id = $id AND COALESCE(has_user_event, 0) <> 1
""";
SqliteParameter idParameter = userEventCommand.Parameters.Add("$id", SqliteType.Text);
foreach (string threadId in userEventThreadIds)
{
idParameter.Value = threadId;
userEventRowsUpdated += await userEventCommand.ExecuteNonQueryAsync();
}
}
int cwdRowsUpdated = 0;
if (threadCwdsById?.Count > 0 && await TableHasColumnAsync(connection, "threads", "cwd"))
{
await using SqliteCommand cwdCommand = connection.CreateCommand();
cwdCommand.CommandText = """
UPDATE threads
SET cwd = $cwd
WHERE id = $id AND COALESCE(cwd, '') <> $cwd
""";
SqliteParameter cwdIdParameter = cwdCommand.Parameters.Add("$id", SqliteType.Text);
SqliteParameter cwdParameter = cwdCommand.Parameters.Add("$cwd", SqliteType.Text);
foreach ((string threadId, string cwd) in threadCwdsById)
{
if (string.IsNullOrWhiteSpace(threadId) || string.IsNullOrWhiteSpace(cwd))
{
continue;
}
cwdIdParameter.Value = threadId;
cwdParameter.Value = cwd;
cwdRowsUpdated += await cwdCommand.ExecuteNonQueryAsync();
}
}
int updatedRows = providerRowsUpdated + userEventRowsUpdated + cwdRowsUpdated;
if (afterUpdate is not null)
{
await afterUpdate((updatedRows, providerRowsUpdated, userEventRowsUpdated, cwdRowsUpdated, true));
}
await ExecuteNonQueryAsync(connection, "COMMIT");
transactionOpen = false;
return (updatedRows, providerRowsUpdated, userEventRowsUpdated, cwdRowsUpdated, true);
}
catch (Exception error)
{
if (transactionOpen)
{
try
{
await ExecuteNonQueryAsync(connection, "ROLLBACK");
}
catch
{
// Ignore rollback failures and surface the original error.
}
}
throw WrapSqliteMalformedError(
WrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata");
}
}
private static SqliteConnection OpenConnection(string dbPath, SqliteOpenMode mode)
{
SqliteConnectionStringBuilder builder = new()
{
DataSource = dbPath,
Mode = mode,
Pooling = false
};
return new SqliteConnection(builder.ConnectionString);
}
private static long CountRolloutFiles(string codexHome)
{
long count = 0;
foreach (string directory in AppConstants.SessionDirectories)
{
count += CountRolloutFilesInDirectory(Path.Combine(codexHome, directory));
}
return count;
}
private static long CountRolloutFilesInDirectory(string rootDir)
{
if (!Directory.Exists(rootDir))
{
return 0;
}
try
{
return Directory
.EnumerateFiles(rootDir, "rollout-*.jsonl", SearchOption.AllDirectories)
.LongCount();
}
catch
{
return 0;
}
}
private static StateDbCandidateStats ReadStateDbCandidateStats(
StateDbLocation candidate,
int priority,
long rolloutCount)
{
using SqliteConnection connection = OpenConnection(candidate.Path, SqliteOpenMode.ReadOnly);
connection.Open();
if (!TableExists(connection, "threads"))
{
throw new InvalidOperationException("threads table not found");
}
long threadCount = ExecuteScalarLong(connection, "SELECT COUNT(*) FROM threads");
long rolloutDistance = rolloutCount > 0 ? Math.Abs(threadCount - rolloutCount) : 0;
return new StateDbCandidateStats(
candidate,
priority,
threadCount,
MaxThreadTimestampMs(connection),
File.GetLastWriteTimeUtc(candidate.Path).Ticks,
rolloutDistance);
}
private static bool TableExists(SqliteConnection connection, string tableName)
{
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name";
command.Parameters.AddWithValue("$name", tableName);
object? value = command.ExecuteScalar();
return value is not null && value is not DBNull;
}
private static long MaxThreadTimestampMs(SqliteConnection connection)
{
if (TableHasColumn(connection, "threads", "updated_at_ms"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(updated_at_ms), 0) FROM threads");
}
if (TableHasColumn(connection, "threads", "updated_at"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(updated_at), 0) FROM threads") * 1000;
}
if (TableHasColumn(connection, "threads", "created_at_ms"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(created_at_ms), 0) FROM threads");
}
if (TableHasColumn(connection, "threads", "created_at"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(created_at), 0) FROM threads") * 1000;
}
return 0;
}
private static long ExecuteScalarLong(SqliteConnection connection, string commandText)
{
using SqliteCommand command = connection.CreateCommand();
command.CommandText = commandText;
object? value = command.ExecuteScalar();
return value is null || value is DBNull ? 0 : Convert.ToInt64(value);
}
private static async Task SetBusyTimeoutAsync(SqliteConnection connection, int? busyTimeoutMs)
{
int timeout = busyTimeoutMs is >= 0 ? busyTimeoutMs.Value : DefaultBusyTimeoutMs;
await ExecuteNonQueryAsync(connection, $"PRAGMA busy_timeout = {timeout}");
}
private static async Task ExecuteNonQueryAsync(SqliteConnection connection, string commandText)
{
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = commandText;
await command.ExecuteNonQueryAsync();
}
private static async Task<bool> TableHasColumnAsync(SqliteConnection connection, string tableName, string columnName)
{
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({QuoteIdentifier(tableName)})";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
if (string.Equals(reader.GetString(1), columnName, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static bool TableHasColumn(SqliteConnection connection, string tableName, string columnName)
{
using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({QuoteIdentifier(tableName)})";
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader.GetString(1), columnName, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string QuoteIdentifier(string value)
{
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
}
internal static Exception WrapSqliteBusyError(Exception error, string action)
{
if (error is not SqliteException sqliteError
|| (sqliteError.SqliteErrorCode != 5 && sqliteError.SqliteErrorCode != 6))
{
return error;
}
return new InvalidOperationException(
$"Unable to {action} because state_5.sqlite is currently in use. Close Codex and the Codex app, then retry. Original error: {sqliteError.Message}",
sqliteError);
}
private static bool IsSqliteBusyError(Exception error)
{
if (error.InnerException is not null && IsSqliteBusyError(error.InnerException))
{
return true;
}
return error is SqliteException sqliteError
&& (sqliteError.SqliteErrorCode == 5 || sqliteError.SqliteErrorCode == 6);
}
private static bool IsSqliteMalformedError(Exception error)
{
if (error.InnerException is not null && IsSqliteMalformedError(error.InnerException))
{
return true;
}
return error is SqliteException sqliteError
&& (sqliteError.SqliteErrorCode == 11
|| sqliteError.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase)
|| sqliteError.Message.Contains("not a database", StringComparison.OrdinalIgnoreCase));
}
internal static Exception WrapSqliteMalformedError(Exception error, string action)
{
if (!IsSqliteMalformedError(error))
{
return error;
}
return new InvalidOperationException(
$"Unable to {action} because state_5.sqlite is malformed or unreadable. Close Codex, back up or repair the database, then retry. Original error: {error.Message}",
error);
}
}
@@ -0,0 +1,210 @@
using System.Collections.Generic;
using System.Linq;
namespace CodexProviderSync.Core;
public static class TextFormatter
{
public static string FormatStatus(StatusSnapshot status)
{
List<string> lines =
[
$"Codex home: {status.CodexHome}",
$"Current provider: {status.CurrentProvider.Provider}{(status.CurrentProvider.Implicit ? " (implicit default)" : string.Empty)}",
$"Configured providers: {string.Join(", ", status.ConfiguredProviders)}",
$"Backups: {status.BackupSummary.Count} ({FormatBytes(status.BackupSummary.TotalBytes)})",
$"Backup root: {status.BackupRoot}",
string.Empty,
"Rollout files:",
$" sessions: {FormatCounts(status.RolloutCounts.Sessions)}",
$" archived_sessions: {FormatCounts(status.RolloutCounts.ArchivedSessions)}",
$" encrypted_content sessions: {FormatCounts(status.EncryptedContentCounts.Sessions)}",
$" encrypted_content archived_sessions: {FormatCounts(status.EncryptedContentCounts.ArchivedSessions)}",
string.Empty,
"SQLite state:"
];
List<string> rolloutNotes = [];
if (status.LockedRolloutFiles.Count > 0)
{
rolloutNotes.Add($" Locked rollout files skipped during status scan: {status.LockedRolloutFiles.Count}");
}
if (status.UnreadableRolloutFiles.Count > 0)
{
rolloutNotes.Add($" Unreadable rollout files skipped during status scan: {status.UnreadableRolloutFiles.Count}");
}
if (!string.IsNullOrWhiteSpace(status.EncryptedContentWarning))
{
rolloutNotes.Add($" {status.EncryptedContentWarning}");
}
lines.InsertRange(11, rolloutNotes);
if (status.StateDbLocation is not null)
{
string legacyNote = status.StateDbLocation.Source == "legacy-root" ? " (legacy root)" : string.Empty;
lines.Add($" database: {status.StateDbLocation.Path}{legacyNote}");
}
else
{
lines.Add(" database: not found (checked sqlite/state_5.sqlite, state_5.sqlite)");
}
if (status.SqliteCounts?.Unreadable == true)
{
lines.Add($" {status.SqliteCounts.Error ?? "state_5.sqlite is malformed or unreadable"}");
}
else if (status.SqliteCounts is null)
{
lines.Add(" state_5.sqlite not found");
}
else
{
lines.Add($" sessions: {FormatCounts(status.SqliteCounts.Sessions)}");
lines.Add($" archived_sessions: {FormatCounts(status.SqliteCounts.ArchivedSessions)}");
if (status.SqliteRepairStats?.UserEventRowsNeedingRepair > 0)
{
lines.Add($" user-event flags needing repair: {status.SqliteRepairStats.UserEventRowsNeedingRepair}");
}
if (status.SqliteRepairStats?.CwdRowsNeedingRepair > 0)
{
lines.Add($" cwd paths needing repair: {status.SqliteRepairStats.CwdRowsNeedingRepair}");
}
}
if (status.ProjectThreadVisibility.Count > 0)
{
lines.Add(string.Empty);
lines.Add("Project visibility:");
foreach (ProjectThreadVisibility project in status.ProjectThreadVisibility)
{
string rankText = string.IsNullOrWhiteSpace(project.RankPreview) ? "(none)" : project.RankPreview;
lines.Add(
$" {project.Root}: interactive {project.InteractiveThreads}, first page {project.FirstPageThreads}/50, ranks {rankText}, exact cwd {project.ExactCwdMatches}/{project.InteractiveThreads}, verbatim cwd {project.VerbatimCwdRows}, providers {FormatCounts(project.ProviderCounts)}");
}
}
return string.Join(Environment.NewLine, lines);
}
public static string FormatSyncResult(SyncResult result, string label)
{
List<string> lines =
[
$"{label} provider: {result.TargetProvider}",
$"Codex home: {result.CodexHome}",
$"Backup: {result.BackupDir}",
$"Updated rollout files: {result.ChangedSessionFiles}",
$"Updated SQLite rows: {result.SqliteRowsUpdated}{(result.SqlitePresent ? string.Empty : " (state_5.sqlite not found)")}"
];
if (result.SqliteUserEventRowsUpdated > 0)
{
lines.Add($"Updated SQLite user-event flags: {result.SqliteUserEventRowsUpdated}");
}
if (result.SqliteCwdRowsUpdated > 0)
{
lines.Add($"Updated SQLite cwd paths: {result.SqliteCwdRowsUpdated}");
}
if (result.UpdatedWorkspaceRoots > 0)
{
lines.Add($"Updated workspace roots: {result.UpdatedWorkspaceRoots}");
}
if (result.SkippedLockedRolloutFiles.Count > 0)
{
string preview = string.Join(", ", result.SkippedLockedRolloutFiles.Take(5));
int extraCount = result.SkippedLockedRolloutFiles.Count - Math.Min(result.SkippedLockedRolloutFiles.Count, 5);
lines.Add($"Skipped locked rollout files: {result.SkippedLockedRolloutFiles.Count}");
lines.Add($"Locked file(s): {preview}{(extraCount > 0 ? $" (+{extraCount} more)" : string.Empty)}");
}
if (result.SkippedUnreadableRolloutFiles.Count > 0)
{
string preview = string.Join(", ", result.SkippedUnreadableRolloutFiles.Take(5));
int extraCount = result.SkippedUnreadableRolloutFiles.Count - Math.Min(result.SkippedUnreadableRolloutFiles.Count, 5);
lines.Add($"Skipped unreadable rollout files: {result.SkippedUnreadableRolloutFiles.Count}");
lines.Add($"Unreadable file(s): {preview}{(extraCount > 0 ? $" (+{extraCount} more)" : string.Empty)}");
}
if (!string.IsNullOrWhiteSpace(result.EncryptedContentWarning))
{
lines.Add(result.EncryptedContentWarning);
}
if (result.AutoPruneResult is not null)
{
lines.Add(
$"Backup cleanup: deleted {result.AutoPruneResult.DeletedCount}, remaining {result.AutoPruneResult.RemainingCount}, freed {FormatBytes(result.AutoPruneResult.FreedBytes)}");
}
if (!string.IsNullOrWhiteSpace(result.AutoPruneWarning))
{
lines.Add($"Backup cleanup warning: {result.AutoPruneWarning}");
}
return string.Join(Environment.NewLine, lines);
}
public static string FormatRestoreResult(RestoreResult result)
{
List<string> lines =
[
$"Restored backup from {result.BackupDir}",
$"Codex home: {result.CodexHome}",
$"Provider at backup time: {result.TargetProvider}",
$"Backed up rollout file count: {result.ChangedSessionFiles}"
];
if (result.CreatedAt is not null)
{
lines.Add($"Backup created at: {result.CreatedAt:O}");
}
return string.Join(Environment.NewLine, lines);
}
public static string FormatBackupPruneResult(BackupPruneResult result)
{
List<string> lines =
[
$"Backup root: {result.BackupRoot}",
$"Deleted backups: {result.DeletedCount}",
$"Remaining backups: {result.RemainingCount}",
$"Freed space: {FormatBytes(result.FreedBytes)}"
];
return string.Join(Environment.NewLine, lines);
}
public static string FormatProviderSources(ProviderOption option)
{
return string.Join(", ", option.Sources.Select(source => source switch
{
ProviderSource.Config => "配置",
ProviderSource.Rollout => "Rollout",
ProviderSource.Sqlite => "SQLite",
ProviderSource.Manual => "手动",
_ => source.ToString()
}));
}
private static string FormatCounts(Dictionary<string, int> counts)
{
return counts.Count == 0
? "(none)"
: string.Join(", ", counts.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}: {pair.Value}"));
}
private static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB"];
double value = bytes;
int unitIndex = 0;
while (value >= 1024 && unitIndex < units.Length - 1)
{
value /= 1024;
unitIndex += 1;
}
return unitIndex == 0 ? $"{bytes} B" : $"{value:0.##} {units[unitIndex]}";
}
}
+28
View File
@@ -0,0 +1,28 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Styling;
using Avalonia.Themes.Fluent;
namespace CodexProviderSync.Mac;
public sealed class App : Application
{
public override void Initialize()
{
RequestedThemeVariant = ThemeVariant.Light;
Styles.Add(new FluentTheme());
}
public override void OnFrameworkInitializationCompleted()
{
MainWindow? mainWindow = null;
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
mainWindow = new MainWindow();
desktop.MainWindow = mainWindow;
}
base.OnFrameworkInitializationCompleted();
mainWindow?.Show();
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\CodexProviderSync.Core\CodexProviderSync.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>CodexProviderSync</AssemblyName>
<Product>Codex Provider Sync</Product>
<Company>Dailin521</Company>
<Version>0.2.9</Version>
<AssemblyVersion>0.2.9.0</AssemblyVersion>
<FileVersion>0.2.9.0</FileVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
</Project>
@@ -0,0 +1,62 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace CodexProviderSync.Mac;
internal sealed class ConfirmationDialog : Window
{
public ConfirmationDialog(string title, string message, string primaryText, string? secondaryText)
{
Title = title;
Width = 460;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
CanResize = false;
Background = Brushes.White;
StackPanel root = new()
{
Margin = new Thickness(20),
Spacing = 18
};
root.Children.Add(new TextBlock
{
Text = message,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
Foreground = Brush.Parse("#18181b")
});
StackPanel buttons = new()
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 10
};
if (!string.IsNullOrWhiteSpace(secondaryText))
{
Button secondary = new()
{
Content = secondaryText,
MinWidth = 88
};
secondary.Click += (_, _) => Close(false);
buttons.Children.Add(secondary);
}
Button primary = new()
{
Content = primaryText,
MinWidth = 88
};
primary.Click += (_, _) => Close(true);
buttons.Children.Add(primary);
root.Children.Add(buttons);
Content = root;
}
}
@@ -0,0 +1,223 @@
using CodexProviderSync.Core;
namespace CodexProviderSync.Mac;
internal static class MacDisplayFormatter
{
public static string FormatStatus(StatusSnapshot status, string language)
{
if (!MacUiText.IsChinese(language))
{
return TextFormatter.FormatStatus(status);
}
List<string> lines =
[
$"Codex Home: {status.CodexHome}",
$"当前 Provider: {status.CurrentProvider.Provider}{(status.CurrentProvider.Implicit ? "(隐式默认)" : string.Empty)}",
$"配置中的 Provider: {string.Join(", ", status.ConfiguredProviders)}",
$"备份: {status.BackupSummary.Count}{FormatBytes(status.BackupSummary.TotalBytes)}",
$"备份目录: {status.BackupRoot}",
string.Empty,
"Rollout 文件:",
$" sessions: {FormatCounts(status.RolloutCounts.Sessions)}",
$" archived_sessions: {FormatCounts(status.RolloutCounts.ArchivedSessions)}",
$" encrypted_content sessions: {FormatCounts(status.EncryptedContentCounts.Sessions)}",
$" encrypted_content archived_sessions: {FormatCounts(status.EncryptedContentCounts.ArchivedSessions)}",
string.Empty,
"SQLite 状态:"
];
List<string> rolloutNotes = [];
if (status.LockedRolloutFiles.Count > 0)
{
rolloutNotes.Add($" 状态扫描时跳过 locked rollout 文件: {status.LockedRolloutFiles.Count}");
}
if (status.UnreadableRolloutFiles.Count > 0)
{
rolloutNotes.Add($" 状态扫描时跳过不可读 rollout 文件: {status.UnreadableRolloutFiles.Count}");
}
if (!string.IsNullOrWhiteSpace(status.EncryptedContentWarning))
{
rolloutNotes.Add($" {status.EncryptedContentWarning}");
}
lines.InsertRange(11, rolloutNotes);
if (status.StateDbLocation is not null)
{
string legacyNote = status.StateDbLocation.Source == "legacy-root" ? " (legacy root)" : string.Empty;
lines.Add($" database: {status.StateDbLocation.Path}{legacyNote}");
}
else
{
lines.Add(" database: not found (checked sqlite/state_5.sqlite, state_5.sqlite)");
}
if (status.SqliteCounts?.Unreadable == true)
{
lines.Add($" {status.SqliteCounts.Error ?? "state_5.sqlite "}");
}
else if (status.SqliteCounts is null)
{
lines.Add(" 未找到 state_5.sqlite");
}
else
{
lines.Add($" sessions: {FormatCounts(status.SqliteCounts.Sessions)}");
lines.Add($" archived_sessions: {FormatCounts(status.SqliteCounts.ArchivedSessions)}");
if (status.SqliteRepairStats?.UserEventRowsNeedingRepair > 0)
{
lines.Add($" 需要修复的 user-event 标记: {status.SqliteRepairStats.UserEventRowsNeedingRepair}");
}
if (status.SqliteRepairStats?.CwdRowsNeedingRepair > 0)
{
lines.Add($" 需要修复的 cwd 路径: {status.SqliteRepairStats.CwdRowsNeedingRepair}");
}
}
if (status.ProjectThreadVisibility.Count > 0)
{
lines.Add(string.Empty);
lines.Add("项目可见性:");
foreach (ProjectThreadVisibility project in status.ProjectThreadVisibility)
{
string rankText = string.IsNullOrWhiteSpace(project.RankPreview) ? "(无)" : project.RankPreview;
lines.Add(
$" {project.Root}: interactive {project.InteractiveThreads}, 首屏 {project.FirstPageThreads}/50, ranks {rankText}, 精确 cwd {project.ExactCwdMatches}/{project.InteractiveThreads}, 原始 cwd 行 {project.VerbatimCwdRows}, providers {FormatCounts(project.ProviderCounts)}");
}
}
return string.Join(Environment.NewLine, lines);
}
public static string FormatSyncResult(SyncResult result, string label, string language)
{
if (!MacUiText.IsChinese(language))
{
return TextFormatter.FormatSyncResult(result, label);
}
List<string> lines =
[
$"{label} provider: {result.TargetProvider}",
$"Codex Home: {result.CodexHome}",
$"备份: {result.BackupDir}",
$"已更新 rollout 文件: {result.ChangedSessionFiles}",
$"已更新 SQLite 行: {result.SqliteRowsUpdated}{(result.SqlitePresent ? string.Empty : "(未找到 state_5.sqlite")}"
];
if (result.SqliteUserEventRowsUpdated > 0)
{
lines.Add($"已更新 SQLite user-event 标记: {result.SqliteUserEventRowsUpdated}");
}
if (result.SqliteCwdRowsUpdated > 0)
{
lines.Add($"已更新 SQLite cwd 路径: {result.SqliteCwdRowsUpdated}");
}
if (result.UpdatedWorkspaceRoots > 0)
{
lines.Add($"已更新 workspace roots: {result.UpdatedWorkspaceRoots}");
}
if (result.SkippedLockedRolloutFiles.Count > 0)
{
string preview = string.Join(", ", result.SkippedLockedRolloutFiles.Take(5));
int extraCount = result.SkippedLockedRolloutFiles.Count - Math.Min(result.SkippedLockedRolloutFiles.Count, 5);
lines.Add($"跳过 locked rollout 文件: {result.SkippedLockedRolloutFiles.Count}");
lines.Add($"Locked 文件: {preview}{(extraCount > 0 ? $" {extraCount} " : string.Empty)}");
}
if (result.SkippedUnreadableRolloutFiles.Count > 0)
{
string preview = string.Join(", ", result.SkippedUnreadableRolloutFiles.Take(5));
int extraCount = result.SkippedUnreadableRolloutFiles.Count - Math.Min(result.SkippedUnreadableRolloutFiles.Count, 5);
lines.Add($"跳过不可读 rollout 文件: {result.SkippedUnreadableRolloutFiles.Count}");
lines.Add($"不可读文件: {preview}{(extraCount > 0 ? $" {extraCount} " : string.Empty)}");
}
if (!string.IsNullOrWhiteSpace(result.EncryptedContentWarning))
{
lines.Add(result.EncryptedContentWarning);
}
if (result.AutoPruneResult is not null)
{
lines.Add(
$"备份清理: 删除 {result.AutoPruneResult.DeletedCount}, 剩余 {result.AutoPruneResult.RemainingCount}, 释放 {FormatBytes(result.AutoPruneResult.FreedBytes)}");
}
if (!string.IsNullOrWhiteSpace(result.AutoPruneWarning))
{
lines.Add($"备份清理警告: {result.AutoPruneWarning}");
}
return string.Join(Environment.NewLine, lines);
}
public static string FormatRestoreResult(RestoreResult result, string language)
{
if (!MacUiText.IsChinese(language))
{
return TextFormatter.FormatRestoreResult(result);
}
List<string> lines =
[
$"已从备份恢复: {result.BackupDir}",
$"Codex Home: {result.CodexHome}",
$"备份时的 Provider: {result.TargetProvider}",
$"备份的 rollout 文件数量: {result.ChangedSessionFiles}"
];
if (result.CreatedAt is not null)
{
lines.Add($"备份创建时间: {result.CreatedAt:O}");
}
return string.Join(Environment.NewLine, lines);
}
public static string FormatBackupPruneResult(BackupPruneResult result, string language)
{
if (!MacUiText.IsChinese(language))
{
return TextFormatter.FormatBackupPruneResult(result);
}
return string.Join(Environment.NewLine, new[]
{
$"备份根目录: {result.BackupRoot}",
$"已删除备份: {result.DeletedCount}",
$"剩余备份: {result.RemainingCount}",
$"释放空间: {FormatBytes(result.FreedBytes)}"
});
}
public static string FormatProviderSources(ProviderOption option, string language)
{
return string.Join(", ", option.Sources.Select(source => source switch
{
ProviderSource.Config => MacUiText.IsChinese(language) ? "配置" : "Config",
ProviderSource.Rollout => "Rollout",
ProviderSource.Sqlite => "SQLite",
ProviderSource.Manual => MacUiText.IsChinese(language) ? "手动" : "Manual",
_ => source.ToString()
}));
}
private static string FormatCounts(Dictionary<string, int> counts)
{
return counts.Count == 0
? "(none)"
: string.Join(", ", counts.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}: {pair.Value}"));
}
private static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB"];
double value = bytes;
int unitIndex = 0;
while (value >= 1024 && unitIndex < units.Length - 1)
{
value /= 1024;
unitIndex += 1;
}
return unitIndex == 0 ? $"{bytes} B" : $"{value:0.##} {units[unitIndex]}";
}
}
@@ -0,0 +1,87 @@
namespace CodexProviderSync.Mac;
internal static class MacUiText
{
public const string English = "en";
public const string Chinese = "zh-Hans";
public static string NormalizeLanguage(string? language)
{
return string.Equals(language, Chinese, StringComparison.Ordinal) ? Chinese : English;
}
public static bool IsChinese(string language)
{
return string.Equals(NormalizeLanguage(language), Chinese, StringComparison.Ordinal);
}
public static string Get(string language, string key)
{
bool zh = IsChinese(language);
return key switch
{
"language" => zh ? "语言" : "Language",
"recent" => zh ? "最近使用" : "Recent",
"browse" => zh ? "浏览" : "Browse",
"refresh" => zh ? "刷新" : "Refresh",
"status" => zh ? "当前状态" : "Status",
"providers" => zh ? "Provider 列表" : "Providers",
"actions" => zh ? "执行" : "Actions",
"executionLog" => zh ? "执行日志" : "Execution Log",
"addProvider" => zh ? "添加 provider id" : "Add provider id",
"add" => zh ? "添加" : "Add",
"removeManual" => zh ? "删除手动项" : "Remove Manual",
"noProvider" => zh ? "未选择 provider" : "No provider selected",
"switchConfig" => zh ? "切换 config.toml 并同步" : "Switch config.toml and sync",
"syncMetadata" => zh ? "仅同步 Metadata" : "Sync Metadata Only",
"switchSync" => zh ? "切换配置并同步" : "Switch Config + Sync",
"restoreConfig" => zh ? "恢复 config.toml" : "Restore config.toml",
"restoreSqlite" => zh ? "恢复 SQLite" : "Restore SQLite",
"restoreRollout" => zh ? "恢复 rollout metadata" : "Restore rollout metadata",
"restoreBackup" => zh ? "恢复备份" : "Restore Backup",
"openBackupFolder" => zh ? "打开备份目录" : "Open Backup Folder",
"cleanBackups" => zh ? "清理旧备份" : "Clean Old Backups",
"ready" => zh ? "就绪" : "Ready",
"targetProvider" => zh ? "目标 Provider" : "Target provider",
"restoreContents" => zh ? "恢复内容" : "Restore contents",
"keepBackups" => zh ? "保留备份数" : "Keep backups",
"writeWarning" => zh ? "执行写操作前,请先关闭 Codex CLI、Codex App、app-server 和相关终端。" : "Close Codex CLI, Codex App, app-server, and related terminals before executing write actions.",
"refreshing" => zh ? "刷新中..." : "Refreshing...",
"executing" => zh ? "执行中..." : "Executing...",
"restoring" => zh ? "恢复中..." : "Restoring...",
"cleaning" => zh ? "清理中..." : "Cleaning backups...",
"loadedSettings" => zh ? "已加载设置" : "Loaded settings",
"languageChanged" => zh ? "语言已切换" : "Language changed",
"enterProvider" => zh ? "请输入要添加的 provider id。" : "Enter a provider id before adding it.",
"selectProvider" => zh ? "请先选择一个 provider。" : "Select a provider first.",
"selectTargetProvider" => zh ? "请先选择目标 provider。" : "Select a target provider first.",
"addedManualProvider" => zh ? "已添加手动 provider" : "Added manual provider",
"removedManualProvider" => zh ? "已删除手动 provider" : "Removed manual provider",
"confirmSyncMode" => zh ? "仅同步 metadata" : "sync metadata only",
"confirmSwitchMode" => zh ? "切换 config.toml 并同步 metadata" : "switch config.toml and sync metadata",
"confirmWriteMessage" => zh ? "将为 provider \"{1}\" 执行:{0}。\n\n请先关闭 Codex CLI、Codex App、app-server 和相关终端。\n\n修改 metadata 前会先创建备份。是否继续?" : "This will {0} for provider \"{1}\".\n\nClose Codex CLI, Codex App, app-server, and related terminals first.\n\nA backup will be created before metadata is changed. Continue?",
"executionFinished" => zh ? "执行完成" : "Execution finished",
"switchedAndSynced" => zh ? "已切换并同步" : "Switched and synced",
"synced" => zh ? "已同步" : "Synced",
"chooseRestoreTarget" => zh ? "请至少选择一种要恢复的内容。" : "Choose at least one restore target.",
"restoreMessage" => zh ? "确认恢复以下备份?\n\n{0}\n\n将覆盖当前的: {1}。\n请先关闭 Codex。是否继续?" : "Restore this backup?\n\n{0}\n\nThis will overwrite: {1}.\nClose Codex first. Continue?",
"restoreFinished" => zh ? "恢复完成" : "Restore finished",
"cleanMessage" => zh ? "只保留最新 {0} 份本工具管理的备份。\n\n已删除的备份无法从本 App 恢复。是否继续?" : "Keep only the newest {0} managed backup(s).\n\nDeleted backup folders cannot be restored from this app. Continue?",
"backupCleanupFinished" => zh ? "备份清理完成" : "Backup cleanup finished",
"refreshed" => zh ? "已刷新" : "Refreshed",
"chooseCodexFolder" => zh ? "选择 .codex 目录" : "Choose .codex folder",
"chooseBackupFolder" => zh ? "选择备份目录" : "Choose backup folder",
"continue" => zh ? "继续" : "Continue",
"cancel" => zh ? "取消" : "Cancel",
"ok" => zh ? "确定" : "OK",
"error" => zh ? "错误" : "Error",
"confirmWriteTitle" => zh ? "确认写操作" : "Confirm Write Action",
"restoreTitle" => zh ? "恢复备份" : "Restore Backup",
"cleanTitle" => zh ? "清理旧备份" : "Clean Old Backups",
"current" => zh ? "当前" : "current",
"manual" => zh ? "手动" : "manual",
"saved" => zh ? "已保存" : "saved",
_ => key
};
}
}
+984
View File
@@ -0,0 +1,984 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using CodexProviderSync.Core;
namespace CodexProviderSync.Mac;
public sealed class MainWindow : Window
{
private readonly CodexSyncService _syncService = new();
private readonly SettingsService _settingsService = new();
private readonly TextBox _codexHomeText = new();
private readonly ComboBox _recentHomes = new();
private readonly TextBlock _languageLabel = new();
private readonly ComboBox _languageCombo = new();
private readonly Button _browseButton = new();
private readonly Button _refreshButton = new();
private readonly TextBlock _busyText = new();
private readonly TextBox _statusText = new();
private readonly ListBox _providerList = new();
private readonly TextBox _manualProviderText = new();
private readonly Button _addProviderButton = new();
private readonly Button _removeProviderButton = new();
private readonly TextBlock _selectedProviderText = new();
private readonly CheckBox _switchConfigCheck = new();
private readonly NumericUpDown _backupRetentionInput = new();
private readonly Button _executeButton = new();
private readonly CheckBox _restoreConfigCheck = new();
private readonly CheckBox _restoreDatabaseCheck = new();
private readonly CheckBox _restoreSessionsCheck = new();
private readonly Button _restoreButton = new();
private readonly Button _openBackupButton = new();
private readonly Button _pruneBackupsButton = new();
private readonly TextBox _logText = new();
private readonly TextBlock _codexHomeLabel = new();
private readonly TextBlock _statusTitle = new();
private readonly TextBlock _providersTitle = new();
private readonly TextBlock _actionsTitle = new();
private readonly TextBlock _logTitle = new();
private readonly TextBlock _targetProviderLabel = new();
private readonly TextBlock _restoreContentsLabel = new();
private readonly TextBlock _keepBackupsLabel = new();
private readonly TextBlock _writeWarningText = new();
private AppSettings _settings = new();
private StatusSnapshot? _currentStatus;
private bool _loadingSettings;
private string _language = MacUiText.English;
public MainWindow()
{
Title = "Codex Provider Sync";
MinWidth = 1120;
MinHeight = 760;
Width = 1240;
Height = 820;
Background = Brush.Parse("#f5f5f7");
Content = BuildLayout();
WireEvents();
}
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
await LoadStateAsync();
}
protected override void OnClosing(WindowClosingEventArgs e)
{
PersistUiState();
base.OnClosing(e);
}
private Control BuildLayout()
{
Grid root = new()
{
RowDefinitions = new RowDefinitions("Auto,*,220"),
Margin = new Thickness(18),
RowSpacing = 14
};
root.Children.Add(BuildCodexHomePanel());
root.Children.Add(BuildMainPanel());
root.Children.Add(BuildLogPanel());
Grid.SetRow(root.Children[1], 1);
Grid.SetRow(root.Children[2], 2);
return root;
}
private Control BuildCodexHomePanel()
{
Grid panel = new()
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,170,Auto,Auto,Auto,132"),
ColumnSpacing = 10
};
ConfigureLabel(_codexHomeLabel, "Codex Home");
ConfigureLabel(_languageLabel, "Language");
_codexHomeText.PlaceholderText = AppConstants.DefaultCodexHome();
_codexHomeText.MinHeight = 34;
_recentHomes.MinHeight = 34;
_recentHomes.PlaceholderText = "Recent";
_languageCombo.MinHeight = 34;
_languageCombo.Items.Add("English");
_languageCombo.Items.Add("中文");
_languageCombo.SelectedIndex = 0;
_browseButton.Content = "Browse";
_browseButton.MinHeight = 34;
_refreshButton.Content = "Refresh";
_refreshButton.MinHeight = 34;
panel.Children.Add(_codexHomeLabel);
panel.Children.Add(_codexHomeText);
panel.Children.Add(_recentHomes);
panel.Children.Add(_browseButton);
panel.Children.Add(_refreshButton);
panel.Children.Add(_languageLabel);
panel.Children.Add(_languageCombo);
Grid.SetColumn(_codexHomeText, 1);
Grid.SetColumn(_recentHomes, 2);
Grid.SetColumn(_browseButton, 3);
Grid.SetColumn(_refreshButton, 4);
Grid.SetColumn(_languageLabel, 5);
Grid.SetColumn(_languageCombo, 6);
return Card(panel);
}
private Control BuildMainPanel()
{
Grid main = new()
{
ColumnDefinitions = new ColumnDefinitions("1.15*,1*,340"),
ColumnSpacing = 14
};
main.Children.Add(BuildStatusPanel());
main.Children.Add(BuildProviderPanel());
main.Children.Add(BuildActionsPanel());
Grid.SetColumn(main.Children[1], 1);
Grid.SetColumn(main.Children[2], 2);
return main;
}
private Control BuildStatusPanel()
{
_statusText.IsReadOnly = true;
_statusText.AcceptsReturn = true;
_statusText.TextWrapping = TextWrapping.NoWrap;
_statusText.FontFamily = FontFamily.Parse("Menlo, Consolas, monospace");
_statusText.FontSize = 12;
_statusText.Background = Brushes.White;
return Section(_statusTitle, _statusText);
}
private Control BuildProviderPanel()
{
Grid panel = new()
{
RowDefinitions = new RowDefinitions("*,Auto,Auto"),
RowSpacing = 10
};
_providerList.SelectionMode = SelectionMode.Single;
_providerList.Background = Brushes.White;
Grid addPanel = new()
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 8
};
_manualProviderText.PlaceholderText = "Add provider id";
_manualProviderText.MinHeight = 34;
_addProviderButton.Content = "Add";
_addProviderButton.MinHeight = 34;
addPanel.Children.Add(_manualProviderText);
addPanel.Children.Add(_addProviderButton);
Grid.SetColumn(_addProviderButton, 1);
_removeProviderButton.Content = "Remove Manual";
_removeProviderButton.HorizontalAlignment = HorizontalAlignment.Left;
panel.Children.Add(_providerList);
panel.Children.Add(addPanel);
panel.Children.Add(_removeProviderButton);
Grid.SetRow(addPanel, 1);
Grid.SetRow(_removeProviderButton, 2);
return Section(_providersTitle, panel);
}
private Control BuildActionsPanel()
{
StackPanel panel = new()
{
Spacing = 12
};
_selectedProviderText.Text = "No provider selected";
_selectedProviderText.FontSize = 18;
_selectedProviderText.FontWeight = FontWeight.SemiBold;
_selectedProviderText.TextWrapping = TextWrapping.Wrap;
_switchConfigCheck.Content = "Switch config.toml and sync";
_backupRetentionInput.Minimum = 1;
_backupRetentionInput.Maximum = 100000;
_backupRetentionInput.Value = AppConstants.DefaultBackupRetentionCount;
_backupRetentionInput.Width = 112;
_executeButton.Content = "Sync Metadata Only";
_executeButton.MinHeight = 40;
_executeButton.HorizontalAlignment = HorizontalAlignment.Stretch;
_restoreConfigCheck.Content = "Restore config.toml";
_restoreDatabaseCheck.Content = "Restore SQLite";
_restoreSessionsCheck.Content = "Restore rollout metadata";
_restoreConfigCheck.IsChecked = false;
_restoreDatabaseCheck.IsChecked = true;
_restoreSessionsCheck.IsChecked = true;
_restoreButton.Content = "Restore Backup";
_restoreButton.MinHeight = 38;
_openBackupButton.Content = "Open Backup Folder";
_openBackupButton.MinHeight = 38;
_pruneBackupsButton.Content = "Clean Old Backups";
_pruneBackupsButton.MinHeight = 38;
_busyText.Text = "Ready";
_busyText.Foreground = Brush.Parse("#166534");
ConfigureMutedLabel(_targetProviderLabel, "Target provider");
ConfigureMutedLabel(_restoreContentsLabel, "Restore contents");
panel.Children.Add(_targetProviderLabel);
panel.Children.Add(_selectedProviderText);
panel.Children.Add(_switchConfigCheck);
panel.Children.Add(BuildRetentionPanel());
panel.Children.Add(WarningBlock(_writeWarningText));
panel.Children.Add(_executeButton);
panel.Children.Add(Separator());
panel.Children.Add(_restoreContentsLabel);
panel.Children.Add(_restoreConfigCheck);
panel.Children.Add(_restoreDatabaseCheck);
panel.Children.Add(_restoreSessionsCheck);
panel.Children.Add(_restoreButton);
panel.Children.Add(_openBackupButton);
panel.Children.Add(_pruneBackupsButton);
panel.Children.Add(_busyText);
return Section(_actionsTitle, panel);
}
private Control BuildRetentionPanel()
{
StackPanel panel = new()
{
Orientation = Orientation.Horizontal,
Spacing = 8,
VerticalAlignment = VerticalAlignment.Center
};
ConfigureMutedLabel(_keepBackupsLabel, "Keep backups");
panel.Children.Add(_keepBackupsLabel);
panel.Children.Add(_backupRetentionInput);
return panel;
}
private Control BuildLogPanel()
{
_logText.IsReadOnly = true;
_logText.AcceptsReturn = true;
_logText.FontFamily = FontFamily.Parse("Menlo, Consolas, monospace");
_logText.FontSize = 12;
_logText.Background = Brushes.White;
return Section(_logTitle, _logText);
}
private void WireEvents()
{
_browseButton.Click += async (_, _) => await BrowseCodexHomeAsync();
_refreshButton.Click += async (_, _) => await RefreshStatusAsync();
_languageCombo.SelectionChanged += async (_, _) => await ChangeLanguageAsync();
_recentHomes.SelectionChanged += (_, _) =>
{
if (_recentHomes.SelectedItem is string home)
{
_codexHomeText.Text = home;
}
};
_addProviderButton.Click += async (_, _) => await AddManualProviderAsync();
_removeProviderButton.Click += async (_, _) => await RemoveManualProviderAsync();
_providerList.SelectionChanged += (_, _) => UpdateSelectionLabel();
_switchConfigCheck.PropertyChanged += (_, args) =>
{
if (args.Property.Name == nameof(CheckBox.IsChecked))
{
UpdateExecuteButtonText();
}
};
_backupRetentionInput.ValueChanged += async (_, _) => await PersistBackupRetentionAsync();
_executeButton.Click += async (_, _) => await ExecuteSyncOrSwitchAsync();
_restoreButton.Click += async (_, _) => await RestoreBackupAsync();
_openBackupButton.Click += async (_, _) => await OpenBackupFolderAsync();
_pruneBackupsButton.Click += async (_, _) => await PruneBackupsAsync();
_codexHomeText.LostFocus += async (_, _) => await PersistHomeSelectionAsync();
_manualProviderText.KeyDown += async (_, args) =>
{
if (args.Key == Key.Enter)
{
args.Handled = true;
await AddManualProviderAsync();
}
};
}
private async Task LoadStateAsync()
{
_loadingSettings = true;
_settings = await _settingsService.LoadAsync();
_language = MacUiText.NormalizeLanguage(_settings.UiLanguage);
_languageCombo.SelectedIndex = MacUiText.IsChinese(_language) ? 1 : 0;
ApplyLanguage();
ApplyWindowBounds(_settings.WindowBounds);
ReloadRecentHomes();
_codexHomeText.Text = _settings.LastCodexHome ?? AppConstants.DefaultCodexHome();
_backupRetentionInput.Value = Math.Max(1, _settings.BackupRetentionCount);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("loadedSettings")}: {_settingsService.SettingsPath}");
_loadingSettings = false;
await RefreshStatusAsync();
}
private async Task RefreshStatusAsync()
{
string codexHome = CurrentCodexHome();
await RunBusyAsync(T("refreshing"), () => RefreshStatusCoreAsync(codexHome));
}
private async Task ChangeLanguageAsync()
{
if (_loadingSettings)
{
return;
}
_language = _languageCombo.SelectedIndex == 1 ? MacUiText.Chinese : MacUiText.English;
_settings = _settingsService.UpdateUiLanguage(_settings, _language);
await _settingsService.SaveAsync(_settings);
ApplyLanguage();
if (_currentStatus is not null)
{
_statusText.Text = MacDisplayFormatter.FormatStatus(_currentStatus, _language);
}
ReloadProviderList();
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("languageChanged")}: {(_language == MacUiText.Chinese ? "" : "English")}");
}
private async Task BrowseCodexHomeAsync()
{
IStorageFolder? start = null;
string current = CurrentCodexHome();
if (Directory.Exists(current))
{
start = await StorageProvider.TryGetFolderFromPathAsync(current);
}
IReadOnlyList<IStorageFolder> folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = T("chooseCodexFolder"),
AllowMultiple = false,
SuggestedStartLocation = start
});
if (folders.Count == 0 || folders[0].Path.LocalPath is not { Length: > 0 } selected)
{
return;
}
_codexHomeText.Text = selected;
await PersistHomeSelectionAsync();
await RefreshStatusAsync();
}
private async Task PersistHomeSelectionAsync()
{
string codexHome = CurrentCodexHome();
if (string.IsNullOrWhiteSpace(codexHome))
{
return;
}
_settings = _settingsService.RecordCodexHome(_settings, codexHome);
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), _settings.LastBackupDirectory, CaptureWindowBounds(), CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
ReloadRecentHomes();
}
private async Task PersistBackupRetentionAsync()
{
if (_loadingSettings)
{
return;
}
_settings = _settingsService.UpdateState(
_settings,
SelectedProvider(),
_settings.LastBackupDirectory,
CaptureWindowBounds(),
CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Backup retention set to {CurrentBackupRetentionCount()}");
}
private async Task AddManualProviderAsync()
{
string provider = (_manualProviderText.Text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(provider))
{
await ShowInfoAsync(T("enterProvider"));
return;
}
_settings = _settingsService.AddManualProvider(_settings, provider);
await _settingsService.SaveAsync(_settings);
_manualProviderText.Clear();
ReloadProviderList();
SelectProvider(provider);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("addedManualProvider")}: {provider}");
}
private async Task RemoveManualProviderAsync()
{
string? provider = SelectedProvider();
if (string.IsNullOrWhiteSpace(provider))
{
await ShowInfoAsync(T("selectProvider"));
return;
}
_settings = _settingsService.RemoveManualProvider(_settings, provider);
await _settingsService.SaveAsync(_settings);
ReloadProviderList();
SelectProvider(_currentStatus?.CurrentProvider.Provider);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("removedManualProvider")}: {provider}");
}
private async Task ExecuteSyncOrSwitchAsync()
{
string? provider = SelectedProvider();
if (string.IsNullOrWhiteSpace(provider))
{
await ShowInfoAsync(T("selectTargetProvider"));
return;
}
string mode = IsSwitchMode()
? T("confirmSwitchMode")
: T("confirmSyncMode");
if (!await ConfirmAsync(
T("confirmWriteTitle"),
string.Format(T("confirmWriteMessage"), mode, provider)))
{
return;
}
await RunBusyAsync(T("executing"), async () =>
{
string codexHome = CurrentCodexHome();
int backupRetentionCount = CurrentBackupRetentionCount();
SyncResult result = IsSwitchMode()
? await Task.Run(async () => await _syncService.RunSwitchAsync(codexHome, provider, backupRetentionCount))
: await Task.Run(async () => await _syncService.RunSyncAsync(codexHome, provider: provider, keepCount: backupRetentionCount));
_settings = _settingsService.UpdateState(_settings, provider, result.BackupDir, CaptureWindowBounds(), backupRetentionCount);
await _settingsService.SaveAsync(_settings);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("executionFinished")}");
AppendLog(MacDisplayFormatter.FormatSyncResult(result, IsSwitchMode() ? T("switchedAndSynced") : T("synced"), _language));
AppendLog(string.Empty);
await RefreshStatusCoreAsync(codexHome);
SelectProvider(provider);
});
}
private async Task RestoreBackupAsync()
{
string backupRoot = _currentStatus?.BackupRoot ?? AppConstants.DefaultBackupRoot(CurrentCodexHome());
string initialBackupDir = Directory.Exists(_settings.LastBackupDirectory)
? _settings.LastBackupDirectory!
: backupRoot;
IStorageFolder? start = Directory.Exists(initialBackupDir)
? await StorageProvider.TryGetFolderFromPathAsync(initialBackupDir)
: null;
IReadOnlyList<IStorageFolder> folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = T("chooseBackupFolder"),
AllowMultiple = false,
SuggestedStartLocation = start
});
if (folders.Count == 0 || folders[0].Path.LocalPath is not { Length: > 0 } backupDir)
{
return;
}
bool restoreConfig = _restoreConfigCheck.IsChecked == true;
bool restoreDatabase = _restoreDatabaseCheck.IsChecked == true;
bool restoreSessions = _restoreSessionsCheck.IsChecked == true;
if (!restoreConfig && !restoreDatabase && !restoreSessions)
{
await ShowInfoAsync(T("chooseRestoreTarget"));
return;
}
string restoreTargets = string.Join(", ", new[]
{
restoreConfig ? "config.toml" : null,
restoreDatabase ? "SQLite" : null,
restoreSessions ? "rollout metadata" : null
}.Where(static value => value is not null));
if (!await ConfirmAsync(
T("restoreTitle"),
string.Format(T("restoreMessage"), backupDir, restoreTargets)))
{
return;
}
await RunBusyAsync(T("restoring"), async () =>
{
string codexHome = CurrentCodexHome();
RestoreResult result = await Task.Run(async () => await _syncService.RunRestoreAsync(
codexHome,
backupDir,
new RestoreBackupOptions
{
RestoreConfig = restoreConfig,
RestoreDatabase = restoreDatabase,
RestoreSessions = restoreSessions
}));
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), backupDir, CaptureWindowBounds(), CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("restoreFinished")}");
AppendLog(MacDisplayFormatter.FormatRestoreResult(result, _language));
AppendLog(string.Empty);
await RefreshStatusCoreAsync(codexHome);
});
}
private async Task OpenBackupFolderAsync()
{
string path = _currentStatus?.BackupRoot ?? AppConstants.DefaultBackupRoot(CurrentCodexHome());
Directory.CreateDirectory(path);
try
{
Process.Start(new ProcessStartInfo
{
FileName = OperatingSystem.IsMacOS() ? "open" : path,
Arguments = OperatingSystem.IsMacOS() ? QuoteShellArgument(path) : string.Empty,
UseShellExecute = !OperatingSystem.IsMacOS()
});
}
catch (Exception error)
{
await ShowErrorAsync(error);
}
}
private async Task PruneBackupsAsync()
{
if (!await ConfirmAsync(
T("cleanTitle"),
string.Format(T("cleanMessage"), CurrentBackupRetentionCount())))
{
return;
}
await RunBusyAsync(T("cleaning"), async () =>
{
string codexHome = CurrentCodexHome();
BackupPruneResult result = await Task.Run(async () => await _syncService.RunPruneBackupsAsync(codexHome, CurrentBackupRetentionCount()));
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("backupCleanupFinished")}");
AppendLog(MacDisplayFormatter.FormatBackupPruneResult(result, _language));
AppendLog(string.Empty);
await RefreshStatusCoreAsync(codexHome);
});
}
private async Task RefreshStatusCoreAsync(string codexHome)
{
_currentStatus = await Task.Run(async () => await _syncService.GetStatusAsync(codexHome));
_settings = _settingsService.RecordCodexHome(_settings, _currentStatus.CodexHome);
_settings = _settingsService.MergeDetectedProviders(_settings, _syncService.ExtractDetectedProviderIds(_currentStatus));
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), _settings.LastBackupDirectory, CaptureWindowBounds(), CurrentBackupRetentionCount());
await _settingsService.SaveAsync(_settings);
_statusText.Text = MacDisplayFormatter.FormatStatus(_currentStatus, _language);
ReloadRecentHomes();
ReloadProviderList();
_codexHomeText.Text = _currentStatus.CodexHome;
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("refreshed")}: {_currentStatus.CodexHome}");
}
private void ReloadRecentHomes()
{
string? selected = _codexHomeText.Text;
_recentHomes.Items.Clear();
foreach (string home in _settings.RecentCodexHomes)
{
_recentHomes.Items.Add(home);
}
_codexHomeText.Text = selected;
}
private void ReloadProviderList()
{
_providerList.Items.Clear();
if (_currentStatus is not null)
{
foreach (ProviderOption option in _syncService.BuildProviderOptions(_currentStatus, _settings))
{
ProviderListItem item = new()
{
Option = option,
SourcesText = MacDisplayFormatter.FormatProviderSources(option, _language),
DetailText = ProviderDetailText(option)
};
_providerList.Items.Add(BuildProviderRow(item));
}
}
SelectProvider(_settings.LastSelectedProvider ?? _currentStatus?.CurrentProvider.Provider);
UpdateSelectionLabel();
}
private ListBoxItem BuildProviderRow(ProviderListItem provider)
{
Grid row = new()
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
RowDefinitions = new RowDefinitions("Auto,Auto"),
Margin = new Thickness(8, 7)
};
TextBlock title = new()
{
Text = provider.Option.Id,
FontWeight = provider.Option.IsCurrentProvider ? FontWeight.SemiBold : FontWeight.Normal,
TextTrimming = TextTrimming.CharacterEllipsis
};
TextBlock sources = new()
{
Text = provider.SourcesText,
Foreground = Brush.Parse("#52525b"),
FontSize = 12,
TextWrapping = TextWrapping.Wrap
};
TextBlock detail = new()
{
Text = provider.DetailText,
Foreground = Brush.Parse("#71717a"),
FontSize = 12
};
row.Children.Add(title);
row.Children.Add(detail);
row.Children.Add(sources);
Grid.SetColumn(detail, 1);
Grid.SetRow(sources, 1);
Grid.SetColumnSpan(sources, 2);
return new ListBoxItem
{
Tag = provider.Option.Id,
Content = row
};
}
private string ProviderDetailText(ProviderOption option)
{
List<string> details = [];
if (option.IsCurrentProvider)
{
details.Add(T("current"));
}
if (option.IsManual)
{
details.Add(T("manual"));
}
if (option.IsSaved)
{
details.Add(T("saved"));
}
return details.Count == 0 ? string.Empty : string.Join(" · ", details);
}
private void SelectProvider(string? provider)
{
if (string.IsNullOrWhiteSpace(provider))
{
return;
}
foreach (object? item in _providerList.Items)
{
if (item is ListBoxItem row && string.Equals(row.Tag as string, provider, StringComparison.Ordinal))
{
_providerList.SelectedItem = row;
break;
}
}
}
private void UpdateSelectionLabel()
{
string? provider = SelectedProvider();
_selectedProviderText.Text = string.IsNullOrWhiteSpace(provider) ? T("noProvider") : provider;
}
private void UpdateExecuteButtonText()
{
_executeButton.Content = IsSwitchMode()
? T("switchSync")
: T("syncMetadata");
}
private string? SelectedProvider()
{
return _providerList.SelectedItem is ListBoxItem row ? row.Tag as string : null;
}
private string CurrentCodexHome()
{
string text = (_codexHomeText.Text ?? string.Empty).Trim();
return string.IsNullOrWhiteSpace(text) ? AppConstants.DefaultCodexHome() : text;
}
private int CurrentBackupRetentionCount()
{
decimal value = _backupRetentionInput.Value ?? AppConstants.DefaultBackupRetentionCount;
return Decimal.ToInt32(Math.Max(1, value));
}
private bool IsSwitchMode()
{
return _switchConfigCheck.IsChecked == true;
}
private void PersistUiState()
{
try
{
_settings = _settingsService.RecordCodexHome(_settings, CurrentCodexHome());
_settings = _settingsService.UpdateUiLanguage(_settings, _language);
_settings = _settingsService.UpdateState(_settings, SelectedProvider(), _settings.LastBackupDirectory, CaptureWindowBounds(), CurrentBackupRetentionCount());
_settingsService.Save(_settings);
}
catch
{
// Ignore shutdown persistence failures.
}
}
private WindowBoundsState CaptureWindowBounds()
{
return new WindowBoundsState
{
X = Position.X,
Y = Position.Y,
Width = Decimal.ToInt32((decimal)Math.Max(Width, MinWidth)),
Height = Decimal.ToInt32((decimal)Math.Max(Height, MinHeight)),
Maximized = WindowState == WindowState.Maximized
};
}
private void ApplyWindowBounds(WindowBoundsState? bounds)
{
if (bounds is null || bounds.Width < 800 || bounds.Height < 600)
{
return;
}
Position = new PixelPoint(bounds.X, bounds.Y);
Width = bounds.Width;
Height = bounds.Height;
if (bounds.Maximized)
{
WindowState = WindowState.Maximized;
}
}
private async Task RunBusyAsync(string stateText, Func<Task> action)
{
SetBusy(true, stateText);
try
{
await action();
}
catch (Exception error)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {T("error")}: {error}");
await ShowErrorAsync(error);
}
finally
{
SetBusy(false, T("ready"));
}
}
private void SetBusy(bool busy, string stateText)
{
Cursor = busy ? new Cursor(StandardCursorType.Wait) : Cursor.Default;
_busyText.Text = stateText;
_busyText.Foreground = busy ? Brush.Parse("#b45309") : Brush.Parse("#166534");
_browseButton.IsEnabled = !busy;
_refreshButton.IsEnabled = !busy;
_addProviderButton.IsEnabled = !busy;
_removeProviderButton.IsEnabled = !busy;
_switchConfigCheck.IsEnabled = !busy;
_backupRetentionInput.IsEnabled = !busy;
_restoreConfigCheck.IsEnabled = !busy;
_restoreDatabaseCheck.IsEnabled = !busy;
_restoreSessionsCheck.IsEnabled = !busy;
_executeButton.IsEnabled = !busy;
_restoreButton.IsEnabled = !busy;
_openBackupButton.IsEnabled = !busy;
_pruneBackupsButton.IsEnabled = !busy;
_providerList.IsEnabled = !busy;
_manualProviderText.IsEnabled = !busy;
_codexHomeText.IsEnabled = !busy;
_recentHomes.IsEnabled = !busy;
_languageCombo.IsEnabled = !busy;
}
private void AppendLog(string message)
{
_logText.Text = string.IsNullOrEmpty(_logText.Text)
? message
: $"{_logText.Text}{Environment.NewLine}{message}";
_logText.CaretIndex = _logText.Text?.Length ?? 0;
}
private async Task<bool> ConfirmAsync(string title, string message)
{
ConfirmationDialog dialog = new(title, message, T("continue"), T("cancel"));
return await dialog.ShowDialog<bool>(this);
}
private async Task ShowInfoAsync(string message)
{
ConfirmationDialog dialog = new("Codex Provider Sync", message, T("ok"), null);
await dialog.ShowDialog<bool>(this);
}
private async Task ShowErrorAsync(Exception error)
{
ConfirmationDialog dialog = new(T("error"), error.Message, T("ok"), null);
await dialog.ShowDialog<bool>(this);
}
private void ApplyLanguage()
{
_languageLabel.Text = T("language");
_recentHomes.PlaceholderText = T("recent");
_browseButton.Content = T("browse");
_refreshButton.Content = T("refresh");
_statusTitle.Text = T("status");
_providersTitle.Text = T("providers");
_actionsTitle.Text = T("actions");
_logTitle.Text = T("executionLog");
_manualProviderText.PlaceholderText = T("addProvider");
_addProviderButton.Content = T("add");
_removeProviderButton.Content = T("removeManual");
_switchConfigCheck.Content = T("switchConfig");
_restoreConfigCheck.Content = T("restoreConfig");
_restoreDatabaseCheck.Content = T("restoreSqlite");
_restoreSessionsCheck.Content = T("restoreRollout");
_restoreButton.Content = T("restoreBackup");
_openBackupButton.Content = T("openBackupFolder");
_pruneBackupsButton.Content = T("cleanBackups");
_targetProviderLabel.Text = T("targetProvider");
_restoreContentsLabel.Text = T("restoreContents");
_keepBackupsLabel.Text = T("keepBackups");
_writeWarningText.Text = T("writeWarning");
_busyText.Text = T("ready");
UpdateExecuteButtonText();
UpdateSelectionLabel();
}
private string T(string key)
{
return MacUiText.Get(_language, key);
}
private static Border Card(Control content)
{
return new Border
{
Background = Brushes.White,
BorderBrush = Brush.Parse("#d4d4d8"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12),
Child = content
};
}
private static Control Section(TextBlock title, Control content)
{
Grid panel = new()
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 8
};
ConfigureSectionTitle(title);
panel.Children.Add(title);
panel.Children.Add(content);
Grid.SetRow(content, 1);
return Card(panel);
}
private static void ConfigureLabel(TextBlock label, string text)
{
label.Text = text;
label.VerticalAlignment = VerticalAlignment.Center;
label.FontWeight = FontWeight.SemiBold;
label.Foreground = Brush.Parse("#18181b");
}
private static void ConfigureSectionTitle(TextBlock title)
{
title.FontSize = 13;
title.FontWeight = FontWeight.SemiBold;
title.Foreground = Brush.Parse("#18181b");
}
private static void ConfigureMutedLabel(TextBlock label, string text)
{
label.Text = text;
label.Foreground = Brush.Parse("#52525b");
label.FontSize = 12;
}
private static Border WarningBlock(TextBlock text)
{
text.Foreground = Brush.Parse("#9a3412");
text.TextWrapping = TextWrapping.Wrap;
return new Border
{
Background = Brush.Parse("#fff7ed"),
BorderBrush = Brush.Parse("#fed7aa"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10),
Child = text
};
}
private static Separator Separator()
{
return new Separator
{
Margin = new Thickness(0, 2)
};
}
private static string QuoteShellArgument(string value)
{
return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
}
}
+20
View File
@@ -0,0 +1,20 @@
using Avalonia;
namespace CodexProviderSync.Mac;
internal static class Program
{
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
private static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
}
}
@@ -0,0 +1,10 @@
using CodexProviderSync.Core;
namespace CodexProviderSync.Mac;
internal sealed class ProviderListItem
{
public required ProviderOption Option { get; init; }
public required string SourcesText { get; init; }
public required string DetailText { get; init; }
}
+243
View File
@@ -0,0 +1,243 @@
<div align="center">
# codex-provider-sync
### Keep Codex history visible after switching between providers
[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml)
[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/Dailin521/codex-provider-sync)
[![Node](https://img.shields.io/badge/node-16%2B-brightgreen.svg)](https://nodejs.org/)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
[![Community](https://img.shields.io/badge/community-LINUX%20DO-2ea043.svg)](https://linux.do/)
English | [中文](../README.md)
</div>
## What It Solves
Codex session visibility can break after you switch `model_provider`.
Typical symptom:
- old sessions are visible under one provider
- then disappear after switching to another provider
- `codex resume` and Codex App may disagree because session metadata is stored in both rollout files and SQLite
`codex-provider-sync` fixes that by updating both:
- `~/.codex/sessions` and `~/.codex/archived_sessions`
- the Codex state database, usually `~/.codex/sqlite/state_5.sqlite`
Older Codex layouts may still use `~/.codex/state_5.sqlite`; the tool detects the active location and reports it in `codex-provider status`.
## GUI For Windows
If you want a normal Windows app instead of Node/npm, download `CodexProviderSync.exe` from Releases.
The GUI app:
- scans the current `.codex` home
- shows provider distribution from rollout files and SQLite
- lets you choose a target provider from detected and saved providers
- can optionally update root `model_provider` in `config.toml`
- keeps the latest 5 managed backups by default, with a configurable retention count
- can manually clean old managed backups from the app
- can restore from backup without using a terminal
For GUI-specific usage notes, see [README_GUI_ZH.md](README_GUI_ZH.md).
## Install
```bash
npm install -g git+https://github.com/Dailin521/codex-provider-sync.git
```
Requirements:
- Node.js `16+`
- Node.js 24+ uses the built-in `node:sqlite` module; older Node.js releases use the optional `better-sqlite3` dependency.
- standard `~/.codex` layout
- Windows is the primary tested target for now
For end users, the GUI EXE is the recommended path. The npm CLI remains available for power users and automation.
## Quick Start
GUI:
- download `CodexProviderSync.exe` from Releases
- open it and click `Refresh`
- choose the target provider
- click `Execute`
If you already switched auth/provider using your usual method:
```bash
codex-provider sync
```
If you want to change the root `model_provider` and sync history in one step:
```bash
codex-provider switch openai
codex-provider switch apigather
```
If you want a different automatic backup retention count for one run:
```bash
codex-provider sync --keep 5
codex-provider switch apigather --keep 10
```
Check current state first:
```bash
codex-provider status
```
Install a Windows double-click launcher (placed on your Desktop by default):
```bash
codex-provider install-windows-launcher
```
Rollback from a backup:
```bash
codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\<timestamp>
```
Clean old managed backups manually:
```bash
codex-provider prune-backups --keep 5
```
## AI Quick Run
If you want an AI assistant to handle this in one shot, copy this prompt:
```text
Help me fix Codex session visibility with codex-provider-sync.
Steps:
1. Run `codex-provider status`.
2. If my current provider is already correct, run `codex-provider sync`.
3. If I explicitly want to switch provider, run `codex-provider switch <provider-id>` instead.
4. If `state_5.sqlite` is currently in use, tell me to close Codex / Codex App / app-server and retry.
5. If sync skips locked rollout files, tell me which files were skipped and remind me to rerun `codex-provider sync` later.
6. Summarize the final provider counts in rollout files and SQLite.
```
If the user prefers the GUI, the AI can instead guide these steps:
1. Open `CodexProviderSync.exe`
2. Confirm the `.codex` path
3. Click `Refresh`
4. Pick the target provider from the list
5. Enable the config checkbox only if root `model_provider` should also change
6. Click `Execute`
7. Read the log panel for backup path, updated rollout files, SQLite rows, and skipped locked files
Quick mapping:
- inspect only: `codex-provider status`
- fix visibility under current provider: `codex-provider sync`
- switch provider and sync: `codex-provider switch openai`
- install a desktop double-click launcher: `codex-provider install-windows-launcher`
- roll back a mistake: `codex-provider restore <backup-dir>`
## Commands
- `codex-provider status`
- shows current provider, the detected SQLite database path, and provider distribution in rollout files and SQLite
- `codex-provider sync`
- syncs history to the current provider
- `--provider <id>` overrides the target provider
- if root `model_provider` is missing, it falls back to `openai`
- `codex-provider switch <provider-id>`
- updates root `model_provider` in `config.toml`
- immediately runs a sync
- `--keep <n>` overrides how many managed backups are retained after the run
- `codex-provider prune-backups`
- manually removes older managed backups and keeps the newest `n`
- `codex-provider restore <backup-dir>`
- restores a previous backup
- use `--no-config`, `--no-db`, or `--no-sessions` to skip a restore target
- `codex-provider install-windows-launcher`
- creates two files on the Desktop by default
- `Codex Provider Sync.vbs`: hidden double-click launcher with a result popup
- `Codex Provider Sync.cmd`: visible console version for troubleshooting
- use `--dir <path>` to choose another install directory
- use `--codex-home <path>` to bake a fixed `CODEX_HOME` into the launcher
```bash
codex-provider status
codex-provider sync
codex-provider sync --keep 5
codex-provider sync --provider openai
codex-provider switch openai
codex-provider switch apigather
codex-provider prune-backups --keep 5
codex-provider install-windows-launcher
codex-provider install-windows-launcher --dir D:\Tools
codex-provider install-windows-launcher --codex-home C:\Users\you\.codex
codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\20260319T042708906Z
codex-provider status --codex-home C:\Users\you\.codex
codex-provider sync --codex-home C:\Users\you\.codex
codex-provider switch apigather --codex-home C:\Users\you\.codex
codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\20260319T042708906Z
```
## Safety
Before each sync, the tool creates a backup under:
```text
~/.codex/backups_state/provider-sync/<timestamp>
```
It also uses:
```text
~/.codex/tmp/provider-sync.lock
```
- It does not replace official `codex`.
- It does not manage `auth.json` or third-party login tools.
- It does not rewrite message history, titles, cwd, or timestamps.
- It keeps the newest 5 managed backups by default; GUI retention settings or CLI `--keep <n>` can override that.
- Manual cleanup and auto-prune only touch backups created by this tool inside `backups_state/provider-sync`.
- `Codex Provider Sync.vbs` assumes the `codex-provider` command is already available.
- If `state_5.sqlite` is in use, close Codex / Codex App / app-server and retry.
- If `state_5.sqlite` is malformed, the tool reports it as malformed/unreadable and blocks sync; back up, repair, or remove the damaged database before retrying.
- If a live session keeps one rollout file open, `sync` skips that file and reports it. Rerun later.
- If history contains `encrypted_content`, switching across providers/accounts may restore visibility only; continuing or compacting those sessions can still fail with `invalid_encrypted_content` because this tool cannot re-encrypt Codex history.
## EXE double-click troubleshooting
1. Fully extract the release archive before running `CodexProviderSync.exe`.
2. If no window appears, open PowerShell in the EXE directory and run `./CodexProviderSync.exe`.
3. Check Windows SmartScreen, Defender, or third-party antivirus blocks.
4. Check `%AppData%\codex-provider-sync\startup-error.log`; startup exceptions are written there.
## For AI Agents
For a fuller machine-oriented version, see [AGENTS.md](../AGENTS.md).
## Development
```bash
git clone https://github.com/Dailin521/codex-provider-sync.git
cd codex-provider-sync
npm test
dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj
pwsh ./scripts/publish-gui.ps1
node ./src/cli.js status --codex-home C:\path\to\.codex
```
## License
MIT
+63
View File
@@ -0,0 +1,63 @@
# Codex Provider Sync GUI
## 适用场景
这是 Windows 用户可用的图形界面版本。
如果你不想装 Node、不想打开 PowerShell,也不想记命令,直接下载发布页里的 `CodexProviderSync.exe` 双击运行即可。
macOS 桌面版说明见 [README_MAC_GUI_ZH.md](README_MAC_GUI_ZH.md)。
## 它能做什么
- 检测当前 `.codex` 下的 root provider
- 统计 rollout files 和 SQLite 里的 provider 分布
- 自动汇总当前可见的全部 provider
- 支持手动补充 provider,并持久化保存
- 选择目标 provider 后一键执行同步
- 可选同时改写 `config.toml` 的 root `model_provider`
- 默认自动保留最近 5 份由本工具生成的备份,并支持自定义保留数
- 支持手动清理旧备份
- 支持从 backup 目录恢复
- 恢复时可分别选择 config、SQLite、rollout metadata
- 如果 EXE 双击无反应,查看 `%AppData%\codex-provider-sync\startup-error.log`,或在 PowerShell 中运行 `./CodexProviderSync.exe` 获取错误
-`encrypted_content` 的历史会话跨 provider/account 后可能只能恢复可见性,继续对话或 compact 仍可能报 `invalid_encrypted_content`
## 能力边界
- GUI 只同步历史会话可见性相关 metadata,不会处理登录、认证或第三方 provider 切换
- GUI 不会修改消息历史、会话标题、对话内容、认证信息或 `auth.json`
- GUI 不会修改会话 `updated_at`,也不会通过改变历史排序来修复 Desktop 显示问题
-`encrypted_content` 的旧会话不能由本工具重新加密到另一个 provider / account
- 如果 CLI 能看到历史会话但 Desktop 项目侧仍不显示,请优先复制并反馈 Refresh 后的完整状态文本
## Codex Desktop 最近 50 条限制
Codex Desktop 当前首屏只拉取最近 `50` 条会话。如果某个项目的旧会话排在全局最近 50 条之后,CLI `/resume` 可能能看到,但 Desktop 项目侧仍显示“暂无对话”。
GUI Refresh 会显示项目可见性诊断,例如 `first page 0/50``ranks 64-77`。这表示会话存在,但没有进入 Desktop 首屏最近 50 条。本工具不会修改 `updated_at` 或历史排序来绕过这个限制。
## 使用方式
1. 打开 `CodexProviderSync.exe`
2. 确认顶部 `Codex Home` 路径
3. 点击 `Refresh`
4. 在中间列表里选择目标 Provider
5. 如果你希望同时改写 `config.toml` 根级 provider,勾选右侧复选框
6. 根据需要调整“自动保留最近 N 份备份”
7. 点击 `Execute`
8. 如需回滚,点击 `Restore Backup`
9. 如需立刻清理旧备份,点击 `清理旧备份`
## 持久化位置
- GUI 设置:`%AppData%\codex-provider-sync\settings.json`
- 备份目录:`%USERPROFILE%\.codex\backups_state\provider-sync\`
## 注意事项
- 如果 `state_5.sqlite` 被占用,请先关闭 Codex / Codex App / app-server 再重试
- 如果某个 rollout 文件仍被活跃会话占用,程序会跳过它并在日志区列出来
- 自动清理和手动清理都只会处理由本工具创建的备份目录
- 手动清理旧备份前会弹确认框
- GUI 不会处理登录、认证或第三方 provider 切换,只负责同步可见性相关元数据
+72
View File
@@ -0,0 +1,72 @@
# macOS GUI 使用说明
`CodexProviderSync.app` 是 macOS 桌面版 GUI,使用 Avalonia 构建,复用 `desktop/CodexProviderSync.Core` 的状态、同步、切换、恢复和清理逻辑。
## 构建
需要 .NET 10 SDK。
```bash
./scripts/publish-gui-macos.sh
```
构建 Intel (x86_64) 版本:
```bash
./scripts/publish-gui-macos.sh --runtime osx-x64 --output artifacts/osx-x64
```
默认产物:
```text
artifacts/osx-arm64/CodexProviderSync.app
```
如需指定 SDK
```bash
DOTNET=/path/to/dotnet ./scripts/publish-gui-macos.sh
```
## 功能
- 选择或输入 Codex Home,默认 `~/.codex`
- `Refresh` 查看当前 provider、rollout provider counts、SQLite provider counts、备份统计、project visibility 和 `encrypted_content` 风险提示
- Provider 列表展示 `config``rollout``SQLite``manual` 来源
- 手动添加或删除 provider
- `Sync Metadata Only`
- `Switch config.toml and sync`
- `Restore Backup`,可选择恢复 `config.toml`、SQLite、rollout metadata
- `Clean Old Backups`
- `Open Backup Folder`
- 执行日志和错误提示
## 安全边界
- App 启动和 `Refresh` 不会修改 Codex Home 下的 session、SQLite 或 `config.toml` 元数据
- 写操作前会弹出确认
- `sync``switch` 由 Core 先创建 backup,再修改 metadata
- `encrypted_content` 只提示风险,不承诺修复
- 不处理 `auth.json`
- 不登录、不认证
- 不修改对话内容
- 不修改 `updated_at`
## 操作建议
执行 `Sync Metadata Only``Switch config.toml and sync``Restore Backup``Clean Old Backups` 前,先关闭:
- Codex CLI
- Codex App
- app-server
- 仍在使用相关 Codex Home 的终端任务
如果看到 `state_5.sqlite is currently in use`,关闭上述进程后重试。
如果日志显示跳过 locked rollout files,通常表示当前会话仍占用 rollout 文件。同步大多已完成;等会话结束后再运行一次 sync 可补齐这些文件。
## 发布说明
`scripts/publish-gui-macos.sh` 默认发布 `osx-arm64` self-contained `.app` bundle,并在本机可用时执行 ad-hoc `codesign`。该脚本不修改 Windows GUI 发布脚本 `scripts/publish-gui.ps1`
当前 Avalonia 11.3.x 构建可能显示 `Tmds.DBus.Protocol` 的 NuGet advisory warning。该依赖来自 Avalonia 桌面包的跨平台传递依赖;macOS 发布产物可构建运行,但发布前仍应按团队安全策略评估或升级 Avalonia。
+115
View File
@@ -0,0 +1,115 @@
<div align="center">
# codex-provider-sync
### 切换 provider 后,让 Codex 历史会话重新可见
[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml)
[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/Dailin521/codex-provider-sync)
[![Node](https://img.shields.io/badge/node-16%2B-brightgreen.svg)](https://nodejs.org/)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
中文镜像 | [English](README_EN.md) | [主 README](../README.md)
</div>
## 解决什么问题
Codex 切换 `model_provider` 后,旧会话可能在 Desktop 或 `/resume` 里不可见。原因通常不是会话文件丢了,而是 rollout 文件、SQLite 线程表、项目路径缓存里的 provider / 可见性 metadata 不一致。
本工具同步这些位置:
- `~/.codex/sessions`
- `~/.codex/archived_sessions`
- `~/.codex/state_5.sqlite`
- `.codex-global-state.json` 中的项目根路径缓存
## 快速使用
Windows 用户优先下载 Release 里的 `CodexProviderSync.exe`
1. 打开 `CodexProviderSync.exe`
2. 点击 `Refresh`
3. 选择目标 Provider
4. 点击 `Execute`
macOS 等环境使用 CLI
```bash
npm install -g git+https://github.com/Dailin521/codex-provider-sync.git
codex-provider sync
```
CLI 需要 Node.js `16+`。Node 24+ 会优先使用内置 `node:sqlite`;旧版 Node 会使用可选依赖 `better-sqlite3`
更多 CLI 常用命令:
```bash
codex-provider status
codex-provider sync
codex-provider sync --provider openai
codex-provider switch apigather
codex-provider restore C:\Users\you\.codex\backups_state\provider-sync\<timestamp>
codex-provider prune-backups --keep 5
```
命令含义:
- `status`:只检查当前 provider、rollout、SQLite、项目可见性诊断。
- `sync`:不切换登录状态,只把历史会话 metadata 同步到当前 provider。
- `switch <provider-id>`:修改 `config.toml` 根级 `model_provider`,然后执行同步。
- `restore <backup-dir>`:从备份恢复,支持 `--no-config``--no-db``--no-sessions`
- `prune-backups --keep <n>`:只清理本工具创建的旧备份。
## 能力边界
本工具只修复“历史会话可见性”相关 metadata,不修改会话内容。
- 不处理登录、认证、`auth.json` 或第三方切号工具。
- 不修改消息历史、会话标题、对话内容。
- 不修改 `updated_at`,不通过改变历史排序来修复 Desktop 显示。
- 不把旧会话里的 `encrypted_content` 重新加密到另一个 provider / account。
-`encrypted_content` 的旧会话跨 provider/account 后,通常只能恢复列表可见性,继续对话或 compact 仍可能报 `invalid_encrypted_content`
## Codex Desktop 最近 50 条限制
目前 Codex Desktop 的 Recent/项目侧会话列表存在一个上游显示限制:前端首屏只拉取最近 `50` 条会话。
影响:
- CLI `/resume` 能看到的旧会话,Desktop 项目侧可能仍显示“暂无对话”。
- 旧项目会话如果排在全局最近 50 条之后,Desktop 首屏可能不会展示。
- `codex-provider-sync status` / GUI Refresh 会显示 `first page 0/50``ranks 64-77` 这类诊断,帮助判断是不是这个问题。
本工具不会通过修改 `updated_at` 或文件时间把旧会话强行挤进前 50。这个问题应由 Codex Desktop 上游改成按项目分页加载,或提高/开放首屏加载数量。
## 安全与排障
每次 `sync` / `switch` 前都会备份到:
```text
~/.codex/backups_state/provider-sync/<timestamp>
```
注意:
- 如果 `state_5.sqlite` 被占用,关闭 Codex / Codex App / app-server 后重试。
- 如果 `state_5.sqlite` 损坏,工具会提示 malformed/unreadable 并停止同步。
- 如果活跃会话锁住 rollout 文件,工具会跳过该文件并继续处理其它历史会话。
- 如果 EXE 双击无反应,先确认已解压,再查看 `%AppData%\codex-provider-sync\startup-error.log`,或在 PowerShell 里运行 `./CodexProviderSync.exe`
GUI 说明见 [README_GUI_ZH.md](README_GUI_ZH.md)。AI / Agent 说明见 [AGENTS.md](../AGENTS.md)。
## 开发
```bash
git clone https://github.com/Dailin521/codex-provider-sync.git
cd codex-provider-sync
npm test
dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj
pwsh ./scripts/publish-gui.ps1
```
## License
MIT
+471
View File
@@ -0,0 +1,471 @@
{
"name": "codex-provider-sync",
"version": "0.2.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-provider-sync",
"version": "0.2.9",
"license": "MIT",
"bin": {
"codex-provider": "src/cli.js"
},
"engines": {
"node": ">=16.0.0"
},
"optionalDependencies": {
"better-sqlite3": "8.7.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true
},
"node_modules/better-sqlite3": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.7.0.tgz",
"integrity": "sha512-99jZU4le+f3G6aIl6PmmV0cxUIWqKieHxsiF7G34CVFiE+/UabpYqkU0NJIkY/96mQKikHeBjtR27vFfs5JpEw==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"optional": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"optional": true
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"optional": true,
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"optional": true
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"optional": true
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"optional": true
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"optional": true
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"optional": true
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"optional": true
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"optional": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true
},
"node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"optional": true
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"optional": true
}
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "codex-provider-sync",
"version": "0.2.9",
"description": "Synchronize Codex session provider metadata across rollout files and SQLite state.",
"type": "module",
"files": [
"README.md",
"AGENTS.md",
"docs",
"src"
],
"bin": {
"codex-provider": "./src/cli.js"
},
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=16.0.0"
},
"keywords": [
"codex",
"sqlite",
"sessions",
"provider"
],
"license": "MIT",
"optionalDependencies": {
"better-sqlite3": "8.7.0"
}
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
configuration="Release"
runtime="osx-arm64"
output="artifacts/osx-arm64"
while [[ $# -gt 0 ]]; do
case "$1" in
--configuration)
configuration="${2:?Missing value for --configuration}"
shift 2
;;
--runtime)
runtime="${2:?Missing value for --runtime}"
shift 2
;;
--output)
output="${2:?Missing value for --output}"
shift 2
;;
-h|--help)
cat <<'EOF'
Usage: scripts/publish-gui-macos.sh [--configuration Release] [--runtime osx-arm64] [--output artifacts/osx-arm64]
Publishes the Avalonia macOS GUI as a self-contained app bundle.
Set DOTNET=/path/to/dotnet to use a specific SDK.
EOF
exit 0
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project="$repo_root/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj"
output_dir="$repo_root/$output"
publish_dir="$output_dir/publish"
app_dir="$output_dir/CodexProviderSync.app"
contents_dir="$app_dir/Contents"
macos_dir="$contents_dir/MacOS"
resources_dir="$contents_dir/Resources"
dotnet="${DOTNET:-dotnet}"
if [[ "$runtime" != osx-* ]]; then
echo "Runtime must be a macOS RID such as osx-arm64 or osx-x64." >&2
exit 1
fi
if [[ -e "$output_dir" ]]; then
rm -rf "$output_dir"
fi
mkdir -p "$publish_dir" "$macos_dir" "$resources_dir"
"$dotnet" publish "$project" \
--runtime "$runtime" \
-c "$configuration" \
--self-contained true \
-o "$publish_dir" \
/p:PublishSingleFile=true \
/p:IncludeNativeLibrariesForSelfExtract=true \
/p:EnableCompressionInSingleFile=true \
/p:DebugType=None \
/p:DebugSymbols=false \
/p:PublishTrimmed=false
cp -R "$publish_dir"/. "$macos_dir"/
chmod +x "$macos_dir/CodexProviderSync"
cat > "$contents_dir/Info.plist" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Codex Provider Sync</string>
<key>CFBundleExecutable</key>
<string>CodexProviderSync</string>
<key>CFBundleIdentifier</key>
<string>com.dailin521.codexprovidersync</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Codex Provider Sync</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.5</string>
<key>CFBundleVersion</key>
<string>0.2.5</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
rm -rf "$publish_dir"
if command -v codesign >/dev/null 2>&1; then
codesign --force --deep --sign - "$app_dir" >/dev/null
fi
echo "macOS GUI app published to $app_dir"
+37
View File
@@ -0,0 +1,37 @@
param(
[string]$Configuration = "Release",
[string]$Runtime = "win-x64",
[string]$Output = "artifacts\win-x64"
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$project = Join-Path $repoRoot "desktop\CodexProviderSync.App\CodexProviderSync.App.csproj"
$outputDir = Join-Path $repoRoot $Output
if (Test-Path $outputDir) {
try {
Remove-Item -Recurse -Force $outputDir
}
catch {
throw "Unable to clean publish output '$outputDir'. Close CodexProviderSync.exe if it is still running, or pass -Output to publish into a different directory."
}
}
dotnet publish $project `
--runtime $Runtime `
-c $Configuration `
--self-contained true `
-o $outputDir `
/p:PublishSingleFile=true `
/p:IncludeNativeLibrariesForSelfExtract=true `
/p:EnableCompressionInSingleFile=true `
/p:DebugType=None `
/p:DebugSymbols=false
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
Write-Host "GUI build published to $outputDir"
+304
View File
@@ -0,0 +1,304 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
BACKUP_NAMESPACE,
DB_FILE_BASENAME,
DEFAULT_BACKUP_RETENTION_COUNT,
defaultBackupRoot,
GLOBAL_STATE_BACKUP_FILE_BASENAME,
GLOBAL_STATE_FILE_BASENAME
} from "./constants.js";
import { assertSessionFilesWritable, restoreSessionChanges } from "./session-files.js";
import { assertSqliteWritable, detectStateDb } from "./sqlite-state.js";
function timestampSlug(date = new Date()) {
return date.toISOString().replaceAll(":", "").replaceAll("-", "").replace(".", "");
}
async function copyIfPresent(sourcePath, destinationPath) {
try {
await fs.access(sourcePath);
} catch {
return false;
}
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
await fs.copyFile(sourcePath, destinationPath);
return true;
}
function dbBackupRelativePath(codexHome, dbPath, suffix) {
const relativePath = path.relative(codexHome, `${dbPath}${suffix}`);
return relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)
? relativePath
: `${DB_FILE_BASENAME}${suffix}`;
}
function restoreDbTargetPath(codexHome, relativePath) {
if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) {
throw new Error(`Invalid database backup path: ${relativePath}`);
}
return path.join(codexHome, relativePath);
}
async function removeIfPresent(targetPath) {
await fs.rm(targetPath, { force: true });
}
async function backupGlobalStateFiles(codexHome, backupDir) {
for (const fileName of [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]) {
await copyIfPresent(path.join(codexHome, fileName), path.join(backupDir, fileName));
}
}
export async function restoreGlobalStateFilesFromBackup(backupDir, codexHome) {
for (const fileName of [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]) {
await copyIfPresent(path.join(backupDir, fileName), path.join(codexHome, fileName));
}
}
export async function createBackup({
codexHome,
targetProvider,
sessionChanges,
configPath,
configBackupText
}) {
const backupRoot = defaultBackupRoot(codexHome);
const backupDir = path.join(backupRoot, timestampSlug());
const dbDir = path.join(backupDir, "db");
await fs.mkdir(dbDir, { recursive: true });
const copiedDbFiles = [];
const stateDb = await detectStateDb(codexHome);
if (stateDb) {
for (const suffix of ["", "-shm", "-wal"]) {
const relativePath = dbBackupRelativePath(codexHome, stateDb.path, suffix);
const copied = await copyIfPresent(`${stateDb.path}${suffix}`, path.join(dbDir, relativePath));
if (copied) {
copiedDbFiles.push(relativePath);
}
}
}
if (configBackupText !== undefined) {
await fs.writeFile(path.join(backupDir, "config.toml"), configBackupText, "utf8");
} else {
await copyIfPresent(configPath, path.join(backupDir, "config.toml"));
}
await backupGlobalStateFiles(codexHome, backupDir);
const sessionManifest = {
version: 1,
namespace: BACKUP_NAMESPACE,
codexHome,
targetProvider,
createdAt: new Date().toISOString(),
files: sessionChanges.map((change) => ({
path: change.path,
originalFirstLine: change.originalFirstLine,
originalSeparator: change.originalSeparator,
originalMtimeMs: change.originalMtimeMs
}))
};
await fs.writeFile(
path.join(backupDir, "session-meta-backup.json"),
JSON.stringify(sessionManifest, null, 2),
"utf8"
);
await fs.writeFile(
path.join(backupDir, "metadata.json"),
JSON.stringify(
{
version: 1,
namespace: BACKUP_NAMESPACE,
codexHome,
targetProvider,
createdAt: sessionManifest.createdAt,
dbFiles: copiedDbFiles,
changedSessionFiles: sessionChanges.length
},
null,
2
),
"utf8"
);
return backupDir;
}
export async function updateSessionBackupManifest(backupDir, sessionChanges) {
const manifestPath = path.join(backupDir, "session-meta-backup.json");
const metadataPath = path.join(backupDir, "metadata.json");
const sessionManifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
sessionManifest.files = sessionChanges.map((change) => ({
path: change.path,
originalFirstLine: change.originalFirstLine,
originalSeparator: change.originalSeparator,
originalMtimeMs: change.originalMtimeMs
}));
metadata.changedSessionFiles = sessionChanges.length;
await fs.writeFile(manifestPath, JSON.stringify(sessionManifest, null, 2), "utf8");
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
}
export async function getBackupSummary(codexHome) {
const backupRoot = defaultBackupRoot(codexHome);
const backupDirs = await listManagedBackupDirectories(backupRoot);
let totalBytes = 0;
for (const entry of backupDirs) {
totalBytes += await getDirectorySize(entry.fullPath);
}
return {
count: backupDirs.length,
totalBytes
};
}
export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTION_COUNT) {
if (!Number.isInteger(keepCount) || keepCount < 0) {
throw new Error(`Invalid keep count: ${keepCount}. Expected a non-negative integer.`);
}
const backupRoot = defaultBackupRoot(codexHome);
const backupDirs = await listManagedBackupDirectories(backupRoot);
const toDelete = backupDirs.slice(keepCount);
let freedBytes = 0;
for (const entry of toDelete) {
freedBytes += await getDirectorySize(entry.fullPath);
await fs.rm(entry.fullPath, { recursive: true, force: true });
}
return {
backupRoot,
deletedCount: toDelete.length,
remainingCount: backupDirs.length - toDelete.length,
freedBytes
};
}
export async function restoreBackup(backupDir, codexHome, options = {}) {
const {
restoreConfig = true,
restoreDatabase = true,
restoreSessions = true
} = options;
const metadataPath = path.join(backupDir, "metadata.json");
const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
if (metadata.codexHome !== codexHome) {
throw new Error(`Backup was created for ${metadata.codexHome}, not ${codexHome}.`);
}
let sessionManifest = null;
if (restoreSessions) {
const sessionManifestPath = path.join(backupDir, "session-meta-backup.json");
sessionManifest = JSON.parse(await fs.readFile(sessionManifestPath, "utf8"));
await assertSessionFilesWritable(sessionManifest.files ?? []);
}
const configBackupPath = path.join(backupDir, "config.toml");
if (restoreConfig) {
await copyIfPresent(configBackupPath, path.join(codexHome, "config.toml"));
await restoreGlobalStateFilesFromBackup(backupDir, codexHome);
}
if (restoreDatabase) {
await assertSqliteWritable(codexHome);
const dbDir = path.join(backupDir, "db");
const backedUpFiles = new Set(metadata.dbFiles ?? []);
const backedUpBaseFiles = (metadata.dbFiles ?? [])
.filter((fileName) => path.basename(fileName) === DB_FILE_BASENAME);
for (const baseFile of backedUpBaseFiles) {
const basePath = restoreDbTargetPath(codexHome, baseFile);
for (const suffix of ["-shm", "-wal"]) {
const sidecarFile = `${baseFile}${suffix}`;
if (!backedUpFiles.has(sidecarFile)) {
await removeIfPresent(`${basePath}${suffix}`);
}
}
}
for (const fileName of metadata.dbFiles ?? []) {
await copyIfPresent(path.join(dbDir, fileName), restoreDbTargetPath(codexHome, fileName));
}
}
if (restoreSessions) {
await restoreSessionChanges(sessionManifest.files ?? []);
}
return metadata;
}
async function listManagedBackupDirectories(backupRoot) {
let entries;
try {
entries = await fs.readdir(backupRoot, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") {
return [];
}
throw error;
}
const directories = entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
fullPath: path.join(backupRoot, entry.name)
}));
const managed = [];
for (const entry of directories) {
if (await isManagedBackupDirectory(entry.fullPath)) {
managed.push(entry);
}
}
return managed.sort((left, right) => right.name.localeCompare(left.name));
}
async function isManagedBackupDirectory(backupDir) {
const metadataPath = path.join(backupDir, "metadata.json");
try {
const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
return metadata?.namespace === BACKUP_NAMESPACE;
} catch (error) {
if (error?.code === "ENOENT") {
return false;
}
return false;
}
}
async function getDirectorySize(directoryPath) {
let entries;
try {
entries = await fs.readdir(directoryPath, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") {
return 0;
}
throw error;
}
let total = 0;
for (const entry of entries) {
const fullPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
total += await getDirectorySize(fullPath);
continue;
}
if (entry.isFile()) {
const stat = await fs.stat(fullPath);
total += stat.size;
}
}
return total;
}
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env node
import path from "node:path";
import { DEFAULT_BACKUP_RETENTION_COUNT } from "./constants.js";
import { installWindowsLauncher } from "./launcher.js";
import { assertSupportedNodeVersion } from "./node-version.js";
async function loadService() {
assertSupportedNodeVersion();
return import("./service.js");
}
function printHelp() {
console.log(`codex-provider
Usage:
codex-provider status [--codex-home PATH]
codex-provider sync [--provider ID] [--keep N] [--codex-home PATH]
codex-provider switch <provider-id> [--keep N] [--codex-home PATH]
codex-provider prune-backups [--keep N] [--codex-home PATH]
codex-provider restore <backup-dir> [--no-config] [--no-db] [--no-sessions] [--codex-home PATH]
codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH]
`);
}
function parseArgs(argv) {
const positionals = [];
const flags = {};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith("--")) {
positionals.push(value);
continue;
}
const [flagName, inlineValue] = value.split("=", 2);
const normalizedName = flagName.slice(2);
if (inlineValue !== undefined) {
flags[normalizedName] = inlineValue;
continue;
}
const nextValue = argv[index + 1];
if (nextValue && !nextValue.startsWith("--")) {
flags[normalizedName] = nextValue;
index += 1;
} else {
flags[normalizedName] = true;
}
}
return { positionals, flags };
}
function summarizeSync(result, label) {
const lines = [
`${label} provider: ${result.targetProvider}`,
`Codex home: ${result.codexHome}`,
`Backup: ${result.backupDir}`,
`Backup creation time: ${formatDuration(result.backupDurationMs ?? 0)}`,
`Updated rollout files: ${result.changedSessionFiles}`,
`Updated SQLite rows: ${result.sqliteRowsUpdated}${result.sqlitePresent ? "" : " (state_5.sqlite not found)"}`
];
if (result.sqliteUserEventRowsUpdated) {
lines.push(`Updated SQLite user-event flags: ${result.sqliteUserEventRowsUpdated}`);
}
if (result.sqliteCwdRowsUpdated) {
lines.push(`Updated SQLite cwd paths: ${result.sqliteCwdRowsUpdated}`);
}
if (result.updatedWorkspaceRoots) {
lines.push(`Updated workspace roots: ${result.updatedWorkspaceRoots}`);
}
if (result.skippedLockedRolloutFiles?.length) {
const preview = result.skippedLockedRolloutFiles.slice(0, 5).join(", ");
const extraCount = result.skippedLockedRolloutFiles.length - Math.min(result.skippedLockedRolloutFiles.length, 5);
lines.push(`Skipped locked rollout files: ${result.skippedLockedRolloutFiles.length}`);
lines.push(`Locked file(s): ${preview}${extraCount > 0 ? ` (+${extraCount} more)` : ""}`);
}
if (result.encryptedContentWarning) {
lines.push(result.encryptedContentWarning);
}
if (result.autoPruneResult) {
lines.push(
`Backup cleanup: deleted ${result.autoPruneResult.deletedCount}, remaining ${result.autoPruneResult.remainingCount}, freed ${formatBytes(result.autoPruneResult.freedBytes)}`
);
}
if (result.autoPruneWarning) {
lines.push(`Backup cleanup warning: ${result.autoPruneWarning}`);
}
return lines.join("\n");
}
function summarizePrune(result) {
return [
`Backup root: ${result.backupRoot}`,
`Deleted backups: ${result.deletedCount}`,
`Remaining backups: ${result.remainingCount}`,
`Freed space: ${formatBytes(result.freedBytes)}`
].join("\n");
}
function formatBytes(bytes) {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return unitIndex === 0 ? `${bytes} B` : `${value.toFixed(value >= 10 ? 1 : 2).replace(/\.0$/, "")} ${units[unitIndex]}`;
}
function formatDuration(durationMs) {
if (!Number.isFinite(durationMs) || durationMs < 1000) {
return `${Math.max(0, Math.round(durationMs ?? 0))} ms`;
}
const seconds = durationMs / 1000;
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 1 : 2).replace(/\.0$/, "")} s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds - (minutes * 60);
return `${minutes}m ${remainingSeconds.toFixed(remainingSeconds >= 10 ? 0 : 1).replace(/\.0$/, "")}s`;
}
const SYNC_PROGRESS_STAGES = [
["scan_rollout_files", "Scanning rollout files..."],
["check_locked_rollout_files", "Checking locked rollout files..."],
["create_backup", "Creating backup..."],
["update_sqlite", "Updating SQLite..."],
["rewrite_rollout_files", "Rewriting rollout files..."],
["clean_backups", "Cleaning backups..."]
];
const SYNC_PROGRESS_STAGE_INDEX = new Map(
SYNC_PROGRESS_STAGES.map(([stage], index) => [stage, index + 1])
);
function createSyncProgressReporter() {
return (event) => {
if (event?.stage === "update_config" && event.status === "start") {
console.log(`Updating config.toml root model_provider to ${event.provider}...`);
return;
}
const stageIndex = SYNC_PROGRESS_STAGE_INDEX.get(event?.stage);
if (!stageIndex || event.status !== "start") {
if (event?.stage === "create_backup" && event.status === "complete") {
console.log(` Backup created in ${formatDuration(event.durationMs)}: ${event.backupDir}`);
}
return;
}
console.log(`[${stageIndex}/${SYNC_PROGRESS_STAGES.length}] ${SYNC_PROGRESS_STAGES[stageIndex - 1][1]}`);
};
}
function parseKeepCount(rawValue, { allowZero = false } = {}) {
if (rawValue === undefined) {
return DEFAULT_BACKUP_RETENTION_COUNT;
}
const normalized = String(rawValue).trim();
if (!/^\d+$/.test(normalized)) {
const minimum = allowZero ? 0 : 1;
throw new Error(`Invalid --keep value: ${rawValue}. Expected an integer greater than or equal to ${minimum}.`);
}
const keepCount = Number.parseInt(normalized, 10);
const minimum = allowZero ? 0 : 1;
if (!Number.isInteger(keepCount) || keepCount < minimum) {
throw new Error(`Invalid --keep value: ${rawValue}. Expected an integer greater than or equal to ${minimum}.`);
}
return keepCount;
}
async function main() {
const { positionals, flags } = parseArgs(process.argv.slice(2));
const command = positionals[0];
if (!command || command === "help" || flags.help) {
printHelp();
return;
}
if (command === "status") {
const { getStatus, renderStatus } = await loadService();
const status = await getStatus({ codexHome: flags["codex-home"] });
console.log(renderStatus(status));
return;
}
if (command === "sync") {
const { runSync } = await loadService();
const result = await runSync({
codexHome: flags["codex-home"],
provider: flags.provider,
keepCount: parseKeepCount(flags.keep),
onProgress: createSyncProgressReporter()
});
console.log(summarizeSync(result, "Synchronized"));
return;
}
if (command === "switch") {
const { runSwitch } = await loadService();
const provider = positionals[1] ?? flags.provider;
const result = await runSwitch({
codexHome: flags["codex-home"],
provider,
keepCount: parseKeepCount(flags.keep),
onProgress: createSyncProgressReporter()
});
console.log(summarizeSync(result, "Switched to"));
return;
}
if (command === "prune-backups") {
const { runPruneBackups } = await loadService();
const result = await runPruneBackups({
codexHome: flags["codex-home"],
keepCount: parseKeepCount(flags.keep, { allowZero: true })
});
console.log(summarizePrune(result));
return;
}
if (command === "restore") {
const { runRestore } = await loadService();
const backupDir = positionals[1] ?? flags.backup;
const result = await runRestore({
codexHome: flags["codex-home"],
backupDir,
restoreConfig: !flags["no-config"],
restoreDatabase: !flags["no-db"],
restoreSessions: !flags["no-sessions"]
});
console.log(`Restored backup from ${path.resolve(backupDir)}`);
console.log(`Codex home: ${result.codexHome}`);
console.log(`Provider at backup time: ${result.targetProvider}`);
return;
}
if (command === "install-windows-launcher") {
const result = await installWindowsLauncher({
dir: flags.dir,
codexHome: flags["codex-home"]
});
console.log("Installed Windows launcher files:");
console.log(` Hidden double-click launcher: ${result.vbsPath}`);
console.log(` Visible console launcher: ${result.cmdPath}`);
console.log(` Target directory: ${result.targetDir}`);
if (result.codexHome) {
console.log(` Fixed CODEX_HOME: ${result.codexHome}`);
} else {
console.log(" CODEX_HOME: default current environment / ~/.codex");
}
return;
}
throw new Error(`Unknown command: ${command}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+76
View File
@@ -0,0 +1,76 @@
import fs from "node:fs/promises";
import { DEFAULT_PROVIDER } from "./constants.js";
function splitLines(text) {
return text.split(/\r?\n/);
}
function escapeTomlString(value) {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
export async function readConfigText(configPath) {
return fs.readFile(configPath, "utf8");
}
export function readCurrentProviderFromConfigText(configText) {
const lines = splitLines(configText);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
if (trimmed.startsWith("[")) {
break;
}
const match = trimmed.match(/^model_provider\s*=\s*"([^"]+)"\s*$/);
if (match) {
return { provider: match[1], implicit: false };
}
}
return { provider: DEFAULT_PROVIDER, implicit: true };
}
export function listConfiguredProviderIds(configText) {
const providerIds = new Set([DEFAULT_PROVIDER]);
const regex = /^\[model_providers\.([A-Za-z0-9_.-]+)]\s*$/gm;
for (const match of configText.matchAll(regex)) {
providerIds.add(match[1]);
}
return [...providerIds].sort();
}
export function configDeclaresProvider(configText, provider) {
return listConfiguredProviderIds(configText).includes(provider);
}
export function setRootProviderInConfigText(configText, provider) {
const lines = splitLines(configText);
let insertIndex = lines.length;
for (let index = 0; index < lines.length; index += 1) {
const trimmed = lines[index].trim();
if (!trimmed || trimmed.startsWith("#")) {
insertIndex = index + 1;
continue;
}
if (trimmed.startsWith("[")) {
insertIndex = index;
break;
}
if (/^model_provider\s*=/.test(trimmed)) {
lines[index] = `model_provider = "${escapeTomlString(provider)}"`;
return `${lines.join("\n")}${configText.endsWith("\n") ? "\n" : ""}`.replace(/\n\n$/, "\n");
}
insertIndex = index + 1;
}
lines.splice(insertIndex, 0, `model_provider = "${escapeTomlString(provider)}"`);
const nextText = lines.join("\n");
return configText.endsWith("\n") ? `${nextText}\n` : nextText;
}
export async function writeConfigText(configPath, configText) {
await fs.writeFile(configPath, configText, "utf8");
}
+20
View File
@@ -0,0 +1,20 @@
import os from "node:os";
import path from "node:path";
export const DEFAULT_PROVIDER = "openai";
export const DEFAULT_LOCK_NAME = "provider-sync.lock";
export const BACKUP_NAMESPACE = "provider-sync";
export const DB_FILE_BASENAME = "state_5.sqlite";
export const SQLITE_DIR_BASENAME = "sqlite";
export const GLOBAL_STATE_FILE_BASENAME = ".codex-global-state.json";
export const GLOBAL_STATE_BACKUP_FILE_BASENAME = ".codex-global-state.json.bak";
export const DEFAULT_BACKUP_RETENTION_COUNT = 5;
export const SESSION_DIRS = ["sessions", "archived_sessions"];
export function defaultCodexHome() {
return path.join(os.homedir(), ".codex");
}
export function defaultBackupRoot(codexHome) {
return path.join(codexHome, "backups_state", BACKUP_NAMESPACE);
}
+112
View File
@@ -0,0 +1,112 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const WINDOWS_CMD_LAUNCHER_FILENAME = "Codex Provider Sync.cmd";
export const WINDOWS_VBS_LAUNCHER_FILENAME = "Codex Provider Sync.vbs";
function resolveLauncherDirectory(explicitDir) {
return path.resolve(explicitDir ?? path.join(os.homedir(), "Desktop"));
}
function quoteForBatch(value) {
return `"${String(value).replace(/"/g, "\"\"")}"`;
}
function quoteForVbs(value) {
return String(value).replace(/"/g, "\"\"");
}
function buildBatchScript({ codexHome }) {
const command = [
"codex-provider",
"sync",
...(codexHome ? ["--codex-home", quoteForBatch(codexHome)] : [])
].join(" ");
return [
"@echo off",
"setlocal",
command,
"exit /b %ERRORLEVEL%"
].join("\r\n") + "\r\n";
}
function buildVbsScript({ codexHome }) {
const syncCommand = [
"codex-provider",
"sync",
...(codexHome ? [`--codex-home ""${quoteForVbs(codexHome)}""`] : [])
].join(" ");
return [
"Option Explicit",
"",
'Const TITLE = "Codex Provider Sync"',
"Const MAX_OUTPUT = 3000",
"",
"Function TruncateOutput(value)",
" If Len(value) <= MAX_OUTPUT Then",
" TruncateOutput = value",
" Else",
' TruncateOutput = Left(value, MAX_OUTPUT) & vbCrLf & vbCrLf & "... output truncated ..."',
" End If",
"End Function",
"",
"Dim shell, exec, command, stdoutText, stderrText, combined, message, exitCode",
`command = "cmd.exe /d /c ${syncCommand}"`,
'Set shell = CreateObject("WScript.Shell")',
"Set exec = shell.Exec(command)",
"",
"Do While exec.Status = 0",
" WScript.Sleep 200",
"Loop",
"",
"stdoutText = Trim(exec.StdOut.ReadAll)",
"stderrText = Trim(exec.StdErr.ReadAll)",
"combined = stdoutText",
"",
"If Len(stderrText) > 0 Then",
" If Len(combined) > 0 Then",
" combined = combined & vbCrLf & vbCrLf",
" End If",
" combined = combined & stderrText",
"End If",
"",
"If Len(combined) = 0 Then",
' combined = "(no output)"',
"End If",
"",
"exitCode = exec.ExitCode",
"",
"If exitCode = 0 Then",
' message = "Synchronization finished." & vbCrLf & vbCrLf & TruncateOutput(combined)',
' MsgBox message, vbOKOnly + vbInformation, TITLE',
"Else",
' message = "Synchronization failed (exit code " & exitCode & ")." & vbCrLf & vbCrLf & TruncateOutput(combined)',
' MsgBox message, vbOKOnly + vbCritical, TITLE',
"End If",
""
].join("\r\n");
}
export async function installWindowsLauncher({
dir,
codexHome
} = {}) {
const targetDir = resolveLauncherDirectory(dir);
await fs.mkdir(targetDir, { recursive: true });
const cmdPath = path.join(targetDir, WINDOWS_CMD_LAUNCHER_FILENAME);
const vbsPath = path.join(targetDir, WINDOWS_VBS_LAUNCHER_FILENAME);
await fs.writeFile(cmdPath, buildBatchScript({ codexHome }), "utf8");
await fs.writeFile(vbsPath, buildVbsScript({ codexHome }), "utf8");
return {
targetDir,
cmdPath,
vbsPath,
codexHome: codexHome ? path.resolve(codexHome) : null
};
}
+77
View File
@@ -0,0 +1,77 @@
import fs from "node:fs/promises";
import path from "node:path";
import { DEFAULT_LOCK_NAME } from "./constants.js";
const DEFAULT_LOCK_CREATE_RETRY_COUNT = 3;
const DEFAULT_LOCK_CREATE_RETRY_DELAY_MS = 75;
function isTransientLockCreateError(error) {
return error?.code === "EPERM";
}
async function sleep(delayMs) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
async function createLockDirectory(lockDir, {
fsImpl,
retryCount,
retryDelayMs,
sleepImpl
}) {
let attempts = 0;
while (true) {
try {
await fsImpl.mkdir(lockDir);
return;
} catch (error) {
if (error && error.code === "EEXIST") {
throw new Error(`Lock already exists at ${lockDir}. Close Codex/App and retry, or remove the stale lock if you are sure no sync is running.`);
}
// Windows can briefly surface EPERM after a previous run releases the lock directory.
if (!isTransientLockCreateError(error) || attempts >= retryCount) {
throw error;
}
attempts += 1;
await sleepImpl(retryDelayMs);
}
}
}
export async function acquireLock(codexHome, label = "codex-provider-sync", options = {}) {
const {
fsImpl = fs,
retryCount = DEFAULT_LOCK_CREATE_RETRY_COUNT,
retryDelayMs = DEFAULT_LOCK_CREATE_RETRY_DELAY_MS,
sleepImpl = sleep
} = options;
const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME);
await fsImpl.mkdir(path.dirname(lockDir), { recursive: true });
await createLockDirectory(lockDir, {
fsImpl,
retryCount,
retryDelayMs,
sleepImpl
});
const ownerPath = path.join(lockDir, "owner.json");
const owner = {
pid: process.pid,
startedAt: new Date().toISOString(),
label,
cwd: process.cwd()
};
await fsImpl.writeFile(ownerPath, JSON.stringify(owner, null, 2), "utf8");
let released = false;
return async function releaseLock() {
if (released) {
return;
}
released = true;
await fsImpl.rm(lockDir, { recursive: true, force: true });
};
}
+20
View File
@@ -0,0 +1,20 @@
export const MINIMUM_NODE_MAJOR_VERSION = 16;
export function getUnsupportedNodeVersionMessage(nodeVersion = process.versions.node) {
const majorVersion = Number.parseInt(String(nodeVersion).split(".")[0] ?? "", 10);
if (Number.isInteger(majorVersion) && majorVersion >= MINIMUM_NODE_MAJOR_VERSION) {
return null;
}
const displayVersion = String(nodeVersion).startsWith("v") ? String(nodeVersion) : `v${nodeVersion}`;
return `codex-provider-sync requires Node.js ${MINIMUM_NODE_MAJOR_VERSION}+. `
+ `Current Node.js version: ${displayVersion}. `
+ "Please upgrade Node.js, then reinstall or rerun codex-provider.";
}
export function assertSupportedNodeVersion() {
const message = getUnsupportedNodeVersionMessage();
if (message) {
throw new Error(message);
}
}
+489
View File
@@ -0,0 +1,489 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
DEFAULT_BACKUP_RETENTION_COUNT,
DEFAULT_PROVIDER,
defaultBackupRoot,
defaultCodexHome
} from "./constants.js";
import {
configDeclaresProvider,
listConfiguredProviderIds,
readConfigText,
readCurrentProviderFromConfigText,
setRootProviderInConfigText,
writeConfigText
} from "./config-file.js";
import {
createBackup,
getBackupSummary,
pruneBackups,
restoreBackup,
restoreGlobalStateFilesFromBackup,
updateSessionBackupManifest
} from "./backup.js";
import { acquireLock } from "./locking.js";
import {
applySessionChanges,
collectSessionChanges,
restoreSessionChanges,
splitLockedSessionChanges,
summarizeProviderCounts
} from "./session-files.js";
import {
assertSqliteWritable,
detectStateDb,
readSqliteProviderCounts,
readSqliteRepairStats,
updateSqliteProvider
} from "./sqlite-state.js";
import {
readProjectThreadVisibility,
readThreadCwdStats,
syncWorkspaceRoots
} from "./workspace-roots.js";
function normalizeCodexHome(explicitCodexHome) {
return path.resolve(explicitCodexHome ?? process.env.CODEX_HOME ?? defaultCodexHome());
}
async function ensureCodexHome(codexHome) {
await fs.access(codexHome);
}
function formatCounts(counts) {
return Object.entries(counts ?? {})
.map(([provider, count]) => `${provider}: ${count}`)
.join(", ") || "(none)";
}
function formatBytes(bytes) {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return unitIndex === 0 ? `${bytes} B` : `${value.toFixed(value >= 10 ? 1 : 2).replace(/\.0$/, "")} ${units[unitIndex]}`;
}
function emitProgress(onProgress, event) {
if (typeof onProgress === "function") {
onProgress(event);
}
}
function sumCounts(counts) {
return Object.values(counts ?? {}).reduce((total, value) => total + value, 0);
}
function buildEncryptedContentWarning(encryptedContentCounts, targetProvider) {
const riskyProviders = new Set();
for (const scope of ["sessions", "archived_sessions"]) {
for (const [provider, count] of Object.entries(encryptedContentCounts?.[scope] ?? {})) {
if (count > 0 && provider !== targetProvider) {
riskyProviders.add(provider);
}
}
}
const total = sumCounts(encryptedContentCounts?.sessions) + sumCounts(encryptedContentCounts?.archived_sessions);
if (riskyProviders.size === 0) {
return null;
}
return `Encrypted content warning: ${total} rollout file(s) contain encrypted_content from provider(s) ${[...riskyProviders].sort().join(", ")}. Visibility metadata can be synchronized to ${targetProvider}, but continuing or compacting those histories may fail with invalid_encrypted_content. Return to the original provider/account or start a new session if you need reliable continuation.`;
}
export async function getStatus({ codexHome: explicitCodexHome } = {}) {
const codexHome = normalizeCodexHome(explicitCodexHome);
await ensureCodexHome(codexHome);
const configPath = path.join(codexHome, "config.toml");
const configText = await readConfigText(configPath);
const current = readCurrentProviderFromConfigText(configText);
const configuredProviders = listConfiguredProviderIds(configText);
const {
providerCounts,
encryptedContentCounts,
lockedPaths,
userEventThreadIds,
threadCwdById
} = await collectSessionChanges(codexHome, "__status_only__", { skipLockedReads: true });
const stateDbLocation = await detectStateDb(codexHome);
const sqliteCounts = await readSqliteProviderCounts(codexHome);
const sqliteRepairStats = sqliteCounts && !sqliteCounts.unreadable
? await readSqliteRepairStats(codexHome, { userEventThreadIds, threadCwdById })
: null;
const projectThreadVisibility = sqliteCounts?.unreadable
? []
: await readProjectThreadVisibility(codexHome);
const backupSummary = await getBackupSummary(codexHome);
return {
codexHome,
currentProvider: current.provider,
currentProviderImplicit: current.implicit,
configuredProviders,
rolloutCounts: summarizeProviderCounts(providerCounts),
lockedRolloutFiles: lockedPaths,
encryptedContentCounts,
encryptedContentWarning: buildEncryptedContentWarning(encryptedContentCounts, current.provider ?? DEFAULT_PROVIDER),
sqliteCounts,
stateDbLocation,
sqliteRepairStats,
projectThreadVisibility,
backupRoot: defaultBackupRoot(codexHome),
backupSummary
};
}
export function renderStatus(status) {
const lines = [
`Codex home: ${status.codexHome}`,
`Current provider: ${status.currentProvider}${status.currentProviderImplicit ? " (implicit default)" : ""}`,
`Configured providers: ${status.configuredProviders.join(", ")}`,
`Backups: ${status.backupSummary.count} (${formatBytes(status.backupSummary.totalBytes)})`,
`Backup root: ${status.backupRoot}`
];
lines.push("");
lines.push("Rollout files:");
lines.push(` sessions: ${formatCounts(status.rolloutCounts.sessions)}`);
lines.push(` archived_sessions: ${formatCounts(status.rolloutCounts.archived_sessions)}`);
if (status.encryptedContentCounts) {
lines.push(` encrypted_content sessions: ${formatCounts(status.encryptedContentCounts.sessions)}`);
lines.push(` encrypted_content archived_sessions: ${formatCounts(status.encryptedContentCounts.archived_sessions)}`);
}
if (status.encryptedContentWarning) {
lines.push(` ${status.encryptedContentWarning}`);
}
if (status.lockedRolloutFiles?.length) {
lines.push(` Locked rollout files skipped during status scan: ${status.lockedRolloutFiles.length}`);
}
lines.push("");
lines.push("SQLite state:");
if (status.stateDbLocation) {
const legacyNote = status.stateDbLocation.source === "legacy-root" ? " (legacy root)" : "";
lines.push(` database: ${status.stateDbLocation.path}${legacyNote}`);
} else {
lines.push(" database: not found (checked sqlite/state_5.sqlite, state_5.sqlite)");
}
if (status.sqliteCounts?.unreadable) {
lines.push(` ${status.sqliteCounts.error ?? "state_5.sqlite is malformed or unreadable"}`);
} else if (!status.sqliteCounts) {
lines.push(" state_5.sqlite not found");
} else {
lines.push(` sessions: ${formatCounts(status.sqliteCounts.sessions)}`);
lines.push(` archived_sessions: ${formatCounts(status.sqliteCounts.archived_sessions)}`);
if (status.sqliteRepairStats?.userEventRowsNeedingRepair) {
lines.push(` user-event flags needing repair: ${status.sqliteRepairStats.userEventRowsNeedingRepair}`);
}
if (status.sqliteRepairStats?.cwdRowsNeedingRepair) {
lines.push(` cwd paths needing repair: ${status.sqliteRepairStats.cwdRowsNeedingRepair}`);
}
}
if (status.projectThreadVisibility?.length) {
lines.push("");
lines.push("Project visibility:");
for (const project of status.projectThreadVisibility) {
const providers = formatCounts(project.providerCounts);
const rankText = project.rankPreview || "(none)";
lines.push(
` ${project.root}: interactive ${project.interactiveThreads}, first page ${project.firstPageThreads}/50, ranks ${rankText}, exact cwd ${project.exactCwdMatches}/${project.interactiveThreads}, verbatim cwd ${project.verbatimCwdRows}, providers ${providers}`
);
}
}
return lines.join("\n");
}
export async function runSync({
codexHome: explicitCodexHome,
provider,
configBackupText,
keepCount = DEFAULT_BACKUP_RETENTION_COUNT,
sqliteBusyTimeoutMs,
onProgress
} = {}) {
if (!Number.isInteger(keepCount) || keepCount < 1) {
throw new Error(`Invalid automatic keep count: ${keepCount}. Expected an integer greater than or equal to 1.`);
}
const codexHome = normalizeCodexHome(explicitCodexHome);
await ensureCodexHome(codexHome);
const configPath = path.join(codexHome, "config.toml");
const configText = await readConfigText(configPath);
const current = readCurrentProviderFromConfigText(configText);
const targetProvider = provider ?? current.provider ?? DEFAULT_PROVIDER;
const releaseLock = await acquireLock(codexHome, "sync");
let backupDir = null;
let backupDurationMs = 0;
try {
emitProgress(onProgress, { stage: "scan_rollout_files", status: "start" });
const {
changes,
lockedPaths: lockedReadPaths,
providerCounts,
encryptedContentCounts,
userEventThreadIds,
threadCwdById
} = await collectSessionChanges(codexHome, targetProvider, { skipLockedReads: true });
const cwdStats = await readThreadCwdStats(codexHome);
const encryptedContentWarning = buildEncryptedContentWarning(encryptedContentCounts, targetProvider);
emitProgress(onProgress, {
stage: "scan_rollout_files",
status: "complete",
scannedChanges: changes.length,
lockedReadCount: lockedReadPaths.length
});
emitProgress(onProgress, { stage: "check_locked_rollout_files", status: "start" });
const {
writableChanges,
lockedChanges
} = await splitLockedSessionChanges(changes);
emitProgress(onProgress, {
stage: "check_locked_rollout_files",
status: "complete",
writableCount: writableChanges.length,
lockedCount: lockedChanges.length + lockedReadPaths.length
});
const skippedRolloutFiles = [...new Set([
...lockedReadPaths,
...lockedChanges.map((change) => change.path)
])].sort((left, right) => left.localeCompare(right));
await assertSqliteWritable(codexHome, { busyTimeoutMs: sqliteBusyTimeoutMs });
emitProgress(onProgress, {
stage: "create_backup",
status: "start",
writableCount: writableChanges.length
});
const backupStartedAt = Date.now();
backupDir = await createBackup({
codexHome,
targetProvider,
sessionChanges: writableChanges,
configPath,
configBackupText
});
backupDurationMs = Date.now() - backupStartedAt;
emitProgress(onProgress, {
stage: "create_backup",
status: "complete",
backupDir,
durationMs: backupDurationMs
});
let sessionRestoreNeeded = false;
let appliedSessionChanges = [];
let globalStateRestoreNeeded = false;
let workspaceRootResult = {
updated: false,
updatedWorkspaceRoots: 0,
savedWorkspaceRootCount: 0
};
try {
let applyResult = { appliedChanges: 0, appliedPaths: [], skippedPaths: [] };
emitProgress(onProgress, { stage: "update_sqlite", status: "start" });
emitProgress(onProgress, {
stage: "rewrite_rollout_files",
status: "start",
writableCount: writableChanges.length
});
const sqliteResult = await updateSqliteProvider(
codexHome,
targetProvider,
async () => {
if (writableChanges.length > 0) {
applyResult = await applySessionChanges(writableChanges);
const appliedPathSet = new Set(applyResult.appliedPaths ?? []);
appliedSessionChanges = writableChanges.filter((change) => appliedPathSet.has(change.path));
sessionRestoreNeeded = appliedSessionChanges.length > 0;
await updateSessionBackupManifest(backupDir, appliedSessionChanges);
}
workspaceRootResult = await syncWorkspaceRoots(codexHome, { cwdStats });
globalStateRestoreNeeded = workspaceRootResult.updated;
},
{ busyTimeoutMs: sqliteBusyTimeoutMs, userEventThreadIds, threadCwdById }
);
emitProgress(onProgress, {
stage: "rewrite_rollout_files",
status: "complete",
appliedChanges: applyResult.appliedChanges,
skippedChanges: applyResult.skippedPaths.length
});
emitProgress(onProgress, {
stage: "update_sqlite",
status: "complete",
updatedRows: sqliteResult.updatedRows
});
const skippedLockedRolloutFiles = [...new Set([
...skippedRolloutFiles,
...applyResult.skippedPaths
])].sort((left, right) => left.localeCompare(right));
let autoPruneResult = null;
let autoPruneWarning = null;
emitProgress(onProgress, {
stage: "clean_backups",
status: "start",
keepCount
});
try {
autoPruneResult = await pruneBackups(codexHome, keepCount);
} catch (pruneError) {
autoPruneWarning = `Automatic backup cleanup failed: ${pruneError instanceof Error ? pruneError.message : String(pruneError)}`;
}
emitProgress(onProgress, {
stage: "clean_backups",
status: "complete",
deletedCount: autoPruneResult?.deletedCount ?? 0,
warning: autoPruneWarning
});
return {
codexHome,
targetProvider,
previousProvider: current.provider,
backupDir,
backupDurationMs,
changedSessionFiles: applyResult.appliedChanges,
skippedLockedRolloutFiles,
sqliteRowsUpdated: sqliteResult.updatedRows,
sqliteProviderRowsUpdated: sqliteResult.providerRowsUpdated,
sqliteUserEventRowsUpdated: sqliteResult.userEventRowsUpdated,
sqliteCwdRowsUpdated: sqliteResult.cwdRowsUpdated,
updatedWorkspaceRoots: workspaceRootResult.updatedWorkspaceRoots,
savedWorkspaceRootCount: workspaceRootResult.savedWorkspaceRootCount,
sqlitePresent: sqliteResult.databasePresent,
rolloutCountsBefore: summarizeProviderCounts(providerCounts),
encryptedContentCounts,
encryptedContentWarning,
autoPruneResult,
autoPruneWarning
};
} catch (error) {
const restoreFailures = [];
if (sessionRestoreNeeded) {
try {
await restoreSessionChanges(appliedSessionChanges.map((change) => ({
path: change.path,
originalFirstLine: change.originalFirstLine,
originalSeparator: change.originalSeparator
})));
} catch (restoreError) {
restoreFailures.push(`rollout files: ${restoreError.message}`);
}
}
if (globalStateRestoreNeeded && backupDir) {
try {
await restoreGlobalStateFilesFromBackup(backupDir, codexHome);
} catch (restoreError) {
restoreFailures.push(`global state: ${restoreError.message}`);
}
}
if (restoreFailures.length > 0) {
throw new Error(
`Failed to restore state after sync error. Original error: ${error.message}. Restore error: ${restoreFailures.join("; ")}`
);
}
throw error;
}
} finally {
await releaseLock();
}
}
export async function runSwitch({
codexHome: explicitCodexHome,
provider,
keepCount = DEFAULT_BACKUP_RETENTION_COUNT,
onProgress
}) {
if (!provider) {
throw new Error("Missing provider id. Usage: codex-provider switch <provider-id>");
}
const codexHome = normalizeCodexHome(explicitCodexHome);
await ensureCodexHome(codexHome);
const configPath = path.join(codexHome, "config.toml");
const originalConfigText = await readConfigText(configPath);
if (!configDeclaresProvider(originalConfigText, provider)) {
throw new Error(`Provider "${provider}" is not available in config.toml. Configure it first or use one of: ${listConfiguredProviderIds(originalConfigText).join(", ")}`);
}
const nextConfigText = setRootProviderInConfigText(originalConfigText, provider);
emitProgress(onProgress, {
stage: "update_config",
status: "start",
provider
});
await writeConfigText(configPath, nextConfigText);
emitProgress(onProgress, {
stage: "update_config",
status: "complete",
provider
});
try {
const syncResult = await runSync({
codexHome,
provider,
configBackupText: originalConfigText,
keepCount,
onProgress
});
return {
...syncResult,
configUpdated: true
};
} catch (error) {
await writeConfigText(configPath, originalConfigText);
throw error;
}
}
export async function runRestore({
codexHome: explicitCodexHome,
backupDir,
restoreConfig = true,
restoreDatabase = true,
restoreSessions = true
}) {
if (!backupDir) {
throw new Error("Missing backup path. Usage: codex-provider restore <backup-dir>");
}
const codexHome = normalizeCodexHome(explicitCodexHome);
await ensureCodexHome(codexHome);
const releaseLock = await acquireLock(codexHome, "restore");
try {
return await restoreBackup(path.resolve(backupDir), codexHome, {
restoreConfig,
restoreDatabase,
restoreSessions
});
} finally {
await releaseLock();
}
}
export async function runPruneBackups({
codexHome: explicitCodexHome,
keepCount = DEFAULT_BACKUP_RETENTION_COUNT
} = {}) {
if (!Number.isInteger(keepCount) || keepCount < 0) {
throw new Error(`Invalid keep count: ${keepCount}. Expected a non-negative integer.`);
}
const codexHome = normalizeCodexHome(explicitCodexHome);
await ensureCodexHome(codexHome);
const releaseLock = await acquireLock(codexHome, "prune-backups");
try {
return await pruneBackups(codexHome, keepCount);
} finally {
await releaseLock();
}
}
+833
View File
@@ -0,0 +1,833 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
import { SESSION_DIRS } from "./constants.js";
const execFileAsync = promisify(execFile);
const ROLLOUT_SCAN_CHUNK_BYTES = 1024 * 1024;
function isRolloutFileBusyError(error) {
const message = `${error?.code ?? ""} ${error?.message ?? ""}`.toLowerCase();
return message.includes("ebusy")
|| message.includes("resource busy or locked")
|| message.includes("being used by another process")
|| message.includes("currently in use")
|| message.includes("eperm");
}
function wrapRolloutFileBusyError(error, filePath, action) {
if (!isRolloutFileBusyError(error)) {
return error;
}
return new Error(
`Unable to ${action} rollout file because it is currently in use. Close Codex and the Codex app, then retry. Locked file: ${filePath}`
);
}
async function getFileSnapshot(filePath) {
const stat = await fsp.stat(filePath);
return {
size: stat.size,
mtimeMs: stat.mtimeMs
};
}
function snapshotMatches(change, snapshot) {
return change.originalSize === snapshot.size
&& change.originalMtimeMs === snapshot.mtimeMs;
}
function emptyEncryptedContentCounts() {
return {
sessions: {},
archived_sessions: {}
};
}
function incrementPlainCount(counts, directory, provider) {
counts[directory][provider] = (counts[directory][provider] ?? 0) + 1;
}
function streamContainsText(filePath, text, startOffset) {
const needle = Buffer.from(text);
const safeStartOffset = Math.max(0, startOffset ?? 0);
return new Promise((resolve, reject) => {
let previous = Buffer.alloc(0);
let settled = false;
const stream = fs.createReadStream(filePath, {
start: safeStartOffset,
highWaterMark: ROLLOUT_SCAN_CHUNK_BYTES
});
function settle(value, error) {
if (settled) {
return;
}
settled = true;
if (error) {
reject(wrapRolloutFileBusyError(error, filePath, "scan"));
return;
}
resolve(value);
}
stream.on("data", (chunk) => {
const buffer = previous.length ? Buffer.concat([previous, chunk]) : chunk;
if (buffer.indexOf(needle) !== -1) {
settle(true);
stream.destroy();
return;
}
const keepBytes = Math.max(0, needle.length - 1);
previous = keepBytes > 0
? buffer.subarray(Math.max(0, buffer.length - keepBytes))
: Buffer.alloc(0);
});
stream.on("end", () => settle(false));
stream.on("error", (error) => {
if (settled) {
return;
}
settle(false, error);
});
});
}
async function fileHasEncryptedContent(filePath, firstLine, startOffset) {
if (firstLine.includes("encrypted_content")) {
return true;
}
return streamContainsText(filePath, "encrypted_content", startOffset);
}
function recordHasUserEvent(record) {
if (!record || typeof record !== "object") {
return false;
}
if (record.type === "event_msg" && record.payload?.type === "user_message") {
return true;
}
for (const key of ["payload", "item", "msg"]) {
const value = record[key];
if (value?.type === "message" && value.role === "user") {
return true;
}
}
return false;
}
function toDesktopWorkspacePath(value) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return value;
}
const extendedUnc = trimmed.match(/^\\\\\?\\UNC\\(.+)$/i);
if (extendedUnc) {
return `\\\\${extendedUnc[1]}`.replace(/\//g, "\\");
}
const extendedDrive = trimmed.match(/^\\\\\?\\([A-Za-z]:)(?:[\\/](.*))?$/);
if (extendedDrive) {
const [, drive, rest] = extendedDrive;
return rest && rest.length > 0
? `${drive}\\${rest.replace(/\//g, "\\")}`
: `${drive}\\`;
}
if (trimmed.startsWith("\\\\?\\")) {
return trimmed.slice(4).replace(/\//g, "\\");
}
return value;
}
async function fileHasUserEvent(filePath, firstLine, startOffset) {
try {
if (recordHasUserEvent(JSON.parse(firstLine))) {
return true;
}
} catch {
// Keep scanning the rest of the rollout below.
}
const stream = fs.createReadStream(filePath, {
encoding: "utf8",
start: Math.max(0, startOffset ?? 0),
highWaterMark: ROLLOUT_SCAN_CHUNK_BYTES
});
const lines = readline.createInterface({
input: stream,
crlfDelay: Infinity
});
try {
for await (const line of lines) {
if (!line) {
continue;
}
try {
if (recordHasUserEvent(JSON.parse(line))) {
return true;
}
} catch {
// Ignore malformed non-metadata lines; provider sync only needs positive evidence.
}
}
return false;
} catch (error) {
throw wrapRolloutFileBusyError(error, filePath, "scan");
} finally {
lines.close();
stream.destroy();
}
}
async function listJsonlFiles(rootDir) {
const entries = await fsp.readdir(rootDir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listJsonlFiles(fullPath)));
continue;
}
if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
files.push(fullPath);
}
}
return files;
}
async function readFirstLineRecord(filePath) {
let handle;
try {
handle = await fsp.open(filePath, "r");
let position = 0;
let collected = Buffer.alloc(0);
while (true) {
const chunk = Buffer.alloc(64 * 1024);
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
if (bytesRead === 0) {
break;
}
position += bytesRead;
collected = Buffer.concat([collected, chunk.subarray(0, bytesRead)]);
const newlineIndex = collected.indexOf(0x0a);
if (newlineIndex !== -1) {
const crlf = newlineIndex > 0 && collected[newlineIndex - 1] === 0x0d;
const lineBuffer = crlf ? collected.subarray(0, newlineIndex - 1) : collected.subarray(0, newlineIndex);
return {
firstLine: lineBuffer.toString("utf8"),
separator: crlf ? "\r\n" : "\n",
offset: newlineIndex + 1
};
}
}
return {
firstLine: collected.toString("utf8"),
separator: "",
offset: collected.length
};
} catch (error) {
throw wrapRolloutFileBusyError(error, filePath, "read");
} finally {
await handle?.close();
}
}
function parseSessionMetaRecord(firstLine) {
if (!firstLine) {
return null;
}
try {
const parsed = JSON.parse(firstLine);
if (parsed?.type !== "session_meta" || typeof parsed?.payload !== "object" || parsed.payload === null) {
return null;
}
return parsed;
} catch {
return null;
}
}
function isValidWindowsRewriteResult(result) {
return result === "APPLIED" || result === "SKIP_BUSY" || result === "SKIP_CHANGED";
}
function parseWindowsRewriteResults(stdout, changes) {
const trimmed = stdout.trim();
const parsed = trimmed ? JSON.parse(trimmed) : [];
const results = Array.isArray(parsed) ? parsed : [parsed];
if (results.length !== changes.length) {
throw new Error(`Unexpected rewrite result count. Expected ${changes.length}, received ${results.length}.`);
}
return results.map((entry, index) => {
const expectedPath = changes[index].path;
if (entry?.path !== expectedPath || !isValidWindowsRewriteResult(entry?.result)) {
throw new Error(`Unexpected rewrite result for ${expectedPath}: ${JSON.stringify(entry)}`);
}
return entry.result;
});
}
async function restoreOriginalMtime(filePath, mtimeMs) {
if (!Number.isFinite(mtimeMs)) {
return;
}
const mtime = new Date(mtimeMs);
try {
const stat = await fsp.stat(filePath);
await fsp.utimes(filePath, stat.atime, mtime);
} catch {
// Best effort only; rewriting metadata is still the primary operation.
}
}
async function invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatch }) {
if (!changes.length) {
return [];
}
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codex-provider-rewrite-"));
const manifestPath = path.join(tempDir, "changes.json");
const script = `
& {
param([string]$manifestPath)
function Read-FirstLineRecord([System.IO.FileStream]$stream) {
$stream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null
$buffer = New-Object byte[] (64 * 1024)
$collected = New-Object System.IO.MemoryStream
try {
while ($true) {
$bytesRead = $stream.Read($buffer, 0, $buffer.Length)
if ($bytesRead -le 0) {
break
}
$collected.Write($buffer, 0, $bytesRead)
$bytes = $collected.ToArray()
$newlineIndex = [Array]::IndexOf($bytes, [byte]10)
if ($newlineIndex -ge 0) {
$crlf = $newlineIndex -gt 0 -and $bytes[$newlineIndex - 1] -eq [byte]13
$lineLength = if ($crlf) { $newlineIndex - 1 } else { $newlineIndex }
return @{
firstLine = [System.Text.Encoding]::UTF8.GetString($bytes, 0, $lineLength)
offset = $newlineIndex + 1
}
}
}
return @{
firstLine = [System.Text.Encoding]::UTF8.GetString($collected.ToArray())
offset = [int]$collected.Length
}
} finally {
$collected.Dispose()
}
}
function Invoke-RewriteChange($change) {
$path = [string]$change.path
$tmpPath = "$path.provider-sync.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()).tmp"
$encoding = [System.Text.UTF8Encoding]::new($false)
$source = $null
$writer = $null
$tempReader = $null
try {
try {
$source = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
} catch {
if (Test-Path $path) {
return "SKIP_BUSY"
}
return "SKIP_CHANGED"
}
if ([bool]$change.requireOriginalMatch) {
if ($source.Length -ne [int64]$change.originalSize) {
return "SKIP_CHANGED"
}
$record = Read-FirstLineRecord $source
if ($record.firstLine -ne [string]$change.originalFirstLine -or $record.offset -ne [int]$change.originalOffset) {
return "SKIP_CHANGED"
}
$separator = [string]$change.originalSeparator
$sourceOffset = [int64]$change.originalOffset
$headerOnly = $sourceOffset -ge [int64]$change.originalSize
} else {
$record = Read-FirstLineRecord $source
$separator = [string]$change.separator
$sourceOffset = [int64]$record.offset
$headerOnly = $record.offset -ge $source.Length
}
$writer = [System.IO.File]::Open($tmpPath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
$firstLineBytes = $encoding.GetBytes([string]$change.updatedFirstLine)
$writer.Write($firstLineBytes, 0, $firstLineBytes.Length)
if (-not [string]::IsNullOrEmpty($separator)) {
$separatorBytes = $encoding.GetBytes($separator)
$writer.Write($separatorBytes, 0, $separatorBytes.Length)
}
if (-not $headerOnly) {
$source.Seek($sourceOffset, [System.IO.SeekOrigin]::Begin) | Out-Null
$source.CopyTo($writer)
}
$writer.Flush()
$writer.Dispose()
$writer = $null
$tempReader = [System.IO.File]::OpenRead($tmpPath)
$source.SetLength(0)
$source.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null
$tempReader.CopyTo($source)
$source.Flush()
return "APPLIED"
} finally {
if ($tempReader) {
$tempReader.Dispose()
}
if ($writer) {
$writer.Dispose()
}
if ($source) {
$source.Dispose()
}
Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue
}
}
$changes = Get-Content -Raw -Encoding UTF8 -Path $manifestPath | ConvertFrom-Json
if ($null -eq $changes) {
$changes = @()
} elseif ($changes -is [string] -or $changes -isnot [System.Collections.IEnumerable]) {
$changes = @($changes)
} else {
$changes = @($changes)
}
$results = @(foreach ($change in $changes) {
[pscustomobject]@{
path = [string]$change.path
result = Invoke-RewriteChange $change
}
})
$results | ConvertTo-Json -Compress
}
`.trim();
try {
await fsp.writeFile(
manifestPath,
JSON.stringify(changes.map((change) => ({
...change,
requireOriginalMatch
}))),
"utf8"
);
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
manifestPath
], {
maxBuffer: 16 * 1024 * 1024
});
return parseWindowsRewriteResults(stdout, changes);
} catch (error) {
throw wrapRolloutFileBusyError(error, changes[0]?.path, "rewrite");
} finally {
await fsp.rm(tempDir, { recursive: true, force: true });
}
}
async function invokeWindowsExclusiveRewrite(change, options) {
const [result] = await invokeWindowsExclusiveRewriteBatch([change], options);
return result;
}
async function rewriteFirstLine(filePath, nextFirstLine, separator) {
if (process.platform === "win32") {
const result = await invokeWindowsExclusiveRewrite(
{
path: filePath,
separator,
updatedFirstLine: nextFirstLine
},
{ requireOriginalMatch: false }
);
if (result !== "APPLIED") {
throw new Error(
`Unable to rewrite rollout file because it is currently in use. Close Codex and the Codex app, then retry. Locked file: ${filePath}`
);
}
return;
}
const current = await readFirstLineRecord(filePath);
const tmpPath = `${filePath}.provider-sync.${process.pid}.${Date.now()}.tmp`;
const writer = fs.createWriteStream(tmpPath, { encoding: "utf8" });
try {
await new Promise((resolve, reject) => {
writer.on("error", reject);
writer.write(nextFirstLine);
if (separator) {
writer.write(separator);
}
const headerOnly =
current.separator === "" &&
current.offset === Buffer.byteLength(current.firstLine, "utf8");
if (headerOnly) {
writer.end();
writer.once("finish", resolve);
return;
}
const reader = fs.createReadStream(filePath, { start: current.offset });
reader.on("error", reject);
reader.on("end", () => writer.end());
writer.once("finish", resolve);
reader.pipe(writer, { end: false });
});
await fsp.rename(tmpPath, filePath);
} catch (error) {
await fsp.rm(tmpPath, { force: true });
throw wrapRolloutFileBusyError(error, filePath, "rewrite");
}
}
async function tryRewriteCollectedFirstLine(change) {
const beforeSnapshot = await getFileSnapshot(change.path);
if (!snapshotMatches(change, beforeSnapshot)) {
return false;
}
const current = await readFirstLineRecord(change.path);
if (current.firstLine !== change.originalFirstLine || current.offset !== change.originalOffset) {
return false;
}
const tmpPath = `${change.path}.provider-sync.${process.pid}.${Date.now()}.tmp`;
const writer = fs.createWriteStream(tmpPath, { encoding: "utf8" });
try {
await new Promise((resolve, reject) => {
writer.on("error", reject);
writer.write(change.updatedFirstLine);
if (change.originalSeparator) {
writer.write(change.originalSeparator);
}
const headerOnly = change.originalOffset >= change.originalSize;
if (headerOnly) {
writer.end();
writer.once("finish", resolve);
return;
}
const reader = fs.createReadStream(change.path, { start: change.originalOffset });
reader.on("error", reject);
reader.on("end", () => writer.end());
writer.once("finish", resolve);
reader.pipe(writer, { end: false });
});
const afterSnapshot = await getFileSnapshot(change.path);
if (!snapshotMatches(change, afterSnapshot)) {
await fsp.rm(tmpPath, { force: true });
return false;
}
await fsp.rename(tmpPath, change.path);
return true;
} catch (error) {
await fsp.rm(tmpPath, { force: true });
throw wrapRolloutFileBusyError(error, change.path, "rewrite");
}
}
async function findLockedFilesOnWindows(filePaths) {
if (!filePaths.length) {
return [];
}
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codex-provider-locks-"));
const manifestPath = path.join(tempDir, "paths.json");
const script = `
& {
param([string]$manifestPath)
$paths = Get-Content -Raw -Encoding UTF8 -Path $manifestPath | ConvertFrom-Json
foreach ($path in $paths) {
try {
$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$stream.Close()
} catch {
Write-Output $path
}
}
}
`.trim();
try {
await fsp.writeFile(manifestPath, JSON.stringify(filePaths), "utf8");
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
manifestPath
]);
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
} catch (error) {
throw new Error(`Unable to verify rollout file locks on Windows. ${error.message}`);
} finally {
await fsp.rm(tempDir, { recursive: true, force: true });
}
}
export async function collectSessionChanges(codexHome, targetProvider, options = {}) {
const {
skipLockedReads = false
} = options;
const summaries = [];
const lockedPaths = [];
const providerCounts = {
sessions: new Map(),
archived_sessions: new Map()
};
const encryptedContentCounts = emptyEncryptedContentCounts();
const userEventThreadIds = new Set();
const threadCwdById = new Map();
for (const dirName of SESSION_DIRS) {
const rootDir = path.join(codexHome, dirName);
try {
await fsp.access(rootDir);
} catch {
continue;
}
const rolloutPaths = await listJsonlFiles(rootDir);
for (const rolloutPath of rolloutPaths) {
let record;
try {
record = await readFirstLineRecord(rolloutPath);
} catch (error) {
if (skipLockedReads && isRolloutFileBusyError(error)) {
lockedPaths.push(rolloutPath);
continue;
}
throw error;
}
const parsed = parseSessionMetaRecord(record.firstLine);
if (!parsed) {
continue;
}
const currentProvider = parsed.payload.model_provider ?? "(missing)";
providerCounts[dirName].set(currentProvider, (providerCounts[dirName].get(currentProvider) ?? 0) + 1);
if (typeof parsed.payload.id === "string"
&& parsed.payload.id
&& typeof parsed.payload.cwd === "string"
&& parsed.payload.cwd.trim()) {
threadCwdById.set(parsed.payload.id, toDesktopWorkspacePath(parsed.payload.cwd));
}
try {
if (await fileHasEncryptedContent(rolloutPath, record.firstLine, record.offset)) {
incrementPlainCount(encryptedContentCounts, dirName, currentProvider);
}
if (parsed.payload.id && await fileHasUserEvent(rolloutPath, record.firstLine, record.offset)) {
userEventThreadIds.add(parsed.payload.id);
}
} catch (error) {
if (skipLockedReads && isRolloutFileBusyError(error)) {
lockedPaths.push(rolloutPath);
continue;
}
throw error;
}
if (targetProvider !== "__status_only__" && parsed.payload.model_provider !== targetProvider) {
const snapshot = await getFileSnapshot(rolloutPath);
parsed.payload.model_provider = targetProvider;
summaries.push({
path: rolloutPath,
threadId: parsed.payload.id ?? null,
directory: dirName,
originalFirstLine: record.firstLine,
originalSeparator: record.separator,
originalOffset: record.offset,
originalSize: snapshot.size,
originalMtimeMs: snapshot.mtimeMs,
originalProvider: currentProvider,
updatedFirstLine: JSON.stringify(parsed)
});
}
}
}
return { changes: summaries, lockedPaths, providerCounts, encryptedContentCounts, userEventThreadIds, threadCwdById };
}
export async function applySessionChanges(changes) {
const normalizedChanges = changes ?? [];
const skippedPaths = [];
const appliedPaths = [];
let appliedChanges = 0;
if (process.platform === "win32") {
const results = await invokeWindowsExclusiveRewriteBatch(normalizedChanges, { requireOriginalMatch: true });
for (let index = 0; index < normalizedChanges.length; index += 1) {
if (results[index] === "APPLIED") {
appliedChanges += 1;
appliedPaths.push(normalizedChanges[index].path);
await restoreOriginalMtime(normalizedChanges[index].path, normalizedChanges[index].originalMtimeMs);
} else {
skippedPaths.push(normalizedChanges[index].path);
}
}
} else {
for (const change of normalizedChanges) {
if (await tryRewriteCollectedFirstLine(change)) {
appliedChanges += 1;
appliedPaths.push(change.path);
await restoreOriginalMtime(change.path, change.originalMtimeMs);
} else {
skippedPaths.push(change.path);
}
}
}
appliedPaths.sort((left, right) => left.localeCompare(right));
skippedPaths.sort((left, right) => left.localeCompare(right));
return {
appliedChanges,
appliedPaths,
skippedPaths
};
}
export async function assertSessionFilesWritable(changes) {
if (!changes?.length || process.platform !== "win32") {
return;
}
const lockedPaths = await findLockedFilesOnWindows(changes.map((change) => change.path));
if (lockedPaths.length === 0) {
return;
}
const preview = lockedPaths.slice(0, 5).join(", ");
const extraCount = lockedPaths.length - Math.min(lockedPaths.length, 5);
const suffix = extraCount > 0 ? ` (+${extraCount} more)` : "";
throw new Error(
`Unable to rewrite rollout files because ${lockedPaths.length} file(s) are currently in use. Close Codex and the Codex app, then retry. Locked file(s): ${preview}${suffix}`
);
}
export async function splitLockedSessionChanges(changes) {
if (!changes?.length || process.platform !== "win32") {
return {
writableChanges: changes ?? [],
lockedChanges: []
};
}
const lockedPaths = new Set(await findLockedFilesOnWindows(changes.map((change) => change.path)));
if (lockedPaths.size === 0) {
return {
writableChanges: changes,
lockedChanges: []
};
}
const writableChanges = [];
const lockedChanges = [];
for (const change of changes) {
if (lockedPaths.has(change.path)) {
lockedChanges.push(change);
} else {
writableChanges.push(change);
}
}
return {
writableChanges,
lockedChanges
};
}
export async function restoreSessionChanges(manifestEntries) {
if (!manifestEntries?.length) {
return;
}
if (process.platform === "win32") {
const changes = manifestEntries.map((entry) => ({
path: entry.path,
separator: entry.originalSeparator ?? "\n",
updatedFirstLine: entry.originalFirstLine,
originalMtimeMs: entry.originalMtimeMs
}));
const results = await invokeWindowsExclusiveRewriteBatch(changes, { requireOriginalMatch: false });
const firstFailureIndex = results.findIndex((result) => result !== "APPLIED");
if (firstFailureIndex !== -1) {
const filePath = changes[firstFailureIndex].path;
throw new Error(
`Unable to rewrite rollout file because it is currently in use. Close Codex and the Codex app, then retry. Locked file: ${filePath}`
);
}
for (const change of changes) {
await restoreOriginalMtime(change.path, change.originalMtimeMs);
}
return;
}
for (const entry of manifestEntries) {
await rewriteFirstLine(entry.path, entry.originalFirstLine, entry.originalSeparator ?? "\n");
await restoreOriginalMtime(entry.path, entry.originalMtimeMs);
}
}
export function summarizeProviderCounts(providerCounts) {
const result = {};
for (const [scope, counts] of Object.entries(providerCounts)) {
result[scope] = Object.fromEntries([...counts.entries()].sort(([left], [right]) => left.localeCompare(right)));
}
return result;
}
+431
View File
@@ -0,0 +1,431 @@
import fs from "node:fs/promises";
import path from "node:path";
import { DB_FILE_BASENAME, SESSION_DIRS, SQLITE_DIR_BASENAME } from "./constants.js";
import { openDatabase } from "./sqlite.js";
const DEFAULT_BUSY_TIMEOUT_MS = 5000;
export function stateDbPath(codexHome) {
return path.join(codexHome, SQLITE_DIR_BASENAME, DB_FILE_BASENAME);
}
export function legacyStateDbPath(codexHome) {
return path.join(codexHome, DB_FILE_BASENAME);
}
export function stateDbCandidates(codexHome) {
return [
{
path: stateDbPath(codexHome),
relativePath: path.join(SQLITE_DIR_BASENAME, DB_FILE_BASENAME),
source: "sqlite-dir"
},
{
path: legacyStateDbPath(codexHome),
relativePath: DB_FILE_BASENAME,
source: "legacy-root"
}
];
}
async function countRolloutFilesInDir(rootDir) {
let entries;
try {
entries = await fs.readdir(rootDir, { withFileTypes: true });
} catch {
return 0;
}
let count = 0;
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
count += await countRolloutFilesInDir(fullPath);
continue;
}
if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
count += 1;
}
}
return count;
}
async function countRolloutFiles(codexHome) {
let count = 0;
for (const dirname of SESSION_DIRS) {
count += await countRolloutFilesInDir(path.join(codexHome, dirname));
}
return count;
}
function tableExists(db, tableName) {
return Boolean(db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName));
}
function maxThreadTimestampMs(db) {
if (tableHasColumn(db, "threads", "updated_at_ms")) {
return Number(db.prepare("SELECT COALESCE(MAX(updated_at_ms), 0) AS value FROM threads").get().value) || 0;
}
if (tableHasColumn(db, "threads", "updated_at")) {
return (Number(db.prepare("SELECT COALESCE(MAX(updated_at), 0) AS value FROM threads").get().value) || 0) * 1000;
}
if (tableHasColumn(db, "threads", "created_at_ms")) {
return Number(db.prepare("SELECT COALESCE(MAX(created_at_ms), 0) AS value FROM threads").get().value) || 0;
}
if (tableHasColumn(db, "threads", "created_at")) {
return (Number(db.prepare("SELECT COALESCE(MAX(created_at), 0) AS value FROM threads").get().value) || 0) * 1000;
}
return 0;
}
async function readStateDbCandidateStats(candidate, priority) {
let db;
try {
db = await openDatabase(candidate.path, { readOnly: true });
if (!tableExists(db, "threads")) {
throw new Error("threads table not found");
}
const threadCount = Number(db.prepare("SELECT COUNT(*) AS count FROM threads").get().count) || 0;
return {
candidate,
priority,
threadCount,
maxThreadTimestampMs: maxThreadTimestampMs(db),
mtimeMs: (await fs.stat(candidate.path)).mtimeMs
};
} finally {
db?.close();
}
}
function compareStateDbCandidateStats(a, b) {
if (a.rolloutDistance !== b.rolloutDistance) {
return a.rolloutDistance - b.rolloutDistance;
}
if (a.threadCount !== b.threadCount) {
return b.threadCount - a.threadCount;
}
if (a.maxThreadTimestampMs !== b.maxThreadTimestampMs) {
return b.maxThreadTimestampMs - a.maxThreadTimestampMs;
}
if (a.mtimeMs !== b.mtimeMs) {
return b.mtimeMs - a.mtimeMs;
}
return a.priority - b.priority;
}
export async function detectStateDb(codexHome) {
const existingCandidates = [];
const candidates = stateDbCandidates(codexHome);
for (const [priority, candidate] of candidates.entries()) {
try {
await fs.access(candidate.path);
existingCandidates.push({ candidate, priority });
} catch {
// Try the next known Codex state DB location.
}
}
if (existingCandidates.length === 0) {
return null;
}
const rolloutCount = await countRolloutFiles(codexHome);
const readableCandidates = [];
for (const { candidate, priority } of existingCandidates) {
try {
const stats = await readStateDbCandidateStats(candidate, priority);
readableCandidates.push({
...stats,
rolloutDistance: rolloutCount > 0 ? Math.abs(stats.threadCount - rolloutCount) : 0
});
} catch {
// Keep unreadable candidates as a fallback so existing status/error
// handling still points at state_5.sqlite when no usable DB exists.
}
}
if (readableCandidates.length === 0) {
return existingCandidates[0].candidate;
}
return readableCandidates.sort(compareStateDbCandidateStats)[0].candidate;
}
export async function existingStateDbPath(codexHome) {
return (await detectStateDb(codexHome))?.path ?? null;
}
function tableHasColumn(db, tableName, columnName) {
return db
.prepare(`PRAGMA table_info(${JSON.stringify(tableName)})`)
.all()
.some((column) => column.name === columnName);
}
function normalizeBusyTimeoutMs(busyTimeoutMs) {
return Number.isInteger(busyTimeoutMs) && busyTimeoutMs >= 0
? busyTimeoutMs
: DEFAULT_BUSY_TIMEOUT_MS;
}
function setBusyTimeout(db, busyTimeoutMs) {
db.exec(`PRAGMA busy_timeout = ${normalizeBusyTimeoutMs(busyTimeoutMs)}`);
}
function isSqliteBusyError(error) {
const message = `${error?.code ?? ""} ${error?.message ?? ""}`.toLowerCase();
return message.includes("database is locked") || message.includes("sqlite_busy") || message.includes("busy");
}
function isSqliteMalformedError(error) {
const message = `${error?.code ?? ""} ${error?.message ?? ""}`.toLowerCase();
return message.includes("database disk image is malformed")
|| message.includes("sqlite_corrupt")
|| message.includes("malformed")
|| message.includes("not a database");
}
export function wrapSqliteBusyError(error, action) {
if (!isSqliteBusyError(error)) {
return error;
}
return new Error(
`Unable to ${action} because state_5.sqlite is currently in use. Close Codex and the Codex app, then retry. Original error: ${error.message}`
);
}
export function wrapSqliteMalformedError(error, action) {
if (!isSqliteMalformedError(error)) {
return error;
}
return new Error(
`Unable to ${action} because state_5.sqlite is malformed or unreadable. Close Codex, back up or repair the database, then retry. Original error: ${error.message}`
);
}
export async function readSqliteProviderCounts(codexHome) {
const dbPath = await existingStateDbPath(codexHome);
if (!dbPath) {
return null;
}
let db;
try {
db = await openDatabase(dbPath);
const rows = db.prepare(`
SELECT
CASE
WHEN model_provider IS NULL OR model_provider = '' THEN '(missing)'
ELSE model_provider
END AS model_provider,
archived,
COUNT(*) AS count
FROM threads
GROUP BY model_provider, archived
ORDER BY archived, model_provider
`).all();
const result = {
sessions: {},
archived_sessions: {}
};
for (const row of rows) {
const bucket = row.archived ? result.archived_sessions : result.sessions;
bucket[row.model_provider] = row.count;
}
return result;
} catch (error) {
if (isSqliteMalformedError(error)) {
return {
sessions: {},
archived_sessions: {},
unreadable: true,
error: "state_5.sqlite is malformed or unreadable"
};
}
if (isSqliteBusyError(error)) {
return {
sessions: {},
archived_sessions: {},
unreadable: true,
error: "state_5.sqlite is currently in use"
};
}
throw error;
} finally {
db?.close();
}
}
export async function readSqliteRepairStats(codexHome, options = {}) {
const dbPath = await existingStateDbPath(codexHome);
if (!dbPath) {
return null;
}
let db;
try {
db = await openDatabase(dbPath);
let userEventRowsNeedingRepair = 0;
if (tableHasColumn(db, "threads", "has_user_event") && options.userEventThreadIds?.size) {
const stmt = db.prepare("SELECT has_user_event FROM threads WHERE id = ?");
for (const threadId of options.userEventThreadIds) {
const row = stmt.get(threadId);
if (row && Number(row.has_user_event) !== 1) {
userEventRowsNeedingRepair += 1;
}
}
}
let cwdRowsNeedingRepair = 0;
if (tableHasColumn(db, "threads", "cwd") && options.threadCwdById?.size) {
const stmt = db.prepare("SELECT cwd FROM threads WHERE id = ?");
for (const [threadId, cwd] of options.threadCwdById) {
if (typeof threadId !== "string" || !threadId || typeof cwd !== "string" || !cwd.trim()) {
continue;
}
const row = stmt.get(threadId);
if (row && row.cwd !== cwd) {
cwdRowsNeedingRepair += 1;
}
}
}
return {
userEventRowsNeedingRepair,
cwdRowsNeedingRepair
};
} catch (error) {
throw wrapSqliteMalformedError(
wrapSqliteBusyError(error, "read SQLite repair diagnostics"),
"read SQLite repair diagnostics"
);
} finally {
db?.close();
}
}
export async function assertSqliteWritable(codexHome, options = {}) {
const dbPath = await existingStateDbPath(codexHome);
if (!dbPath) {
return { databasePresent: false };
}
let db;
try {
db = await openDatabase(dbPath);
setBusyTimeout(db, options.busyTimeoutMs);
db.exec("BEGIN IMMEDIATE");
db.exec("ROLLBACK");
return { databasePresent: true };
} catch (error) {
throw wrapSqliteMalformedError(
wrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata"
);
} finally {
db?.close();
}
}
export async function updateSqliteProvider(codexHome, targetProvider, afterUpdateOrOptions, maybeOptions) {
const afterUpdate = typeof afterUpdateOrOptions === "function" ? afterUpdateOrOptions : null;
const options = typeof afterUpdateOrOptions === "function"
? (maybeOptions ?? {})
: (afterUpdateOrOptions ?? {});
const dbPath = await existingStateDbPath(codexHome);
if (!dbPath) {
if (afterUpdate) {
await afterUpdate({
updatedRows: 0,
providerRowsUpdated: 0,
userEventRowsUpdated: 0,
cwdRowsUpdated: 0,
databasePresent: false
});
}
return {
updatedRows: 0,
providerRowsUpdated: 0,
userEventRowsUpdated: 0,
cwdRowsUpdated: 0,
databasePresent: false
};
}
let db;
let transactionOpen = false;
try {
db = await openDatabase(dbPath);
setBusyTimeout(db, options.busyTimeoutMs);
db.exec("BEGIN IMMEDIATE");
transactionOpen = true;
const stmt = db.prepare(`
UPDATE threads
SET model_provider = ?
WHERE COALESCE(model_provider, '') <> ?
`);
const result = stmt.run(targetProvider, targetProvider);
let userEventUpdatedRows = 0;
if (tableHasColumn(db, "threads", "has_user_event") && options.userEventThreadIds?.size) {
const userEventStmt = db.prepare(`
UPDATE threads
SET has_user_event = 1
WHERE id = ? AND COALESCE(has_user_event, 0) <> 1
`);
for (const threadId of options.userEventThreadIds) {
userEventUpdatedRows += userEventStmt.run(threadId).changes ?? 0;
}
}
let cwdUpdatedRows = 0;
if (tableHasColumn(db, "threads", "cwd") && options.threadCwdById?.size) {
const cwdStmt = db.prepare(`
UPDATE threads
SET cwd = ?
WHERE id = ? AND COALESCE(cwd, '') <> ?
`);
for (const [threadId, cwd] of options.threadCwdById) {
if (typeof threadId !== "string" || !threadId || typeof cwd !== "string" || !cwd.trim()) {
continue;
}
cwdUpdatedRows += cwdStmt.run(cwd, threadId, cwd).changes ?? 0;
}
}
const updatedRows = (result.changes ?? 0) + userEventUpdatedRows + cwdUpdatedRows;
if (afterUpdate) {
await afterUpdate({
updatedRows,
providerRowsUpdated: result.changes ?? 0,
userEventRowsUpdated: userEventUpdatedRows,
cwdRowsUpdated: cwdUpdatedRows,
databasePresent: true
});
}
db.exec("COMMIT");
transactionOpen = false;
return {
updatedRows,
providerRowsUpdated: result.changes ?? 0,
userEventRowsUpdated: userEventUpdatedRows,
cwdRowsUpdated: cwdUpdatedRows,
databasePresent: true
};
} catch (error) {
if (transactionOpen) {
try {
db.exec("ROLLBACK");
} catch {
// Ignore rollback failures and surface the original error.
}
}
throw wrapSqliteMalformedError(
wrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata"
);
} finally {
db?.close();
}
}
+58
View File
@@ -0,0 +1,58 @@
let databaseFactoryPromise = null;
function normalizeImportDefault(moduleNamespace) {
return moduleNamespace.default ?? moduleNamespace;
}
class BetterSqliteDatabase {
constructor(Database, dbPath, options = {}) {
this.db = new Database(dbPath, {
readonly: Boolean(options.readOnly)
});
}
prepare(sql) {
return this.db.prepare(sql);
}
exec(sql) {
return this.db.exec(sql);
}
close() {
return this.db.close();
}
}
async function loadDatabaseFactory() {
try {
const sqlite = await import("node:sqlite");
if (sqlite.DatabaseSync) {
return (dbPath, options) => new sqlite.DatabaseSync(dbPath, options);
}
} catch {
// Older Node.js releases do not include node:sqlite.
}
try {
const betterSqlite3 = normalizeImportDefault(await import("better-sqlite3"));
return (dbPath, options) => new BetterSqliteDatabase(betterSqlite3, dbPath, options);
} catch (error) {
throw new Error(
"SQLite support requires Node.js with node:sqlite, or the optional better-sqlite3 dependency on older Node.js. "
+ "For local/link installs, run npm install --include=optional in the package directory. "
+ "For normal installs, reinstall without --omit=optional. "
+ `Original error: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async function getDatabaseFactory() {
databaseFactoryPromise ??= loadDatabaseFactory();
return databaseFactoryPromise;
}
export async function openDatabase(dbPath, options = {}) {
const createDatabase = await getDatabaseFactory();
return createDatabase(dbPath, options);
}
+441
View File
@@ -0,0 +1,441 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
GLOBAL_STATE_BACKUP_FILE_BASENAME,
GLOBAL_STATE_FILE_BASENAME
} from "./constants.js";
import { openDatabase } from "./sqlite.js";
import {
existingStateDbPath,
wrapSqliteBusyError,
wrapSqliteMalformedError
} from "./sqlite-state.js";
export function globalStatePath(codexHome) {
return path.join(codexHome, GLOBAL_STATE_FILE_BASENAME);
}
export function globalStateBackupPath(codexHome) {
return path.join(codexHome, GLOBAL_STATE_BACKUP_FILE_BASENAME);
}
export function normalizeComparablePath(value) {
if (typeof value !== "string") {
return null;
}
let normalized = value.trim();
if (!normalized) {
return null;
}
const extendedUnc = normalized.match(/^\\\\\?\\UNC\\(.+)$/i);
normalized = extendedUnc ? `\\\\${extendedUnc[1]}` : normalized.replace(/^\\\\\?\\/, "");
normalized = normalized.replace(/\//g, "\\");
normalized = normalized.replace(/\\+$/, "");
if (/^[A-Za-z]:$/.test(normalized)) {
normalized += "\\";
}
return normalized.toLowerCase();
}
function tableHasColumn(db, tableName, columnName) {
return db
.prepare(`PRAGMA table_info("${tableName.replaceAll("\"", "\"\"")}")`)
.all()
.some((column) => column.name === columnName);
}
function toPathArray(value) {
if (Array.isArray(value)) {
return value.filter((entry) => typeof entry === "string" && entry.trim());
}
if (typeof value === "string" && value.trim()) {
return [value];
}
return [];
}
function dedupePaths(paths) {
const seen = new Set();
const result = [];
for (const value of paths ?? []) {
const comparable = normalizeComparablePath(value);
if (!comparable || seen.has(comparable)) {
continue;
}
seen.add(comparable);
result.push(value);
}
return result;
}
function arraysEqual(left, right) {
if (left.length !== right.length) {
return false;
}
return left.every((value, index) => value === right[index]);
}
function countArrayChanges(previous, next) {
const compared = Math.max(previous.length, next.length);
let changed = 0;
for (let index = 0; index < compared; index += 1) {
if ((previous[index] ?? null) !== (next[index] ?? null)) {
changed += 1;
}
}
return changed;
}
export function toDesktopWorkspacePath(value) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return value;
}
const extendedUnc = trimmed.match(/^\\\\\?\\UNC\\(.+)$/i);
if (extendedUnc) {
return `\\\\${extendedUnc[1]}`.replace(/\//g, "\\");
}
const extendedDrive = trimmed.match(/^\\\\\?\\([A-Za-z]:)(?:[\\/](.*))?$/);
if (extendedDrive) {
const [, drive, rest] = extendedDrive;
return rest && rest.length > 0
? `${drive}\\${rest.replace(/\//g, "\\")}`
: `${drive}\\`;
}
if (trimmed.startsWith("\\\\?\\")) {
return trimmed.slice(4).replace(/\//g, "\\");
}
return value;
}
function resolveStoredPath(value, cwdStats) {
const comparable = normalizeComparablePath(value);
if (!comparable) {
return value;
}
const matches = (cwdStats ?? []).filter((entry) => entry.normalizedCwd === comparable);
if (matches.length === 0) {
return toDesktopWorkspacePath(value);
}
matches.sort((left, right) => (
(right.count - left.count)
|| (right.updatedAtMs - left.updatedAtMs)
|| left.cwd.localeCompare(right.cwd)
));
return toDesktopWorkspacePath(matches[0].cwd);
}
function readWorkspaceRootsFromGlobalState(state) {
const savedRoots = toPathArray(state["electron-saved-workspace-roots"]);
const projectOrder = toPathArray(state["project-order"]);
const activeRoots = toPathArray(state["active-workspace-roots"]);
return dedupePaths(
(projectOrder.length > 0
? [...projectOrder, ...savedRoots, ...activeRoots]
: [...savedRoots, ...activeRoots]
).map(toDesktopWorkspacePath)
);
}
function copyResolvedObjectKeys(input, cwdStats) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return input;
}
const result = {};
for (const [key, value] of Object.entries(input)) {
const resolved = resolveStoredPath(key, cwdStats);
if (result[resolved] === undefined || resolved === key) {
result[resolved] = value;
}
}
return result;
}
export async function readThreadCwdStats(codexHome) {
const dbPath = await existingStateDbPath(codexHome);
if (!dbPath) {
return [];
}
let db;
try {
db = await openDatabase(dbPath, { readOnly: true });
if (!tableHasColumn(db, "threads", "cwd")) {
return [];
}
const updatedAtExpression = tableHasColumn(db, "threads", "updated_at_ms")
? (tableHasColumn(db, "threads", "updated_at")
? "COALESCE(MAX(updated_at_ms), MAX(updated_at) * 1000, 0)"
: "COALESCE(MAX(updated_at_ms), 0)")
: (tableHasColumn(db, "threads", "updated_at")
? "COALESCE(MAX(updated_at) * 1000, 0)"
: "0");
const rows = db.prepare(`
SELECT
cwd,
COUNT(*) AS count,
${updatedAtExpression} AS updated_at_ms
FROM threads
WHERE cwd IS NOT NULL AND cwd <> ''
GROUP BY cwd
ORDER BY count DESC, updated_at_ms DESC, cwd
`).all();
return rows
.filter((row) => typeof row.cwd === "string" && row.cwd)
.map((row) => ({
cwd: row.cwd,
normalizedCwd: normalizeComparablePath(row.cwd),
count: Number(row.count) || 0,
updatedAtMs: Number(row.updated_at_ms) || 0
}))
.filter((row) => row.normalizedCwd);
} catch (error) {
throw wrapSqliteMalformedError(
wrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata"
);
} finally {
db?.close();
}
}
function buildTimeExpression(columns) {
const expressions = [];
if (columns.has("updated_at_ms")) {
expressions.push("updated_at_ms");
}
if (columns.has("updated_at")) {
expressions.push("updated_at * 1000");
}
if (columns.has("created_at_ms")) {
expressions.push("created_at_ms");
}
if (columns.has("created_at")) {
expressions.push("created_at * 1000");
}
expressions.push("0");
return `COALESCE(${expressions.join(", ")})`;
}
function formatRankPreview(ranks, maxCount = 12) {
const preview = ranks.slice(0, maxCount).join(", ");
const remaining = ranks.length - Math.min(ranks.length, maxCount);
return remaining > 0 ? `${preview} (+${remaining} more)` : preview;
}
export async function readProjectThreadVisibility(codexHome, options = {}) {
const pageSize = Number.isInteger(options.pageSize) && options.pageSize > 0
? options.pageSize
: 50;
const filePath = globalStatePath(codexHome);
let state;
try {
state = JSON.parse(await fs.readFile(filePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") {
return [];
}
throw error;
}
const roots = readWorkspaceRootsFromGlobalState(state);
if (roots.length === 0) {
return [];
}
const dbPath = await existingStateDbPath(codexHome);
if (!dbPath) {
return roots.map((root) => ({
root,
interactiveThreads: 0,
firstPageThreads: 0,
exactCwdMatches: 0,
verbatimCwdRows: 0,
ranks: [],
rankPreview: "",
providerCounts: {}
}));
}
let db;
try {
db = await openDatabase(dbPath, { readOnly: true });
const columns = new Set(db.prepare('PRAGMA table_info("threads")').all().map((column) => column.name));
if (!columns.has("cwd")) {
return [];
}
const sourceFilter = columns.has("source")
? "AND source IN ('cli', 'vscode')"
: "";
const archivedFilter = columns.has("archived")
? "AND archived = 0"
: "";
const firstUserFilter = columns.has("first_user_message")
? "AND first_user_message <> ''"
: "";
const timeExpression = buildTimeExpression(columns);
const providerExpression = columns.has("model_provider")
? "model_provider"
: "'' AS model_provider";
const rows = db.prepare(`
SELECT
id,
cwd,
${providerExpression},
${timeExpression} AS sort_ts
FROM threads
WHERE cwd IS NOT NULL AND cwd <> ''
${archivedFilter}
${firstUserFilter}
${sourceFilter}
ORDER BY sort_ts DESC, id DESC
`).all();
const rankedRows = rows.map((row, index) => ({
...row,
rank: index + 1,
normalizedCwd: normalizeComparablePath(row.cwd),
desktopCwd: toDesktopWorkspacePath(row.cwd)
}));
return roots.map((root) => {
const normalizedRoot = normalizeComparablePath(root);
const exactRoot = toDesktopWorkspacePath(root);
const matchingRows = rankedRows.filter((row) => row.normalizedCwd === normalizedRoot);
const ranks = matchingRows.map((row) => row.rank);
const providerCounts = {};
let exactCwdMatches = 0;
let verbatimCwdRows = 0;
for (const row of matchingRows) {
const provider = row.model_provider || "(missing)";
providerCounts[provider] = (providerCounts[provider] ?? 0) + 1;
if (row.cwd === exactRoot || row.desktopCwd === exactRoot) {
exactCwdMatches += row.cwd === exactRoot ? 1 : 0;
}
if (typeof row.cwd === "string" && row.cwd.startsWith("\\\\?\\")) {
verbatimCwdRows += 1;
}
}
return {
root: exactRoot,
interactiveThreads: matchingRows.length,
firstPageThreads: ranks.filter((rank) => rank <= pageSize).length,
exactCwdMatches,
verbatimCwdRows,
topRank: ranks[0] ?? null,
ranks,
rankPreview: formatRankPreview(ranks),
providerCounts
};
});
} catch (error) {
throw wrapSqliteMalformedError(
wrapSqliteBusyError(error, "read project thread visibility diagnostics"),
"read project thread visibility diagnostics"
);
} finally {
db?.close();
}
}
export async function syncWorkspaceRoots(codexHome, options = {}) {
const filePath = globalStatePath(codexHome);
const backupPath = globalStateBackupPath(codexHome);
let originalText;
try {
originalText = await fs.readFile(filePath, "utf8");
} catch (error) {
if (error?.code === "ENOENT") {
return {
present: false,
updated: false,
updatedWorkspaceRoots: 0,
savedWorkspaceRootCount: 0
};
}
throw error;
}
const state = JSON.parse(originalText);
const cwdStats = options.cwdStats ?? await readThreadCwdStats(codexHome);
const existingSavedRoots = toPathArray(state["electron-saved-workspace-roots"]);
const existingProjectOrder = toPathArray(state["project-order"]);
const existingActiveRoots = toPathArray(state["active-workspace-roots"]);
const nextSavedRoots = dedupePaths(
(existingProjectOrder.length > 0
? [...existingProjectOrder, ...existingSavedRoots, ...existingActiveRoots]
: [...existingSavedRoots, ...existingActiveRoots]
).map((value) => resolveStoredPath(value, cwdStats))
);
const nextProjectOrder = dedupePaths(
(existingProjectOrder.length > 0
? [...existingProjectOrder, ...existingSavedRoots]
: [...nextSavedRoots]
).map((value) => resolveStoredPath(value, cwdStats))
);
const nextActiveRoots = dedupePaths(existingActiveRoots.map((value) => resolveStoredPath(value, cwdStats)));
const nextLabels = copyResolvedObjectKeys(state["electron-workspace-root-labels"], cwdStats);
const nextOpenTargets = (
state["open-in-target-preferences"]
&& typeof state["open-in-target-preferences"] === "object"
&& !Array.isArray(state["open-in-target-preferences"])
)
? {
...state["open-in-target-preferences"],
perPath: copyResolvedObjectKeys(state["open-in-target-preferences"].perPath, cwdStats)
}
: state["open-in-target-preferences"];
const originalActiveValue = state["active-workspace-roots"];
const nextActiveValue = Array.isArray(originalActiveValue)
? nextActiveRoots
: (nextActiveRoots[0] ?? originalActiveValue);
const savedRootsChanged = !arraysEqual(existingSavedRoots, nextSavedRoots);
const projectOrderChanged = !arraysEqual(existingProjectOrder, nextProjectOrder);
const activeRootsChanged = JSON.stringify(originalActiveValue ?? null) !== JSON.stringify(nextActiveValue ?? null);
const labelsChanged = JSON.stringify(state["electron-workspace-root-labels"] ?? null) !== JSON.stringify(nextLabels ?? null);
const openTargetsChanged = JSON.stringify(state["open-in-target-preferences"] ?? null) !== JSON.stringify(nextOpenTargets ?? null);
state["electron-saved-workspace-roots"] = nextSavedRoots;
state["project-order"] = nextProjectOrder;
state["active-workspace-roots"] = nextActiveValue;
if (nextLabels !== undefined) {
state["electron-workspace-root-labels"] = nextLabels;
}
if (nextOpenTargets !== undefined) {
state["open-in-target-preferences"] = nextOpenTargets;
}
const nextText = `${JSON.stringify(state, null, 2)}\n`;
const backupMissing = await fs.access(backupPath).then(() => false).catch(() => true);
const updated = savedRootsChanged || projectOrderChanged || activeRootsChanged || labelsChanged || openTargetsChanged || backupMissing;
if (updated) {
await fs.writeFile(filePath, nextText, "utf8");
await fs.writeFile(backupPath, nextText, "utf8");
}
return {
present: true,
updated,
updatedWorkspaceRoots: countArrayChanges(existingSavedRoots, nextSavedRoots),
savedWorkspaceRootCount: nextSavedRoots.length
};
}
+56
View File
@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
configDeclaresProvider,
listConfiguredProviderIds,
readCurrentProviderFromConfigText,
setRootProviderInConfigText
} from "../src/config-file.js";
test("readCurrentProviderFromConfigText falls back to implicit openai", () => {
const input = `
# comment
sandbox_mode = "danger-full-access"
[features]
apps = true
`;
assert.deepEqual(readCurrentProviderFromConfigText(input), {
provider: "openai",
implicit: true
});
});
test("setRootProviderInConfigText inserts root-level model_provider before first table", () => {
const input = `# comment
sandbox_mode = "danger-full-access"
[features]
apps = true
`;
const next = setRootProviderInConfigText(input, "apigather");
assert.match(next, /^# comment\nsandbox_mode = "danger-full-access"\n\nmodel_provider = "apigather"\n\[features]/);
});
test("setRootProviderInConfigText updates existing root-level model_provider", () => {
const input = `model_provider = "openai"\nsandbox_mode = "danger-full-access"\n`;
const next = setRootProviderInConfigText(input, "newapi");
assert.equal(next, `model_provider = "newapi"\nsandbox_mode = "danger-full-access"\n`);
});
test("provider declarations include openai and custom tables", () => {
const input = `
[model_providers.apigather]
base_url = "https://example.com"
[model_providers.newapi]
base_url = "https://example.org"
`;
assert.deepEqual(listConfiguredProviderIds(input), ["apigather", "newapi", "openai"]);
assert.equal(configDeclaresProvider(input, "apigather"), true);
assert.equal(configDeclaresProvider(input, "missing"), false);
});
+31
View File
@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
installWindowsLauncher,
WINDOWS_CMD_LAUNCHER_FILENAME,
WINDOWS_VBS_LAUNCHER_FILENAME
} from "../src/launcher.js";
test("installWindowsLauncher creates cmd and vbs launchers", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-launcher-"));
const codexHome = "C:\\Users\\Example User\\.codex";
const result = await installWindowsLauncher({ dir, codexHome });
assert.equal(result.targetDir, dir);
assert.equal(result.cmdPath, path.join(dir, WINDOWS_CMD_LAUNCHER_FILENAME));
assert.equal(result.vbsPath, path.join(dir, WINDOWS_VBS_LAUNCHER_FILENAME));
assert.equal(result.codexHome, path.resolve(codexHome));
const cmdText = await fs.readFile(result.cmdPath, "utf8");
const vbsText = await fs.readFile(result.vbsPath, "utf8");
assert.match(cmdText, /codex-provider sync --codex-home "C:\\Users\\Example User\\.codex"/);
assert.match(vbsText, /Synchronization finished\./);
assert.match(vbsText, /Codex Provider Sync/);
assert.match(vbsText, /codex-provider sync --codex-home ""C:\\Users\\Example User\\.codex""/);
});
+90
View File
@@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { acquireLock } from "../src/locking.js";
import { DEFAULT_LOCK_NAME } from "../src/constants.js";
test("acquireLock retries transient EPERM when creating the lock directory", async () => {
const codexHome = "C:\\CodexHome";
const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME);
const calls = [];
const sleepCalls = [];
let lockMkdirAttempts = 0;
const fsImpl = {
async mkdir(targetPath, options) {
calls.push({ fn: "mkdir", targetPath, options });
if (targetPath === lockDir) {
lockMkdirAttempts += 1;
if (lockMkdirAttempts < 3) {
const error = new Error("operation not permitted");
error.code = "EPERM";
throw error;
}
}
},
async writeFile(targetPath, content, encoding) {
calls.push({ fn: "writeFile", targetPath, content, encoding });
},
async rm(targetPath, options) {
calls.push({ fn: "rm", targetPath, options });
}
};
const releaseLock = await acquireLock(codexHome, "sync", {
fsImpl,
retryCount: 3,
retryDelayMs: 25,
sleepImpl: async (delayMs) => {
sleepCalls.push(delayMs);
}
});
assert.equal(lockMkdirAttempts, 3);
assert.deepEqual(sleepCalls, [25, 25]);
assert.equal(calls.filter((call) => call.fn === "writeFile").length, 1);
await releaseLock();
assert.equal(calls.at(-1).fn, "rm");
assert.equal(calls.at(-1).targetPath, lockDir);
});
test("acquireLock does not retry when the lock directory already exists", async () => {
const codexHome = "C:\\CodexHome";
const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME);
let lockMkdirAttempts = 0;
const sleepCalls = [];
const fsImpl = {
async mkdir(targetPath) {
if (targetPath === lockDir) {
lockMkdirAttempts += 1;
const error = new Error("already exists");
error.code = "EEXIST";
throw error;
}
},
async writeFile() {
throw new Error("writeFile should not be called");
},
async rm() {
throw new Error("rm should not be called");
}
};
await assert.rejects(
() => acquireLock(codexHome, "sync", {
fsImpl,
retryCount: 3,
retryDelayMs: 25,
sleepImpl: async (delayMs) => {
sleepCalls.push(delayMs);
}
}),
/Lock already exists/
);
assert.equal(lockMkdirAttempts, 1);
assert.deepEqual(sleepCalls, []);
});
File diff suppressed because it is too large Load Diff