chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 仍可能失败。
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tgz
|
||||
artifacts/
|
||||
desktop/**/bin/
|
||||
desktop/**/obj/
|
||||
*.csproj.user
|
||||
test/*.exe
|
||||
WORKLOG*.md
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,118 @@
|
||||
<div align="center">
|
||||
|
||||
# codex-provider-sync
|
||||
|
||||
### 切换 provider 后,让 Codex 历史会话重新可见
|
||||
|
||||
[](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml)
|
||||
[](https://github.com/Dailin521/codex-provider-sync)
|
||||
[](https://nodejs.org/)
|
||||
[](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
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) + "\"";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<div align="center">
|
||||
|
||||
# codex-provider-sync
|
||||
|
||||
### Keep Codex history visible after switching between providers
|
||||
|
||||
[](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml)
|
||||
[](https://github.com/Dailin521/codex-provider-sync)
|
||||
[](https://nodejs.org/)
|
||||
[](../LICENSE)
|
||||
[](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
|
||||
@@ -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 切换,只负责同步可见性相关元数据
|
||||
@@ -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。
|
||||
@@ -0,0 +1,115 @@
|
||||
<div align="center">
|
||||
|
||||
# codex-provider-sync
|
||||
|
||||
### 切换 provider 后,让 Codex 历史会话重新可见
|
||||
|
||||
[](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml)
|
||||
[](https://github.com/Dailin521/codex-provider-sync)
|
||||
[](https://nodejs.org/)
|
||||
[](../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
|
||||
Generated
+471
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+111
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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""/);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user