commit d03c025041d88626dd96d6c093622ae6bbdba09f Author: wehub-resource-sync Date: Mon Jul 13 12:31:29 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..062549b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5b125a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 的区别和工具能力边界。 diff --git a/.github/ISSUE_TEMPLATE/desktop_visibility.yml b/.github/ISSUE_TEMPLATE/desktop_visibility.yml new file mode 100644 index 0000000..caf1d27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/desktop_visibility.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/support.yml b/.github/ISSUE_TEMPLATE/support.yml new file mode 100644 index 0000000..40167f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f73b424 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a5d3d93 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 ` 恢复本工具创建的备份。 + + ## 已知事项 + 含 encrypted_content 的历史会话跨 provider/account 后,通常只能恢复列表可见性;继续对话或 compact 仍可能失败。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..638c30b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +coverage/ +*.tgz +artifacts/ +desktop/**/bin/ +desktop/**/obj/ +*.csproj.user +test/*.exe +WORKLOG*.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d6dfe53 --- /dev/null +++ b/AGENTS.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 ` 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 ` 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 ` +- `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 ` 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 ` 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 ` 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 diff --git a/CodexProviderSync.sln b/CodexProviderSync.sln new file mode 100644 index 0000000..f89b45e --- /dev/null +++ b/CodexProviderSync.sln @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..367c780 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fe281d --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +
+ +# codex-provider-sync + +### 切换 provider 后,让 Codex 历史会话重新可见 + +[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/Dailin521/codex-provider-sync) +[![Node](https://img.shields.io/badge/node-16%2B-brightgreen.svg)](https://nodejs.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +中文 | [English](docs/README_EN.md) + +
+ +## 解决什么问题 + +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\ +codex-provider prune-backups --keep 5 +``` + +命令含义: + +- `status`:只检查当前 provider、rollout、SQLite、项目可见性诊断。 +- `sync`:不切换登录状态,只把历史会话 metadata 同步到当前 provider。 +- `switch `:修改 `config.toml` 根级 `model_provider`,然后执行同步。 +- `restore `:从备份恢复,支持 `--no-config`、`--no-db`、`--no-sessions`。 +- `prune-backups --keep `:只清理本工具创建的旧备份。 + +## 能力边界 + +本工具只修复“历史会话可见性”相关 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/ +``` + +注意: + +- 如果 `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 diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..578327d --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Dailin521/codex-provider-sync` +- 原始仓库:https://github.com/Dailin521/codex-provider-sync +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj new file mode 100644 index 0000000..2273488 --- /dev/null +++ b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj @@ -0,0 +1,21 @@ + + + + + + + + WinExe + net10.0-windows + enable + true + enable + CodexProviderSync + Codex Provider Sync + Dailin521 + 0.2.9 + 0.2.9.0 + 0.2.9.0 + + + diff --git a/desktop/CodexProviderSync.App/FocusRequestServer.cs b/desktop/CodexProviderSync.App/FocusRequestServer.cs new file mode 100644 index 0000000..0f7308b --- /dev/null +++ b/desktop/CodexProviderSync.App/FocusRequestServer.cs @@ -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; + +/// +/// 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. +/// +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 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}"; + } +} diff --git a/desktop/CodexProviderSync.App/MainForm.cs b/desktop/CodexProviderSync.App/MainForm.cs new file mode 100644 index 0000000..f0763df --- /dev/null +++ b/desktop/CodexProviderSync.App/MainForm.cs @@ -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(); + } + + /// + /// 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. + /// + 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 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; + } +} diff --git a/desktop/CodexProviderSync.App/Program.cs b/desktop/CodexProviderSync.App/Program.cs new file mode 100644 index 0000000..bfab746 --- /dev/null +++ b/desktop/CodexProviderSync.App/Program.cs @@ -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}"); + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj b/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj new file mode 100644 index 0000000..6cb59ac --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs new file mode 100644 index 0000000..6d30ca7 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs @@ -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( + 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( + 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 + { + ["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 + { + [@"\\?\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 + { + ["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( + () => 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( + () => 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)); + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs b/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs new file mode 100644 index 0000000..d65ce08 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs @@ -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 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( + () => 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( + () => LockService.CreateLockDirectoryAsync( + @"C:\temp\provider-sync.lock", + retryCount: 2, + retryDelayMs: 10, + delayAsync: _ => Task.CompletedTask, + tryCreateDirectory: _ => 5)); + + Assert.Contains("Win32 error: 5", error.Message); + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/SettingsAndDiscoveryTests.cs b/desktop/CodexProviderSync.Core.Tests/SettingsAndDiscoveryTests.cs new file mode 100644 index 0000000..b206a25 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/SettingsAndDiscoveryTests.cs @@ -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(StringComparer.Ordinal) + { + ["newapi"] = 2 + }, + ArchivedSessions = new Dictionary(StringComparer.Ordinal) + }, + LockedRolloutFiles = [], + UnreadableRolloutFiles = [], + EncryptedContentCounts = new ProviderCounts(), + SqliteCounts = new ProviderCounts + { + Sessions = new Dictionary(StringComparer.Ordinal), + ArchivedSessions = new Dictionary(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 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); + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/SingleInstanceGuardTests.cs b/desktop/CodexProviderSync.Core.Tests/SingleInstanceGuardTests.cs new file mode 100644 index 0000000..9937412 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/SingleInstanceGuardTests.cs @@ -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); + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/TestCodexHomeFixture.cs b/desktop/CodexProviderSync.Core.Tests/TestCodexHomeFixture.cs new file mode 100644 index 0000000..3f084f1 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/TestCodexHomeFixture.cs @@ -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 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 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"); + } +} diff --git a/desktop/CodexProviderSync.Core/AppConstants.cs b/desktop/CodexProviderSync.Core/AppConstants.cs new file mode 100644 index 0000000..124f196 --- /dev/null +++ b/desktop/CodexProviderSync.Core/AppConstants.cs @@ -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"); + } +} diff --git a/desktop/CodexProviderSync.Core/BackupService.cs b/desktop/CodexProviderSync.Core/BackupService.cs new file mode 100644 index 0000000..093410e --- /dev/null +++ b/desktop/CodexProviderSync.Core/BackupService.cs @@ -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 CreateBackupAsync( + string codexHome, + string targetProvider, + IReadOnlyList 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 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 RestoreBackupAsync( + string backupDir, + string codexHome, + RestoreBackupOptions? options = null) + { + options ??= new RestoreBackupOptions(); + string normalizedBackupDir = Path.GetFullPath(backupDir); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + 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( + 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 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 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( + await File.ReadAllTextAsync(manifestPath), + JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}"); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + 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 GetBackupSummaryAsync(string codexHome) + { + string backupRoot = AppConstants.DefaultBackupRoot(codexHome); + return Task.Run(() => + { + if (!Directory.Exists(backupRoot)) + { + return new BackupSummary + { + Count = 0, + TotalBytes = 0 + }; + } + + List entries = GetManagedBackupDirectories(backupRoot); + long totalBytes = entries.Sum(static entry => GetDirectorySize(entry.FullName)); + + return new BackupSummary + { + Count = entries.Count, + TotalBytes = totalBytes + }; + }); + } + + public Task 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 entries = GetManagedBackupDirectories(backupRoot); + + List 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 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 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( + File.ReadAllText(metadataPath), + JsonOptions()); + return string.Equals(metadata?.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal); + } + catch + { + return false; + } + } +} diff --git a/desktop/CodexProviderSync.Core/CodexHomeService.cs b/desktop/CodexProviderSync.Core/CodexHomeService.cs new file mode 100644 index 0000000..4469155 --- /dev/null +++ b/desktop/CodexProviderSync.Core/CodexHomeService.cs @@ -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); + } +} diff --git a/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj new file mode 100644 index 0000000..2159c9d --- /dev/null +++ b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + 0.2.9 + 0.2.9.0 + 0.2.9.0 + + + + + + + diff --git a/desktop/CodexProviderSync.Core/CodexSyncService.cs b/desktop/CodexProviderSync.Core/CodexSyncService.cs new file mode 100644 index 0000000..bb69383 --- /dev/null +++ b/desktop/CodexProviderSync.Core/CodexSyncService.cs @@ -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 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 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 = 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 BuildProviderOptions(StatusSnapshot status, AppSettings settings) + { + return _providerDiscoveryService.BuildProviderOptions(status, settings); + } + + public IReadOnlyList ExtractDetectedProviderIds(StatusSnapshot status) + { + return _providerDiscoveryService.ExtractDetectedProviderIds(status); + } + + public async Task 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 workspaceCwdStats = await _globalStateService.ReadThreadCwdStatsAsync(codexHome); + string? encryptedContentWarning = BuildEncryptedContentWarning(sessionInfo.EncryptedContentCounts, targetProvider); + (IReadOnlyList writableChanges, IReadOnlyList lockedChanges) = + await _sessionRolloutService.SplitLockedSessionChangesAsync(sessionInfo.Changes); + + List skippedRolloutFiles = [.. sessionInfo.LockedPaths, .. lockedChanges.Select(static change => change.Path)]; + IReadOnlyList 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 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 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 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 RunSwitchAsync( + string? explicitCodexHome, + string provider, + int keepCount = AppConstants.DefaultBackupRetentionCount) + { + if (string.IsNullOrWhiteSpace(provider)) + { + throw new InvalidOperationException("Missing provider id. Usage: codex-provider switch "); + } + + 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 RunRestoreAsync(string? explicitCodexHome, string backupDir) + { + return await RunRestoreAsync(explicitCodexHome, backupDir, new RestoreBackupOptions()); + } + + public async Task RunRestoreAsync(string? explicitCodexHome, string backupDir, RestoreBackupOptions options) + { + if (string.IsNullOrWhiteSpace(backupDir)) + { + throw new InvalidOperationException("Missing backup path. Usage: codex-provider restore "); + } + + 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 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 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."; + } +} diff --git a/desktop/CodexProviderSync.Core/ConfigFileService.cs b/desktop/CodexProviderSync.Core/ConfigFileService.cs new file mode 100644 index 0000000..ea81619 --- /dev/null +++ b/desktop/CodexProviderSync.Core/ConfigFileService.cs @@ -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 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 ListConfiguredProviderIds(string configText) + { + HashSet 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 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 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); + } +} diff --git a/desktop/CodexProviderSync.Core/GlobalStateService.cs b/desktop/CodexProviderSync.Core/GlobalStateService.cs new file mode 100644 index 0000000..d48e4b9 --- /dev/null +++ b/desktop/CodexProviderSync.Core/GlobalStateService.cs @@ -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> 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 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 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 SyncWorkspaceRootsAsync( + string codexHome, + IReadOnlyList? 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 effectiveCwdStats = cwdStats ?? await ReadThreadCwdStatsAsync(codexHome); + + List existingSavedRoots = ToPathList(state["electron-saved-workspace-roots"]); + List existingProjectOrder = ToPathList(state["project-order"]); + List 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 savedRootCandidates = existingProjectOrder.Count > 0 + ? existingProjectOrder.Concat(existingSavedRoots).Concat(existingActiveRoots) + : existingSavedRoots.Concat(existingActiveRoots); + List nextSavedRoots = DedupePaths( + savedRootCandidates.Select(value => ResolveStoredPath(value, effectiveCwdStats))); + IEnumerable projectOrderCandidates = existingProjectOrder.Count > 0 + ? existingProjectOrder.Concat(existingSavedRoots) + : nextSavedRoots; + List nextProjectOrder = DedupePaths( + projectOrderCandidates.Select(value => ResolveStoredPath(value, effectiveCwdStats))); + List 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> 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 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(StringComparer.Ordinal) + }) + .ToList(); + } + + try + { + await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly); + await connection.OpenAsync(); + HashSet 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 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 ranks = matchingRows.Select(static row => row.Rank).ToList(); + Dictionary 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> ReadThreadTableColumnsAsync(SqliteConnection connection) + { + HashSet 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 columns) + { + List 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 ReadWorkspaceRootsFromState(JsonObject state) + { + List savedRoots = ToPathList(state["electron-saved-workspace-roots"]); + List projectOrder = ToPathList(state["project-order"]); + List activeRoots = ToPathList(state["active-workspace-roots"]); + IEnumerable candidates = projectOrder.Count > 0 + ? projectOrder.Concat(savedRoots).Concat(activeRoots) + : savedRoots.Concat(activeRoots); + return DedupePaths(candidates.Select(ToDesktopWorkspacePath)); + } + + private static string FormatRankPreview(IReadOnlyList 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 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 ToPathList(JsonNode? node) + { + if (node is JsonArray array) + { + return array + .Select(static entry => entry?.GetValue()) + .Where(static entry => !string.IsNullOrWhiteSpace(entry)) + .Cast() + .ToList(); + } + + if (node is JsonValue value) + { + string? text = value.GetValue(); + return string.IsNullOrWhiteSpace(text) ? [] : [text]; + } + + return []; + } + + private static JsonArray ToJsonArray(IEnumerable values) + { + JsonArray array = []; + foreach (string value in values) + { + array.Add(value); + } + + return array; + } + + private static List DedupePaths(IEnumerable values) + { + HashSet seen = new(StringComparer.Ordinal); + List 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 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 previous, IReadOnlyList 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 + }; + } +} diff --git a/desktop/CodexProviderSync.Core/LockService.cs b/desktop/CodexProviderSync.Core/LockService.cs new file mode 100644 index 0000000..a94d6dc --- /dev/null +++ b/desktop/CodexProviderSync.Core/LockService.cs @@ -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 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? delayAsync = null, + Func? 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; + } +} diff --git a/desktop/CodexProviderSync.Core/Models.cs b/desktop/CodexProviderSync.Core/Models.cs new file mode 100644 index 0000000..1e8c784 --- /dev/null +++ b/desktop/CodexProviderSync.Core/Models.cs @@ -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 Sessions { get; init; } = new(StringComparer.Ordinal); + public Dictionary 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 ConfiguredProviders { get; init; } + public required ProviderCounts RolloutCounts { get; init; } + public required IReadOnlyList LockedRolloutFiles { get; init; } + public required IReadOnlyList 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 { 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 Ranks { get; init; } + public required string RankPreview { get; init; } + public required Dictionary 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 Changes { get; init; } + public required IReadOnlyList LockedPaths { get; init; } + public required IReadOnlyList UnreadablePaths { get; init; } + public required ProviderCounts ProviderCounts { get; init; } + public required ProviderCounts EncryptedContentCounts { get; init; } + public required IReadOnlyCollection UserEventThreadIds { get; init; } + public required IReadOnlyDictionary 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 SkippedLockedRolloutFiles { get; init; } + public required IReadOnlyList 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 AppliedPaths { get; init; } + public required IReadOnlyList 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 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 RecentCodexHomes { get; init; } = []; + public string? LastCodexHome { get; init; } + public List SavedProviders { get; init; } = []; + public List 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 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 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; } +} diff --git a/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs b/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..e518ce2 --- /dev/null +++ b/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodexProviderSync.Core.Tests")] diff --git a/desktop/CodexProviderSync.Core/ProviderDiscoveryService.cs b/desktop/CodexProviderSync.Core/ProviderDiscoveryService.cs new file mode 100644 index 0000000..fc8ea42 --- /dev/null +++ b/desktop/CodexProviderSync.Core/ProviderDiscoveryService.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; + +namespace CodexProviderSync.Core; + +public sealed class ProviderDiscoveryService +{ + public IReadOnlyList BuildProviderOptions(StatusSnapshot status, AppSettings settings) + { + Dictionary> sources = new(StringComparer.Ordinal); + + void AddSources(IEnumerable providerIds, ProviderSource source) + { + foreach (string providerId in providerIds.Where(static value => !string.IsNullOrWhiteSpace(value))) + { + if (!sources.TryGetValue(providerId, out HashSet? 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 manualProviders = new(settings.ManualProviders, StringComparer.Ordinal); + HashSet 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 ExtractDetectedProviderIds(StatusSnapshot status) + { + HashSet 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(); + } +} diff --git a/desktop/CodexProviderSync.Core/SessionRolloutService.cs b/desktop/CodexProviderSync.Core/SessionRolloutService.cs new file mode 100644 index 0000000..809f0b5 --- /dev/null +++ b/desktop/CodexProviderSync.Core/SessionRolloutService.cs @@ -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 CollectSessionChangesAsync( + string codexHome, + string targetProvider, + bool skipLockedReads = false) + { + List changes = []; + List lockedPaths = []; + List unreadablePaths = []; + Dictionary sessionCounts = new(StringComparer.Ordinal); + Dictionary archivedCounts = new(StringComparer.Ordinal); + Dictionary encryptedSessionCounts = new(StringComparer.Ordinal); + Dictionary encryptedArchivedCounts = new(StringComparer.Ordinal); + HashSet userEventThreadIds = new(StringComparer.Ordinal); + Dictionary 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() ?? "(missing)"; + Dictionary bucket = dirName == "archived_sessions" ? archivedCounts : sessionCounts; + bucket[currentProvider] = bucket.TryGetValue(currentProvider, out int count) ? count + 1 : 1; + if (payload["id"]?.GetValue() is string metadataThreadId + && !string.IsNullOrWhiteSpace(metadataThreadId) + && payload["cwd"]?.GetValue() 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() 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 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(), + 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 ApplySessionChangesAsync(IEnumerable changes) + { + int appliedCount = 0; + List appliedPaths = []; + List 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 filePaths) + { + List 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 WritableChanges, IReadOnlyList LockedChanges)> SplitLockedSessionChangesAsync( + IEnumerable changes) + { + List changeList = changes.ToList(); + List lockedPaths = await FindLockedFilesAsync(changeList.Select(static change => change.Path)); + if (lockedPaths.Count == 0) + { + return (changeList, []); + } + + HashSet lockedSet = new(lockedPaths, StringComparer.Ordinal); + List writable = []; + List 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 manifestEntries) + { + foreach (SessionBackupManifestEntry entry in manifestEntries) + { + await RewriteFirstLineAsync(entry.Path, entry.OriginalFirstLine, entry.OriginalSeparator); + TryRestoreLastWriteTimeUtc(entry.Path, entry.OriginalLastWriteTimeUtcTicks); + } + } + + internal Task RestoreSessionChangesAsync(IEnumerable 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() != "session_meta") + { + return false; + } + + payload = root["payload"] as JsonObject; + return payload is not null; + } + catch + { + return false; + } + } + + private async Task 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 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 ReadFirstLineRecordAsync(FileStream stream) + { + stream.Seek(0, SeekOrigin.Begin); + byte[] buffer = ArrayPool.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 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.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 FileContainsTextAsync(string filePath, string text, int startOffset) + { + byte[] needle = Encoding.UTF8.GetBytes(text); + byte[] buffer = ArrayPool.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.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.Shared.Return(haystack); + } + } + } + } + catch (Exception error) + { + throw WrapRolloutFileBusyError(error, filePath, "scan"); + } + finally + { + ArrayPool.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 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 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(); + } + 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> FindLockedFilesAsync(IEnumerable filePaths) + { + List 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); +} diff --git a/desktop/CodexProviderSync.Core/SettingsService.cs b/desktop/CodexProviderSync.Core/SettingsService.cs new file mode 100644 index 0000000..9f80a58 --- /dev/null +++ b/desktop/CodexProviderSync.Core/SettingsService.cs @@ -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 LoadAsync() + { + if (!File.Exists(SettingsPath)) + { + return new AppSettings(); + } + + try + { + AppSettings? settings = JsonSerializer.Deserialize( + 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 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 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 Deduplicate(IEnumerable 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"; + } +} diff --git a/desktop/CodexProviderSync.Core/SingleInstanceGuard.cs b/desktop/CodexProviderSync.Core/SingleInstanceGuard.cs new file mode 100644 index 0000000..414282c --- /dev/null +++ b/desktop/CodexProviderSync.Core/SingleInstanceGuard.cs @@ -0,0 +1,403 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace CodexProviderSync.Core; + +/// +/// Cross-platform single-instance guard. The first process to call +/// owns the lock; subsequent callers get back a +/// with IsOwner == false and +/// the metadata of the existing owner so the caller can route a "focus" +/// request to it. +/// +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) + { + } + + /// + /// 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. + /// + internal SingleInstanceGuard(Func isProcessAlive) + : this(isProcessAlive, DefaultLockDirectory()) + { + } + + /// + /// 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 %APPDATA%/codex-provider-sync/singleton. + /// + internal SingleInstanceGuard(Func isProcessAlive, string lockDirectory) + { + IsProcessAlive = isProcessAlive; + LockDirectory = lockDirectory; + } + + internal Func 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(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; +} diff --git a/desktop/CodexProviderSync.Core/SqliteStateService.cs b/desktop/CodexProviderSync.Core/SqliteStateService.cs new file mode 100644 index 0000000..4c02983 --- /dev/null +++ b/desktop/CodexProviderSync.Core/SqliteStateService.cs @@ -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 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 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 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 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 sessions = new(StringComparer.Ordinal); + Dictionary 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 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 ReadSqliteRepairStatsAsync( + string codexHome, + IReadOnlyCollection? userEventThreadIds = null, + IReadOnlyDictionary? 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 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? userEventThreadIds = null, + IReadOnlyDictionary? 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 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); + } +} diff --git a/desktop/CodexProviderSync.Core/TextFormatter.cs b/desktop/CodexProviderSync.Core/TextFormatter.cs new file mode 100644 index 0000000..1e64995 --- /dev/null +++ b/desktop/CodexProviderSync.Core/TextFormatter.cs @@ -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 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 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 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 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 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 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]}"; + } +} diff --git a/desktop/CodexProviderSync.Mac/App.cs b/desktop/CodexProviderSync.Mac/App.cs new file mode 100644 index 0000000..89bab84 --- /dev/null +++ b/desktop/CodexProviderSync.Mac/App.cs @@ -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(); + } +} diff --git a/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj new file mode 100644 index 0000000..e14030f --- /dev/null +++ b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + WinExe + net10.0 + enable + enable + CodexProviderSync + Codex Provider Sync + Dailin521 + 0.2.9 + 0.2.9.0 + 0.2.9.0 + true + + + diff --git a/desktop/CodexProviderSync.Mac/ConfirmationDialog.cs b/desktop/CodexProviderSync.Mac/ConfirmationDialog.cs new file mode 100644 index 0000000..796d016 --- /dev/null +++ b/desktop/CodexProviderSync.Mac/ConfirmationDialog.cs @@ -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; + } +} diff --git a/desktop/CodexProviderSync.Mac/MacDisplayFormatter.cs b/desktop/CodexProviderSync.Mac/MacDisplayFormatter.cs new file mode 100644 index 0000000..f84b22b --- /dev/null +++ b/desktop/CodexProviderSync.Mac/MacDisplayFormatter.cs @@ -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 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 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 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 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 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]}"; + } +} diff --git a/desktop/CodexProviderSync.Mac/MacUiText.cs b/desktop/CodexProviderSync.Mac/MacUiText.cs new file mode 100644 index 0000000..b5b860e --- /dev/null +++ b/desktop/CodexProviderSync.Mac/MacUiText.cs @@ -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 + }; + } +} diff --git a/desktop/CodexProviderSync.Mac/MainWindow.cs b/desktop/CodexProviderSync.Mac/MainWindow.cs new file mode 100644 index 0000000..9c28024 --- /dev/null +++ b/desktop/CodexProviderSync.Mac/MainWindow.cs @@ -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 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 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 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 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 ConfirmAsync(string title, string message) + { + ConfirmationDialog dialog = new(title, message, T("continue"), T("cancel")); + return await dialog.ShowDialog(this); + } + + private async Task ShowInfoAsync(string message) + { + ConfirmationDialog dialog = new("Codex Provider Sync", message, T("ok"), null); + await dialog.ShowDialog(this); + } + + private async Task ShowErrorAsync(Exception error) + { + ConfirmationDialog dialog = new(T("error"), error.Message, T("ok"), null); + await dialog.ShowDialog(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) + "\""; + } +} diff --git a/desktop/CodexProviderSync.Mac/Program.cs b/desktop/CodexProviderSync.Mac/Program.cs new file mode 100644 index 0000000..209c7b1 --- /dev/null +++ b/desktop/CodexProviderSync.Mac/Program.cs @@ -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() + .UsePlatformDetect() + .LogToTrace(); + } +} diff --git a/desktop/CodexProviderSync.Mac/ProviderListItem.cs b/desktop/CodexProviderSync.Mac/ProviderListItem.cs new file mode 100644 index 0000000..323efb5 --- /dev/null +++ b/desktop/CodexProviderSync.Mac/ProviderListItem.cs @@ -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; } +} diff --git a/docs/README_EN.md b/docs/README_EN.md new file mode 100644 index 0000000..7258022 --- /dev/null +++ b/docs/README_EN.md @@ -0,0 +1,243 @@ +
+ +# codex-provider-sync + +### Keep Codex history visible after switching between providers + +[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/Dailin521/codex-provider-sync) +[![Node](https://img.shields.io/badge/node-16%2B-brightgreen.svg)](https://nodejs.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) +[![Community](https://img.shields.io/badge/community-LINUX%20DO-2ea043.svg)](https://linux.do/) + +English | [中文](../README.md) + +
+ +## 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\ +``` + +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 ` 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 ` + +## 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 ` overrides the target provider + - if root `model_provider` is missing, it falls back to `openai` +- `codex-provider switch ` + - updates root `model_provider` in `config.toml` + - immediately runs a sync + - `--keep ` 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 ` + - 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 ` to choose another install directory + - use `--codex-home ` 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/ +``` + +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 ` 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 diff --git a/docs/README_GUI_ZH.md b/docs/README_GUI_ZH.md new file mode 100644 index 0000000..ff898e5 --- /dev/null +++ b/docs/README_GUI_ZH.md @@ -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 切换,只负责同步可见性相关元数据 diff --git a/docs/README_MAC_GUI_ZH.md b/docs/README_MAC_GUI_ZH.md new file mode 100644 index 0000000..d6dacc2 --- /dev/null +++ b/docs/README_MAC_GUI_ZH.md @@ -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。 diff --git a/docs/README_ZH.md b/docs/README_ZH.md new file mode 100644 index 0000000..2b6837b --- /dev/null +++ b/docs/README_ZH.md @@ -0,0 +1,115 @@ +
+ +# codex-provider-sync + +### 切换 provider 后,让 Codex 历史会话重新可见 + +[![CI](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/Dailin521/codex-provider-sync/actions/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/Dailin521/codex-provider-sync) +[![Node](https://img.shields.io/badge/node-16%2B-brightgreen.svg)](https://nodejs.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) + +中文镜像 | [English](README_EN.md) | [主 README](../README.md) + +
+ +## 解决什么问题 + +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\ +codex-provider prune-backups --keep 5 +``` + +命令含义: + +- `status`:只检查当前 provider、rollout、SQLite、项目可见性诊断。 +- `sync`:不切换登录状态,只把历史会话 metadata 同步到当前 provider。 +- `switch `:修改 `config.toml` 根级 `model_provider`,然后执行同步。 +- `restore `:从备份恢复,支持 `--no-config`、`--no-db`、`--no-sessions`。 +- `prune-backups --keep `:只清理本工具创建的旧备份。 + +## 能力边界 + +本工具只修复“历史会话可见性”相关 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/ +``` + +注意: + +- 如果 `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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..58d69f1 --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f508b58 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/publish-gui-macos.sh b/scripts/publish-gui-macos.sh new file mode 100755 index 0000000..61035bb --- /dev/null +++ b/scripts/publish-gui-macos.sh @@ -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' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Codex Provider Sync + CFBundleExecutable + CodexProviderSync + CFBundleIdentifier + com.dailin521.codexprovidersync + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Codex Provider Sync + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.5 + CFBundleVersion + 0.2.5 + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + + +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" diff --git a/scripts/publish-gui.ps1 b/scripts/publish-gui.ps1 new file mode 100644 index 0000000..c2792ca --- /dev/null +++ b/scripts/publish-gui.ps1 @@ -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" diff --git a/src/backup.js b/src/backup.js new file mode 100644 index 0000000..408de85 --- /dev/null +++ b/src/backup.js @@ -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; +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..c90af13 --- /dev/null +++ b/src/cli.js @@ -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 [--keep N] [--codex-home PATH] + codex-provider prune-backups [--keep N] [--codex-home PATH] + codex-provider restore [--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; +}); diff --git a/src/config-file.js b/src/config-file.js new file mode 100644 index 0000000..edc8638 --- /dev/null +++ b/src/config-file.js @@ -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"); +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..41310d4 --- /dev/null +++ b/src/constants.js @@ -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); +} diff --git a/src/launcher.js b/src/launcher.js new file mode 100644 index 0000000..3fe03ff --- /dev/null +++ b/src/launcher.js @@ -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 + }; +} diff --git a/src/locking.js b/src/locking.js new file mode 100644 index 0000000..d78ab35 --- /dev/null +++ b/src/locking.js @@ -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 }); + }; +} diff --git a/src/node-version.js b/src/node-version.js new file mode 100644 index 0000000..e317efd --- /dev/null +++ b/src/node-version.js @@ -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); + } +} diff --git a/src/service.js b/src/service.js new file mode 100644 index 0000000..fd45db7 --- /dev/null +++ b/src/service.js @@ -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 "); + } + + 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 "); + } + 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(); + } +} diff --git a/src/session-files.js b/src/session-files.js new file mode 100644 index 0000000..6a63d14 --- /dev/null +++ b/src/session-files.js @@ -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; +} diff --git a/src/sqlite-state.js b/src/sqlite-state.js new file mode 100644 index 0000000..e78fa93 --- /dev/null +++ b/src/sqlite-state.js @@ -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(); + } +} diff --git a/src/sqlite.js b/src/sqlite.js new file mode 100644 index 0000000..57da76d --- /dev/null +++ b/src/sqlite.js @@ -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); +} diff --git a/src/workspace-roots.js b/src/workspace-roots.js new file mode 100644 index 0000000..c073ecc --- /dev/null +++ b/src/workspace-roots.js @@ -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 + }; +} diff --git a/test/config-file.test.js b/test/config-file.test.js new file mode 100644 index 0000000..e66c3b4 --- /dev/null +++ b/test/config-file.test.js @@ -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); +}); diff --git a/test/launcher.test.js b/test/launcher.test.js new file mode 100644 index 0000000..46c2d7d --- /dev/null +++ b/test/launcher.test.js @@ -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""/); +}); diff --git a/test/locking.test.js b/test/locking.test.js new file mode 100644 index 0000000..dd672ed --- /dev/null +++ b/test/locking.test.js @@ -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, []); +}); diff --git a/test/sync-service.test.js b/test/sync-service.test.js new file mode 100644 index 0000000..2722e14 --- /dev/null +++ b/test/sync-service.test.js @@ -0,0 +1,1221 @@ +import { spawn } from "node:child_process"; +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 { + createBackup, + getBackupSummary, + pruneBackups, + restoreBackup, + updateSessionBackupManifest +} from "../src/backup.js"; +import { getStatus, renderStatus, runRestore, runSwitch, runSync } from "../src/service.js"; +import { DB_FILE_BASENAME, DEFAULT_BACKUP_RETENTION_COUNT, SQLITE_DIR_BASENAME } from "../src/constants.js"; +import { getUnsupportedNodeVersionMessage } from "../src/node-version.js"; +import { applySessionChanges, collectSessionChanges } from "../src/session-files.js"; +import { openDatabase } from "../src/sqlite.js"; + +async function makeTempCodexHome() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-")); + const codexHome = path.join(root, ".codex"); + await fs.mkdir(path.join(codexHome, "sessions", "2026", "03", "19"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions", "2026", "03", "18"), { recursive: true }); + return { root, codexHome }; +} + +async function writeRollout(filePath, id, provider) { + const payload = { + id, + timestamp: "2026-03-19T00:00:00.000Z", + cwd: "C:\\AITemp", + source: "cli", + cli_version: "0.115.0", + model_provider: provider + }; + const lines = [ + JSON.stringify({ timestamp: payload.timestamp, type: "session_meta", payload }), + JSON.stringify({ timestamp: payload.timestamp, type: "event_msg", payload: { type: "user_message", message: "hi" } }) + ]; + await fs.writeFile(filePath, `${lines.join("\n")}\n`, "utf8"); +} + +async function writeCustomRollout(filePath, payload, message = "hi") { + const lines = [ + JSON.stringify({ timestamp: payload.timestamp, type: "session_meta", payload }), + JSON.stringify({ timestamp: payload.timestamp, type: "event_msg", payload: { type: "user_message", message } }) + ]; + await fs.writeFile(filePath, `${lines.join("\n")}\n`, "utf8"); +} + +function backupRoot(codexHome) { + return path.join(codexHome, "backups_state", "provider-sync"); +} + +async function writeBackup(codexHome, directoryName, files) { + const backupDir = path.join(backupRoot(codexHome), directoryName); + await fs.mkdir(backupDir, { recursive: true }); + let totalBytes = 0; + if (!files.some(([relativePath]) => relativePath === "metadata.json")) { + const metadataPath = path.join(backupDir, "metadata.json"); + const metadataContent = JSON.stringify({ + version: 1, + namespace: "provider-sync", + codexHome, + targetProvider: "openai", + createdAt: "2026-03-24T00:00:00.000Z", + dbFiles: [], + changedSessionFiles: 0 + }, null, 2); + await fs.writeFile(metadataPath, metadataContent, "utf8"); + const metadataStat = await fs.stat(metadataPath); + totalBytes += metadataStat.size; + } + for (const [relativePath, content] of files) { + const fullPath = path.join(backupDir, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, "utf8"); + const stat = await fs.stat(fullPath); + totalBytes += stat.size; + } + return totalBytes; +} + +async function writeConfig(codexHome, modelProviderLine = "") { + const config = `${modelProviderLine}${modelProviderLine ? "\n" : ""}sandbox_mode = "danger-full-access"\n\n[model_providers.apigather]\nbase_url = "https://example.com"\n`; + await fs.writeFile(path.join(codexHome, "config.toml"), config, "utf8"); +} + +async function writeGlobalState(codexHome, value) { + const text = `${JSON.stringify(value, null, 2)}\n`; + await fs.writeFile(path.join(codexHome, ".codex-global-state.json"), text, "utf8"); + await fs.writeFile(path.join(codexHome, ".codex-global-state.json.bak"), text, "utf8"); +} + +function stateDbPath(codexHome) { + return path.join(codexHome, SQLITE_DIR_BASENAME, DB_FILE_BASENAME); +} + +function legacyStateDbPath(codexHome) { + return path.join(codexHome, DB_FILE_BASENAME); +} + +async function writeStateDbAt(dbPath, rows) { + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + const db = await openDatabase(dbPath); + try { + db.exec(` + 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 '' + ) + `); + const stmt = db.prepare("INSERT INTO threads (id, model_provider, cwd, archived, first_user_message) VALUES (?, ?, ?, ?, ?)"); + for (const row of rows) { + stmt.run(row.id, row.model_provider, row.cwd ?? "C:\\AITemp", row.archived ? 1 : 0, row.first_user_message ?? "hello"); + } + } finally { + db.close(); + } +} + +async function writeStateDb(codexHome, rows) { + await writeStateDbAt(stateDbPath(codexHome), rows); +} + +async function writeLegacyStateDb(codexHome, rows) { + await writeStateDbAt(legacyStateDbPath(codexHome), rows); +} + +async function writeStateDbWithUserEventColumn(codexHome, rows) { + const dbPath = stateDbPath(codexHome); + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + const db = await openDatabase(dbPath); + try { + db.exec(` + 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 '' + ) + `); + const stmt = db.prepare("INSERT INTO threads (id, model_provider, cwd, archived, has_user_event, first_user_message) VALUES (?, ?, ?, ?, ?, ?)"); + for (const row of rows) { + stmt.run(row.id, row.model_provider, row.cwd ?? "C:\\AITemp", row.archived ? 1 : 0, row.has_user_event ? 1 : 0, row.first_user_message ?? "hello"); + } + } finally { + db.close(); + } +} + +async function writeStateDbForProjectVisibility(codexHome, rows) { + const dbPath = stateDbPath(codexHome); + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + const db = await openDatabase(dbPath); + try { + db.exec(` + 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 + ) + `); + const stmt = db.prepare("INSERT INTO threads (id, model_provider, cwd, source, archived, first_user_message, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)"); + for (const row of rows) { + stmt.run( + row.id, + row.model_provider ?? "dal", + row.cwd, + row.source ?? "cli", + row.archived ? 1 : 0, + row.first_user_message ?? "hello", + row.updated_at_ms ?? 0 + ); + } + } finally { + db.close(); + } +} + +async function lockRolloutFile(filePath, shareMode = "None") { + const script = ` +& { + param([string]$path, [string]$shareMode) + $share = [System.Enum]::Parse([System.IO.FileShare], $shareMode) + $stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, $share) + try { + Write-Output 'locked' + [Console]::Out.Flush() + Start-Sleep -Seconds 30 + } finally { + $stream.Close() + } +} +`.trim(); + + const child = spawn("powershell.exe", [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + filePath, + shareMode + ], { + stdio: ["ignore", "pipe", "pipe"] + }); + + await new Promise((resolve, reject) => { + let settled = false; + let stdout = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + if (!settled && stdout.includes("locked")) { + settled = true; + resolve(); + } + }); + + child.once("error", (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + + child.once("exit", (code, signal) => { + if (!settled) { + settled = true; + reject(new Error(`Failed to acquire rollout file lock. Exit code: ${code ?? "null"}, signal: ${signal ?? "null"}`)); + } + }); + }); + + return child; +} + +async function runCli(args) { + const cliPath = path.resolve("src", "cli.js"); + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: path.resolve("."), + stdio: ["ignore", "pipe", "pipe"] + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", reject); + child.once("exit", (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +test("runSync rewrites rollout files and sqlite, then restore reverts both", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-b.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeRollout(archivedPath, "thread-b", "newapi"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false }, + { id: "thread-b", model_provider: "newapi", archived: true } + ]); + + const syncResult = await runSync({ codexHome }); + assert.equal(syncResult.targetProvider, "openai"); + assert.equal(typeof syncResult.backupDurationMs, "number"); + assert.ok(syncResult.backupDurationMs >= 0); + assert.equal(syncResult.changedSessionFiles, 2); + assert.deepEqual(syncResult.skippedLockedRolloutFiles, []); + assert.equal(syncResult.sqliteRowsUpdated, 2); + const backupMetadata = JSON.parse(await fs.readFile(path.join(syncResult.backupDir, "metadata.json"), "utf8")); + assert.deepEqual( + backupMetadata.dbFiles.map((fileName) => fileName.replaceAll("\\", "/")), + ["sqlite/state_5.sqlite"] + ); + + const syncedSession = await fs.readFile(sessionPath, "utf8"); + const syncedArchived = await fs.readFile(archivedPath, "utf8"); + assert.match(syncedSession, /"model_provider":"openai"/); + assert.match(syncedArchived, /"model_provider":"openai"/); + + const db = await openDatabase(stateDbPath(codexHome)); + try { + const providers = db + .prepare("SELECT id, model_provider FROM threads ORDER BY id") + .all() + .map((row) => ({ ...row })); + assert.deepEqual(providers, [ + { id: "thread-a", model_provider: "openai" }, + { id: "thread-b", model_provider: "openai" } + ]); + } finally { + db.close(); + } + + await runRestore({ codexHome, backupDir: syncResult.backupDir }); + + const restoredSession = await fs.readFile(sessionPath, "utf8"); + const restoredArchived = await fs.readFile(archivedPath, "utf8"); + assert.match(restoredSession, /"model_provider":"apigather"/); + assert.match(restoredArchived, /"model_provider":"newapi"/); +}); + +test("runSync updates legacy root sqlite database when sqlite-dir state is stale", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-active-a.jsonl"); + const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-active-b.jsonl"); + await writeRollout(sessionPath, "thread-active-a", "dal"); + await writeRollout(archivedPath, "thread-active-b", "dal"); + await writeStateDb(codexHome, [ + { id: "thread-active-a", model_provider: "dal", archived: false } + ]); + await writeLegacyStateDb(codexHome, [ + { id: "thread-active-a", model_provider: "dal", archived: false }, + { id: "thread-active-b", model_provider: "dal", archived: true } + ]); + + const syncResult = await runSync({ codexHome }); + + assert.equal(syncResult.sqliteRowsUpdated, 2); + const backupMetadata = JSON.parse(await fs.readFile(path.join(syncResult.backupDir, "metadata.json"), "utf8")); + assert.deepEqual(backupMetadata.dbFiles, [DB_FILE_BASENAME]); + + const legacyDb = await openDatabase(legacyStateDbPath(codexHome)); + try { + assert.deepEqual( + legacyDb.prepare("SELECT id, model_provider FROM threads ORDER BY id").all().map((row) => ({ ...row })), + [ + { id: "thread-active-a", model_provider: "openai" }, + { id: "thread-active-b", model_provider: "openai" } + ] + ); + } finally { + legacyDb.close(); + } + + const staleDb = await openDatabase(stateDbPath(codexHome)); + try { + assert.deepEqual( + staleDb.prepare("SELECT id, model_provider FROM threads ORDER BY id").all().map((row) => ({ ...row })), + [ + { id: "thread-active-a", model_provider: "dal" } + ] + ); + } finally { + staleDb.close(); + } +}); + +test("runSync reports stage progress and backup duration", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + const progressEvents = []; + const result = await runSync({ + codexHome, + onProgress(event) { + progressEvents.push(event); + } + }); + + assert.ok(result.backupDurationMs >= 0); + assert.deepEqual( + progressEvents + .filter((event) => event.status === "start") + .map((event) => event.stage), + [ + "scan_rollout_files", + "check_locked_rollout_files", + "create_backup", + "update_sqlite", + "rewrite_rollout_files", + "clean_backups" + ] + ); + + const backupCompleteEvent = progressEvents.find((event) => event.stage === "create_backup" && event.status === "complete"); + assert.ok(backupCompleteEvent); + assert.equal(backupCompleteEvent.backupDir, result.backupDir); + assert.ok(backupCompleteEvent.durationMs >= 0); +}); + +test("runSync repairs SQLite has_user_event from rollout user messages", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "openai"); + await writeStateDbWithUserEventColumn(codexHome, [ + { id: "thread-a", model_provider: "openai", archived: false, has_user_event: false } + ]); + + const syncResult = await runSync({ codexHome }); + + assert.equal(syncResult.changedSessionFiles, 0); + assert.equal(syncResult.sqliteRowsUpdated, 1); + assert.equal(syncResult.sqliteUserEventRowsUpdated, 1); + + const db = await openDatabase(stateDbPath(codexHome)); + try { + const row = db + .prepare("SELECT has_user_event FROM threads WHERE id = ?") + .get("thread-a"); + assert.equal(row.has_user_event, 1); + } finally { + db.close(); + } +}); + +test("runSync repairs SQLite cwd from rollout session metadata", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cwd.jsonl"); + await writeCustomRollout(sessionPath, { + id: "thread-cwd", + timestamp: "2026-03-19T00:00:00.000Z", + cwd: "D:\\GitHubProject\\oss-maintainer-hub", + source: "vscode", + cli_version: "0.115.0", + model_provider: "openai" + }); + await writeStateDb(codexHome, [ + { + id: "thread-cwd", + model_provider: "openai", + archived: false, + cwd: "\\\\?\\D:\\GitHubProject\\oss-maintainer-hub" + } + ]); + + const syncResult = await runSync({ codexHome }); + + assert.equal(syncResult.changedSessionFiles, 0); + assert.equal(syncResult.sqliteRowsUpdated, 1); + assert.equal(syncResult.sqliteCwdRowsUpdated, 1); + + const db = await openDatabase(stateDbPath(codexHome)); + try { + const row = db + .prepare("SELECT cwd FROM threads WHERE id = ?") + .get("thread-cwd"); + assert.equal(row.cwd, "D:\\GitHubProject\\oss-maintainer-hub"); + } finally { + db.close(); + } +}); + +test("runSync normalizes extended rollout cwd before repairing SQLite", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cwd-extended.jsonl"); + await writeCustomRollout(sessionPath, { + id: "thread-cwd-extended", + timestamp: "2026-03-19T00:00:00.000Z", + cwd: "\\\\?\\E:\\GitHubProject\\lin-framework", + source: "vscode", + cli_version: "0.115.0", + model_provider: "openai" + }); + await writeStateDb(codexHome, [ + { + id: "thread-cwd-extended", + model_provider: "openai", + archived: false, + cwd: "\\\\?\\E:\\GitHubProject\\lin-framework" + } + ]); + + const syncResult = await runSync({ codexHome }); + + assert.equal(syncResult.sqliteRowsUpdated, 1); + assert.equal(syncResult.sqliteCwdRowsUpdated, 1); + + const db = await openDatabase(stateDbPath(codexHome)); + try { + const row = db + .prepare("SELECT cwd FROM threads WHERE id = ?") + .get("thread-cwd-extended"); + assert.equal(row.cwd, "E:\\GitHubProject\\lin-framework"); + } finally { + db.close(); + } +}); + +test("runSync restores workspace roots from project order and normalizes them to Desktop path variants", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const originalState = { + "electron-saved-workspace-roots": [ + "\\\\?\\D:\\GitHubProject\\codex-provider-sync" + ], + "project-order": [ + "\\\\?\\D:\\GitHubProject\\codex-provider-sync", + "\\\\?\\E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" + ], + "active-workspace-roots": [ + "\\\\?\\D:\\GitHubProject\\codex-provider-sync" + ], + "electron-workspace-root-labels": { + "\\\\?\\E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets": "BrainLifeAssets" + } + }; + await writeGlobalState(codexHome, originalState); + await writeStateDb(codexHome, [ + { + id: "thread-a", + model_provider: "openai", + archived: false, + cwd: "\\\\?\\D:\\GitHubProject\\codex-provider-sync" + }, + { + id: "thread-b", + model_provider: "openai", + archived: false, + cwd: "\\\\?\\E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" + } + ]); + + const syncResult = await runSync({ codexHome }); + assert.equal(syncResult.updatedWorkspaceRoots, 2); + + const syncedState = JSON.parse(await fs.readFile(path.join(codexHome, ".codex-global-state.json"), "utf8")); + assert.deepEqual(syncedState["electron-saved-workspace-roots"], [ + "D:\\GitHubProject\\codex-provider-sync", + "E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" + ]); + assert.deepEqual(syncedState["project-order"], [ + "D:\\GitHubProject\\codex-provider-sync", + "E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" + ]); + assert.deepEqual(syncedState["active-workspace-roots"], [ + "D:\\GitHubProject\\codex-provider-sync" + ]); + assert.equal( + syncedState["electron-workspace-root-labels"]["E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets"], + "BrainLifeAssets" + ); + + await runRestore({ codexHome, backupDir: syncResult.backupDir }); + + const restoredState = JSON.parse(await fs.readFile(path.join(codexHome, ".codex-global-state.json"), "utf8")); + assert.deepEqual(restoredState["electron-saved-workspace-roots"], originalState["electron-saved-workspace-roots"]); + assert.deepEqual(restoredState["project-order"], originalState["project-order"]); +}); + +test("runSwitch updates config and syncs provider metadata", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "openai"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "openai", archived: false } + ]); + + const result = await runSwitch({ codexHome, provider: "apigather" }); + assert.equal(result.targetProvider, "apigather"); + + const config = await fs.readFile(path.join(codexHome, "config.toml"), "utf8"); + assert.match(config, /^model_provider = "apigather"/m); + const rollout = await fs.readFile(sessionPath, "utf8"); + assert.match(rollout, /"model_provider":"apigather"/); +}); + +test("status reports implicit default provider and rollout/sqlite counts", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-b.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeRollout(archivedPath, "thread-b", "openai"); + const backupOneBytes = await writeBackup(codexHome, "20260319T000000000Z", [["note.txt", "backup-one"]]); + const backupTwoBytes = await writeBackup(codexHome, "20260320T000000000Z", [["note.txt", "backup-two"]]); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false }, + { id: "thread-b", model_provider: "openai", archived: true } + ]); + + const status = await getStatus({ codexHome }); + assert.equal(status.currentProvider, "openai"); + assert.equal(status.currentProviderImplicit, true); + assert.deepEqual(status.rolloutCounts.sessions, { apigather: 1 }); + assert.deepEqual(status.sqliteCounts.archived_sessions, { openai: 1 }); + assert.equal(status.stateDbLocation.source, "sqlite-dir"); + assert.equal(status.stateDbLocation.path, stateDbPath(codexHome)); + assert.equal(status.backupSummary.count, 2); + assert.equal(status.backupSummary.totalBytes, backupOneBytes + backupTwoBytes); + assert.match(renderStatus(status), new RegExp(`database: ${stateDbPath(codexHome).replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")}`)); +}); + +test("status falls back to legacy root sqlite database", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome); + const sqliteDir = path.dirname(stateDbPath(codexHome)); + await fs.mkdir(sqliteDir, { recursive: true }); + const db = await openDatabase(legacyStateDbPath(codexHome)); + try { + db.exec(` + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT, + archived INTEGER NOT NULL DEFAULT 0 + ) + `); + db.prepare("INSERT INTO threads (id, model_provider, archived) VALUES (?, ?, ?)").run("legacy-thread", "openai", 0); + } finally { + db.close(); + } + + const status = await getStatus({ codexHome }); + + assert.equal(status.stateDbLocation.source, "legacy-root"); + assert.equal(status.stateDbLocation.path, legacyStateDbPath(codexHome)); + assert.deepEqual(status.sqliteCounts.sessions, { openai: 1 }); + assert.match(renderStatus(status), /legacy root/); +}); + +test("status chooses legacy root sqlite database when sqlite-dir state is stale", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome); + await writeRollout( + path.join(codexHome, "sessions", "2026", "03", "19", "rollout-active-a.jsonl"), + "thread-active-a", + "openai" + ); + await writeRollout( + path.join(codexHome, "sessions", "2026", "03", "19", "rollout-active-b.jsonl"), + "thread-active-b", + "openai" + ); + await writeRollout( + path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-active-c.jsonl"), + "thread-active-c", + "openai" + ); + await writeStateDb(codexHome, [ + { id: "thread-active-a", model_provider: "custom", archived: false } + ]); + await writeLegacyStateDb(codexHome, [ + { id: "thread-active-a", model_provider: "openai", archived: false }, + { id: "thread-active-b", model_provider: "openai", archived: false }, + { id: "thread-active-c", model_provider: "openai", archived: true } + ]); + + const status = await getStatus({ codexHome }); + + assert.equal(status.stateDbLocation.source, "legacy-root"); + assert.equal(status.stateDbLocation.path, legacyStateDbPath(codexHome)); + assert.deepEqual(status.sqliteCounts.sessions, { openai: 2 }); + assert.deepEqual(status.sqliteCounts.archived_sessions, { openai: 1 }); + assert.match(renderStatus(status), /legacy root/); +}); + +test("status reports pending SQLite user-event and cwd repairs", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-repair-status.jsonl"); + await writeCustomRollout(sessionPath, { + id: "thread-repair-status", + timestamp: "2026-03-19T00:00:00.000Z", + cwd: "E:\\GitHubProject\\lin-framework", + source: "vscode", + cli_version: "0.115.0", + model_provider: "openai" + }); + await writeStateDbWithUserEventColumn(codexHome, [ + { + id: "thread-repair-status", + model_provider: "openai", + archived: false, + has_user_event: false, + cwd: "\\\\?\\E:\\GitHubProject\\lin-framework" + } + ]); + + const status = await getStatus({ codexHome }); + + assert.equal(status.sqliteRepairStats.userEventRowsNeedingRepair, 1); + assert.equal(status.sqliteRepairStats.cwdRowsNeedingRepair, 1); + assert.match(renderStatus(status), /user-event flags needing repair: 1/); + assert.match(renderStatus(status), /cwd paths needing repair: 1/); +}); + +test("status reports project visibility ranks and cwd exact-match diagnostics", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "dal"'); + await writeGlobalState(codexHome, { + "electron-saved-workspace-roots": [ + "E:\\GitHubProject\\lin-framework" + ] + }); + + const unrelatedRows = Array.from({ length: 51 }, (_, index) => ({ + id: `thread-other-${String(index).padStart(2, "0")}`, + cwd: "D:\\OtherProject", + updated_at_ms: 1000 - index + })); + await writeStateDbForProjectVisibility(codexHome, [ + ...unrelatedRows, + { + id: "thread-lin", + cwd: "\\\\?\\E:\\GitHubProject\\lin-framework", + updated_at_ms: 1 + } + ]); + + const status = await getStatus({ codexHome }); + const [project] = status.projectThreadVisibility; + + assert.equal(project.root, "E:\\GitHubProject\\lin-framework"); + assert.equal(project.interactiveThreads, 1); + assert.equal(project.firstPageThreads, 0); + assert.deepEqual(project.ranks, [52]); + assert.equal(project.exactCwdMatches, 0); + assert.equal(project.verbatimCwdRows, 1); + assert.match(renderStatus(status), /Project visibility:/); + assert.match(renderStatus(status), /first page 0\/50, ranks 52, exact cwd 0\/1, verbatim cwd 1/); +}); + +test("runSwitch rejects unknown custom providers", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome); + await assert.rejects( + () => runSwitch({ codexHome, provider: "missing" }), + /Provider "missing" is not available/ + ); +}); + +test("runSync leaves rollout files and sqlite untouched when sqlite is locked", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + const lockDb = await openDatabase(stateDbPath(codexHome)); + try { + lockDb.exec("BEGIN IMMEDIATE"); + await assert.rejects( + () => runSync({ codexHome, sqliteBusyTimeoutMs: 0 }), + /state_5\.sqlite is currently in use/ + ); + } finally { + try { + lockDb.exec("ROLLBACK"); + } catch { + // Ignore cleanup failures in tests. + } + lockDb.close(); + } + + const rollout = await fs.readFile(sessionPath, "utf8"); + assert.match(rollout, /"model_provider":"apigather"/); + + const db = await openDatabase(stateDbPath(codexHome)); + try { + const row = db + .prepare("SELECT model_provider FROM threads WHERE id = ?") + .get("thread-a"); + assert.equal(row.model_provider, "apigather"); + } finally { + db.close(); + } +}); + +test("runSync skips locked rollout files and still updates sqlite", async () => { + if (process.platform !== "win32") { + return; + } + + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + const lockProcess = await lockRolloutFile(sessionPath); + let result; + try { + result = await runSync({ codexHome, sqliteBusyTimeoutMs: 0 }); + } finally { + lockProcess.kill(); + await new Promise((resolve) => lockProcess.once("exit", resolve)); + } + + assert.equal(result.changedSessionFiles, 0); + assert.equal(result.sqliteRowsUpdated, 1); + assert.deepEqual(result.skippedLockedRolloutFiles, [sessionPath]); + + const rollout = await fs.readFile(sessionPath, "utf8"); + assert.match(rollout, /"model_provider":"apigather"/); + + const db = await openDatabase(stateDbPath(codexHome)); + try { + const row = db + .prepare("SELECT model_provider FROM threads WHERE id = ?") + .get("thread-a"); + assert.equal(row.model_provider, "openai"); + } finally { + db.close(); + } +}); + +test("applySessionChanges skips rollout files that changed after collection", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + + const { changes } = await collectSessionChanges(codexHome, "openai"); + await fs.appendFile( + sessionPath, + '{"timestamp":"2026-03-19T00:00:01.000Z","type":"event_msg","payload":{"type":"assistant_message","message":"later"}}\n', + "utf8" + ); + + const result = await applySessionChanges(changes); + assert.equal(result.appliedChanges, 0); + assert.deepEqual(result.skippedPaths, [sessionPath]); + + const rollout = await fs.readFile(sessionPath, "utf8"); + assert.match(rollout, /"model_provider":"apigather"/); + assert.match(rollout, /"message":"later"/); +}); + +test("applySessionChanges preserves large UTF-8 session metadata", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-large.jsonl"); + const payload = { + id: "thread-large", + timestamp: "2026-03-19T00:00:00.000Z", + cwd: "C:\\AITemp\\中文", + source: "cli", + cli_version: "0.115.0", + model_provider: "apigather", + title: "中文会话", + note: "保留 UTF-8 内容", + large_blob: "数据块".repeat(40000) + }; + await writeCustomRollout(sessionPath, payload, "你好"); + + const { changes } = await collectSessionChanges(codexHome, "openai"); + const result = await applySessionChanges(changes); + + assert.equal(result.appliedChanges, 1); + assert.deepEqual(result.skippedPaths, []); + + const rollout = await fs.readFile(sessionPath, "utf8"); + assert.match(rollout, /"model_provider":"openai"/); + assert.match(rollout, /"title":"中文会话"/); + assert.match(rollout, /"note":"保留 UTF-8 内容"/); + assert.match(rollout, /"message":"你好"/); + assert.match(rollout, /"large_blob":"数据块数据块/); +}); + +test("applySessionChanges restores original rollout mtime", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-mtime.jsonl"); + await writeRollout(sessionPath, "thread-mtime", "apigather"); + const originalTime = new Date("2026-01-02T03:04:05.000Z"); + await fs.utimes(sessionPath, originalTime, originalTime); + + const { changes } = await collectSessionChanges(codexHome, "openai"); + const result = await applySessionChanges(changes); + + assert.equal(result.appliedChanges, 1); + const stat = await fs.stat(sessionPath); + assert.equal(Math.round(stat.mtimeMs), originalTime.getTime()); +}); + +test("collectSessionChanges reports encrypted_content counts by provider and scope", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-enc.jsonl"); + const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-enc-archived.jsonl"); + await writeRollout(sessionPath, "thread-enc", "apigather"); + await fs.appendFile(sessionPath, '{"type":"event_msg","payload":{"encrypted_content":"gAAA"}}\n', "utf8"); + await writeRollout(archivedPath, "thread-enc-archived", "openai"); + await fs.appendFile(archivedPath, '{"type":"event_msg","payload":{"encrypted_content":"gBBB"}}\n', "utf8"); + + const { encryptedContentCounts } = await collectSessionChanges(codexHome, "openai"); + + assert.deepEqual(encryptedContentCounts, { + sessions: { apigather: 1 }, + archived_sessions: { openai: 1 } + }); +}); + +test("collectSessionChanges scans large rollout content without full-file reads", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-streamed.jsonl"); + const payload = { + id: "thread-streamed", + timestamp: "2026-03-19T00:00:00.000Z", + cwd: "C:\\AITemp", + source: "cli", + cli_version: "0.115.0", + model_provider: "apigather" + }; + await fs.writeFile( + sessionPath, + `${JSON.stringify({ timestamp: payload.timestamp, type: "session_meta", payload })}\n`, + "utf8" + ); + + const chunkBytes = 1024 * 1024; + const tokenPrefix = "encrypted_"; + await fs.appendFile( + sessionPath, + `${"x".repeat(chunkBytes - tokenPrefix.length)}${tokenPrefix}content\n${JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: "after large content" } + })}\n`, + "utf8" + ); + + const originalReadFile = fs.readFile; + fs.readFile = async (filePath, ...args) => { + if (path.resolve(String(filePath)) === path.resolve(sessionPath)) { + throw new Error("rollout scan should not read the full file"); + } + return originalReadFile.call(fs, filePath, ...args); + }; + + try { + const { encryptedContentCounts, userEventThreadIds } = await collectSessionChanges(codexHome, "openai"); + + assert.deepEqual(encryptedContentCounts, { + sessions: { apigather: 1 }, + archived_sessions: {} + }); + assert.equal(userEventThreadIds.has("thread-streamed"), true); + } finally { + fs.readFile = originalReadFile; + } +}); + +test("applySessionChanges skips only the rollout file that becomes locked on Windows", async () => { + if (process.platform !== "win32") { + return; + } + + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const lockedPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-locked.jsonl"); + const writablePath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-writable.jsonl"); + await writeRollout(lockedPath, "thread-locked", "apigather"); + await writeRollout(writablePath, "thread-writable", "apigather"); + + const { changes } = await collectSessionChanges(codexHome, "openai"); + const lockProcess = await lockRolloutFile(lockedPath); + let result; + try { + result = await applySessionChanges(changes); + } finally { + lockProcess.kill(); + await new Promise((resolve) => lockProcess.once("exit", resolve)); + } + + assert.equal(result.appliedChanges, 1); + assert.deepEqual(result.appliedPaths, [writablePath]); + assert.deepEqual(result.skippedPaths, [lockedPath]); + + const lockedRollout = await fs.readFile(lockedPath, "utf8"); + const writableRollout = await fs.readFile(writablePath, "utf8"); + assert.match(lockedRollout, /"model_provider":"apigather"/); + assert.match(writableRollout, /"model_provider":"openai"/); +}); + +test("restoreBackup only restores rollout files that were actually applied", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + + await updateSessionBackupManifest(backupDir, []); + await writeRollout(sessionPath, "thread-a", "manual"); + + await restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }); + + const rollout = await fs.readFile(sessionPath, "utf8"); + assert.match(rollout, /"model_provider":"manual"/); +}); + +test("restoreBackup can skip config, database, and sessions", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-skip.jsonl"); + await writeRollout(sessionPath, "thread-skip", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-skip", model_provider: "apigather", archived: false } + ]); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ codexHome, targetProvider: "openai", sessionChanges: changes, configPath }); + + await writeConfig(codexHome, 'model_provider = "manual"'); + await writeRollout(sessionPath, "thread-skip", "manual"); + await restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: false + }); + + assert.match(await fs.readFile(configPath, "utf8"), /^model_provider = "manual"/m); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"manual"/); +}); + +test("runSync fails before rollout rewrite when SQLite is malformed", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-malformed-db.jsonl"); + await writeRollout(sessionPath, "thread-malformed", "apigather"); + await fs.mkdir(path.dirname(stateDbPath(codexHome)), { recursive: true }); + await fs.writeFile(stateDbPath(codexHome), "not sqlite", "utf8"); + + await assert.rejects( + () => runSync({ codexHome }), + /state_5\.sqlite is malformed or unreadable/ + ); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"apigather"/); +}); + +test("status reports malformed SQLite without failing", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + await writeRollout(path.join(codexHome, "sessions", "2026", "03", "19", "rollout-status-db.jsonl"), "thread-status", "openai"); + await fs.mkdir(path.dirname(stateDbPath(codexHome)), { recursive: true }); + await fs.writeFile(stateDbPath(codexHome), "not sqlite", "utf8"); + + const status = await getStatus({ codexHome }); + assert.equal(status.sqliteCounts.unreadable, true); + assert.match(renderStatus(status), /state_5\.sqlite is malformed or unreadable/); +}); + +test("status skips locked rollout files without failing", async () => { + if (process.platform !== "win32") { + return; + } + + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-status-locked.jsonl"); + await writeRollout(sessionPath, "thread-status-locked", "openai"); + + const lockProcess = await lockRolloutFile(sessionPath); + try { + const status = await getStatus({ codexHome }); + assert.deepEqual(status.lockedRolloutFiles, [sessionPath]); + assert.match(renderStatus(status), /Locked rollout files skipped during status scan: 1/); + } finally { + lockProcess.kill(); + await new Promise((resolve) => lockProcess.once("exit", resolve)); + } +}); + +test("pruneBackups removes the oldest backup directories", async () => { + const { codexHome } = await makeTempCodexHome(); + const oldestBytes = await writeBackup(codexHome, "20260319T000000000Z", [ + ["note.txt", "oldest"], + ["db/state_5.sqlite", "sqlite"] + ]); + await writeBackup(codexHome, "20260320T000000000Z", [["note.txt", "middle"]]); + await writeBackup(codexHome, "20260321T000000000Z", [["note.txt", "newest"]]); + + const result = await pruneBackups(codexHome, 2); + + assert.equal(result.backupRoot, backupRoot(codexHome)); + assert.equal(result.deletedCount, 1); + assert.equal(result.remainingCount, 2); + assert.equal(result.freedBytes, oldestBytes); + await assert.rejects(fs.access(path.join(backupRoot(codexHome), "20260319T000000000Z"))); + await fs.access(path.join(backupRoot(codexHome), "20260320T000000000Z")); + await fs.access(path.join(backupRoot(codexHome), "20260321T000000000Z")); +}); + +test("pruneBackups ignores directories without managed backup metadata", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeBackup(codexHome, "20260320T000000000Z", [ + ["metadata.json", JSON.stringify({ namespace: "provider-sync" })] + ]); + const junkDirectory = path.join(backupRoot(codexHome), "manual-notes"); + await fs.mkdir(junkDirectory, { recursive: true }); + await fs.writeFile(path.join(junkDirectory, "readme.txt"), "keep me", "utf8"); + + const result = await pruneBackups(codexHome, 0); + + assert.equal(result.deletedCount, 1); + assert.equal(result.remainingCount, 0); + await fs.access(junkDirectory); +}); + +test("runSync auto-prunes backups to the default retention count", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + for (let index = 0; index < DEFAULT_BACKUP_RETENTION_COUNT; index += 1) { + await writeBackup(codexHome, `20240101T0000${String(index).padStart(2, "0")}000Z`, [ + ["note.txt", `backup-${index}`] + ]); + } + + const result = await runSync({ codexHome }); + const summary = await getBackupSummary(codexHome); + + assert.equal(summary.count, DEFAULT_BACKUP_RETENTION_COUNT); + await fs.access(result.backupDir); + assert.equal(result.autoPruneResult.deletedCount, 1); + assert.equal(result.autoPruneResult.remainingCount, DEFAULT_BACKUP_RETENTION_COUNT); + assert.equal(result.autoPruneWarning, null); +}); + +test("runSync uses a custom automatic backup retention count", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + for (let index = 0; index < 4; index += 1) { + await writeBackup(codexHome, `20240101T0000${String(index).padStart(2, "0")}000Z`, [ + ["note.txt", `backup-${index}`] + ]); + } + + const result = await runSync({ codexHome, keepCount: 2 }); + const summary = await getBackupSummary(codexHome); + + assert.equal(summary.count, 2); + await fs.access(result.backupDir); + assert.equal(result.autoPruneResult.deletedCount, 3); + assert.equal(result.autoPruneResult.remainingCount, 2); + assert.equal(result.autoPruneWarning, null); +}); + +test("cli rejects non-integer keep values", async () => { + const result = await runCli(["prune-backups", "--keep", "1.5"]); + assert.equal(result.code, 1); + assert.match(result.stderr, /Invalid --keep value: 1\.5/); +}); + +test("node version guard allows Node 16 and rejects older releases", () => { + assert.equal(getUnsupportedNodeVersionMessage("16.0.0"), null); + assert.match( + getUnsupportedNodeVersionMessage("14.21.3"), + /requires Node\.js 16\+/ + ); +}); + +test("cli sync prints stage progress and backup timing", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); + await writeRollout(sessionPath, "thread-a", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-a", model_provider: "apigather", archived: false } + ]); + + const result = await runCli(["sync", "--codex-home", codexHome]); + assert.equal(result.code, 0); + assert.match(result.stdout, /\[1\/6\] Scanning rollout files\.\.\./); + assert.match(result.stdout, /\[2\/6\] Checking locked rollout files\.\.\./); + assert.match(result.stdout, /\[3\/6\] Creating backup\.\.\./); + assert.match(result.stdout, /\[4\/6\] Updating SQLite\.\.\./); + assert.match(result.stdout, /\[5\/6\] Rewriting rollout files\.\.\./); + assert.match(result.stdout, /\[6\/6\] Cleaning backups\.\.\./); + assert.match(result.stdout, /Backup created in .*: .+/); + assert.match(result.stdout, /Backup creation time: /); +});