diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4f13945 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +package-lock.json text eol=lf +apps/desktop/release/artifact-audit-policy.v1.json text eol=lf +web/index.html text eol=lf +web/dist/index.html text eol=lf +web/dist/assets/*.js text eol=lf -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaddbb9..7074da1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,14 +27,325 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: "npm" + - name: Install root production tree for Node 16 compatibility + if: matrix.node-version == '16.20.2' + run: npm ci --workspaces=false --omit=dev + - name: Verify the frozen Node 16 and npm 8 toolchain + if: matrix.node-version == '16.20.2' + run: npm run runtime:verify-node16 + - name: Install complete workspace tree on Node 24 + if: matrix.node-version == '24' + run: npm ci + - run: npm test + + workspace-contract: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - run: npm run workspaces:check + + root-package-compat: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "16.20.2" + cache: "npm" + - run: npm ci --workspaces=false --omit=dev + - run: npm run runtime:verify-node16 + - run: npm run package:verify-root-tree + - run: npm run package:smoke + - run: npm run package:smoke:lifecycle + + web-build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" - run: npm ci - run: npm run web:build - - run: npm test + + web-browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - run: npm run web:build + - run: npx playwright install --with-deps chromium + - run: npm run web:test:e2e + + electron-desktop: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - ubuntu-latest + - macos-latest + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - name: Install the pinned Electron runtime + if: runner.os == 'Linux' + run: node -e "require('electron')" + - name: Configure the Linux Electron runtime sandbox + if: runner.os == 'Linux' + shell: bash + run: sudo -- "$(command -v node)" apps/desktop/scripts/configure-linux-sandbox.mjs node_modules/electron/dist chrome-sandbox + - name: Install Linux Electron runtime dependencies + if: runner.os == 'Linux' + run: npx playwright install-deps + - run: npm run desktop:test + - run: npm run desktop:build + - run: npm run desktop:verify-production-bundle + - run: npm run desktop:pack:dir + - name: Configure the unpacked Linux production sandbox + if: runner.os == 'Linux' + shell: bash + run: sudo -- "$(command -v node)" apps/desktop/scripts/configure-linux-sandbox.mjs dist-desktop/linux-unpacked chrome-sandbox + - name: Run unpacked production Electron smoke + if: runner.os != 'Linux' + run: npm run desktop:test:e2e:packaged + - name: Run unpacked production Electron smoke under Xvfb + if: runner.os == 'Linux' + run: xvfb-run -a npm run desktop:test:e2e:packaged + - run: npm run desktop:build:test + - name: Run Utility crash, recovery, Sync, and Switch integration + if: runner.os != 'Linux' + run: npm run test:e2e --workspace @codex-provider-sync/desktop + - name: Run Utility crash, recovery, Sync, and Switch integration under Xvfb + if: runner.os == 'Linux' + run: xvfb-run -a npm run test:e2e --workspace @codex-provider-sync/desktop + - name: Upload Electron failure traces + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: electron-desktop-${{ runner.os }}-failure-traces + path: | + apps/desktop/test-results/**/trace.zip + apps/desktop/test-results/**/error-context.md + if-no-files-found: warn + retention-days: 7 + + electron-release-candidate: + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: windows-x64 + runner: windows-latest + - target: macos-x64 + runner: macos-15-intel + - target: macos-arm64 + runner: macos-15 + - target: linux-x64 + runner: ubuntu-latest + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - name: Verify candidate input byte identity + run: node -e "const {execFileSync}=require('node:child_process');const files=['package-lock.json','apps/desktop/release/artifact-audit-policy.v1.json'];const value=execFileSync('git',['ls-files','--eol',...files],{encoding:'utf8'});for(const file of files){const line=value.split(/\r?\n/).find((entry)=>entry.endsWith('\t'+file));if(!line||!line.includes('w/lf'))throw new Error(file+' must be checked out with LF bytes.');}process.stdout.write(value)" + - name: Install the pinned Electron runtime + if: runner.os == 'Linux' + run: node -e "require('electron')" + - name: Configure the Linux Electron runtime sandbox + if: runner.os == 'Linux' + shell: bash + run: sudo -- "$(command -v node)" apps/desktop/scripts/configure-linux-sandbox.mjs node_modules/electron/dist chrome-sandbox + - name: Install Linux Electron runtime dependencies + if: runner.os == 'Linux' + run: npx playwright install-deps chromium + - name: Resolve immutable candidate identity + id: candidate + env: + CPS_CANDIDATE_CHANNEL: rc + CPS_CANDIDATE_RUN_NUMBER: ${{ github.run_number }} + CPS_CANDIDATE_SHA: ${{ github.sha }} + CPS_CANDIDATE_TARGET: ${{ matrix.target }} + run: node apps/desktop/scripts/resolve-candidate-build.mjs + - name: Build native release candidate without publishing + env: + CPS_CANDIDATE_TARGET: ${{ matrix.target }} + CPS_DESKTOP_VERSION: ${{ steps.candidate.outputs.version }} + CPS_DESKTOP_BUILD_ID: ${{ steps.candidate.outputs.build_id }} + run: npm run desktop:pack:candidate + - name: Configure the unpacked Linux candidate sandbox + if: runner.os == 'Linux' + shell: bash + run: sudo -- "$(command -v node)" apps/desktop/scripts/configure-linux-sandbox.mjs dist-desktop/linux-unpacked chrome-sandbox + - name: Audit and stage candidate + if: runner.os != 'Linux' + env: + CPS_CANDIDATE_TARGET: ${{ matrix.target }} + CPS_DESKTOP_VERSION: ${{ steps.candidate.outputs.version }} + CPS_DESKTOP_BUILD_ID: ${{ steps.candidate.outputs.build_id }} + CPS_CANDIDATE_SHA: ${{ steps.candidate.outputs.commit }} + run: npm run desktop:stage:candidate + - name: Audit and stage Linux candidate under Xvfb + if: runner.os == 'Linux' + env: + CPS_CANDIDATE_TARGET: ${{ matrix.target }} + CPS_DESKTOP_VERSION: ${{ steps.candidate.outputs.version }} + CPS_DESKTOP_BUILD_ID: ${{ steps.candidate.outputs.build_id }} + CPS_CANDIDATE_SHA: ${{ steps.candidate.outputs.commit }} + run: xvfb-run -a npm run desktop:stage:candidate + - name: Smoke final candidate containers + if: runner.os != 'Linux' + env: + CPS_CANDIDATE_TARGET: ${{ matrix.target }} + CPS_DESKTOP_VERSION: ${{ steps.candidate.outputs.version }} + run: npm run desktop:smoke:candidate:artifacts + - name: Smoke final Linux candidate containers under Xvfb + if: runner.os == 'Linux' + env: + CPS_CANDIDATE_TARGET: ${{ matrix.target }} + CPS_DESKTOP_VERSION: ${{ steps.candidate.outputs.version }} + CPS_LINUX_SANDBOX_SETUP: setuid + run: xvfb-run -a npm run desktop:smoke:candidate:artifacts + - name: Upload audited candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: electron-release-candidate-${{ matrix.target }} + path: artifacts/c9/${{ matrix.target }} + if-no-files-found: error + include-hidden-files: false + retention-days: 30 + + electron-candidate-set: + needs: electron-release-candidate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - name: Download all native candidates + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: electron-release-candidate-* + path: artifacts/c9-download + merge-multiple: false + - run: npm run desktop:verify:candidate:set + - name: Upload candidate-set index + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: electron-release-candidate-set + path: artifacts/c9-index + if-no-files-found: error + include-hidden-files: false + retention-days: 30 + + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - name: Reject production moderate, high, and critical vulnerabilities + run: npm audit --omit=dev --audit-level=moderate + - name: Reject full-tree high and critical vulnerabilities + run: npm audit --audit-level=high + + cross-runtime-fixtures: + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: "10.0.x" + - run: npm ci + - run: dotnet build desktop/CodexProviderSync.Core.Tests/FixtureHost/CodexProviderSync.FixtureHost.csproj --configuration Release + - run: dotnet build desktop/CodexProviderSync.Core.Tests/CrashHost/CodexProviderSync.CrashHost.csproj --configuration Release + - run: npm run fixtures:cross-runtime + - run: npm run fixtures:historical-tags + - name: Refuse hosted Release binary execution for an untrusted fork + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + shell: pwsh + run: throw 'Hosted historical binaries are verified only from a same-repository branch; a maintainer must run the release-evidence gate from trusted repository code.' + - name: Verify a hosted formal Release backup with current Node Restore + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + GITHUB_TOKEN: ${{ github.token }} + run: npm run fixtures:historical-formal-release + - name: Upload historical tag backup evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: historical-tag-backup-evidence + path: artifacts/test-fixtures/historical-tag-backup-evidence.json + if-no-files-found: error + retention-days: 30 + - name: Upload hosted formal Release backup evidence + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: historical-formal-release-backup-evidence + path: artifacts/test-fixtures/historical-formal-release-backup-evidence.json + if-no-files-found: error + retention-days: 30 desktop-test: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "10.0.x" @@ -50,6 +361,9 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "10.0.x" @@ -66,27 +380,110 @@ jobs: dotnet-version: "10.0.x" - run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --filter FullyQualifiedName~LockServiceTests + c10-evidence-bundle: + if: ${{ always() }} + needs: + - test + - workspace-contract + - root-package-compat + - web-build + - web-browser + - electron-desktop + - electron-release-candidate + - electron-candidate-set + - dependency-audit + - cross-runtime-fixtures + - desktop-test + - desktop-macos + - desktop-linux-lock + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: "npm" + - run: npm ci + - name: Download the verified four-target candidate index + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: electron-release-candidate-set + path: artifacts/c9-index + - name: Download the hosted formal Release backup evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: historical-formal-release-backup-evidence + path: artifacts/formal-release + - name: Generate the redacted commit-bound C10 evidence bundle + env: + CPS_CANDIDATE_INDEX: artifacts/c9-index/candidate-index.v1.json + CPS_FORMAL_RELEASE_EVIDENCE: artifacts/formal-release/historical-formal-release-backup-evidence.json + CPS_REQUIRED_JOB_RESULTS_JSON: ${{ toJSON(needs) }} + CPS_SOURCE_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CPS_EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: npm run evidence:c10 + - name: Upload the non-release C10 evidence bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vnext-c10-evidence-${{ github.sha }} + path: artifacts/c10 + if-no-files-found: error + include-hidden-files: false + retention-days: 30 + ci-gate: name: ci-gate if: ${{ always() }} needs: - test + - workspace-contract + - root-package-compat + - web-build + - web-browser + - electron-desktop + - electron-release-candidate + - electron-candidate-set + - dependency-audit + - cross-runtime-fixtures - desktop-test - desktop-macos - desktop-linux-lock + - c10-evidence-bundle runs-on: ubuntu-latest steps: - name: Verify required jobs env: NODE_TEST_RESULT: ${{ needs.test.result }} + WORKSPACE_CONTRACT_RESULT: ${{ needs.workspace-contract.result }} + ROOT_PACKAGE_COMPAT_RESULT: ${{ needs.root-package-compat.result }} + WEB_BUILD_RESULT: ${{ needs.web-build.result }} + WEB_BROWSER_RESULT: ${{ needs.web-browser.result }} + ELECTRON_DESKTOP_RESULT: ${{ needs.electron-desktop.result }} + ELECTRON_RELEASE_CANDIDATE_RESULT: ${{ needs.electron-release-candidate.result }} + ELECTRON_CANDIDATE_SET_RESULT: ${{ needs.electron-candidate-set.result }} + DEPENDENCY_AUDIT_RESULT: ${{ needs.dependency-audit.result }} + CROSS_RUNTIME_FIXTURES_RESULT: ${{ needs.cross-runtime-fixtures.result }} DESKTOP_TEST_RESULT: ${{ needs.desktop-test.result }} MACOS_TEST_RESULT: ${{ needs.desktop-macos.result }} LINUX_LOCK_RESULT: ${{ needs.desktop-linux-lock.result }} + C10_EVIDENCE_RESULT: ${{ needs.c10-evidence-bundle.result }} run: | if [ "$NODE_TEST_RESULT" != "success" ] || + [ "$WORKSPACE_CONTRACT_RESULT" != "success" ] || + [ "$ROOT_PACKAGE_COMPAT_RESULT" != "success" ] || + [ "$WEB_BUILD_RESULT" != "success" ] || + [ "$WEB_BROWSER_RESULT" != "success" ] || + [ "$ELECTRON_DESKTOP_RESULT" != "success" ] || + [ "$ELECTRON_RELEASE_CANDIDATE_RESULT" != "success" ] || + [ "$ELECTRON_CANDIDATE_SET_RESULT" != "success" ] || + [ "$DEPENDENCY_AUDIT_RESULT" != "success" ] || + [ "$CROSS_RUNTIME_FIXTURES_RESULT" != "success" ] || [ "$DESKTOP_TEST_RESULT" != "success" ] || [ "$MACOS_TEST_RESULT" != "success" ] || - [ "$LINUX_LOCK_RESULT" != "success" ]; then + [ "$LINUX_LOCK_RESULT" != "success" ] || + [ "$C10_EVIDENCE_RESULT" != "success" ]; then echo "One or more required CI jobs did not succeed." exit 1 fi diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index dbd5d6f..b06d6ae 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -35,7 +35,29 @@ jobs: package-manager-cache: false - run: npm ci + - run: npm run workspaces:check - run: npm run web:build + - run: npx playwright install --with-deps chromium + - run: npm run web:test:e2e - run: npm test - - run: npm pack --dry-run --json + - run: npm run package:smoke + - run: npm audit --omit=dev --audit-level=moderate + - run: npm audit --audit-level=high + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "16.20.2" + package-manager-cache: false + + - run: npm ci --workspaces=false --omit=dev + - run: npm run runtime:verify-node16 + - run: npm run package:verify-root-tree + - run: npm run package:smoke:lifecycle + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + - run: npm publish --access public diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 082faf1..c38ad09 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,16 @@ name: publish on: - push: - tags: - - "v*" + workflow_dispatch: + inputs: + release_tag: + description: Existing v-prefixed release tag to publish + required: true + type: string + +concurrency: + group: publish-${{ inputs.release_tag }} + cancel-in-progress: false permissions: contents: read @@ -14,14 +21,22 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ inputs.release_tag }} fetch-depth: 0 persist-credentials: false - name: Verify release tag commit is on main shell: pwsh + env: + RELEASE_TAG: ${{ inputs.release_tag }} run: | + if ($env:RELEASE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + Write-Error "release_tag must be a v-prefixed semantic version." + exit 1 + } git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - git merge-base --is-ancestor "$env:GITHUB_SHA" "refs/remotes/origin/main" + $releaseCommit = git rev-parse HEAD + git merge-base --is-ancestor "$releaseCommit" "refs/remotes/origin/main" if ($LASTEXITCODE -ne 0) { Write-Error "Release tags must point to a commit contained in main." exit 1 @@ -35,7 +50,7 @@ jobs: - name: Verify release version consistency shell: pwsh env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ inputs.release_tag }} run: | node scripts/verify-release-version.js --tag $env:RELEASE_TAG node scripts/read-release-metadata.js --tag $env:RELEASE_TAG @@ -45,8 +60,29 @@ jobs: dotnet-version: "10.0.x" - run: npm ci + - run: npm run workspaces:check - run: npm run web:build + - run: npx playwright install --with-deps chromium + - run: npm run web:test:e2e - run: npm test + - run: npm run package:smoke + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "16.20.2" + cache: npm + + - run: npm ci --workspaces=false --omit=dev + - run: npm run runtime:verify-node16 + - run: npm run package:verify-root-tree + - run: npm run package:smoke:lifecycle + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - run: npm ci - run: dotnet build CodexProviderSync.sln --configuration Release - run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --configuration Release --no-build - run: dotnet test desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj --configuration Release --no-build @@ -62,7 +98,7 @@ jobs: - name: Package release assets shell: pwsh env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ inputs.release_tag }} run: | $version = $env:RELEASE_TAG.Substring(1) ./scripts/package-release-assets.ps1 -Version $version -PublishOutput artifacts/win-x64 -Output artifacts/release @@ -83,6 +119,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ inputs.release_tag }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.1 @@ -92,7 +129,9 @@ jobs: - name: Resolve versioned Chinese release announcement id: release_metadata shell: bash - run: node scripts/read-release-metadata.js --tag "$GITHUB_REF_NAME" --github-output "$GITHUB_OUTPUT" + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: node scripts/read-release-metadata.js --tag "$RELEASE_TAG" --github-output "$GITHUB_OUTPUT" - name: Download packaged assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -103,6 +142,7 @@ jobs: - name: Upload release assets uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: + tag_name: ${{ inputs.release_tag }} name: ${{ steps.release_metadata.outputs.release_title }} body_path: ${{ steps.release_metadata.outputs.release_body_path }} files: | diff --git a/.gitignore b/.gitignore index 638c30b..b386466 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,23 @@ node_modules/ +apps/*/dist/ +apps/desktop/out/ +apps/desktop/test-results/ +apps/desktop/playwright-report/ +dist-desktop/ +packages/*/dist/ +!packages/contracts/dist/ +!packages/contracts/dist/** +apps/*/*.tsbuildinfo +packages/*/*.tsbuildinfo coverage/ *.tgz artifacts/ desktop/**/bin/ desktop/**/obj/ +test-support/**/obj/ *.csproj.user test/*.exe WORKLOG*.md +.playwright-cli/ +apps/web/test-results/ +apps/web/playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bdee749..fa257d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ 本文件记录面向用户和集成方的重要变化。完整的发布叙事、升级说明和下载入口见对应版本的中文发布说明;实现证据和测试门禁见技术发布说明。 +## [Unreleased] + +### 新增 + +- 有限 CLI 命令新增 opt-in `--json`,stdout 固定为一个 schema v1 终态对象;帮助、输入失败、成功、partial、recovery、busy 和取消共享同一顶层结构。 +- JSON Mode 固化 `0/1/2/3/4/5/130` 退出码矩阵,并使用 Canonical Core Error Code。 + +### 兼容性 + +- 未传入 `--json` 时继续使用既有 Human 输出和 `0/1` 行为;partial sync 在 Human Mode 仍为成功退出。 +- `watch` 与 `web` 暂不提供单文档 JSON 模式,并在创建长运行资源前返回结构化 `INVALID_INPUT`;未来流式机器接口需要独立协议。 +- npm tarball 或 Windows npm shim 使用短路径、长路径或符号链接形式启动 CLI 时,会对入口两侧做物理路径规范化,避免已安装的 `codex-provider` 被误判为模块导入而静默退出。 + +### 安全 + +- JSON 进度只写 stderr 且不报告 backup path;固定错误文案、命令级 result allowlist 和枚举化 details 会阻止非法参数值、未知异常、底层 warning、凭据样式字段、prompt 与消息正文进入 stdout。 +- stdout broken pipe 只尝试一次终态写入;stderr observer 失败不能改变已启动业务操作的结果。 + ## [0.5.0] - 2026-08-15 ### 新增 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 523919c..84f4b73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,15 +16,21 @@ This project welcomes issues, pull requests, documentation, and tests in either ## 项目结构 +当前公开发布的桌面端仍是 .NET Windows GUI;`apps/desktop` 是 vNext Electron 未发布候选。只有 Phase 6 退出门槛和单独发布授权闭合后,文档才会切换为 Electron 主入口并把 .NET 标记为 Legacy。贡献和评审中不得提前把候选描述为默认、Stable 或已发布产品。 + | 路径 | 内容 | | --- | --- | -| `src/` | Node.js CLI、Web 服务和共享同步逻辑 | -| `web/` | Local Web UI 前端 | +| `src/` | 兼容 Node.js CLI、Local Web Host 与 Node Core 实现 | +| `apps/cli/` | vNext CLI workspace 边界 | +| `apps/web/` | 共享 React UI 的 Local Web 组合与浏览器 E2E | +| `apps/desktop/` | vNext Electron 候选:Main、Preload、Renderer、Utility Process、打包与 E2E | +| `packages/` | Core、Contracts、CoreClient、App UI、Design System 与脱敏 Test Fixtures | +| `web/` | 根 npm 包携带的 Local Web UI production 输出与兼容入口 | | `test/` | Node.js 自动化测试 | -| `desktop/CodexProviderSync.Core/` | Windows 与 macOS GUI 共用的 .NET 核心逻辑 | -| `desktop/CodexProviderSync.Application/` | Windows GUI 与 Automation 共用的应用用例 | -| `desktop/CodexProviderSync.App/` | Windows WinForms GUI | -| `desktop/CodexProviderSync.Mac/` | macOS Avalonia GUI | +| `desktop/CodexProviderSync.Core/` | 当前发布桌面端共用的 .NET 核心;迁移期保持锁协议和兼容维护 | +| `desktop/CodexProviderSync.Application/` | 当前 Windows GUI 与 Automation 共用的应用用例 | +| `desktop/CodexProviderSync.App/` | 当前已发布的 Windows WinForms GUI | +| `desktop/CodexProviderSync.Mac/` | 迁移期保留的 macOS Avalonia 本地构建 | | `desktop/CodexProviderSync.Automation/` | 实验性的 Windows Automation 接口 | | `desktop/*Tests/` | .NET 自动化测试 | | `scripts/` | GUI 构建和 WSL 安全验证脚本 | @@ -35,7 +41,7 @@ This project welcomes issues, pull requests, documentation, and tests in either 基础开发需要: - Git -- Node.js 16.20.2 或更高版本;CI 验证最低版本和当前发布矩阵 +- Node.js 16.20.2 或更高版本用于根 CLI 包;现代 workspace、Web 构建和 Electron 使用 Node 24 - npm - .NET 10 SDK(修改 .NET Core 或 GUI 时) - PowerShell 7(修改或验证 Windows 打包脚本时) @@ -46,6 +52,7 @@ This project welcomes issues, pull requests, documentation, and tests in either npm ci npm test npm run web:build +npm run workspaces:check ``` 运行共享 Core 和 Windows GUI 测试: @@ -85,6 +92,7 @@ dotnet build desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj --config | 文档 | 检查链接、路径和命令;同一内容有多个语言版本时保持一致 | | Node.js CLI | `npm test` | | Local Web UI | `npm run web:build`、`npm test`;界面改动附浏览器截图或说明未手测原因 | +| vNext Electron 候选 | `npm run desktop:test`、`npm run desktop:test:e2e`、production bundle 审计;界面自动化默认 hidden,平台打包改动还需原生候选容器 smoke | | 共享 .NET Core | Core Tests;涉及 CLI 时同时运行 `npm test` | | Windows GUI | Core Tests、App Tests;布局改动附 Windows 截图或说明未手测原因 | | macOS GUI | Core Tests、macOS Release build;真实 macOS GUI 手测无法完成时,在 PR 中明确记录 | @@ -113,13 +121,13 @@ PR 中请特别说明: ## 准备发布 -CLI/Web npm 包和 Windows GitHub Release 独立发布,版本号可能不同。 +CLI/Web npm 包、当前 .NET Windows GitHub Release 和未来 Electron Release 是不同的受控发布路径,版本号可能不同。任何贡献或 CI 候选都不会自动授权公开发布。 ### CLI / Web npm 包 按 [npm 发布维护指南](docs/NPM_PUBLISHING.md) 更新 `package.json` 与 `package-lock.json`、完成构建和测试,并从 `main` 手动运行受信发布工作流。仅发布 CLI/Web 时不创建 Git tag 或 Windows Release。 -### Windows GitHub Release +### 当前 .NET Windows GitHub Release 发布 tag 前需要: @@ -131,11 +139,13 @@ CLI/Web npm 包和 Windows GitHub Release 独立发布,版本号可能不同 发布工作流会读取与 tag 同名的中文发布说明,并生成单文件 GUI、独立 Automation ZIP、Windows 完整包和对应 SHA-256。缺少发布说明、标题与 tag 不匹配,或遗漏固定的下载、安全和限制声明时会直接停止。 +这一流程只描述当前 .NET Windows Release,不授权发布 `apps/desktop` Electron。Electron 候选固定使用 `--publish never`;公开 tag、GitHub Release、签名、公证、更新 metadata 和跨版本升级验证都必须在 C10 证据闭合后另行授权。 + ## English quick guide - Small fixes, tests, and documentation updates can be submitted directly as a PR. Please open an Issue before starting a large feature, behavior change, or refactor. - If you do not have write access, fork the repository, push your branch to your fork, and open a PR against this repository's `main` branch. -- Use Node.js 16.20.2 or later and run `npm ci`, `npm test`, and `npm run web:build`. Changes to shared .NET or desktop code also require the relevant .NET 10 tests listed above. +- Use Node.js 16.20.2 or later for the compatible root CLI package. Modern workspaces, Web builds, and Electron use Node 24; run `npm ci`, `npm test`, `npm run workspaces:check`, and the affected Web or Electron gates. Changes to shared .NET code also require the relevant .NET 10 tests listed above. - Automated tests and reproduction scripts must use temporary directories or fixtures and must not depend on, read, or modify a real user's `~/.codex`. Prefer a dedicated test Codex Home for manual validation and describe its scope in the PR. - Never include unredacted credentials, `auth.json`, Codex sessions, SQLite databases, backups, logs, tokens, or personal data. - Keep each PR focused. Explain why the change is needed, what it writes, which platforms it affects, what was tested, and what was not tested. @@ -143,6 +153,7 @@ CLI/Web npm 包和 Windows GitHub Release 独立发布,版本号可能不同 - Update affected documentation when user-visible behavior, command options, or safety boundaries change. - All changes go through a PR and must pass `ci-gate`. - The CLI/Web npm package and Windows GitHub Release are independent release channels; follow `docs/NPM_PUBLISHING.md` for npm releases. +- The published desktop product is still the Windows .NET GUI. `apps/desktop` is an unreleased Electron candidate and must not be called default, Stable, or released until Phase 6 and separately authorized release validation close. ## License diff --git a/README.md b/README.md index 7e78d4c..91b9007 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,18 @@ ## 快速开始 -> CLI/Web 与 Windows GUI 独立发布,版本号可能不同。 +> CLI/Web 与当前 Windows GUI 独立发布,版本号可能不同。 +> +> **V1 候选定位:**本 PR 按 C10 将 Electron 标记为新版主桌面端候选,将保留的 .NET Windows/macOS 实现标记为交接后的 Legacy fallback。**公开发行状态另计:**当前 Releases 仍只提供 Windows .NET GUI;Electron 尚未合入 `main`、未发布、未签名,也不是当前可下载或自动更新的产品。候选角色不表示 Electron 已经公开替代 .NET。 | 场景 | 推荐入口 | | --- | --- | -| Windows 桌面 | [下载 Windows GUI](https://github.com/Dailin521/codex-provider-sync/releases/latest) · [使用说明](#windows-gui) | +| Windows 桌面 | [下载当前公开 Windows GUI(.NET)](https://github.com/Dailin521/codex-provider-sync/releases/latest) · [`V1` Electron 主桌面端候选说明(尚无公开下载)](docs/README_DESKTOP_ZH.md) | | macOS 桌面 | [本地 Web UI(需 CLI)](#本地-web-ui);[原生 GUI 构建说明](docs/README_MAC_GUI_ZH.md) | | 需要浏览器界面或跨平台使用 | [本地 Web UI(需 CLI)](#本地-web-ui) | | 脚本、CI 或 WSL | [CLI](#cli) | -### Windows GUI +### 当前公开 Windows GUI(.NET;V1 候选中的 Legacy fallback) 从 [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest) 下载 `CodexProviderSync.exe`: @@ -53,6 +55,8 @@ [Windows GUI 完整说明](docs/README_GUI_ZH.md) +新版 Electron 主桌面端候选的能力、安全边界和内部验收方式见 [Electron 主桌面端候选说明](docs/README_DESKTOP_ZH.md)。候选角色不等于公开发行;该说明不提供下载,也不构成发布授权。 + ### 本地 Web UI 本地 Web UI 由 CLI 提供。安装 Node.js `16.20.2+` 后,安装本项目官方 npm 包并启动: @@ -74,13 +78,13 @@ codex-provider web --port 8792 # 指定端口 codex-provider web --reset-access # 重新配对浏览器 ``` -Web UI 默认只监听 `127.0.0.1`,并自动打开浏览器完成配对。存储路径由页面顶部的存储配置(Profile)管理,写操作需要确认。 +Web UI 默认只监听 `127.0.0.1`,并自动打开浏览器完成配对。共享 React 界面通过版本化 `HttpCoreClient` 调用本地 Web Host;存储路径由服务端 Profile 管理,Sync、Switch 与 Restore 均先显示计划再确认。 #### 切换 Provider 后同步历史 1. 使用 CCSwitch 等常用工具切换 Provider。 -2. 在 Web UI 点击“读取状态”(可跳过)。 -3. 保持“仅同步元数据”,选择目标 Provider(供应商),确认执行同步。 +2. 在“概览”检查当前 Provider 与两侧分布。 +3. 进入“同步”,生成计划并确认执行。 4. 显示“Provider 元数据已对齐”即完成。 > **注意:** 元数据同步只能恢复历史可见性。跨供应商继续旧会话时,目标后端可能无法解密会话中的 `encrypted_content` 推理内容,导致继续对话或压缩(compact)失败。 @@ -107,21 +111,37 @@ codex-provider sync `switch` 默认会在目标 Provider section 定义了 `model` 时同步根级 `model`。使用 `--keep-root-model` 保留当前值,或使用 `--model ` 显式指定。 +有限命令可使用 `--json` 供自动化读取。stdout 只输出一个 schema v1 终态对象,进度和运行时诊断进入 stderr;JSON Mode 使用 `0/1/2/3/4/5/130` 细分退出码,Human Mode 继续保持既有 `0/1` 行为。例如: + +```bash +codex-provider status --json +codex-provider sync --json +codex-provider switch openai --json +``` + +`watch` 和 `web` 是长运行命令,当前不支持单文档 JSON Mode;传入 `--json` 会在启动 watcher/server 前返回结构化输入错误。完整合同见 [CLI 命令兼容合同](docs/architecture/contracts/CLI_CONTRACT_ZH.md)。 + SQLite Home 解析顺序:`--sqlite-home` → `config.toml` 根级 `sqlite_home` → `CODEX_SQLITE_HOME` → `/sqlite`。只有默认布局会回退到 `/state_5.sqlite`。 ## 当前架构 ```mermaid flowchart LR - Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] - WebServer --> NodeService["Node Service"] - CLI["Node CLI"] --> NodeService + Browser["Browser React UI"] --> HttpClient["HttpCoreClient"] + HttpClient --> WebServer["Local Web Host
127.0.0.1 + pairing"] + WebServer --> NodeCore["Node Core public facade"] + CLI["Node CLI"] --> NodeCore - WindowsGUI["Windows GUI"] --> Application[".NET Application"] + ElectronRenderer["Electron Renderer
V1 primary desktop candidate"] --> DesktopClient["DesktopCoreClient"] + DesktopClient --> ElectronHost["Preload / Main
narrow IPC"] + ElectronHost --> Utility["Utility Process"] + Utility --> NodeCore + + WindowsGUI[".NET GUI
published now; V1 Legacy fallback target"] --> Application[".NET Application"] Application --> DotNetCore[".NET Core"] MacGUI["macOS GUI"] --> DotNetCore - NodeService --> Storage["Codex Storage"] + NodeCore --> Storage["Codex Storage"] DotNetCore --> Storage Storage --> Config["config.toml"] @@ -130,10 +150,13 @@ flowchart LR Storage --> Backups["managed backups"] ``` -- Web UI 和 CLI 使用同一套 Node 服务逻辑。 +- Web UI 的业务请求经 `HttpCoreClient → /api/core → Node Core public facade`;CLI 直接调用同一公开 Core 边界,不解析彼此的人类输出。 +- `V1` 新版 Electron 主桌面端候选经 `DesktopCoreClient → 窄 Preload/Main IPC → Utility Process → Node Core`,Renderer 不接触 Node、任意路径或通用 IPC。 - Windows GUI 通过 Application 层调用 .NET Core;macOS GUI 当前直接调用 .NET Core。 - Node 服务和 .NET Core 处理相同的配置、rollout、SQLite 和备份安全边界。 +`V1` 候选按 C10 携带“Electron 新版主桌面端 / .NET Legacy fallback”的交接目标;.NET 仍可构建、测试并至少保留两个维护周期。当前公开 Releases 仍是 .NET,Electron 尚未合入、发布或签名。不得把候选中的角色标识表述成已经发生的公开入口切换;后续发布门槛见 [vNext 分阶段迁移执行索引](docs/migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md)。 + ## 安全边界 - 每次 `sync` / `switch` 前备份到 `/backups_state/provider-sync/`;使用默认 Codex Home 时即为 `~/.codex/backups_state/provider-sync/`。 @@ -149,6 +172,7 @@ flowchart LR - [vNext 分阶段迁移执行索引](docs/migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md) - [AI / Agent 操作指南](AGENTS.md) - [Windows GUI](docs/README_GUI_ZH.md) +- [V1 Electron 主桌面端候选说明](docs/README_DESKTOP_ZH.md) - [Web UI](docs/README_WEB_UI_ZH.md) - [English](docs/README_EN.md) · [日本語](docs/README_JA.md) · [한국어](docs/README_KO.md) - [macOS GUI:中文](docs/README_MAC_GUI_ZH.md) · [English](docs/README_MAC_GUI_EN.md) diff --git a/apps/cli/checks/ownership.contract.mjs b/apps/cli/checks/ownership.contract.mjs new file mode 100644 index 0000000..3e149db --- /dev/null +++ b/apps/cli/checks/ownership.contract.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CLI_MIGRATION_STATE } from "../src/ownership.js"; + +test("CLI workspace records root-package compatibility ownership", () => { + assert.deepEqual(CLI_MIGRATION_STATE, { + compatibilityEntrypoint: "src/cli.js", + owner: "apps/cli", + implementationMoved: false + }); + assert.ok(Object.isFrozen(CLI_MIGRATION_STATE)); +}); diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..2ab56c8 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,13 @@ +{ + "name": "@codex-provider-sync/cli", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": "./src/ownership.js", + "scripts": { + "test": "node --test checks/ownership.contract.mjs" + }, + "engines": { + "node": ">=16.20.2" + } +} diff --git a/apps/cli/src/ownership.js b/apps/cli/src/ownership.js new file mode 100644 index 0000000..ebc21c8 --- /dev/null +++ b/apps/cli/src/ownership.js @@ -0,0 +1,7 @@ +// C4 establishes package ownership without moving the compatibility binary. +// The published root package must continue to execute src/cli.js on Node 16. +export const CLI_MIGRATION_STATE = Object.freeze({ + compatibilityEntrypoint: "src/cli.js", + owner: "apps/cli", + implementationMoved: false +}); diff --git a/apps/desktop/checks/ownership.contract.mjs b/apps/desktop/checks/ownership.contract.mjs new file mode 100644 index 0000000..e5be67c --- /dev/null +++ b/apps/desktop/checks/ownership.contract.mjs @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DESKTOP_RUNTIME_STATE } from "../dist/index.js"; + +test("desktop workspace records the C8 Restore and Watch runtime boundary", () => { + assert.equal(DESKTOP_RUNTIME_STATE, "restore-watch-c8"); +}); diff --git a/apps/desktop/checks/security.contract.mjs b/apps/desktop/checks/security.contract.mjs new file mode 100644 index 0000000..946f62b --- /dev/null +++ b/apps/desktop/checks/security.contract.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; + +const desktopRoot = path.resolve(import.meta.dirname, ".."); + +async function read(relativePath) { + return fs.readFile(path.join(desktopRoot, relativePath), "utf8"); +} + +async function filesUnder(relativeRoot) { + const root = path.join(desktopRoot, relativeRoot); + const files = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) files.push(absolute); + } + } + await visit(root); + return files; +} + +test("Renderer has no Node, Electron, filesystem, Core or arbitrary IPC imports", async () => { + const sources = await filesUnder("src/renderer"); + const text = (await Promise.all(sources.map((file) => fs.readFile(file, "utf8")))).join("\n"); + assert.doesNotMatch(text, /from\s+["'](?:node:|electron(?:\/|["']))/); + assert.doesNotMatch(text, /@codex-provider-sync\/core(?:["'/])/); + assert.doesNotMatch(text, /ipcRenderer|BrowserWindow|child_process|node:fs|node:path/); + assert.match(text, /DesktopCoreClient/); + assert.match(text, /DESKTOP_C8_APP_UI_CAPABILITIES/); + assert.match(text, /surface="desktop"/); +}); + +test("Renderer applies an allowlisted persisted theme before the application bundle", async () => { + const html = await read("src/renderer/index.html"); + const bootstrap = await read("src/renderer/public/theme-bootstrap.js"); + assert.ok(html.indexOf("/theme-bootstrap.js") < html.indexOf("./main.tsx")); + assert.match(bootstrap, /cps\.desktop\.theme/); + assert.match(bootstrap, /theme === "system" \|\| theme === "light" \|\| theme === "dark"/); + assert.doesNotMatch(bootstrap, /eval|Function\s*\(|innerHTML|document\.write/); +}); + +test("Preload exposes one frozen purpose-built bridge and no raw IPC surface", async () => { + const source = await read("src/preload/index.ts"); + assert.match(source, /exposeInMainWorld\("codexProvider"/); + assert.match(source, /requestReadOnly/); + assert.match(source, /requestSyncSwitch/); + assert.match(source, /requestRestore/); + assert.match(source, /requestMaintenance/); + assert.match(source, /diagnosticsExport/); + assert.match(source, /updateStatus/); + assert.match(source, /updateCheck/); + assert.match(source, /updateDownload/); + assert.match(source, /updateInstall/); + assert.match(source, /subscribeOperation/); + assert.match(source, /cancelOperation/); + assert.match(source, /ipcRenderer\.on\(DESKTOP_IPC_CHANNELS\.operationEvent/); + assert.doesNotMatch(source, /ipcRenderer\.(?:send|sendSync|once|postMessage)\s*\(/); + assert.doesNotMatch(source, /node:(?:fs|path|child_process)|@codex-provider-sync\/core["']/); + assert.doesNotMatch(source, /exposeInMainWorld\([^,]+,\s*ipcRenderer/); + assert.match(source, /__CPS_DESKTOP_TEST_BUILD__/); +}); + +test("Main security policy fixes the BrowserWindow, CSP, protocol and deny defaults", async () => { + const policy = await read("src/main/security-policy.ts"); + const security = await read("src/main/security.ts"); + const constants = await read("src/shared/constants.ts"); + for (const expected of [ + "nodeIntegration: false", + "nodeIntegrationInWorker: false", + "contextIsolation: true", + "sandbox: true", + "webSecurity: true", + "allowRunningInsecureContent: false", + "experimentalFeatures: false", + "webviewTag: false" + ]) assert.match(policy, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(constants, /script-src 'self'/); + assert.doesNotMatch(constants, /unsafe-inline|unsafe-eval/); + assert.match(security, /setWindowOpenHandler\(\(\) => \(\{ action: "deny" \}\)\)/); + assert.match(security, /setPermissionCheckHandler\(\(\) => false\)/); + assert.match(security, /setPermissionRequestHandler/); + assert.match(security, /will-navigate/); + assert.match(security, /will-attach-webview/); +}); + +test("Utility imports only the Core public package and the exact C8 method surface", async () => { + const runtime = await read("src/runtime/host.ts"); + const protocol = await read("src/shared/runtime-protocol.ts"); + const clientPolicy = await read("../../packages/core-client/src/desktop.ts"); + assert.match(runtime, /from "@codex-provider-sync\/core"/); + assert.doesNotMatch(runtime, /src\/(?:public-api|service|backup|locking|history|watch)/); + assert.doesNotMatch(runtime, /\.\.\/main\//); + for (const allowed of [ + "prepareSync", + "applySync", + "prepareSwitch", + "applySwitch", + "prepareRestore", + "applyRestore", + "pruneBackups", + "startWatch", + "stopWatch", + "getWatchStatus" + ]) { + assert.match(clientPolicy, new RegExp(`\\"${allowed}\\"`)); + } + assert.doesNotMatch(clientPolicy, /runSync|runSwitch|runRestore|runWatch/); + assert.match(protocol, /dispatchId/); + assert.match(protocol, /operation-event/); + assert.match(protocol, /RuntimeCancelFrame/); +}); + +test("Diagnostics export is fixed-entry and updates stay in one narrow Main-only controller", async () => { + const diagnostics = await read("src/main/diagnostics-export.ts"); + const policy = await read("src/main/update-policy.ts"); + const updates = await read("src/main/updater.ts"); + const bridge = await read("src/shared/bridge.ts"); + for (const entry of [ + "app-info.json", + "status-summary.json", + "storage-layout.json", + "pending-transaction-summary.json", + "recent-redacted-logs/README.txt" + ]) assert.match(diagnostics, new RegExp(entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(diagnostics, /auth\.json|encrypted_content|rollout-.*\.jsonl|state_5\.sqlite/); + assert.doesNotMatch(policy, /electron-updater|autoUpdater|node:https|node:http|\bfetch\s*\(|downloadUpdate|quitAndInstall/); + assert.match(updates, /import\("electron-updater"\)/); + assert.match(updates, /autoDownload = false/); + assert.match(updates, /autoInstallOnAppQuit = false/); + assert.match(updates, /quitAndInstall\(false, true\)/); + assert.doesNotMatch(updates, /setFeedURL|node:https|node:http|\bfetch\s*\(/); + assert.doesNotMatch(bridge, /url|channel|filePath|targetPath|releaseNotes/); + const sourceFiles = await filesUnder("src"); + const updaterImports = []; + for (const file of sourceFiles) { + const source = await fs.readFile(file, "utf8"); + if (source.includes("electron-updater")) updaterImports.push(path.relative(desktopRoot, file)); + } + assert.deepEqual(updaterImports, [path.join("src", "main", "updater.ts")]); +}); + +test("electron-vite emits a CJS sandbox preload and keeps source maps disabled", async () => { + const config = await read("electron.vite.config.ts"); + assert.match(config, /format: "cjs"/); + assert.match(config, /entryFileNames: "\[name\]\.cjs"/); + assert.match(config, /inlineDynamicImports: true/); + assert.equal((config.match(/sourcemap: false/g) ?? []).length, 3); + assert.match(config, /external: \["electron"\]/); + assert.match(config, /__CPS_DESKTOP_TEST_BUILD__:\s*JSON\.stringify\(mode === "test"\)/); + assert.match(config, /__CPS_DESKTOP_FORCE_BETTER_SQLITE3__:\s*JSON\.stringify\(mode === "test"\)/); + assert.match(config, /__CPS_DESKTOP_RELEASE_AUTHORIZED__:\s*JSON\.stringify\(desktopReleaseAuthorized\)/); + const sqlite = await read("../../src/sqlite.js"); + assert.match(sqlite, /typeof __CPS_DESKTOP_FORCE_BETTER_SQLITE3__ !== "undefined"/); + assert.doesNotMatch(sqlite, /process\.env\.[A-Za-z0-9_]*SQLITE|CPS_FORCE_BETTER_SQLITE3/); +}); + +test("packaging always replaces test output with a verified production bundle", async () => { + const packageDocument = JSON.parse(await read("package.json")); + assert.equal( + packageDocument.scripts["pack:dir"], + "npm run build && npm run build:electron && npm run verify:production-bundle && electron-builder --dir --config electron-builder.yml" + ); +}); diff --git a/apps/desktop/e2e/desktop-production-boundary.spec.mjs b/apps/desktop/e2e/desktop-production-boundary.spec.mjs new file mode 100644 index 0000000..d122fbd --- /dev/null +++ b/apps/desktop/e2e/desktop-production-boundary.spec.mjs @@ -0,0 +1,373 @@ +import { createRequire } from "node:module"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { _electron as electron, chromium, expect, test } from "@playwright/test"; + +import { createDesktopReadOnlyFixture } from "../../../test-support/desktop-readonly-fixture.mjs"; +import { createDesktopSyncSwitchFixture } from "../../../test-support/desktop-sync-switch-fixture.mjs"; + +const require = createRequire(import.meta.url); +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packagedExecutable = process.env.CPS_DESKTOP_EXECUTABLE; +const electronExecutable = packagedExecutable || require("electron"); +const PRODUCTION_SMOKE_TIMEOUT_MS = 150_000; +const PRODUCTION_OPERATION_TIMEOUT_MS = 30_000; +const PRODUCTION_READY_TIMEOUT_MS = 20_000; +const PRODUCTION_BRIDGE_TIMEOUT_MS = 30_000; +const PRODUCTION_CDP_TIMEOUT_MS = 60_000; +const PRODUCTION_CLOSE_TIMEOUT_MS = 10_000; + +async function withDeadline(label, task, timeoutMs) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs); + timer.unref?.(); + }); + try { + return await Promise.race([task, timeout]); + } finally { + clearTimeout(timer); + } +} + +async function waitForProductionReady(page) { + await expect(page).toHaveURL("cps-app://app/index.html", { + timeout: PRODUCTION_READY_TIMEOUT_MS + }); + await page.waitForLoadState("load", { timeout: PRODUCTION_READY_TIMEOUT_MS }); + await expect(page.getByText("Codex Provider Sync", { exact: true })).toBeVisible({ + timeout: PRODUCTION_READY_TIMEOUT_MS + }); + // The provider distribution is populated only after the Renderer has + // completed its first real Core Status request. + await expect(page.getByText("openai", { exact: true }).first()).toBeVisible({ + timeout: PRODUCTION_READY_TIMEOUT_MS + }); +} + +function waitForExit(child, timeoutMs) { + if (child.exitCode !== null) return Promise.resolve(true); + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.off("exit", onExit); + resolve(false); + }, timeoutMs); + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + child.once("exit", onExit); + }); +} + +async function forceStopPackagedChild(child) { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + if (await waitForExit(child, 5_000)) return; + child.kill("SIGKILL"); + if (!await waitForExit(child, 5_000)) { + throw new Error("Packaged desktop process could not be terminated after activation failure."); + } +} + +async function launchPackagedDesktop({ args, env }) { + const child = spawn(packagedExecutable, [...args, "--remote-debugging-port=0"], { + env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + let launchOutput = ""; + let browser; + let page; + try { + const endpoint = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => finish(new Error(`Timed out waiting for packaged DevTools endpoint. ${launchOutput}`)), 15_000); + const finish = (error, value) => { + clearTimeout(timeout); + child.stdout?.off("data", onData); + child.stderr?.off("data", onData); + child.off("error", onError); + child.off("exit", onExit); + if (error) reject(error); + else resolve(value); + }; + const onData = (chunk) => { + launchOutput = `${launchOutput}${chunk}`.slice(-16_384); + const match = launchOutput.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (match) finish(null, match[1]); + }; + const onError = (error) => finish(error); + const onExit = (code) => finish(new Error(`Packaged desktop exited before CDP was ready (${code}). ${launchOutput}`)); + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.once("error", onError); + child.once("exit", onExit); + }); + browser = await chromium.connectOverCDP(endpoint, { + timeout: PRODUCTION_CDP_TIMEOUT_MS + }); + for (let attempt = 0; attempt < 100 && !page; attempt += 1) { + page = browser.contexts().flatMap((context) => context.pages())[0]; + if (!page) await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (!page) throw new Error("Packaged desktop did not create a renderer page."); + } catch (activationError) { + let cleanupError; + try { + if (browser) { + await withDeadline("Failed packaged desktop CDP close", browser.close(), 5_000).catch(() => {}); + } + await forceStopPackagedChild(child); + } catch (error) { + cleanupError = error; + } + if (cleanupError) { + throw new AggregateError( + [activationError, cleanupError], + "Packaged desktop activation failed and its process could not be cleaned up." + ); + } + throw activationError; + } + return { + async firstWindow() { + return page; + }, + async close() { + let pageCloseError; + try { + if (!page.isClosed()) { + await withDeadline( + "Packaged desktop page close", + page.close({ runBeforeUnload: true }), + PRODUCTION_CLOSE_TIMEOUT_MS + ); + } + } catch (error) { + // A graceful application shutdown may close CDP before Playwright receives acknowledgement. + pageCloseError = error; + } + let exited = await waitForExit(child, process.platform === "darwin" ? 500 : 10_000); + if (!exited && process.platform === "darwin") { + child.kill("SIGTERM"); + exited = await waitForExit(child, 10_000); + } + await withDeadline( + "Packaged desktop CDP close", + browser.close(), + 5_000 + ).catch(() => {}); + if (!exited) { + child.kill("SIGKILL"); + await waitForExit(child, 5_000); + const detail = pageCloseError instanceof Error ? ` ${pageCloseError.message}` : ""; + throw new Error(`Packaged desktop did not complete a graceful shutdown.${detail}`); + } + } + }; +} + +function launchProductionDesktop(options) { + return packagedExecutable + ? launchPackagedDesktop(options) + : electron.launch({ executablePath: electronExecutable, ...options }); +} + +test("production desktop bundle has no test bridge and reads the real SQLite fixture", async () => { + test.setTimeout(PRODUCTION_SMOKE_TIMEOUT_MS); + const fixture = await createDesktopReadOnlyFixture(); + let electronApp; + try { + electronApp = await launchProductionDesktop({ + args: [ + ...(packagedExecutable ? [] : [path.join(desktopRoot, "out", "main", "index.js")]), + `--user-data-dir=${fixture.userData}`, + "--lang=en-US" + ], + env: { + ...process.env, + CODEX_HOME: fixture.codexHome, + CPS_DESKTOP_E2E: "1", + CPS_DESKTOP_WINDOW_DISPLAY: "hidden", + ELECTRON_ENABLE_SECURITY_WARNINGS: "true" + } + }); + const page = await electronApp.firstWindow(); + await test.step("wait for the production UI to finish its first Status request", async () => { + await waitForProductionReady(page); + }); + const boundary = await page.evaluate(() => ({ + bridgeKeys: Object.keys(window.codexProvider).sort(), + coreKeys: Object.keys(window.codexProvider.core).sort(), + updateKeys: Object.keys(window.codexProvider.updates).sort(), + process: typeof globalThis.process, + require: typeof globalThis.require + })); + expect(boundary).toEqual({ + bridgeKeys: ["core", "diagnostics", "profiles", "updates", "version"], + coreKeys: [ + "cancelOperation", + "requestMaintenance", + "requestReadOnly", + "requestRestore", + "requestSyncSwitch", + "subscribeOperation" + ], + updateKeys: ["check", "download", "getStatus", "install"], + process: "undefined", + require: "undefined" + }); + + const updateStatus = await test.step("read the production update status", () => withDeadline( + "Production update status", + page.evaluate(() => window.codexProvider.updates.getStatus()), + PRODUCTION_BRIDGE_TIMEOUT_MS + )); + expect(updateStatus.schemaVersion).toBe(2); + expect(updateStatus.installAllowed).toBe(false); + expect(JSON.stringify(updateStatus)).not.toMatch(/url|path|releaseNotes|token/i); + + const profile = (await test.step("read the production profile list", () => withDeadline( + "Production profile list", + page.evaluate(() => window.codexProvider.profiles.list()), + PRODUCTION_BRIDGE_TIMEOUT_MS + ))).profiles[0]; + const status = await test.step("read production Core Status", () => withDeadline( + "Production Core Status", + page.evaluate(async ({ profile }) => window.codexProvider.core.requestReadOnly({ + protocolVersion: 1, + requestId: "c6-production-status", + method: "getStatus", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision } } + }), { profile }), + PRODUCTION_BRIDGE_TIMEOUT_MS + )); + expect(status.ok).toBe(true); + expect(status.result.sqliteCounts.sessions.openai).toBe(1); + expect(status.result.pendingRecovery).toBe(true); + + const denied = await test.step("reject a write over the read-only bridge", () => withDeadline( + "Production read-only permission check", + page.evaluate(async ({ profile }) => window.codexProvider.core.requestReadOnly({ + protocolVersion: 1, + requestId: "c6-production-write-denied", + method: "prepareSync", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision }, keepCount: 5 } + }), { profile }), + PRODUCTION_BRIDGE_TIMEOUT_MS + )); + expect(denied.ok).toBe(false); + expect(denied.error.code).toBe("PERMISSION_DENIED"); + + const recoveryBlocked = await test.step("block a real write while recovery is pending", () => withDeadline( + "Production recovery write gate", + page.evaluate(async ({ profile }) => window.codexProvider.core.requestSyncSwitch({ + protocolVersion: 1, + requestId: "c7-production-recovery-blocked", + method: "prepareSync", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision }, keepCount: 5 } + }), { profile }), + PRODUCTION_BRIDGE_TIMEOUT_MS + )); + expect(recoveryBlocked.ok).toBe(false); + expect(recoveryBlocked.error.code).toBe("PENDING_TRANSACTION"); + + await page.getByRole("button", { name: "History" }).click({ + timeout: PRODUCTION_READY_TIMEOUT_MS + }); + await expect(page.getByText("Untitled session", { exact: true })).toBeVisible(); + await expect(page.locator("body")).not.toContainText("C6_DESKTOP_BODY_ONLY_MARKER"); + } finally { + let closeError; + try { + await electronApp?.close(); + } catch (error) { + closeError = error; + } + try { + await fixture.assertUnchanged(); + } finally { + await fixture.close(); + } + if (closeError) throw closeError; + } +}); + +test("production or unpacked desktop completes real Sync and Restore through Utility Core", async () => { + test.setTimeout(PRODUCTION_SMOKE_TIMEOUT_MS); + const fixture = await createDesktopSyncSwitchFixture(); + const baseline = await fixture.snapshotTargets(); + let electronApp; + try { + electronApp = await launchProductionDesktop({ + args: [ + ...(packagedExecutable ? [] : [path.join(desktopRoot, "out", "main", "index.js")]), + `--user-data-dir=${fixture.userData}`, + "--lang=en-US" + ], + env: { + ...process.env, + CODEX_HOME: fixture.codexHome, + CPS_DESKTOP_E2E: "1", + CPS_DESKTOP_WINDOW_DISPLAY: "hidden", + ELECTRON_ENABLE_SECURITY_WARNINGS: "true" + } + }); + const page = await electronApp.firstWindow(); + await test.step("wait for the production UI to finish its first Status request", async () => { + await waitForProductionReady(page); + }); + await page.evaluate(() => { + globalThis.__productionOperationEvents = []; + globalThis.__productionUnsubscribe = window.codexProvider.core.subscribeOperation((event) => { + globalThis.__productionOperationEvents.push(event); + }); + }); + await page.getByRole("button", { name: "Sync" }).click(); + await page.getByRole("button", { name: "Prepare sync" }).click(); + const dialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(page.getByText("Operation completed.", { exact: true })).toBeVisible({ + timeout: PRODUCTION_OPERATION_TIMEOUT_MS + }); + await page.getByRole("dialog", { name: "Operation result" }).getByRole("button", { name: "Close" }).last().click(); + await expect.poll( + async () => (await fixture.inspect()).sqlite.provider, + { timeout: PRODUCTION_OPERATION_TIMEOUT_MS } + ).toBe("openai"); + const state = await fixture.inspect(); + expect(state.rollout.model_provider).toBe("openai"); + expect(state.backupIds).toHaveLength(1); + const syncBackupId = state.backupIds[0]; + + await page.getByRole("button", { name: "Backups / Restore" }).click(); + await page.getByRole("button", { name: new RegExp(syncBackupId) }).click(); + await page.getByRole("button", { name: "Prepare restore" }).click(); + const restoreDialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(restoreDialog).toBeVisible(); + await restoreDialog.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(page.getByText("Operation completed.", { exact: true }).last()).toBeVisible({ + timeout: PRODUCTION_OPERATION_TIMEOUT_MS + }); + await expect.poll( + async () => (await fixture.snapshotTargets()).hash, + { timeout: PRODUCTION_OPERATION_TIMEOUT_MS } + ).toBe(baseline.hash); + + const events = await page.evaluate(() => globalThis.__productionOperationEvents); + expect(events.filter((event) => event.event === "operation-started")).toHaveLength(2); + expect(JSON.stringify(events)).not.toMatch(/codex-home|state_5\.sqlite|backupDir|messageBody/i); + } finally { + let closeError; + try { + await electronApp?.close(); + } catch (error) { + closeError = error; + } + await fixture.close(); + if (closeError) throw closeError; + } +}); diff --git a/apps/desktop/e2e/desktop-readonly.spec.mjs b/apps/desktop/e2e/desktop-readonly.spec.mjs new file mode 100644 index 0000000..38f9c8d --- /dev/null +++ b/apps/desktop/e2e/desktop-readonly.spec.mjs @@ -0,0 +1,239 @@ +import { createRequire } from "node:module"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { _electron as electron, expect, test } from "@playwright/test"; + +import { createDesktopReadOnlyFixture } from "../../../test-support/desktop-readonly-fixture.mjs"; + +const require = createRequire(import.meta.url); +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packagedExecutable = process.env.CPS_DESKTOP_EXECUTABLE; +const electronExecutable = packagedExecutable || require("electron"); + +test("secure desktop exposes the C8 surface narrowly and blocks ordinary writes during recovery", async () => { + test.setTimeout(90_000); + const fixture = await createDesktopReadOnlyFixture(); + const diagnosticsTarget = path.join(fixture.fixtureRoot, "diagnostics.zip"); + let electronApp; + try { + electronApp = await electron.launch({ + executablePath: electronExecutable, + args: packagedExecutable + ? ["--lang=en-US"] + : [path.join(desktopRoot, "out", "main", "index.js"), "--lang=en-US"], + env: { + ...process.env, + CPS_DESKTOP_E2E: "1", + CPS_DESKTOP_CODEX_HOME: fixture.codexHome, + CPS_DESKTOP_USER_DATA: fixture.userData, + CPS_DESKTOP_WINDOW_DISPLAY: "hidden", + CPS_DESKTOP_DIAGNOSTICS_TARGET: diagnosticsTarget, + ELECTRON_ENABLE_SECURITY_WARNINGS: "true" + } + }); + const page = await electronApp.firstWindow(); + await expect(page).toHaveURL("cps-app://app/index.html"); + await page.waitForLoadState("load"); + await expect(page.getByText("Codex Provider Sync", { exact: true })).toBeVisible(); + await page.evaluate(() => localStorage.setItem("cps.desktop.theme", "dark")); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.getByText("Codex Provider Sync", { exact: true })).toBeVisible(); + await expect(page.getByText("openai", { exact: true }).first()).toBeVisible(); + + const hiddenWindowState = await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return { + visible: window.isVisible(), + focused: window.isFocused(), + minimized: window.isMinimized() + }; + }); + expect(hiddenWindowState).toEqual({ visible: false, focused: false, minimized: false }); + + const preferences = await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return window.webContents.getLastWebPreferences(); + }); + expect(preferences.nodeIntegration).toBe(false); + expect(preferences.nodeIntegrationInWorker).toBe(false); + expect(preferences.contextIsolation).toBe(true); + expect(preferences.sandbox).toBe(true); + expect(preferences.webSecurity).toBe(true); + expect(preferences.allowRunningInsecureContent).toBe(false); + expect(preferences.experimentalFeatures).toBe(false); + expect(preferences.webviewTag).toBe(false); + + const rendererBoundary = await page.evaluate(() => ({ + process: typeof globalThis.process, + require: typeof globalThis.require, + buffer: typeof globalThis.Buffer, + bridgeKeys: Object.keys(window.codexProvider).sort(), + coreKeys: Object.keys(window.codexProvider.core).sort(), + updateKeys: Object.keys(window.codexProvider.updates).sort(), + frozen: Object.isFrozen(window.codexProvider) && Object.isFrozen(window.codexProvider.core), + csp: document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute("content") + })); + expect(rendererBoundary).toMatchObject({ + process: "undefined", + require: "undefined", + buffer: "undefined", + bridgeKeys: ["core", "diagnostics", "profiles", "test", "updates", "version"], + coreKeys: [ + "cancelOperation", + "requestMaintenance", + "requestReadOnly", + "requestRestore", + "requestSyncSwitch", + "subscribeOperation" + ], + updateKeys: ["check", "download", "getStatus", "install"], + frozen: true + }); + expect(rendererBoundary.csp).toContain("script-src 'self'"); + expect(rendererBoundary.csp).not.toContain("unsafe-inline"); + expect(rendererBoundary.csp).not.toContain("unsafe-eval"); + + const navigation = page.getByRole("navigation").getByRole("button"); + await expect(navigation).toHaveCount(8); + await expect(page.getByRole("button", { name: "Sync" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Switch Provider" })).toBeVisible(); + + await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + window.setSize(760, 560); + window.webContents.setZoomFactor(2); + }); + await expect.poll(() => page.evaluate(() => document.documentElement.clientWidth)).toBeLessThanOrEqual(380); + await expect(page.getByLabel("Profile")).toBeVisible(); + await expect(page.getByText("Local service ready", { exact: true })).toBeVisible(); + const zoomedPages = [ + ["Overview", "Provider metadata overview"], + ["Sync", "Sync current Provider"], + ["Switch Provider", "Switch Provider"], + ["Backups / Restore", "Backups and Restore"], + ["History", "History"], + ["Profiles", "Profiles"], + ["Diagnostics", "Diagnostics"], + ["Settings", "Settings"] + ]; + for (const [navigationName, headingName] of zoomedPages) { + const target = page.getByRole("navigation").getByRole("button", { name: navigationName, exact: true }); + await target.scrollIntoViewIfNeeded(); + await target.click(); + const heading = page.getByRole("heading", { name: headingName, level: 1 }); + await expect(heading).toBeVisible(); + await heading.scrollIntoViewIfNeeded(); + await expect(heading).toBeInViewport(); + const zoomedLayout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth + })); + expect(zoomedLayout.scrollWidth, `${navigationName} overflowed at 760px/200%`).toBeLessThanOrEqual(zoomedLayout.clientWidth); + } + const hiddenZoomedWindow = await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return { visible: window.isVisible(), focused: window.isFocused() }; + }); + expect(hiddenZoomedWindow).toEqual({ visible: false, focused: false }); + await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + window.webContents.setZoomFactor(1); + window.setSize(1180, 760); + }); + + await page.getByRole("button", { name: "Backups / Restore" }).click(); + await expect(page.getByRole("button", { name: "Prepare restore" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Prune older backups" })).toBeVisible(); + + await page.getByRole("button", { name: "Profiles" }).click(); + await expect(page.getByText(/profile IDs and revisions only/i)).toBeVisible(); + await expect(page.getByText(fixture.codexHome)).toHaveCount(0); + + await page.getByRole("button", { name: "Diagnostics" }).click(); + await expect(page.getByText(/runtime/i).first()).toBeVisible(); + await expect(page.locator("body")).not.toContainText(fixture.codexHome); + await page.getByRole("button", { name: "Export redacted bundle" }).click(); + await expect(page.getByText("Redacted diagnostics bundle created.", { exact: true })).toBeVisible(); + const diagnosticsArchive = await fs.readFile(diagnosticsTarget); + expect(diagnosticsArchive.toString("utf8")).not.toContain(fixture.codexHome); + expect(diagnosticsArchive.toString("utf8")).not.toContain("C6_DESKTOP_BODY_ONLY_MARKER"); + + await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByText("Updates", { exact: true })).toBeVisible(); + await expect(page.getByText("Update checks are available only in a packaged build.", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Start watch" })).toBeDisabled(); + const updateStatus = await page.evaluate(() => window.codexProvider.updates.getStatus()); + expect(updateStatus).toEqual({ + schemaVersion: 2, + state: "disabled", + reason: "not-packaged", + installAllowed: false + }); + expect(JSON.stringify(updateStatus)).not.toMatch(/url|path|releaseNotes|token/i); + + await page.getByRole("button", { name: "History" }).click(); + await expect(page.locator("body")).not.toContainText("C6_DESKTOP_BODY_ONLY_MARKER"); + await expect(page.getByText("Untitled session", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Open session" }).click(); + await expect(page.getByText("C6_DESKTOP_BODY_ONLY_MARKER")).toBeVisible(); + await page.getByRole("button", { name: "Back to sessions" }).click(); + await expect(page.locator("body")).not.toContainText("C6_DESKTOP_BODY_ONLY_MARKER"); + + const profile = (await page.evaluate(() => window.codexProvider.profiles.list())).profiles[0]; + const statusBeforeCrash = await page.evaluate(async ({ profile }) => window.codexProvider.test.requestRaw({ + protocolVersion: 1, + requestId: "c6-real-sqlite-status", + method: "getStatus", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision } } + }), { profile }); + expect(statusBeforeCrash.ok).toBe(true); + expect(statusBeforeCrash.result.sqliteCounts.sessions.openai).toBe(1); + expect(statusBeforeCrash.result.pendingRecovery).toBe(true); + const writeAttempt = await page.evaluate(async ({ profile }) => window.codexProvider.test.requestRaw({ + protocolVersion: 1, + requestId: "c6-write-denied", + method: "prepareSync", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision }, keepCount: 5 } + }), { profile }); + expect(writeAttempt.ok).toBe(false); + expect(writeAttempt.error.code).toBe("PERMISSION_DENIED"); + const recoveryBlocked = await page.evaluate(async ({ profile }) => window.codexProvider.core.requestSyncSwitch({ + protocolVersion: 1, + requestId: "c7-write-recovery-blocked", + method: "prepareSync", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision }, keepCount: 5 } + }), { profile }); + expect(recoveryBlocked.ok).toBe(false); + expect(recoveryBlocked.error.code).toBe("PENDING_TRANSACTION"); + + const beforeCrash = await electronApp.evaluate(() => globalThis.__CPS_DESKTOP_TEST__.runtime()); + expect(beforeCrash.state).toBe("ready"); + expect(beforeCrash.lastHandshakeAt).not.toBeNull(); + expect((await page.evaluate(() => window.codexProvider.test.crashRuntime())).crashed).toBe(true); + await expect.poll(() => electronApp.evaluate(() => globalThis.__CPS_DESKTOP_TEST__.runtime().state)).toBe("crashed"); + const afterRestart = await page.evaluate(async ({ profile }) => window.codexProvider.test.requestRaw({ + protocolVersion: 1, + requestId: "c6-restart-status", + method: "getStatus", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision } } + }), { profile }); + expect(afterRestart.ok).toBe(true); + const restarted = await electronApp.evaluate(() => globalThis.__CPS_DESKTOP_TEST__.runtime()); + expect(restarted.state).toBe("ready"); + expect(restarted.generation).toBe(beforeCrash.generation + 1); + expect(restarted.recoveryBlocked).toBe(true); + + const originalUrl = page.url(); + await page.evaluate(() => { globalThis.location.href = "https://example.com/"; }); + await page.waitForTimeout(250); + expect(page.url()).toBe(originalUrl); + expect(await page.evaluate(() => globalThis.open("https://example.com/"))).toBeNull(); + } finally { + await electronApp?.close(); + await fixture.assertUnchanged(); + await fixture.close(); + } +}); diff --git a/apps/desktop/e2e/desktop-restore-relocation.spec.mjs b/apps/desktop/e2e/desktop-restore-relocation.spec.mjs new file mode 100644 index 0000000..3a8f7ab --- /dev/null +++ b/apps/desktop/e2e/desktop-restore-relocation.spec.mjs @@ -0,0 +1,109 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { _electron as electron, expect, test } from "@playwright/test"; + +import { createDesktopSyncSwitchFixture } from "../../../test-support/desktop-sync-switch-fixture.mjs"; + +const require = createRequire(import.meta.url); +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const electronExecutable = require("electron"); + +async function launchDesktop(fixture) { + return electron.launch({ + executablePath: electronExecutable, + args: [path.join(desktopRoot, "out", "main", "index.js"), "--lang=en-US"], + env: { + ...process.env, + CPS_DESKTOP_E2E: "1", + CPS_DESKTOP_CODEX_HOME: fixture.codexHome, + CPS_DESKTOP_USER_DATA: fixture.userData, + CPS_DESKTOP_WINDOW_DISPLAY: "hidden", + ELECTRON_ENABLE_SECURITY_WARNINGS: "true" + } + }); +} + +async function confirmPlan(page) { + const dialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(page.getByText("Operation completed.", { exact: true })).toBeVisible(); + await expect(dialog).toHaveCount(0); + await page.getByRole("dialog", { name: "Operation result" }) + .getByRole("button", { name: "Close" }) + .last() + .click(); +} + +test("hidden Electron restores only the State DB into an explicit relocation target", async () => { + test.setTimeout(120_000); + const fixture = await createDesktopSyncSwitchFixture(); + const originalSourceSqlite = await fixture.snapshotSqlite(); + const originalTargetSqlite = await fixture.snapshotSqlite(fixture.targetStateDbPath); + let electronApp; + try { + electronApp = await launchDesktop(fixture); + const page = await electronApp.firstWindow(); + await expect(page.getByText("openai", { exact: true }).first()).toBeVisible(); + + await page.getByRole("button", { name: "Sync" }).click(); + await page.getByRole("button", { name: "Prepare sync" }).click(); + await confirmPlan(page); + await expect.poll(async () => (await fixture.inspect()).sqlite.provider).toBe("openai"); + const sourceAfterSync = await fixture.snapshotTargets(); + const backupId = (await fixture.inspect()).backupIds[0]; + expect(backupId).toBeTruthy(); + + await page.getByRole("button", { name: "Backups / Restore" }).click(); + await page.getByRole("button", { name: new RegExp(backupId) }).click(); + await page.getByLabel("Restore config.toml").uncheck(); + await page.getByLabel("Restore rollout files").uncheck(); + await expect(page.getByLabel("Restore State DB")).toBeChecked(); + await page.getByLabel("Confirm SQLite Home relocation").check(); + await page.getByLabel("Relocation target profile").selectOption("relocation-target"); + await page.getByRole("button", { name: "Prepare restore" }).click(); + + const dialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(dialog).toContainText("Restore config.toml"); + await expect(dialog).toContainText("Restore State DB"); + await expect(dialog).toContainText("Restore rollout files"); + await expect(dialog).toContainText("SQLite Home relocation"); + await confirmPlan(page); + + expect((await fixture.snapshotTargets()).hash).toBe(sourceAfterSync.hash); + const relocated = await fixture.snapshotSqlite(fixture.targetStateDbPath); + expect(relocated.hash).toBe(originalSourceSqlite.hash); + expect(relocated.hash).not.toBe(originalTargetSqlite.hash); + expect((await fixture.inspect()).sqlite.provider).toBe("openai"); + expect(relocated.threads[0].model_provider).toBe("legacy-provider"); + + const defaultProfile = (await page.evaluate( + () => window.codexProvider.profiles.list() + )).profiles.find((profile) => profile.id === "default"); + const invalid = await page.evaluate(async ({ profile, backupId: selectedBackupId }) => ( + window.codexProvider.core.requestRestore({ + protocolVersion: 1, + requestId: "restore-relocation-no-sqlite-target", + method: "prepareRestore", + payload: { + profile: { profileId: profile.id, profileRevision: profile.revision }, + backupId: selectedBackupId, + restoreConfig: false, + restoreDatabase: true, + restoreSessions: false, + allowSqliteHomeRelocation: true, + relocationTargetProfileId: "no-sqlite-target" + } + }) + ), { profile: defaultProfile, backupId }); + expect(invalid.ok).toBe(false); + expect(invalid.error.code).toBe("INVALID_INPUT"); + expect((await fixture.snapshotTargets()).hash).toBe(sourceAfterSync.hash); + expect((await fixture.snapshotSqlite(fixture.targetStateDbPath)).hash).toBe(relocated.hash); + } finally { + await electronApp?.close(); + await fixture.close(); + } +}); diff --git a/apps/desktop/e2e/desktop-sync-switch.spec.mjs b/apps/desktop/e2e/desktop-sync-switch.spec.mjs new file mode 100644 index 0000000..e54480f --- /dev/null +++ b/apps/desktop/e2e/desktop-sync-switch.spec.mjs @@ -0,0 +1,634 @@ +import fs from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { _electron as electron, expect, test } from "@playwright/test"; + +import { createDesktopSyncSwitchFixture } from "../../../test-support/desktop-sync-switch-fixture.mjs"; + +const require = createRequire(import.meta.url); +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const electronExecutable = require("electron"); + +async function launchDesktop(fixture, extraEnv = {}) { + return electron.launch({ + executablePath: electronExecutable, + args: [path.join(desktopRoot, "out", "main", "index.js"), "--lang=en-US"], + env: { + ...process.env, + CPS_DESKTOP_E2E: "1", + CPS_DESKTOP_CODEX_HOME: fixture.codexHome, + CPS_DESKTOP_USER_DATA: fixture.userData, + CPS_DESKTOP_WINDOW_DISPLAY: "hidden", + ELECTRON_ENABLE_SECURITY_WARNINGS: "true", + ...extraEnv + } + }); +} + +async function openSyncPlan(page) { + await page.getByRole("button", { name: "Sync" }).click(); + await page.getByRole("button", { name: "Prepare sync" }).click(); + const dialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function openSwitchPlan(page, { provider = "relay", mode = "provider-default", model } = {}) { + await page.getByRole("button", { name: "Switch Provider" }).click(); + await page.getByLabel("Provider ID").fill(provider); + await page.getByLabel("Model handling").selectOption(mode); + if (mode === "explicit") await page.getByLabel("Model name").fill(model); + await page.getByRole("button", { name: "Prepare switch" }).click(); + const dialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function waitForGate(markerPath, expectedPoint, timeout = 10_000) { + await expect.poll(async () => { + try { + return JSON.parse(await fs.readFile(markerPath, "utf8")).point; + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } + }, { timeout }).toBe(expectedPoint); +} + +function notificationWithCode(page, code) { + return page.getByRole("listitem").filter({ hasText: `(${code})` }).last(); +} + +async function prepareSyncDirect(page, requestId) { + const profile = (await page.evaluate(() => window.codexProvider.profiles.list())).profiles[0]; + return page.evaluate(async ({ profile, requestId }) => window.codexProvider.core.requestSyncSwitch({ + protocolVersion: 1, + requestId, + method: "prepareSync", + payload: { + profile: { profileId: profile.id, profileRevision: profile.revision }, + keepCount: 5 + } + }), { profile, requestId }); +} + +async function applySyncDirect(page, planId, requestId) { + return page.evaluate(async ({ planId, requestId }) => window.codexProvider.core.requestSyncSwitch({ + protocolVersion: 1, + requestId, + method: "applySync", + payload: { schemaVersion: 1, planId } + }), { planId, requestId }); +} + +async function lockRolloutFile(filePath) { + const script = ` +& { + param([string]$path) + $stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + 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 + ], { stdio: ["ignore", "pipe", "pipe"] }); + await new Promise((resolve, reject) => { + let output = ""; + let settled = false; + child.stdout.on("data", (chunk) => { + output += chunk.toString("utf8"); + if (!settled && output.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(`Rollout lock exited before ready (${code ?? "null"}/${signal ?? "null"}).`)); + } + }); + }); + return child; +} + +async function releaseChild(child) { + if (!child || child.exitCode !== null) return; + const exited = new Promise((resolve) => child.once("exit", resolve)); + child.kill(); + await exited; +} + +async function runProcess(command, args) { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + const stdout = []; + const stderr = []; + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + const result = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + if (result.code !== 0) { + throw new Error(`${command} failed (${result.code ?? "null"}/${result.signal ?? "null"}): ${Buffer.concat(stderr).toString("utf8")}`); + } + return Buffer.concat(stdout).toString("utf8").trim(); +} + +async function findWslDistro() { + const candidates = [...new Set([process.env.CPS_WSL_DISTRO, "Ubuntu"].filter(Boolean))]; + for (const candidate of candidates) { + try { + if (await runProcess("wsl.exe", ["-d", candidate, "--", "printf", "cps-ready"]) === "cps-ready") { + return candidate; + } + } catch {} + } + return null; +} + +async function confirmPlan(page, returnFocus) { + const dialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Target", { exact: true })).toBeVisible(); + await expect(dialog.getByText("Impact", { exact: true })).toBeVisible(); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(page.getByText("Operation completed.", { exact: true })).toBeVisible(); + await expect(dialog).toHaveCount(0); + await page.getByRole("dialog", { name: "Operation result" }).getByRole("button", { name: "Close" }).last().click(); + if (returnFocus) await expect(returnFocus).toBeFocused(); +} + +async function switchProvider(page, { provider, mode, model }) { + await page.getByRole("button", { name: "Switch Provider" }).click(); + await page.getByLabel("Provider ID").fill(provider); + await page.getByLabel("Model handling").selectOption(mode); + if (mode === "explicit") await page.getByLabel("Model name").fill(model); + const prepare = page.getByRole("button", { name: "Prepare switch" }); + await prepare.click(); + await confirmPlan(page, prepare); +} + +test("hidden Electron test build forces the native fallback through Status, Sync, Restore, and the narrow C8 bridge", async () => { + test.setTimeout(120_000); + const fixture = await createDesktopSyncSwitchFixture(); + const baseline = await fixture.snapshotTargets(); + let electronApp; + let syncBackupId; + try { + const diagnosticsTarget = path.join(fixture.fixtureRoot, "diagnostics.zip"); + electronApp = await launchDesktop(fixture, { + CPS_DESKTOP_DIAGNOSTICS_TARGET: diagnosticsTarget + }); + const page = await electronApp.firstWindow(); + await expect(page).toHaveURL("cps-app://app/index.html"); + await expect(page.getByText("openai", { exact: true }).first()).toBeVisible(); + const profile = (await page.evaluate(() => window.codexProvider.profiles.list())).profiles[0]; + const fallbackStatus = await page.evaluate(async ({ profile }) => window.codexProvider.core.requestReadOnly({ + protocolVersion: 1, + requestId: "c9-test-fallback-status", + method: "getStatus", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision } } + }), { profile }); + expect(fallbackStatus.ok).toBe(true); + expect(fallbackStatus.result.sqliteCounts.sessions["legacy-provider"]).toBe(1); + await page.evaluate(() => { + globalThis.__c7OperationEvents = []; + globalThis.__c7Unsubscribe = window.codexProvider.core.subscribeOperation((event) => { + globalThis.__c7OperationEvents.push(event); + }); + }); + + await page.getByRole("button", { name: "Sync" }).click(); + const prepareSync = page.getByRole("button", { name: "Prepare sync" }); + await prepareSync.click(); + await confirmPlan(page, prepareSync); + await expect.poll(async () => (await fixture.inspect()).sqlite.provider).toBe("openai"); + syncBackupId = (await fixture.inspect()).backupIds[0]; + + await switchProvider(page, { provider: "relay", mode: "provider-default" }); + let state = await fixture.inspect(); + expect(state.configText).toMatch(/^model_provider = "relay"/m); + expect(state.configText).toMatch(/^model = "relay-model"/m); + + await switchProvider(page, { provider: "openai", mode: "keep-root-model" }); + state = await fixture.inspect(); + expect(state.configText).toMatch(/^model_provider = "openai"/m); + expect(state.configText).toMatch(/^model = "relay-model"/m); + + await switchProvider(page, { provider: "relay", mode: "explicit", model: "explicit-model" }); + state = await fixture.inspect(); + expect(state.configText).toMatch(/^model_provider = "relay"/m); + expect(state.configText).toMatch(/^model = "explicit-model"/m); + expect(state.rollout.model_provider).toBe("relay"); + expect(state.turnContext.model).toBe("explicit-model"); + expect(state.turnContext.collaboration_mode.settings.model).toBe("explicit-model"); + expect(state.sqlite.provider).toBe("relay"); + expect(state.sqlite.model).toBe("explicit-model"); + expect(state.sqlite.updatedAt).toBe(1787702400); + expect(state.sqlite.updatedAtMs).toBe(1787702400000); + expect(state.backupIds).toHaveLength(4); + + const events = await page.evaluate(() => globalThis.__c7OperationEvents); + expect(events.filter((event) => event.event === "operation-started")).toHaveLength(4); + expect(events.some((event) => event.event === "progress" + && event.progress.stage === "create_backup")).toBe(true); + expect(JSON.stringify(events)).not.toMatch(/codex-home|state_5\.sqlite|backupDir|messageBody/i); + + await page.getByRole("button", { name: "Backups / Restore" }).click(); + await page.getByRole("button", { name: new RegExp(syncBackupId) }).click(); + const prepareRestore = page.getByRole("button", { name: "Prepare restore" }); + await prepareRestore.click(); + await confirmPlan(page, prepareRestore); + expect((await fixture.snapshotTargets()).hash).toBe(baseline.hash); + + await page.getByLabel("Keep newest backups").fill("2"); + await page.getByRole("button", { name: "Prune older backups" }).click(); + await expect(page.getByText("Operation completed.", { exact: true }).last()).toBeVisible(); + + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Start watch" }).click(); + await expect(page.getByRole("button", { name: "Stop watch" })).toBeVisible(); + await page.getByRole("button", { name: "Stop watch" }).click(); + await expect(page.getByRole("button", { name: "Start watch" })).toBeVisible(); + await expect(page.getByText("Updates", { exact: true })).toBeVisible(); + await expect(page.getByText("Update checks are available only in a packaged build.", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Diagnostics" }).click(); + await page.getByRole("button", { name: "Export redacted bundle" }).click(); + await expect(page.getByText("Redacted diagnostics bundle created.", { exact: true })).toBeVisible(); + const diagnostics = await fs.readFile(diagnosticsTarget); + expect(diagnostics.toString("utf8")).not.toContain(fixture.codexHome); + expect(diagnostics.toString("utf8")).not.toMatch(/auth\.json|encrypted_content|message body/i); + + const eventsAfterRestore = await page.evaluate(() => globalThis.__c7OperationEvents); + expect(eventsAfterRestore.filter((event) => event.event === "operation-started")).toHaveLength(5); + } finally { + await electronApp?.close(); + await fixture.close(); + } +}); + +test("Electron rejects a stale confirmed plan before backup or mutation", async () => { + const fixture = await createDesktopSyncSwitchFixture(); + let electronApp; + try { + electronApp = await launchDesktop(fixture); + const page = await electronApp.firstWindow(); + const dialog = await openSyncPlan(page); + await fixture.appendConfigDrift(); + const expected = await fixture.snapshotProtected(); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(notificationWithCode(page, "STALE_STATE")).toContainText( + "The protected state changed. Prepare the operation again." + ); + await expect(dialog).toHaveCount(0); + expect((await fixture.snapshotProtected()).hash).toBe(expected.hash); + expect((await fixture.inspect()).backupIds).toHaveLength(0); + } finally { + await electronApp?.close(); + await fixture.close(); + } +}); + +test("Electron rejects an unconfigured custom Provider before plan or backup creation", async () => { + const fixture = await createDesktopSyncSwitchFixture(); + const baseline = await fixture.snapshotProtected(); + let electronApp; + try { + electronApp = await launchDesktop(fixture); + const page = await electronApp.firstWindow(); + await expect(page.getByText("openai", { exact: true }).first()).toBeVisible(); + await page.getByRole("button", { name: "Switch Provider" }).click(); + await page.getByLabel("Provider ID").fill("missing-provider"); + await page.getByLabel("Model handling").selectOption("provider-default"); + const prepareSwitch = page.getByRole("button", { name: "Prepare switch" }); + await expect(prepareSwitch).toBeEnabled(); + await prepareSwitch.click(); + await expect(notificationWithCode(page, "INVALID_INPUT")).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Review plan" })).toHaveCount(0); + expect((await fixture.snapshotProtected()).hash).toBe(baseline.hash); + expect((await fixture.inspect()).backupIds).toHaveLength(0); + } finally { + await electronApp?.close(); + await fixture.close(); + } +}); + +test("Electron Main rejects tampered and replayed plan IDs without a second backup", async () => { + const fixture = await createDesktopSyncSwitchFixture(); + let electronApp; + try { + electronApp = await launchDesktop(fixture); + const page = await electronApp.firstWindow(); + await expect(page.getByText("openai", { exact: true }).first()).toBeVisible(); + const prepared = await prepareSyncDirect(page, "plan-ownership-prepare"); + expect(prepared.ok, JSON.stringify(prepared)).toBe(true); + + const tampered = await applySyncDirect( + page, + "tampered-plan-id".padEnd(40, "x"), + "plan-ownership-tampered" + ); + expect(tampered.ok).toBe(false); + expect(tampered.error.code).toBe("PLAN_EXPIRED"); + expect((await fixture.inspect()).backupIds).toHaveLength(0); + + const applied = await applySyncDirect( + page, + prepared.result.planId, + "plan-ownership-apply" + ); + expect(applied.ok, JSON.stringify(applied)).toBe(true); + expect((await fixture.inspect()).backupIds).toHaveLength(1); + + const replay = await applySyncDirect( + page, + prepared.result.planId, + "plan-ownership-replay" + ); + expect(replay.ok).toBe(false); + expect(replay.error.code).toBe("PLAN_EXPIRED"); + expect((await fixture.inspect()).backupIds).toHaveLength(1); + } finally { + await electronApp?.close(); + await fixture.close(); + } +}); + +test("Electron reports a real SQLite writer as busy before creating a backup", async () => { + const fixture = await createDesktopSyncSwitchFixture(); + let electronApp; + let sqliteLock; + try { + const baseline = await fixture.snapshotProtected(); + electronApp = await launchDesktop(fixture); + const page = await electronApp.firstWindow(); + const dialog = await openSyncPlan(page); + sqliteLock = fixture.holdSqliteWriteLock(); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(notificationWithCode(page, "SQLITE_BUSY")).toContainText( + "The state database is busy. Close Codex processes and retry." + ); + sqliteLock.release(); + sqliteLock = undefined; + expect((await fixture.snapshotProtected()).hash).toBe(baseline.hash); + expect((await fixture.inspect()).backupIds).toHaveLength(0); + } finally { + sqliteLock?.release(); + await electronApp?.close(); + await fixture.close(); + } +}); + +test("Electron reports a locked rollout as partial without rewriting the locked file", async () => { + test.skip(process.platform !== "win32", "Real FileShare.None rollout locks are Windows-specific."); + const fixture = await createDesktopSyncSwitchFixture(); + const rolloutBefore = await fs.readFile(fixture.rolloutPath); + let electronApp; + let lockProcess; + try { + lockProcess = await lockRolloutFile(fixture.rolloutPath); + electronApp = await launchDesktop(fixture); + const page = await electronApp.firstWindow(); + const dialog = await openSyncPlan(page); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(page.getByText( + "Completed with locked rollout files skipped.", + { exact: true } + )).toBeVisible(); + await releaseChild(lockProcess); + lockProcess = undefined; + const state = await fixture.inspect(); + expect(await fs.readFile(fixture.rolloutPath)).toEqual(rolloutBefore); + expect(state.rollout.model_provider).toBe("legacy-provider"); + expect(state.sqlite.provider).toBe("openai"); + expect(state.backupIds).toHaveLength(1); + } finally { + await releaseChild(lockProcess); + await electronApp?.close(); + await fixture.close(); + } +}); + +test("Electron Cancel before backup leaves every protected target unchanged", async () => { + test.setTimeout(90_000); + const fixture = await createDesktopSyncSwitchFixture(); + const baseline = await fixture.snapshotProtected(); + let electronApp; + try { + electronApp = await launchDesktop(fixture, { + CPS_DESKTOP_TEST_GATE: "before_backup", + CPS_DESKTOP_TEST_GATE_FILE: fixture.gateMarkerPath + }); + const page = await electronApp.firstWindow(); + const dialog = await openSyncPlan(page); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await waitForGate(fixture.gateMarkerPath, "before_backup"); + await dialog.getByRole("button", { name: "Cancel operation" }).click(); + await expect(page.getByText("Operation cancelled.", { exact: true })).toBeVisible(); + await expect(dialog).toHaveCount(0); + expect((await fixture.snapshotProtected()).hash).toBe(baseline.hash); + expect(await fixture.readJournals()).toEqual([]); + } finally { + await electronApp?.close(); + await fixture.close(); + } +}); + +test("Electron Cancel after config mutation waits for a durable rollback terminal", async () => { + const fixture = await createDesktopSyncSwitchFixture(); + const baseline = await fixture.snapshotTargets(); + let electronApp; + try { + electronApp = await launchDesktop(fixture, { + CPS_DESKTOP_TEST_GATE: "after_config_mutation_before_applied", + CPS_DESKTOP_TEST_GATE_FILE: fixture.gateMarkerPath + }); + const page = await electronApp.firstWindow(); + const dialog = await openSwitchPlan(page, { provider: "relay", mode: "provider-default" }); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await waitForGate(fixture.gateMarkerPath, "after_config_mutation_before_applied"); + expect((await fixture.inspect()).configText).toMatch(/^model_provider = "relay"/m); + await dialog.getByRole("button", { name: "Cancel operation" }).click(); + await expect(notificationWithCode(page, "SYNC_FAILED_ROLLED_BACK")).toContainText( + "The operation failed and its changes were rolled back." + ); + await expect(dialog).toHaveCount(0); + expect((await fixture.snapshotTargets()).hash).toBe(baseline.hash); + expect(await fixture.readJournals()).toEqual([ + expect.objectContaining({ state: "rolledBack", terminal: true, invalidTail: false }) + ]); + } finally { + await electronApp?.close(); + await fixture.close(); + } +}); + +for (const scenario of [ + { point: "before_backup", operation: "sync", recoveryBlocked: false, journalState: null }, + { point: "after_config_mutation_before_applied", operation: "switch", recoveryBlocked: true, journalState: "applying" }, + { + point: "after_rollout_mutation_before_applied", + operation: "sync", + recoveryBlocked: true, + journalState: "applying", + testTimeoutMs: 90_000 + }, + { point: "after_sqlite_commit_before_ack", operation: "sync", recoveryBlocked: true, journalState: "applied" }, + { point: "after_transaction_journal_commit_before_ack", operation: "sync", recoveryBlocked: false, journalState: "committed" }, + { + point: "after_transaction_commit", + operation: "sync", + recoveryBlocked: false, + journalState: "committed", + gateTimeoutMs: 30_000, + testTimeoutMs: 90_000 + } +]) { + test(`Utility crash matrix: ${scenario.point}`, async () => { + if (scenario.testTimeoutMs) test.setTimeout(scenario.testTimeoutMs); + const fixture = await createDesktopSyncSwitchFixture(); + const baseline = await fixture.snapshotProtected(); + let electronApp; + try { + electronApp = await launchDesktop(fixture, { + CPS_DESKTOP_TEST_GATE: scenario.point, + CPS_DESKTOP_TEST_GATE_FILE: fixture.gateMarkerPath + }); + const page = await electronApp.firstWindow(); + const dialog = scenario.operation === "switch" + ? await openSwitchPlan(page, { provider: "relay", mode: "provider-default" }) + : await openSyncPlan(page); + await dialog.getByRole("button", { name: "Confirm and apply" }).click(); + await waitForGate( + fixture.gateMarkerPath, + scenario.point, + scenario.gateTimeoutMs + ); + const beforeCrash = await electronApp.evaluate( + () => globalThis.__CPS_DESKTOP_TEST__.runtime() + ); + expect((await page.evaluate(() => window.codexProvider.test.crashRuntime())).crashed).toBe(true); + await expect(notificationWithCode(page, "CORE_RUNTIME_CRASHED")).toContainText( + "The Core runtime stopped unexpectedly." + ); + // The renderer refreshes Status after the failed write and the query + // layer may retry a transient first recovery probe. Assert the safety + // boundary (the crashed generation is abandoned and a ready Runtime + // preflights the journal), not the UI's exact number of read attempts. + await expect.poll(() => electronApp.evaluate( + () => globalThis.__CPS_DESKTOP_TEST__.runtime() + )).toMatchObject({ state: "ready" }); + + const recovered = await electronApp.evaluate( + () => globalThis.__CPS_DESKTOP_TEST__.runtime() + ); + expect(recovered.generation).toBeGreaterThan(beforeCrash.generation); + + const nextWrite = await prepareSyncDirect(page, `crash-${scenario.point}`); + expect(nextWrite.ok, JSON.stringify(nextWrite)).toBe(!scenario.recoveryBlocked); + if (scenario.recoveryBlocked) expect(nextWrite.error.code).toBe("PENDING_TRANSACTION"); + const afterRecoveryProbe = await electronApp.evaluate( + () => globalThis.__CPS_DESKTOP_TEST__.runtime() + ); + expect(afterRecoveryProbe.state).toBe("ready"); + expect(afterRecoveryProbe.generation).toBeGreaterThanOrEqual(recovered.generation); + expect(afterRecoveryProbe.recoveryBlocked).toBe(scenario.recoveryBlocked); + const journals = await fixture.readJournals(); + if (scenario.journalState === null) { + expect(journals).toEqual([]); + expect((await fixture.snapshotProtected()).hash).toBe(baseline.hash); + } else { + expect(journals).toEqual([ + expect.objectContaining({ + state: scenario.journalState, + terminal: !scenario.recoveryBlocked, + invalidTail: false + }) + ]); + } + } finally { + await electronApp?.close(); + await fixture.close(); + } + }); +} + +test("Windows WSL UNC storage is rejected with every protected hash unchanged", async () => { + const requireRealWsl = process.env.CPS_REQUIRE_REAL_WSL === "1"; + if (process.platform !== "win32" && requireRealWsl) { + throw new Error("CPS_REQUIRE_REAL_WSL=1 requires a Windows test process with a real WSL distribution."); + } + test.skip(process.platform !== "win32", "WSL UNC is a Windows-only safety boundary."); + const distro = await findWslDistro(); + if (!distro && requireRealWsl) { + throw new Error("CPS_REQUIRE_REAL_WSL=1 but no runnable WSL distribution is available."); + } + test.skip(!distro, "No runnable WSL distribution is available on this machine."); + const fixture = await createDesktopSyncSwitchFixture(); + let electronApp; + let linuxRoot; + try { + linuxRoot = await runProcess("wsl.exe", [ + "-d", + distro, + "--", + "mktemp", + "-d", + "/tmp/cps-c7-wsl-XXXXXX" + ]); + if (!/^\/tmp\/cps-c7-wsl-[A-Za-z0-9]+$/.test(linuxRoot)) { + throw new Error("WSL fixture returned an unsafe temporary path."); + } + const uncRoot = `\\\\wsl.localhost\\${distro}${linuxRoot.replaceAll("/", "\\")}`; + const wslStateDb = path.join(uncRoot, "state_5.sqlite"); + await fs.writeFile(wslStateDb, "C7 real WSL UNC unchanged marker\n", "utf8"); + const wslBefore = await fs.readFile(wslStateDb); + const baseline = await fixture.snapshotProtected(); + electronApp = await launchDesktop(fixture, { CPS_DESKTOP_SQLITE_HOME: uncRoot }); + const page = await electronApp.firstWindow(); + + await page.getByRole("button", { name: "Sync" }).click(); + await page.getByRole("button", { name: "Prepare sync" }).click(); + await expect(notificationWithCode(page, "SQLITE_UNSUPPORTED_PATH")).toContainText( + "The selected SQLite path is not supported by this runtime." + ); + await expect(page.getByRole("dialog", { name: "Review plan" })).toHaveCount(0); + + await page.getByRole("button", { name: "Switch Provider" }).click(); + await page.getByLabel("Provider ID").fill("relay"); + await page.getByLabel("Model handling").selectOption("provider-default"); + await page.getByRole("button", { name: "Prepare switch" }).click(); + await expect(notificationWithCode(page, "SQLITE_UNSUPPORTED_PATH")).toContainText( + "The selected SQLite path is not supported by this runtime." + ); + + expect((await fixture.snapshotProtected()).hash).toBe(baseline.hash); + expect(await fs.readFile(wslStateDb)).toEqual(wslBefore); + expect((await fixture.inspect()).backupIds).toHaveLength(0); + } finally { + await electronApp?.close(); + await fixture.close(); + if (linuxRoot && /^\/tmp\/cps-c7-wsl-[A-Za-z0-9]+$/.test(linuxRoot) && distro) { + await runProcess("wsl.exe", ["-d", distro, "--", "rm", "-rf", "--", linuxRoot]); + } + } +}); diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 0000000..596324f --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,81 @@ +appId: com.dailin521.codex-provider-sync +productName: Codex Provider Sync +asar: + smartUnpack: false +asarUnpack: + - node_modules/better-sqlite3/prebuilds/${platform}-${arch}.node +npmRebuild: true +buildDependenciesFromSource: false +removePackageScripts: true +includePdb: false +directories: + output: ../../dist-desktop +files: + - out/** + - package.json + - "!**/*.map" + - "!**/{test,tests,__tests__,checks,e2e,fixtures,__fixtures__,test-fixtures,test-support}/**" + - "!**/*.{test,spec}.*" + - "!**/{.env,.env.*,auth.json,credentials,credentials.json,secrets.json,tokens.json,*.jsonl,*.sqlite,*.sqlite3,*.db,*.pem,*.key,*.p12,*.pfx}" + - "!node_modules/better-sqlite3/{build,deps,src}/**" + - "!node_modules/better-sqlite3/prebuilds/!(${platform}-${arch}).node" +extraMetadata: + main: out/main/index.js +electronFuses: + runAsNode: false + enableCookieEncryption: true + enableNodeOptionsEnvironmentVariable: false + enableNodeCliInspectArguments: false + enableEmbeddedAsarIntegrityValidation: true + onlyLoadAppFromAsar: true + loadBrowserProcessSpecificV8Snapshot: false + grantFileProtocolExtraPrivileges: false +win: + target: + - target: nsis + arch: + - x64 + - target: zip + arch: + - x64 + artifactName: CodexProviderSync-${version}-windows-${arch}-portable.${ext} +nsis: + artifactName: CodexProviderSync-${version}-windows-${arch}-setup.${ext} + oneClick: false + allowToChangeInstallationDirectory: true + perMachine: false +mac: + target: + - target: dmg + arch: + - x64 + - arm64 + - target: zip + arch: + - x64 + - arm64 + artifactName: CodexProviderSync-${version}-macos-${arch}.${ext} + category: public.app-category.utilities +linux: + target: + - target: AppImage + arch: + - x64 + - target: deb + arch: + - x64 + executableName: codex-provider-sync + category: Utility + maintainer: Dailin521 + desktop: + entry: + Name: Codex Provider Sync +appImage: + artifactName: CodexProviderSync-${version}-linux-x64.AppImage +deb: + artifactName: CodexProviderSync-${version}-linux-x64.deb +publish: + - provider: github + owner: Dailin521 + repo: codex-provider-sync + releaseType: release diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 0000000..8295781 --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,87 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "electron-vite"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const desktopBuildId = process.env.CPS_DESKTOP_BUILD_ID?.trim() || "dev-c9"; +const desktopReleaseAuthorized = process.env.CPS_DESKTOP_RELEASE_AUTHORIZED === "true"; + +if (!/^[A-Za-z0-9._-]{1,128}$/.test(desktopBuildId)) { + throw new Error("CPS_DESKTOP_BUILD_ID must be a 1-128 character safe identifier."); +} + +export default defineConfig(({ mode }) => ({ + main: { + define: { + __CPS_DESKTOP_TEST_BUILD__: JSON.stringify(mode === "test"), + __CPS_DESKTOP_FORCE_BETTER_SQLITE3__: JSON.stringify(mode === "test"), + __CPS_DESKTOP_BUILD_ID__: JSON.stringify(desktopBuildId), + __CPS_DESKTOP_RELEASE_AUTHORIZED__: JSON.stringify(desktopReleaseAuthorized) + }, + ssr: { + noExternal: [/^@codex-provider-sync\//] + }, + build: { + outDir: path.resolve(root, "out/main"), + sourcemap: false, + externalizeDeps: { + exclude: [ + "@codex-provider-sync/contracts", + "@codex-provider-sync/core", + "@codex-provider-sync/core-client" + ] + }, + rollupOptions: { + input: { + index: path.resolve(root, "src/main/index.ts"), + runtime: path.resolve(root, "src/runtime/index.ts") + }, + external: ["better-sqlite3"] + } + } + }, + preload: { + define: { + __CPS_DESKTOP_TEST_BUILD__: JSON.stringify(mode === "test"), + __CPS_DESKTOP_FORCE_BETTER_SQLITE3__: "false", + __CPS_DESKTOP_BUILD_ID__: JSON.stringify(desktopBuildId) + }, + ssr: { + noExternal: [/^@codex-provider-sync\//] + }, + build: { + outDir: path.resolve(root, "out/preload"), + sourcemap: false, + externalizeDeps: { + exclude: [ + "@codex-provider-sync/contracts", + "@codex-provider-sync/core-client" + ] + }, + rollupOptions: { + input: { + index: path.resolve(root, "src/preload/index.ts") + }, + external: ["electron"], + output: { + format: "cjs", + entryFileNames: "[name].cjs", + inlineDynamicImports: true + } + } + } + }, + renderer: { + root: path.resolve(root, "src/renderer"), + base: "./", + plugins: [react(), tailwindcss()], + build: { + outDir: path.resolve(root, "out/renderer"), + emptyOutDir: true, + sourcemap: false, + target: "es2022" + } + } +})); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..0be855c --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,62 @@ +{ + "name": "@codex-provider-sync/desktop", + "version": "1.0.0", + "description": "vNext Electron desktop host for codex-provider-sync.", + "homepage": "https://github.com/Dailin521/codex-provider-sync#readme", + "author": "Dailin521", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./out/main/index.js", + "scripts": { + "build": "tsc -b", + "build:electron": "electron-vite build", + "build:electron:test": "electron-vite build --mode test", + "verify:production-bundle": "node scripts/verify-production-bundle.mjs", + "pack:dir": "npm run build && npm run build:electron && npm run verify:production-bundle && electron-builder --dir --config electron-builder.yml", + "pack:candidate": "node scripts/build-candidate.mjs", + "stage:candidate": "node scripts/stage-candidate.mjs", + "smoke:candidate:artifacts": "node scripts/smoke-candidate-artifacts.mjs", + "verify:candidate:set": "node scripts/verify-candidate-set.mjs", + "test": "node --test checks/*.contract.mjs tests/*.test.mjs", + "test:e2e": "node scripts/verify-test-fallback-bundle.mjs && playwright test -c playwright.config.mjs", + "test:e2e:production": "playwright test -c playwright.production.config.mjs", + "test:e2e:packaged": "node scripts/run-packaged-e2e.mjs" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@codex-provider-sync/app-ui": "0.0.0", + "@codex-provider-sync/contracts": "0.0.0", + "@codex-provider-sync/core": "0.0.0", + "@codex-provider-sync/core-client": "0.0.0", + "@codex-provider-sync/design-system": "0.0.0", + "better-sqlite3": "13.0.3", + "electron-updater": "6.8.9", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@codex-provider-sync/test-fixtures": "0.0.0", + "@electron/asar": "4.3.0", + "@electron/fuses": "2.1.3", + "@playwright/test": "1.62.1", + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "5.2.0", + "electron": "44.0.0", + "electron-builder": "26.15.7", + "electron-vite": "5.0.0", + "plist": "5.0.0", + "resedit": "3.1.0", + "tailwindcss": "4.3.3", + "vite": "7.3.6" + } +} diff --git a/apps/desktop/playwright.config.mjs b/apps/desktop/playwright.config.mjs new file mode 100644 index 0000000..65c67e6 --- /dev/null +++ b/apps/desktop/playwright.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + forbidOnly: true, + testDir: "./e2e", + testMatch: [ + "desktop-readonly.spec.mjs", + "desktop-sync-switch.spec.mjs", + "desktop-restore-relocation.spec.mjs" + ], + timeout: 45_000, + expect: { timeout: 10_000 }, + workers: 1, + fullyParallel: false, + reporter: "line" +}); diff --git a/apps/desktop/playwright.production.config.mjs b/apps/desktop/playwright.production.config.mjs new file mode 100644 index 0000000..6a90bb4 --- /dev/null +++ b/apps/desktop/playwright.production.config.mjs @@ -0,0 +1,13 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "desktop-production-boundary.spec.mjs", + timeout: 45_000, + expect: { timeout: 10_000 }, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: "line", + use: { trace: "retain-on-failure" } +}); diff --git a/apps/desktop/release/artifact-audit-policy.v1.json b/apps/desktop/release/artifact-audit-policy.v1.json new file mode 100644 index 0000000..aa1904c --- /dev/null +++ b/apps/desktop/release/artifact-audit-policy.v1.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 1, + "requiredAsarEntries": [ + "package.json", + "out/main/index.js", + "out/main/runtime.js", + "out/preload/index.cjs", + "out/renderer/index.html", + "node_modules/better-sqlite3/package.json", + "node_modules/better-sqlite3/lib/index.js" + ], + "forbiddenPathSegments": [ + ".git", + ".aws", + ".ssh", + "__fixtures__", + "__tests__", + "test", + "tests", + "checks", + "e2e", + "fixtures", + "test-fixtures", + "test-support" + ], + "forbiddenFileNames": [ + ".env", + ".env.local", + ".npmrc", + "auth.json", + "credentials", + "credentials.json", + "id_rsa", + "id_ed25519", + "secrets.json", + "tokens.json" + ], + "forbiddenExtensions": [ + ".map", + ".pem", + ".key", + ".jsonl", + ".p12", + ".pfx", + ".db", + ".sqlite", + ".sqlite3" + ], + "forbiddenFilePatterns": [ + "^\\.env(?:\\.|$)", + "^(?:credential|secret|token)s?\\.(?:json|ya?ml|txt)$", + "\\.(?:spec|test)\\.[^.]+$", + "^rollout-.*\\.jsonl$", + "\\.(?:sqlite|sqlite3|db)(?:-(?:wal|shm))?$" + ], + "auditedProductTextExtensions": [ + ".cjs", + ".css", + ".html", + ".js", + ".json", + ".md", + ".mjs", + ".svg", + ".toml", + ".txt", + ".webmanifest", + ".xml", + ".yaml", + ".yml" + ], + "forbiddenTextRules": [ + { + "id": "private-key-pem", + "pattern": "-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----" + }, + { + "id": "github-token", + "pattern": "\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\\b" + }, + { + "id": "aws-access-key", + "pattern": "\\b(?:AKIA|ASIA)[A-Z0-9]{16}\\b" + }, + { + "id": "slack-token", + "pattern": "\\bxox[baprs]-[A-Za-z0-9-]{20,}\\b" + }, + { + "id": "desktop-test-bridge", + "pattern": "__CPS_DESKTOP_TEST__" + }, + { + "id": "desktop-test-sqlite-driver", + "pattern": "__CPS_DESKTOP_FORCE_BETTER_SQLITE3__" + }, + { + "id": "desktop-fault-gate", + "pattern": "CPS_DESKTOP_TEST_GATE|desktop E2E fault gate" + }, + { + "id": "desktop-test-runtime", + "pattern": "CPS_DESKTOP_E2E|CPS_DESKTOP_USER_DATA|cps:v1:test:crash-runtime" + }, + { + "id": "desktop-test-ipc", + "pattern": "requestRaw|crashRuntime" + }, + { + "id": "desktop-updater-feed-override", + "pattern": "setFeedURL" + }, + { + "id": "fixture-canary", + "pattern": "C6_DESKTOP_BODY_ONLY_MARKER|example\\.invalid" + } + ] +} diff --git a/apps/desktop/scripts/build-candidate.mjs b/apps/desktop/scripts/build-candidate.mjs new file mode 100644 index 0000000..534f428 --- /dev/null +++ b/apps/desktop/scripts/build-candidate.mjs @@ -0,0 +1,69 @@ +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { DESKTOP_CANDIDATE_TARGETS } from "./resolve-candidate-build.mjs"; + +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = path.resolve(desktopRoot, "../.."); +const VERSION_PATTERN = /^1\.0\.0-(?:alpha|beta|rc)\.\d+$/; +const BUILD_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; + +const TARGET_CONFIG = Object.freeze({ + "windows-x64": { platform: "win32", arch: "x64", args: ["--win", "nsis", "zip", "--x64"] }, + "macos-x64": { platform: "darwin", arch: "x64", args: ["--mac", "dmg", "zip", "--x64"] }, + "macos-arm64": { platform: "darwin", arch: "arm64", args: ["--mac", "dmg", "zip", "--arm64"] }, + "linux-x64": { + platform: "linux", + arch: "x64", + args: ["--linux", "AppImage", "deb", "--x64"], + configOverrides: ["--config.productName=CodexProviderSync"] + } +}); + +function runNpm(args, { cwd = repositoryRoot, env = process.env } = {}) { + const npmCli = process.env.npm_execpath; + if (!npmCli) throw new Error("npm_execpath is required for a candidate build."); + const result = spawnSync(process.execPath, [npmCli, ...args], { + cwd, + env, + encoding: "utf8", + stdio: "inherit" + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`npm ${args.join(" ")} failed with exit code ${result.status}.`); +} + +const target = process.env.CPS_CANDIDATE_TARGET; +const version = process.env.CPS_DESKTOP_VERSION; +const buildId = process.env.CPS_DESKTOP_BUILD_ID; +if (!DESKTOP_CANDIDATE_TARGETS.includes(target)) throw new Error("CPS_CANDIDATE_TARGET is invalid."); +if (!VERSION_PATTERN.test(version || "")) throw new Error("CPS_DESKTOP_VERSION is not a supported v1 candidate version."); +if (!BUILD_ID_PATTERN.test(buildId || "")) throw new Error("CPS_DESKTOP_BUILD_ID is invalid."); + +const config = TARGET_CONFIG[target]; +if (process.platform !== config.platform || process.arch !== config.arch) { + throw new Error(`Candidate ${target} must be built on native ${config.platform}/${config.arch}, got ${process.platform}/${process.arch}.`); +} + +const buildEnvironment = { + ...process.env, + CPS_DESKTOP_BUILD_ID: buildId, + CPS_DESKTOP_RELEASE_AUTHORIZED: "false" +}; +runNpm(["run", "workspaces:build"], { env: buildEnvironment }); +runNpm(["run", "build:electron"], { cwd: desktopRoot, env: buildEnvironment }); +runNpm(["run", "verify:production-bundle"], { cwd: desktopRoot, env: buildEnvironment }); +runNpm([ + "exec", + "--", + "electron-builder", + ...config.args, + "--publish", + "never", + "--config", + "electron-builder.yml", + ...(config.configOverrides || []), + `--config.extraMetadata.version=${version}` +], { cwd: desktopRoot, env: buildEnvironment }); + +process.stdout.write(`Desktop candidate built: ${target} ${version} ${buildId}\n`); diff --git a/apps/desktop/scripts/configure-linux-sandbox.mjs b/apps/desktop/scripts/configure-linux-sandbox.mjs new file mode 100644 index 0000000..59e1769 --- /dev/null +++ b/apps/desktop/scripts/configure-linux-sandbox.mjs @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { constants as fsConstants } from "node:fs"; + +function fail(message) { + throw new Error(`Linux Electron sandbox setup refused: ${message}`); +} + +function isWithin(root, candidate) { + const relative = path.relative(root, candidate); + return relative !== "" + && !path.isAbsolute(relative) + && relative !== ".." + && !relative.startsWith(`..${path.sep}`); +} + +if (process.platform !== "linux" || typeof process.getuid !== "function" || process.getuid() !== 0) { + fail("the helper must run as root on Linux"); +} + +const [rootArgument, relativeArgument, ...extraArguments] = process.argv.slice(2); +if (!rootArgument || relativeArgument !== "chrome-sandbox" || extraArguments.length > 0) { + fail("expected one allowed root and the exact chrome-sandbox filename"); +} + +const lexicalRoot = path.resolve(rootArgument); +const rootInfo = await fs.lstat(lexicalRoot, { bigint: true }); +if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) { + fail("the allowed root must be a real directory"); +} +const realRoot = path.resolve(await fs.realpath(lexicalRoot)); +if (realRoot !== lexicalRoot) { + fail("the allowed root must already be canonical"); +} + +const sandboxPath = path.resolve(lexicalRoot, relativeArgument); +if (!isWithin(realRoot, sandboxPath)) { + fail("the target escaped its allowed root"); +} +const before = await fs.lstat(sandboxPath, { bigint: true }); +if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n) { + fail("the target must be one regular, unlinked file"); +} +const realSandboxPath = path.resolve(await fs.realpath(sandboxPath)); +if (realSandboxPath !== sandboxPath || !isWithin(realRoot, realSandboxPath)) { + fail("the target physical path escaped its allowed root"); +} + +const noFollow = fsConstants.O_NOFOLLOW; +if (typeof noFollow !== "number") fail("O_NOFOLLOW is unavailable"); +const handle = await fs.open(sandboxPath, fsConstants.O_RDONLY | noFollow); +try { + const opened = await handle.stat({ bigint: true }); + if (!opened.isFile() + || opened.nlink !== 1n + || opened.dev !== before.dev + || opened.ino !== before.ino) { + fail("the target changed before it could be opened safely"); + } + await handle.chown(0, 0); + await handle.chmod(0o4755); + const configured = await handle.stat({ bigint: true }); + if (configured.uid !== 0n + || (configured.mode & 0o7777n) !== 0o4755n + || configured.nlink !== 1n) { + fail("the opened target failed owner/mode verification"); + } + const named = await fs.lstat(sandboxPath, { bigint: true }); + if (!named.isFile() + || named.isSymbolicLink() + || named.dev !== configured.dev + || named.ino !== configured.ino + || named.uid !== 0n + || (named.mode & 0o7777n) !== 0o4755n + || named.nlink !== 1n) { + fail("the named target changed during owner/mode configuration"); + } +} finally { + await handle.close(); +} + +process.stdout.write("Linux Electron sandbox owner and mode verified.\n"); diff --git a/apps/desktop/scripts/native-driver-probe.cjs b/apps/desktop/scripts/native-driver-probe.cjs new file mode 100644 index 0000000..c8c9335 --- /dev/null +++ b/apps/desktop/scripts/native-driver-probe.cjs @@ -0,0 +1,33 @@ +"use strict"; + +const { app } = require("electron"); + +async function run() { + const packagePath = process.env.CPS_NATIVE_DRIVER_PACKAGE; + const nativeBinding = process.env.CPS_NATIVE_DRIVER_BINDING; + if (!packagePath || !nativeBinding) throw new Error("Native driver probe inputs are missing."); + + const Database = require(packagePath); + const db = new Database(":memory:", { nativeBinding }); + try { + db.exec("CREATE TABLE probe (value INTEGER NOT NULL); INSERT INTO probe VALUES (42);"); + const row = db.prepare("SELECT value FROM probe").get(); + if (row?.value !== 42) throw new Error("Native SQLite probe returned an unexpected row."); + process.stdout.write(`CPS_NATIVE_DRIVER_RESULT=${JSON.stringify({ + driver: "better-sqlite3", + electron: process.versions.electron, + modules: process.versions.modules, + sqlite: process.versions.sqlite + })}\n`); + } finally { + db.close(); + } +} + +app.whenReady() + .then(run) + .then(() => app.exit(0)) + .catch((error) => { + process.stderr.write(`Native driver probe failed: ${error instanceof Error ? error.message : String(error)}\n`); + app.exit(1); + }); diff --git a/apps/desktop/scripts/release-audit.mjs b/apps/desktop/scripts/release-audit.mjs new file mode 100644 index 0000000..0a4b29e --- /dev/null +++ b/apps/desktop/scripts/release-audit.mjs @@ -0,0 +1,672 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { extractFile, getRawHeader, listPackage } from "@electron/asar"; +import { + FuseState, + FuseV1Options, + getCurrentFuseWire +} from "@electron/fuses"; +import { parse as parsePlist, parseBinary as parseBinaryPlist } from "plist"; +import { NtExecutable, NtExecutableResource } from "resedit"; + +const require = createRequire(import.meta.url); +const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptRoot, ".."); +const repositoryRoot = path.resolve(desktopRoot, "../.."); +const nativeProbeScript = path.join(scriptRoot, "native-driver-probe.cjs"); +export const ARTIFACT_AUDIT_POLICY_PATH = path.join( + desktopRoot, + "release", + "artifact-audit-policy.v1.json" +); +const AUDIT_POLICY = JSON.parse(fsSync.readFileSync(ARTIFACT_AUDIT_POLICY_PATH, "utf8")); +assert.equal(AUDIT_POLICY.schemaVersion, 1, "Unsupported artifact audit policy."); +const REQUIRED_ASAR_ENTRIES = Object.freeze([...AUDIT_POLICY.requiredAsarEntries]); +const FORBIDDEN_SEGMENTS = new Set(AUDIT_POLICY.forbiddenPathSegments); +const FORBIDDEN_NAMES = new Set(AUDIT_POLICY.forbiddenFileNames); +const FORBIDDEN_EXTENSIONS = new Set(AUDIT_POLICY.forbiddenExtensions); +const AUDITED_PRODUCT_TEXT_EXTENSIONS = new Set(AUDIT_POLICY.auditedProductTextExtensions); +const FORBIDDEN_FILE_PATTERNS = Object.freeze( + AUDIT_POLICY.forbiddenFilePatterns.map((pattern) => new RegExp(pattern)) +); +const FORBIDDEN_TEXT_RULES = Object.freeze( + AUDIT_POLICY.forbiddenTextRules.map((rule) => Object.freeze({ + id: rule.id, + pattern: new RegExp(rule.pattern) + })) +); + +export const RELEASE_TARGETS = Object.freeze({ + "windows-x64": Object.freeze({ + platform: "win32", + arch: "x64", + nativeBinding: "node_modules/better-sqlite3/prebuilds/win32-x64.node", + unpackedDirectories: ["win-unpacked"], + assets(version) { + return [ + `CodexProviderSync-${version}-windows-x64-setup.exe`, + `CodexProviderSync-${version}-windows-x64-portable.zip` + ]; + } + }), + "macos-x64": Object.freeze({ + platform: "darwin", + arch: "x64", + nativeBinding: "node_modules/better-sqlite3/prebuilds/darwin-x64.node", + unpackedDirectories: ["mac", "mac-x64"], + assets(version) { + return [ + `CodexProviderSync-${version}-macos-x64.dmg`, + `CodexProviderSync-${version}-macos-x64.zip` + ]; + } + }), + "macos-arm64": Object.freeze({ + platform: "darwin", + arch: "arm64", + nativeBinding: "node_modules/better-sqlite3/prebuilds/darwin-arm64.node", + unpackedDirectories: ["mac-arm64", "mac"], + assets(version) { + return [ + `CodexProviderSync-${version}-macos-arm64.dmg`, + `CodexProviderSync-${version}-macos-arm64.zip` + ]; + } + }), + "linux-x64": Object.freeze({ + platform: "linux", + arch: "x64", + nativeBinding: "node_modules/better-sqlite3/prebuilds/linux-x64.node", + unpackedDirectories: ["linux-unpacked"], + assets(version) { + return [ + `CodexProviderSync-${version}-linux-x64.AppImage`, + `CodexProviderSync-${version}-linux-x64.deb` + ]; + } + }) +}); + +function normalizeEntry(entry) { + return entry.replace(/^pack\s*:\s*/, "").replaceAll("\\", "/").replace(/^\/+/, ""); +} + +function asarEntryPath(entry) { + return normalizeEntry(entry).split("/").join(path.sep); +} + +function headerNode(header, entry) { + let node = header; + for (const segment of normalizeEntry(entry).split("/")) { + node = node?.files?.[segment]; + if (!node) return null; + } + return node; +} + +function bufferIntegrity(buffer, blockSize) { + const blocks = []; + for (let offset = 0; offset < buffer.length; offset += blockSize) { + blocks.push(crypto.createHash("sha256").update(buffer.subarray(offset, offset + blockSize)).digest("hex")); + } + if (buffer.length === 0) blocks.push(crypto.createHash("sha256").update(buffer).digest("hex")); + return Object.freeze({ + hash: crypto.createHash("sha256").update(buffer).digest("hex"), + blocks + }); +} + +function verifyAsarEntryIntegrity(rawHeader, asarPath, entries) { + let verified = 0; + for (const entry of entries) { + const node = headerNode(rawHeader.header, entry); + if (!node || typeof node.size !== "number" || node.unpacked || node.link) continue; + const integrity = node.integrity; + assert.ok(integrity && typeof integrity === "object", `ASAR entry lacks integrity metadata: ${entry}`); + assert.equal(integrity.algorithm, "SHA256", `ASAR entry uses an unexpected integrity algorithm: ${entry}`); + assert.equal(Number.isSafeInteger(integrity.blockSize) && integrity.blockSize > 0, true); + assert.equal(Array.isArray(integrity.blocks), true); + const value = extractFile(asarPath, asarEntryPath(entry)); + const actual = bufferIntegrity(value, integrity.blockSize); + assert.equal(actual.hash, integrity.hash, `ASAR entry hash mismatch: ${entry}`); + assert.deepEqual(actual.blocks, integrity.blocks, `ASAR entry block hash mismatch: ${entry}`); + verified += 1; + } + assert.ok(verified > 0, "ASAR contains no integrity-protected files."); + return verified; +} + +async function verifyEmbeddedAsarIntegrity(layout, descriptor, headerSha256) { + if (descriptor.platform === "linux") return "unsupported-platform"; + if (descriptor.platform === "win32") { + const executable = NtExecutable.from(await fs.readFile(layout.executable)); + const resources = NtExecutableResource.from(executable); + const matches = resources.entries.filter((entry) => + String(entry.type).toUpperCase() === "INTEGRITY" + && String(entry.id).toUpperCase() === "ELECTRONASAR"); + assert.equal(matches.length, 1, "Windows executable must contain one Electron ASAR integrity resource."); + const records = JSON.parse(Buffer.from(matches[0].bin).toString("utf8")); + assert.deepEqual(records, [{ + file: "resources\\app.asar", + alg: "SHA256", + value: headerSha256 + }], "Windows executable ASAR integrity binding is invalid."); + return "verified"; + } + + const infoPath = path.join(layout.appRoot, "Contents", "Info.plist"); + const infoBuffer = await fs.readFile(infoPath); + const info = parseMacInfoPlist(infoBuffer); + const integrity = info?.ElectronAsarIntegrity; + assert.ok(integrity && typeof integrity === "object" && !Array.isArray(integrity)); + const records = Object.entries(integrity); + assert.equal(records.length, 1, "macOS app must contain one Electron ASAR integrity binding."); + const [resourcePath, record] = records[0]; + assert.equal(resourcePath.replaceAll("\\", "/"), "Resources/app.asar"); + assert.deepEqual(record, { algorithm: "SHA256", hash: headerSha256 }); + return "verified"; +} + +function isFileNotFound(error) { + return error && typeof error === "object" && error.code === "ENOENT"; +} + +async function isDirectory(value) { + try { + return (await fs.stat(value)).isDirectory(); + } catch (error) { + if (isFileNotFound(error)) return false; + throw error; + } +} + +export async function sha256File(filePath) { + const hash = crypto.createHash("sha256"); + const handle = await fs.open(filePath, "r"); + try { + for await (const chunk of handle.createReadStream()) hash.update(chunk); + } finally { + await handle.close(); + } + return hash.digest("hex"); +} + +export function assertSafeAsarEntries(entries) { + for (const entry of entries) { + const normalized = normalizeEntry(entry); + const lower = normalized.toLowerCase(); + const segments = lower.split("/").filter(Boolean); + const name = segments.at(-1) || ""; + const forbiddenNamePattern = FORBIDDEN_FILE_PATTERNS.some((pattern) => pattern.test(name)); + assert.equal( + segments.some((segment) => FORBIDDEN_SEGMENTS.has(segment)), + false, + `Packaged ASAR contains forbidden path segment: ${normalized}` + ); + assert.equal(FORBIDDEN_NAMES.has(name), false, `Packaged ASAR contains forbidden file: ${normalized}`); + assert.equal(forbiddenNamePattern, false, `Packaged ASAR contains forbidden file pattern: ${normalized}`); + assert.equal( + FORBIDDEN_EXTENSIONS.has(path.posix.extname(name)), + false, + `Packaged ASAR contains forbidden extension: ${normalized}` + ); + } +} + +export function assertSafeProductTextEntry(entry, value) { + for (const rule of FORBIDDEN_TEXT_RULES) { + assert.equal(rule.pattern.test(value), false, `Packaged product text violates ${rule.id}: ${entry}`); + } +} + +export function isAuditedProductTextEntry(entry) { + const normalized = normalizeEntry(entry); + return normalized.startsWith("out/") + && AUDITED_PRODUCT_TEXT_EXTENSIONS.has(path.posix.extname(normalized).toLowerCase()); +} + +async function filesUnder(root) { + const result = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error(`Packaged unpacked tree contains a symbolic link: ${entry.name}`); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) result.push(absolute); + else throw new Error(`Packaged unpacked tree contains an unsupported entry: ${entry.name}`); + } + } + await visit(root); + return result.sort((left, right) => left.localeCompare(right)); +} + +async function assertSafeContainerTree(root) { + const resolvedRoot = path.resolve(root); + const entries = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + const relative = path.relative(root, absolute).replaceAll("\\", "/"); + entries.push(relative); + if (entry.isSymbolicLink()) { + const target = await fs.readlink(absolute); + assert.equal(path.isAbsolute(target), false, `Packaged app contains an absolute symbolic link: ${relative}`); + const resolvedTarget = path.resolve(path.dirname(absolute), target); + const targetRelative = path.relative(resolvedRoot, resolvedTarget); + assert.equal( + targetRelative !== "" && !targetRelative.startsWith("..") && !path.isAbsolute(targetRelative), + true, + `Packaged app symbolic link escapes its root: ${relative}` + ); + } else if (entry.isDirectory()) await visit(absolute); + else if (!entry.isFile()) throw new Error(`Packaged app contains an unsupported entry: ${relative}`); + } + } + await visit(root); + assertSafeAsarEntries(entries); +} + +export async function resolvePackagedLayout(outputRoot, target) { + const descriptor = RELEASE_TARGETS[target]; + if (!descriptor) throw new Error("Unknown release target."); + for (const directory of descriptor.unpackedDirectories) { + const unpackedRoot = path.join(outputRoot, directory); + if (!await isDirectory(unpackedRoot)) continue; + if (descriptor.platform === "darwin") { + const appRoot = path.join(unpackedRoot, "Codex Provider Sync.app"); + if (!await isDirectory(appRoot)) continue; + return Object.freeze({ + unpackedRoot, + appRoot, + resources: path.join(appRoot, "Contents", "Resources"), + executable: path.join(appRoot, "Contents", "MacOS", "Codex Provider Sync") + }); + } + return Object.freeze({ + unpackedRoot, + appRoot: unpackedRoot, + resources: path.join(unpackedRoot, "resources"), + executable: descriptor.platform === "win32" + ? path.join(unpackedRoot, "Codex Provider Sync.exe") + : path.join(unpackedRoot, "codex-provider-sync") + }); + } + throw new Error(`No unpacked ${target} application was found under the builder output.`); +} + +function parsePackageJson(buffer, label) { + try { + return JSON.parse(buffer.toString("utf8")); + } catch { + throw new Error(`Packaged ${label} is not valid JSON.`); + } +} + +function componentLicense(value) { + if (typeof value === "string" && value.trim()) return value.trim(); + if (value && typeof value === "object" && typeof value.type === "string") return value.type; + return null; +} + +function packagedComponents(asarPath, entries) { + const result = new Map(); + for (const entry of entries) { + if (entry !== "package.json" + && !/(?:^|\/)node_modules\/(?:@[^/]+\/[^/]+|[^/]+)\/package\.json$/.test(entry)) continue; + let manifest; + try { + manifest = parsePackageJson(extractFile(asarPath, asarEntryPath(entry)), entry); + } catch { + continue; + } + if (typeof manifest.name !== "string" || typeof manifest.version !== "string") continue; + const key = `${manifest.name}@${manifest.version}`; + if (!result.has(key)) { + result.set(key, Object.freeze({ + name: manifest.name, + version: manifest.version, + license: componentLicense(manifest.license) + })); + } + } + return [...result.values()].sort((left, right) => `${left.name}@${left.version}`.localeCompare(`${right.name}@${right.version}`)); +} + +function fuseIs(fuses, option, expected) { + assert.equal(fuses[option], expected, `Electron fuse ${FuseV1Options[option]} has an unsafe state.`); +} + +export function parseMacInfoPlist(infoBuffer) { + return infoBuffer.subarray(0, 8).toString("ascii") === "bplist00" + ? parseBinaryPlist(infoBuffer) + : parsePlist(infoBuffer.toString("utf8")); +} + +async function verifyNativeDriver(nativeBinding, asarPath) { + const electronBinary = require("electron"); + const sourcePackage = path.join(asarPath, "node_modules", "better-sqlite3"); + const args = process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0 + ? ["--no-sandbox", nativeProbeScript] + : [nativeProbeScript]; + const result = spawnSync(electronBinary, args, { + cwd: desktopRoot, + env: { + ...process.env, + CPS_NATIVE_DRIVER_PACKAGE: sourcePackage, + CPS_NATIVE_DRIVER_BINDING: nativeBinding + }, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024 + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Electron native driver probe failed with exit code ${result.status}: ${String(result.stderr).trim()}`); + } + const match = String(result.stdout).match(/CPS_NATIVE_DRIVER_RESULT=(\{[^\r\n]+\})/); + if (!match) throw new Error("Electron native driver probe did not return a result."); + const probe = JSON.parse(match[1]); + assert.equal(probe.driver, "better-sqlite3"); + assert.equal(probe.electron, "44.0.0"); + assert.match(String(probe.modules), /^\d+$/); + return Object.freeze({ + driver: probe.driver, + electron: probe.electron, + modules: String(probe.modules), + sqlite: typeof probe.sqlite === "string" ? probe.sqlite : null + }); +} + +export async function auditPackagedLayout({ layout, target, version, buildId }) { + const descriptor = RELEASE_TARGETS[target]; + if (!descriptor) throw new Error("Unknown release target."); + const asarPath = path.join(layout.resources, "app.asar"); + const unpackedPath = `${asarPath}.unpacked`; + const [asarStat, executableStat] = await Promise.all([fs.stat(asarPath), fs.stat(layout.executable)]); + assert.equal(asarStat.isFile(), true, "Packaged app.asar is missing."); + assert.equal(executableStat.isFile(), true, "Packaged executable is missing."); + await assertSafeContainerTree(layout.appRoot); + + const entries = listPackage(asarPath, { isPack: false }).map(normalizeEntry); + const entrySet = new Set(entries); + for (const required of REQUIRED_ASAR_ENTRIES) { + assert.equal(entrySet.has(required), true, `Packaged ASAR is missing ${required}.`); + } + assertSafeAsarEntries(entries); + const nativePrebuildEntries = entries.filter((entry) => + /^node_modules\/better-sqlite3\/prebuilds\/[^/]+\.node$/.test(entry)); + assert.deepEqual( + nativePrebuildEntries, + [descriptor.nativeBinding], + "Packaged ASAR must reference only the target platform's native SQLite binding." + ); + assert.equal( + headerNode(getRawHeader(asarPath).header, descriptor.nativeBinding)?.unpacked, + true, + "The target native SQLite binding must be marked unpacked." + ); + + const manifest = parsePackageJson(extractFile(asarPath, asarEntryPath("package.json")), "package.json"); + assert.equal(manifest.name, "@codex-provider-sync/desktop"); + assert.equal(manifest.version, version, "Packaged Electron version does not match the candidate."); + assert.equal(manifest.main, "out/main/index.js"); + + const productTextEntries = entries.filter(isAuditedProductTextEntry); + let buildIdFound = false; + for (const entry of productTextEntries) { + const value = extractFile(asarPath, asarEntryPath(entry)).toString("utf8"); + if (value.includes(buildId)) buildIdFound = true; + assertSafeProductTextEntry(entry, value); + } + assert.equal(buildIdFound, true, "Packaged build ID is missing."); + + const unpackedFiles = await filesUnder(unpackedPath); + const unpackedRelative = unpackedFiles.map((file) => path.relative(unpackedPath, file).replaceAll("\\", "/")); + assert.deepEqual( + unpackedRelative, + [descriptor.nativeBinding], + "Only the target platform's native SQLite binding may be outside app.asar." + ); + + const fuses = await getCurrentFuseWire(layout.executable); + fuseIs(fuses, FuseV1Options.RunAsNode, FuseState.DISABLE); + fuseIs(fuses, FuseV1Options.EnableCookieEncryption, FuseState.ENABLE); + fuseIs(fuses, FuseV1Options.EnableNodeOptionsEnvironmentVariable, FuseState.DISABLE); + fuseIs(fuses, FuseV1Options.EnableNodeCliInspectArguments, FuseState.DISABLE); + fuseIs(fuses, FuseV1Options.EnableEmbeddedAsarIntegrityValidation, FuseState.ENABLE); + fuseIs(fuses, FuseV1Options.OnlyLoadAppFromAsar, FuseState.ENABLE); + fuseIs(fuses, FuseV1Options.LoadBrowserProcessSpecificV8Snapshot, FuseState.DISABLE); + fuseIs(fuses, FuseV1Options.GrantFileProtocolExtraPrivileges, FuseState.DISABLE); + + const nativeBinding = unpackedFiles[0]; + const nativeProbe = await verifyNativeDriver(nativeBinding, asarPath); + const rawHeader = getRawHeader(asarPath); + const headerSha256 = crypto.createHash("sha256").update(rawHeader.headerString).digest("hex"); + const integrityEntryCount = verifyAsarEntryIntegrity(rawHeader, asarPath, entries); + const runtimeIntegrity = await verifyEmbeddedAsarIntegrity(layout, descriptor, headerSha256); + const components = packagedComponents(asarPath, entries); + return Object.freeze({ + schemaVersion: 1, + target, + platform: descriptor.platform, + arch: descriptor.arch, + version, + buildId, + asar: Object.freeze({ + sha256: await sha256File(asarPath), + headerSha256, + entryCount: entries.length, + integrityEntryCount, + runtimeIntegrity + }), + nativeDriver: Object.freeze({ + ...nativeProbe, + bindingSha256: await sha256File(nativeBinding) + }), + fuses: Object.freeze({ + runAsNode: false, + cookieEncryption: true, + nodeOptions: false, + nodeCliInspect: false, + embeddedAsarIntegrity: true, + onlyLoadAppFromAsar: true, + browserProcessV8Snapshot: false, + fileProtocolExtraPrivileges: false + }), + components + }); +} + +export async function auditPackagedApp({ outputRoot, target, version, buildId }) { + const layout = await resolvePackagedLayout(outputRoot, target); + return auditPackagedLayout({ layout, target, version, buildId }); +} + +export async function auditExtractedApp({ appRoot, target, version, buildId }) { + const descriptor = RELEASE_TARGETS[target]; + if (!descriptor) throw new Error("Unknown release target."); + const resolvedRoot = path.resolve(appRoot); + const layout = descriptor.platform === "darwin" + ? Object.freeze({ + unpackedRoot: path.dirname(resolvedRoot), + appRoot: resolvedRoot, + resources: path.join(resolvedRoot, "Contents", "Resources"), + executable: path.join(resolvedRoot, "Contents", "MacOS", "Codex Provider Sync") + }) + : Object.freeze({ + unpackedRoot: resolvedRoot, + appRoot: resolvedRoot, + resources: path.join(resolvedRoot, "resources"), + executable: path.join( + resolvedRoot, + descriptor.platform === "win32" ? "Codex Provider Sync.exe" : "codex-provider-sync" + ) + }); + return auditPackagedLayout({ layout, target, version, buildId }); +} + +function resolveLockDependency(packages, fromKey, dependencyName) { + let current = fromKey; + while (true) { + const candidate = path.posix.join(current, "node_modules", dependencyName); + const entry = packages[candidate]; + if (entry) return entry.link ? entry.resolved : candidate; + if (!current) break; + const parent = path.posix.dirname(current); + current = parent === "." ? "" : parent; + } + return null; +} + +function npmPurl(name, version) { + if (name.startsWith("@")) { + const [scope, packageName] = name.slice(1).split("/"); + return `pkg:npm/%40${encodeURIComponent(scope)}/${encodeURIComponent(packageName)}@${encodeURIComponent(version)}`; + } + return `pkg:npm/${encodeURIComponent(name)}@${encodeURIComponent(version)}`; +} + +function integrityHash(integrity) { + const match = typeof integrity === "string" ? integrity.match(/^sha512-(.+)$/) : null; + if (!match) return []; + return [{ alg: "SHA-512", content: Buffer.from(match[1], "base64").toString("hex") }]; +} + +export async function createRuntimeProjection(lockfilePath) { + const lockfile = JSON.parse(await fs.readFile(lockfilePath, "utf8")); + assert.equal(lockfile.lockfileVersion, 3, "C9 runtime projection requires npm lockfile v3."); + const packages = lockfile.packages; + const rootKey = "apps/desktop"; + assert.equal(packages[rootKey]?.name, "@codex-provider-sync/desktop"); + const records = new Map(); + const visited = new Set(); + + function visit(lockKey, expectedName) { + if (visited.has(lockKey)) { + const existing = packages[lockKey]; + return `${existing.name ?? expectedName}@${existing.version}`; + } + visited.add(lockKey); + const entry = packages[lockKey]; + assert.ok(entry && typeof entry === "object", `Runtime dependency is absent from lockfile: ${lockKey}`); + const name = entry.name ?? expectedName; + assert.equal(typeof name, "string"); + assert.equal(typeof entry.version, "string"); + const ref = `${name}@${entry.version}`; + const dependencyRefs = new Set(); + const dependencyGroups = [ + [entry.dependencies ?? {}, false], + [entry.optionalDependencies ?? {}, true], + [entry.peerDependencies ?? {}, false] + ]; + for (const [dependencies, optionalGroup] of dependencyGroups) { + for (const dependencyName of Object.keys(dependencies).sort()) { + const optionalPeer = entry.peerDependenciesMeta?.[dependencyName]?.optional === true; + const dependencyKey = resolveLockDependency(packages, lockKey, dependencyName); + if (!dependencyKey) { + if (optionalGroup || optionalPeer) continue; + throw new Error(`Required runtime dependency is unresolved: ${name} -> ${dependencyName}`); + } + dependencyRefs.add(visit(dependencyKey, dependencyName)); + } + } + const current = records.get(ref); + if (current) { + for (const dependencyRef of dependencyRefs) current.dependencies.add(dependencyRef); + } else { + records.set(ref, { + ref, + name, + version: entry.version, + license: typeof entry.license === "string" ? entry.license : null, + resolved: typeof entry.resolved === "string" && /^https:\/\//.test(entry.resolved) ? entry.resolved : null, + hashes: integrityHash(entry.integrity), + dependencies: dependencyRefs + }); + } + return ref; + } + + const rootRef = visit(rootKey, "@codex-provider-sync/desktop"); + const root = records.get(rootRef); + records.delete(rootRef); + return Object.freeze({ + rootDependencies: [...root.dependencies].sort(), + components: [...records.values()] + .map((record) => Object.freeze({ ...record, dependencies: [...record.dependencies].sort() })) + .sort((left, right) => left.ref.localeCompare(right.ref)) + }); +} + +export function createCycloneDx({ audit, timestamp, runtimeProjection }) { + const applicationRef = `application:@codex-provider-sync/desktop@${audit.version}`; + const electronRef = "framework:electron@44.0.0"; + const projected = new Set(runtimeProjection.components.map((component) => component.ref)); + for (const component of audit.components) { + if (component.name === "@codex-provider-sync/desktop") continue; + assert.equal( + projected.has(`${component.name}@${component.version}`), + true, + `Packaged component is absent from the runtime lock projection: ${component.name}@${component.version}` + ); + } + const libraries = runtimeProjection.components.map((component) => ({ + type: "library", + name: component.name, + version: component.version, + "bom-ref": `library:${component.name}@${component.version}`, + purl: npmPurl(component.name, component.version), + ...(component.license ? { licenses: [{ license: { name: component.license } }] } : {}), + ...(component.hashes.length ? { hashes: component.hashes } : {}), + ...(component.resolved ? { + externalReferences: [{ type: "distribution", url: component.resolved }] + } : {}) + })); + const libraryRefs = new Map(libraries.map((component) => [ + `${component.name}@${component.version}`, + component["bom-ref"] + ])); + return { + bomFormat: "CycloneDX", + specVersion: "1.6", + serialNumber: `urn:uuid:${crypto.randomUUID()}`, + version: 1, + metadata: { + timestamp, + tools: { + components: [{ type: "application", name: "codex-provider-sync-c9-auditor", version: "1" }] + }, + component: { + type: "application", + name: "@codex-provider-sync/desktop", + version: audit.version, + "bom-ref": applicationRef + } + }, + components: [ + { type: "framework", name: "electron", version: "44.0.0", "bom-ref": electronRef }, + ...libraries + ], + dependencies: [ + { + ref: applicationRef, + dependsOn: [ + electronRef, + ...runtimeProjection.rootDependencies.map((ref) => libraryRefs.get(ref)) + ].sort() + }, + { ref: electronRef, dependsOn: [] }, + ...runtimeProjection.components.map((component) => ({ + ref: libraryRefs.get(component.ref), + dependsOn: component.dependencies.map((ref) => libraryRefs.get(ref)).sort() + })) + ] + }; +} + +export const RELEASE_REPOSITORY_ROOT = repositoryRoot; diff --git a/apps/desktop/scripts/resolve-candidate-build.mjs b/apps/desktop/scripts/resolve-candidate-build.mjs new file mode 100644 index 0000000..d7fa020 --- /dev/null +++ b/apps/desktop/scripts/resolve-candidate-build.mjs @@ -0,0 +1,53 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const DESKTOP_RELEASE_BASE_VERSION = "1.0.0"; +export const DESKTOP_CANDIDATE_TARGETS = Object.freeze([ + "windows-x64", + "macos-x64", + "macos-arm64", + "linux-x64" +]); + +const CHANNELS = new Set(["alpha", "beta", "rc"]); +const SHA_PATTERN = /^[0-9a-f]{7,40}$/i; + +export function resolveCandidateBuild({ channel, runNumber, sha, target }) { + if (!CHANNELS.has(channel)) throw new Error("Candidate channel must be alpha, beta, or rc."); + if (!Number.isSafeInteger(runNumber) || runNumber < 0) { + throw new Error("Candidate run number must be a non-negative safe integer."); + } + if (!SHA_PATTERN.test(sha)) throw new Error("Candidate commit must be a 7-40 character hexadecimal SHA."); + if (!DESKTOP_CANDIDATE_TARGETS.includes(target)) throw new Error("Unknown desktop candidate target."); + + const version = `${DESKTOP_RELEASE_BASE_VERSION}-${channel}.${runNumber}`; + const commit = sha.toLowerCase(); + const buildId = `${version}-${commit.slice(0, 12)}-${target}`; + return Object.freeze({ version, buildId, commit, target, channel, runNumber }); +} + +async function main() { + const result = resolveCandidateBuild({ + channel: process.env.CPS_CANDIDATE_CHANNEL || "rc", + runNumber: Number(process.env.CPS_CANDIDATE_RUN_NUMBER || "0"), + sha: process.env.CPS_CANDIDATE_SHA || "0000000", + target: process.env.CPS_CANDIDATE_TARGET || "windows-x64" + }); + + const outputPath = process.env.GITHUB_OUTPUT; + if (outputPath) { + await fs.appendFile(path.resolve(outputPath), [ + `version=${result.version}`, + `build_id=${result.buildId}`, + `commit=${result.commit}`, + `target=${result.target}`, + "" + ].join("\n"), "utf8"); + } + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + await main(); +} diff --git a/apps/desktop/scripts/run-packaged-e2e.mjs b/apps/desktop/scripts/run-packaged-e2e.mjs new file mode 100644 index 0000000..fb6d72e --- /dev/null +++ b/apps/desktop/scripts/run-packaged-e2e.mjs @@ -0,0 +1,70 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = path.resolve(desktopRoot, "../.."); +const outputRoot = path.join(repositoryRoot, "dist-desktop"); + +async function existing(candidates) { + for (const candidate of candidates) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + return null; +} + +const directories = (await fs.readdir(outputRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +let candidates; +if (process.platform === "win32") { + candidates = [ + path.join(outputRoot, "win-unpacked", "Codex Provider Sync.exe"), + path.join(outputRoot, "win-unpacked", "codex-provider-sync.exe") + ]; +} else if (process.platform === "darwin") { + candidates = directories + .filter((name) => name.startsWith("mac")) + .flatMap((name) => [ + path.join(outputRoot, name, "Codex Provider Sync.app", "Contents", "MacOS", "Codex Provider Sync"), + path.join(outputRoot, name, "Codex Provider Sync.app", "Contents", "MacOS", "codex-provider-sync") + ]); +} else if (process.platform === "linux") { + candidates = directories + .filter((name) => name.startsWith("linux") && name.endsWith("unpacked")) + .flatMap((name) => [ + path.join(outputRoot, name, "codex-provider-sync"), + path.join(outputRoot, name, "Codex Provider Sync") + ]); +} else { + throw new Error(`Unsupported packaged Electron smoke platform: ${process.platform}`); +} + +const executable = await existing(candidates); +if (!executable) { + throw new Error(`No unpacked Electron executable found under ${outputRoot}.`); +} +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error("npm_execpath is required to run the packaged Electron smoke."); +const result = spawnSync(process.execPath, [ + npmCli, + "run", + "test:e2e:production", + "--workspace", + "@codex-provider-sync/desktop" +], { + cwd: repositoryRoot, + env: { ...process.env, CPS_DESKTOP_EXECUTABLE: executable }, + encoding: "utf8", + stdio: "inherit" +}); +if (result.error) throw result.error; +if (result.status !== 0) process.exit(result.status ?? 1); +process.stdout.write(`Unpacked Electron smoke passed: ${executable}\n`); diff --git a/apps/desktop/scripts/smoke-candidate-artifacts.mjs b/apps/desktop/scripts/smoke-candidate-artifacts.mjs new file mode 100644 index 0000000..9b5cf4b --- /dev/null +++ b/apps/desktop/scripts/smoke-candidate-artifacts.mjs @@ -0,0 +1,386 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + auditExtractedApp, + RELEASE_REPOSITORY_ROOT, + RELEASE_TARGETS, + sha256File +} from "./release-audit.mjs"; + +const VERSION_PATTERN = /^1\.0\.0-(?:alpha|beta|rc)\.\d+$/; +const LINUX_SANDBOX_HELPER = fileURLToPath(new URL("./configure-linux-sandbox.mjs", import.meta.url)); +const target = process.env.CPS_CANDIDATE_TARGET; +const version = process.env.CPS_DESKTOP_VERSION; +const descriptor = RELEASE_TARGETS[target]; +if (!descriptor) throw new Error("CPS_CANDIDATE_TARGET is invalid."); +if (!VERSION_PATTERN.test(version || "")) throw new Error("CPS_DESKTOP_VERSION is invalid."); +if (process.platform !== descriptor.platform || process.arch !== descriptor.arch) { + throw new Error(`Candidate ${target} smoke must run on native ${descriptor.platform}/${descriptor.arch}.`); +} + +const candidateRoot = path.join(RELEASE_REPOSITORY_ROOT, "artifacts", "c9", target); +const assetsRoot = path.join(candidateRoot, "assets"); +const metadataRoot = path.join(candidateRoot, "metadata"); +const stagingPath = path.join(metadataRoot, "candidate-staging.v1.json"); +const auditPath = path.join(metadataRoot, "audit-report.v1.json"); +const staging = JSON.parse(await fs.readFile(stagingPath, "utf8")); +const expectedAudit = JSON.parse(await fs.readFile(auditPath, "utf8")); +assert.equal(staging.schemaVersion, 1); +assert.equal(staging.scope, "ci-candidate-staging"); +assert.equal(staging.releaseAuthorized, false); +assert.equal(staging.target, target); +assert.equal(staging.version, version); +assert.equal(expectedAudit.target, target); +assert.equal(expectedAudit.version, version); +assert.equal(expectedAudit.buildId, staging.buildId); +assert.deepEqual( + staging.assets.map((asset) => asset.name).sort(), + descriptor.assets(version).sort(), + "Staged candidate assets do not match the release target." +); +const containerRecords = []; +const tempBase = path.resolve(os.tmpdir()); +const tempRoot = await fs.mkdtemp(path.join(tempBase, "cps-c9-artifact-smoke-")); + +function sorted(value) { + if (Array.isArray(value)) return value.map(sorted); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])])); +} + +async function writeJson(filePath, value) { + await fs.writeFile(filePath, `${JSON.stringify(sorted(value), null, 2)}\n`, { encoding: "utf8", flag: "wx" }); +} + +async function fileRecord(filePath, name) { + const stat = await fs.stat(filePath); + assert.equal(stat.isFile(), true, `Candidate metadata is not a file: ${name}`); + return Object.freeze({ name, sizeBytes: stat.size, sha256: await sha256File(filePath) }); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || RELEASE_REPOSITORY_ROOT, + env: options.env || process.env, + encoding: "utf8", + stdio: options.inherit ? "inherit" : "pipe", + maxBuffer: 16 * 1024 * 1024 + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${path.basename(command)} failed with exit code ${result.status}: ${String(result.stderr).trim()}`); + } + return result; +} + +async function existingFile(candidates) { + for (const candidate of candidates) { + try { + if ((await fs.stat(candidate)).isFile()) return candidate; + } catch (error) { + if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error; + } + } + return null; +} + +async function findNamedFile(root, names) { + const found = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile() && names.includes(entry.name)) found.push(absolute); + } + } + await visit(root); + if (found.length !== 1) throw new Error(`Expected one packaged executable under ${path.basename(root)}, found ${found.length}.`); + return found[0]; +} + +async function waitForRemoval(targetPath, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fs.lstat(targetPath); + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for uninstall cleanup: ${path.basename(targetPath)}`); +} + +function runProductSmoke(executable) { + const npmCli = process.env.npm_execpath; + if (!npmCli) throw new Error("npm_execpath is required for candidate smoke."); + run(process.execPath, [ + npmCli, + "run", + "test:e2e:production", + "--workspace", + "@codex-provider-sync/desktop" + ], { + inherit: true, + env: { + ...process.env, + CPS_DESKTOP_EXECUTABLE: executable, + CPS_DESKTOP_WINDOW_DISPLAY: "hidden" + } + }); +} + +async function configureLinuxSandbox(appRoot) { + if (process.platform !== "linux") { + if (process.env.CPS_LINUX_SANDBOX_SETUP) { + throw new Error("CPS_LINUX_SANDBOX_SETUP is supported only on Linux."); + } + return; + } + const realTempRoot = await fs.realpath(tempRoot); + const realAppRoot = await fs.realpath(path.resolve(appRoot)); + const relativeAppRoot = path.relative(realTempRoot, realAppRoot); + if (!relativeAppRoot + || relativeAppRoot.startsWith(`..${path.sep}`) + || relativeAppRoot === ".." + || path.isAbsolute(relativeAppRoot)) { + throw new Error("Refusing to configure a Linux sandbox outside the candidate smoke directory."); + } + const sandboxPath = path.join(realAppRoot, "chrome-sandbox"); + const before = await fs.lstat(sandboxPath); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) { + throw new Error("The packaged Linux chrome-sandbox must be one regular, unlinked file."); + } + if (before.uid === 0 && (before.mode & 0o7777) === 0o4755) return; + if (process.env.CPS_LINUX_SANDBOX_SETUP !== "setuid") { + throw new Error( + "The Linux candidate sandbox is not configured; set CPS_LINUX_SANDBOX_SETUP=setuid in an authorized CI environment." + ); + } + run("sudo", ["--", process.execPath, LINUX_SANDBOX_HELPER, realAppRoot, "chrome-sandbox"]); + const after = await fs.lstat(sandboxPath); + if (after.uid !== 0 || (after.mode & 0o7777) !== 0o4755 || after.nlink !== 1) { + throw new Error("The packaged Linux chrome-sandbox failed its owner/mode verification."); + } +} + +async function inspectAndSmokeContainer({ appRoot, executable, assetName, containerKind }) { + const audit = await auditExtractedApp({ + appRoot, + target, + version, + buildId: staging.buildId + }); + assert.deepEqual(audit, expectedAudit, `Final ${containerKind} container differs from the audited app.`); + await configureLinuxSandbox(appRoot); + runProductSmoke(executable); + return Object.freeze({ + assetName, + containerKind, + asarSha256: audit.asar.sha256, + asarHeaderSha256: audit.asar.headerSha256, + asarEntryCount: audit.asar.entryCount, + asarIntegrityEntryCount: audit.asar.integrityEntryCount, + asarRuntimeIntegrity: audit.asar.runtimeIntegrity, + nativeBindingSha256: audit.nativeDriver.bindingSha256, + nativeDriverLoaded: true, + fixtureStatusVerified: true, + syncRestoreVerified: true, + gracefulExitVerified: true + }); +} + +async function smokeWindows() { + const [setupName, zipName] = descriptor.assets(version); + const zipRoot = path.join(tempRoot, "portable"); + await fs.mkdir(zipRoot); + run("tar.exe", ["-xf", path.join(assetsRoot, zipName), "-C", zipRoot]); + const portableExecutable = await findNamedFile(zipRoot, ["Codex Provider Sync.exe", "codex-provider-sync.exe"]); + containerRecords.push(await inspectAndSmokeContainer({ + appRoot: path.dirname(portableExecutable), + executable: portableExecutable, + assetName: zipName, + containerKind: "zip" + })); + + const installRoot = path.join(tempRoot, "installed"); + await fs.mkdir(installRoot); + run(path.join(assetsRoot, setupName), ["/S", `/D=${installRoot}`]); + const installedExecutable = await findNamedFile(installRoot, ["Codex Provider Sync.exe", "codex-provider-sync.exe"]); + const setupRecord = await inspectAndSmokeContainer({ + appRoot: path.dirname(installedExecutable), + executable: installedExecutable, + assetName: setupName, + containerKind: "nsis" + }); + const uninstaller = await existingFile([ + path.join(installRoot, "Uninstall Codex Provider Sync.exe"), + path.join(installRoot, "Uninstall codex-provider-sync.exe") + ]); + if (!uninstaller) throw new Error("NSIS candidate did not install its uninstaller."); + run(uninstaller, ["/S"]); + await waitForRemoval(installRoot); + containerRecords.push(Object.freeze({ ...setupRecord, uninstallVerified: true })); +} + +async function smokeMac() { + const [dmgName, zipName] = descriptor.assets(version); + const zipRoot = path.join(tempRoot, "zip"); + await fs.mkdir(zipRoot); + run("ditto", ["-x", "-k", path.join(assetsRoot, zipName), zipRoot]); + const zipApp = path.join(zipRoot, "Codex Provider Sync.app"); + containerRecords.push(await inspectAndSmokeContainer({ + appRoot: zipApp, + executable: path.join(zipApp, "Contents", "MacOS", "Codex Provider Sync"), + assetName: zipName, + containerKind: "zip" + })); + + const mountRoot = path.join(tempRoot, "dmg-mount"); + const copyRoot = path.join(tempRoot, "dmg-copy"); + await fs.mkdir(mountRoot); + await fs.mkdir(copyRoot); + run("hdiutil", ["attach", "-nobrowse", "-readonly", "-mountpoint", mountRoot, path.join(assetsRoot, dmgName)]); + try { + await fs.cp( + path.join(mountRoot, "Codex Provider Sync.app"), + path.join(copyRoot, "Codex Provider Sync.app"), + { recursive: true, errorOnExist: true, verbatimSymlinks: true } + ); + } finally { + run("hdiutil", ["detach", mountRoot]); + } + const dmgApp = path.join(copyRoot, "Codex Provider Sync.app"); + containerRecords.push(await inspectAndSmokeContainer({ + appRoot: dmgApp, + executable: path.join(dmgApp, "Contents", "MacOS", "Codex Provider Sync"), + assetName: dmgName, + containerKind: "dmg" + })); +} + +async function smokeLinux() { + const [appImageName, debName] = descriptor.assets(version); + const appImage = path.join(assetsRoot, appImageName); + await fs.chmod(appImage, 0o755); + const appImageRoot = path.join(tempRoot, "appimage"); + await fs.mkdir(appImageRoot); + run(appImage, ["--appimage-extract"], { cwd: appImageRoot }); + const appImageExecutable = await findNamedFile(path.join(appImageRoot, "squashfs-root"), ["codex-provider-sync"]); + containerRecords.push(await inspectAndSmokeContainer({ + appRoot: path.dirname(appImageExecutable), + executable: appImageExecutable, + assetName: appImageName, + containerKind: "appimage" + })); + + const debRoot = path.join(tempRoot, "deb"); + await fs.mkdir(debRoot); + run("dpkg-deb", ["-x", path.join(assetsRoot, debName), debRoot]); + const debExecutable = await findNamedFile(debRoot, ["codex-provider-sync"]); + const expectedDebExecutable = path.join( + debRoot, + "opt", + "CodexProviderSync", + "codex-provider-sync" + ); + assert.equal( + debExecutable, + expectedDebExecutable, + "The deb candidate must use the space-free Electron SUID sandbox install path." + ); + containerRecords.push(await inspectAndSmokeContainer({ + appRoot: path.dirname(debExecutable), + executable: debExecutable, + assetName: debName, + containerKind: "deb" + })); +} + +async function finalizeCandidate() { + containerRecords.sort((left, right) => left.assetName.localeCompare(right.assetName)); + assert.deepEqual( + containerRecords.map((record) => record.assetName), + descriptor.assets(version).sort(), + "Every release container must be audited and smoked exactly once." + ); + for (const asset of staging.assets) { + const assetPath = path.join(assetsRoot, asset.name); + const stat = await fs.stat(assetPath); + assert.equal(stat.size, asset.sizeBytes, `Candidate asset size changed: ${asset.name}`); + assert.equal(await sha256File(assetPath), asset.sha256, `Candidate asset hash changed: ${asset.name}`); + } + + const verifiedAt = new Date().toISOString(); + const containerPath = path.join(metadataRoot, "container-verification.v1.json"); + const report = { + schemaVersion: 1, + scope: "final-release-containers", + target, + version, + buildId: staging.buildId, + commit: staging.commit, + verifiedAt, + containers: containerRecords + }; + await writeJson(containerPath, report); + + const metadata = await Promise.all([ + fileRecord(auditPath, "metadata/audit-report.v1.json"), + fileRecord(path.join(metadataRoot, "sbom.cyclonedx.json"), "metadata/sbom.cyclonedx.json"), + fileRecord(stagingPath, "metadata/candidate-staging.v1.json"), + fileRecord(containerPath, "metadata/container-verification.v1.json") + ]); + metadata.sort((left, right) => left.name.localeCompare(right.name)); + const containerMetadata = metadata.find((entry) => entry.name.endsWith("container-verification.v1.json")); + assert.ok(containerMetadata); + const { scope: _stagingScope, ...stagingFields } = staging; + const manifest = { + ...stagingFields, + scope: "ci-candidate", + verifiedAt, + containerVerification: { + reportSha256: containerMetadata.sha256, + containerCount: containerRecords.length, + fixtureStatus: true, + syncRestore: true, + nativeDriver: true, + gracefulExit: true, + uninstall: descriptor.platform === "win32" + }, + metadata + }; + const manifestPath = path.join(metadataRoot, "release-manifest.v1.json"); + await writeJson(manifestPath, manifest); + + const checksumEntries = [ + ...staging.assets.map((asset) => ({ name: `assets/${asset.name}`, sha256: asset.sha256 })), + ...metadata.map(({ name, sha256 }) => ({ name, sha256 })), + { name: "metadata/release-manifest.v1.json", sha256: await sha256File(manifestPath) } + ].sort((left, right) => left.name.localeCompare(right.name)); + await fs.writeFile( + path.join(metadataRoot, "SHA256SUMS.txt"), + `${checksumEntries.map((entry) => `${entry.sha256} ${entry.name}`).join("\n")}\n`, + { encoding: "utf8", flag: "wx" } + ); +} + +try { + if (descriptor.platform === "win32") await smokeWindows(); + else if (descriptor.platform === "darwin") await smokeMac(); + else await smokeLinux(); + await finalizeCandidate(); + process.stdout.write(`Candidate artifact smoke passed: ${target} ${version}\n`); +} finally { + const resolved = path.resolve(tempRoot); + if (path.dirname(resolved) !== tempBase || !path.basename(resolved).startsWith("cps-c9-artifact-smoke-")) { + throw new Error("Refusing to remove an unexpected candidate smoke directory."); + } + await fs.rm(resolved, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); +} diff --git a/apps/desktop/scripts/stage-candidate.mjs b/apps/desktop/scripts/stage-candidate.mjs new file mode 100644 index 0000000..008482a --- /dev/null +++ b/apps/desktop/scripts/stage-candidate.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + auditPackagedApp, + ARTIFACT_AUDIT_POLICY_PATH, + createCycloneDx, + createRuntimeProjection, + RELEASE_REPOSITORY_ROOT, + RELEASE_TARGETS, + sha256File +} from "./release-audit.mjs"; + +const VERSION_PATTERN = /^1\.0\.0-(?:alpha|beta|rc)\.\d+$/; +const BUILD_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; +const COMMIT_PATTERN = /^[0-9a-f]{40}$/i; +const outputRoot = path.join(RELEASE_REPOSITORY_ROOT, "dist-desktop"); +const artifactRoot = path.join(RELEASE_REPOSITORY_ROOT, "artifacts", "c9"); + +function sorted(value) { + if (Array.isArray(value)) return value.map(sorted); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])])); +} + +async function writeJson(filePath, value) { + await fs.writeFile(filePath, `${JSON.stringify(sorted(value), null, 2)}\n`, { encoding: "utf8", flag: "wx" }); +} + +async function assertNewDirectory(directory) { + try { + await fs.lstat(directory); + throw new Error(`Candidate output already exists: ${path.basename(directory)}`); + } catch (error) { + if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error; + } + await fs.mkdir(directory, { recursive: true }); +} + +async function copyAsset(source, destination) { + const stat = await fs.lstat(source); + assert.equal(stat.isFile(), true, `Candidate asset is not a regular file: ${path.basename(source)}`); + assert.equal(stat.isSymbolicLink(), false, `Candidate asset is a symbolic link: ${path.basename(source)}`); + await fs.copyFile(source, destination, fs.constants.COPYFILE_EXCL); + const copied = await fs.stat(destination); + return Object.freeze({ + name: path.basename(destination), + sizeBytes: copied.size, + sha256: await sha256File(destination) + }); +} + +const target = process.env.CPS_CANDIDATE_TARGET; +const version = process.env.CPS_DESKTOP_VERSION; +const buildId = process.env.CPS_DESKTOP_BUILD_ID; +const commit = process.env.CPS_CANDIDATE_SHA?.toLowerCase(); +const descriptor = RELEASE_TARGETS[target]; +if (!descriptor) throw new Error("CPS_CANDIDATE_TARGET is invalid."); +if (!VERSION_PATTERN.test(version || "")) throw new Error("CPS_DESKTOP_VERSION is invalid."); +if (!BUILD_ID_PATTERN.test(buildId || "")) throw new Error("CPS_DESKTOP_BUILD_ID is invalid."); +if (!COMMIT_PATTERN.test(commit || "")) throw new Error("CPS_CANDIDATE_SHA must be a full commit SHA."); +if (process.platform !== descriptor.platform || process.arch !== descriptor.arch) { + throw new Error(`Candidate ${target} must be staged on native ${descriptor.platform}/${descriptor.arch}.`); +} + +const candidateRoot = path.join(artifactRoot, target); +const assetsRoot = path.join(candidateRoot, "assets"); +const metadataRoot = path.join(candidateRoot, "metadata"); +const audit = await auditPackagedApp({ outputRoot, target, version, buildId }); +await assertNewDirectory(candidateRoot); +await fs.mkdir(assetsRoot); +await fs.mkdir(metadataRoot); + +const assetRecords = []; +for (const assetName of descriptor.assets(version)) { + assetRecords.push(await copyAsset(path.join(outputRoot, assetName), path.join(assetsRoot, assetName))); +} +assetRecords.sort((left, right) => left.name.localeCompare(right.name)); + +const timestamp = new Date().toISOString(); +const auditPath = path.join(metadataRoot, "audit-report.v1.json"); +const sbomPath = path.join(metadataRoot, "sbom.cyclonedx.json"); +const stagingPath = path.join(metadataRoot, "candidate-staging.v1.json"); +await writeJson(auditPath, audit); +const runtimeProjection = await createRuntimeProjection(path.join(RELEASE_REPOSITORY_ROOT, "package-lock.json")); +await writeJson(sbomPath, createCycloneDx({ audit, timestamp, runtimeProjection })); + +const staging = { + schemaVersion: 1, + scope: "ci-candidate-staging", + releaseAuthorized: false, + signingStatus: "unsigned-candidate", + notarizationStatus: "not-authorized", + target, + platform: descriptor.platform, + arch: descriptor.arch, + version, + buildId, + commit, + createdAt: timestamp, + lockfileSha256: await sha256File(path.join(RELEASE_REPOSITORY_ROOT, "package-lock.json")), + toolVersions: { + electron: "44.0.0", + electronBuilder: "26.15.7", + electronAsar: "4.3.0", + electronFuses: "2.1.3", + betterSqlite3: "13.0.3", + plist: "5.0.0", + resedit: "3.1.0" + }, + assets: assetRecords, + audit: { + asarSha256: audit.asar.sha256, + asarHeaderSha256: audit.asar.headerSha256, + asarEntryCount: audit.asar.entryCount, + asarIntegrityEntryCount: audit.asar.integrityEntryCount, + asarRuntimeIntegrity: audit.asar.runtimeIntegrity, + nativeDriver: audit.nativeDriver.driver, + nativeBindingSha256: audit.nativeDriver.bindingSha256, + fusePolicy: "c9-v1", + artifactAuditPolicy: { + schemaVersion: 1, + sha256: await sha256File(ARTIFACT_AUDIT_POLICY_PATH) + } + } +}; +await writeJson(stagingPath, staging); + +process.stdout.write(`Release candidate assets staged for container verification: ${target} ${version}\n`); diff --git a/apps/desktop/scripts/verify-candidate-set.mjs b/apps/desktop/scripts/verify-candidate-set.mjs new file mode 100644 index 0000000..2df856f --- /dev/null +++ b/apps/desktop/scripts/verify-candidate-set.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { RELEASE_REPOSITORY_ROOT, RELEASE_TARGETS, sha256File } from "./release-audit.mjs"; + +const downloadRoot = path.join(RELEASE_REPOSITORY_ROOT, "artifacts", "c9-download"); +const indexRoot = path.join(RELEASE_REPOSITORY_ROOT, "artifacts", "c9-index"); + +async function findNamed(root, name) { + const found = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error("Candidate download contains a symbolic link."); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile() && entry.name === name) found.push(absolute); + } + } + await visit(root); + return found.sort((left, right) => left.localeCompare(right)); +} + +async function relativeFiles(root) { + const found = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error("Candidate artifact contains a symbolic link."); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) found.push(path.relative(root, absolute).replaceAll("\\", "/")); + else throw new Error("Candidate artifact contains an unsupported entry."); + } + } + await visit(root); + return found.sort((left, right) => left.localeCompare(right)); +} + +function safeRelative(root, value) { + const normalized = value.replaceAll("\\", "/"); + if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) { + throw new Error("Candidate checksum contains an unsafe path."); + } + const absolute = path.resolve(root, ...normalized.split("/")); + const relative = path.relative(root, absolute); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Candidate checksum escapes its artifact root."); + } + return absolute; +} + +const manifestFiles = await findNamed(downloadRoot, "release-manifest.v1.json"); +assert.equal(manifestFiles.length, 4, "Exactly four candidate manifests are required."); +const records = []; +for (const manifestFile of manifestFiles) { + const metadataRoot = path.dirname(manifestFile); + const candidateRoot = path.dirname(metadataRoot); + const checksumsFile = path.join(metadataRoot, "SHA256SUMS.txt"); + const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8")); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.scope, "ci-candidate"); + assert.equal(manifest.releaseAuthorized, false); + assert.equal(manifest.signingStatus, "unsigned-candidate"); + assert.equal(manifest.notarizationStatus, "not-authorized"); + assert.ok(RELEASE_TARGETS[manifest.target], "Candidate manifest target is unknown."); + assert.match(manifest.lockfileSha256, /^[0-9a-f]{64}$/); + assert.equal(manifest.audit?.fusePolicy, "c9-v1"); + assert.equal(manifest.audit?.artifactAuditPolicy?.schemaVersion, 1); + assert.match(manifest.audit?.artifactAuditPolicy?.sha256, /^[0-9a-f]{64}$/); + assert.ok(manifest.audit?.asarIntegrityEntryCount > 0); + assert.equal( + manifest.audit?.asarRuntimeIntegrity, + manifest.platform === "linux" ? "unsupported-platform" : "verified" + ); + assert.equal(manifest.containerVerification?.containerCount, 2); + assert.equal(manifest.containerVerification?.fixtureStatus, true); + assert.equal(manifest.containerVerification?.syncRestore, true); + assert.equal(manifest.containerVerification?.nativeDriver, true); + assert.equal(manifest.containerVerification?.gracefulExit, true); + assert.deepEqual( + manifest.assets.map((asset) => asset.name).sort(), + RELEASE_TARGETS[manifest.target].assets(manifest.version).sort(), + "Candidate asset names do not match the frozen release target." + ); + + const containerReport = JSON.parse(await fs.readFile(path.join(metadataRoot, "container-verification.v1.json"), "utf8")); + assert.equal(containerReport.schemaVersion, 1); + assert.equal(containerReport.scope, "final-release-containers"); + assert.equal(containerReport.target, manifest.target); + assert.equal(containerReport.version, manifest.version); + assert.equal(containerReport.buildId, manifest.buildId); + assert.equal(containerReport.commit, manifest.commit); + assert.deepEqual( + containerReport.containers.map((container) => container.assetName).sort(), + RELEASE_TARGETS[manifest.target].assets(manifest.version).sort(), + "Container verification does not cover every candidate asset." + ); + + const checksumLines = (await fs.readFile(checksumsFile, "utf8")).trim().split(/\r?\n/); + const expectedChecksummedPaths = [ + ...manifest.assets.map((asset) => `assets/${asset.name}`), + ...manifest.metadata.map((entry) => entry.name), + "metadata/release-manifest.v1.json" + ].sort((left, right) => left.localeCompare(right)); + const checksumPaths = []; + for (const line of checksumLines) { + const match = line.match(/^([0-9a-f]{64}) ([A-Za-z0-9._/-]+)$/); + assert.ok(match, "Candidate checksum line is malformed."); + const checkedPath = safeRelative(candidateRoot, match[2]); + const stat = await fs.stat(checkedPath); + assert.equal(stat.isFile(), true, `Checksum target is not a regular file: ${match[2]}.`); + assert.equal(await sha256File(checkedPath), match[1], `Checksum mismatch for ${match[2]}.`); + checksumPaths.push(match[2]); + } + checksumPaths.sort((left, right) => left.localeCompare(right)); + assert.deepEqual(checksumPaths, expectedChecksummedPaths, "Candidate checksums must cover the exact manifest closure."); + assert.equal(new Set(checksumPaths).size, checksumPaths.length, "Candidate checksums contain duplicate paths."); + assert.deepEqual( + await relativeFiles(candidateRoot), + [...expectedChecksummedPaths, "metadata/SHA256SUMS.txt"].sort((left, right) => left.localeCompare(right)), + "Candidate artifact contains an unmanifested file." + ); + for (const record of [...manifest.assets.map((asset) => ({ ...asset, name: `assets/${asset.name}` })), ...manifest.metadata]) { + const recordPath = safeRelative(candidateRoot, record.name); + const stat = await fs.stat(recordPath); + assert.equal(stat.size, record.sizeBytes, `Manifest size mismatch for ${record.name}.`); + assert.equal(await sha256File(recordPath), record.sha256, `Manifest hash mismatch for ${record.name}.`); + } + assert.equal( + manifest.containerVerification.reportSha256, + manifest.metadata.find((entry) => entry.name === "metadata/container-verification.v1.json")?.sha256, + "Container verification report hash is not bound into the manifest." + ); + records.push(Object.freeze({ + target: manifest.target, + version: manifest.version, + commit: manifest.commit, + buildId: manifest.buildId, + lockfileSha256: manifest.lockfileSha256, + toolVersions: manifest.toolVersions, + fusePolicy: manifest.audit.fusePolicy, + artifactAuditPolicy: manifest.audit.artifactAuditPolicy, + manifestSha256: await sha256File(manifestFile), + assets: manifest.assets.map(({ name, sizeBytes, sha256 }) => ({ name, sizeBytes, sha256 })) + })); +} + +records.sort((left, right) => left.target.localeCompare(right.target)); +assert.deepEqual(records.map((record) => record.target), Object.keys(RELEASE_TARGETS).sort()); +assert.equal(new Set(records.map((record) => record.version)).size, 1, "Candidate versions do not match."); +assert.equal(new Set(records.map((record) => record.commit)).size, 1, "Candidate commits do not match."); +assert.equal(new Set(records.map((record) => record.lockfileSha256)).size, 1, "Candidate lockfiles do not match."); +assert.equal( + new Set(records.map((record) => JSON.stringify(record.toolVersions))).size, + 1, + "Candidate tool versions do not match." +); +assert.equal(new Set(records.map((record) => record.fusePolicy)).size, 1, "Candidate fuse policies do not match."); +assert.equal( + new Set(records.map((record) => JSON.stringify(record.artifactAuditPolicy))).size, + 1, + "Candidate artifact audit policies do not match." +); + +try { + await fs.lstat(indexRoot); + throw new Error("Candidate index output already exists."); +} catch (error) { + if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error; +} +await fs.mkdir(indexRoot, { recursive: true }); +const index = { + schemaVersion: 1, + scope: "ci-candidate-index", + releaseAuthorized: false, + version: records[0].version, + commit: records[0].commit, + targets: records +}; +await fs.writeFile( + path.join(indexRoot, "candidate-index.v1.json"), + `${JSON.stringify(index, null, 2)}\n`, + { encoding: "utf8", flag: "wx" } +); +process.stdout.write(`Candidate set verified: ${records[0].version} ${records[0].commit}\n`); diff --git a/apps/desktop/scripts/verify-production-bundle.mjs b/apps/desktop/scripts/verify-production-bundle.mjs new file mode 100644 index 0000000..6aaffb0 --- /dev/null +++ b/apps/desktop/scripts/verify-production-bundle.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outputRoot = path.join(desktopRoot, "out"); +const auditPolicy = JSON.parse(await fs.readFile( + path.join(desktopRoot, "release", "artifact-audit-policy.v1.json"), + "utf8" +)); +assert.equal(auditPolicy.schemaVersion, 1, "Unsupported artifact audit policy."); +const auditedTextExtensions = new Set(auditPolicy.auditedProductTextExtensions); +const forbiddenTextRules = auditPolicy.forbiddenTextRules.map((rule) => ({ + id: rule.id, + pattern: new RegExp(rule.pattern) +})); + +async function filesUnder(root) { + const result = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) result.push(absolute); + } + } + await visit(root); + return result; +} + +const files = await filesUnder(outputRoot); +const relative = files.map((file) => path.relative(outputRoot, file).replaceAll("\\", "/")); +for (const required of ["main/index.js", "main/runtime.js", "preload/index.cjs", "renderer/index.html"]) { + assert.ok(relative.includes(required), `Production Electron output is missing ${required}.`); +} +assert.equal(relative.some((file) => file.endsWith(".map")), false, "Production Electron output contains source maps."); + +const processText = (await Promise.all( + files + .filter((file) => auditedTextExtensions.has(path.extname(file).toLowerCase())) + .map((file) => fs.readFile(file, "utf8")) +)).join("\n"); +for (const rule of forbiddenTextRules) { + assert.doesNotMatch(processText, rule.pattern, `Production Electron output violates ${rule.id}.`); +} +assert.doesNotMatch(processText, /@codex-provider-sync\//, "Production Electron output contains a workspace import."); + +const preload = await fs.readFile(path.join(outputRoot, "preload", "index.cjs"), "utf8"); +assert.match(preload, /require\("electron"\)/); +assert.deepEqual( + [...preload.matchAll(/require\("([^"]+)"\)/g)].map((match) => match[1]), + ["electron"], + "Sandbox preload requires something other than Electron." +); + +const main = await fs.readFile(path.join(outputRoot, "main", "index.js"), "utf8"); +const runtime = await fs.readFile(path.join(outputRoot, "main", "runtime.js"), "utf8"); +const renderer = (await Promise.all( + files + .filter((file) => path.relative(outputRoot, file).replaceAll("\\", "/").startsWith("renderer/") + && /\.(?:js|html)$/.test(file)) + .map((file) => fs.readFile(file, "utf8")) +)).join("\n"); +assert.match(main, /electron-updater/, "Production Main is missing the controlled updater."); +assert.doesNotMatch(preload, /electron-updater|autoUpdater|quitAndInstall/); +assert.doesNotMatch(runtime, /electron-updater|autoUpdater|quitAndInstall/); +assert.doesNotMatch(renderer, /electron-updater|autoUpdater|quitAndInstall|setFeedURL/); + +process.stdout.write("Production Electron bundle boundary verified.\n"); diff --git a/apps/desktop/scripts/verify-test-fallback-bundle.mjs b/apps/desktop/scripts/verify-test-fallback-bundle.mjs new file mode 100644 index 0000000..1aecf18 --- /dev/null +++ b/apps/desktop/scripts/verify-test-fallback-bundle.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const mainRoot = path.join(desktopRoot, "out", "main"); + +async function filesUnder(root) { + const result = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile() && entry.name.endsWith(".js")) result.push(absolute); + } + } + await visit(root); + return result; +} + +const text = (await Promise.all((await filesUnder(mainRoot)).map((file) => fs.readFile(file, "utf8")))).join("\n"); +assert.match(text, /import\(["']better-sqlite3["']\)/, "Desktop test output is missing the native SQLite fallback."); +assert.doesNotMatch(text, /import\(["']node:sqlite["']\)/, "Desktop test output did not compile out node:sqlite."); +assert.doesNotMatch( + text, + /__CPS_DESKTOP_FORCE_BETTER_SQLITE3__/, + "Desktop test output retained the compile-time SQLite driver selector." +); + +process.stdout.write("Desktop test bundle is pinned to the native SQLite fallback.\n"); diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts new file mode 100644 index 0000000..5e73881 --- /dev/null +++ b/apps/desktop/src/index.ts @@ -0,0 +1,17 @@ +import type { CoreClient, CoreTransport } from "@codex-provider-sync/core-client"; +import type { CoreProtocolVersion } from "@codex-provider-sync/contracts"; + +export const DESKTOP_RUNTIME_STATE = "restore-watch-c8" as const; + +export interface DesktopHandshakeContract { + appVersion: string; + coreVersion: string; + protocolVersion: CoreProtocolVersion; +} + +export interface DesktopHostBoundary { + createClient(transport: CoreTransport): CoreClient; +} + +// Electron process internals remain private to this workspace. Consumers only +// receive the stable state marker and handshake boundary types above. diff --git a/apps/desktop/src/main/diagnostics-export.ts b/apps/desktop/src/main/diagnostics-export.ts new file mode 100644 index 0000000..939bf63 --- /dev/null +++ b/apps/desktop/src/main/diagnostics-export.ts @@ -0,0 +1,275 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + assertCoreMethodOutput, + type DiagnosticsSnapshot +} from "@codex-provider-sync/contracts"; + +import { + DESKTOP_BUILD_ID, + DESKTOP_CORE_VERSION +} from "../shared/constants.js"; +import type { DesktopDiagnosticsExportResult } from "../shared/diagnostics-types.js"; + +interface TargetCapability { + path: string; + expiresAt: number; + reservationKey: string; +} + +interface ZipEntry { + name: string; + data: Buffer; +} + +export interface DesktopDiagnosticsExporterOptions { + appVersion: string; + isPackaged: boolean; + now?: () => Date; +} + +const CAPABILITY_TTL_MS = 5 * 60_000; +const MAX_TARGET_CAPABILITIES = 32; + +function crc32(data: Buffer): number { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createStoredZip(entries: readonly ZipEntry[]): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + for (const entry of entries) { + const name = Buffer.from(entry.name, "utf8"); + const checksum = crc32(entry.data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(0, 8); + local.writeUInt32LE(0x00210000, 10); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(entry.data.length, 18); + local.writeUInt32LE(entry.data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, name, entry.data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(0, 10); + central.writeUInt32LE(0x00210000, 12); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(entry.data.length, 20); + central.writeUInt32LE(entry.data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, name); + offset += local.length + name.length + entry.data.length; + } + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + return Buffer.concat([...localParts, ...centralParts, end]); +} + +function jsonEntry(name: string, value: unknown): ZipEntry { + return { name, data: Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8") }; +} + +function normalizeTarget(value: string): string { + if (typeof value !== "string" || value.includes("\0") || !path.isAbsolute(value)) { + throw new TypeError("Diagnostics target must be an absolute path selected by Main."); + } + const target = path.resolve(value); + if (path.extname(target).toLowerCase() !== ".zip") { + throw new TypeError("Diagnostics target must use the .zip extension."); + } + return target; +} + +function targetReservationKey(target: string): string { + return process.platform === "win32" ? target.toLowerCase() : target; +} + +export class DesktopDiagnosticsExporter { + readonly #appVersion: string; + readonly #isPackaged: boolean; + readonly #now: () => Date; + readonly #targets = new Map(); + readonly #reservedTargets = new Map(); + readonly #activeDestinations = new Set(); + + constructor(options: DesktopDiagnosticsExporterOptions) { + this.#appVersion = options.appVersion; + this.#isPackaged = options.isPackaged; + this.#now = options.now ?? (() => new Date()); + } + + authorizeTarget(targetPath: string): string { + const now = this.#now().getTime(); + this.#sweepExpired(now); + if (this.#targets.size >= MAX_TARGET_CAPABILITIES) { + throw new Error("Too many pending diagnostics target capabilities."); + } + const target = normalizeTarget(targetPath); + const reservationKey = targetReservationKey(target); + if (this.#reservedTargets.has(reservationKey)) { + throw new Error("The diagnostics target is already reserved."); + } + const token = randomBytes(32).toString("base64url"); + this.#targets.set(token, { + path: target, + expiresAt: now + CAPABILITY_TTL_MS, + reservationKey + }); + this.#reservedTargets.set(reservationKey, token); + return token; + } + + revoke(token: string): void { + const capability = this.#targets.get(token); + this.#targets.delete(token); + if (capability) this.#releaseReservation(token, capability); + } + + async export(token: string, snapshot: DiagnosticsSnapshot): Promise { + this.#sweepExpired(this.#now().getTime()); + const capability = this.#targets.get(token); + this.#targets.delete(token); + if (!capability) { + return { schemaVersion: 1, status: "failed", reason: "write-failed" }; + } + try { + try { + assertCoreMethodOutput("getDiagnostics", snapshot); + } catch { + return { schemaVersion: 1, status: "failed", reason: "invalid-snapshot" }; + } + const createdAt = this.#now().toISOString(); + const entries: ZipEntry[] = [ + jsonEntry("app-info.json", { + schemaVersion: 1, + appVersion: this.#appVersion, + coreVersion: DESKTOP_CORE_VERSION, + buildId: DESKTOP_BUILD_ID, + packaged: this.#isPackaged, + platform: process.platform, + arch: process.arch, + generatedAt: createdAt + }), + jsonEntry("status-summary.json", { + schemaVersion: 1, + generatedAt: snapshot.generatedAt, + provider: snapshot.provider, + safety: { + pendingRecovery: snapshot.safety.pendingRecovery, + operationInProgress: snapshot.safety.operationInProgress, + rolloutScanComplete: snapshot.safety.rolloutScanComplete, + lockedRolloutCount: snapshot.safety.lockedRolloutCount, + projectThreadVisibilityAvailable: snapshot.safety.projectThreadVisibilityAvailable + } + }), + jsonEntry("storage-layout.json", { + schemaVersion: 1, + generatedAt: snapshot.generatedAt, + storage: snapshot.storage + }), + jsonEntry("pending-transaction-summary.json", { + schemaVersion: 1, + generatedAt: snapshot.generatedAt, + pendingTransactions: snapshot.safety.pendingTransactions + }), + { + name: "recent-redacted-logs/README.txt", + data: Buffer.from( + "No persistent application logs were included. Credentials, message bodies, rollout files, and databases are excluded.\n", + "utf8" + ) + } + ]; + const archive = createStoredZip(entries); + let temporary: string | null = null; + let activeDestinationKey: string | null = null; + let ownsActiveDestination = false; + try { + await fs.mkdir(path.dirname(capability.path), { recursive: true }); + const parent = await fs.realpath(path.dirname(capability.path)); + const destination = path.join(parent, path.basename(capability.path)); + activeDestinationKey = targetReservationKey(destination); + if (this.#activeDestinations.has(activeDestinationKey)) { + return { schemaVersion: 1, status: "failed", reason: "write-failed" }; + } + this.#activeDestinations.add(activeDestinationKey); + ownsActiveDestination = true; + temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`; + const handle = await fs.open(temporary, "wx", 0o600); + try { + await handle.writeFile(archive); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(temporary, destination); + await fs.chmod(destination, 0o600).catch(() => {}); + const directory = await fs.open(path.dirname(destination), "r").catch(() => null); + if (directory) { + try { await directory.sync(); } catch {} finally { await directory.close(); } + } + return { + schemaVersion: 1, + status: "created", + artifactId: randomUUID(), + createdAt + }; + } catch { + if (temporary) await fs.rm(temporary, { force: true }).catch(() => {}); + return { schemaVersion: 1, status: "failed", reason: "write-failed" }; + } finally { + if (activeDestinationKey && ownsActiveDestination) { + this.#activeDestinations.delete(activeDestinationKey); + } + } + } finally { + this.#releaseReservation(token, capability); + } + } + + #sweepExpired(now: number): void { + for (const [token, capability] of this.#targets) { + if (capability.expiresAt > now) continue; + this.#targets.delete(token); + this.#releaseReservation(token, capability); + } + } + + #releaseReservation(token: string, capability: TargetCapability): void { + if (this.#reservedTargets.get(capability.reservationKey) === token) { + this.#reservedTargets.delete(capability.reservationKey); + } + } +} diff --git a/apps/desktop/src/main/e2e-hooks.ts b/apps/desktop/src/main/e2e-hooks.ts new file mode 100644 index 0000000..81251a2 --- /dev/null +++ b/apps/desktop/src/main/e2e-hooks.ts @@ -0,0 +1,22 @@ +import type { BrowserWindow, IpcMain } from "electron"; + +import type { CoreRuntimeSupervisor } from "./runtime-supervisor.js"; +import { isTrustedSender } from "./ipc-router.js"; + +const TEST_CRASH_RUNTIME_CHANNEL = "cps:v1:test:crash-runtime"; + +export function registerDesktopTestHooks(options: { + ipcMain: IpcMain; + getWindow(): BrowserWindow | null; + rendererOrigin: string; + supervisor: CoreRuntimeSupervisor; +}): () => void { + options.ipcMain.handle(TEST_CRASH_RUNTIME_CHANNEL, (event, value) => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin) + || value !== null) { + throw new Error("Desktop test request rejected."); + } + return { crashed: options.supervisor.crashForTest() }; + }); + return () => options.ipcMain.removeHandler(TEST_CRASH_RUNTIME_CHANNEL); +} diff --git a/apps/desktop/src/main/electron-utility.ts b/apps/desktop/src/main/electron-utility.ts new file mode 100644 index 0000000..9fdfa6d --- /dev/null +++ b/apps/desktop/src/main/electron-utility.ts @@ -0,0 +1,58 @@ +import path from "node:path"; + +import { utilityProcess, type UtilityProcess } from "electron"; + +import type { ExpectedRuntimeIdentity, RuntimeFrame } from "../shared/runtime-protocol.js"; +import type { RuntimeUtilityHandle, RuntimeUtilitySpawner } from "./runtime-supervisor.js"; + +export interface ElectronUtilitySpawnerOptions { + runtimePath: string; + profileFile: string; + defaultCodexHome: string; + defaultSqliteHome?: string; +} + +function wrapUtility(child: UtilityProcess): RuntimeUtilityHandle { + return { + postMessage(frame: RuntimeFrame) { + child.postMessage(frame); + }, + kill() { + child.kill(); + }, + onMessage(listener) { + const wrapped = (message: unknown) => listener(message); + child.on("message", wrapped); + return () => child.removeListener("message", wrapped); + }, + onExit(listener) { + const wrapped = () => listener(); + child.on("exit", wrapped); + return () => child.removeListener("exit", wrapped); + } + }; +} + +export function createElectronUtilitySpawner( + options: ElectronUtilitySpawnerOptions +): RuntimeUtilitySpawner { + const runtimePath = path.resolve(options.runtimePath); + return (identity: ExpectedRuntimeIdentity) => { + const child = utilityProcess.fork(runtimePath, [], { + serviceName: "Codex Provider Sync Core", + stdio: "ignore", + env: { + ...process.env, + CPS_DESKTOP_APP_VERSION: identity.appVersion, + CPS_DESKTOP_RUNTIME_NONCE: identity.sessionNonce, + CPS_DESKTOP_RUNTIME_GENERATION: String(identity.generation), + CPS_DESKTOP_PROFILE_FILE: path.resolve(options.profileFile), + CPS_DESKTOP_DEFAULT_CODEX_HOME: path.resolve(options.defaultCodexHome), + ...(options.defaultSqliteHome + ? { CPS_DESKTOP_DEFAULT_SQLITE_HOME: path.resolve(options.defaultSqliteHome) } + : {}) + } + }); + return wrapUtility(child); + }; +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 0000000..0207eed --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,228 @@ +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + app, + BrowserWindow, + dialog, + ipcMain, + nativeTheme, + protocol, + screen, + session +} from "electron"; + +import { + DESKTOP_APP_ORIGIN +} from "../shared/constants.js"; +import { createElectronUtilitySpawner } from "./electron-utility.js"; +import { DesktopDiagnosticsExporter } from "./diagnostics-export.js"; +import { registerDesktopIpc, type DesktopIpcRegistration } from "./ipc-router.js"; +import { DesktopProfileRepository } from "../profiles/repository.js"; +import { CoreRuntimeSupervisor } from "./runtime-supervisor.js"; +import { createSecureWebPreferences } from "./security-policy.js"; +import { DesktopUpdateController } from "./updater.js"; +import { + registerDesktopProtocol, + registerDesktopScheme, + installDesktopSecurity +} from "./security.js"; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const preloadPath = path.resolve(currentDirectory, "../preload/index.cjs"); +const runtimePath = path.resolve(currentDirectory, "runtime.js"); +const rendererRoot = path.resolve(currentDirectory, "../renderer"); +const e2eEnabled = __CPS_DESKTOP_TEST_BUILD__ && process.env.CPS_DESKTOP_E2E === "1"; + +if (e2eEnabled && process.env.CPS_DESKTOP_USER_DATA) { + app.setPath("userData", path.resolve(process.env.CPS_DESKTOP_USER_DATA)); +} + +registerDesktopScheme(protocol); + +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + let mainWindow: BrowserWindow | null = null; + let supervisor: CoreRuntimeSupervisor | null = null; + let removeIpc: (() => void) | null = null; + let ipcRegistration: DesktopIpcRegistration | null = null; + let removeTestIpc: (() => void) | null = null; + let removeSecurity: (() => void) | null = null; + let updates: DesktopUpdateController | null = null; + let activeWatchCount = 0; + let quitting = false; + if (process.platform !== "win32") { + const requestGracefulQuit = () => app.quit(); + process.once("SIGINT", requestGracefulQuit); + process.once("SIGTERM", requestGracefulQuit); + } + + const defaultCodexHome = path.resolve( + (e2eEnabled ? process.env.CPS_DESKTOP_CODEX_HOME : undefined) + ?? process.env.CODEX_HOME + ?? path.join(os.homedir(), ".codex") + ); + const defaultSqliteHome = e2eEnabled && process.env.CPS_DESKTOP_SQLITE_HOME + ? path.resolve(process.env.CPS_DESKTOP_SQLITE_HOME) + : undefined; + const profileFile = path.join(app.getPath("userData"), "profiles.v1.json"); + const profiles = new DesktopProfileRepository({ + filePath: profileFile, + defaultCodexHome, + ...(defaultSqliteHome ? { defaultSqliteHome } : {}) + }); + + const createWindow = async (): Promise => { + const windowDisplay = process.env.CPS_DESKTOP_WINDOW_DISPLAY; + const preferredDisplay = windowDisplay === "secondary" + ? screen.getAllDisplays().find((display) => display.id !== screen.getPrimaryDisplay().id) + : undefined; + const workArea = preferredDisplay?.workArea; + const width = workArea ? Math.min(1280, workArea.width) : 1280; + const height = workArea ? Math.min(840, workArea.height) : 840; + const window = new BrowserWindow({ + width, + height, + ...(workArea ? { + x: workArea.x + Math.max(0, Math.floor((workArea.width - width) / 2)), + y: workArea.y + Math.max(0, Math.floor((workArea.height - height) / 2)) + } : {}), + minWidth: 760, + minHeight: 560, + show: false, + title: "Codex Provider Sync", + backgroundColor: nativeTheme.shouldUseDarkColors ? "#11141b" : "#f6f7fb", + webPreferences: createSecureWebPreferences(preloadPath) + }); + if (windowDisplay !== "hidden") window.once("ready-to-show", () => window.show()); + window.on("closed", () => { + if (mainWindow === window) mainWindow = null; + }); + await window.loadURL(`${DESKTOP_APP_ORIGIN}/index.html`); + return window; + }; + + void app.whenReady().then(async () => { + await profiles.initialize(); + await registerDesktopProtocol(protocol, rendererRoot); + removeSecurity = installDesktopSecurity(app, session.defaultSession); + supervisor = new CoreRuntimeSupervisor({ + appVersion: app.getVersion(), + spawnUtility: createElectronUtilitySpawner({ + runtimePath, + profileFile, + defaultCodexHome, + ...(defaultSqliteHome ? { defaultSqliteHome } : {}) + }) + }); + const diagnosticsExporter = new DesktopDiagnosticsExporter({ + appVersion: app.getVersion(), + isPackaged: app.isPackaged + }); + updates = new DesktopUpdateController({ + isPackaged: app.isPackaged, + platform: process.platform, + arch: process.arch, + appVersion: app.getVersion(), + configured: app.getVersion() !== "0.0.0", + releaseAuthorized: __CPS_DESKTOP_RELEASE_AUTHORIZED__, + supervisor, + hasActiveWatches: () => activeWatchCount > 0, + verifyNoActiveWatches: async () => ( + (await ipcRegistration?.verifyNoActiveWatchesForRestart()) === "clear" + ), + verifyRecoveryState: () => supervisor!.verifyProfilesSafeForRestart( + profiles.list().map((profile) => ({ + profileId: profile.id, + profileRevision: profile.revision + })) + ) + }); + ipcRegistration = registerDesktopIpc({ + ipcMain, + getWindow: () => mainWindow, + rendererOrigin: DESKTOP_APP_ORIGIN, + profiles, + supervisor, + updates, + onActiveWatchCountChanged(count) { + activeWatchCount = count; + }, + diagnosticsExporter, + async selectDiagnosticsTarget() { + if (e2eEnabled && process.env.CPS_DESKTOP_DIAGNOSTICS_TARGET) { + return path.resolve(process.env.CPS_DESKTOP_DIAGNOSTICS_TARGET); + } + const options = { + title: "Export redacted diagnostics", + defaultPath: path.join( + app.getPath("downloads"), + `codex-provider-diagnostics-${new Date().toISOString().slice(0, 10)}.zip` + ), + filters: [{ name: "ZIP archive", extensions: ["zip"] }], + properties: ["showOverwriteConfirmation" as const] + }; + const result = mainWindow + ? await dialog.showSaveDialog(mainWindow, options) + : await dialog.showSaveDialog(options); + return result.canceled || !result.filePath ? null : result.filePath; + } + }); + removeIpc = ipcRegistration; + if (e2eEnabled) { + const { registerDesktopTestHooks } = await import("./e2e-hooks.js"); + removeTestIpc = registerDesktopTestHooks({ + ipcMain, + getWindow: () => mainWindow, + rendererOrigin: DESKTOP_APP_ORIGIN, + supervisor + }); + } + mainWindow = await createWindow(); + updates.scheduleInitialCheck(); + + if (e2eEnabled) { + Object.defineProperty(globalThis, "__CPS_DESKTOP_TEST__", { + configurable: true, + value: Object.freeze({ + runtime: () => supervisor?.snapshot ?? null, + window: () => mainWindow + }) + }); + } + }).catch(() => { + app.exit(1); + }); + + app.on("second-instance", () => { + if (!mainWindow) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + }); + + app.on("activate", () => { + if (!mainWindow && app.isReady()) void createWindow().then((window) => { + mainWindow = window; + }); + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin" || e2eEnabled) app.quit(); + }); + + app.on("before-quit", (event) => { + if (quitting) return; + event.preventDefault(); + quitting = true; + void (async () => { + updates?.dispose(); + await supervisor?.shutdown(); + removeTestIpc?.(); + removeIpc?.(); + removeSecurity?.(); + app.quit(); + })(); + }); +} diff --git a/apps/desktop/src/main/ipc-router.ts b/apps/desktop/src/main/ipc-router.ts new file mode 100644 index 0000000..fcbd576 --- /dev/null +++ b/apps/desktop/src/main/ipc-router.ts @@ -0,0 +1,697 @@ +import type { + BrowserWindow, + IpcMain, + IpcMainInvokeEvent +} from "electron"; +import { randomUUID } from "node:crypto"; + +import { + CORE_PROTOCOL_VERSION, + ContractValidationError, + assertCoreRequestEnvelope, + createCoreRequestEnvelope, + createPublicCoreErrorDto, + type CoreErrorCode, + type CoreRequestEnvelope, + type CoreResponseEnvelope, + type PlanSummary, + type ProfileSelector, + type WatchSnapshot, + type WatchStatusList +} from "@codex-provider-sync/contracts"; +import { + isDesktopMaintenanceMethod, + isDesktopReadMethod, + isDesktopRestoreMethod, + isDesktopSyncSwitchMethod, + type DesktopMaintenanceMethod, + type DesktopReadMethod, + type DesktopRestoreMethod, + type DesktopSyncSwitchMethod +} from "@codex-provider-sync/core-client"; + +import { + DESKTOP_IPC_CHANNELS, + MAX_DESKTOP_IPC_BYTES +} from "../shared/constants.js"; +import type { DesktopProfileListResponse } from "../shared/profile-types.js"; +import type { DesktopProfileRepository } from "../profiles/repository.js"; +import type { DesktopDiagnosticsExporter } from "./diagnostics-export.js"; +import type { CoreRuntimeSupervisor } from "./runtime-supervisor.js"; +import type { DesktopUpdateController } from "./updater.js"; +import type { + DesktopDiagnosticsExportInput, + DesktopDiagnosticsExportResult +} from "../shared/diagnostics-types.js"; +import type { DesktopUpdateStatus } from "../shared/update-types.js"; + +export interface DesktopIpcRouterOptions { + ipcMain: IpcMain; + getWindow(): BrowserWindow | null; + rendererOrigin: string; + profiles: DesktopProfileRepository; + supervisor: CoreRuntimeSupervisor; + diagnosticsExporter: DesktopDiagnosticsExporter; + selectDiagnosticsTarget(): Promise; + updates: Pick< + DesktopUpdateController, + "status" | "restartPending" | "check" | "download" | "install" + >; + onActiveWatchCountChanged?(count: number): void; +} + +export type DesktopWatchRestartVerification = "clear" | "active" | "unverifiable"; + +export interface DesktopIpcRegistration { + (): void; + verifyNoActiveWatchesForRestart(): Promise; +} + +interface PlanOwnership { + senderId: number; + applyMethod: "applySync" | "applySwitch" | "applyRestore"; + profile: ProfileSelector; + generation: number; + expiresAt: number; + state: "prepared" | "applying"; +} + +interface WatchOwnership { + senderId: number; + profile: ProfileSelector; + generation: number; +} + +interface ActiveRequestOwnership { + senderId: number; + planId: string; +} + +const MAX_DESKTOP_OWNED_PLANS = 256; + +function isTrustedSender( + event: IpcMainInvokeEvent, + window: BrowserWindow | null, + rendererOrigin: string +): boolean { + if (!window || window.isDestroyed() || event.sender !== window.webContents) return false; + const frame = event.senderFrame; + if (!frame || frame !== event.sender.mainFrame) return false; + try { + const actual = new URL(frame.url); + const expected = new URL(rendererOrigin); + return actual.protocol === expected.protocol + && actual.hostname === expected.hostname + && actual.port === expected.port + && actual.username === "" + && actual.password === ""; + } catch { + return false; + } +} + +function correlation(value: unknown): { requestId: string; operationId?: string } { + const source = value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + return { + requestId: typeof source.requestId === "string" && source.requestId.length > 0 + ? source.requestId + : "invalid-request", + ...(typeof source.operationId === "string" && source.operationId.length > 0 + ? { operationId: source.operationId } + : {}) + }; +} + +function failureEnvelope( + value: unknown, + code: CoreErrorCode, + details?: unknown +): CoreResponseEnvelope { + const ids = correlation(value); + return { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId: ids.requestId, + ...(ids.operationId ? { operationId: ids.operationId } : {}), + ok: false, + error: createPublicCoreErrorDto(code, { details }) + }; +} + +function encodedSize(value: unknown): number { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +function requestProfile( + request: CoreRequestEnvelope +): ProfileSelector | null { + if (request.method === "applySync" + || request.method === "applySwitch" + || request.method === "applyRestore") return null; + const payload = request.payload as { profile?: ProfileSelector }; + return payload.profile ?? null; +} + +function validPlanResult( + request: CoreRequestEnvelope, + value: unknown +): value is PlanSummary { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const result = value as PlanSummary; + const expectedOperation = request.method === "prepareSync" + ? "sync" + : request.method === "prepareSwitch" + ? "switch" + : "restore"; + const profile = requestProfile(request); + const expiresAt = Date.parse(result.expiresAt); + return result.schemaVersion === 1 + && result.operation === expectedOperation + && typeof result.planId === "string" + && /^[A-Za-z0-9_-]{32,128}$/.test(result.planId) + && Number.isFinite(expiresAt) + && expiresAt > Date.now() + && expiresAt <= Date.now() + 10 * 60_000 + 5_000 + && Boolean(profile) + && result.profile.id === profile?.profileId + && (profile?.profileRevision === undefined || result.profile.revision === profile.profileRevision); +} + +function diagnosticsExportInput(value: unknown): DesktopDiagnosticsExportInput | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + const input = value as Record; + if (Object.keys(input).sort().join(",") !== "profile,schemaVersion" + || input.schemaVersion !== 1 + || input.profile === null + || typeof input.profile !== "object" + || Array.isArray(input.profile)) return null; + const profile = input.profile as Record; + const allowed = profile.profileRevision === undefined + ? ["profileId"] + : ["profileId", "profileRevision"]; + if (Object.keys(profile).sort().join(",") !== allowed.sort().join(",") + || typeof profile.profileId !== "string" + || !/^[A-Za-z0-9._-]{1,80}$/.test(profile.profileId) + || (profile.profileRevision !== undefined + && (typeof profile.profileRevision !== "string" + || profile.profileRevision.length === 0 + || profile.profileRevision.length > 512))) return null; + return structuredClone(value) as DesktopDiagnosticsExportInput; +} + +function validCancelInput(value: unknown): value is { requestId: string; operationId?: string } { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + const allowed = source.operationId === undefined + ? ["requestId"] + : ["requestId", "operationId"]; + return Object.keys(source).sort().join(",") === allowed.sort().join(",") + && typeof source.requestId === "string" + && source.requestId.length > 0 + && source.requestId.length <= 512 + && (source.operationId === undefined + || (typeof source.operationId === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(source.operationId))); +} + +export function registerDesktopIpc(options: DesktopIpcRouterOptions): DesktopIpcRegistration { + const registered: string[] = []; + const plans = new Map(); + const watches = new Map(); + const activeRequests = new Map(); + const inFlightRequestIds = new Set(); + const notifyWatchCount = (): void => options.onActiveWatchCountChanged?.(watches.size); + const sameProfile = (left: ProfileSelector, right: ProfileSelector): boolean => ( + left.profileId === right.profileId + && (left.profileRevision ?? null) === (right.profileRevision ?? null) + ); + const reconcileWatchStatus = ( + senderId: number, + profile: ProfileSelector, + result: WatchSnapshot | WatchStatusList + ): void => { + let changed = false; + if ("watches" in result) { + const liveIds = new Set( + result.watches + .filter((watch) => watch.status !== "stopped") + .map((watch) => watch.watchId) + ); + for (const [ownedWatchId, owner] of watches) { + if (owner.senderId === senderId + && sameProfile(owner.profile, profile) + && !liveIds.has(ownedWatchId)) { + watches.delete(ownedWatchId); + changed = true; + } + } + } else if (result.status === "stopped" && watches.delete(result.watchId)) { + changed = true; + } + if (changed) notifyWatchCount(); + }; + const removeStaleWatchOwnership = (): boolean => { + const generation = options.supervisor.snapshot.generation; + let changed = false; + for (const [watchId, owner] of watches) { + if (owner.generation !== generation) { + watches.delete(watchId); + changed = true; + } + } + if (changed) notifyWatchCount(); + return changed; + }; + const verifyNoActiveWatchesForRestart = async (): Promise => { + removeStaleWatchOwnership(); + const first = watches.values().next().value as WatchOwnership | undefined; + if (!first) return "clear"; + const generation = options.supervisor.snapshot.generation; + const request = createCoreRequestEnvelope( + "getWatchStatus", + {}, + `desktop-update-watch-${randomUUID()}` + ); + try { + const response = await options.supervisor.requestManaged(request, first.profile, { + allowRecoveryBlocked: true + }); + if (!response.ok || options.supervisor.snapshot.generation !== generation) { + return "unverifiable"; + } + const result = response.result as WatchSnapshot | WatchStatusList; + if (!("watches" in result)) return "unverifiable"; + if (result.watches.some((watch) => watch.status !== "stopped")) return "active"; + if (watches.size > 0) { + watches.clear(); + notifyWatchCount(); + } + return "clear"; + } catch { + return "unverifiable"; + } + }; + const updateBusy = (value: unknown): CoreResponseEnvelope => failureEnvelope( + value, + "OPERATION_BUSY", + { busyScope: "codex-home" } + ); + const pruneExpiredPlans = (): void => { + const now = Date.now(); + const generation = options.supervisor.snapshot.generation; + for (const [planId, owner] of plans) { + if (owner.state === "prepared" + && (owner.expiresAt <= now || owner.generation !== generation)) { + plans.delete(planId); + } + } + }; + const makeRoomForPreparedPlan = (): boolean => { + while (plans.size >= MAX_DESKTOP_OWNED_PLANS) { + const victim = [...plans].find(([, owner]) => owner.state === "prepared"); + if (!victim) return false; + plans.delete(victim[0]); + } + return true; + }; + const register = ( + channel: string, + handler: (event: IpcMainInvokeEvent, value: unknown) => unknown | Promise + ) => { + options.ipcMain.handle(channel, handler); + registered.push(channel); + }; + + const unsubscribeOperations = options.supervisor.subscribeOperation((event) => { + const owner = activeRequests.get(event.requestId); + const window = options.getWindow(); + if (!owner || !window || window.isDestroyed() || window.webContents.id !== owner.senderId) return; + window.webContents.send(DESKTOP_IPC_CHANNELS.operationEvent, structuredClone(event)); + }); + + register(DESKTOP_IPC_CHANNELS.coreRead, async (event, value) => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin)) { + return failureEnvelope(value, "PERMISSION_DENIED"); + } + if (encodedSize(value) > MAX_DESKTOP_IPC_BYTES) { + return failureEnvelope(value, "INVALID_INPUT"); + } + let request: CoreRequestEnvelope; + try { + assertCoreRequestEnvelope(value); + request = value; + } catch (error) { + return failureEnvelope( + value, + error instanceof ContractValidationError && error.code === "PROTOCOL_VERSION_MISMATCH" + ? "PROTOCOL_VERSION_MISMATCH" + : "INVALID_INPUT" + ); + } + if (!isDesktopReadMethod(request.method)) return failureEnvelope(request, "PERMISSION_DENIED"); + if (inFlightRequestIds.has(request.requestId)) return failureEnvelope(request, "INVALID_INPUT"); + inFlightRequestIds.add(request.requestId); + try { + return await options.supervisor.request(request as CoreRequestEnvelope); + } finally { + inFlightRequestIds.delete(request.requestId); + } + }); + + register(DESKTOP_IPC_CHANNELS.coreSyncSwitch, async (event, value) => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin)) { + return failureEnvelope(value, "PERMISSION_DENIED"); + } + if (encodedSize(value) > MAX_DESKTOP_IPC_BYTES) return failureEnvelope(value, "INVALID_INPUT"); + if (options.updates.restartPending) return updateBusy(value); + let request: CoreRequestEnvelope; + try { + assertCoreRequestEnvelope(value); + request = value; + } catch (error) { + return failureEnvelope( + value, + error instanceof ContractValidationError && error.code === "PROTOCOL_VERSION_MISMATCH" + ? "PROTOCOL_VERSION_MISMATCH" + : "INVALID_INPUT" + ); + } + if (!isDesktopSyncSwitchMethod(request.method) || request.operationId !== undefined) { + return failureEnvelope(request, "PERMISSION_DENIED"); + } + const typed = request as CoreRequestEnvelope; + if (inFlightRequestIds.has(request.requestId)) return failureEnvelope(request, "INVALID_INPUT"); + inFlightRequestIds.add(request.requestId); + try { + pruneExpiredPlans(); + if (typed.method === "prepareSync" || typed.method === "prepareSwitch") { + const profile = requestProfile(typed); + if (!profile) return failureEnvelope(request, "INVALID_INPUT"); + const response = await options.supervisor.requestWrite(typed, profile); + if (!response.ok) return response; + if (!validPlanResult(typed, response.result) + || options.supervisor.snapshot.state !== "ready" + || plans.has(response.result.planId) + || !makeRoomForPreparedPlan()) { + return failureEnvelope(request, "INTERNAL_ERROR"); + } + plans.set(response.result.planId, { + senderId: event.sender.id, + applyMethod: typed.method === "prepareSync" ? "applySync" : "applySwitch", + profile: { + profileId: response.result.profile.id, + profileRevision: response.result.profile.revision + }, + generation: options.supervisor.snapshot.generation, + expiresAt: Date.parse(response.result.expiresAt), + state: "prepared" + }); + return response; + } + + const planId = (typed.payload as { planId: string }).planId; + const owner = plans.get(planId); + if (!owner + || owner.senderId !== event.sender.id + || owner.applyMethod !== typed.method + || owner.state !== "prepared" + || owner.expiresAt <= Date.now() + || owner.generation !== options.supervisor.snapshot.generation + || options.supervisor.snapshot.state !== "ready") { + plans.delete(planId); + return failureEnvelope(request, "PLAN_EXPIRED"); + } + owner.state = "applying"; + activeRequests.set(request.requestId, { senderId: event.sender.id, planId }); + try { + return await options.supervisor.requestWrite(typed, owner.profile); + } finally { + activeRequests.delete(request.requestId); + plans.delete(planId); + } + } finally { + inFlightRequestIds.delete(request.requestId); + } + }); + + register(DESKTOP_IPC_CHANNELS.coreRestore, async (event, value) => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin)) { + return failureEnvelope(value, "PERMISSION_DENIED"); + } + if (encodedSize(value) > MAX_DESKTOP_IPC_BYTES) return failureEnvelope(value, "INVALID_INPUT"); + if (options.updates.restartPending) return updateBusy(value); + let request: CoreRequestEnvelope; + try { + assertCoreRequestEnvelope(value); + request = value; + } catch (error) { + return failureEnvelope( + value, + error instanceof ContractValidationError && error.code === "PROTOCOL_VERSION_MISMATCH" + ? "PROTOCOL_VERSION_MISMATCH" + : "INVALID_INPUT" + ); + } + if (!isDesktopRestoreMethod(request.method) || request.operationId !== undefined) { + return failureEnvelope(request, "PERMISSION_DENIED"); + } + const typed = request as CoreRequestEnvelope; + if (inFlightRequestIds.has(request.requestId)) return failureEnvelope(request, "INVALID_INPUT"); + inFlightRequestIds.add(request.requestId); + try { + pruneExpiredPlans(); + if (typed.method === "prepareRestore") { + const profile = requestProfile(typed); + if (!profile) return failureEnvelope(request, "INVALID_INPUT"); + const response = await options.supervisor.requestManaged(typed, profile, { + allowRecoveryBlocked: true + }); + if (!response.ok) return response; + if (!validPlanResult(typed, response.result) + || options.supervisor.snapshot.state !== "ready" + || plans.has(response.result.planId) + || !makeRoomForPreparedPlan()) { + return failureEnvelope(request, "INTERNAL_ERROR"); + } + plans.set(response.result.planId, { + senderId: event.sender.id, + applyMethod: "applyRestore", + profile: { + profileId: response.result.profile.id, + profileRevision: response.result.profile.revision + }, + generation: options.supervisor.snapshot.generation, + expiresAt: Date.parse(response.result.expiresAt), + state: "prepared" + }); + return response; + } + + const planId = (typed.payload as { planId: string }).planId; + const owner = plans.get(planId); + if (!owner + || owner.senderId !== event.sender.id + || owner.applyMethod !== "applyRestore" + || owner.state !== "prepared" + || owner.expiresAt <= Date.now() + || owner.generation !== options.supervisor.snapshot.generation + || options.supervisor.snapshot.state !== "ready") { + plans.delete(planId); + return failureEnvelope(request, "PLAN_EXPIRED"); + } + owner.state = "applying"; + activeRequests.set(request.requestId, { senderId: event.sender.id, planId }); + try { + return await options.supervisor.requestManaged(typed, owner.profile, { + allowRecoveryBlocked: true + }); + } finally { + activeRequests.delete(request.requestId); + plans.delete(planId); + } + } finally { + inFlightRequestIds.delete(request.requestId); + } + }); + + register(DESKTOP_IPC_CHANNELS.coreMaintenance, async (event, value) => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin)) { + return failureEnvelope(value, "PERMISSION_DENIED"); + } + if (encodedSize(value) > MAX_DESKTOP_IPC_BYTES) return failureEnvelope(value, "INVALID_INPUT"); + let request: CoreRequestEnvelope; + try { + assertCoreRequestEnvelope(value); + request = value; + } catch (error) { + return failureEnvelope( + value, + error instanceof ContractValidationError && error.code === "PROTOCOL_VERSION_MISMATCH" + ? "PROTOCOL_VERSION_MISMATCH" + : "INVALID_INPUT" + ); + } + if (!isDesktopMaintenanceMethod(request.method) || request.operationId !== undefined) { + return failureEnvelope(request, "PERMISSION_DENIED"); + } + const typed = request as CoreRequestEnvelope; + if (inFlightRequestIds.has(request.requestId)) return failureEnvelope(request, "INVALID_INPUT"); + inFlightRequestIds.add(request.requestId); + try { + removeStaleWatchOwnership(); + if (typed.method === "pruneBackups" || typed.method === "startWatch") { + if (options.updates.restartPending) return updateBusy(request); + const profile = (typed.payload as { profile?: ProfileSelector }).profile; + if (!profile) return failureEnvelope(request, "INVALID_INPUT"); + const response = await options.supervisor.requestManaged(typed, profile, { + allowRecoveryBlocked: typed.method === "pruneBackups" + }); + if (response.ok && typed.method === "startWatch") { + const watch = response.result as WatchSnapshot; + watches.set(watch.watchId, { + senderId: event.sender.id, + profile, + generation: options.supervisor.snapshot.generation + }); + notifyWatchCount(); + } + return response; + } + const watchId = (typed.payload as { watchId?: string }).watchId; + const owner = watchId ? watches.get(watchId) : undefined; + if (watchId && (!owner || owner.senderId !== event.sender.id)) { + return failureEnvelope(request, "INVALID_INPUT"); + } + const fallback = owner ?? [...watches.values()].find( + (candidate) => candidate.senderId === event.sender.id + ); + const defaultProfile = options.profiles.list()[0]; + const profile = fallback?.profile ?? (defaultProfile ? { + profileId: defaultProfile.id, + profileRevision: defaultProfile.revision + } : null); + if (!profile) return failureEnvelope(request, "INTERNAL_ERROR"); + const response = await options.supervisor.requestManaged(typed, profile, { + allowRecoveryBlocked: true + }); + if (response.ok && typed.method === "getWatchStatus") { + reconcileWatchStatus( + event.sender.id, + profile, + response.result as WatchSnapshot | WatchStatusList + ); + } + if (response.ok && typed.method === "stopWatch") { + if (watchId) { + watches.delete(watchId); + } else { + for (const [ownedWatchId, candidate] of watches) { + if (candidate.senderId === event.sender.id) watches.delete(ownedWatchId); + } + } + notifyWatchCount(); + } + return response; + } finally { + inFlightRequestIds.delete(request.requestId); + } + }); + + register(DESKTOP_IPC_CHANNELS.operationCancel, (event, value) => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin) + || encodedSize(value) > MAX_DESKTOP_IPC_BYTES + || !validCancelInput(value)) { + return { accepted: false }; + } + const owner = activeRequests.get(value.requestId); + if (!owner || owner.senderId !== event.sender.id) return { accepted: false }; + return { accepted: options.supervisor.cancel(value.requestId, value.operationId) }; + }); + + register(DESKTOP_IPC_CHANNELS.profilesList, (event, value) => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin) || value !== null) { + throw new Error("Desktop profile request rejected."); + } + const response: DesktopProfileListResponse = { + schemaVersion: 1, + profiles: options.profiles.list() + }; + return response; + }); + + register(DESKTOP_IPC_CHANNELS.diagnosticsExport, async (event, value): Promise => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin) + || encodedSize(value) > MAX_DESKTOP_IPC_BYTES) { + return { schemaVersion: 1, status: "failed", reason: "runtime-unavailable" }; + } + const input = diagnosticsExportInput(value); + if (!input) return { schemaVersion: 1, status: "failed", reason: "runtime-unavailable" }; + let target: string | null; + let token: string; + try { + target = await options.selectDiagnosticsTarget(); + if (!target) return { schemaVersion: 1, status: "cancelled" }; + token = options.diagnosticsExporter.authorizeTarget(target); + } catch { + return { schemaVersion: 1, status: "failed", reason: "write-failed" }; + } + const request = createCoreRequestEnvelope( + "getDiagnostics", + { profile: input.profile }, + `desktop-diagnostics-${randomUUID()}` + ); + const response = await options.supervisor.request(request); + if (!response.ok) { + options.diagnosticsExporter.revoke(token); + return { schemaVersion: 1, status: "failed", reason: "runtime-unavailable" }; + } + return options.diagnosticsExporter.export(token, response.result); + }); + + register(DESKTOP_IPC_CHANNELS.updateStatus, (event, value): DesktopUpdateStatus => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin) || value !== null) { + return { + schemaVersion: 2, + state: "disabled", + reason: "not-configured", + installAllowed: false + }; + } + return options.updates.status; + }); + + const updateAction = ( + action: "check" | "download" | "install" + ) => async (event: IpcMainInvokeEvent, value: unknown): Promise => { + if (!isTrustedSender(event, options.getWindow(), options.rendererOrigin) || value !== null) { + return { + schemaVersion: 2, + state: "disabled", + reason: "not-configured", + installAllowed: false + }; + } + return options.updates[action](); + }; + register(DESKTOP_IPC_CHANNELS.updateCheck, updateAction("check")); + register(DESKTOP_IPC_CHANNELS.updateDownload, updateAction("download")); + register(DESKTOP_IPC_CHANNELS.updateInstall, updateAction("install")); + + const cleanup = (() => { + unsubscribeOperations(); + plans.clear(); + watches.clear(); + notifyWatchCount(); + activeRequests.clear(); + for (const channel of registered) options.ipcMain.removeHandler(channel); + }) as DesktopIpcRegistration; + cleanup.verifyNoActiveWatchesForRestart = verifyNoActiveWatchesForRestart; + return cleanup; +} + +export { isTrustedSender }; diff --git a/apps/desktop/src/main/runtime-supervisor.ts b/apps/desktop/src/main/runtime-supervisor.ts new file mode 100644 index 0000000..5cc6421 --- /dev/null +++ b/apps/desktop/src/main/runtime-supervisor.ts @@ -0,0 +1,668 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { + createCoreFailureEnvelope, + createCoreRequestEnvelope, + createPublicCoreErrorDto, + type CoreErrorCode, + type CoreOperationEventEnvelope, + type CoreRequestEnvelope, + type CoreResponseEnvelope, + type ProfileSelector, + type StatusSnapshot +} from "@codex-provider-sync/contracts"; +import { + isDesktopManagedMethod, + isDesktopReadMethod, + isDesktopSyncSwitchMethod, + type DesktopManagedMethod, + type DesktopReadMethod, + type DesktopRuntimeMethod, + type DesktopSyncSwitchMethod +} from "@codex-provider-sync/core-client"; + +import { + DESKTOP_BUILD_ID, + DESKTOP_CORE_VERSION, + DESKTOP_RUNTIME_PROTOCOL_VERSION +} from "../shared/constants.js"; +import { + assertRuntimeHelloFrame, + assertRuntimeOperationEventFrame, + assertRuntimeResponseFrame, + createRuntimeCancelFrame, + createRuntimeRequestFrame, + type ExpectedRuntimeIdentity, + type RuntimeFrame, + type RuntimeHelloFrame +} from "../shared/runtime-protocol.js"; + +export type RuntimeSupervisorState = "stopped" | "starting" | "ready" | "crashed" | "shutting-down"; + +export interface RuntimeUtilityHandle { + postMessage(frame: RuntimeFrame): void; + kill(): void; + onMessage(listener: (frame: unknown) => void): () => void; + onExit(listener: () => void): () => void; +} + +export type RuntimeUtilitySpawner = ( + identity: ExpectedRuntimeIdentity +) => RuntimeUtilityHandle; + +export interface RuntimeRestartInstallLease { + waitForWrites(): Promise; + release(): void; +} + +export interface CoreRuntimeSupervisorOptions { + appVersion: string; + spawnUtility: RuntimeUtilitySpawner; + handshakeTimeoutMs?: number; + requestTimeoutMs?: number; + writeRequestTimeoutMs?: number; +} + +interface PendingRuntimeRequest { + dispatchId: string; + request: CoreRequestEnvelope; + generation: number; + isWrite: boolean; + operationId?: string; + resolve(response: CoreResponseEnvelope): void; + timer: ReturnType; +} + +class RuntimeActivationError extends Error { + readonly code: CoreErrorCode; + + constructor(code: CoreErrorCode) { + super(code); + this.name = "RuntimeActivationError"; + this.code = code; + } +} + +function profileFromReadRequest(request: CoreRequestEnvelope): ProfileSelector { + const payload = request.payload as { profile?: ProfileSelector }; + if (!payload.profile) throw new RuntimeActivationError("INVALID_INPUT"); + return payload.profile; +} + +function profileKey(profile: ProfileSelector): string { + return JSON.stringify([profile.profileId, profile.profileRevision]); +} + +function isApplyMethod(method: DesktopRuntimeMethod): boolean { + return method === "applySync" || method === "applySwitch" || method === "applyRestore"; +} + +export class CoreRuntimeSupervisor { + readonly #appVersion: string; + readonly #spawnUtility: RuntimeUtilitySpawner; + readonly #handshakeTimeoutMs: number; + readonly #requestTimeoutMs: number; + readonly #writeRequestTimeoutMs: number; + readonly #pending = new Map(); + readonly #dispatchByRequestId = new Map(); + readonly #profilePreflights = new Map>(); + readonly #recoveryByProfile = new Map(); + readonly #operationListeners = new Set<(event: CoreOperationEventEnvelope) => void>(); + #state: RuntimeSupervisorState = "stopped"; + #generation = 0; + #child: RuntimeUtilityHandle | null = null; + #detachChild: (() => void) | null = null; + #activation: Promise | null = null; + #shutdownPromise: Promise | null = null; + #disposed = false; + #helloResolve: ((frame: RuntimeHelloFrame) => void) | null = null; + #helloReject: ((error: RuntimeActivationError) => void) | null = null; + #preflightReadsAfterCrash = false; + #lastHandshakeAt: string | null = null; + #restartInstallGateClosed = false; + #admittedWriteCount = 0; + readonly #writeDrainWaiters = new Set<() => void>(); + + constructor(options: CoreRuntimeSupervisorOptions) { + this.#appVersion = options.appVersion; + this.#spawnUtility = options.spawnUtility; + this.#handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; + this.#requestTimeoutMs = options.requestTimeoutMs ?? 30_000; + this.#writeRequestTimeoutMs = options.writeRequestTimeoutMs ?? 15 * 60_000; + } + + get snapshot(): Readonly<{ + state: RuntimeSupervisorState; + generation: number; + recoveryBlocked: boolean; + writeInProgress: boolean; + lastHandshakeAt: string | null; + }> { + return Object.freeze({ + state: this.#state, + generation: this.#generation, + recoveryBlocked: [...this.#recoveryByProfile.values()].some(Boolean), + writeInProgress: this.#admittedWriteCount > 0 + || [...this.#pending.values()].some((pending) => pending.isWrite), + lastHandshakeAt: this.#lastHandshakeAt + }); + } + + tryBeginRestartInstall(): RuntimeRestartInstallLease | null { + if (this.#disposed + || this.#state === "shutting-down" + || this.#shutdownPromise + || this.#restartInstallGateClosed) return null; + this.#restartInstallGateClosed = true; + let released = false; + return Object.freeze({ + waitForWrites: async () => { + if (released || this.#admittedWriteCount === 0) return; + await new Promise((resolve) => this.#writeDrainWaiters.add(resolve)); + }, + release: () => { + if (released) return; + released = true; + this.#restartInstallGateClosed = false; + } + }); + } + + subscribeOperation(listener: (event: CoreOperationEventEnvelope) => void): () => void { + this.#operationListeners.add(listener); + return () => this.#operationListeners.delete(listener); + } + + async verifyProfilesSafeForRestart( + profiles: readonly ProfileSelector[] + ): Promise<"clear" | "blocked" | "unverifiable"> { + if (profiles.length === 0 || this.snapshot.writeInProgress) return "unverifiable"; + try { + for (const profile of profiles) { + await this.#ensureReady(profile, false); + this.#invalidateProfilePreflight(profile); + await this.#ensureProfilePreflight(profile); + if (this.#recoveryByProfile.get(profileKey(profile)) === true) return "blocked"; + } + return this.snapshot.writeInProgress ? "unverifiable" : "clear"; + } catch { + return "unverifiable"; + } + } + + async request( + request: CoreRequestEnvelope + ): Promise> { + if (!isDesktopReadMethod(request.method)) { + return createCoreFailureEnvelope(request, createPublicCoreErrorDto("PERMISSION_DENIED")); + } + const profile = profileFromReadRequest(request as CoreRequestEnvelope); + try { + await this.#ensureReady(profile, false); + } catch (error) { + const code = error instanceof RuntimeActivationError ? error.code : "INTERNAL_ERROR"; + return createCoreFailureEnvelope(request, createPublicCoreErrorDto(code)); + } + return this.#dispatch(request, false) as Promise>; + } + + async requestWrite( + request: CoreRequestEnvelope, + profile: ProfileSelector + ): Promise> { + if (!isDesktopSyncSwitchMethod(request.method) || request.operationId !== undefined) { + return createCoreFailureEnvelope(request, createPublicCoreErrorDto("PERMISSION_DENIED")); + } + return this.requestManaged(request, profile); + } + + async requestManaged( + request: CoreRequestEnvelope, + profile: ProfileSelector, + options: { + allowRecoveryBlocked?: boolean; + } = {} + ): Promise> { + if (!isDesktopManagedMethod(request.method) || request.operationId !== undefined) { + return createCoreFailureEnvelope(request, createPublicCoreErrorDto("PERMISSION_DENIED")); + } + const isWrite = request.method !== "getWatchStatus"; + const releaseAdmission = isWrite ? this.#tryAdmitWrite() : null; + if (isWrite && !releaseAdmission) { + return createCoreFailureEnvelope(request, createPublicCoreErrorDto("OPERATION_BUSY", { + details: { busyScope: "codex-home" } + })); + } + try { + try { + await this.#ensureReady(profile, isWrite); + if (!options.allowRecoveryBlocked + && this.#recoveryByProfile.get(profileKey(profile)) === true) { + throw new RuntimeActivationError("PENDING_TRANSACTION"); + } + } catch (error) { + const code = error instanceof RuntimeActivationError ? error.code : "INTERNAL_ERROR"; + return createCoreFailureEnvelope(request, createPublicCoreErrorDto(code)); + } + const response = await this.#dispatch(request, isWrite) as CoreResponseEnvelope; + if (request.method === "applyRestore") { + this.#invalidateProfilePreflight(profile); + if (response.ok) { + try { + await this.#ensureProfilePreflight(profile); + } catch { + this.#recoveryByProfile.set(profileKey(profile), true); + } + } + } + return response; + } finally { + releaseAdmission?.(); + } + } + + cancel(requestId: string, operationId?: string): boolean { + const dispatchId = this.#dispatchByRequestId.get(requestId); + const pending = dispatchId ? this.#pending.get(dispatchId) : undefined; + if (!pending || !pending.isWrite || !isApplyMethod(pending.request.method)) return false; + if (operationId !== undefined && operationId !== pending.operationId) return false; + const child = this.#child; + if (!child || pending.generation !== this.#generation || this.#state !== "ready") return false; + try { + child.postMessage(createRuntimeCancelFrame( + pending.generation, + pending.dispatchId, + requestId, + operationId + )); + return true; + } catch { + this.#failRuntime(child); + return false; + } + } + + crashForTest(): boolean { + const child = this.#child; + if (!child || this.#state !== "ready") return false; + this.#failRuntime(child); + return true; + } + + async shutdown(): Promise { + if (this.#shutdownPromise) return this.#shutdownPromise; + this.#disposed = true; + if (this.#state === "stopped") return; + const shutdown = this.#performShutdown(); + this.#shutdownPromise = shutdown; + try { + await shutdown; + } finally { + if (this.#shutdownPromise === shutdown) this.#shutdownPromise = null; + } + } + + async #performShutdown(): Promise { + this.#state = "shutting-down"; + this.#helloReject?.(new RuntimeActivationError("INTERNAL_ERROR")); + const child = this.#child; + if (!child) { + this.#failAllPending("INTERNAL_ERROR"); + this.#clearRuntimeCaches(); + this.#state = "stopped"; + return; + } + try { + child.postMessage({ + kind: "shutdown", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + generation: this.#generation + }); + } catch { + child.kill(); + } + await new Promise((resolve) => { + const timeout = setTimeout(() => { + child.kill(); + resolve(); + }, 30_000); + const detach = child.onExit(() => { + clearTimeout(timeout); + detach(); + resolve(); + }); + }); + await this.#activation?.catch(() => undefined); + this.#clearChild(child); + this.#failAllPending("INTERNAL_ERROR"); + this.#clearRuntimeCaches(); + this.#state = "stopped"; + } + + #tryAdmitWrite(): (() => void) | null { + if (this.#restartInstallGateClosed || this.#disposed || this.#state === "shutting-down") { + return null; + } + this.#admittedWriteCount += 1; + let released = false; + return () => { + if (released) return; + released = true; + this.#admittedWriteCount -= 1; + if (this.#admittedWriteCount !== 0) return; + const waiters = [...this.#writeDrainWaiters]; + this.#writeDrainWaiters.clear(); + for (const resolve of waiters) resolve(); + }; + } + + async #ensureReady(profile: ProfileSelector, requireWritePreflight: boolean): Promise { + if (this.#disposed || this.#state === "shutting-down" || this.#shutdownPromise) { + throw new RuntimeActivationError("INTERNAL_ERROR"); + } + if (this.#state !== "ready") { + if (!this.#activation) this.#activation = this.#startRuntime(this.#state === "crashed"); + const activation = this.#activation; + try { + await activation; + } finally { + if (this.#activation === activation) this.#activation = null; + } + } + if (this.#shutdownPromise) throw new RuntimeActivationError("INTERNAL_ERROR"); + if (this.#state !== "ready") throw new RuntimeActivationError("CORE_RUNTIME_CRASHED"); + if (requireWritePreflight || this.#preflightReadsAfterCrash) { + await this.#ensureProfilePreflight(profile); + } + } + + async #startRuntime(restartedAfterCrash: boolean): Promise { + this.#generation += 1; + const identity: ExpectedRuntimeIdentity = { + appVersion: this.#appVersion, + coreVersion: DESKTOP_CORE_VERSION, + buildId: DESKTOP_BUILD_ID, + sessionNonce: randomBytes(32).toString("hex"), + generation: this.#generation + }; + this.#state = "starting"; + let child: RuntimeUtilityHandle; + try { + child = this.#spawnUtility(identity); + } catch { + this.#state = "crashed"; + throw new RuntimeActivationError("CORE_RUNTIME_CRASHED"); + } + const hello = new Promise((resolve, reject) => { + this.#helloResolve = resolve; + this.#helloReject = reject; + }); + this.#child = child; + const detachMessage = child.onMessage((frame) => this.#handleMessage(child, frame, identity)); + const detachExit = child.onExit(() => this.#handleExit(child)); + this.#detachChild = () => { detachMessage(); detachExit(); }; + const timeout = setTimeout(() => { + this.#helloReject?.(new RuntimeActivationError("PROTOCOL_VERSION_MISMATCH")); + }, this.#handshakeTimeoutMs); + try { + await hello; + } catch (error) { + this.#clearChild(child); + child.kill(); + if (!this.#shutdownPromise) this.#state = "crashed"; + throw error; + } finally { + clearTimeout(timeout); + this.#helloResolve = null; + this.#helloReject = null; + } + if (this.#child !== child) throw new RuntimeActivationError("CORE_RUNTIME_CRASHED"); + this.#clearRuntimeCaches(); + this.#preflightReadsAfterCrash = restartedAfterCrash; + this.#state = "ready"; + this.#lastHandshakeAt = new Date().toISOString(); + } + + async #ensureProfilePreflight(profile: ProfileSelector): Promise { + const key = profileKey(profile); + const generationKey = `${this.#generation}:${key}`; + let preflight = this.#profilePreflights.get(generationKey); + if (!preflight) { + preflight = this.#preflight(profile, key, this.#generation).catch((error) => { + if (this.#profilePreflights.get(generationKey) === preflight) { + this.#profilePreflights.delete(generationKey); + } + throw error; + }); + this.#profilePreflights.set(generationKey, preflight); + } + await preflight; + } + + #invalidateProfilePreflight(profile: ProfileSelector): void { + const key = profileKey(profile); + for (const cached of this.#profilePreflights.keys()) { + if (cached.endsWith(`:${key}`)) this.#profilePreflights.delete(cached); + } + this.#recoveryByProfile.delete(key); + } + + async #preflight(profile: ProfileSelector, key: string, generation: number): Promise { + const request = createCoreRequestEnvelope( + "getStatus", + { profile }, + `desktop-preflight-${randomUUID()}` + ); + const response = await this.#dispatch(request, false); + if (!response.ok) throw new RuntimeActivationError(response.error.code); + if (generation !== this.#generation || this.#state !== "ready") { + throw new RuntimeActivationError("CORE_RUNTIME_CRASHED"); + } + const status = response.result as StatusSnapshot; + if (status.profile.id !== profile.profileId + || (profile.profileRevision !== undefined + && status.profile.revision !== profile.profileRevision)) { + const child = this.#child; + if (child) this.#failRuntime(child); + throw new RuntimeActivationError("CORE_RUNTIME_CRASHED"); + } + this.#recoveryByProfile.set( + key, + status.pendingRecovery === true + || (Array.isArray(status.pendingTransactions) && status.pendingTransactions.length > 0) + ); + } + + #dispatch( + request: CoreRequestEnvelope, + isWrite: boolean + ): Promise> { + const child = this.#child; + const generation = this.#generation; + if (!child || this.#state !== "ready") { + return Promise.resolve(createCoreFailureEnvelope( + request, + createPublicCoreErrorDto("CORE_RUNTIME_CRASHED") + )); + } + if (this.#dispatchByRequestId.has(request.requestId)) { + return Promise.resolve(createCoreFailureEnvelope( + request, + createPublicCoreErrorDto("INVALID_INPUT") + )); + } + const dispatchId = randomUUID(); + return new Promise>((resolve) => { + const timeoutMs = isWrite ? this.#writeRequestTimeoutMs : this.#requestTimeoutMs; + const timer = setTimeout(() => { + this.#removePending(dispatchId); + resolve(createCoreFailureEnvelope( + request, + createPublicCoreErrorDto(isWrite ? "CORE_RUNTIME_CRASHED" : "INTERNAL_ERROR") + )); + this.#failRuntime(child); + }, timeoutMs); + const pending: PendingRuntimeRequest = { + dispatchId, + request: request as CoreRequestEnvelope, + generation, + isWrite, + resolve: resolve as (response: CoreResponseEnvelope) => void, + timer + }; + this.#pending.set(dispatchId, pending); + this.#dispatchByRequestId.set(request.requestId, dispatchId); + try { + child.postMessage(createRuntimeRequestFrame(generation, dispatchId, request)); + } catch { + this.#removePending(dispatchId); + resolve(createCoreFailureEnvelope(request, createPublicCoreErrorDto("CORE_RUNTIME_CRASHED"))); + this.#failRuntime(child); + } + }); + } + + #handleMessage( + child: RuntimeUtilityHandle, + frame: unknown, + identity: ExpectedRuntimeIdentity + ): void { + if (this.#child !== child) return; + if (this.#state === "starting") { + try { + assertRuntimeHelloFrame(frame, identity); + this.#helloResolve?.(frame); + } catch { + this.#helloReject?.(new RuntimeActivationError("PROTOCOL_VERSION_MISMATCH")); + } + return; + } + if (this.#state !== "ready") return; + try { + const kind = frame !== null && typeof frame === "object" && !Array.isArray(frame) + ? (frame as { kind?: unknown }).kind + : undefined; + if (kind === "operation-event") { + this.#handleOperationEvent(frame); + return; + } + assertRuntimeResponseFrame(frame); + if (frame.generation !== this.#generation) throw new Error("Stale runtime response."); + const pending = this.#pending.get(frame.dispatchId); + if (!pending || pending.generation !== frame.generation) throw new Error("Unknown runtime response."); + assertRuntimeResponseFrame(frame, { + dispatchId: pending.dispatchId, + requestId: pending.request.requestId + }); + const responseOperationId = frame.envelope.operationId; + if (pending.operationId !== undefined) { + if (responseOperationId !== pending.operationId) { + throw new Error("Runtime response operationId mismatch."); + } + if (frame.envelope.ok + && (!frame.envelope.result + || typeof frame.envelope.result !== "object" + || !("operationId" in frame.envelope.result) + || frame.envelope.result.operationId !== pending.operationId)) { + throw new Error("Runtime result operationId mismatch."); + } + if (!frame.envelope.ok + && frame.envelope.error.operationId !== undefined + && frame.envelope.error.operationId !== pending.operationId) { + throw new Error("Runtime error operationId mismatch."); + } + } else if (responseOperationId !== undefined) { + throw new Error("Runtime response supplied operationId before operation-started."); + } + this.#removePending(pending.dispatchId); + pending.resolve(frame.envelope); + } catch { + this.#failRuntime(child); + } + } + + #handleOperationEvent(frame: unknown): void { + assertRuntimeOperationEventFrame(frame); + if (frame.generation !== this.#generation) throw new Error("Stale runtime operation event."); + const pending = this.#pending.get(frame.dispatchId); + if (!pending || pending.generation !== frame.generation || !isApplyMethod(pending.request.method)) { + throw new Error("Unknown runtime operation event."); + } + assertRuntimeOperationEventFrame(frame, { + dispatchId: pending.dispatchId, + requestId: pending.request.requestId, + ...(pending.operationId ? { operationId: pending.operationId } : {}) + }); + if (frame.envelope.event === "operation-started") { + if (pending.operationId !== undefined) throw new Error("Duplicate operation-started event."); + const expectedOperation = pending.request.method === "applySync" + ? "sync" + : pending.request.method === "applySwitch" + ? "switch" + : "restore"; + if (frame.envelope.operation !== expectedOperation) { + throw new Error("Runtime operation kind mismatch."); + } + pending.operationId = frame.envelope.operationId; + } else if (pending.operationId === undefined) { + throw new Error("Runtime progress preceded operation-started."); + } + for (const listener of this.#operationListeners) { + try { listener(frame.envelope); } catch {} + } + } + + #removePending(dispatchId: string): PendingRuntimeRequest | undefined { + const pending = this.#pending.get(dispatchId); + if (!pending) return undefined; + clearTimeout(pending.timer); + this.#pending.delete(dispatchId); + if (this.#dispatchByRequestId.get(pending.request.requestId) === dispatchId) { + this.#dispatchByRequestId.delete(pending.request.requestId); + } + return pending; + } + + #handleExit(child: RuntimeUtilityHandle): void { + if (this.#child !== child) return; + const shuttingDown = this.#state === "shutting-down"; + this.#clearChild(child); + this.#helloReject?.(new RuntimeActivationError("CORE_RUNTIME_CRASHED")); + this.#failAllPending(shuttingDown ? "INTERNAL_ERROR" : "CORE_RUNTIME_CRASHED"); + this.#clearRuntimeCaches(); + this.#state = shuttingDown ? "stopped" : "crashed"; + } + + #failRuntime(child: RuntimeUtilityHandle): void { + if (this.#child !== child) return; + this.#clearChild(child); + child.kill(); + this.#failAllPending("CORE_RUNTIME_CRASHED"); + this.#clearRuntimeCaches(); + if (this.#state !== "shutting-down") this.#state = "crashed"; + } + + #failAllPending(code: CoreErrorCode): void { + for (const pending of [...this.#pending.values()]) { + this.#removePending(pending.dispatchId); + pending.resolve(createCoreFailureEnvelope( + pending.request, + createPublicCoreErrorDto(code, { operationId: pending.operationId }), + pending.operationId + )); + } + } + + #clearChild(child: RuntimeUtilityHandle): void { + if (this.#child !== child) return; + this.#detachChild?.(); + this.#detachChild = null; + this.#child = null; + } + + #clearRuntimeCaches(): void { + this.#preflightReadsAfterCrash = false; + this.#profilePreflights.clear(); + this.#recoveryByProfile.clear(); + } +} diff --git a/apps/desktop/src/main/security-policy.ts b/apps/desktop/src/main/security-policy.ts new file mode 100644 index 0000000..69eba35 --- /dev/null +++ b/apps/desktop/src/main/security-policy.ts @@ -0,0 +1,111 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + DESKTOP_APP_HOST, + DESKTOP_APP_SCHEME, + DESKTOP_CSP +} from "../shared/constants.js"; + +const MIME_TYPES: Readonly> = Object.freeze({ + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2" +}); + +function isWithinRoot(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export async function resolveRendererAsset( + rendererRoot: string, + requestUrl: string +): Promise<{ filePath: string; contentType: string }> { + const url = new URL(requestUrl); + if (url.protocol !== `${DESKTOP_APP_SCHEME}:` + || url.hostname !== DESKTOP_APP_HOST + || url.username + || url.password + || url.port + || url.search + || url.hash) { + throw new TypeError("Invalid desktop asset URL."); + } + let decoded: string; + try { + decoded = decodeURIComponent(url.pathname); + } catch { + throw new TypeError("Invalid desktop asset encoding."); + } + if (decoded.includes("\0") || decoded.includes("\\") || decoded.includes("%")) { + throw new TypeError("Invalid desktop asset path."); + } + const segments = (decoded === "/" ? "/index.html" : decoded) + .split("/") + .filter(Boolean); + if (segments.length < 1 || segments.some((segment) => segment === "." || segment === "..")) { + throw new TypeError("Invalid desktop asset path."); + } + const root = await fs.realpath(rendererRoot); + const candidate = path.resolve(root, ...segments); + if (!isWithinRoot(root, candidate)) throw new TypeError("Desktop asset escaped its root."); + const physical = await fs.realpath(candidate); + if (!isWithinRoot(root, physical)) throw new TypeError("Desktop asset escaped its root."); + const stats = await fs.stat(physical); + if (!stats.isFile()) throw new TypeError("Desktop asset is not a regular file."); + return { + filePath: physical, + contentType: MIME_TYPES[path.extname(physical).toLowerCase()] ?? "application/octet-stream" + }; +} + +export async function createRendererAssetResponse( + rendererRoot: string, + request: Request +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } }); + } + try { + const asset = await resolveRendererAsset(rendererRoot, request.url); + const body = request.method === "HEAD" ? null : await fs.readFile(asset.filePath); + return new Response(body, { + status: 200, + headers: { + "Content-Type": asset.contentType, + "Content-Security-Policy": DESKTOP_CSP, + "Cache-Control": "no-store", + "Cross-Origin-Opener-Policy": "same-origin", + "X-Content-Type-Options": "nosniff" + } + }); + } catch { + return new Response(null, { + status: 404, + headers: { + "Content-Security-Policy": DESKTOP_CSP, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff" + } + }); + } +} + +export function createSecureWebPreferences(preload: string): Readonly> { + return Object.freeze({ + preload, + nodeIntegration: false, + nodeIntegrationInWorker: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + webviewTag: false + }); +} diff --git a/apps/desktop/src/main/security.ts b/apps/desktop/src/main/security.ts new file mode 100644 index 0000000..1684fe1 --- /dev/null +++ b/apps/desktop/src/main/security.ts @@ -0,0 +1,52 @@ +import type { + App, + Protocol, + Session, + WebContents +} from "electron"; + +import { + DESKTOP_APP_SCHEME +} from "../shared/constants.js"; +import { createRendererAssetResponse } from "./security-policy.js"; + +export function registerDesktopScheme(protocol: Protocol): void { + protocol.registerSchemesAsPrivileged([{ + scheme: DESKTOP_APP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + bypassCSP: false, + allowServiceWorkers: false + } + }]); +} + +export async function registerDesktopProtocol( + protocol: Protocol, + rendererRoot: string +): Promise { + await protocol.handle( + DESKTOP_APP_SCHEME, + (request) => createRendererAssetResponse(rendererRoot, request) + ); +} + +function lockWebContents(contents: WebContents): void { + contents.on("will-navigate", (event) => event.preventDefault()); + contents.on("will-attach-webview", (event) => event.preventDefault()); + contents.setWindowOpenHandler(() => ({ action: "deny" })); +} + +export function installDesktopSecurity(app: App, session: Session): () => void { + session.setPermissionCheckHandler(() => false); + session.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + const onCreated = (_event: Electron.Event, contents: WebContents) => lockWebContents(contents); + app.on("web-contents-created", onCreated); + return () => { + app.removeListener("web-contents-created", onCreated); + session.setPermissionCheckHandler(null); + session.setPermissionRequestHandler(null); + }; +} diff --git a/apps/desktop/src/main/update-policy.ts b/apps/desktop/src/main/update-policy.ts new file mode 100644 index 0000000..38c8bc0 --- /dev/null +++ b/apps/desktop/src/main/update-policy.ts @@ -0,0 +1,57 @@ +import type { CoreRuntimeSupervisor } from "./runtime-supervisor.js"; + +export type DesktopUpdateUnavailableReason = + | "not-packaged" + | "not-authorized" + | "not-configured" + | "unsupported-target"; + +export type DesktopInstallBlockedReason = + | "write-in-progress" + | "watch-active" + | "pending-recovery" + | "recovery-unverified"; + +export interface DesktopUpdateAvailabilityInput { + isPackaged: boolean; + platform: NodeJS.Platform; + arch: string; + releaseAuthorized: boolean; + configured: boolean; +} + +export interface DesktopInstallGateInput { + supervisor: Pick; + hasActiveWatches: boolean; + recoveryVerified: boolean; +} + +export function supportedUpdateTarget(platform: NodeJS.Platform, arch: string): boolean { + return (platform === "win32" && arch === "x64") + || (platform === "darwin" && (arch === "x64" || arch === "arm64")) + || (platform === "linux" && arch === "x64"); +} + +export function getDesktopUpdateUnavailableReason( + input: DesktopUpdateAvailabilityInput +): DesktopUpdateUnavailableReason | null { + if (!input.isPackaged) return "not-packaged"; + if (!supportedUpdateTarget(input.platform, input.arch)) return "unsupported-target"; + if (!input.releaseAuthorized) return "not-authorized"; + if (!input.configured) return "not-configured"; + return null; +} + +export function getDesktopInstallBlockedReason( + input: DesktopInstallGateInput +): DesktopInstallBlockedReason | null { + if (input.supervisor.snapshot.recoveryBlocked) { + return "pending-recovery"; + } + if (input.supervisor.snapshot.writeInProgress) { + return "write-in-progress"; + } + if (input.hasActiveWatches) return "watch-active"; + if (!input.recoveryVerified) return "recovery-unverified"; + return null; +} diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts new file mode 100644 index 0000000..81748d5 --- /dev/null +++ b/apps/desktop/src/main/updater.ts @@ -0,0 +1,403 @@ +import type { + DesktopUpdateReason, + DesktopUpdateStatus +} from "../shared/update-types.js"; +import { + getDesktopInstallBlockedReason, + getDesktopUpdateUnavailableReason +} from "./update-policy.js"; +import type { CoreRuntimeSupervisor } from "./runtime-supervisor.js"; + +type UpdaterEvent = + | "checking-for-update" + | "update-available" + | "update-not-available" + | "download-progress" + | "update-downloaded" + | "error"; + +type UpdaterListener = (value?: unknown) => void; + +export interface DesktopUpdaterPort { + on(event: UpdaterEvent, listener: UpdaterListener): void; + off(event: UpdaterEvent, listener: UpdaterListener): void; + checkForUpdates(): Promise; + downloadUpdate(): Promise; + quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void; +} + +export type DesktopRecoveryVerification = "clear" | "blocked" | "unverifiable"; + +export interface DesktopUpdateControllerOptions { + isPackaged: boolean; + platform: NodeJS.Platform; + arch: string; + appVersion: string; + releaseAuthorized: boolean; + configured: boolean; + supervisor: Pick; + hasActiveWatches(): boolean; + verifyNoActiveWatches(): Promise; + verifyRecoveryState(): Promise; + beforeInstall?(): Promise; + createPort?: () => Promise; + setTimeoutImpl?: typeof setTimeout; + clearTimeoutImpl?: typeof clearTimeout; +} + +function safeVersion(value: unknown): string | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const version = (value as { version?: unknown }).version; + if (typeof version !== "string" || !/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(version)) { + return undefined; + } + return version; +} + +function safeProgressPercent(value: unknown): number | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const percent = (value as { percent?: unknown }).percent; + if (typeof percent !== "number" || !Number.isFinite(percent)) return undefined; + return Math.max(0, Math.min(100, Math.round(percent))); +} + +export async function createProductionUpdaterPort(options: { + allowPrerelease: boolean; +}): Promise { + const { autoUpdater } = await import("electron-updater"); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.allowPrerelease = options.allowPrerelease; + autoUpdater.logger = null; + return { + on(event, listener) { + autoUpdater.on(event, listener); + }, + off(event, listener) { + autoUpdater.off(event, listener); + }, + checkForUpdates() { + return autoUpdater.checkForUpdates(); + }, + downloadUpdate() { + return autoUpdater.downloadUpdate(); + }, + quitAndInstall(isSilent, isForceRunAfter) { + autoUpdater.quitAndInstall(isSilent, isForceRunAfter); + } + }; +} + +export class DesktopUpdateController { + readonly #supervisor: Pick; + readonly #hasActiveWatches: () => boolean; + readonly #verifyNoActiveWatches: () => Promise; + readonly #verifyRecoveryState: () => Promise; + readonly #beforeInstall: () => Promise; + readonly #createPort: () => Promise; + readonly #setTimeout: typeof setTimeout; + readonly #clearTimeout: typeof clearTimeout; + readonly #unavailableReason: ReturnType; + readonly #listeners = new Map(); + #state: DesktopUpdateStatus["state"]; + #reason: DesktopUpdateReason | undefined; + #version: string | undefined; + #progressPercent: number | undefined; + #recoveryVerification: "unknown" | "clear" | "blocked" = "unknown"; + #restartPending = false; + #port: DesktopUpdaterPort | null = null; + #portPromise: Promise | null = null; + #checkPromise: Promise | null = null; + #downloadPromise: Promise | null = null; + #installPromise: Promise | null = null; + #initialCheckTimer: ReturnType | null = null; + #disposed = false; + + constructor(options: DesktopUpdateControllerOptions) { + this.#supervisor = options.supervisor; + this.#hasActiveWatches = options.hasActiveWatches; + this.#verifyNoActiveWatches = options.verifyNoActiveWatches; + this.#verifyRecoveryState = options.verifyRecoveryState; + this.#beforeInstall = options.beforeInstall ?? (async () => {}); + this.#createPort = options.createPort ?? (() => createProductionUpdaterPort({ + allowPrerelease: options.appVersion.includes("-") + })); + this.#setTimeout = options.setTimeoutImpl ?? setTimeout; + this.#clearTimeout = options.clearTimeoutImpl ?? clearTimeout; + this.#unavailableReason = getDesktopUpdateUnavailableReason(options); + this.#state = this.#unavailableReason ? "disabled" : "idle"; + this.#reason = this.#unavailableReason ?? undefined; + } + + get restartPending(): boolean { + return this.#restartPending; + } + + get status(): DesktopUpdateStatus { + const status: DesktopUpdateStatus = { + schemaVersion: 2, + state: this.#state, + installAllowed: false, + ...(this.#reason ? { reason: this.#reason } : {}), + ...(this.#version ? { version: this.#version } : {}), + ...(this.#progressPercent !== undefined ? { progressPercent: this.#progressPercent } : {}) + }; + if (this.#state !== "downloaded") return status; + const blocked = this.#recoveryVerification === "blocked" + ? "pending-recovery" + : getDesktopInstallBlockedReason({ + supervisor: this.#supervisor, + hasActiveWatches: this.#hasActiveWatches(), + recoveryVerified: this.#recoveryVerification === "clear" + }); + return { + ...status, + installAllowed: blocked === null, + ...(blocked ? { installBlockedReason: blocked } : {}) + }; + } + + scheduleInitialCheck(delayMs = 15_000): void { + if (this.#disposed || this.#unavailableReason || this.#initialCheckTimer) return; + const timer = this.#setTimeout(() => { + if (this.#initialCheckTimer !== timer) return; + this.#initialCheckTimer = null; + void this.check(); + }, Math.max(0, delayMs)); + timer.unref?.(); + this.#initialCheckTimer = timer; + } + + async check(): Promise { + if (this.#disposed || this.#unavailableReason || this.#restartPending) return this.status; + if (this.#checkPromise) return this.#checkPromise; + if (this.#state === "downloading" || this.#state === "downloaded" || this.#state === "installing") { + return this.status; + } + const pending = (async () => { + this.#state = "checking"; + this.#reason = undefined; + this.#version = undefined; + this.#progressPercent = undefined; + this.#recoveryVerification = "unknown"; + try { + const port = await this.#ensurePort(); + const result = await port.checkForUpdates(); + if (this.#state === "checking") { + const resultVersion = safeVersion( + result && typeof result === "object" && !Array.isArray(result) + ? (result as { updateInfo?: unknown }).updateInfo + : undefined + ); + this.#version = resultVersion; + this.#state = resultVersion ? "available" : "not-available"; + } + } catch { + this.#fail("check-failed"); + } + return this.status; + })(); + this.#checkPromise = pending; + try { + return await pending; + } finally { + if (this.#checkPromise === pending) this.#checkPromise = null; + } + } + + async download(): Promise { + if (this.#disposed || this.#restartPending || this.#state !== "available") return this.status; + if (this.#downloadPromise) return this.#downloadPromise; + const pending = (async () => { + this.#state = "downloading"; + this.#reason = undefined; + this.#progressPercent = 0; + try { + const port = await this.#ensurePort(); + await port.downloadUpdate(); + if (this.#state === "downloading") { + this.#state = "downloaded"; + this.#progressPercent = 100; + } + if (this.#state === "downloaded") await this.#refreshRecoveryVerification(); + } catch { + this.#fail("download-failed"); + } + return this.status; + })(); + this.#downloadPromise = pending; + try { + return await pending; + } finally { + if (this.#downloadPromise === pending) this.#downloadPromise = null; + } + } + + async install(): Promise { + if (this.#disposed || this.#state !== "downloaded") return this.status; + if (this.#installPromise) return this.#installPromise; + const pending = (async () => { + const restartLease = this.#supervisor.tryBeginRestartInstall(); + if (!restartLease) { + this.#recoveryVerification = "unknown"; + return this.status; + } + this.#restartPending = true; + let retainRestartGate = false; + try { + await restartLease.waitForWrites(); + if (this.#hasActiveWatches()) { + let noActiveWatches = false; + try { + noActiveWatches = await this.#verifyNoActiveWatches(); + } catch {} + if (!noActiveWatches) return this.status; + } + this.#recoveryVerification = "unknown"; + const recoveryVerification = await this.#refreshRecoveryVerification(); + if (this.#immediateInstallBlock() || recoveryVerification !== "clear") { + return this.status; + } + this.#state = "installing"; + const port = await this.#ensurePort(); + await this.#beforeInstall(); + if (this.#immediateInstallBlock()) { + this.#state = "downloaded"; + this.#recoveryVerification = "unknown"; + return this.status; + } + port.quitAndInstall(false, true); + retainRestartGate = true; + } catch { + this.#fail("install-failed"); + } finally { + if (!retainRestartGate) { + restartLease.release(); + this.#restartPending = false; + } + } + return this.status; + })(); + this.#installPromise = pending; + try { + return await pending; + } finally { + if (this.#installPromise === pending) this.#installPromise = null; + } + } + + dispose(): void { + this.#disposed = true; + if (this.#initialCheckTimer) { + this.#clearTimeout(this.#initialCheckTimer); + this.#initialCheckTimer = null; + } + if (this.#port) { + for (const [event, listener] of this.#listeners) this.#port.off(event, listener); + } + this.#listeners.clear(); + } + + #immediateInstallBlock(): boolean { + return this.#supervisor.snapshot.recoveryBlocked + || this.#supervisor.snapshot.writeInProgress + || this.#hasActiveWatches(); + } + + async #refreshRecoveryVerification(): Promise<"unknown" | "clear" | "blocked"> { + if (this.#immediateInstallBlock()) { + this.#recoveryVerification = this.#supervisor.snapshot.recoveryBlocked + ? "blocked" + : "unknown"; + return this.#recoveryVerification; + } + try { + const result = await this.#verifyRecoveryState(); + this.#recoveryVerification = result === "clear" + ? "clear" + : result === "blocked" + ? "blocked" + : "unknown"; + } catch { + this.#recoveryVerification = "unknown"; + } + return this.#recoveryVerification; + } + + async #ensurePort(): Promise { + if (this.#port) return this.#port; + if (!this.#portPromise) { + this.#portPromise = this.#createPort().then((port) => { + if (this.#disposed) throw new Error("Update controller is disposed."); + this.#port = port; + this.#bindPort(port); + return port; + }); + } + try { + return await this.#portPromise; + } finally { + if (!this.#port) this.#portPromise = null; + } + } + + #bindPort(port: DesktopUpdaterPort): void { + const bind = (event: UpdaterEvent, listener: UpdaterListener) => { + this.#listeners.set(event, listener); + port.on(event, listener); + }; + bind("checking-for-update", () => { + if (!this.#restartPending) this.#state = "checking"; + }); + bind("update-available", (info) => { + if (this.#restartPending) return; + const version = safeVersion(info); + if (!version) { + this.#fail("check-failed"); + return; + } + this.#state = "available"; + this.#reason = undefined; + this.#version = version; + this.#progressPercent = undefined; + this.#recoveryVerification = "unknown"; + }); + bind("update-not-available", () => { + if (this.#restartPending) return; + this.#state = "not-available"; + this.#reason = undefined; + this.#version = undefined; + this.#progressPercent = undefined; + this.#recoveryVerification = "unknown"; + }); + bind("download-progress", (progress) => { + if (this.#state !== "downloading") return; + const percent = safeProgressPercent(progress); + if (percent !== undefined) this.#progressPercent = percent; + }); + bind("update-downloaded", (info) => { + if (this.#restartPending) return; + const version = safeVersion(info) ?? this.#version; + if (!version) { + this.#fail("download-failed"); + return; + } + this.#state = "downloaded"; + this.#reason = undefined; + this.#version = version; + this.#progressPercent = 100; + void this.#refreshRecoveryVerification(); + }); + bind("error", () => { + if (this.#restartPending) return; + this.#fail(this.#state === "downloading" ? "download-failed" : "check-failed"); + }); + } + + #fail(reason: DesktopUpdateReason): void { + this.#state = "error"; + this.#reason = reason; + this.#progressPercent = undefined; + this.#recoveryVerification = "unknown"; + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 0000000..e0e2aeb --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,408 @@ +import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; + +import { + assertCoreRequestEnvelope, + assertCoreOperationEventEnvelope, + createCoreFailureEnvelope, + createPublicCoreErrorDto, + type CoreRequestEnvelope, + type CoreResponseEnvelope, + type CoreOperationEventEnvelope +} from "@codex-provider-sync/contracts"; +import { + isDesktopMaintenanceMethod, + isDesktopReadMethod, + isDesktopRestoreMethod, + isDesktopSyncSwitchMethod, + type DesktopCancelOperationInput, + type DesktopMaintenanceMethod, + type DesktopReadMethod, + type DesktopRestoreMethod, + type DesktopSyncSwitchMethod +} from "@codex-provider-sync/core-client"; + +import type { DesktopBridgeApi } from "../shared/bridge.js"; +import { + DESKTOP_IPC_CHANNELS, + MAX_DESKTOP_IPC_BYTES +} from "../shared/constants.js"; +import type { DesktopProfileListResponse } from "../shared/profile-types.js"; +import type { + DesktopDiagnosticsExportInput, + DesktopDiagnosticsExportResult +} from "../shared/diagnostics-types.js"; +import type { DesktopUpdateStatus } from "../shared/update-types.js"; + +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value as Record)) deepFreeze(nested); + } + return value; +} + +function requestFailure( + request: CoreRequestEnvelope, + code: "INVALID_INPUT" | "PERMISSION_DENIED" +): CoreResponseEnvelope { + return createCoreFailureEnvelope(request, createPublicCoreErrorDto(code)); +} + +function validateProfileList(value: unknown): DesktopProfileListResponse { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Invalid desktop profile response."); + } + const source = value as Record; + if (Object.keys(source).sort().join(",") !== "profiles,schemaVersion" + || source.schemaVersion !== 1 + || !Array.isArray(source.profiles)) { + throw new TypeError("Invalid desktop profile response."); + } + for (const entry of source.profiles) { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new TypeError("Invalid desktop profile response."); + } + const profile = entry as Record; + if (Object.keys(profile).sort().join(",") !== "codexHomeConfigured,id,name,revision,sqliteHomeConfigured" + || typeof profile.id !== "string" + || typeof profile.name !== "string" + || typeof profile.revision !== "string" + || typeof profile.codexHomeConfigured !== "boolean" + || typeof profile.sqliteHomeConfigured !== "boolean") { + throw new TypeError("Invalid desktop profile response."); + } + } + return structuredClone(value) as DesktopProfileListResponse; +} + +async function requestReadOnly( + envelope: CoreRequestEnvelope +): Promise { + try { + assertCoreRequestEnvelope(envelope); + } catch { + return requestFailure(envelope, "INVALID_INPUT"); + } + if (!isDesktopReadMethod(envelope.method)) { + return requestFailure(envelope, "PERMISSION_DENIED"); + } + let size = Number.POSITIVE_INFINITY; + try { + size = new TextEncoder().encode(JSON.stringify(envelope)).byteLength; + } catch {} + if (size > MAX_DESKTOP_IPC_BYTES) return requestFailure(envelope, "INVALID_INPUT"); + return ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.coreRead, + structuredClone(envelope) + ); +} + +async function requestSyncSwitch( + envelope: CoreRequestEnvelope +): Promise { + try { + assertCoreRequestEnvelope(envelope); + } catch { + return requestFailure(envelope, "INVALID_INPUT"); + } + if (!isDesktopSyncSwitchMethod(envelope.method) || envelope.operationId !== undefined) { + return requestFailure(envelope, "PERMISSION_DENIED"); + } + let size = Number.POSITIVE_INFINITY; + try { size = new TextEncoder().encode(JSON.stringify(envelope)).byteLength; } catch {} + if (size > MAX_DESKTOP_IPC_BYTES) return requestFailure(envelope, "INVALID_INPUT"); + return ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.coreSyncSwitch, + structuredClone(envelope) + ); +} + +async function requestRestore( + envelope: CoreRequestEnvelope +): Promise { + try { + assertCoreRequestEnvelope(envelope); + } catch { + return requestFailure(envelope, "INVALID_INPUT"); + } + if (!isDesktopRestoreMethod(envelope.method) || envelope.operationId !== undefined) { + return requestFailure(envelope, "PERMISSION_DENIED"); + } + let size = Number.POSITIVE_INFINITY; + try { size = new TextEncoder().encode(JSON.stringify(envelope)).byteLength; } catch {} + if (size > MAX_DESKTOP_IPC_BYTES) return requestFailure(envelope, "INVALID_INPUT"); + return ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.coreRestore, + structuredClone(envelope) + ); +} + +async function requestMaintenance( + envelope: CoreRequestEnvelope +): Promise { + try { + assertCoreRequestEnvelope(envelope); + } catch { + return requestFailure(envelope, "INVALID_INPUT"); + } + if (!isDesktopMaintenanceMethod(envelope.method) || envelope.operationId !== undefined) { + return requestFailure(envelope, "PERMISSION_DENIED"); + } + let size = Number.POSITIVE_INFINITY; + try { size = new TextEncoder().encode(JSON.stringify(envelope)).byteLength; } catch {} + if (size > MAX_DESKTOP_IPC_BYTES) return requestFailure(envelope, "INVALID_INPUT"); + return ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.coreMaintenance, + structuredClone(envelope) + ); +} + +function validProfileSelector(value: unknown): boolean { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const profile = value as Record; + const allowed = profile.profileRevision === undefined + ? ["profileId"] + : ["profileId", "profileRevision"]; + return Object.keys(profile).sort().join(",") === allowed.sort().join(",") + && typeof profile.profileId === "string" + && /^[A-Za-z0-9._-]{1,80}$/.test(profile.profileId) + && (profile.profileRevision === undefined + || (typeof profile.profileRevision === "string" + && profile.profileRevision.length > 0 + && profile.profileRevision.length <= 512)); +} + +function validateDiagnosticsExportInput(value: unknown): DesktopDiagnosticsExportInput { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Invalid diagnostics export request."); + } + const input = value as Record; + if (Object.keys(input).sort().join(",") !== "profile,schemaVersion" + || input.schemaVersion !== 1 + || !validProfileSelector(input.profile)) { + throw new TypeError("Invalid diagnostics export request."); + } + return structuredClone(value) as DesktopDiagnosticsExportInput; +} + +function validateDiagnosticsExportResult(value: unknown): DesktopDiagnosticsExportResult { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Invalid diagnostics export response."); + } + const result = value as Record; + if (result.schemaVersion !== 1) throw new TypeError("Invalid diagnostics export response."); + if (result.status === "cancelled" + && Object.keys(result).sort().join(",") === "schemaVersion,status") { + return structuredClone(value) as DesktopDiagnosticsExportResult; + } + if (result.status === "created" + && Object.keys(result).sort().join(",") === "artifactId,createdAt,schemaVersion,status" + && typeof result.artifactId === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result.artifactId) + && typeof result.createdAt === "string" + && Number.isFinite(Date.parse(result.createdAt))) { + return structuredClone(value) as DesktopDiagnosticsExportResult; + } + if (result.status === "failed" + && Object.keys(result).sort().join(",") === "reason,schemaVersion,status" + && ["runtime-unavailable", "invalid-snapshot", "write-failed"].includes(String(result.reason))) { + return structuredClone(value) as DesktopDiagnosticsExportResult; + } + throw new TypeError("Invalid diagnostics export response."); +} + +function validateUpdateStatus(value: unknown): DesktopUpdateStatus { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Invalid update status response."); + } + const status = value as Record; + const allowedKeys = new Set([ + "schemaVersion", + "state", + "installAllowed", + "reason", + "version", + "progressPercent", + "installBlockedReason" + ]); + const states = new Set([ + "disabled", + "idle", + "checking", + "available", + "downloading", + "downloaded", + "not-available", + "error", + "installing" + ]); + if (Object.keys(status).some((key) => !allowedKeys.has(key)) + || status.schemaVersion !== 2 + || typeof status.installAllowed !== "boolean" + || !states.has(String(status.state))) { + throw new TypeError("Invalid update status response."); + } + const state = String(status.state); + const disabledReasons = new Set([ + "not-packaged", + "not-authorized", + "not-configured", + "unsupported-target" + ]); + const errorReasons = new Set(["check-failed", "download-failed", "install-failed"]); + if ((state === "disabled" && !disabledReasons.has(String(status.reason))) + || (state === "error" && !errorReasons.has(String(status.reason))) + || (!["disabled", "error"].includes(state) && status.reason !== undefined)) { + throw new TypeError("Invalid update status response."); + } + const versioned = ["available", "downloading", "downloaded", "installing"].includes(state); + if ((versioned + && (typeof status.version !== "string" + || !/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(status.version))) + || (!versioned && status.version !== undefined)) { + throw new TypeError("Invalid update status response."); + } + if (status.progressPercent !== undefined + && (!Number.isInteger(status.progressPercent) + || Number(status.progressPercent) < 0 + || Number(status.progressPercent) > 100 + || !["downloading", "downloaded"].includes(state))) { + throw new TypeError("Invalid update status response."); + } + const blockedReasons = new Set([ + "write-in-progress", + "watch-active", + "pending-recovery", + "recovery-unverified" + ]); + if (state === "downloaded") { + if ((status.installAllowed === true && status.installBlockedReason !== undefined) + || (status.installAllowed === false + && !blockedReasons.has(String(status.installBlockedReason)))) { + throw new TypeError("Invalid update status response."); + } + } else if (status.installAllowed !== false || status.installBlockedReason !== undefined) { + throw new TypeError("Invalid update status response."); + } + return structuredClone(value) as DesktopUpdateStatus; +} + +function subscribeOperation( + listener: (event: CoreOperationEventEnvelope) => void +): () => void { + const receive = (_event: IpcRendererEvent, value: unknown) => { + try { + assertCoreOperationEventEnvelope(value); + listener(structuredClone(value)); + } catch { + // Main is trusted, but malformed lifecycle data must fail closed at the + // observer boundary and never influence an in-flight transaction. + } + }; + ipcRenderer.on(DESKTOP_IPC_CHANNELS.operationEvent, receive); + return () => ipcRenderer.removeListener(DESKTOP_IPC_CHANNELS.operationEvent, receive); +} + +async function cancelOperation(input: DesktopCancelOperationInput): Promise<{ accepted: boolean }> { + const allowed = input.operationId === undefined + ? ["requestId"] + : ["requestId", "operationId"]; + if (Object.keys(input).sort().join(",") !== allowed.sort().join(",") + || typeof input.requestId !== "string" + || input.requestId.length === 0 + || input.requestId.length > 512 + || (input.operationId !== undefined + && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.operationId))) { + return { accepted: false }; + } + const value: unknown = await ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.operationCancel, + structuredClone(input) + ); + return value !== null + && typeof value === "object" + && !Array.isArray(value) + && Object.keys(value).length === 1 + && typeof (value as { accepted?: unknown }).accepted === "boolean" + ? { accepted: (value as { accepted: boolean }).accepted } + : { accepted: false }; +} + +const api: DesktopBridgeApi = { + version: 1, + core: { + requestReadOnly, + requestSyncSwitch, + requestRestore, + requestMaintenance, + subscribeOperation, + cancelOperation + }, + profiles: { + async list() { + return validateProfileList(await ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.profilesList, + null + )); + } + }, + diagnostics: { + async export(input) { + const validated = validateDiagnosticsExportInput(input); + return validateDiagnosticsExportResult(await ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.diagnosticsExport, + validated + )); + } + }, + updates: { + async getStatus() { + return validateUpdateStatus(await ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.updateStatus, + null + )); + }, + async check() { + return validateUpdateStatus(await ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.updateCheck, + null + )); + }, + async download() { + return validateUpdateStatus(await ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.updateDownload, + null + )); + }, + async install() { + return validateUpdateStatus(await ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.updateInstall, + null + )); + } + }, + ...(__CPS_DESKTOP_TEST_BUILD__ && process.env.CPS_DESKTOP_E2E === "1" ? { + test: { + async crashRuntime() { + const value: unknown = await ipcRenderer.invoke( + "cps:v1:test:crash-runtime", + null + ); + return value !== null + && typeof value === "object" + && !Array.isArray(value) + && typeof (value as { crashed?: unknown }).crashed === "boolean" + ? { crashed: (value as { crashed: boolean }).crashed } + : { crashed: false }; + }, + async requestRaw(envelope) { + return ipcRenderer.invoke( + DESKTOP_IPC_CHANNELS.coreRead, + structuredClone(envelope) + ) as Promise; + } + } + } : {}) +}; + +contextBridge.exposeInMainWorld("codexProvider", deepFreeze(api)); diff --git a/apps/desktop/src/profiles/repository.ts b/apps/desktop/src/profiles/repository.ts new file mode 100644 index 0000000..d14d196 --- /dev/null +++ b/apps/desktop/src/profiles/repository.ts @@ -0,0 +1,159 @@ +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { ProfileSelector } from "@codex-provider-sync/contracts"; + +import type { DesktopProfileSummary, TrustedDesktopProfile } from "../shared/profile-types.js"; + +interface StoredProfile { + id: string; + name: string; + codexHome: string; + sqliteHome?: string; +} + +interface StoredProfileDocument { + schemaVersion: 1; + profiles: StoredProfile[]; +} + +export interface DesktopProfileRepositoryOptions { + filePath: string; + defaultCodexHome: string; + defaultSqliteHome?: string; +} + +function profileError(code: "INVALID_INPUT" | "PROFILE_CHANGED", message: string): Error { + return Object.assign(new Error(message), { code }); +} + +function normalizeOptionalPath(value: unknown): string | undefined { + if (value === undefined || value === null || String(value).trim() === "") return undefined; + if (typeof value !== "string" || value.includes("\0") || !path.isAbsolute(value.trim())) { + throw profileError("INVALID_INPUT", "Invalid trusted SQLite Home."); + } + return path.resolve(value.trim()); +} + +function normalizeProfile(value: unknown): StoredProfile { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw profileError("INVALID_INPUT", "Invalid trusted desktop profile."); + } + const source = value as Record; + const allowed = new Set(["id", "name", "codexHome", "sqliteHome"]); + if (Object.keys(source).some((key) => !allowed.has(key)) + || typeof source.id !== "string" + || !/^[A-Za-z0-9._-]{1,80}$/.test(source.id) + || typeof source.name !== "string" + || source.name.trim().length < 1 + || source.name.trim().length > 120 + || typeof source.codexHome !== "string" + || source.codexHome.includes("\0") + || !path.isAbsolute(source.codexHome.trim())) { + throw profileError("INVALID_INPUT", "Invalid trusted desktop profile."); + } + const sqliteHome = normalizeOptionalPath(source.sqliteHome); + return { + id: source.id, + name: source.name.trim(), + codexHome: path.resolve(source.codexHome.trim()), + ...(sqliteHome ? { sqliteHome } : {}) + }; +} + +function revisionOf(profile: StoredProfile): string { + return createHash("sha256").update(JSON.stringify(profile), "utf8").digest("base64url"); +} + +function trustedProfile(profile: StoredProfile): TrustedDesktopProfile { + return { ...profile, revision: revisionOf(profile) }; +} + +export class DesktopProfileRepository { + readonly #filePath: string; + readonly #defaultProfile: StoredProfile; + #profiles: StoredProfile[]; + #initialized = false; + + constructor(options: DesktopProfileRepositoryOptions) { + this.#filePath = path.resolve(options.filePath); + this.#defaultProfile = normalizeProfile({ + id: "default", + name: "Default", + codexHome: options.defaultCodexHome, + ...(options.defaultSqliteHome ? { sqliteHome: options.defaultSqliteHome } : {}) + }); + this.#profiles = [this.#defaultProfile]; + } + + async initialize(): Promise { + let namedProfiles: StoredProfile[] = []; + try { + const parsed: unknown = JSON.parse(await fs.readFile(this.#filePath, "utf8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw profileError("INVALID_INPUT", "Invalid desktop profile document."); + } + const document = parsed as Record; + if (Object.keys(document).sort().join(",") !== "profiles,schemaVersion" + || document.schemaVersion !== 1 + || !Array.isArray(document.profiles)) { + throw profileError("INVALID_INPUT", "Invalid desktop profile document."); + } + namedProfiles = document.profiles + .map(normalizeProfile) + .filter((profile) => profile.id !== "default"); + const ids = new Set(["default"]); + for (const profile of namedProfiles) { + if (ids.has(profile.id)) throw profileError("INVALID_INPUT", "Duplicate desktop profile ID."); + ids.add(profile.id); + } + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + } + this.#profiles = [this.#defaultProfile, ...namedProfiles]; + await this.#persist(); + this.#initialized = true; + } + + list(): DesktopProfileSummary[] { + this.#assertInitialized(); + return this.#profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + revision: revisionOf(profile), + codexHomeConfigured: true, + sqliteHomeConfigured: Boolean(profile.sqliteHome) + })); + } + + resolve(selector: ProfileSelector): TrustedDesktopProfile { + this.#assertInitialized(); + const profile = this.#profiles.find((candidate) => candidate.id === selector.profileId); + if (!profile) throw profileError("INVALID_INPUT", "Unknown desktop profile."); + const trusted = trustedProfile(profile); + if (selector.profileRevision !== undefined && selector.profileRevision !== trusted.revision) { + throw profileError("PROFILE_CHANGED", "The desktop profile changed."); + } + return trusted; + } + + #assertInitialized(): void { + if (!this.#initialized) throw new Error("Desktop profile repository is not initialized."); + } + + async #persist(): Promise { + const document: StoredProfileDocument = { + schemaVersion: 1, + profiles: this.#profiles + }; + await fs.mkdir(path.dirname(this.#filePath), { recursive: true }); + const temporary = `${this.#filePath}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`; + await fs.writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600 + }); + await fs.rename(temporary, this.#filePath); + await fs.chmod(this.#filePath, 0o600).catch(() => {}); + } +} diff --git a/apps/desktop/src/renderer/favicon.svg b/apps/desktop/src/renderer/favicon.svg new file mode 100644 index 0000000..98d49d9 --- /dev/null +++ b/apps/desktop/src/renderer/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html new file mode 100644 index 0000000..7fd2c01 --- /dev/null +++ b/apps/desktop/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Codex Provider Sync + + +
+ + + diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx new file mode 100644 index 0000000..945f785 --- /dev/null +++ b/apps/desktop/src/renderer/main.tsx @@ -0,0 +1,79 @@ +import { + AppUi, + DESKTOP_C8_APP_UI_CAPABILITIES, + type HostClient, + type HostProfile, + type PreferenceStore +} from "@codex-provider-sync/app-ui"; +import { DesktopCoreClient } from "@codex-provider-sync/core-client"; +import type { SupportedLocale, ThemeMode } from "@codex-provider-sync/design-system"; +import React from "react"; +import { createRoot } from "react-dom/client"; + +import type { DesktopBridgeApi } from "../shared/bridge.js"; +import "./styles.css"; + +declare global { + interface Window { + readonly codexProvider: DesktopBridgeApi; + } +} + +const bridge = window.codexProvider; +if (!bridge || bridge.version !== 1) throw new Error("Desktop preload bridge is unavailable."); + +const core = new DesktopCoreClient(bridge.core); +const host: HostClient = Object.freeze({ + async listProfiles(): Promise { + const value = await bridge.profiles.list(); + return value.profiles.map((profile) => ({ ...profile })); + }, + async exportDiagnostics(profile: { profileId: string; profileRevision?: string }) { + const result = await bridge.diagnostics.export({ schemaVersion: 1, profile }); + return { status: result.status }; + }, + async getUpdateStatus() { + const status = await bridge.updates.getStatus(); + return { ...status }; + }, + async checkForUpdates() { + return { ...await bridge.updates.check() }; + }, + async downloadUpdate() { + return { ...await bridge.updates.download() }; + }, + async installUpdate() { + return { ...await bridge.updates.install() }; + } +}); + +const preferences: PreferenceStore = Object.freeze({ + getLocale(): SupportedLocale | null { + const value = localStorage.getItem("cps.desktop.locale"); + return value === "zh-CN" || value === "en" ? value : null; + }, + setLocale(locale: SupportedLocale): void { + localStorage.setItem("cps.desktop.locale", locale); + }, + getTheme(): ThemeMode | null { + const value = localStorage.getItem("cps.desktop.theme"); + return value === "system" || value === "light" || value === "dark" ? value : null; + }, + setTheme(theme: ThemeMode): void { + localStorage.setItem("cps.desktop.theme", theme); + } +}); + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/apps/desktop/src/renderer/public/theme-bootstrap.js b/apps/desktop/src/renderer/public/theme-bootstrap.js new file mode 100644 index 0000000..ef7bfc5 --- /dev/null +++ b/apps/desktop/src/renderer/public/theme-bootstrap.js @@ -0,0 +1,10 @@ +(() => { + try { + const theme = globalThis.localStorage.getItem("cps.desktop.theme"); + if (theme === "system" || theme === "light" || theme === "dark") { + document.documentElement.dataset.theme = theme; + } + } catch { + // Preferences are optional; the system theme remains the safe default. + } +})(); diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css new file mode 100644 index 0000000..c07e3fe --- /dev/null +++ b/apps/desktop/src/renderer/styles.css @@ -0,0 +1,7 @@ +@import "tailwindcss"; +@import "@codex-provider-sync/design-system/tokens.css"; +@source "../../../../packages/app-ui/src"; + +html { min-width: 320px; background: var(--surface); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--surface); } +button, input, select { font: inherit; } diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/desktop/src/runtime/e2e-gate.ts b/apps/desktop/src/runtime/e2e-gate.ts new file mode 100644 index 0000000..6c5a426 --- /dev/null +++ b/apps/desktop/src/runtime/e2e-gate.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { applyPreparedDesktopOperationForTest } from "@codex-provider-sync/test-fixtures/desktop-faults"; + +import type { DesktopRuntimeTestApplyInvoker } from "./host.js"; + +const ALLOWED_TEST_GATE_POINTS = new Set([ + "before_backup", + "after_config_mutation_before_applied", + "after_rollout_mutation_before_applied", + "after_sqlite_commit_before_ack", + "after_transaction_journal_commit_before_ack", + "after_transaction_commit" +]); + +function cancelledAtTestGate(): Error & { code: "ABORT_ERR" } { + const error = new Error("Desktop E2E operation cancelled at a deterministic safety gate.") as Error & { + code: "ABORT_ERR"; + }; + error.name = "AbortError"; + error.code = "ABORT_ERR"; + return error; +} + +function createDesktopTestFaultInjector(signal: AbortSignal): + (event: Record) => Promise { + const selectedPoint = process.env.CPS_DESKTOP_TEST_GATE; + const markerPath = process.env.CPS_DESKTOP_TEST_GATE_FILE; + if (!selectedPoint + || !markerPath + || !ALLOWED_TEST_GATE_POINTS.has(selectedPoint) + || !path.isAbsolute(markerPath)) { + throw new Error("Invalid desktop E2E fault gate configuration."); + } + let entered = false; + return async (event) => { + if (entered || event.point !== selectedPoint) return; + entered = true; + await fs.mkdir(path.dirname(markerPath), { recursive: true }); + await fs.writeFile(markerPath, `${JSON.stringify({ point: selectedPoint })}\n`, { + encoding: "utf8", + flag: "wx" + }); + if (!signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + } + throw cancelledAtTestGate(); + }; +} + +export function createDesktopTestApplyInvoker(): DesktopRuntimeTestApplyInvoker | undefined { + const selectedPoint = process.env.CPS_DESKTOP_TEST_GATE; + const markerPath = process.env.CPS_DESKTOP_TEST_GATE_FILE; + if (!selectedPoint && !markerPath) return undefined; + if (!selectedPoint + || !markerPath + || !ALLOWED_TEST_GATE_POINTS.has(selectedPoint) + || !path.isAbsolute(markerPath)) { + throw new Error("Invalid desktop E2E fault gate configuration."); + } + return async (method, input, control) => { + return applyPreparedDesktopOperationForTest( + method, + input, + control, + createDesktopTestFaultInjector(control.signal ?? new AbortController().signal) + ) as ReturnType; + }; +} diff --git a/apps/desktop/src/runtime/host.ts b/apps/desktop/src/runtime/host.ts new file mode 100644 index 0000000..66463dc --- /dev/null +++ b/apps/desktop/src/runtime/host.ts @@ -0,0 +1,120 @@ +import { + createCoreFailureEnvelope, + createCoreSuccessEnvelope, + createPublicCoreErrorDto, + sanitizePublicCoreErrorDto, + type CoreMethodMap, + type CoreOperationStartedEnvelope, + type CoreProgressEnvelope, + type CoreRequestEnvelope, + type CoreResponseEnvelope, + type ProgressEvent +} from "@codex-provider-sync/contracts"; +import { createCoreFacade } from "@codex-provider-sync/core"; +import type { DesktopRuntimeMethod } from "@codex-provider-sync/core-client"; + +import type { DesktopProfileRepository } from "../profiles/repository.js"; + +export interface DesktopRuntimeDispatchControl { + signal?: AbortSignal; + onOperationStarted?(event: CoreOperationStartedEnvelope): void; + onProgress?(event: CoreProgressEnvelope): void; +} + +export type DesktopRuntimeTestApplyInvoker = ( + method: "applySync" | "applySwitch" | "applyRestore", + input: CoreMethodMap["applySync"]["input"], + control: { + signal?: AbortSignal; + onOperationStarted?(value: { + operationId: string; + operation: "sync" | "switch" | "restore"; + }): void; + onProgress?(event: ProgressEvent): void; + } +) => Promise; + +export interface DesktopRuntimeHost { + dispatch( + request: CoreRequestEnvelope, + control?: DesktopRuntimeDispatchControl + ): Promise>; +} + +export function createDesktopRuntimeHost( + profiles: DesktopProfileRepository, + testApplyInvoker?: DesktopRuntimeTestApplyInvoker +): DesktopRuntimeHost { + const core = createCoreFacade({ + resolveProfile: async (selector) => profiles.resolve(selector) + }); + + return Object.freeze({ + async dispatch( + request: CoreRequestEnvelope, + control: DesktopRuntimeDispatchControl = {} + ): Promise> { + let operationId: string | undefined; + try { + const handler = core[request.method] as ( + input: CoreMethodMap[M]["input"], + hostControl?: { + signal?: AbortSignal; + onOperationStarted?(value: { + operationId: string; + operation: "sync" | "switch" | "restore"; + }): void; + onProgress?(event: ProgressEvent): void; + } + ) => Promise; + const hostControl: Parameters[2] = { + ...(control.signal ? { signal: control.signal } : {}), + onOperationStarted(value) { + operationId = value.operationId; + control.onOperationStarted?.({ + protocolVersion: 1, + requestId: request.requestId, + operationId: value.operationId, + event: "operation-started", + operation: value.operation + }); + }, + onProgress(progress) { + if (!operationId) return; + control.onProgress?.({ + protocolVersion: 1, + requestId: request.requestId, + operationId, + event: "progress", + progress + }); + } + }; + const result: CoreMethodMap[M]["output"] = testApplyInvoker + && (request.method === "applySync" + || request.method === "applySwitch" + || request.method === "applyRestore") + ? await testApplyInvoker( + request.method, + request.payload as CoreMethodMap["applySync"]["input"], + hostControl + ) as CoreMethodMap[M]["output"] + : await handler(request.payload, hostControl); + const resultOperationId = result !== null + && typeof result === "object" + && "operationId" in result + && typeof result.operationId === "string" + ? result.operationId + : operationId; + return createCoreSuccessEnvelope(request, result, resultOperationId); + } catch (error) { + const dto = error instanceof Error + && error.name === "AbortError" + && (error as Error & { code?: unknown }).code === "ABORT_ERR" + ? createPublicCoreErrorDto("OPERATION_CANCELLED", { operationId }) + : sanitizePublicCoreErrorDto(error); + return createCoreFailureEnvelope(request, dto, operationId); + } + } + }); +} diff --git a/apps/desktop/src/runtime/index.ts b/apps/desktop/src/runtime/index.ts new file mode 100644 index 0000000..9161f8c --- /dev/null +++ b/apps/desktop/src/runtime/index.ts @@ -0,0 +1,165 @@ +import process from "node:process"; + +import { DESKTOP_RUNTIME_METHODS } from "@codex-provider-sync/core-client"; + +import { DesktopProfileRepository } from "../profiles/repository.js"; +import { + DESKTOP_BUILD_ID, + DESKTOP_CORE_PROTOCOL_VERSION, + DESKTOP_CORE_VERSION, + DESKTOP_RUNTIME_PROTOCOL_VERSION +} from "../shared/constants.js"; +import { + assertRuntimeCancelFrame, + assertRuntimeRequestFrame, + assertRuntimeShutdownFrame, + createRuntimeOperationEventFrame, + createRuntimeResponseFrame, + type RuntimeHelloFrame +} from "../shared/runtime-protocol.js"; +import { createDesktopRuntimeHost } from "./host.js"; + +interface UtilityParentPort { + postMessage(value: unknown): void; + on(event: "message", listener: (event: { data: unknown }) => void): void; +} + +interface ActiveDispatch { + requestId: string; + controller: AbortController; + operationId?: string; + task?: Promise; +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing desktop runtime bootstrap field: ${name}`); + return value; +} + +const parentPort = (process as NodeJS.Process & { parentPort?: UtilityParentPort }).parentPort; +if (!parentPort) throw new Error("Desktop Core Runtime requires an Electron Utility Process parent port."); + +const generation = Number(requiredEnvironment("CPS_DESKTOP_RUNTIME_GENERATION")); +if (!Number.isSafeInteger(generation) || generation < 1) { + throw new Error("Invalid desktop runtime generation."); +} + +const profiles = new DesktopProfileRepository({ + filePath: requiredEnvironment("CPS_DESKTOP_PROFILE_FILE"), + defaultCodexHome: requiredEnvironment("CPS_DESKTOP_DEFAULT_CODEX_HOME"), + ...(process.env.CPS_DESKTOP_DEFAULT_SQLITE_HOME + ? { defaultSqliteHome: process.env.CPS_DESKTOP_DEFAULT_SQLITE_HOME } + : {}) +}); +await profiles.initialize(); +const testApplyInvoker = __CPS_DESKTOP_TEST_BUILD__ + && process.env.CPS_DESKTOP_E2E === "1" + ? (await import("./e2e-gate.js")).createDesktopTestApplyInvoker() + : undefined; +const host = createDesktopRuntimeHost(profiles, testApplyInvoker); +const active = new Map(); +let shuttingDown = false; + +const hello: RuntimeHelloFrame = { + kind: "hello", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + coreProtocolVersion: DESKTOP_CORE_PROTOCOL_VERSION, + appVersion: requiredEnvironment("CPS_DESKTOP_APP_VERSION"), + coreVersion: DESKTOP_CORE_VERSION, + buildId: DESKTOP_BUILD_ID, + sessionNonce: requiredEnvironment("CPS_DESKTOP_RUNTIME_NONCE"), + generation, + capabilities: DESKTOP_RUNTIME_METHODS +}; +parentPort.postMessage(hello); + +async function beginShutdown(frame: unknown): Promise { + assertRuntimeShutdownFrame(frame); + if (frame.generation !== generation) throw new Error("Stale desktop runtime shutdown frame."); + shuttingDown = true; + for (const dispatch of active.values()) dispatch.controller.abort(); + await Promise.allSettled( + [...active.values()].map((dispatch) => dispatch.task).filter(Boolean) as Promise[] + ); + process.exit(0); +} + +function cancelDispatch(frame: unknown): void { + assertRuntimeCancelFrame(frame); + if (frame.generation !== generation) throw new Error("Stale desktop runtime cancel frame."); + const dispatch = active.get(frame.dispatchId); + if (!dispatch || dispatch.requestId !== frame.requestId) { + throw new Error("Unknown desktop runtime cancel target."); + } + if (frame.operationId !== undefined && dispatch.operationId !== frame.operationId) { + throw new Error("Desktop runtime cancel operationId mismatch."); + } + dispatch.controller.abort(); +} + +function startDispatch(frame: unknown): void { + assertRuntimeRequestFrame(frame); + if (shuttingDown) throw new Error("Desktop runtime is shutting down."); + if (frame.generation !== generation) throw new Error("Stale desktop runtime request frame."); + if (active.has(frame.dispatchId)) throw new Error("Duplicate desktop runtime dispatchId."); + const dispatch: ActiveDispatch = { + requestId: frame.envelope.requestId, + controller: new AbortController() + }; + active.set(frame.dispatchId, dispatch); + const task = host.dispatch(frame.envelope, { + signal: dispatch.controller.signal, + onOperationStarted(envelope) { + if (dispatch.operationId !== undefined) { + throw new Error("Duplicate desktop operation-started event."); + } + dispatch.operationId = envelope.operationId; + parentPort.postMessage(createRuntimeOperationEventFrame( + generation, + frame.dispatchId, + envelope + )); + }, + onProgress(envelope) { + if (!dispatch.operationId || envelope.operationId !== dispatch.operationId) { + throw new Error("Desktop progress event preceded operation-started."); + } + parentPort.postMessage(createRuntimeOperationEventFrame( + generation, + frame.dispatchId, + envelope + )); + } + }).then((response) => { + parentPort.postMessage(createRuntimeResponseFrame( + generation, + frame.dispatchId, + response + )); + }).finally(() => { + active.delete(frame.dispatchId); + }); + dispatch.task = task; + void task.catch(() => process.exit(70)); +} + +parentPort.on("message", (event) => { + try { + const frame = event.data; + const kind = frame !== null && typeof frame === "object" && !Array.isArray(frame) + ? (frame as { kind?: unknown }).kind + : undefined; + if (kind === "shutdown") { + void beginShutdown(frame).catch(() => process.exit(70)); + return; + } + if (kind === "cancel") { + cancelDispatch(frame); + return; + } + startDispatch(frame); + } catch { + process.exit(70); + } +}); diff --git a/apps/desktop/src/shared/bridge.ts b/apps/desktop/src/shared/bridge.ts new file mode 100644 index 0000000..b80768d --- /dev/null +++ b/apps/desktop/src/shared/bridge.ts @@ -0,0 +1,36 @@ +import type { + CoreRequestEnvelope, + CoreResponseEnvelope +} from "@codex-provider-sync/contracts"; +import type { + DesktopCoreBridge, + DesktopReadMethod +} from "@codex-provider-sync/core-client"; + +import type { DesktopProfileListResponse } from "./profile-types.js"; +import type { + DesktopDiagnosticsExportInput, + DesktopDiagnosticsExportResult +} from "./diagnostics-types.js"; +import type { DesktopUpdateStatus } from "./update-types.js"; + +export interface DesktopBridgeApi { + readonly version: 1; + readonly core: DesktopCoreBridge; + readonly profiles: { + list(): Promise; + }; + readonly diagnostics: { + export(input: DesktopDiagnosticsExportInput): Promise; + }; + readonly updates: { + getStatus(): Promise; + check(): Promise; + download(): Promise; + install(): Promise; + }; + readonly test?: { + crashRuntime(): Promise<{ crashed: boolean }>; + requestRaw(envelope: CoreRequestEnvelope): Promise; + }; +} diff --git a/apps/desktop/src/shared/build-mode.d.ts b/apps/desktop/src/shared/build-mode.d.ts new file mode 100644 index 0000000..1e08b0b --- /dev/null +++ b/apps/desktop/src/shared/build-mode.d.ts @@ -0,0 +1,4 @@ +declare const __CPS_DESKTOP_TEST_BUILD__: boolean; +declare const __CPS_DESKTOP_FORCE_BETTER_SQLITE3__: boolean; +declare const __CPS_DESKTOP_BUILD_ID__: string; +declare const __CPS_DESKTOP_RELEASE_AUTHORIZED__: boolean; diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts new file mode 100644 index 0000000..3954dbe --- /dev/null +++ b/apps/desktop/src/shared/constants.ts @@ -0,0 +1,41 @@ +import { CORE_PROTOCOL_VERSION } from "@codex-provider-sync/contracts"; + +export const DESKTOP_RUNTIME_PROTOCOL_VERSION = 2 as const; +export const DESKTOP_CORE_PROTOCOL_VERSION = CORE_PROTOCOL_VERSION; +export const DESKTOP_CORE_VERSION = "0.0.0" as const; +export const DESKTOP_BUILD_ID = typeof __CPS_DESKTOP_BUILD_ID__ === "string" + ? __CPS_DESKTOP_BUILD_ID__ + : "dev-c9"; +export const DESKTOP_APP_SCHEME = "cps-app" as const; +export const DESKTOP_APP_HOST = "app" as const; +export const DESKTOP_APP_ORIGIN = `${DESKTOP_APP_SCHEME}://${DESKTOP_APP_HOST}` as const; +export const MAX_DESKTOP_IPC_BYTES = 64 * 1024; + +export const DESKTOP_IPC_CHANNELS = Object.freeze({ + coreRead: "cps:v1:core:read", + coreSyncSwitch: "cps:v1:core:sync-switch", + coreRestore: "cps:v1:core:restore", + coreMaintenance: "cps:v1:core:maintenance", + operationEvent: "cps:v1:operation:event", + operationCancel: "cps:v1:operation:cancel", + profilesList: "cps:v1:profiles:list", + diagnosticsExport: "cps:v1:diagnostics:export", + updateStatus: "cps:v1:update:status", + updateCheck: "cps:v1:update:check", + updateDownload: "cps:v1:update:download", + updateInstall: "cps:v1:update:install" +}); + +export const DESKTOP_CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "object-src 'none'", + "frame-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" +].join("; "); diff --git a/apps/desktop/src/shared/diagnostics-types.ts b/apps/desktop/src/shared/diagnostics-types.ts new file mode 100644 index 0000000..718b510 --- /dev/null +++ b/apps/desktop/src/shared/diagnostics-types.ts @@ -0,0 +1,23 @@ +import type { ProfileSelector } from "@codex-provider-sync/contracts"; + +export interface DesktopDiagnosticsExportInput { + schemaVersion: 1; + profile: ProfileSelector; +} + +export type DesktopDiagnosticsExportResult = + | { + schemaVersion: 1; + status: "created"; + artifactId: string; + createdAt: string; + } + | { + schemaVersion: 1; + status: "cancelled"; + } + | { + schemaVersion: 1; + status: "failed"; + reason: "runtime-unavailable" | "invalid-snapshot" | "write-failed"; + }; diff --git a/apps/desktop/src/shared/profile-types.ts b/apps/desktop/src/shared/profile-types.ts new file mode 100644 index 0000000..024fafb --- /dev/null +++ b/apps/desktop/src/shared/profile-types.ts @@ -0,0 +1,20 @@ +export interface TrustedDesktopProfile { + id: string; + name: string; + revision: string; + codexHome: string; + sqliteHome?: string; +} + +export interface DesktopProfileSummary { + id: string; + name: string; + revision: string; + codexHomeConfigured: boolean; + sqliteHomeConfigured: boolean; +} + +export interface DesktopProfileListResponse { + schemaVersion: 1; + profiles: DesktopProfileSummary[]; +} diff --git a/apps/desktop/src/shared/runtime-protocol.ts b/apps/desktop/src/shared/runtime-protocol.ts new file mode 100644 index 0000000..9a65b59 --- /dev/null +++ b/apps/desktop/src/shared/runtime-protocol.ts @@ -0,0 +1,293 @@ +import { + assertCoreOperationEventEnvelope, + assertCoreRequestEnvelope, + assertCoreResponseEnvelope, + type CoreMethodName, + type CoreOperationEventEnvelope, + type CoreRequestEnvelope, + type CoreResponseEnvelope +} from "@codex-provider-sync/contracts"; +import { + DESKTOP_RUNTIME_METHODS, + isDesktopRuntimeMethod, + type DesktopRuntimeMethod +} from "@codex-provider-sync/core-client"; + +import { + DESKTOP_CORE_PROTOCOL_VERSION, + DESKTOP_RUNTIME_PROTOCOL_VERSION +} from "./constants.js"; + +export interface RuntimeHelloFrame { + kind: "hello"; + runtimeProtocolVersion: typeof DESKTOP_RUNTIME_PROTOCOL_VERSION; + coreProtocolVersion: typeof DESKTOP_CORE_PROTOCOL_VERSION; + appVersion: string; + coreVersion: string; + buildId: string; + sessionNonce: string; + generation: number; + capabilities: readonly DesktopRuntimeMethod[]; +} + +export interface RuntimeRequestFrame { + kind: "request"; + runtimeProtocolVersion: typeof DESKTOP_RUNTIME_PROTOCOL_VERSION; + generation: number; + dispatchId: string; + envelope: CoreRequestEnvelope; +} + +export interface RuntimeResponseFrame { + kind: "response"; + runtimeProtocolVersion: typeof DESKTOP_RUNTIME_PROTOCOL_VERSION; + generation: number; + dispatchId: string; + envelope: CoreResponseEnvelope; +} + +export interface RuntimeOperationEventFrame { + kind: "operation-event"; + runtimeProtocolVersion: typeof DESKTOP_RUNTIME_PROTOCOL_VERSION; + generation: number; + dispatchId: string; + envelope: CoreOperationEventEnvelope; +} + +export interface RuntimeCancelFrame { + kind: "cancel"; + runtimeProtocolVersion: typeof DESKTOP_RUNTIME_PROTOCOL_VERSION; + generation: number; + dispatchId: string; + requestId: string; + operationId?: string; +} + +export interface RuntimeShutdownFrame { + kind: "shutdown"; + runtimeProtocolVersion: typeof DESKTOP_RUNTIME_PROTOCOL_VERSION; + generation: number; +} + +export type RuntimeFrame = + | RuntimeHelloFrame + | RuntimeRequestFrame + | RuntimeResponseFrame + | RuntimeOperationEventFrame + | RuntimeCancelFrame + | RuntimeShutdownFrame; + +export interface ExpectedRuntimeIdentity { + appVersion: string; + coreVersion: string; + buildId: string; + sessionNonce: string; + generation: number; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasExactKeys(value: Record, allowed: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...allowed].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function isBoundedString(value: unknown, maximum = 256): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maximum; +} + +function assertGeneration(value: unknown): asserts value is number { + if (!Number.isSafeInteger(value) || Number(value) < 1) { + throw new TypeError("Invalid desktop runtime generation."); + } +} + +function assertDispatchId(value: unknown): asserts value is string { + if (typeof value !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + throw new TypeError("Invalid desktop runtime dispatchId."); + } +} + +export function assertRuntimeHelloFrame( + value: unknown, + expected: ExpectedRuntimeIdentity +): asserts value is RuntimeHelloFrame { + if (!isRecord(value) + || !hasExactKeys(value, [ + "kind", "runtimeProtocolVersion", "coreProtocolVersion", "appVersion", + "coreVersion", "buildId", "sessionNonce", "generation", "capabilities" + ]) + || value.kind !== "hello" + || value.runtimeProtocolVersion !== DESKTOP_RUNTIME_PROTOCOL_VERSION + || value.coreProtocolVersion !== DESKTOP_CORE_PROTOCOL_VERSION + || !isBoundedString(value.appVersion) + || !isBoundedString(value.coreVersion) + || !isBoundedString(value.buildId) + || !/^[a-f0-9]{32,128}$/.test(String(value.sessionNonce)) + || !Array.isArray(value.capabilities)) { + throw new TypeError("Invalid desktop runtime hello frame."); + } + assertGeneration(value.generation); + if (value.appVersion !== expected.appVersion + || value.coreVersion !== expected.coreVersion + || value.buildId !== expected.buildId + || value.sessionNonce !== expected.sessionNonce + || value.generation !== expected.generation + || value.capabilities.length !== DESKTOP_RUNTIME_METHODS.length + || value.capabilities.some((method, index) => method !== DESKTOP_RUNTIME_METHODS[index])) { + throw new TypeError("Desktop runtime identity is incompatible."); + } +} + +export function assertRuntimeRequestFrame(value: unknown): asserts value is RuntimeRequestFrame { + if (!isRecord(value) + || !hasExactKeys(value, ["kind", "runtimeProtocolVersion", "generation", "dispatchId", "envelope"]) + || value.kind !== "request" + || value.runtimeProtocolVersion !== DESKTOP_RUNTIME_PROTOCOL_VERSION) { + throw new TypeError("Invalid desktop runtime request frame."); + } + assertGeneration(value.generation); + assertDispatchId(value.dispatchId); + assertCoreRequestEnvelope(value.envelope); + if (!isDesktopRuntimeMethod(value.envelope.method)) { + throw new TypeError("Desktop runtime method is not allowed."); + } +} + +export function assertRuntimeResponseFrame( + value: unknown, + expected?: { dispatchId?: string; requestId?: string } +): asserts value is RuntimeResponseFrame { + if (!isRecord(value) + || !hasExactKeys(value, ["kind", "runtimeProtocolVersion", "generation", "dispatchId", "envelope"]) + || value.kind !== "response" + || value.runtimeProtocolVersion !== DESKTOP_RUNTIME_PROTOCOL_VERSION) { + throw new TypeError("Invalid desktop runtime response frame."); + } + assertGeneration(value.generation); + assertDispatchId(value.dispatchId); + if (expected?.dispatchId !== undefined && value.dispatchId !== expected.dispatchId) { + throw new TypeError("Desktop runtime response dispatchId mismatch."); + } + assertCoreResponseEnvelope(value.envelope, expected?.requestId); +} + +export function assertRuntimeOperationEventFrame( + value: unknown, + expected?: { dispatchId?: string; requestId?: string; operationId?: string } +): asserts value is RuntimeOperationEventFrame { + if (!isRecord(value) + || !hasExactKeys(value, ["kind", "runtimeProtocolVersion", "generation", "dispatchId", "envelope"]) + || value.kind !== "operation-event" + || value.runtimeProtocolVersion !== DESKTOP_RUNTIME_PROTOCOL_VERSION) { + throw new TypeError("Invalid desktop runtime operation event frame."); + } + assertGeneration(value.generation); + assertDispatchId(value.dispatchId); + if (expected?.dispatchId !== undefined && value.dispatchId !== expected.dispatchId) { + throw new TypeError("Desktop runtime operation event dispatchId mismatch."); + } + assertCoreOperationEventEnvelope(value.envelope, expected?.requestId, expected?.operationId); +} + +export function assertRuntimeCancelFrame(value: unknown): asserts value is RuntimeCancelFrame { + if (!isRecord(value) + || !hasExactKeys(value, [ + "kind", "runtimeProtocolVersion", "generation", "dispatchId", "requestId", + ...(value.operationId === undefined ? [] : ["operationId"]) + ]) + || value.kind !== "cancel" + || value.runtimeProtocolVersion !== DESKTOP_RUNTIME_PROTOCOL_VERSION + || !isBoundedString(value.requestId, 512) + || (value.operationId !== undefined + && (typeof value.operationId !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.operationId)))) { + throw new TypeError("Invalid desktop runtime cancel frame."); + } + assertGeneration(value.generation); + assertDispatchId(value.dispatchId); +} + +export function assertRuntimeShutdownFrame(value: unknown): asserts value is RuntimeShutdownFrame { + if (!isRecord(value) + || !hasExactKeys(value, ["kind", "runtimeProtocolVersion", "generation"]) + || value.kind !== "shutdown" + || value.runtimeProtocolVersion !== DESKTOP_RUNTIME_PROTOCOL_VERSION) { + throw new TypeError("Invalid desktop runtime shutdown frame."); + } + assertGeneration(value.generation); +} + +export function createRuntimeRequestFrame( + generation: number, + dispatchId: string, + envelope: CoreRequestEnvelope +): RuntimeRequestFrame { + const frame: RuntimeRequestFrame = { + kind: "request", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + generation, + dispatchId, + envelope + }; + assertRuntimeRequestFrame(frame); + return frame; +} + +export function createRuntimeResponseFrame( + generation: number, + dispatchId: string, + envelope: CoreResponseEnvelope +): RuntimeResponseFrame { + const frame: RuntimeResponseFrame = { + kind: "response", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + generation, + dispatchId, + envelope + }; + assertRuntimeResponseFrame(frame); + return frame; +} + +export function createRuntimeOperationEventFrame( + generation: number, + dispatchId: string, + envelope: CoreOperationEventEnvelope +): RuntimeOperationEventFrame { + const frame: RuntimeOperationEventFrame = { + kind: "operation-event", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + generation, + dispatchId, + envelope + }; + assertRuntimeOperationEventFrame(frame); + return frame; +} + +export function createRuntimeCancelFrame( + generation: number, + dispatchId: string, + requestId: string, + operationId?: string +): RuntimeCancelFrame { + const frame: RuntimeCancelFrame = { + kind: "cancel", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + generation, + dispatchId, + requestId, + ...(operationId ? { operationId } : {}) + }; + assertRuntimeCancelFrame(frame); + return frame; +} + +export function isRuntimeCoreMethod(value: CoreMethodName): value is DesktopRuntimeMethod { + return isDesktopRuntimeMethod(value); +} diff --git a/apps/desktop/src/shared/update-types.ts b/apps/desktop/src/shared/update-types.ts new file mode 100644 index 0000000..011e991 --- /dev/null +++ b/apps/desktop/src/shared/update-types.ts @@ -0,0 +1,35 @@ +export type DesktopUpdateState = + | "disabled" + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded" + | "not-available" + | "error" + | "installing"; + +export type DesktopUpdateReason = + | "not-packaged" + | "not-authorized" + | "not-configured" + | "unsupported-target" + | "check-failed" + | "download-failed" + | "install-failed"; + +export type DesktopUpdateInstallBlockedReason = + | "write-in-progress" + | "watch-active" + | "pending-recovery" + | "recovery-unverified"; + +export type DesktopUpdateStatus = { + schemaVersion: 2; + state: DesktopUpdateState; + installAllowed: boolean; + reason?: DesktopUpdateReason; + version?: string; + progressPercent?: number; + installBlockedReason?: DesktopUpdateInstallBlockedReason; +}; diff --git a/apps/desktop/tests/diagnostics-export.test.mjs b/apps/desktop/tests/diagnostics-export.test.mjs new file mode 100644 index 0000000..84dd9d8 --- /dev/null +++ b/apps/desktop/tests/diagnostics-export.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { DesktopDiagnosticsExporter } from "../dist/main/diagnostics-export.js"; + +function readStoredEntries(archive) { + const entries = new Map(); + let offset = 0; + while (offset + 30 <= archive.length && archive.readUInt32LE(offset) === 0x04034b50) { + const size = archive.readUInt32LE(offset + 18); + const nameLength = archive.readUInt16LE(offset + 26); + const extraLength = archive.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + const name = archive.subarray(nameStart, nameStart + nameLength).toString("utf8"); + entries.set(name, archive.subarray(dataStart, dataStart + size)); + offset = dataStart + size; + } + return entries; +} + +function diagnosticsSnapshot() { + return { + schemaVersion: 1, + generatedAt: "2026-08-27T00:00:00.000Z", + runtime: { node: "v24.0.0", platform: "win32", arch: "x64" }, + storage: { sqliteHomeSource: "default", stateDbFound: true, sqliteSupported: true }, + provider: { + current: "openai", + implicit: false, + configured: ["openai"], + rolloutCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + sqliteCounts: { sessions: { openai: 1 }, archived_sessions: {} } + }, + safety: { + storageRevision: "safe-revision", + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutCount: 0, + projectThreadVisibilityAvailable: true + } + }; +} + +test("diagnostics exporter writes one valid fixed-entry redacted ZIP through a one-shot token", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-diagnostics-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const target = path.join(root, "diagnostics.zip"); + const exporter = new DesktopDiagnosticsExporter({ + appVersion: "1.0.0-test", + isPackaged: false, + now: () => new Date("2026-08-27T00:00:00.000Z") + }); + const token = exporter.authorizeTarget(target); + assert.equal(token.includes(root), false); + const result = await exporter.export(token, diagnosticsSnapshot()); + assert.deepEqual(Object.keys(result).sort(), ["artifactId", "createdAt", "schemaVersion", "status"]); + assert.equal(result.status, "created"); + assert.equal(JSON.stringify(result).includes(root), false); + + const archive = await fs.readFile(target); + const entries = readStoredEntries(archive); + assert.deepEqual([...entries.keys()], [ + "app-info.json", + "status-summary.json", + "storage-layout.json", + "pending-transaction-summary.json", + "recent-redacted-logs/README.txt" + ]); + const text = archive.toString("utf8"); + assert.equal(text.includes(root), false); + assert.doesNotMatch(text, /auth\.json|encrypted_content|message body sentinel|state_5\.sqlite|rollout-.*\.jsonl/i); + assert.equal((await exporter.export(token, diagnosticsSnapshot())).status, "failed"); +}); + +test("diagnostics exporter rejects malformed snapshots and never accepts Renderer paths", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-diagnostics-invalid-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const exporter = new DesktopDiagnosticsExporter({ appVersion: "test", isPackaged: false }); + assert.throws(() => exporter.authorizeTarget("relative.zip"), /absolute path/); + const target = path.join(root, "invalid.zip"); + const token = exporter.authorizeTarget(target); + const malformed = diagnosticsSnapshot(); + malformed.storage = { ...malformed.storage, path: "C:\\secret" }; + assert.deepEqual(await exporter.export(token, malformed), { + schemaVersion: 1, + status: "failed", + reason: "invalid-snapshot" + }); + await assert.rejects(fs.access(target)); +}); + +test("diagnostics capabilities are bounded, expire lazily, reserve targets and remain one-shot", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-diagnostics-capabilities-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + let now = new Date("2026-08-27T00:00:00.000Z"); + const exporter = new DesktopDiagnosticsExporter({ + appVersion: "test", + isPackaged: false, + now: () => now + }); + const firstTarget = path.join(root, "first.zip"); + const first = exporter.authorizeTarget(firstTarget); + assert.throws(() => exporter.authorizeTarget(firstTarget), /already reserved/); + for (let index = 1; index < 32; index += 1) { + exporter.authorizeTarget(path.join(root, `pending-${index}.zip`)); + } + assert.throws( + () => exporter.authorizeTarget(path.join(root, "overflow.zip")), + /Too many pending/ + ); + + now = new Date("2026-08-27T00:05:00.001Z"); + const replacement = exporter.authorizeTarget(firstTarget); + assert.notEqual(replacement, first); + assert.deepEqual(await exporter.export(first, diagnosticsSnapshot()), { + schemaVersion: 1, + status: "failed", + reason: "write-failed" + }); + + const concurrent = await Promise.all([ + exporter.export(replacement, diagnosticsSnapshot()), + exporter.export(replacement, diagnosticsSnapshot()) + ]); + assert.equal(concurrent.filter((result) => result.status === "created").length, 1); + assert.equal(concurrent.filter((result) => result.status === "failed").length, 1); + assert.equal((await fs.stat(firstTarget)).isFile(), true); + + const afterCompletion = exporter.authorizeTarget(firstTarget); + exporter.revoke(afterCompletion); + const afterRevoke = exporter.authorizeTarget(firstTarget); + exporter.revoke(afterRevoke); +}); + +test("diagnostics exporter serializes physical destinations reached through path aliases", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-diagnostics-alias-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const realParent = path.join(root, "real"); + const aliasParent = path.join(root, "alias"); + const secondAliasParent = path.join(root, "alias-two"); + await fs.mkdir(realParent); + try { + await fs.symlink(realParent, aliasParent, process.platform === "win32" ? "junction" : "dir"); + await fs.symlink(realParent, secondAliasParent, process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + t.skip(`path aliases are unavailable: ${error instanceof Error ? error.message : String(error)}`); + return; + } + + const exporter = new DesktopDiagnosticsExporter({ appVersion: "test", isPackaged: false }); + const direct = exporter.authorizeTarget(path.join(realParent, "diagnostics.zip")); + const alias = exporter.authorizeTarget(path.join(aliasParent, "diagnostics.zip")); + const secondAlias = exporter.authorizeTarget(path.join(secondAliasParent, "diagnostics.zip")); + const results = await Promise.all([ + exporter.export(direct, diagnosticsSnapshot()), + exporter.export(alias, diagnosticsSnapshot()), + exporter.export(secondAlias, diagnosticsSnapshot()) + ]); + assert.equal(results.filter((result) => result.status === "created").length, 1); + assert.equal(results.filter((result) => result.status === "failed").length, 2); + assert.equal((await fs.stat(path.join(realParent, "diagnostics.zip"))).isFile(), true); +}); diff --git a/apps/desktop/tests/ipc-router.test.mjs b/apps/desktop/tests/ipc-router.test.mjs new file mode 100644 index 0000000..71e6768 --- /dev/null +++ b/apps/desktop/tests/ipc-router.test.mjs @@ -0,0 +1,802 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createCoreOperationStartedEnvelope, + createCoreProgressEnvelope, + createCoreRequestEnvelope, + createCoreSuccessEnvelope +} from "@codex-provider-sync/contracts"; + +import { registerDesktopIpc } from "../dist/main/ipc-router.js"; +import { DesktopDiagnosticsExporter } from "../dist/main/diagnostics-export.js"; +import { DESKTOP_IPC_CHANNELS } from "../dist/shared/constants.js"; + +const profile = { profileId: "default", profileRevision: "r1" }; +const operationId = "11111111-1111-4111-8111-111111111111"; + +function planResult(request) { + const operation = request.method === "prepareSwitch" + ? "switch" + : request.method === "prepareRestore" + ? "restore" + : "sync"; + const planId = `${operation}-${Buffer.from(request.requestId).toString("base64url")}` + .padEnd(40, "p") + .slice(0, 128); + return { + schemaVersion: 1, + planId, + operation, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + profile: { id: profile.profileId, revision: profile.profileRevision }, + storageRevision: "storage", + configRevision: "config", + rolloutRevision: "rollout", + stateDbRevision: "db", + ...(operation === "restore" ? { backupRevision: "backup" } : {}), + target: operation === "restore" + ? { backupId: request.payload.backupId } + : { provider: operation === "sync" ? "openai" : request.payload.provider }, + impact: { backupExpected: true }, + warnings: [], + requiresConfirmation: true + }; +} + +function harness({ + holdApply = false, + diagnosticsSnapshot = null, + diagnosticsExporter = null, + diagnosticsTarget = "D:\\safe\\diagnostics.zip", + updateRestartPending = false +} = {}) { + const handlers = new Map(); + const ipcMain = { + handle(channel, handler) { + assert.equal(handlers.has(channel), false); + handlers.set(channel, handler); + }, + removeHandler(channel) { handlers.delete(channel); } + }; + const frame = { url: "cps-app://app/index.html" }; + const sent = []; + const webContents = { + id: 7, + mainFrame: frame, + send(channel, value) { sent.push({ channel, value }); } + }; + const window = { webContents, isDestroyed: () => false }; + const event = { sender: webContents, senderFrame: frame }; + const calls = []; + const cancellations = []; + const diagnosticExports = []; + let selectedTargets = 0; + let updateCalls = 0; + const pendingApplies = []; + const listeners = new Set(); + const activeWatchCounts = []; + const supervisor = { + snapshot: { + state: "ready", + generation: 1, + recoveryBlocked: false, + writeInProgress: false, + lastHandshakeAt: null + }, + subscribeOperation(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + async request(request) { + calls.push({ kind: "read", request }); + if (request.method === "getDiagnostics") { + const validSnapshot = { + schemaVersion: 1, + generatedAt: "2026-08-26T00:00:00.000Z", + runtime: { node: "v24", platform: "win32", arch: "x64" }, + storage: { sqliteHomeSource: "default", stateDbFound: true, sqliteSupported: true }, + provider: { + current: "openai", + implicit: false, + configured: ["openai"], + rolloutCounts: { sessions: {}, archived_sessions: {} }, + sqliteCounts: { sessions: {}, archived_sessions: {} } + }, + safety: { + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutCount: 0, + projectThreadVisibilityAvailable: true + } + }; + const envelope = createCoreSuccessEnvelope(request, validSnapshot); + return diagnosticsSnapshot ? { ...envelope, result: diagnosticsSnapshot } : envelope; + } + return createCoreSuccessEnvelope(request, { + schemaVersion: 1, + snapshotAt: "2026-08-26T00:00:00.000Z", + storageRevision: "storage", + profile: { id: "default", revision: "r1" }, + currentProvider: "openai", + rolloutCounts: {}, + sqliteCounts: {}, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [] + }); + }, + async requestWrite(request, selectedProfile) { + calls.push({ kind: "write", request, profile: selectedProfile }); + if (request.method === "prepareSync" || request.method === "prepareSwitch") { + return createCoreSuccessEnvelope(request, planResult(request)); + } + const operation = request.method === "applySync" ? "sync" : "switch"; + for (const listener of listeners) listener(createCoreOperationStartedEnvelope( + request.requestId, + operationId, + operation + )); + for (const listener of listeners) listener(createCoreProgressEnvelope( + request.requestId, + operationId, + { stage: "create_backup", status: "start" } + )); + const complete = () => createCoreSuccessEnvelope(request, { + schemaVersion: 1, + operationId, + operation, + outcome: "completed", + backup: { backupId: "managed-backup" }, + warnings: [], + result: { targetProvider: "openai" } + }, operationId); + if (!holdApply) return complete(); + return new Promise((resolve) => pendingApplies.push(() => resolve(complete()))); + }, + async requestManaged(request, selectedProfile, options) { + calls.push({ kind: "managed", request, profile: selectedProfile, options }); + if (request.method === "prepareRestore") { + return createCoreSuccessEnvelope(request, planResult(request)); + } + if (request.method === "applyRestore") { + for (const listener of listeners) listener(createCoreOperationStartedEnvelope( + request.requestId, + operationId, + "restore" + )); + return createCoreSuccessEnvelope(request, { + schemaVersion: 1, + operationId, + operation: "restore", + outcome: "completed", + backup: { backupId: "pre-restore-snapshot" }, + warnings: [], + result: { sourceBackupId: "managed" } + }, operationId); + } + if (request.method === "pruneBackups") { + return createCoreSuccessEnvelope(request, { + deletedCount: 1, + remainingCount: request.payload.keepCount, + freedBytes: 1024 + }); + } + const watch = { + schemaVersion: 1, + watchId: request.payload.watchId ?? "22222222-2222-4222-8222-222222222222", + status: request.method === "stopWatch" ? "stopped" : "running", + startedAt: "2026-08-26T00:00:00.000Z", + stoppedAt: request.method === "stopWatch" ? "2026-08-26T00:01:00.000Z" : null, + stopReason: request.method === "stopWatch" ? "manual" : null, + includeStateDb: true, + once: false + }; + return createCoreSuccessEnvelope( + request, + request.method === "getWatchStatus" && !request.payload.watchId + ? { schemaVersion: 1, watches: [watch] } + : watch + ); + }, + cancel(requestId, selectedOperationId) { + cancellations.push({ requestId, operationId: selectedOperationId }); + return true; + } + }; + const cleanup = registerDesktopIpc({ + ipcMain, + getWindow: () => window, + rendererOrigin: "cps-app://app", + profiles: { + list: () => [{ + id: "default", + name: "Default", + revision: "r1", + codexHomeConfigured: true, + sqliteHomeConfigured: false + }] + }, + supervisor, + diagnosticsExporter: diagnosticsExporter ?? { + authorizeTarget(target) { + assert.equal(target, diagnosticsTarget); + return "main-only-token"; + }, + async export(token, snapshot) { + diagnosticExports.push({ token, snapshot }); + return { + schemaVersion: 1, + status: "created", + artifactId: "33333333-3333-4333-8333-333333333333", + createdAt: "2026-08-26T00:00:00.000Z" + }; + } + }, + async selectDiagnosticsTarget() { + selectedTargets += 1; + return diagnosticsTarget; + }, + updates: { + restartPending: updateRestartPending, + get status() { + updateCalls += 1; + return { + schemaVersion: 2, + state: "disabled", + reason: "not-configured", + installAllowed: false + }; + }, + async check() { + updateCalls += 1; + return { schemaVersion: 2, state: "checking", installAllowed: false }; + }, + async download() { + updateCalls += 1; + return { schemaVersion: 2, state: "downloading", version: "1.0.1", progressPercent: 0, installAllowed: false }; + }, + async install() { + updateCalls += 1; + return { schemaVersion: 2, state: "installing", version: "1.0.1", installAllowed: false }; + } + }, + onActiveWatchCountChanged(count) { activeWatchCounts.push(count); } + }); + return { + handlers, + event, + calls, + cancellations, + pendingApplies, + sent, + supervisor, + diagnosticExports, + activeWatchCounts, + get selectedTargets() { return selectedTargets; }, + get updateCalls() { return updateCalls; }, + cleanup + }; +} + +test("read IPC accepts only a top-level local sender and a validated read method", async () => { + const value = harness(); + try { + const request = createCoreRequestEnvelope("getStatus", { profile }, "ipc-status"); + const response = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreRead)(value.event, request); + assert.equal(response.ok, true); + const evilFrame = { url: "cps-app://evil/index.html" }; + const denied = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreRead)( + { sender: value.event.sender, senderFrame: evilFrame }, + request + ); + assert.equal(denied.error.code, "PERMISSION_DENIED"); + assert.equal(value.calls.length, 1); + } finally { value.cleanup(); } +}); + +test("Sync Prepare and same-method Apply use a one-time Main-owned plan", async () => { + const value = harness(); + try { + const prepare = createCoreRequestEnvelope( + "prepareSync", + { profile, keepCount: 5 }, + "ipc-prepare" + ); + const prepared = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)(value.event, prepare); + assert.equal(prepared.ok, true); + const apply = createCoreRequestEnvelope( + "applySync", + { schemaVersion: 1, planId: prepared.result.planId }, + "ipc-apply" + ); + const applied = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)(value.event, apply); + assert.equal(applied.ok, true); + assert.equal(applied.result.operationId, operationId); + assert.deepEqual(value.sent.map((entry) => entry.channel), [ + DESKTOP_IPC_CHANNELS.operationEvent, + DESKTOP_IPC_CHANNELS.operationEvent + ]); + const replay = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)(value.event, apply); + assert.equal(replay.ok, false); + assert.equal(replay.error.code, "PLAN_EXPIRED"); + assert.deepEqual(value.calls.map((call) => call.request.method), ["prepareSync", "applySync"]); + } finally { value.cleanup(); } +}); + +test("a Sync plan cannot be consumed by ApplySwitch", async () => { + const value = harness(); + try { + const prepared = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope("prepareSync", { profile, keepCount: 5 }, "prepare-cross") + ); + const denied = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope( + "applySwitch", + { schemaVersion: 1, planId: prepared.result.planId }, + "apply-cross" + ) + ); + assert.equal(denied.error.code, "PLAN_EXPIRED"); + assert.equal(value.calls.length, 1); + } finally { value.cleanup(); } +}); + +test("Restore Prepare and Apply use a one-time Main-owned recovery plan", async () => { + const value = harness(); + try { + const prepared = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreRestore)( + value.event, + createCoreRequestEnvelope("prepareRestore", { + profile, + backupId: "managed", + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true + }, "restore-prepare") + ); + assert.equal(prepared.ok, true); + assert.equal(prepared.result.operation, "restore"); + const apply = createCoreRequestEnvelope( + "applyRestore", + { schemaVersion: 1, planId: prepared.result.planId }, + "restore-apply" + ); + const applied = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreRestore)(value.event, apply); + assert.equal(applied.ok, true); + assert.equal(applied.result.operation, "restore"); + assert.equal(value.sent[0].value.operation, "restore"); + assert.equal(value.calls[0].options.allowRecoveryBlocked, true); + assert.equal(value.calls[1].options.allowRecoveryBlocked, true); + assert.equal( + (await value.handlers.get(DESKTOP_IPC_CHANNELS.coreRestore)(value.event, apply)).error.code, + "PLAN_EXPIRED" + ); + } finally { value.cleanup(); } +}); + +test("Maintenance owns Watch IDs and keeps Prune available during recovery", async () => { + const value = harness(); + try { + const prune = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("pruneBackups", { profile, keepCount: 5 }, "prune") + ); + assert.equal(prune.ok, true); + assert.equal(value.calls[0].options.allowRecoveryBlocked, true); + const started = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "watch-start") + ); + assert.equal(started.result.status, "running"); + assert.equal(value.calls[1].options.allowRecoveryBlocked, false); + const status = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("getWatchStatus", { watchId: started.result.watchId }, "watch-status") + ); + assert.equal(status.ok, true); + assert.equal("isWrite" in value.calls[2].options, false); + const evilFrame = { url: "cps-app://evil/index.html" }; + const denied = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + { sender: value.event.sender, senderFrame: evilFrame }, + createCoreRequestEnvelope("stopWatch", { watchId: started.result.watchId }, "watch-evil-stop") + ); + assert.equal(denied.error.code, "PERMISSION_DENIED"); + const stopped = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("stopWatch", { watchId: started.result.watchId }, "watch-stop") + ); + assert.equal(stopped.result.status, "stopped"); + assert.equal(value.calls[3].options.allowRecoveryBlocked, true); + } finally { value.cleanup(); } +}); + +test("Watch status reconciliation removes an autonomously stopped Watch from Main ownership", async () => { + const value = harness(); + try { + const started = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "watch-auto-start") + ); + assert.equal(started.ok, true); + assert.equal(value.activeWatchCounts.at(-1), 1); + const originalRequestManaged = value.supervisor.requestManaged; + value.supervisor.requestManaged = async (request, selectedProfile, options) => { + if (request.method !== "getWatchStatus") { + return originalRequestManaged(request, selectedProfile, options); + } + value.calls.push({ kind: "managed", request, profile: selectedProfile, options }); + return createCoreSuccessEnvelope(request, { + schemaVersion: 1, + watchId: started.result.watchId, + status: "stopped", + startedAt: "2026-08-26T00:00:00.000Z", + stoppedAt: "2026-08-26T00:01:00.000Z", + stopReason: "recovery-required", + includeStateDb: true, + once: false + }); + }; + const status = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("getWatchStatus", { watchId: started.result.watchId }, "watch-auto-status") + ); + assert.equal(status.ok, true); + assert.equal(status.result.status, "stopped"); + assert.equal(value.activeWatchCounts.at(-1), 0); + const staleStop = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("stopWatch", { watchId: started.result.watchId }, "watch-auto-stale-stop") + ); + assert.equal(staleStop.ok, false); + assert.equal(staleStop.error.code, "INVALID_INPUT"); + } finally { value.cleanup(); } +}); + +test("Main restart verification clears an autonomously stopped Watch without Renderer polling", async () => { + const value = harness(); + try { + const started = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "watch-restart-start") + ); + assert.equal(started.ok, true); + assert.equal(value.activeWatchCounts.at(-1), 1); + const originalRequestManaged = value.supervisor.requestManaged; + value.supervisor.requestManaged = async (request, selectedProfile, options) => { + if (request.method !== "getWatchStatus") { + return originalRequestManaged(request, selectedProfile, options); + } + value.calls.push({ kind: "managed", request, profile: selectedProfile, options }); + return createCoreSuccessEnvelope(request, { + schemaVersion: 1, + watches: [{ + schemaVersion: 1, + watchId: started.result.watchId, + status: "stopped", + startedAt: "2026-08-26T00:00:00.000Z", + stoppedAt: "2026-08-26T00:01:00.000Z", + stopReason: "once", + includeStateDb: true, + once: true + }] + }); + }; + assert.equal(await value.cleanup.verifyNoActiveWatchesForRestart(), "clear"); + assert.equal(value.activeWatchCounts.at(-1), 0); + } finally { value.cleanup(); } +}); + +test("Main restart verification preserves ownership when Watch is active or unverifiable", async () => { + const value = harness(); + try { + const started = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreMaintenance)( + value.event, + createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "watch-restart-active") + ); + assert.equal(started.ok, true); + assert.equal(await value.cleanup.verifyNoActiveWatchesForRestart(), "active"); + assert.equal(value.activeWatchCounts.at(-1), 1); + value.supervisor.requestManaged = async () => { + throw new Error("runtime unavailable"); + }; + assert.equal(await value.cleanup.verifyNoActiveWatchesForRestart(), "unverifiable"); + assert.equal(value.activeWatchCounts.at(-1), 1); + } finally { value.cleanup(); } +}); + +test("Diagnostics and Update IPC accept only pathless trusted product input", async () => { + const value = harness(); + try { + const exported = await value.handlers.get(DESKTOP_IPC_CHANNELS.diagnosticsExport)( + value.event, + { schemaVersion: 1, profile } + ); + assert.equal(exported.status, "created"); + assert.equal("path" in exported, false); + assert.equal(value.selectedTargets, 1); + assert.equal(value.diagnosticExports.length, 1); + assert.equal(value.diagnosticExports[0].token, "main-only-token"); + const denied = await value.handlers.get(DESKTOP_IPC_CHANNELS.diagnosticsExport)( + value.event, + { schemaVersion: 1, profile, path: "D:\\attacker.zip", token: "forged" } + ); + assert.equal(denied.status, "failed"); + assert.equal(value.selectedTargets, 1); + const update = await value.handlers.get(DESKTOP_IPC_CHANNELS.updateStatus)(value.event, null); + assert.equal(update.reason, "not-configured"); + assert.equal(update.installAllowed, false); + const forged = await value.handlers.get(DESKTOP_IPC_CHANNELS.updateStatus)( + value.event, + { url: "https://example.invalid", install: true } + ); + assert.equal(forged.installAllowed, false); + for (const [channel, expectedState] of [ + [DESKTOP_IPC_CHANNELS.updateCheck, "checking"], + [DESKTOP_IPC_CHANNELS.updateDownload, "downloading"], + [DESKTOP_IPC_CHANNELS.updateInstall, "installing"] + ]) { + const result = await value.handlers.get(channel)(value.event, null); + assert.equal(result.state, expectedState); + const rejected = await value.handlers.get(channel)(value.event, { + url: "https://example.invalid", + channel: "attacker", + path: "D:\\update.exe" + }); + assert.equal(rejected.state, "disabled"); + } + assert.equal(value.updateCalls, 4); + } finally { value.cleanup(); } +}); + +test("Update restart intent rejects every new Desktop write before Core dispatch", async () => { + const value = harness({ updateRestartPending: true }); + try { + for (const [channel, request] of [ + [DESKTOP_IPC_CHANNELS.coreSyncSwitch, createCoreRequestEnvelope("prepareSync", { profile }, "update-block-sync")], + [DESKTOP_IPC_CHANNELS.coreRestore, createCoreRequestEnvelope("prepareRestore", { + profile, + backupId: "managed", + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true + }, "update-block-restore")], + [DESKTOP_IPC_CHANNELS.coreMaintenance, createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "update-block-watch")], + [DESKTOP_IPC_CHANNELS.coreMaintenance, createCoreRequestEnvelope("pruneBackups", { profile, keepCount: 5 }, "update-block-prune")] + ]) { + const response = await value.handlers.get(channel)(value.event, request); + assert.equal(response.ok, false); + assert.equal(response.error.code, "OPERATION_BUSY"); + assert.equal(response.error.details.busyScope, "codex-home"); + } + assert.equal(value.calls.length, 0); + } finally { value.cleanup(); } +}); + +test("Diagnostics IPC rejects a hostile Runtime snapshot before creating an archive", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-desktop-diagnostics-ipc-")); + const target = path.join(root, "diagnostics.zip"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const value = harness({ + diagnosticsTarget: target, + diagnosticsExporter: new DesktopDiagnosticsExporter({ appVersion: "test", isPackaged: false }), + diagnosticsSnapshot: { + schemaVersion: 1, + generatedAt: "2026-08-26T00:00:00.000Z", + runtime: { node: "v24", platform: "win32", arch: "x64" }, + storage: { + sqliteHomeSource: "default", + stateDbFound: true, + sqliteSupported: true, + path: "C:\\secret\\state_5.sqlite" + }, + provider: { + current: "openai", + implicit: false, + configured: ["openai"], + rolloutCounts: { sessions: {}, archived_sessions: {} }, + sqliteCounts: { sessions: {}, archived_sessions: {} }, + message: "private message body" + }, + safety: { + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutCount: 0, + projectThreadVisibilityAvailable: true, + encrypted_content: "ciphertext" + } + } + }); + try { + const exported = await value.handlers.get(DESKTOP_IPC_CHANNELS.diagnosticsExport)( + value.event, + { schemaVersion: 1, profile } + ); + assert.deepEqual(exported, { + schemaVersion: 1, + status: "failed", + reason: "invalid-snapshot" + }); + await assert.rejects(fs.access(target), { code: "ENOENT" }); + } finally { value.cleanup(); } +}); + +test("Main bounds unconsumed prepared-plan ownership and expires the oldest plan", async () => { + const value = harness(); + try { + const prepared = []; + for (let index = 0; index <= 256; index += 1) { + prepared.push(await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope( + "prepareSync", + { profile, keepCount: 5 }, + `bounded-plan-${index}` + ) + )); + } + assert.equal(prepared.every((response) => response.ok), true); + const expired = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope( + "applySync", + { schemaVersion: 1, planId: prepared[0].result.planId }, + "apply-evicted-plan" + ) + ); + assert.equal(expired.ok, false); + assert.equal(expired.error.code, "PLAN_EXPIRED"); + const latest = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope( + "applySync", + { schemaVersion: 1, planId: prepared.at(-1).result.planId }, + "apply-latest-plan" + ) + ); + assert.equal(latest.ok, true); + } finally { value.cleanup(); } +}); + +test("a concurrent Apply cannot reuse requestId or steal the first operation routing", async () => { + const value = harness({ holdApply: true }); + try { + const syncPlan = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope("prepareSync", { profile, keepCount: 5 }, "prepare-sync") + ); + const switchPlan = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope("prepareSwitch", { + profile, + provider: "relay", + modelMode: "keep-root-model", + keepCount: 5 + }, "prepare-switch") + ); + const first = value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope( + "applySync", + { schemaVersion: 1, planId: syncPlan.result.planId }, + "duplicate-apply" + ) + ); + await new Promise((resolve) => setImmediate(resolve)); + const duplicate = await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)( + value.event, + createCoreRequestEnvelope( + "applySwitch", + { schemaVersion: 1, planId: switchPlan.result.planId }, + "duplicate-apply" + ) + ); + assert.equal(duplicate.ok, false); + assert.equal(duplicate.error.code, "INVALID_INPUT"); + assert.deepEqual( + await value.handlers.get(DESKTOP_IPC_CHANNELS.operationCancel)(value.event, { + requestId: "duplicate-apply", + operationId + }), + { accepted: true } + ); + assert.deepEqual(value.cancellations, [{ requestId: "duplicate-apply", operationId }]); + value.pendingApplies[0](); + assert.equal((await first).ok, true); + assert.equal(value.sent.length, 2); + } finally { value.cleanup(); } +}); + +test("channels reject cross-capability methods, protocol drift, operationId and oversized payloads", async () => { + const value = harness(); + try { + const write = createCoreRequestEnvelope("prepareSync", { profile, keepCount: 5 }, "write-on-read"); + assert.equal( + (await value.handlers.get(DESKTOP_IPC_CHANNELS.coreRead)(value.event, write)).error.code, + "PERMISSION_DENIED" + ); + const restore = createCoreRequestEnvelope( + "prepareRestore", + { profile, backupId: "managed", restoreConfig: true, restoreDatabase: true, restoreSessions: true }, + "restore-on-write" + ); + assert.equal( + (await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)(value.event, restore)).error.code, + "PERMISSION_DENIED" + ); + const withOperationId = createCoreRequestEnvelope( + "prepareSync", + { profile, keepCount: 5 }, + "forged-operation", + operationId + ); + assert.equal( + (await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)(value.event, withOperationId)).error.code, + "PERMISSION_DENIED" + ); + const mismatch = { ...createCoreRequestEnvelope("getStatus", { profile }, "bad-version"), protocolVersion: 99 }; + assert.equal( + (await value.handlers.get(DESKTOP_IPC_CHANNELS.coreRead)(value.event, mismatch)).error.code, + "PROTOCOL_VERSION_MISMATCH" + ); + const oversized = { + ...createCoreRequestEnvelope("prepareSwitch", { + profile, + provider: "relay", + modelMode: "explicit", + model: "x", + keepCount: 5 + }, "oversized"), + payload: { + profile, + provider: "relay", + modelMode: "explicit", + model: "x".repeat(70 * 1024), + keepCount: 5 + } + }; + assert.equal( + (await value.handlers.get(DESKTOP_IPC_CHANNELS.coreSyncSwitch)(value.event, oversized)).error.code, + "INVALID_INPUT" + ); + assert.equal(value.calls.length, 0); + } finally { value.cleanup(); } +}); + +test("cancel IPC is fixed-schema and sender-bound", async () => { + const value = harness(); + try { + const accepted = await value.handlers.get(DESKTOP_IPC_CHANNELS.operationCancel)(value.event, { + requestId: "not-active", + operationId + }); + assert.deepEqual(accepted, { accepted: false }); + const malformed = await value.handlers.get(DESKTOP_IPC_CHANNELS.operationCancel)(value.event, { + requestId: "not-active", + operationId, + path: "C:/private" + }); + assert.deepEqual(malformed, { accepted: false }); + assert.equal(value.cancellations.length, 0); + } finally { value.cleanup(); } +}); + +test("Profile IPC remains redacted and cleanup removes every registered channel", async () => { + const value = harness(); + const response = await value.handlers.get(DESKTOP_IPC_CHANNELS.profilesList)(value.event, null); + assert.equal("codexHome" in response.profiles[0], false); + value.cleanup(); + assert.equal(value.handlers.size, 0); +}); diff --git a/apps/desktop/tests/profile-repository.test.mjs b/apps/desktop/tests/profile-repository.test.mjs new file mode 100644 index 0000000..f70783d --- /dev/null +++ b/apps/desktop/tests/profile-repository.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { DesktopProfileRepository } from "../dist/profiles/repository.js"; + +test("desktop profile repository projects paths out of Renderer responses", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-c6-profiles-")); + try { + const codexHome = path.join(root, "codex-home"); + const sqliteHome = path.join(root, "sqlite-home"); + const repository = new DesktopProfileRepository({ + filePath: path.join(root, "user-data", "profiles.v1.json"), + defaultCodexHome: codexHome, + defaultSqliteHome: sqliteHome + }); + await repository.initialize(); + const profiles = repository.list(); + assert.equal(profiles.length, 1); + assert.deepEqual(Object.keys(profiles[0]).sort(), [ + "codexHomeConfigured", + "id", + "name", + "revision", + "sqliteHomeConfigured" + ]); + assert.doesNotMatch(JSON.stringify(profiles), new RegExp(codexHome.replaceAll("\\", "\\\\"))); + const resolved = repository.resolve({ + profileId: "default", + profileRevision: profiles[0].revision + }); + assert.equal(resolved.codexHome, path.resolve(codexHome)); + assert.equal(resolved.sqliteHome, path.resolve(sqliteHome)); + assert.throws( + () => repository.resolve({ profileId: "default", profileRevision: "stale" }), + (error) => error?.code === "PROFILE_CHANGED" + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("desktop profile document rejects relative and duplicate trusted paths", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-c6-profile-invalid-")); + try { + const filePath = path.join(root, "profiles.v1.json"); + await fs.writeFile(filePath, JSON.stringify({ + schemaVersion: 1, + profiles: [{ id: "bad", name: "Bad", codexHome: "relative" }] + }), "utf8"); + const repository = new DesktopProfileRepository({ + filePath, + defaultCodexHome: path.join(root, "default") + }); + await assert.rejects(repository.initialize(), (error) => error?.code === "INVALID_INPUT"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/tests/readonly-facade-parity.test.mjs b/apps/desktop/tests/readonly-facade-parity.test.mjs new file mode 100644 index 0000000..04cd6d2 --- /dev/null +++ b/apps/desktop/tests/readonly-facade-parity.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createCoreRequestEnvelope, + sanitizePublicCoreErrorDto +} from "@codex-provider-sync/contracts"; +import { createCoreFacade } from "@codex-provider-sync/core"; + +import { DesktopProfileRepository } from "../dist/profiles/repository.js"; +import { createDesktopRuntimeHost } from "../dist/runtime/host.js"; + +function normalized(method, value) { + if (method === "getStatus") return { ...value, snapshotAt: "" }; + if (method === "getDiagnostics") return { ...value, generatedAt: "" }; + return value; +} + +async function hashTree(root) { + const hash = createHash("sha256"); + async function visit(directory, prefix = "") { + const entries = await fs.readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const absolute = path.join(directory, entry.name); + hash.update(`${entry.isDirectory() ? "d" : "f"}:${relative}\0`); + if (entry.isDirectory()) await visit(absolute, relative); + else hash.update(await fs.readFile(absolute)); + } + } + await visit(root); + return hash.digest("hex"); +} + +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-desktop-readonly-parity-")); + const codexHome = path.join(root, "codex-home"); + const rollout = path.join(codexHome, "sessions", "2026", "08", "27", "rollout-parity.jsonl"); + await fs.mkdir(path.dirname(rollout), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + await fs.writeFile(rollout, [ + { + type: "session_meta", + timestamp: "2026-08-27T00:00:00.000Z", + payload: { + id: "parity-session", + title: "Parity session", + cwd: path.join(root, "private-project"), + model_provider: "openai", + encrypted_content: "ciphertext-must-not-leak" + } + }, + { + type: "event_msg", + timestamp: "2026-08-27T00:01:00.000Z", + payload: { type: "user_message", message: "body-visible-only-in-explicit-detail" } + }, + { + type: "event_msg", + payload: { type: "tool_call", arguments: "tool-secret-must-not-leak" } + } + ].map((line) => JSON.stringify(line)).join("\n") + "\n", "utf8"); + const profiles = new DesktopProfileRepository({ + filePath: path.join(root, "host", "profiles.json"), + defaultCodexHome: codexHome + }); + await profiles.initialize(); + const profile = profiles.list()[0]; + const selector = { profileId: profile.id, profileRevision: profile.revision }; + return { root, codexHome, profiles, selector }; +} + +test("Desktop Utility host read-only methods match the standalone Core facade", async () => { + const value = await fixture(); + try { + const facade = createCoreFacade({ resolveProfile: (selector) => value.profiles.resolve(selector) }); + const host = createDesktopRuntimeHost(value.profiles); + const cases = [ + ["getStatus", { profile: value.selector }], + ["listBackups", { profile: value.selector }], + ["listHistory", { profile: value.selector, page: 1, pageSize: 10 }], + ["getHistorySession", { profile: value.selector, sessionId: "parity-session", messageLimit: 1 }], + ["getDiagnostics", { profile: value.selector }] + ]; + const before = await hashTree(value.codexHome); + + for (const [method, payload] of cases) { + const expected = await facade[method](payload); + const request = createCoreRequestEnvelope(method, payload, `parity-${method}`); + const response = await host.dispatch(request); + assert.equal(response.ok, true, `${method} should succeed through the Utility host`); + assert.deepEqual(normalized(method, response.result), normalized(method, expected)); + if (method !== "getHistorySession") { + assert.doesNotMatch(JSON.stringify(response), /body-visible-only|ciphertext-must-not-leak|tool-secret-must-not-leak|private-project/i); + } + } + + assert.equal(await hashTree(value.codexHome), before, "read-only parity calls must not mutate the fixture"); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("Desktop Utility host preserves Core read-only error DTO semantics", async () => { + const value = await fixture(); + try { + const facade = createCoreFacade({ resolveProfile: (selector) => value.profiles.resolve(selector) }); + const host = createDesktopRuntimeHost(value.profiles); + const cases = [ + ["getStatus", { profile: { ...value.selector, profileRevision: "stale-revision" } }], + ["listBackups", { profile: { ...value.selector, profileRevision: "stale-revision" } }], + ["listHistory", { profile: { ...value.selector, profileRevision: "stale-revision" }, page: 1, pageSize: 10 }], + ["getHistorySession", { profile: value.selector, sessionId: "missing-session", messageLimit: 1 }], + ["getDiagnostics", { profile: { ...value.selector, profileRevision: "stale-revision" } }] + ]; + + for (const [method, payload] of cases) { + let expectedError; + try { + await facade[method](payload); + assert.fail(`${method} should reject`); + } catch (error) { + expectedError = sanitizePublicCoreErrorDto(error); + } + const request = createCoreRequestEnvelope(method, payload, `parity-error-${method}`); + const response = await host.dispatch(request); + assert.equal(response.ok, false, `${method} should fail through the Utility host`); + assert.deepEqual(response.error, expectedError); + assert.doesNotMatch(JSON.stringify(response.error), /stale-revision|missing-session|private-project|codex-home/i); + } + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/tests/release-candidate.test.mjs b/apps/desktop/tests/release-candidate.test.mjs new file mode 100644 index 0000000..3f52354 --- /dev/null +++ b/apps/desktop/tests/release-candidate.test.mjs @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { build as buildPlist } from "plist"; +import { + assertSafeAsarEntries, + assertSafeProductTextEntry, + createRuntimeProjection, + isAuditedProductTextEntry, + parseMacInfoPlist, + RELEASE_TARGETS +} from "../scripts/release-audit.mjs"; +import { resolveCandidateBuild } from "../scripts/resolve-candidate-build.mjs"; + +const desktopRoot = path.resolve(import.meta.dirname, ".."); +const repositoryRoot = path.resolve(desktopRoot, "../.."); + +async function read(relativePath) { + return fs.readFile(path.join(repositoryRoot, ...relativePath.split("/")), "utf8"); +} + +test("candidate identity is injected without mutating the source package version", async () => { + assert.deepEqual(resolveCandidateBuild({ + channel: "rc", + runNumber: 42, + sha: "0123456789abcdef0123456789abcdef01234567", + target: "windows-x64" + }), { + version: "1.0.0-rc.42", + buildId: "1.0.0-rc.42-0123456789ab-windows-x64", + commit: "0123456789abcdef0123456789abcdef01234567", + target: "windows-x64", + channel: "rc", + runNumber: 42 + }); + assert.throws(() => resolveCandidateBuild({ channel: "nightly", runNumber: 1, sha: "0123456", target: "windows-x64" })); + assert.throws(() => resolveCandidateBuild({ channel: "rc", runNumber: -1, sha: "0123456", target: "windows-x64" })); + const rootManifest = JSON.parse(await read("package.json")); + const desktopManifest = JSON.parse(await read("apps/desktop/package.json")); + assert.equal(rootManifest.version, "1.0.0"); + assert.equal(desktopManifest.version, "1.0.0"); + assert.equal(rootManifest.optionalDependencies["better-sqlite3"], "8.7.0"); + assert.equal(desktopManifest.dependencies["better-sqlite3"], "13.0.3"); + assert.equal(desktopManifest.homepage, "https://github.com/Dailin521/codex-provider-sync#readme"); + assert.equal(desktopManifest.devDependencies.plist, "5.0.0"); + assert.equal(desktopManifest.devDependencies.resedit, "3.1.0"); +}); + +test("macOS release audit parses XML Info.plist buffers as XML", () => { + const expected = { + ElectronAsarIntegrity: { + "Resources/app.asar": { algorithm: "SHA256", hash: "a".repeat(64) } + } + }; + assert.deepEqual(parseMacInfoPlist(Buffer.from(buildPlist(expected), "utf8")), expected); +}); + +test("release targets use the frozen C9 artifact names", () => { + const version = "1.0.0-rc.9"; + assert.deepEqual(RELEASE_TARGETS["windows-x64"].assets(version), [ + `CodexProviderSync-${version}-windows-x64-setup.exe`, + `CodexProviderSync-${version}-windows-x64-portable.zip` + ]); + assert.deepEqual(RELEASE_TARGETS["macos-x64"].assets(version), [ + `CodexProviderSync-${version}-macos-x64.dmg`, + `CodexProviderSync-${version}-macos-x64.zip` + ]); + assert.deepEqual(RELEASE_TARGETS["macos-arm64"].assets(version), [ + `CodexProviderSync-${version}-macos-arm64.dmg`, + `CodexProviderSync-${version}-macos-arm64.zip` + ]); + assert.deepEqual(RELEASE_TARGETS["linux-x64"].assets(version), [ + `CodexProviderSync-${version}-linux-x64.AppImage`, + `CodexProviderSync-${version}-linux-x64.deb` + ]); +}); + +test("runtime SBOM projection includes production closure and excludes Desktop build tooling", async () => { + const projection = await createRuntimeProjection(path.join(repositoryRoot, "package-lock.json")); + const refs = new Set(projection.components.map((component) => component.ref)); + for (const ref of [ + "@codex-provider-sync/app-ui@0.0.0", + "@hookform/resolvers@5.9.1", + "better-sqlite3@13.0.3", + "electron-updater@6.8.9", + "react@19.2.8", + "zod@4.4.3" + ]) assert.equal(refs.has(ref), true, `Runtime projection is missing ${ref}.`); + for (const ref of [ + "@playwright/test@1.62.1", + "electron-builder@26.15.7", + "plist@5.0.0", + "resedit@3.1.0", + "vite@7.3.6" + ]) assert.equal(refs.has(ref), false, `Runtime projection contains build-only ${ref}.`); +}); + +test("ASAR policy rejects source maps, fixtures, credentials and key material by path", () => { + assert.doesNotThrow(() => assertSafeAsarEntries([ + "package.json", + "out/main/index.js", + "node_modules/better-sqlite3/lib/index.js" + ])); + for (const entry of [ + "out/main/index.js.map", + "fixtures/profile.json", + "resources/auth.json", + "keys/release.p12", + "config/.env.production", + "config/credentials.json", + "out/main/runtime.spec.mjs", + "rollouts/rollout-example.jsonl" + ]) { + assert.throws(() => assertSafeAsarEntries([entry])); + } + assert.doesNotThrow(() => assertSafeProductTextEntry("out/main/index.js", "const provider = 'openai';")); + assert.throws(() => assertSafeProductTextEntry( + "out/main/index.js", + "const credential = 'AKIA1234567890ABCDEF';" + )); + assert.equal(isAuditedProductTextEntry("out/renderer/assets/logo.svg"), true); + assert.equal(isAuditedProductTextEntry("out/renderer/assets/manifest.webmanifest"), true); + assert.equal(isAuditedProductTextEntry("out/renderer/assets/image.png"), false); + assert.throws(() => assertSafeProductTextEntry( + "out/renderer/assets/logo.svg", + "ghp_123456789012345678901234567890123456" + )); + assert.throws(() => assertSafeProductTextEntry( + "out/main/runtime.js", + "const gate = '__CPS_DESKTOP_FORCE_BETTER_SQLITE3__';" + )); +}); + +test("builder and candidate scripts enforce native fallback, fuses, audit metadata and no publishing", async () => { + const attributes = await read(".gitattributes"); + const builder = await read("apps/desktop/electron-builder.yml"); + const buildScript = await read("apps/desktop/scripts/build-candidate.mjs"); + const stageScript = await read("apps/desktop/scripts/stage-candidate.mjs"); + const smokeScript = await read("apps/desktop/scripts/smoke-candidate-artifacts.mjs"); + const sandboxHelper = await read("apps/desktop/scripts/configure-linux-sandbox.mjs"); + const workflow = await read(".github/workflows/ci.yml"); + const desktopJob = workflow.match( + /^ electron-desktop:\r?\n[\s\S]*?(?=^ electron-release-candidate:)/m + )?.[0]; + assert.ok(desktopJob, "The cross-platform Electron desktop job must remain present."); + const candidateJob = workflow.match( + /^ electron-release-candidate:\r?\n[\s\S]*?(?=^ electron-candidate-set:)/m + )?.[0]; + assert.ok(candidateJob, "The native release-candidate job must remain present."); + for (const expected of [ + "node_modules/better-sqlite3/prebuilds/${platform}-${arch}.node", + "!node_modules/better-sqlite3/prebuilds/!(${platform}-${arch}).node", + "!node_modules/better-sqlite3/{build,deps,src}/**", + "enableEmbeddedAsarIntegrityValidation: true", + "onlyLoadAppFromAsar: true", + "loadBrowserProcessSpecificV8Snapshot: false", + "target: nsis", + "target: dmg", + "target: AppImage", + "target: deb", + "Name: Codex Provider Sync", + "appImage:", + "artifactName: CodexProviderSync-${version}-linux-x64.AppImage", + "deb:", + "artifactName: CodexProviderSync-${version}-linux-x64.deb" + ]) assert.match(builder, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch( + builder, + /artifactName:\s*CodexProviderSync-\$\{version\}-linux-\$\{arch\}/, + "Linux package targets must not expose electron-builder's x86_64/amd64 arch aliases." + ); + assert.match(buildScript, /"--publish",\s*"never"/); + assert.match(buildScript, /--config\.extraMetadata\.version=/); + assert.match(buildScript, /CPS_DESKTOP_RELEASE_AUTHORIZED:\s*"false"/); + assert.match(attributes, /^package-lock\.json text eol=lf$/m); + assert.match(attributes, /^apps\/desktop\/release\/artifact-audit-policy\.v1\.json text eol=lf$/m); + assert.match( + buildScript, + /"linux-x64":\s*\{[\s\S]*?configOverrides:\s*\["--config\.productName=CodexProviderSync"\]/, + "The Linux candidate must keep its setuid sandbox install path free of spaces." + ); + assert.match(stageScript, /releaseAuthorized:\s*false/); + assert.match(stageScript, /signingStatus:\s*"unsigned-candidate"/); + assert.match(stageScript, /sbom\.cyclonedx\.json/); + assert.match(stageScript, /ARTIFACT_AUDIT_POLICY_PATH/); + assert.match(smokeScript, /container-verification\.v1\.json/); + assert.match(smokeScript, /syncRestoreVerified:\s*true/); + assert.match(smokeScript, /SHA256SUMS\.txt/); + assert.match(smokeScript, /configure-linux-sandbox\.mjs/); + assert.match(smokeScript, /verbatimSymlinks:\s*true/); + assert.match( + smokeScript, + /path\.join\(\s*debRoot,\s*"opt",\s*"CodexProviderSync",\s*"codex-provider-sync"\s*\)/, + "The final-container smoke must execute the deb candidate from its real space-free install path." + ); + assert.doesNotMatch(smokeScript, /run\("sudo",\s*\["(?:chown|chmod)"/); + for (const expected of [ + "O_NOFOLLOW", + "handle.chown(0, 0)", + "handle.chmod(0o4755)", + "opened.dev !== before.dev", + "opened.ino !== before.ino", + "opened.nlink !== 1n" + ]) assert.match(sandboxHelper, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(workflow, /configure-linux-sandbox\.mjs node_modules\/electron\/dist chrome-sandbox/); + assert.match(workflow, /configure-linux-sandbox\.mjs dist-desktop\/linux-unpacked chrome-sandbox/); + assert.match(desktopJob, /Upload Electron failure traces/); + assert.match(desktopJob, /if: failure\(\)/); + assert.match(desktopJob, /apps\/desktop\/test-results\/\*\*\/trace\.zip/); + assert.match(desktopJob, /apps\/desktop\/test-results\/\*\*\/error-context\.md/); + assert.match(candidateJob, /Verify candidate input byte identity/); + assert.match(candidateJob, /'package-lock\.json','apps\/desktop\/release\/artifact-audit-policy\.v1\.json'/); + assert.match(candidateJob, /git',\['ls-files','--eol',\.\.\.files\]/); + assert.match(candidateJob, /line\.includes\('w\/lf'\)/); + assert.equal( + [...workflow.matchAll(/run: node -e "require\('electron'\)"/g)].length, + 2, + "Both Linux Electron jobs must install the pinned runtime before configuring its sandbox." + ); + assert.doesNotMatch(workflow, /sudo\s+(?:chown|chmod)\b/); +}); diff --git a/apps/desktop/tests/runtime-protocol.test.mjs b/apps/desktop/tests/runtime-protocol.test.mjs new file mode 100644 index 0000000..eccbcaa --- /dev/null +++ b/apps/desktop/tests/runtime-protocol.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createCoreOperationStartedEnvelope, + createCoreProgressEnvelope, + createCoreRequestEnvelope, + createCoreSuccessEnvelope +} from "@codex-provider-sync/contracts"; +import { DESKTOP_RUNTIME_METHODS } from "@codex-provider-sync/core-client"; + +import { + DESKTOP_BUILD_ID, + DESKTOP_CORE_PROTOCOL_VERSION, + DESKTOP_CORE_VERSION, + DESKTOP_RUNTIME_PROTOCOL_VERSION +} from "../dist/shared/constants.js"; +import { + assertRuntimeHelloFrame, + assertRuntimeOperationEventFrame, + assertRuntimeRequestFrame, + assertRuntimeResponseFrame, + createRuntimeOperationEventFrame, + createRuntimeRequestFrame, + createRuntimeResponseFrame +} from "../dist/shared/runtime-protocol.js"; + +const dispatchId = "22222222-2222-4222-8222-222222222222"; +const operationId = "11111111-1111-4111-8111-111111111111"; +const request = createCoreRequestEnvelope( + "listBackups", + { profile: { profileId: "default", profileRevision: "r1" } }, + "request-1" +); + +test("runtime request and response frames require Main dispatch correlation", () => { + const requestFrame = createRuntimeRequestFrame(1, dispatchId, request); + const responseFrame = createRuntimeResponseFrame( + 1, + dispatchId, + createCoreSuccessEnvelope(request, { backups: [] }) + ); + assert.doesNotThrow(() => assertRuntimeRequestFrame(requestFrame)); + assert.doesNotThrow(() => assertRuntimeResponseFrame(responseFrame, { + dispatchId, + requestId: "request-1" + })); + assert.throws(() => assertRuntimeRequestFrame({ ...requestFrame, path: "C:/private" })); + assert.throws(() => assertRuntimeResponseFrame(responseFrame, { + dispatchId: "33333333-3333-4333-8333-333333333333", + requestId: "request-1" + })); +}); + +test("runtime operation events accept only strict pathless shared envelopes", () => { + const started = createRuntimeOperationEventFrame( + 1, + dispatchId, + createCoreOperationStartedEnvelope("request-1", operationId, "sync") + ); + const progress = createRuntimeOperationEventFrame( + 1, + dispatchId, + createCoreProgressEnvelope("request-1", operationId, { + stage: "update_sqlite", + status: "start", + progress: 0.5, + count: 2 + }) + ); + assert.doesNotThrow(() => assertRuntimeOperationEventFrame(started, { + dispatchId, + requestId: "request-1" + })); + assert.doesNotThrow(() => assertRuntimeOperationEventFrame(progress, { + dispatchId, + requestId: "request-1", + operationId + })); + assert.throws(() => assertRuntimeOperationEventFrame({ + ...progress, + envelope: { + ...progress.envelope, + progress: { ...progress.envelope.progress, backupDir: "C:/private" } + } + })); +}); + +test("runtime hello requires the exact C8 capability set and identity", () => { + const identity = { + appVersion: "0.5.0", + coreVersion: DESKTOP_CORE_VERSION, + buildId: DESKTOP_BUILD_ID, + sessionNonce: "a".repeat(64), + generation: 1 + }; + const hello = { + kind: "hello", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + coreProtocolVersion: DESKTOP_CORE_PROTOCOL_VERSION, + ...identity, + capabilities: DESKTOP_RUNTIME_METHODS + }; + assert.doesNotThrow(() => assertRuntimeHelloFrame(hello, identity)); + assert.throws(() => assertRuntimeHelloFrame({ + ...hello, + capabilities: DESKTOP_RUNTIME_METHODS.slice(0, 5) + }, identity)); + assert.throws(() => assertRuntimeHelloFrame({ + ...hello, + capabilities: [...DESKTOP_RUNTIME_METHODS, "unknownMethod"] + }, identity)); +}); diff --git a/apps/desktop/tests/runtime-supervisor.test.mjs b/apps/desktop/tests/runtime-supervisor.test.mjs new file mode 100644 index 0000000..4274dda --- /dev/null +++ b/apps/desktop/tests/runtime-supervisor.test.mjs @@ -0,0 +1,676 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createCoreFailureEnvelope, + createCoreOperationStartedEnvelope, + createCoreProgressEnvelope, + createCoreRequestEnvelope, + createCoreSuccessEnvelope, + createPublicCoreErrorDto +} from "@codex-provider-sync/contracts"; +import { DESKTOP_RUNTIME_METHODS } from "@codex-provider-sync/core-client"; + +import { CoreRuntimeSupervisor } from "../dist/main/runtime-supervisor.js"; +import { + DESKTOP_CORE_PROTOCOL_VERSION, + DESKTOP_RUNTIME_PROTOCOL_VERSION +} from "../dist/shared/constants.js"; +import { + createRuntimeOperationEventFrame, + createRuntimeResponseFrame +} from "../dist/shared/runtime-protocol.js"; + +const profile = { profileId: "default", profileRevision: "profile-r1" }; +const operationId = "11111111-1111-4111-8111-111111111111"; + +function statusResult({ pending = false, selectedProfile = profile } = {}) { + return { + schemaVersion: 1, + snapshotAt: "2026-08-26T00:00:00.000Z", + storageRevision: "storage-r1", + profile: { id: selectedProfile.profileId, revision: selectedProfile.profileRevision }, + currentProvider: "openai", + rolloutCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + sqliteCounts: {}, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: pending, + pendingTransactions: pending ? [{ operationId: "pending", state: "applying" }] : [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [] + }; +} + +function planResult(request) { + const operation = request.method === "prepareSwitch" + ? "switch" + : request.method === "prepareRestore" + ? "restore" + : "sync"; + return { + schemaVersion: 1, + planId: "p".repeat(48), + operation, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + profile: { + id: request.payload.profile.profileId, + revision: request.payload.profile.profileRevision + }, + storageRevision: "storage-r1", + configRevision: "config-r1", + rolloutRevision: "rollout-r1", + stateDbRevision: "db-r1", + ...(operation === "restore" ? { backupRevision: "backup-r1" } : {}), + target: operation === "restore" + ? { backupId: request.payload.backupId } + : { provider: operation === "sync" ? "openai" : request.payload.provider }, + impact: { backupExpected: true }, + warnings: [], + requiresConfirmation: true + }; +} + +class FakeUtility { + constructor(identity, behavior = {}) { + this.identity = identity; + this.behavior = behavior; + this.messages = []; + this.messageListeners = new Set(); + this.exitListeners = new Set(); + this.exited = false; + this.killCalls = 0; + this.applyFrames = new Map(); + if (!behavior.holdHello) { + queueMicrotask(() => this.emitMessage({ + kind: "hello", + runtimeProtocolVersion: DESKTOP_RUNTIME_PROTOCOL_VERSION, + coreProtocolVersion: DESKTOP_CORE_PROTOCOL_VERSION, + appVersion: behavior.badAppVersion ? "wrong-app" : identity.appVersion, + coreVersion: identity.coreVersion, + buildId: identity.buildId, + sessionNonce: identity.sessionNonce, + generation: identity.generation, + capabilities: behavior.readOnlyHello + ? DESKTOP_RUNTIME_METHODS.slice(0, 5) + : DESKTOP_RUNTIME_METHODS + })); + } + } + + postMessage(frame) { + this.messages.push(frame); + if (frame.kind === "shutdown") { + queueMicrotask(() => this.exit()); + return; + } + if (frame.kind === "cancel") { + const requestFrame = this.applyFrames.get(frame.dispatchId); + if (!requestFrame || this.behavior.ignoreCancel) return; + const response = createCoreFailureEnvelope( + requestFrame.envelope, + createPublicCoreErrorDto("OPERATION_CANCELLED", { operationId }), + operationId + ); + queueMicrotask(() => this.emitMessage(createRuntimeResponseFrame( + frame.generation, + frame.dispatchId, + response + ))); + return; + } + if (frame.kind !== "request") return; + if (this.behavior.crashOnRequest?.(frame.envelope)) { + queueMicrotask(() => this.exit()); + return; + } + if (this.behavior.holdRequests) return; + const respond = () => { + const request = frame.envelope; + if (request.method === "getStatus") { + const response = createCoreSuccessEnvelope(request, statusResult({ + pending: this.behavior.pendingStatus === true + || this.behavior.pendingProfiles?.includes(request.payload.profile.profileId), + selectedProfile: this.behavior.wrongStatusProfile + ? { profileId: "wrong", profileRevision: "wrong" } + : request.payload.profile + })); + this.emitMessage(createRuntimeResponseFrame(frame.generation, frame.dispatchId, response)); + return; + } + if (request.method === "listBackups") { + const response = createCoreSuccessEnvelope(request, { backups: [] }); + this.emitMessage(createRuntimeResponseFrame(frame.generation, frame.dispatchId, response)); + return; + } + if (request.method === "prepareSync" + || request.method === "prepareSwitch" + || request.method === "prepareRestore") { + const response = createCoreSuccessEnvelope(request, planResult(request)); + this.emitMessage(createRuntimeResponseFrame(frame.generation, frame.dispatchId, response)); + return; + } + if (request.method === "applySync" + || request.method === "applySwitch" + || request.method === "applyRestore") { + if (this.behavior.holdApplyBeforeStart) return; + if (this.behavior.failApplyBeforeStart) { + const response = createCoreFailureEnvelope( + request, + createPublicCoreErrorDto("PLAN_EXPIRED") + ); + this.emitMessage(createRuntimeResponseFrame(frame.generation, frame.dispatchId, response)); + return; + } + this.applyFrames.set(frame.dispatchId, frame); + const operation = request.method === "applySync" + ? "sync" + : request.method === "applySwitch" + ? "switch" + : "restore"; + this.emitMessage(createRuntimeOperationEventFrame( + frame.generation, + frame.dispatchId, + createCoreOperationStartedEnvelope(request.requestId, operationId, operation) + )); + this.emitMessage(createRuntimeOperationEventFrame( + frame.generation, + frame.dispatchId, + createCoreProgressEnvelope(request.requestId, operationId, { + stage: "create_backup", + status: "start" + }) + )); + if (this.behavior.holdApply) return; + const response = createCoreSuccessEnvelope(request, { + schemaVersion: 1, + operationId, + operation, + outcome: "completed", + backup: { backupId: "managed-backup" }, + warnings: [], + result: { targetProvider: "openai" } + }, operationId); + this.emitMessage(createRuntimeResponseFrame(frame.generation, frame.dispatchId, response)); + return; + } + if (request.method === "pruneBackups") { + this.emitMessage(createRuntimeResponseFrame( + frame.generation, + frame.dispatchId, + createCoreSuccessEnvelope(request, { + deletedCount: 0, + remainingCount: request.payload.keepCount, + freedBytes: 0 + }) + )); + return; + } + if (request.method === "startWatch" + || request.method === "stopWatch" + || request.method === "getWatchStatus") { + const watch = { + schemaVersion: 1, + watchId: request.payload.watchId ?? "22222222-2222-4222-8222-222222222222", + status: request.method === "stopWatch" ? "stopped" : "running", + startedAt: "2026-08-26T00:00:00.000Z", + stoppedAt: request.method === "stopWatch" ? "2026-08-26T00:01:00.000Z" : null, + stopReason: request.method === "stopWatch" ? "manual" : null, + includeStateDb: true, + once: false + }; + this.emitMessage(createRuntimeResponseFrame( + frame.generation, + frame.dispatchId, + createCoreSuccessEnvelope( + request, + request.method === "getWatchStatus" && !request.payload.watchId + ? { schemaVersion: 1, watches: [watch] } + : watch + ) + )); + } + }; + if (this.behavior.responseDelayMs) setTimeout(respond, this.behavior.responseDelayMs); + else queueMicrotask(respond); + } + + kill() { + this.killCalls += 1; + if (!this.behavior.deferExitOnKill) this.exit(); + } + onMessage(listener) { this.messageListeners.add(listener); return () => this.messageListeners.delete(listener); } + onExit(listener) { this.exitListeners.add(listener); return () => this.exitListeners.delete(listener); } + emitMessage(frame) { for (const listener of [...this.messageListeners]) listener(frame); } + exit() { + if (this.exited) return; + this.exited = true; + for (const listener of [...this.exitListeners]) listener(); + } +} + +function readRequest(method, requestId, selectedProfile = profile) { + return createCoreRequestEnvelope(method, { profile: selectedProfile }, requestId); +} + +function prepareRequest(requestId = "prepare-1") { + return createCoreRequestEnvelope( + "prepareSync", + { profile, keepCount: 5 }, + requestId + ); +} + +function applyRequest(requestId = "apply-1") { + return createCoreRequestEnvelope( + "applySync", + { schemaVersion: 1, planId: "p".repeat(48) }, + requestId + ); +} + +function restorePrepareRequest(requestId = "restore-prepare") { + return createCoreRequestEnvelope("prepareRestore", { + profile, + backupId: "managed", + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true + }, requestId); +} + +function restoreApplyRequest(requestId = "restore-apply") { + return createCoreRequestEnvelope( + "applyRestore", + { schemaVersion: 1, planId: "p".repeat(48) }, + requestId + ); +} + +test("runtime handshake completes before the first read", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { const child = new FakeUtility(identity); children.push(child); return child; } + }); + const response = await supervisor.request(readRequest("getStatus", "status-1")); + assert.equal(response.ok, true); + assert.equal(children.length, 1); + assert.deepEqual(children[0].messages.map((frame) => frame.envelope?.method), ["getStatus"]); + assert.equal(supervisor.snapshot.generation, 1); +}); + +test("first cold-start write preflights Status before Prepare", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { const child = new FakeUtility(identity); children.push(child); return child; } + }); + const response = await supervisor.requestWrite(prepareRequest(), profile); + assert.equal(response.ok, true); + assert.deepEqual( + children[0].messages.filter((frame) => frame.kind === "request").map((frame) => frame.envelope.method), + ["getStatus", "prepareSync"] + ); +}); + +test("restart safety force-preflights every known profile and fails closed", async () => { + const second = { profileId: "secondary", profileRevision: "profile-r2" }; + for (const scenario of ["clear", "blocked", "unverifiable"]) { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { + const child = new FakeUtility(identity, scenario === "blocked" + ? { pendingProfiles: [second.profileId] } + : scenario === "unverifiable" + ? { wrongStatusProfile: true } + : {}); + children.push(child); + return child; + } + }); + assert.equal( + await supervisor.verifyProfilesSafeForRestart([profile, second]), + scenario + ); + const methods = children[0].messages + .filter((frame) => frame.kind === "request") + .map((frame) => frame.envelope.method); + assert.equal(methods.every((method) => method === "getStatus"), true); + assert.equal(methods.length, scenario === "unverifiable" ? 1 : 2); + await supervisor.shutdown(); + } +}); + +test("pending recovery blocks cold-start writes but preserves reads", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { const child = new FakeUtility(identity, { pendingStatus: true }); children.push(child); return child; } + }); + const blocked = await supervisor.requestWrite(prepareRequest(), profile); + assert.equal(blocked.ok, false); + assert.equal(blocked.error.code, "PENDING_TRANSACTION"); + assert.equal((await supervisor.request(readRequest("listBackups", "read-after-block"))).ok, true); + assert.deepEqual( + children[0].messages.filter((frame) => frame.kind === "request").map((frame) => frame.envelope.method), + ["getStatus", "listBackups"] + ); +}); + +test("Restore and Prune may converge recovery while starting Watch remains blocked", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { + const child = new FakeUtility(identity, { pendingStatus: true }); + children.push(child); + return child; + } + }); + const restore = await supervisor.requestManaged(restorePrepareRequest(), profile, { + allowRecoveryBlocked: true + }); + assert.equal(restore.ok, true); + const prune = await supervisor.requestManaged( + createCoreRequestEnvelope("pruneBackups", { profile, keepCount: 5 }, "prune-recovery"), + profile, + { allowRecoveryBlocked: true } + ); + assert.equal(prune.ok, true); + const watch = await supervisor.requestManaged( + createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "watch-recovery"), + profile + ); + assert.equal(watch.ok, false); + assert.equal(watch.error.code, "PENDING_TRANSACTION"); + assert.equal(supervisor.snapshot.recoveryBlocked, true); + assert.deepEqual( + children[0].messages.filter((frame) => frame.kind === "request").map((frame) => frame.envelope.method), + ["getStatus", "prepareRestore", "pruneBackups"] + ); +}); + +test("Restore Apply lifecycle is cancellable and blocks update installation while in flight", async () => { + const children = []; + const events = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { + const child = new FakeUtility(identity, { holdApply: true }); + children.push(child); + return child; + } + }); + supervisor.subscribeOperation((event) => events.push(event)); + const applying = supervisor.requestManaged(restoreApplyRequest(), profile, { + allowRecoveryBlocked: true + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(supervisor.snapshot.writeInProgress, true); + assert.deepEqual(events.map((event) => [event.event, event.operation]), [ + ["operation-started", "restore"], + ["progress", undefined] + ]); + assert.equal(supervisor.cancel("restore-apply", operationId), true); + const response = await applying; + assert.equal(response.ok, false); + assert.equal(response.error.code, "OPERATION_CANCELLED"); + assert.equal(supervisor.snapshot.writeInProgress, false); + assert.equal(children[0].messages.at(-1).kind, "cancel"); +}); + +test("restart install gate drains an admitted Watch and rejects later managed writes", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { + const child = new FakeUtility(identity, { responseDelayMs: 25 }); + children.push(child); + return child; + } + }); + const firstWatch = supervisor.requestManaged( + createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "watch-before-restart"), + profile + ); + assert.equal(supervisor.snapshot.writeInProgress, true); + const restartLease = supervisor.tryBeginRestartInstall(); + assert.ok(restartLease); + let drained = false; + const drain = restartLease.waitForWrites().then(() => { drained = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(drained, false); + const rejected = await supervisor.requestManaged( + createCoreRequestEnvelope("startWatch", { profile, includeStateDb: true }, "watch-after-restart"), + profile + ); + assert.equal(rejected.ok, false); + assert.equal(rejected.error.code, "OPERATION_BUSY"); + assert.equal(rejected.error.details.busyScope, "codex-home"); + assert.equal((await firstWatch).ok, true); + await drain; + assert.equal(drained, true); + assert.equal(supervisor.snapshot.writeInProgress, false); + restartLease.release(); + await supervisor.shutdown(); +}); + +test("apply lifecycle is correlated and cancellation waits for the terminal response", async () => { + const children = []; + const events = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { const child = new FakeUtility(identity, { holdApply: true }); children.push(child); return child; } + }); + supervisor.subscribeOperation((event) => events.push(event)); + const applying = supervisor.requestWrite(applyRequest(), profile); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(events.map((event) => event.event), ["operation-started", "progress"]); + assert.equal(supervisor.cancel("apply-1", operationId), true); + const response = await applying; + assert.equal(response.ok, false); + assert.equal(response.error.code, "OPERATION_CANCELLED"); + assert.equal(response.operationId, operationId); + assert.equal(children[0].messages.at(-1).kind, "cancel"); +}); + +test("cancel before operation-started rejects a forged operationId without killing Runtime", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { + const child = new FakeUtility(identity, { holdApplyBeforeStart: true }); + children.push(child); + return child; + } + }); + const applying = supervisor.requestWrite(applyRequest("apply-before-start"), profile); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(supervisor.cancel("apply-before-start", operationId), false); + assert.equal(children[0].messages.some((frame) => frame.kind === "cancel"), false); + assert.equal(supervisor.snapshot.state, "ready"); + children[0].exit(); + assert.equal((await applying).error.code, "CORE_RUNTIME_CRASHED"); +}); + +test("an Apply failure before operation-started remains a normal Core failure", async () => { + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { return new FakeUtility(identity, { failApplyBeforeStart: true }); } + }); + const response = await supervisor.requestWrite(applyRequest(), profile); + assert.equal(response.ok, false); + assert.equal(response.error.code, "PLAN_EXPIRED"); + assert.equal(response.operationId, undefined); + assert.equal(supervisor.snapshot.state, "ready"); +}); + +test("runtime crash rejects every pending request and next read restarts with preflight", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { + const child = new FakeUtility(identity, children.length === 0 ? { holdRequests: true } : {}); + children.push(child); + return child; + } + }); + const first = supervisor.request(readRequest("getStatus", "pending-1")); + const second = supervisor.request(readRequest("listBackups", "pending-2")); + await new Promise((resolve) => setImmediate(resolve)); + children[0].exit(); + for (const response of await Promise.all([first, second])) { + assert.equal(response.ok, false); + assert.equal(response.error.code, "CORE_RUNTIME_CRASHED"); + } + const recovered = await supervisor.request(readRequest("listBackups", "after-crash")); + assert.equal(recovered.ok, true); + assert.deepEqual( + children[1].messages.filter((frame) => frame.kind === "request").map((frame) => frame.envelope.method), + ["getStatus", "listBackups"] + ); +}); + +test("test crash hook settles pending requests without waiting for a Utility exit event", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { + const child = new FakeUtility(identity, { + holdRequests: true, + deferExitOnKill: true + }); + children.push(child); + return child; + } + }); + const pending = supervisor.request(readRequest("getStatus", "test-crash-pending")); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(supervisor.crashForTest(), true); + const response = await pending; + assert.equal(response.ok, false); + assert.equal(response.error.code, "CORE_RUNTIME_CRASHED"); + assert.equal(supervisor.snapshot.state, "crashed"); + assert.equal(children[0].killCalls, 1); + assert.equal(children[0].exited, false); + children[0].exit(); + assert.equal(supervisor.snapshot.state, "crashed"); + await supervisor.shutdown(); +}); + +test("test crash hook refuses to detach a Runtime while its handshake is pending", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + handshakeTimeoutMs: 100, + spawnUtility(identity) { + const child = new FakeUtility(identity, { holdHello: true }); + children.push(child); + return child; + } + }); + const pending = supervisor.request(readRequest("getStatus", "starting-test-crash")); + assert.equal(supervisor.snapshot.state, "starting"); + assert.equal(supervisor.crashForTest(), false); + assert.equal(children[0].killCalls, 0); + children[0].exit(); + const response = await pending; + assert.equal(response.ok, false); + assert.equal(response.error.code, "CORE_RUNTIME_CRASHED"); + assert.equal(supervisor.snapshot.state, "crashed"); + await supervisor.shutdown(); +}); + +test("unknown dispatch operation event fails the generation closed", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { const child = new FakeUtility(identity); children.push(child); return child; } + }); + assert.equal((await supervisor.request(readRequest("getStatus", "activate"))).ok, true); + children[0].emitMessage(createRuntimeOperationEventFrame( + 1, + "22222222-2222-4222-8222-222222222222", + createCoreOperationStartedEnvelope("unknown", operationId, "sync") + )); + assert.equal(supervisor.snapshot.state, "crashed"); +}); + +test("incompatible read-only capability hello fails before C8 business dispatch", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + handshakeTimeoutMs: 100, + spawnUtility(identity) { const child = new FakeUtility(identity, { readOnlyHello: true }); children.push(child); return child; } + }); + const response = await supervisor.request(readRequest("getStatus", "bad-hello")); + assert.equal(response.ok, false); + assert.equal(response.error.code, "PROTOCOL_VERSION_MISMATCH"); + assert.equal(children[0].messages.length, 0); +}); + +test("read timeout kills its generation before a late response can alias", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + requestTimeoutMs: 5, + spawnUtility(identity) { + const child = new FakeUtility(identity, children.length === 0 ? { responseDelayMs: 25 } : {}); + children.push(child); + return child; + } + }); + const timedOut = await supervisor.request(readRequest("getStatus", "late")); + assert.equal(timedOut.ok, false); + assert.equal(timedOut.error.code, "INTERNAL_ERROR"); + assert.equal(supervisor.snapshot.state, "crashed"); + await new Promise((resolve) => setTimeout(resolve, 35)); + assert.equal((await supervisor.request(readRequest("getStatus", "late"))).ok, true); + assert.equal(children.length, 2); +}); + +test("write timeout is a Runtime crash, never a cancellation, and the restart preflights", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + writeRequestTimeoutMs: 5, + spawnUtility(identity) { + const child = new FakeUtility( + identity, + children.length === 0 ? { holdApplyBeforeStart: true } : {} + ); + children.push(child); + return child; + } + }); + const timedOut = await supervisor.requestWrite(applyRequest("write-timeout"), profile); + assert.equal(timedOut.ok, false); + assert.equal(timedOut.error.code, "CORE_RUNTIME_CRASHED"); + assert.equal(children[0].messages.some((frame) => frame.kind === "cancel"), false); + assert.equal(supervisor.snapshot.state, "crashed"); + const recovered = await supervisor.request(readRequest("listBackups", "after-write-timeout")); + assert.equal(recovered.ok, true); + assert.equal(children.length, 2); + assert.deepEqual( + children[1].messages.filter((frame) => frame.kind === "request").map((frame) => frame.envelope.method), + ["getStatus", "listBackups"] + ); +}); + +test("shutdown before activation permanently rejects later requests", async () => { + const children = []; + const supervisor = new CoreRuntimeSupervisor({ + appVersion: "0.5.0", + spawnUtility(identity) { const child = new FakeUtility(identity); children.push(child); return child; } + }); + await supervisor.shutdown(); + const response = await supervisor.request(readRequest("getStatus", "disposed")); + assert.equal(response.ok, false); + assert.equal(response.error.code, "INTERNAL_ERROR"); + assert.equal(children.length, 0); +}); diff --git a/apps/desktop/tests/security-policy.test.mjs b/apps/desktop/tests/security-policy.test.mjs new file mode 100644 index 0000000..0fe9504 --- /dev/null +++ b/apps/desktop/tests/security-policy.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createRendererAssetResponse, + createSecureWebPreferences, + resolveRendererAsset +} from "../dist/main/security-policy.js"; + +test("custom protocol resolves only regular assets under the renderer root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-c6-assets-")); + try { + await fs.mkdir(path.join(root, "assets"), { recursive: true }); + await fs.writeFile(path.join(root, "index.html"), "safe", "utf8"); + await fs.writeFile(path.join(root, "assets", "app.js"), "export {};", "utf8"); + assert.equal( + (await resolveRendererAsset(root, "cps-app://app/index.html")).filePath, + await fs.realpath(path.join(root, "index.html")) + ); + assert.equal( + (await resolveRendererAsset(root, "cps-app://app/assets/app.js")).contentType, + "text/javascript; charset=utf-8" + ); + for (const candidate of [ + "cps-app://evil/index.html", + "cps-app://app/index.html?path=outside", + "cps-app://app/%252e%252e/secret", + "cps-app://app/C:%5CWindows%5Cwin.ini", + "file:///etc/passwd" + ]) await assert.rejects(resolveRendererAsset(root, candidate)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("custom protocol responses carry strict CSP and fixed MIME headers", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-c6-response-")); + try { + await fs.writeFile(path.join(root, "index.html"), "safe", "utf8"); + const response = await createRendererAssetResponse( + root, + new Request("cps-app://app/index.html") + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "text/html; charset=utf-8"); + assert.match(response.headers.get("content-security-policy"), /script-src 'self'/); + assert.doesNotMatch(response.headers.get("content-security-policy"), /unsafe-inline|unsafe-eval/); + assert.equal(await response.text(), "safe"); + assert.equal((await createRendererAssetResponse( + root, + new Request("cps-app://app/index.html", { method: "POST" }) + )).status, 405); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("BrowserWindow preferences are fail-closed and immutable", () => { + const preferences = createSecureWebPreferences("C:\\synthetic\\preload.cjs"); + assert.deepEqual(preferences, { + preload: "C:\\synthetic\\preload.cjs", + nodeIntegration: false, + nodeIntegrationInWorker: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + webviewTag: false + }); + assert.equal(Object.isFrozen(preferences), true); +}); diff --git a/apps/desktop/tests/update-policy.test.mjs b/apps/desktop/tests/update-policy.test.mjs new file mode 100644 index 0000000..6227a55 --- /dev/null +++ b/apps/desktop/tests/update-policy.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getDesktopInstallBlockedReason, + getDesktopUpdateUnavailableReason, + supportedUpdateTarget +} from "../dist/main/update-policy.js"; + +function blocked(overrides = {}) { + return getDesktopInstallBlockedReason({ + hasActiveWatches: false, + recoveryVerified: true, + supervisor: { + snapshot: { + recoveryBlocked: false, + writeInProgress: false + } + }, + ...overrides + }); +} + +function unavailable(overrides = {}) { + return getDesktopUpdateUnavailableReason({ + isPackaged: false, + platform: "win32", + arch: "x64", + releaseAuthorized: false, + configured: false, + ...overrides + }); +} + +test("update install policy prioritizes recovery, writes, Watch and unverifiable state", () => { + assert.equal(blocked({ + supervisor: { snapshot: { recoveryBlocked: true, writeInProgress: true } } + }), "pending-recovery"); + assert.equal(blocked({ + supervisor: { snapshot: { recoveryBlocked: false, writeInProgress: true } } + }), "write-in-progress"); + assert.equal(blocked({ hasActiveWatches: true }), "watch-active"); + assert.equal(blocked({ recoveryVerified: false }), "recovery-unverified"); + assert.equal(blocked(), null); +}); + +test("update policy is unavailable until a supported packaged channel is configured", () => { + assert.equal(unavailable(), "not-packaged"); + assert.equal(unavailable({ isPackaged: true, platform: "freebsd" }), "unsupported-target"); + assert.equal(unavailable({ isPackaged: true, platform: "darwin", arch: "arm64" }), "not-authorized"); + assert.equal(unavailable({ isPackaged: true, platform: "linux", arch: "x64" }), "not-authorized"); + assert.equal(unavailable({ isPackaged: true, platform: "win32", arch: "arm64" }), "unsupported-target"); + assert.equal(unavailable({ isPackaged: true, releaseAuthorized: true }), "not-configured"); + assert.equal(unavailable({ isPackaged: true, releaseAuthorized: true, configured: true }), null); + assert.equal(supportedUpdateTarget("darwin", "arm64"), true); + assert.equal(supportedUpdateTarget("linux", "arm64"), false); +}); diff --git a/apps/desktop/tests/updater.test.mjs b/apps/desktop/tests/updater.test.mjs new file mode 100644 index 0000000..ff96333 --- /dev/null +++ b/apps/desktop/tests/updater.test.mjs @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { DesktopUpdateController } from "../dist/main/updater.js"; + +class FakeUpdaterPort extends EventEmitter { + checks = 0; + downloads = 0; + installs = 0; + checkResult = { updateInfo: { version: "1.0.1" } }; + checkError = null; + downloadError = null; + installError = null; + + async checkForUpdates() { + this.checks += 1; + this.emit("checking-for-update"); + if (this.checkError) throw this.checkError; + if (this.checkResult?.updateInfo?.version) { + this.emit("update-available", this.checkResult.updateInfo); + } else { + this.emit("update-not-available", {}); + } + return this.checkResult; + } + + async downloadUpdate() { + this.downloads += 1; + if (this.downloadError) throw this.downloadError; + this.emit("download-progress", { percent: 51.4, transferred: 123, total: 240 }); + this.emit("update-downloaded", { version: "1.0.1", files: [{ url: "secret" }] }); + return ["C:\\private\\update.exe"]; + } + + quitAndInstall() { + if (this.installError) throw this.installError; + this.installs += 1; + } +} + +function fixture(overrides = {}) { + const port = new FakeUpdaterPort(); + const snapshot = { recoveryBlocked: false, writeInProgress: false }; + const state = { + watches: false, + verification: "clear", + beforeInstall: 0, + gateClosed: false, + gateReleases: 0, + watchVerification: "active", + watchVerificationCalls: 0, + waitForWrites: async () => {} + }; + const controller = new DesktopUpdateController({ + isPackaged: true, + platform: "win32", + arch: "x64", + appVersion: "1.0.0", + releaseAuthorized: true, + configured: true, + supervisor: { + snapshot, + tryBeginRestartInstall() { + if (state.gateClosed) return null; + state.gateClosed = true; + let released = false; + return { + waitForWrites: () => state.waitForWrites(), + release() { + if (released) return; + released = true; + state.gateClosed = false; + state.gateReleases += 1; + } + }; + } + }, + hasActiveWatches: () => state.watches, + verifyNoActiveWatches: async () => { + state.watchVerificationCalls += 1; + if (state.watchVerification !== "clear") return false; + state.watches = false; + return true; + }, + verifyRecoveryState: async () => state.verification, + beforeInstall: async () => { state.beforeInstall += 1; }, + createPort: async () => port, + ...overrides + }); + return { controller, port, snapshot, state }; +} + +test("updater exposes a redacted Main-only check, download and install state machine", async () => { + const { controller, port, state } = fixture(); + assert.deepEqual(controller.status, { + schemaVersion: 2, + state: "idle", + installAllowed: false + }); + assert.deepEqual(await controller.check(), { + schemaVersion: 2, + state: "available", + installAllowed: false, + version: "1.0.1" + }); + const downloaded = await controller.download(); + assert.deepEqual(downloaded, { + schemaVersion: 2, + state: "downloaded", + installAllowed: true, + version: "1.0.1", + progressPercent: 100 + }); + assert.equal(JSON.stringify(downloaded).includes("private"), false); + assert.equal(JSON.stringify(downloaded).includes("url"), false); + const installing = await controller.install(); + assert.equal(installing.state, "installing"); + assert.equal(installing.installAllowed, false); + assert.equal(controller.restartPending, true); + assert.equal(port.installs, 1); + assert.equal(state.beforeInstall, 1); + controller.dispose(); + assert.equal(port.listenerCount("update-available"), 0); +}); + +test("updater blocks install for writes, Watch, recovery and unverifiable preflight", async () => { + for (const scenario of ["write", "watch", "blocked", "unverifiable"]) { + const { controller, port, snapshot, state } = fixture(); + await controller.check(); + await controller.download(); + if (scenario === "write") snapshot.writeInProgress = true; + if (scenario === "watch") state.watches = true; + if (scenario === "blocked") state.verification = "blocked"; + if (scenario === "unverifiable") state.verification = "unverifiable"; + const result = await controller.install(); + assert.equal(result.state, "downloaded", scenario); + assert.equal(result.installAllowed, false, scenario); + assert.equal(result.installBlockedReason, scenario === "write" + ? "write-in-progress" + : scenario === "watch" + ? "watch-active" + : scenario === "blocked" + ? "pending-recovery" + : "recovery-unverified"); + assert.equal(controller.restartPending, false, scenario); + assert.equal(port.installs, 0, scenario); + controller.dispose(); + } +}); + +test("updater closes admission, drains an already admitted Watch, and reopens without installing", async () => { + const { controller, port, snapshot, state } = fixture(); + await controller.check(); + await controller.download(); + let releaseWrite; + state.waitForWrites = () => new Promise((resolve) => { releaseWrite = resolve; }); + snapshot.writeInProgress = true; + const installing = controller.install(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(controller.restartPending, true); + assert.equal(state.gateClosed, true); + assert.equal(port.installs, 0); + state.watches = true; + snapshot.writeInProgress = false; + releaseWrite(); + const result = await installing; + assert.equal(result.state, "downloaded"); + assert.equal(result.installBlockedReason, "watch-active"); + assert.equal(port.installs, 0); + assert.equal(controller.restartPending, false); + assert.equal(state.gateClosed, false); + assert.equal(state.gateReleases, 1); +}); + +test("updater rechecks an autonomously stopped Watch after closing restart admission", async () => { + const { controller, port, state } = fixture(); + await controller.check(); + await controller.download(); + state.watches = true; + state.watchVerification = "clear"; + const result = await controller.install(); + assert.equal(result.state, "installing"); + assert.equal(state.watchVerificationCalls, 1); + assert.equal(state.watches, false); + assert.equal(port.installs, 1); +}); + +test("updater reopens write admission when the installer fails synchronously", async () => { + const { controller, port, state } = fixture(); + await controller.check(); + await controller.download(); + port.installError = new Error("installer failed"); + const result = await controller.install(); + assert.equal(result.state, "error"); + assert.equal(result.reason, "install-failed"); + assert.equal(controller.restartPending, false); + assert.equal(state.gateClosed, false); + assert.equal(state.gateReleases, 1); +}); + +test("updater fails closed without leaking raw errors or allowing invalid event data", async () => { + const checkFailure = fixture(); + checkFailure.port.checkError = Object.assign(new Error("https://token.example/private"), { + path: "C:\\secret" + }); + assert.deepEqual(await checkFailure.controller.check(), { + schemaVersion: 2, + state: "error", + installAllowed: false, + reason: "check-failed" + }); + + const invalid = fixture(); + invalid.port.checkForUpdates = async function () { + this.emit("update-available", { version: "bad version", releaseNotes: "secret" }); + return { updateInfo: { version: "bad version" } }; + }; + assert.deepEqual(await invalid.controller.check(), { + schemaVersion: 2, + state: "error", + installAllowed: false, + reason: "check-failed" + }); + assert.equal(invalid.port.downloads, 0); +}); + +test("updater stays disabled before a packaged, authorized and configured release", async () => { + const created = []; + const controller = new DesktopUpdateController({ + isPackaged: false, + platform: "win32", + arch: "x64", + appVersion: "0.0.0", + releaseAuthorized: false, + configured: false, + supervisor: { + snapshot: { recoveryBlocked: false, writeInProgress: false }, + tryBeginRestartInstall: () => null + }, + hasActiveWatches: () => false, + verifyNoActiveWatches: async () => true, + verifyRecoveryState: async () => "clear", + createPort: async () => { created.push(true); return new FakeUpdaterPort(); } + }); + assert.deepEqual(await controller.check(), { + schemaVersion: 2, + state: "disabled", + installAllowed: false, + reason: "not-packaged" + }); + assert.equal(created.length, 0); +}); + +test("unsigned candidate never creates an updater port or schedules network work", async () => { + const created = []; + const controller = new DesktopUpdateController({ + isPackaged: true, + platform: "win32", + arch: "x64", + appVersion: "1.0.0-rc.205", + releaseAuthorized: false, + configured: true, + supervisor: { + snapshot: { recoveryBlocked: false, writeInProgress: false }, + tryBeginRestartInstall: () => null + }, + hasActiveWatches: () => false, + verifyNoActiveWatches: async () => true, + verifyRecoveryState: async () => "clear", + createPort: async () => { created.push(true); return new FakeUpdaterPort(); } + }); + assert.deepEqual(controller.status, { + schemaVersion: 2, + state: "disabled", + installAllowed: false, + reason: "not-authorized" + }); + controller.scheduleInitialCheck(0); + await new Promise((resolve) => setTimeout(resolve, 10)); + await controller.check(); + await controller.download(); + await controller.install(); + assert.equal(created.length, 0); + controller.dispose(); +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..32044cb --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ], + "references": [ + { "path": "../../packages/contracts" }, + { "path": "../../packages/core-client" }, + { "path": "../../packages/design-system" }, + { "path": "../../packages/app-ui" } + ] +} diff --git a/apps/web/checks/web-composition.contract.mjs b/apps/web/checks/web-composition.contract.mjs new file mode 100644 index 0000000..c592639 --- /dev/null +++ b/apps/web/checks/web-composition.contract.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import test from "node:test"; + +test("Web composition injects HttpCoreClient and keeps host APIs separate", async () => { + const main = await fs.readFile(new URL("../src/main.tsx", import.meta.url), "utf8"); + const pairing = await fs.readFile(new URL("../src/pairing.ts", import.meta.url), "utf8"); + const html = await fs.readFile(new URL("../index.html", import.meta.url), "utf8"); + const themeBootstrap = await fs.readFile(new URL("../public/theme-bootstrap.js", import.meta.url), "utf8"); + assert.match(main, /new HttpCoreClient/); + assert.match(main, / match[1]); + assert.equal(storedKeys.every((key) => /DEVICE_STORAGE_KEY|LOCALE_STORAGE_KEY|THEME_STORAGE_KEY/.test(key)), true); + assert.ok(html.indexOf("/theme-bootstrap.js") < html.indexOf("/src/main.tsx")); + assert.match(themeBootstrap, /cps\.preference\.theme/); + assert.match(themeBootstrap, /theme === "system" \|\| theme === "light" \|\| theme === "dark"/); + assert.doesNotMatch(themeBootstrap, /eval|Function\s*\(|innerHTML|document\.write/); +}); diff --git a/apps/web/e2e/web-ui.spec.mjs b/apps/web/e2e/web-ui.spec.mjs new file mode 100644 index 0000000..c02763f --- /dev/null +++ b/apps/web/e2e/web-ui.spec.mjs @@ -0,0 +1,282 @@ +import { expect, test } from "@playwright/test"; + +import { createWebUiFixture } from "../../../scripts/run-web-ui-fixture.js"; + +let fixture; + +test.beforeAll(async () => { + fixture = await createWebUiFixture(); +}); + +test.afterAll(async () => { + await fixture?.close(); +}); + +test("paired production UI keeps history lazy and Apply opaque", async ({ page }) => { + const consoleErrors = []; + const coreRequests = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("request", (request) => { + if (request.url() !== `${fixture.origin}/api/core` || request.method() !== "POST") return; + const payload = request.postDataJSON(); + coreRequests.push(payload); + }); + + const response = await page.goto(fixture.pairingUrl); + expect(response?.status()).toBe(200); + const contentSecurityPolicy = response?.headers()["content-security-policy"] ?? ""; + expect(contentSecurityPolicy).toContain("style-src 'self'"); + expect(contentSecurityPolicy).not.toContain("unsafe-inline"); + const nonce = /script-src 'self' 'nonce-([^']+)'/.exec(contentSecurityPolicy)?.[1]; + expect(nonce).toBeTruthy(); + expect(await page.locator("script[nonce]").evaluate((element) => element.nonce)).toBe(nonce); + await expect(page).toHaveURL(`${fixture.origin}/`); + await expect(page.getByRole("heading", { name: "Provider metadata overview" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await page.keyboard.press("Tab"); + await expect(page.getByRole("link", { name: "Skip to content" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.locator("#main-content")).toBeFocused(); + expect(coreRequests.some((entry) => entry.method === "listHistory")).toBe(false); + expect(coreRequests.some((entry) => entry.method === "getHistorySession")).toBe(false); + expect(JSON.stringify(coreRequests)).not.toContain("C5_BODY_ONLY_MARKER"); + expect(JSON.stringify(coreRequests)).not.toMatch(/codexHome|sqliteHome|cwd/i); + + const pages = [ + ["Sync", "Sync current Provider"], + ["Switch Provider", "Switch Provider"], + ["Backups / Restore", "Backups and Restore"], + ["Profiles", "Profiles"], + ["Diagnostics", "Diagnostics"], + ["Settings", "Settings"], + ["Overview", "Provider metadata overview"] + ]; + for (const [navigation, heading] of pages) { + await page.getByRole("button", { name: navigation, exact: true }).click(); + await expect(page.getByRole("heading", { name: heading, level: 1 })).toBeVisible(); + } + + await page.getByRole("button", { name: "History", exact: true }).click(); + await expect(page.getByRole("heading", { name: "History", level: 1 })).toBeVisible(); + await expect(page.getByText("Synthetic History")).toBeVisible(); + expect(coreRequests.filter((entry) => entry.method === "listHistory")).toHaveLength(1); + expect(coreRequests.some((entry) => entry.method === "getHistorySession")).toBe(false); + await expect(page.getByText("C5_BODY_ONLY_MARKER")).toHaveCount(0); + + await page.getByRole("button", { name: "Open session" }).click(); + await expect(page.getByText("C5_BODY_ONLY_MARKER")).toBeVisible(); + expect(coreRequests.filter((entry) => entry.method === "getHistorySession")).toHaveLength(1); + await page.getByRole("button", { name: "Back to sessions" }).click(); + await expect(page.getByText("C5_BODY_ONLY_MARKER")).toHaveCount(0); + await page.getByRole("button", { name: "Overview", exact: true }).click(); + await page.getByRole("button", { name: "History", exact: true }).click(); + await expect(page.getByText("C5_BODY_ONLY_MARKER")).toHaveCount(0); + + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByLabel("Language").selectOption("zh-CN"); + await expect(page.getByRole("heading", { name: "设置", level: 1 })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + await expect(page.getByLabel("存储配置")).toBeVisible(); + await expect(page.locator('nav[aria-label="主导航"]')).toBeVisible(); + await expect(page.getByText("英文为兜底语言")).toBeVisible(); + await page.getByLabel("语言").selectOption("en"); + await expect(page.getByRole("heading", { name: "Settings", level: 1 })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await page.getByRole("button", { name: "Dark", exact: true }).click(); + await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark"); + const coldPage = await page.context().newPage(); + let releaseBundle = () => {}; + const bundleGate = new Promise((resolve) => { releaseBundle = resolve; }); + let observeBundle = () => {}; + const bundleObserved = new Promise((resolve) => { observeBundle = resolve; }); + await coldPage.route(/\/assets\/index-[^/]+\.js$/, async (route) => { + observeBundle(); + await bundleGate; + await route.continue(); + }); + try { + await coldPage.goto(fixture.origin, { waitUntil: "commit" }); + await bundleObserved; + await expect.poll(() => coldPage.evaluate(() => document.documentElement.dataset.theme)).toBe("dark"); + expect(await coldPage.locator("#root").textContent()).toBe(""); + } finally { + releaseBundle(); + } + await expect(coldPage.getByRole("heading", { name: "Provider metadata overview" })).toBeVisible(); + await coldPage.close(); + await page.emulateMedia({ reducedMotion: "reduce" }); + expect(parseFloat(await page.getByRole("button", { name: "Overview", exact: true }).evaluate((element) => getComputedStyle(element).transitionDuration))).toBeLessThanOrEqual(0.001); + + await page.setViewportSize({ width: 640, height: 900 }); + for (const [navigation, heading] of [...pages, ["History", "History"]]) { + await page.getByRole("button", { name: navigation, exact: true }).click(); + await expect(page.getByRole("heading", { name: heading, level: 1 })).toBeVisible(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const layout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth + })); + expect(layout.scrollWidth, `${navigation} overflowed the 640px/200% equivalent viewport`).toBeLessThanOrEqual(layout.clientWidth); + } + + await page.setViewportSize({ width: 380, height: 700 }); + for (const [navigation, heading] of [...pages, ["History", "History"]]) { + await page.getByRole("button", { name: navigation, exact: true }).click(); + const pageHeading = page.getByRole("heading", { name: heading, level: 1 }); + await expect(pageHeading).toBeVisible(); + await expect(pageHeading).toBeInViewport(); + const layout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth + })); + expect(layout.scrollWidth, `${navigation} overflowed the 760px window at 200% zoom`).toBeLessThanOrEqual(layout.clientWidth); + } + await expect(page.getByLabel("Profile")).toBeVisible(); + await expect(page.getByText("Local service ready", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Sync", exact: true }).click(); + const prepare = page.getByRole("button", { name: "Prepare sync" }); + await prepare.click(); + const planDialog = page.getByRole("dialog", { name: "Review plan" }); + await expect(planDialog).toBeVisible(); + await expect(planDialog.getByText("Rollout files affected")).toBeVisible(); + await expect(planDialog.getByText("A backup will be created before writes.")).toBeVisible(); + await expect(planDialog.getByText("Technical details")).toBeVisible(); + await expect(planDialog.locator("details")).not.toHaveAttribute("open", ""); + await page.keyboard.press("Escape"); + await expect(prepare).toBeFocused(); + + await prepare.click(); + await page.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(page.getByRole("dialog", { name: "Review plan" })).toHaveCount(0); + const resultDialog = page.getByRole("dialog", { name: "Operation result" }); + await expect(resultDialog).toBeVisible(); + const resultLayout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth + })); + expect(resultLayout.scrollWidth).toBeLessThanOrEqual(resultLayout.clientWidth); + await resultDialog.getByRole("button", { name: "Close" }).last().click(); + await expect(prepare).toBeFocused(); + const applyRequest = coreRequests.findLast((entry) => entry.method === "applySync"); + expect(applyRequest).toBeDefined(); + expect(Object.keys(applyRequest.payload).sort()).toEqual(["planId", "schemaVersion"]); + expect(applyRequest.payload.schemaVersion).toBe(1); + expect(typeof applyRequest.payload.planId).toBe("string"); + expect(consoleErrors).toEqual([]); +}); + +test("global partial, recovery, operation and error states are visible", async ({ page }) => { + let recovery = false; + let failStatus = false; + await page.route(`${fixture.origin}/api/core`, async (route) => { + const envelope = route.request().postDataJSON(); + const success = (result) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + protocolVersion: 1, + requestId: envelope.requestId, + ok: true, + result + }) + }); + if (envelope.method === "getStatus") { + if (failStatus) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + protocolVersion: 1, + requestId: envelope.requestId, + ok: false, + error: { + code: "INTERNAL_ERROR", + message: "An internal error occurred.", + severity: "fatal", + retryable: false, + recoveryRequired: false + } + }) + }); + return; + } + await success({ + schemaVersion: 1, + snapshotAt: "2026-08-26T00:00:00.000Z", + storageRevision: "storage-r1", + profile: { + id: envelope.payload.profile.profileId, + revision: envelope.payload.profile.profileRevision + }, + currentProvider: "openai", + rolloutCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + sqliteCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: recovery, + pendingTransactions: recovery ? [{ operationId: "recovery-operation", state: "recovery-required" }] : [], + operationInProgress: recovery ? { operationId: "active-operation", operation: "restore", busyScope: "state-db" } : null, + rolloutScanComplete: true, + lockedRolloutFiles: [] + }); + return; + } + if (envelope.method === "prepareSync") { + await success({ + schemaVersion: 1, + planId: "synthetic-plan-id", + operation: "sync", + createdAt: "2026-08-26T00:00:00.000Z", + expiresAt: "2026-08-26T00:10:00.000Z", + profile: { + id: envelope.payload.profile.profileId, + revision: envelope.payload.profile.profileRevision + }, + storageRevision: "storage-r1", + configRevision: "config-r1", + rolloutRevision: "rollout-r1", + stateDbRevision: "state-db-r1", + target: { provider: "openai", model: null }, + impact: { rolloutFilesToChange: 1, sqliteRowsToChange: 0, backupExpected: true }, + warnings: [], + requiresConfirmation: true + }); + return; + } + if (envelope.method === "applySync") { + await success({ + schemaVersion: 1, + operationId: "synthetic-operation", + operation: "sync", + outcome: "partial", + backup: { backupId: "synthetic-backup" }, + warnings: ["One or more rollout files are locked and may be skipped."], + result: { skippedLockedRolloutCount: 1 } + }); + return; + } + await route.continue(); + }); + + await page.goto(fixture.issuePairingUrl()); + await page.getByRole("button", { name: "Sync", exact: true }).click(); + await page.getByRole("button", { name: "Prepare sync" }).click(); + await page.getByRole("button", { name: "Confirm and apply" }).click(); + await expect(page.getByText("Completed with locked rollout files skipped.", { exact: true })).toBeVisible(); + + recovery = true; + await page.reload(); + await expect(page.getByText("RECOVERY_REQUIRED")).toBeVisible(); + await expect(page.locator("#main-content").getByText("Operation in progress", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Sync", exact: true }).click(); + await expect(page.getByRole("button", { name: "Prepare sync" })).toBeDisabled(); + + recovery = false; + failStatus = true; + await page.reload(); + await expect(page.getByRole("alert")).toContainText("An internal error occurred."); +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..f4dfbc6 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + + Codex Provider Sync + + +
+ + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..8ccd925 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@codex-provider-sync/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "test": "node --test checks/web-composition.contract.mjs", + "test:e2e": "playwright test" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@codex-provider-sync/app-ui": "0.0.0", + "@codex-provider-sync/core-client": "0.0.0", + "@codex-provider-sync/design-system": "0.0.0" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "6.1.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "tailwindcss": "4.3.3", + "vite": "8.2.2" + } +} diff --git a/apps/web/playwright.config.mjs b/apps/web/playwright.config.mjs new file mode 100644 index 0000000..0d98b8b --- /dev/null +++ b/apps/web/playwright.config.mjs @@ -0,0 +1,17 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + forbidOnly: true, + testDir: "./e2e", + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: "list", + use: { + ...devices["Desktop Chrome"], + headless: true, + screenshot: "off", + trace: "off", + video: "off" + } +}); diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..d0188e0 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/theme-bootstrap.js b/apps/web/public/theme-bootstrap.js new file mode 100644 index 0000000..8692a8f --- /dev/null +++ b/apps/web/public/theme-bootstrap.js @@ -0,0 +1,10 @@ +(() => { + try { + const theme = globalThis.localStorage.getItem("cps.preference.theme"); + if (theme === "system" || theme === "light" || theme === "dark") { + document.documentElement.dataset.theme = theme; + } + } catch { + // Preferences are optional; the system theme remains the safe default. + } +})(); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..6153bb9 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,44 @@ +import { AppUi } from "@codex-provider-sync/app-ui"; +import { HttpCoreClient } from "@codex-provider-sync/core-client"; +import React from "react"; +import { createRoot } from "react-dom/client"; + +import { createAuthenticatedFetch, createHostClient, forgetBrowser, initializePairing, preferenceStore } from "./pairing.js"; +import "./styles.css"; + +const root = createRoot(document.getElementById("root")!); +const deviceCredential = await initializePairing(); + +if (!deviceCredential) { + root.render( + +
+
+

Codex Provider Sync

+

This browser is not paired. Run codex-provider web again and open the new one-time link.

+
+
+
+ ); +} else { + const host = createHostClient(deviceCredential); + const authenticatedFetch = createAuthenticatedFetch(deviceCredential); + const core = new HttpCoreClient({ + baseUrl: globalThis.location.origin, + fetch: authenticatedFetch + }); + globalThis.addEventListener("cps:pairing-required", () => globalThis.location.reload(), { once: true }); + root.render( + + forgetBrowser(host)} + preferences={preferenceStore} + surface="web" + /> + + ); +} diff --git a/apps/web/src/pairing.ts b/apps/web/src/pairing.ts new file mode 100644 index 0000000..d2aa6a4 --- /dev/null +++ b/apps/web/src/pairing.ts @@ -0,0 +1,106 @@ +import type { HostClient, HostProfile, PreferenceStore, SaveProfileInput } from "@codex-provider-sync/app-ui"; + +const DEVICE_STORAGE_KEY = "cps.web.deviceCredential"; +const LOCALE_STORAGE_KEY = "cps.preference.locale"; +const THEME_STORAGE_KEY = "cps.preference.theme"; + +function credential(): string { + return globalThis.localStorage.getItem(DEVICE_STORAGE_KEY) ?? ""; +} + +async function jsonResponse(response: Response): Promise> { + const payload = await response.json().catch(() => ({})); + return payload && typeof payload === "object" && !Array.isArray(payload) + ? payload as Record + : {}; +} + +function safeHostError(payload: Record, fallback: string): Error { + const code = typeof payload.code === "string" ? payload.code : "HOST_REQUEST_FAILED"; + return Object.assign(new Error(`${fallback} (${code})`), { code }); +} + +export async function initializePairing(): Promise { + const fragment = new URLSearchParams(globalThis.location.hash.replace(/^#/, "")); + const pairingToken = fragment.get("pair"); + if (!pairingToken) return credential() || null; + globalThis.history.replaceState(null, "", `${globalThis.location.pathname}${globalThis.location.search}`); + const response = await globalThis.fetch("/api/pair", { + method: "POST", + redirect: "error", + credentials: "same-origin", + headers: { "X-Codex-Provider-Pairing": pairingToken } + }); + const payload = await jsonResponse(response); + const deviceCredential = typeof payload.deviceCredential === "string" ? payload.deviceCredential : ""; + if (!response.ok || !deviceCredential) return null; + globalThis.localStorage.setItem(DEVICE_STORAGE_KEY, deviceCredential); + return deviceCredential; +} + +export function createAuthenticatedFetch(deviceCredential: string): typeof globalThis.fetch { + return async (input, init = {}) => { + const response = await globalThis.fetch(input, { + ...init, + headers: { ...Object.fromEntries(new Headers(init.headers).entries()), "X-Codex-Provider-Device": deviceCredential } + }); + if (response.status === 403) { + const payload = await jsonResponse(response.clone()); + if (payload.code === "PAIRING_REQUIRED") { + globalThis.localStorage.removeItem(DEVICE_STORAGE_KEY); + globalThis.dispatchEvent(new CustomEvent("cps:pairing-required")); + } + } + return response; + }; +} + +export function createHostClient(deviceCredential: string): HostClient { + const fetch = createAuthenticatedFetch(deviceCredential); + const headers = { "Content-Type": "application/json" }; + const getProfiles = async (signal?: AbortSignal): Promise => { + const response = await fetch("/api/profiles", { credentials: "same-origin", redirect: "error", signal }); + const payload = await jsonResponse(response); + if (!response.ok || !Array.isArray(payload.profiles)) throw safeHostError(payload, "Unable to load profiles"); + return payload.profiles as unknown as HostProfile[]; + }; + return { + listProfiles: getProfiles, + async saveProfile(input: SaveProfileInput, signal?: AbortSignal): Promise { + const response = await fetch("/api/profiles/save", { method: "POST", credentials: "same-origin", redirect: "error", headers, body: JSON.stringify(input), signal }); + const payload = await jsonResponse(response); + if (!response.ok || !payload.profile) throw safeHostError(payload, "Unable to save profile"); + return payload.profile as unknown as HostProfile; + }, + async deleteProfile(profileId: string, profileRevision: string, signal?: AbortSignal): Promise { + const response = await fetch("/api/profiles/delete", { method: "POST", credentials: "same-origin", redirect: "error", headers, body: JSON.stringify({ profileId, profileRevision }), signal }); + const payload = await jsonResponse(response); + if (!response.ok) throw safeHostError(payload, "Unable to delete profile"); + }, + async forgetBrowser(): Promise { + try { + await fetch("/api/access/forget", { method: "POST", credentials: "same-origin", redirect: "error", headers, body: "{}" }); + } finally { + globalThis.localStorage.removeItem(DEVICE_STORAGE_KEY); + } + } + }; +} + +export const preferenceStore: PreferenceStore = { + getLocale() { + const value = globalThis.localStorage.getItem(LOCALE_STORAGE_KEY); + return value === "zh-CN" || value === "en" ? value : null; + }, + setLocale(locale) { globalThis.localStorage.setItem(LOCALE_STORAGE_KEY, locale); }, + getTheme() { + const value = globalThis.localStorage.getItem(THEME_STORAGE_KEY); + return value === "system" || value === "light" || value === "dark" ? value : null; + }, + setTheme(theme) { globalThis.localStorage.setItem(THEME_STORAGE_KEY, theme); } +}; + +export async function forgetBrowser(host: HostClient): Promise { + await host.forgetBrowser?.(); + globalThis.location.reload(); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..dc58e47 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,7 @@ +@import "tailwindcss"; +@import "@codex-provider-sync/design-system/tokens.css"; +@source "../../../packages/app-ui/src"; + +html { min-width: 320px; background: var(--surface); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--surface); } +button, input, select { font: inherit; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..c6c0e0d --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,19 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root, + plugins: [react(), tailwindcss()], + build: { + outDir: path.resolve(root, "../../web/dist"), + emptyOutDir: true, + sourcemap: false, + target: "es2022" + }, + server: { host: "127.0.0.1", port: 5173 } +}); diff --git a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj index 523285c..4258839 100644 --- a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj +++ b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj @@ -24,9 +24,9 @@ CodexProviderSync Codex Provider Sync Dailin521 - 0.5.0 - 0.5.0.0 - 0.5.0.0 + 1.0.0 + 1.0.0.0 + 1.0.0.0 diff --git a/desktop/CodexProviderSync.Application.Tests/ApplicationServiceTests.cs b/desktop/CodexProviderSync.Application.Tests/ApplicationServiceTests.cs index 93835a4..b9d767b 100644 --- a/desktop/CodexProviderSync.Application.Tests/ApplicationServiceTests.cs +++ b/desktop/CodexProviderSync.Application.Tests/ApplicationServiceTests.cs @@ -208,7 +208,7 @@ public async Task Apply_RejectsMissingMismatchedTamperedAndExpiredPlansWithoutEx } [Fact] - public async Task ConcurrentOperation_IsRejectedImmediatelyWithoutReplacingTheActivePlan() + public async Task ConcurrentStatus_BypassesTheWriteGateAndUsesTheReadOnlyCoreGuard() { TestRig rig = new(); TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -222,12 +222,13 @@ public async Task ConcurrentOperation_IsRejectedImmediatelyWithoutReplacingTheAc Task> active = rig.Service.CreatePlanAsync( new CreateApplicationPlanRequest(new SyncIntent("/first", null, "relay"))); await started.Task; - ApplicationOutcome rejected = await rig.Service.GetStatusAsync( + ApplicationOutcome status = await rig.Service.GetStatusAsync( new ApplicationStatusRequest("/second")); - Assert.Equal(ApplicationOperationLifecycle.Rejected, rejected.Lifecycle); - Assert.Equal("operation_busy", Assert.Single(rejected.Errors).Code); - Assert.Empty(rig.Status.Requests); + Assert.Equal(ApplicationOperationLifecycle.Succeeded, status.Lifecycle); + Assert.Equal("/second", status.Data!.CodexHome); + Assert.Empty(status.Errors); + Assert.Single(rig.Status.Requests); pending.SetResult(rig.Write.CreatePreview(new SyncIntent("/first", null, "relay"))); ApplicationOutcome completed = await active; diff --git a/desktop/CodexProviderSync.Application/ApplicationService.cs b/desktop/CodexProviderSync.Application/ApplicationService.cs index ff20254..446c741 100644 --- a/desktop/CodexProviderSync.Application/ApplicationService.cs +++ b/desktop/CodexProviderSync.Application/ApplicationService.cs @@ -75,7 +75,8 @@ public Task> GetStatusAsync( token); return OperationResult.Succeeded(status); }, - cancellationToken); + cancellationToken, + useExclusiveGate: false); } public Task> CreatePlanAsync( @@ -265,7 +266,7 @@ private Task>> RunWriteAsync< } catch (ApplicationPortException error) when ( !error.RecoveryRequired - && (error.Code == "target_busy" + && (error.Code is "target_busy" or "lock_unverifiable" || error.Code.StartsWith("plan_", StringComparison.Ordinal))) { await TryCompletePlanAsync(plan.PlanId, ApplicationOperationLifecycle.Rejected); @@ -421,13 +422,16 @@ private ApplicationOperationPlan ValidateApplyAuthorization( private async Task> RunExclusiveAsync( ApplicationOperationKind operation, Func>> run, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool useExclusiveGate = true) where T : class { string operationId = NewId(); DateTimeOffset startedAt = _timeProvider.GetUtcNow(); OperationContext context = new(operationId, startedAt, _timeProvider); - if (Interlocked.CompareExchange(ref _operationInProgress, 1, 0) != 0) + bool gateAcquired = false; + if (useExclusiveGate + && Interlocked.CompareExchange(ref _operationInProgress, 1, 0) != 0) { context.MoveTo(ApplicationOperationLifecycle.Rejected); return BuildOutcome( @@ -438,6 +442,7 @@ private async Task> RunExclusiveAsync( [], [new ApplicationError("operation_busy", "Another Application operation is already in progress.")]); } + gateAcquired = useExclusiveGate; try { @@ -533,7 +538,7 @@ [new ApplicationError( { ApplicationOperationLifecycle lifecycle = error.RecoveryRequired ? ApplicationOperationLifecycle.RecoveryRequired - : error.Code == "target_busy" + : error.Code is "target_busy" or "lock_unverifiable" || error.Code.StartsWith("plan_", StringComparison.Ordinal) ? ApplicationOperationLifecycle.Rejected : ApplicationOperationLifecycle.Failed; @@ -563,7 +568,10 @@ [new ApplicationError( } finally { - Volatile.Write(ref _operationInProgress, 0); + if (gateAcquired) + { + Volatile.Write(ref _operationInProgress, 0); + } } } diff --git a/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj index 446ef02..bfd0474 100644 --- a/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj +++ b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj @@ -5,9 +5,9 @@ enable enable false - 0.5.0 - 0.5.0.0 - 0.5.0.0 + 1.0.0 + 1.0.0.0 + 1.0.0.0 diff --git a/desktop/CodexProviderSync.Application/CoreApplicationWritePort.cs b/desktop/CodexProviderSync.Application/CoreApplicationWritePort.cs index a0f284c..abe5fab 100644 --- a/desktop/CodexProviderSync.Application/CoreApplicationWritePort.cs +++ b/desktop/CodexProviderSync.Application/CoreApplicationWritePort.cs @@ -294,5 +294,12 @@ private static async Task MapCoreFailuresAsync(Func> operation) error.Message, innerException: error); } + catch (InvalidOperationException error) when (LockService.IsLockUnverifiable(error)) + { + throw new ApplicationPortException( + "lock_unverifiable", + error.Message, + innerException: error); + } } } diff --git a/desktop/CodexProviderSync.Automation.Tests/AutomationHostTests.cs b/desktop/CodexProviderSync.Automation.Tests/AutomationHostTests.cs index 7f6e274..a145fbc 100644 --- a/desktop/CodexProviderSync.Automation.Tests/AutomationHostTests.cs +++ b/desktop/CodexProviderSync.Automation.Tests/AutomationHostTests.cs @@ -130,7 +130,7 @@ public async Task TamperedMalformedAndExpiredPlans_AreRejectedBeforeCoreExecutio } [Fact] - public async Task TimeoutAndConcurrentUse_HaveStableExitCodes() + public async Task TimeoutAndConcurrentStatus_HaveStableExitCodes() { using TemporaryDirectory temporary = new(); string ledger = Path.Combine(temporary.Path, "ledger"); @@ -163,15 +163,16 @@ public async Task TimeoutAndConcurrentUse_HaveStableExitCodes() "--provider", "relay", "--ledger-root", Path.Combine(temporary.Path, "busy-ledger") ]); await started.Task; - AutomationRunResult busy = await busyHost.RunAsync( + AutomationRunResult status = await busyHost.RunAsync( ["status", "--codex-home", temporary.Path]); pending.SetResult(busyFactory.Write.Preview(new SyncIntent(temporary.Path, null, "relay"))); await active; Assert.Equal(AutomationExitCodes.CancelledOrTimedOut, timedOut.ExitCode); Assert.Equal("timeout", Assert.Single(timedOut.Response.Errors).Code); - Assert.Equal(AutomationExitCodes.Busy, busy.ExitCode); - Assert.Equal("operation_busy", Assert.Single(busy.Response.Errors).Code); + Assert.Equal(AutomationExitCodes.Success, status.ExitCode); + Assert.Equal("succeeded", status.Response.Lifecycle); + Assert.Empty(status.Response.Errors); } [Fact] diff --git a/desktop/CodexProviderSync.Automation/AutomationHost.cs b/desktop/CodexProviderSync.Automation/AutomationHost.cs index 8d942ec..bcc6b9d 100644 --- a/desktop/CodexProviderSync.Automation/AutomationHost.cs +++ b/desktop/CodexProviderSync.Automation/AutomationHost.cs @@ -302,7 +302,7 @@ private static int ExitCodeFor(ApplicationOutcome outcome, bool timedOut) ApplicationOperationLifecycle.RecoveryRequired => AutomationExitCodes.RecoveryRequired, ApplicationOperationLifecycle.Failed => ExitCodeForFailedOutcome(outcome), ApplicationOperationLifecycle.Rejected when outcome.Errors.Any(static error => - error.Code is "operation_busy" or "target_busy") => AutomationExitCodes.Busy, + error.Code is "operation_busy" or "target_busy" or "lock_unverifiable") => AutomationExitCodes.Busy, ApplicationOperationLifecycle.Rejected when outcome.Errors.Any(static error => error.Code.StartsWith("plan_", StringComparison.Ordinal)) => AutomationExitCodes.InvalidPlan, ApplicationOperationLifecycle.Rejected => AutomationExitCodes.ValidationOrUsage, diff --git a/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj b/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj index bbdbc8e..57f5b0b 100644 --- a/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj +++ b/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj @@ -7,9 +7,9 @@ enable CodexProviderSync.Automation CodexProviderSync.Automation - 0.5.0 - 0.5.0.0 - 0.5.0.0 + 1.0.0 + 1.0.0.0 + 1.0.0.0 diff --git a/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj b/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj index b60a51c..6d59c3e 100644 --- a/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj +++ b/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs index 8e21897..74a2060 100644 --- a/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CoreIntegrationTests.cs @@ -408,6 +408,51 @@ await FileTransactionJournal.CreateAsync( Assert.Equal("openai", result.TargetProvider); } + [Fact] + public async Task Restore_RejectsPendingJournalWhoseDeclaredBackupDiffersFromItsDirectory() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteStateDbAsync([("thread-forged-binding", "openai", false)]); + string configPath = Path.Combine(fixture.CodexHome, "config.toml"); + BackupService backupService = new(new SessionRolloutService(), new SqliteStateService()); + string backupDir = await backupService.CreateBackupAsync( + fixture.CodexHome, + "openai", + [], + configPath); + await using (FileTransactionJournal journal = await FileTransactionJournal.CreateAsync( + backupDir, + fixture.CodexHome, + "openai", + [configPath])) + { + } + + string declaredElsewhere = fixture.BackupPath("99991231T235959999Z"); + Directory.CreateDirectory(declaredElsewhere); + string journalPath = Path.Combine(backupDir, FileTransactionJournal.FileName); + string originalJournal = await File.ReadAllTextAsync(journalPath); + string forgedJournal = originalJournal.Replace( + JsonSerializer.Serialize(Path.GetFullPath(backupDir)), + JsonSerializer.Serialize(Path.GetFullPath(declaredElsewhere)), + StringComparison.Ordinal); + Assert.NotEqual(originalJournal, forgedJournal); + await File.WriteAllTextAsync(journalPath, forgedJournal); + string configBefore = await File.ReadAllTextAsync(configPath); + + CodexSyncService service = new(); + RecoveryRequiredException error = await Assert.ThrowsAsync( + () => service.RunRestoreAsync(fixture.CodexHome, backupDir)); + + Assert.Contains(Path.GetFullPath(backupDir), error.PendingBackupDirectories); + Assert.Equal(configBefore, await File.ReadAllTextAsync(configPath)); + PendingTransactionInfo pending = Assert.Single( + await FileTransactionJournal.FindPendingAsync(fixture.CodexHome)); + Assert.Equal(Path.GetFullPath(backupDir), pending.BackupDir); + Assert.Equal(Path.GetFullPath(declaredElsewhere), pending.DeclaredBackupDir); + } + [Fact] public async Task CrashRecovery_RestoresActuallyMutatedRolloutAndDatabase_FromPendingJournal() { @@ -1578,9 +1623,14 @@ await fixture.WriteStateDbAsync( begin.CommandText = "BEGIN IMMEDIATE"; await begin.ExecuteNonQueryAsync(); + Stopwatch timer = Stopwatch.StartNew(); InvalidOperationException error = await Assert.ThrowsAsync( - () => service.RunSyncAsync(fixture.CodexHome, sqliteBusyTimeoutMs: 0)); + () => service.RunSyncAsync(fixture.CodexHome)); + timer.Stop(); Assert.Contains("state_5.sqlite is currently in use", error.Message); + Assert.True( + timer.Elapsed < TimeSpan.FromSeconds(3), + $"The default SQLite busy policy must fail fast; elapsed {timer.Elapsed}."); string rollout = await File.ReadAllTextAsync(sessionPath); Assert.Contains("\"model_provider\":\"apigather\"", rollout); @@ -2746,7 +2796,7 @@ await service.RunRestoreAsync( } [Fact] - public async Task RestoreVersionOne_RebuildsMissingDefaultSqliteDatabase() + public async Task RestoreVersionOne_MissingDefaultSqliteParentFailsBeforeMutation() { TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); await fixture.WriteConfigAsync("model_provider = \"openai\""); @@ -2769,18 +2819,29 @@ await fixture.WriteStateDbAtAsync( }); await File.WriteAllTextAsync(Path.Combine(backupDir, "metadata.json"), metadata); + string configPath = Path.Combine(fixture.CodexHome, "config.toml"); + string configBefore = await File.ReadAllTextAsync(configPath); + string metadataBefore = await File.ReadAllTextAsync(Path.Combine(backupDir, "metadata.json")); + CodexSyncService service = new(); - await service.RunRestoreAsync( - fixture.CodexHome, - backupDir, - new RestoreBackupOptions - { - RestoreConfig = false, - RestoreDatabase = true, - RestoreSessions = false - }); + InvalidOperationException error = await Assert.ThrowsAsync(() => + service.RunRestoreAsync( + fixture.CodexHome, + backupDir, + new RestoreBackupOptions + { + RestoreConfig = false, + RestoreDatabase = true, + RestoreSessions = false + })); - Assert.Equal("custom", await ReadProviderAsync(fixture.StateDbPath(), "thread-v1-missing")); + Assert.True(LockService.IsLockUnverifiable(error)); + Assert.Equal("state-db", error.Data["codex-provider-sync/lock-scope"]); + Assert.False(File.Exists(fixture.StateDbPath())); + Assert.False(Directory.Exists(Path.GetDirectoryName(fixture.StateDbPath()))); + Assert.Equal(configBefore, await File.ReadAllTextAsync(configPath)); + Assert.Equal(metadataBefore, await File.ReadAllTextAsync(Path.Combine(backupDir, "metadata.json"))); + Assert.Empty(Directory.EnumerateFiles(backupDir, "transaction.json", SearchOption.AllDirectories)); } [Fact] diff --git a/desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs b/desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs index c75826f..c2134ad 100644 --- a/desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs +++ b/desktop/CodexProviderSync.Core.Tests/CrashHost/Program.cs @@ -1,15 +1,56 @@ using System.Diagnostics; using CodexProviderSync.Core; -if (args is not [string codexHome]) +string operation; +string codexHome; +string? backupDir = null; +string crashPoint; +string? failurePoint = null; +if (args is [string legacyCodexHome]) +{ + operation = "sync"; + codexHome = legacyCodexHome; + crashPoint = "after_rollout_mutation_before_applied"; +} +else if (args is ["sync", string syncCodexHome, string syncPoint]) +{ + operation = "sync"; + codexHome = syncCodexHome; + crashPoint = syncPoint; +} +else if (args.Length >= 4 && args[0] == "restore-v2") +{ + operation = "restore-v2"; + codexHome = args[1]; + backupDir = args[2]; + crashPoint = args[3]; + for (var index = 4; index < args.Length; index++) + { + if (args[index] == "--fail-at" && index + 1 < args.Length) + { + failurePoint = args[++index]; + } + else + { + return 64; + } + } +} +else { return 64; } CodexSyncService service = new(); -service.FaultInjector = (point, _, _) => +var failureInjected = 0; +service.FaultInjector = (observedPoint, _, _) => { - if (point == "after_rollout_mutation_before_applied") + if (observedPoint == failurePoint && Interlocked.Exchange(ref failureInjected, 1) == 0) + { + return Task.FromException(new InvalidOperationException( + $"Forced Restore failure at {observedPoint}.")); + } + if (observedPoint == crashPoint) { Process.GetCurrentProcess().Kill(); Thread.Sleep(Timeout.Infinite); @@ -17,5 +58,12 @@ return Task.CompletedTask; }; -await service.RunSyncAsync(codexHome, provider: "openai"); +if (operation == "sync") +{ + await service.RunSyncAsync(codexHome, provider: "openai"); +} +else +{ + await service.RunRestoreAsync(codexHome, Path.GetFullPath(backupDir!)); +} return 65; diff --git a/desktop/CodexProviderSync.Core.Tests/CrossRuntimeStateDbLockTests.cs b/desktop/CodexProviderSync.Core.Tests/CrossRuntimeStateDbLockTests.cs new file mode 100644 index 0000000..b2cfc93 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/CrossRuntimeStateDbLockTests.cs @@ -0,0 +1,412 @@ +using System.Diagnostics; +using System.Text.Json; +using CodexProviderSync.Core; +using Microsoft.Data.Sqlite; + +namespace CodexProviderSync.Core.Tests; + +public sealed class CrossRuntimeStateDbLockTests +{ + private static readonly TimeSpan NodeReadinessTimeout = TimeSpan.FromSeconds(30); + + [WindowsStateDbAliasFact] + public async Task DotNetAndNode_WindowsDirectoryAliases_ContendOnOneStateDbResource() + { + using StateDbTempDirectory temporary = new(); + string stateDbPath = await CreateStateDbFixtureAsync(temporary.Path); + string sqliteHome = Path.GetDirectoryName(stateDbPath)!; + string aliasHome = Path.Combine(temporary.Path, "node-sqlite-alias"); + WindowsDirectoryAlias.CreateJunction(aliasHome, sqliteHome); + string aliasStateDbPath = Path.Combine(aliasHome, AppConstants.DbFileBasename); + await AssertCrossRuntimeAliasContentionAsync(stateDbPath, aliasStateDbPath); + } + + [WindowsStateDbAliasFact] + public async Task DotNetAndNode_WindowsShortAndLongPaths_ContendOnOneStateDbResource() + { + using StateDbTempDirectory temporary = new(); + string sqliteHome = Path.Combine(temporary.Path, "Long SQLite Home For Cross Runtime Alias"); + Directory.CreateDirectory(sqliteHome); + string stateDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await File.WriteAllTextAsync(stateDbPath, "fixture"); + string shortHome = WindowsDirectoryAlias.GetShortPath(sqliteHome); + if (string.Equals(shortHome, sqliteHome, StringComparison.OrdinalIgnoreCase)) + { + throw Xunit.Sdk.SkipException.ForSkip( + "The temporary volume did not provide an actual Windows 8.3 directory alias."); + } + await AssertCrossRuntimeAliasContentionAsync( + stateDbPath, + Path.Combine(shortHome, AppConstants.DbFileBasename)); + } + + private static async Task AssertCrossRuntimeAliasContentionAsync( + string stateDbPath, + string aliasStateDbPath) + { + StateDbLockResource resource = await StateDbLockResource.ResolveAsync(stateDbPath); + StateDbLockResource aliasResource = await StateDbLockResource.ResolveAsync(aliasStateDbPath); + Assert.Equal(resource.ResourceKey, aliasResource.ResourceKey); + + await using (LockHandle held = await new LockService().AcquireStateDbLockAsync( + resource, + "dotnet-alias-winner")) + { + ProcessResult result = await RunNodeAsync($$""" + import { acquireStateDbLock, resolveStateDbLockResource } from {{JsonSerializer.Serialize(ModuleUrl())}}; + const resolved = await resolveStateDbLockResource({{JsonSerializer.Serialize(aliasStateDbPath)}}); + try { + const held = await acquireStateDbLock({{JsonSerializer.Serialize(aliasStateDbPath)}}, "node-alias-contender"); + await held.release(); + console.log(JSON.stringify({ code: "ACQUIRED", resourceKey: resolved.resourceKey })); + process.exit(0); + } catch (error) { + console.log(JSON.stringify({ code: error?.code, busyScope: error?.details?.busyScope, resourceKey: resolved.resourceKey })); + process.exit(5); + } + """); + Assert.Equal(5, result.ExitCode); + using JsonDocument payload = JsonDocument.Parse(result.StdOut.Trim()); + Assert.Equal("OPERATION_BUSY", payload.RootElement.GetProperty("code").GetString()); + Assert.Equal("state-db", payload.RootElement.GetProperty("busyScope").GetString()); + Assert.Equal(resource.ResourceKey, payload.RootElement.GetProperty("resourceKey").GetString()); + } + + string script = $$""" + import { acquireStateDbLock } from {{JsonSerializer.Serialize(ModuleUrl())}}; + const held = await acquireStateDbLock({{JsonSerializer.Serialize(aliasStateDbPath)}}, "node-alias-winner"); + console.log(JSON.stringify({ ready: true, resourceKey: held.resource.resourceKey })); + await new Promise((resolve) => process.stdin.once("data", resolve)); + await held.release(); + """; + using Process child = StartNode(script); + try + { + string readyLine = await WaitForNodeReadyAsync(child); + using JsonDocument ready = JsonDocument.Parse(readyLine); + Assert.True(ready.RootElement.GetProperty("ready").GetBoolean()); + Assert.Equal(resource.ResourceKey, ready.RootElement.GetProperty("resourceKey").GetString()); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => new LockService().AcquireStateDbLockAsync(resource, "dotnet-alias-contender")); + Assert.True(LockService.IsOperationBusy(error)); + Assert.Equal("state-db", error.Data["codex-provider-sync/lock-scope"]); + Assert.Equal(resource.ResourceKey, error.Data["codex-provider-sync/resource-key"]); + } + finally + { + await ReleaseNodeOwnerAsync(child); + } + } + + [Fact] + public async Task DotNetOwner_BlocksARealNodeContenderWithTheSameResourceKey() + { + using StateDbTempDirectory temporary = new(); + string stateDbPath = await CreateStateDbFixtureAsync(temporary.Path); + StateDbLockResource resource = await StateDbLockResource.ResolveAsync(stateDbPath); + await using LockHandle held = await new LockService().AcquireStateDbLockAsync(resource, "dotnet-winner"); + + ProcessResult result = await RunNodeAsync($$""" + import { acquireStateDbLock } from {{JsonSerializer.Serialize(ModuleUrl())}}; + try { + const held = await acquireStateDbLock({{JsonSerializer.Serialize(stateDbPath)}}, "node-contender"); + await held.release(); + console.log(JSON.stringify({ code: "ACQUIRED", resourceKey: held.resource.resourceKey })); + process.exit(0); + } catch (error) { + console.log(JSON.stringify({ code: error?.code, busyScope: error?.details?.busyScope })); + process.exit(5); + } + """); + + Assert.Equal(5, result.ExitCode); + using JsonDocument payload = JsonDocument.Parse(result.StdOut.Trim()); + Assert.Equal("OPERATION_BUSY", payload.RootElement.GetProperty("code").GetString()); + Assert.Equal("state-db", payload.RootElement.GetProperty("busyScope").GetString()); + } + + [Fact] + public async Task NodeOwner_BlocksADotNetContenderWithTheSameResourceKey() + { + using StateDbTempDirectory temporary = new(); + string stateDbPath = await CreateStateDbFixtureAsync(temporary.Path); + StateDbLockResource resource = await StateDbLockResource.ResolveAsync(stateDbPath); + string script = $$""" + import { acquireStateDbLock } from {{JsonSerializer.Serialize(ModuleUrl())}}; + const held = await acquireStateDbLock({{JsonSerializer.Serialize(stateDbPath)}}, "node-winner"); + console.log(JSON.stringify({ ready: true, resourceKey: held.resource.resourceKey })); + await new Promise((resolve) => process.stdin.once("data", resolve)); + await held.release(); + """; + using Process child = StartNode(script); + try + { + string readyLine = await WaitForNodeReadyAsync(child); + using JsonDocument ready = JsonDocument.Parse(readyLine); + Assert.True(ready.RootElement.GetProperty("ready").GetBoolean()); + Assert.Equal(resource.ResourceKey, ready.RootElement.GetProperty("resourceKey").GetString()); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => new LockService().AcquireStateDbLockAsync(resource, "dotnet-contender")); + Assert.True(LockService.IsOperationBusy(error)); + Assert.Equal("state-db", error.Data["codex-provider-sync/lock-scope"]); + } + finally + { + await ReleaseNodeOwnerAsync(child); + } + } + + [Fact] + public async Task RealNodeOwner_MakesDotNetStatusReturnItsLastCompleteSnapshot() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteStateDbAsync([("thread-node-status", "relay", false)]); + CodexSyncService service = new(); + StatusSnapshot baseline = await service.GetStatusAsync(fixture.CodexHome); + Assert.Equal(1, baseline.SqliteCounts!.Sessions["relay"]); + + string script = $$""" + import { acquireStateDbLock } from {{JsonSerializer.Serialize(ModuleUrl())}}; + const held = await acquireStateDbLock({{JsonSerializer.Serialize(fixture.StateDbPath())}}, "node-status-writer"); + console.log(JSON.stringify({ ready: true, resourceKey: held.resource.resourceKey })); + await new Promise((resolve) => process.stdin.once("data", resolve)); + await held.release(); + """; + using Process child = StartNode(script); + try + { + string readyLine = await WaitForNodeReadyAsync(child); + using JsonDocument ready = JsonDocument.Parse(readyLine); + Assert.True(ready.RootElement.GetProperty("ready").GetBoolean()); + + await using SqliteConnection connection = fixture.OpenSqliteConnection(); + await connection.OpenAsync(); + SqliteCommand update = connection.CreateCommand(); + update.CommandText = "UPDATE threads SET model_provider = 'external' WHERE id = 'thread-node-status'"; + Assert.Equal(1, await update.ExecuteNonQueryAsync()); + + StatusSnapshot blocked = await new CodexSyncService().GetStatusAsync(fixture.CodexHome); + Assert.Equal(1, blocked.SqliteCounts!.Sessions["relay"]); + Assert.False(blocked.SqliteCounts.Sessions.ContainsKey("external")); + Assert.Equal("state-db", blocked.OperationInProgress!.BusyScope); + Assert.Equal("node", blocked.OperationInProgress.Runtime); + Assert.Equal("node-status-writer", blocked.OperationInProgress.Operation); + } + finally + { + await ReleaseNodeOwnerAsync(child); + } + + StatusSnapshot refreshed = await service.GetStatusAsync(fixture.CodexHome); + Assert.Equal(1, refreshed.SqliteCounts!.Sessions["external"]); + } + + [Fact] + public async Task RealNodeHomeOwner_MakesDotNetStatusReturnItsLastCompleteSnapshot() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"apigather\""); + await fixture.WriteStateDbAsync([("thread-node-home-status", "apigather", false)]); + CodexSyncService service = new(); + StatusSnapshot baseline = await service.GetStatusAsync(fixture.CodexHome); + Assert.Equal("apigather", baseline.CurrentProvider.Provider); + + string script = $$""" + import { acquireLock } from {{JsonSerializer.Serialize(LockingModuleUrl())}}; + const release = await acquireLock({{JsonSerializer.Serialize(fixture.CodexHome)}}, "node-home-status-writer"); + console.log(JSON.stringify({ ready: true })); + await new Promise((resolve) => process.stdin.once("data", resolve)); + await release(); + """; + using Process child = StartNode(script); + try + { + string readyLine = await WaitForNodeReadyAsync(child); + using JsonDocument ready = JsonDocument.Parse(readyLine); + Assert.True(ready.RootElement.GetProperty("ready").GetBoolean()); + + await fixture.WriteConfigAsync("model_provider = \"openai\""); + StatusSnapshot blocked = await new CodexSyncService().GetStatusAsync(fixture.CodexHome); + Assert.Equal("apigather", blocked.CurrentProvider.Provider); + Assert.Equal("codex-home", blocked.OperationInProgress!.BusyScope); + Assert.Equal("node", blocked.OperationInProgress.Runtime); + Assert.Equal("node-home-status-writer", blocked.OperationInProgress.Operation); + } + finally + { + await ReleaseNodeOwnerAsync(child); + } + + Assert.Equal("openai", (await service.GetStatusAsync(fixture.CodexHome)).CurrentProvider.Provider); + } + + [Fact] + public async Task DifferentHomeStatus_SharingNodeLockedStateDb_UsesItsOwnCachedSnapshot() + { + TestCodexHomeFixture first = await TestCodexHomeFixture.CreateAsync(); + TestCodexHomeFixture second = await TestCodexHomeFixture.CreateAsync(); + await first.WriteConfigAsync("model_provider = \"openai\""); + await second.WriteConfigAsync("model_provider = \"openai\""); + string sharedSqliteHome = Path.Combine(first.Root, "shared-status-sqlite"); + string sharedStateDb = Path.Combine(sharedSqliteHome, AppConstants.DbFileBasename); + await first.WriteStateDbAtAsync( + sharedStateDb, + [("thread-shared-status", "relay", false)], + model: null); + CodexSyncService service = new(); + StatusSnapshot baseline = await service.GetStatusAsync(second.CodexHome, sharedSqliteHome); + Assert.Equal(1, baseline.SqliteCounts!.Sessions["relay"]); + + string script = $$""" + import { acquireStateDbLock } from {{JsonSerializer.Serialize(ModuleUrl())}}; + const held = await acquireStateDbLock({{JsonSerializer.Serialize(sharedStateDb)}}, "node-shared-db-writer"); + console.log(JSON.stringify({ ready: true, resourceKey: held.resource.resourceKey })); + await new Promise((resolve) => process.stdin.once("data", resolve)); + await held.release(); + """; + using Process child = StartNode(script); + try + { + string readyLine = await WaitForNodeReadyAsync(child); + using JsonDocument ready = JsonDocument.Parse(readyLine); + Assert.True(ready.RootElement.GetProperty("ready").GetBoolean()); + + await using SqliteConnection connection = new($"Data Source={sharedStateDb};Pooling=False"); + await connection.OpenAsync(); + SqliteCommand update = connection.CreateCommand(); + update.CommandText = "UPDATE threads SET model_provider = 'external' WHERE id = 'thread-shared-status'"; + Assert.Equal(1, await update.ExecuteNonQueryAsync()); + + StatusSnapshot blocked = await new CodexSyncService().GetStatusAsync( + second.CodexHome, + sharedSqliteHome); + Assert.Equal(1, blocked.SqliteCounts!.Sessions["relay"]); + Assert.False(blocked.SqliteCounts.Sessions.ContainsKey("external")); + Assert.Equal("state-db", blocked.OperationInProgress!.BusyScope); + Assert.Equal("node-shared-db-writer", blocked.OperationInProgress.Operation); + } + finally + { + await ReleaseNodeOwnerAsync(child); + } + + StatusSnapshot refreshed = await service.GetStatusAsync(second.CodexHome, sharedSqliteHome); + Assert.Equal(1, refreshed.SqliteCounts!.Sessions["external"]); + } + + private static async Task CreateStateDbFixtureAsync(string root) + { + string sqliteHome = Path.Combine(root, "sqlite"); + Directory.CreateDirectory(sqliteHome); + string stateDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await File.WriteAllTextAsync(stateDbPath, "fixture"); + return stateDbPath; + } + + private static string ModuleUrl() => new Uri( + Path.Combine(FindRepositoryRoot(), "src", "state-db-lock.js")).AbsoluteUri; + + private static string LockingModuleUrl() => new Uri( + Path.Combine(FindRepositoryRoot(), "src", "locking.js")).AbsoluteUri; + + private static string FindRepositoryRoot() + { + foreach (string start in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory }) + { + DirectoryInfo? current = new(Path.GetFullPath(start)); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "src", "state-db-lock.js"))) + { + return current.FullName; + } + current = current.Parent; + } + } + throw new DirectoryNotFoundException("Cannot locate the repository root for the Node lock parity test."); + } + + private static Process StartNode(string script) + { + ProcessStartInfo startInfo = new("node") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = FindRepositoryRoot() + }; + startInfo.ArgumentList.Add("--input-type=module"); + startInfo.ArgumentList.Add("-e"); + startInfo.ArgumentList.Add(script); + return Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start the Node lock parity process."); + } + + private static async Task RunNodeAsync(string script) + { + using Process process = StartNode(script); + Task stdout = process.StandardOutput.ReadToEndAsync(); + Task stderr = process.StandardError.ReadToEndAsync(); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(20)); + await process.WaitForExitAsync(timeout.Token); + return new ProcessResult(process.ExitCode, await stdout, await stderr); + } + + private static async Task WaitForNodeReadyAsync(Process process) + { + Task ready = process.StandardOutput.ReadLineAsync(); + Task exited = process.WaitForExitAsync(); + Task timeout = Task.Delay(NodeReadinessTimeout); + Task completed = await Task.WhenAny(ready, exited, timeout); + if (ready.IsCompleted) + { + return await ready + ?? throw new InvalidOperationException("Node lock parity process exited before publishing readiness."); + } + + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + string stderr = (await process.StandardError.ReadToEndAsync()).Trim(); + string diagnostic = string.IsNullOrWhiteSpace(stderr) ? "no stderr" : stderr; + if (completed == exited) + { + throw new InvalidOperationException( + $"Node lock parity process exited with code {process.ExitCode} before publishing readiness ({diagnostic})."); + } + throw new TimeoutException( + $"Node lock parity process did not publish readiness within {NodeReadinessTimeout.TotalSeconds:F0} seconds ({diagnostic})."); + } + + private static async Task ReleaseNodeOwnerAsync(Process process) + { + if (process.HasExited) return; + + await process.StandardInput.WriteLineAsync("release"); + process.StandardInput.Close(); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(15)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + throw new TimeoutException("Node lock parity process did not exit after release."); + } + Assert.Equal(0, process.ExitCode); + } + + private sealed record ProcessResult(int ExitCode, string StdOut, string StdErr); +} diff --git a/desktop/CodexProviderSync.Core.Tests/DualResourceLockIntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/DualResourceLockIntegrationTests.cs new file mode 100644 index 0000000..912319b --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/DualResourceLockIntegrationTests.cs @@ -0,0 +1,68 @@ +using CodexProviderSync.Core; + +namespace CodexProviderSync.Core.Tests; + +public sealed class DualResourceLockIntegrationTests +{ + [Fact] + public async Task DifferentCodexHomes_SharingOneStateDb_ContendBeforeTheLosingBackup() + { + TestCodexHomeFixture firstFixture = await TestCodexHomeFixture.CreateAsync(); + TestCodexHomeFixture secondFixture = await TestCodexHomeFixture.CreateAsync(); + await firstFixture.WriteConfigAsync("model_provider = \"openai\""); + await secondFixture.WriteConfigAsync("model_provider = \"openai\""); + string firstRollout = firstFixture.RolloutPath("sessions", "rollout-shared-a.jsonl"); + string secondRollout = secondFixture.RolloutPath("sessions", "rollout-shared-b.jsonl"); + await firstFixture.WriteRolloutAsync(firstRollout, "thread-shared-a", "custom"); + await secondFixture.WriteRolloutAsync(secondRollout, "thread-shared-b", "custom"); + string sharedSqliteHome = Path.Combine(firstFixture.Root, "shared-sqlite"); + string sharedStateDb = Path.Combine(sharedSqliteHome, AppConstants.DbFileBasename); + await firstFixture.WriteStateDbAtAsync( + sharedStateDb, + [ + ("thread-shared-a", "custom", false), + ("thread-shared-b", "custom", false) + ], + model: null); + byte[] losingConfigBefore = await File.ReadAllBytesAsync( + Path.Combine(secondFixture.CodexHome, "config.toml")); + byte[] losingRolloutBefore = await File.ReadAllBytesAsync(secondRollout); + + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + CodexSyncService winner = new(); + winner.FaultInjector = async (point, _, _) => + { + if (point == "before_backup") + { + entered.TrySetResult(); + await release.Task; + } + }; + Task winnerTask = winner.RunSyncAsync( + firstFixture.CodexHome, + provider: "openai", + explicitSqliteHome: sharedSqliteHome); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + try + { + InvalidOperationException busy = await Assert.ThrowsAsync( + () => new CodexSyncService().RunSyncAsync( + secondFixture.CodexHome, + provider: "openai", + explicitSqliteHome: sharedSqliteHome)); + Assert.True(LockService.IsOperationBusy(busy)); + Assert.Equal("state-db", busy.Data["codex-provider-sync/lock-scope"]); + Assert.False(Directory.Exists(AppConstants.DefaultBackupRoot(secondFixture.CodexHome))); + Assert.Equal(losingConfigBefore, await File.ReadAllBytesAsync( + Path.Combine(secondFixture.CodexHome, "config.toml"))); + Assert.Equal(losingRolloutBefore, await File.ReadAllBytesAsync(secondRollout)); + } + finally + { + release.TrySetResult(); + await winnerTask; + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/FixtureHost/CodexProviderSync.FixtureHost.csproj b/desktop/CodexProviderSync.Core.Tests/FixtureHost/CodexProviderSync.FixtureHost.csproj new file mode 100644 index 0000000..ef86c48 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/FixtureHost/CodexProviderSync.FixtureHost.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/desktop/CodexProviderSync.Core.Tests/FixtureHost/Program.cs b/desktop/CodexProviderSync.Core.Tests/FixtureHost/Program.cs new file mode 100644 index 0000000..b6827d8 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/FixtureHost/Program.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using CodexProviderSync.Core; + +if (args.Length < 2) +{ + return 64; +} + +string operation = args[0]; +string codexHome = Path.GetFullPath(args[1]); +CodexSyncService service = new(); + +try +{ + object output = operation switch + { + "sync" when args.Length == 3 => await SyncAsync(service, codexHome, args[2]), + "sync-explicit" when args.Length == 4 => await SyncAsync( + service, + codexHome, + args[2], + Path.GetFullPath(args[3])), + "sync-gated" when args.Length == 6 => await GatedSyncAsync( + service, + codexHome, + args[2], + Path.GetFullPath(args[3]), + Path.GetFullPath(args[4]), + Path.GetFullPath(args[5])), + "restore" or "restore-v2" when args.Length == 3 => await RestoreAsync(service, codexHome, args[2]), + "source-identity" when args.Length == 3 => await SourceIdentityAsync(args[2]), + _ => throw new ArgumentException("Unsupported fixture operation.") + }; + Console.WriteLine(JsonSerializer.Serialize(output)); + return 0; +} +catch (Exception error) +{ + Console.Error.WriteLine(JsonSerializer.Serialize(new + { + schemaVersion = 1, + ok = false, + errorType = error.GetType().Name, + errorCode = ErrorCode(error), + errorMessage = error.Message, + busyScope = BusyScope(error) + })); + return 1; +} + +static async Task SyncAsync( + CodexSyncService service, + string codexHome, + string provider, + string? explicitSqliteHome = null) +{ + SyncResult result = await service.RunSyncAsync( + codexHome, + provider: provider, + explicitSqliteHome: explicitSqliteHome); + return new + { + schemaVersion = 1, + ok = true, + operation = "sync", + result.BackupDir, + result.TargetProvider + }; +} + +static async Task GatedSyncAsync( + CodexSyncService service, + string codexHome, + string provider, + string explicitSqliteHome, + string readyPath, + string releasePath) +{ + service.FaultInjector = async (point, _, _) => + { + if (point != "before_backup") + { + return; + } + Directory.CreateDirectory(Path.GetDirectoryName(readyPath)!); + await using (FileStream marker = new( + readyPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + bufferSize: 1, + useAsync: true)) + { + await marker.WriteAsync("ready\n"u8.ToArray()); + await marker.FlushAsync(); + } + DateTime deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline) + { + if (File.Exists(releasePath)) + { + return; + } + await Task.Delay(25); + } + throw new TimeoutException("Timed out waiting for the cross-runtime writer release marker."); + }; + return await SyncAsync(service, codexHome, provider, explicitSqliteHome); +} + +static async Task RestoreAsync(CodexSyncService service, string codexHome, string backupDir) +{ + RestoreResult result = await service.RunRestoreAsync(codexHome, Path.GetFullPath(backupDir)); + return new + { + schemaVersion = 1, + ok = true, + operation = "restore", + result.BackupDir, + result.TargetProvider, + result.RestoreVersion, + result.RestoreOperationId, + result.PreRestoreSnapshotId, + result.RestoreJournalState, + result.CommitAcknowledgementRecovered, + result.ResolvedOperationIds + }; +} + +static async Task SourceIdentityAsync(string backupDir) +{ + RestoreBackupIdentity identity = await RestoreV2Service.CaptureSourceIdentityAsync( + Path.GetFullPath(backupDir)); + return new + { + schemaVersion = 1, + ok = true, + operation = "source-identity", + identity.BackupId, + identity.BackupDir, + identity.Revision + }; +} + +static string ErrorCode(Exception error) => error switch +{ + _ when LockService.IsOperationBusy(error) => "OPERATION_BUSY", + RecoveryRequiredException recovery => recovery.Code, + SyncTransactionException transaction => transaction.Code, + OperationCanceledException => "CANCELLED", + _ => "RESTORE_FAILED" +}; + +static string? BusyScope(Exception error) +{ + if (error.Data["codex-provider-sync/lock-scope"] is string scope) + { + return scope; + } + if (error is AggregateException aggregate) + { + return aggregate.InnerExceptions.Select(BusyScope).FirstOrDefault(value => value is not null); + } + return error.InnerException is null ? null : BusyScope(error.InnerException); +} diff --git a/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs b/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs index de6a410..380d00e 100644 --- a/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/LockServiceTests.cs @@ -47,6 +47,46 @@ public async Task AcquireLockAsync_PublishesVersionedOwnerAndClaim_ThenReleasesO Assert.Empty(Directory.EnumerateFiles(claimsPath, "*.json")); } + [Fact] + public async Task InspectLockAsync_IsReadOnlyAndReportsTheVerifiedActiveOwner() + { + string codexHome = CreateTempDirectory(); + LockService service = new(); + LockInspection before = await service.InspectLockAsync(codexHome); + Assert.True(before.IsAbsent); + + await using (LockHandle held = await service.AcquireLockAsync(codexHome, "status-fixture")) + { + LockInspection active = await new LockService().InspectLockAsync(codexHome); + Assert.Equal("active", active.State); + Assert.Equal("codex-home", active.Scope); + Assert.Equal(held.InstanceId, active.Owner!.InstanceId); + Assert.Equal("dotnet", active.Owner.Runtime); + Assert.Equal("status-fixture", active.Owner.Label); + Assert.NotEmpty(active.ObservationRevision); + } + + LockInspection after = await service.InspectLockAsync(codexHome); + Assert.True(after.IsAbsent); + Assert.False(Directory.Exists(AppConstants.LockPath(codexHome))); + } + + [Fact] + public async Task InspectLockAsync_PreservesMalformedOwnerFailClosed() + { + string codexHome = CreateTempDirectory(); + string lockPath = AppConstants.LockPath(codexHome); + Directory.CreateDirectory(lockPath); + await File.WriteAllTextAsync(Path.Combine(lockPath, "owner.json"), "not-json"); + + LockInspection inspection = await new LockService().InspectLockAsync(codexHome); + + Assert.Equal("unverifiable", inspection.State); + Assert.Equal(LockService.LockUnverifiableErrorCode, inspection.ErrorCode); + Assert.True(Directory.Exists(lockPath)); + Assert.Equal("not-json", await File.ReadAllTextAsync(Path.Combine(lockPath, "owner.json"))); + } + [Fact] public async Task AcquirePathLockAsync_SupportsArbitraryExplicitResourcePath() { @@ -75,7 +115,7 @@ public async Task AcquirePathLockAsync_RejectsCanonicalFileWithBusyDiagnostic() () => new LockService().AcquirePathLockAsync(lockPath, "sqlite")); Assert.Contains("not a directory", error.Message); - Assert.True(LockService.IsOperationBusy(error)); + Assert.True(LockService.IsLockUnverifiable(error)); Assert.Equal("foreign", await File.ReadAllTextAsync(lockPath)); Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); } @@ -98,7 +138,7 @@ public async Task AcquirePathLockAsync_RejectsCanonicalSymbolicLinkWithoutFollow () => new LockService().AcquirePathLockAsync(lockPath, "sqlite")); Assert.Contains("symbolic link or reparse point", error.Message); - Assert.True(LockService.IsOperationBusy(error)); + Assert.True(LockService.IsLockUnverifiable(error)); Assert.Empty(Directory.EnumerateFileSystemEntries(targetPath)); Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); } @@ -121,7 +161,7 @@ public async Task AcquireLockAsync_HardLinkFailurePreservesForeignPopulationAndR InvalidOperationException error = await Assert.ThrowsAsync( () => service.AcquireLockAsync(codexHome, "injected")); - Assert.True(LockService.IsOperationBusy(error)); + Assert.True(LockService.IsLockUnverifiable(error)); Assert.Equal("foreign-owner", await File.ReadAllTextAsync(Path.Combine(lockPath, "owner.json"))); Assert.Equal("keep", await File.ReadAllTextAsync(Path.Combine(lockPath, "foreign.txt"))); Assert.Empty(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); @@ -148,7 +188,7 @@ public async Task AcquireLockAsync_ReservationAbaRetainsUncertainClaimAndForeign () => service.AcquireLockAsync(codexHome, "aba")); Assert.Contains("reservation changed identity", error.Message); - Assert.True(LockService.IsOperationBusy(error)); + Assert.True(LockService.IsLockUnverifiable(error)); Assert.Equal("keep", await File.ReadAllTextAsync(Path.Combine(lockPath, "foreign.txt"))); Assert.Single(Directory.EnumerateFiles(lockPath + ".claims", "*.json")); Assert.True(Directory.Exists(displacedPath)); @@ -661,10 +701,10 @@ public async Task CreateLockDirectoryAsync_ThrowsWhenLockAlreadyExists() @"C:\temp\provider-sync.lock", tryCreateDirectory: _ => 183)); - Assert.Contains("Lock already exists", error.Message); - Assert.True(LockService.IsOperationBusy(error)); + Assert.Contains("cannot be verified", error.Message); + Assert.True(LockService.IsLockUnverifiable(error)); Assert.Equal( - LockService.OperationBusyErrorCode, + LockService.LockUnverifiableErrorCode, error.Data["codex-provider-sync/error-code"]); } diff --git a/desktop/CodexProviderSync.Core.Tests/RestoreJournalServiceTests.cs b/desktop/CodexProviderSync.Core.Tests/RestoreJournalServiceTests.cs new file mode 100644 index 0000000..457adca --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/RestoreJournalServiceTests.cs @@ -0,0 +1,452 @@ +using System.Text; +using System.Text.Json; + +namespace CodexProviderSync.Core.Tests; + +public sealed class RestoreJournalServiceTests +{ + [Fact] + public async Task WriterAndReader_CompleteCanonicalRestoreStateMachine() + { + RestoreJournalFixture fixture = RestoreJournalFixture.Create(); + RestoreJournal journal = await RestoreJournal.CreateAsync( + fixture.SnapshotDir, + fixture.OperationId, + fixture.Prepared); + + await journal.ApplyingAsync(); + await journal.TargetIntentAsync(fixture.Target.Id); + await journal.TargetCompletedAsync(fixture.Target.Id, fixture.Target.ExpectedPost.Digest); + await journal.CommittingAsync("post-manifest"); + await journal.CommittedPendingAckAsync("post-manifest"); + await journal.CompletedAsync(); + + RestoreJournalInfo info = await RestoreJournalService.ReadInfoAsync(journal.FilePath); + Assert.False(info.InvalidTail); + Assert.True(info.Terminal); + Assert.False(info.Blocking); + Assert.Equal("completed", info.State); + Assert.Equal(7, info.LastSequence); + Assert.Equal("completed", info.TargetPhases[fixture.Target.Id]); + Assert.Equal(fixture.SourceDir, info.Prepared!.SourceBackup.BackupDir); + Assert.EndsWith("\n", await File.ReadAllTextAsync(journal.FilePath), StringComparison.Ordinal); + + string before = await File.ReadAllTextAsync(journal.FilePath); + await Assert.ThrowsAsync(() => journal.RollbackPendingAsync("too-late")); + Assert.Equal(before, await File.ReadAllTextAsync(journal.FilePath)); + } + + [Fact] + public async Task Reader_AcceptsNodeStylePreparedEventAndTargetTransitions() + { + RestoreJournalFixture fixture = RestoreJournalFixture.Create(); + string journalPath = Path.Combine(fixture.SnapshotDir, RestoreJournal.FileName); + object[] events = + [ + fixture.NodePreparedEvent(sequence: 1), + fixture.NodeEvent(sequence: 2, state: "applying"), + fixture.NodeEvent(sequence: 3, state: "applying", new + { + targetId = fixture.Target.Id, + targetPhase = "intent" + }), + fixture.NodeEvent(sequence: 4, state: "applying", new + { + targetId = fixture.Target.Id, + targetPhase = "completed", + targetDigest = fixture.Target.ExpectedPost.Digest + }), + fixture.NodeEvent(sequence: 5, state: "committing", new { postManifestSha256 = "manifest" }), + fixture.NodeEvent(sequence: 6, state: "committed-pending-ack", new { postManifestSha256 = "manifest" }) + ]; + await File.WriteAllTextAsync( + journalPath, + string.Join("\n", events.Select(static value => JsonSerializer.Serialize(value))) + "\n", + new UTF8Encoding(false)); + + RestoreJournalInfo info = await RestoreJournalService.ReadInfoAsync(journalPath); + + Assert.False(info.InvalidTail); + Assert.False(info.Terminal); + Assert.True(info.Blocking); + Assert.Equal("committed-pending-ack", info.State); + Assert.Equal("completed", info.TargetPhases[fixture.Target.Id]); + } + + [Fact] + public async Task Reader_UnknownSchemaFailsClosedWithoutLosingRawProtectionReferences() + { + RestoreJournalFixture fixture = RestoreJournalFixture.Create(); + string journalPath = Path.Combine(fixture.SnapshotDir, RestoreJournal.FileName); + object value = new + { + schemaVersion = 99, + protocolVersion = 99, + operationKind = "restore", + operationId = fixture.OperationId, + sequence = 1, + state = "prepared", + recordedAt = DateTimeOffset.UtcNow, + sourceBackup = new { backupId = "source", backupDir = fixture.SourceDir, revision = "source-revision" }, + preRestoreSnapshot = new + { + backupId = "snapshot", + backupDir = fixture.SnapshotDir, + revision = "snapshot-revision", + manifestSha256 = "manifest" + } + }; + string raw = JsonSerializer.Serialize(value) + "\n"; + await File.WriteAllTextAsync(journalPath, raw, new UTF8Encoding(false)); + + RestoreJournalInfo info = await RestoreJournalService.ReadInfoAsync(journalPath); + + Assert.True(info.InvalidTail); + Assert.True(info.Blocking); + Assert.False(info.Terminal); + Assert.Equal("recovery-required", info.State); + Assert.Equal(fixture.SourceDir, info.ProtectionReferences.SourceBackupDirectory); + Assert.Equal(fixture.SnapshotDir, info.ProtectionReferences.PreRestoreSnapshotDirectory); + Assert.False(info.ProtectionReferences.IsUnverifiable); + Assert.Equal(raw, await File.ReadAllTextAsync(journalPath)); + } + + [Fact] + public async Task Reader_TruncatedFirstRecordMakesPruneReferencesUnverifiable() + { + RestoreJournalFixture fixture = RestoreJournalFixture.Create(); + string journalPath = Path.Combine(fixture.SnapshotDir, RestoreJournal.FileName); + byte[] raw = Encoding.UTF8.GetBytes("{\"schemaVersion\":2,\"sourceBackup\":"); + await File.WriteAllBytesAsync(journalPath, raw); + + RestoreJournalInfo info = await RestoreJournalService.ReadInfoAsync(journalPath); + + Assert.True(info.InvalidTail); + Assert.True(info.Blocking); + Assert.True(info.ProtectionReferences.IsUnverifiable); + Assert.Equal(raw, await File.ReadAllBytesAsync(journalPath)); + } + + [Fact] + public async Task Scan_ResolvedJournalNoLongerBlocksButStillProtectsNonterminalEvidence() + { + string codexHome = Path.Combine(Path.GetTempPath(), $"cps-restore-scan-{Guid.NewGuid():N}"); + string backupRoot = AppConstants.DefaultBackupRoot(codexHome); + string oldSnapshot = Path.Combine(backupRoot, "restore-v2-old"); + string newSnapshot = Path.Combine(backupRoot, "restore-v2-new"); + string source = Path.Combine(backupRoot, "source"); + Directory.CreateDirectory(oldSnapshot); + Directory.CreateDirectory(newSnapshot); + Directory.CreateDirectory(source); + + RestoreJournalFixture oldFixture = RestoreJournalFixture.CreateAt( + codexHome, + oldSnapshot, + source, + "old-operation"); + RestoreJournal oldJournal = await RestoreJournal.CreateAsync( + oldSnapshot, + oldFixture.OperationId, + oldFixture.Prepared); + await oldJournal.ApplyingAsync(); + + RestoreJournalFixture newFixture = RestoreJournalFixture.CreateAt( + codexHome, + newSnapshot, + source, + "new-operation", + resolvesOperationIds: [oldFixture.OperationId]); + RestoreJournal newJournal = await RestoreJournal.CreateAsync( + newSnapshot, + newFixture.OperationId, + newFixture.Prepared); + await newJournal.ApplyingAsync(); + await newJournal.TargetIntentAsync(newFixture.Target.Id); + await newJournal.TargetCompletedAsync(newFixture.Target.Id, newFixture.Target.ExpectedPost.Digest); + await newJournal.CommittingAsync("post"); + await newJournal.CommittedPendingAckAsync("post"); + await newJournal.CompletedAsync(); + + RestoreJournalScan scan = await RestoreJournalService.ScanAsync(codexHome); + + Assert.Empty(scan.BlockingJournals); + Assert.Contains(oldFixture.OperationId, scan.ResolvedOperationIds); + Assert.Contains(Path.GetFullPath(oldSnapshot), scan.ProtectedDirectories); + Assert.Contains(Path.GetFullPath(source), scan.ProtectedDirectories); + } + + [Fact] + public async Task Scan_MismatchedCompletedResolverCannotHidePendingRestore() + { + string codexHome = Path.Combine(Path.GetTempPath(), $"cps-restore-scan-{Guid.NewGuid():N}"); + string backupRoot = AppConstants.DefaultBackupRoot(codexHome); + string oldSnapshot = Path.Combine(backupRoot, "restore-v2-old"); + string resolverSnapshot = Path.Combine(backupRoot, "restore-v2-resolver"); + string source = Path.Combine(backupRoot, "source"); + Directory.CreateDirectory(oldSnapshot); + Directory.CreateDirectory(resolverSnapshot); + Directory.CreateDirectory(source); + + RestoreJournalFixture oldFixture = RestoreJournalFixture.CreateAt( + codexHome, + oldSnapshot, + source, + "old-operation"); + RestoreJournal oldJournal = await RestoreJournal.CreateAsync( + oldSnapshot, + oldFixture.OperationId, + oldFixture.Prepared); + await oldJournal.ApplyingAsync(); + + RestoreJournalFixture resolverFixture = RestoreJournalFixture.CreateAt( + codexHome, + resolverSnapshot, + source, + "resolver-operation", + resolvesOperationIds: [oldFixture.OperationId]); + RestoreJournalPrepared mismatchedPrepared = resolverFixture.Prepared with + { + SourceBackup = resolverFixture.Prepared.SourceBackup with + { + Revision = resolverFixture.Prepared.SourceBackup.Revision + "-different" + } + }; + RestoreJournal resolver = await RestoreJournal.CreateAsync( + resolverSnapshot, + resolverFixture.OperationId, + mismatchedPrepared); + await resolver.ApplyingAsync(); + await resolver.TargetIntentAsync(resolverFixture.Target.Id); + await resolver.TargetCompletedAsync( + resolverFixture.Target.Id, + resolverFixture.Target.ExpectedPost.Digest); + await resolver.CommittingAsync("post"); + await resolver.CommittedPendingAckAsync("post"); + await resolver.CompletedAsync(); + + RestoreJournalScan scan = await RestoreJournalService.ScanAsync(codexHome); + + RestoreJournalInfo pending = Assert.Single(scan.BlockingJournals); + Assert.Equal(oldFixture.OperationId, pending.OperationId); + Assert.DoesNotContain(oldFixture.OperationId, scan.ResolvedOperationIds); + } + + [Fact] + public async Task Scan_DifferentPersistedPhysicalHomeCannotHidePendingRestore() + { + string codexHome = Path.Combine(Path.GetTempPath(), $"cps-restore-scan-{Guid.NewGuid():N}"); + string backupRoot = AppConstants.DefaultBackupRoot(codexHome); + string oldSnapshot = Path.Combine(backupRoot, "restore-v2-old"); + string resolverSnapshot = Path.Combine(backupRoot, "restore-v2-resolver"); + string source = Path.Combine(backupRoot, "source"); + string otherPhysicalHome = Path.Combine(Path.GetTempPath(), $"cps-restore-other-{Guid.NewGuid():N}"); + Directory.CreateDirectory(oldSnapshot); + Directory.CreateDirectory(resolverSnapshot); + Directory.CreateDirectory(source); + Directory.CreateDirectory(otherPhysicalHome); + + RestoreJournalFixture oldFixture = RestoreJournalFixture.CreateAt( + codexHome, + oldSnapshot, + source, + "old-operation"); + RestoreJournal oldJournal = await RestoreJournal.CreateAsync( + oldSnapshot, + oldFixture.OperationId, + oldFixture.Prepared); + await oldJournal.ApplyingAsync(); + + RestoreJournalFixture resolverFixture = RestoreJournalFixture.CreateAt( + codexHome, + resolverSnapshot, + source, + "resolver-operation", + resolvesOperationIds: [oldFixture.OperationId]); + RestoreJournalPrepared resolverPrepared = resolverFixture.Prepared with + { + Storage = resolverFixture.Prepared.Storage with + { + CodexHomePhysical = StateDbLockResource.ResolveExistingPhysicalPath( + otherPhysicalHome, + directory: true) + } + }; + RestoreJournal resolver = await RestoreJournal.CreateAsync( + resolverSnapshot, + resolverFixture.OperationId, + resolverPrepared); + await resolver.ApplyingAsync(); + await resolver.TargetIntentAsync(resolverFixture.Target.Id); + await resolver.TargetCompletedAsync( + resolverFixture.Target.Id, + resolverFixture.Target.ExpectedPost.Digest); + await resolver.CommittingAsync("post"); + await resolver.CommittedPendingAckAsync("post"); + await resolver.CompletedAsync(); + + RestoreJournalScan scan = await RestoreJournalService.ScanAsync(codexHome); + + RestoreJournalInfo pending = Assert.Single(scan.BlockingJournals); + Assert.Equal(oldFixture.OperationId, pending.OperationId); + Assert.DoesNotContain(oldFixture.OperationId, scan.ResolvedOperationIds); + } + + [Fact] + public async Task Reader_CompletedResolverWithoutTargetEvidenceFailsClosed() + { + RestoreJournalFixture fixture = RestoreJournalFixture.Create(); + string journalPath = Path.Combine(fixture.SnapshotDir, RestoreJournal.FileName); + object[] events = + [ + fixture.NodePreparedEvent(sequence: 1), + fixture.NodeEvent(sequence: 2, state: "applying"), + fixture.NodeEvent(sequence: 3, state: "committing", new { postManifestSha256 = "post" }), + fixture.NodeEvent( + sequence: 4, + state: "committed-pending-ack", + new { postManifestSha256 = "post" }), + fixture.NodeEvent(sequence: 5, state: "completed") + ]; + await File.WriteAllTextAsync( + journalPath, + string.Join("\n", events.Select(static value => JsonSerializer.Serialize(value))) + "\n", + new UTF8Encoding(false)); + + RestoreJournalInfo info = await RestoreJournalService.ReadInfoAsync(journalPath); + + Assert.True(info.InvalidTail); + Assert.True(info.Blocking); + Assert.False(info.Terminal); + Assert.Contains("every declared target", info.ValidationError, StringComparison.Ordinal); + } + + private sealed record RestoreJournalFixture( + string CodexHome, + string SourceDir, + string SnapshotDir, + string OperationId, + RestoreJournalTarget Target, + RestoreJournalPrepared Prepared) + { + internal static RestoreJournalFixture Create() + { + string root = Path.Combine(Path.GetTempPath(), $"cps-restore-journal-{Guid.NewGuid():N}"); + string codexHome = Path.Combine(root, ".codex"); + string source = Path.Combine(root, "source"); + string snapshot = Path.Combine(root, "snapshot"); + Directory.CreateDirectory(codexHome); + Directory.CreateDirectory(source); + Directory.CreateDirectory(snapshot); + return CreateAt(codexHome, snapshot, source, Guid.NewGuid().ToString("D")); + } + + internal static RestoreJournalFixture CreateAt( + string codexHome, + string snapshot, + string source, + string operationId, + IReadOnlyList? resolvesOperationIds = null) + { + Directory.CreateDirectory(codexHome); + Directory.CreateDirectory(snapshot); + Directory.CreateDirectory(source); + string targetPath = Path.Combine(codexHome, "config.toml"); + RestoreJournalTarget target = new( + "target-id", + "config", + targetPath, + new RestoreDigest(false, "absent", "pre"), + new RestoreDigest(true, "sha256-file", "post", 4), + "config.toml"); + RestoreJournalPrepared prepared = new( + new RestoreBackupIdentity("source", Path.GetFullPath(source), "source-revision"), + new RestorePreSnapshotIdentity( + "snapshot", + Path.GetFullPath(snapshot), + "snapshot-revision", + "manifest"), + new RestoreStorageIdentity( + Path.GetFullPath(codexHome), + StateDbLockResource.ResolveExistingPhysicalPath(codexHome, directory: true), + Path.Combine(Path.GetFullPath(codexHome), "sqlite"), + null, + null), + ["config"], + resolvesOperationIds ?? [], + [target]); + return new RestoreJournalFixture( + Path.GetFullPath(codexHome), + Path.GetFullPath(source), + Path.GetFullPath(snapshot), + operationId, + target, + prepared); + } + + internal object NodePreparedEvent(int sequence) => new + { + schemaVersion = 2, + protocolVersion = 2, + operationKind = "restore", + operationId = OperationId, + sequence, + state = "prepared", + recordedAt = DateTimeOffset.UtcNow, + sourceBackup = new + { + backupId = Prepared.SourceBackup.BackupId, + backupDir = Prepared.SourceBackup.BackupDir, + revision = Prepared.SourceBackup.Revision + }, + preRestoreSnapshot = new + { + backupId = Prepared.PreRestoreSnapshot.BackupId, + backupDir = Prepared.PreRestoreSnapshot.BackupDir, + revision = Prepared.PreRestoreSnapshot.Revision, + manifestSha256 = Prepared.PreRestoreSnapshot.ManifestSha256 + }, + storage = new + { + codexHome = Prepared.Storage.CodexHome, + codexHomePhysical = Prepared.Storage.CodexHomePhysical, + sqliteHome = Prepared.Storage.SqliteHome, + stateDbResourceKey = Prepared.Storage.StateDbResourceKey, + targetStateDbPath = Prepared.Storage.TargetStateDbPath + }, + requiredTargetKinds = Prepared.RequiredTargetKinds, + resolvesOperationIds = Prepared.ResolvesOperationIds, + targets = Prepared.Targets.Select(target => new + { + id = target.Id, + kind = target.Kind, + targetPath = target.TargetPath, + pre = target.Pre, + expectedPost = target.ExpectedPost, + snapshotPath = target.SnapshotPath, + snapshotEntryIndex = target.SnapshotEntryIndex + }) + }; + + internal object NodeEvent(int sequence, string state, object? details = null) + { + Dictionary value = new() + { + ["schemaVersion"] = 2, + ["protocolVersion"] = 2, + ["operationKind"] = "restore", + ["operationId"] = OperationId, + ["sequence"] = sequence, + ["state"] = state, + ["recordedAt"] = DateTimeOffset.UtcNow + }; + if (details is not null) + { + foreach (var property in details.GetType().GetProperties()) + { + value[property.Name] = property.GetValue(details); + } + } + return value; + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/RestoreV2IntegrationTests.cs b/desktop/CodexProviderSync.Core.Tests/RestoreV2IntegrationTests.cs new file mode 100644 index 0000000..07ab0c3 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/RestoreV2IntegrationTests.cs @@ -0,0 +1,183 @@ +using System.Text; +using System.Text.Json; + +namespace CodexProviderSync.Core.Tests; + +public sealed class RestoreV2IntegrationTests +{ + [Fact] + public async Task MidTargetFailure_CompensatesAndPersistsRolledBack() + { + RestoreFixture fixture = await RestoreFixture.CreateAsync(); + string beforeRestore = await File.ReadAllTextAsync(fixture.RolloutPath); + CodexSyncService service = new(); + service.FaultInjector = (point, target, _) => + { + if (point == "after_restore_target_write_before_complete" + && target is not null + && Path.GetFileName(target).StartsWith("rollout-", StringComparison.Ordinal)) + { + throw new IOException("injected Restore target failure"); + } + return Task.CompletedTask; + }; + + SyncTransactionException error = await Assert.ThrowsAsync( + () => service.RunRestoreAsync( + fixture.CodexHome, + fixture.SourceBackup, + new RestoreBackupOptions { RestoreDatabase = false })); + + Assert.Equal("SYNC_FAILED_ROLLED_BACK", error.Code); + Assert.False(error.RecoveryRequired); + Assert.Equal(beforeRestore, await File.ReadAllTextAsync(fixture.RolloutPath)); + RestoreJournalInfo journal = Assert.Single(await RestoreJournalService.FindAsync(fixture.CodexHome)); + Assert.Equal("rolled-back", journal.State); + Assert.True(journal.Terminal); + Assert.False(journal.Blocking); + Assert.Empty(await RestoreJournalService.FindBlockingAsync(fixture.CodexHome)); + } + + [Fact] + public async Task LostFinalAcknowledgement_IsRecoveredForwardWithoutRollback() + { + RestoreFixture fixture = await RestoreFixture.CreateAsync(); + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "after_restore_committed_pending_ack_before_completed") + { + throw new IOException("injected lost acknowledgement"); + } + return Task.CompletedTask; + }; + + RestoreResult result = await service.RunRestoreAsync( + fixture.CodexHome, + fixture.SourceBackup, + new RestoreBackupOptions { RestoreDatabase = false }); + + Assert.Equal(2, result.RestoreVersion); + Assert.Equal("completed", result.RestoreJournalState); + Assert.True(result.CommitAcknowledgementRecovered); + Assert.Contains("apigather", await File.ReadAllTextAsync(fixture.RolloutPath)); + RestoreJournalInfo journal = Assert.Single(await RestoreJournalService.FindAsync(fixture.CodexHome)); + Assert.Equal("completed", journal.State); + Assert.DoesNotContain( + journal.Events, + static item => item.State is "rollback-pending" or "rolled-back"); + } + + [Fact] + public async Task PreSnapshotFailure_HasNoMutationAndLeavesNoRestoreJournal() + { + RestoreFixture fixture = await RestoreFixture.CreateAsync(); + string configBefore = await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml")); + string rolloutBefore = await File.ReadAllTextAsync(fixture.RolloutPath); + CodexSyncService service = new(); + service.FaultInjector = (point, _, _) => + { + if (point == "after_restore_pre_snapshot_target_before_hash") + { + throw new IOException("injected pre-snapshot failure"); + } + return Task.CompletedTask; + }; + + await Assert.ThrowsAsync(() => service.RunRestoreAsync( + fixture.CodexHome, + fixture.SourceBackup, + new RestoreBackupOptions { RestoreDatabase = false })); + + Assert.Equal(configBefore, await File.ReadAllTextAsync(Path.Combine(fixture.CodexHome, "config.toml"))); + Assert.Equal(rolloutBefore, await File.ReadAllTextAsync(fixture.RolloutPath)); + Assert.Empty(await RestoreJournalService.FindAsync(fixture.CodexHome)); + } + + [Fact] + public async Task UnknownRestoreSchema_BlocksWritesAndMakesPruneNoOpWithoutRewritingEvidence() + { + RestoreFixture fixture = await RestoreFixture.CreateAsync(); + string snapshotDir = Path.Combine( + AppConstants.DefaultBackupRoot(fixture.CodexHome), + "restore-v2-unknown"); + Directory.CreateDirectory(snapshotDir); + await File.WriteAllTextAsync( + Path.Combine(snapshotDir, "metadata.json"), + JsonSerializer.Serialize(new + { + version = 2, + @namespace = AppConstants.BackupNamespace, + codexHome = fixture.CodexHome, + targetProvider = "openai", + createdAt = DateTimeOffset.UtcNow, + dbFiles = Array.Empty(), + sqliteDbFiles = Array.Empty(), + changedSessionFiles = 0 + })); + string journalPath = Path.Combine(snapshotDir, RestoreJournal.FileName); + string raw = JsonSerializer.Serialize(new + { + schemaVersion = 99, + protocolVersion = 99, + operationKind = "restore", + operationId = Guid.NewGuid().ToString("D"), + sequence = 1, + state = "prepared", + recordedAt = DateTimeOffset.UtcNow, + sourceBackup = new + { + backupId = Path.GetFileName(fixture.SourceBackup), + backupDir = fixture.SourceBackup, + revision = "unknown" + }, + preRestoreSnapshot = new + { + backupId = Path.GetFileName(snapshotDir), + backupDir = snapshotDir, + revision = "unknown", + manifestSha256 = "unknown" + } + }) + "\n"; + await File.WriteAllTextAsync(journalPath, raw, new UTF8Encoding(false)); + string[] before = Directory.GetDirectories(AppConstants.DefaultBackupRoot(fixture.CodexHome)); + + RecoveryRequiredException blocked = await Assert.ThrowsAsync( + () => new CodexSyncService().RunSyncAsync(fixture.CodexHome, provider: "openai")); + Assert.Equal("RECOVERY_REQUIRED", blocked.Code); + + BackupPruneResult pruned = await new CodexSyncService().RunPruneBackupsAsync( + fixture.CodexHome, + keepCount: 0); + string[] after = Directory.GetDirectories(AppConstants.DefaultBackupRoot(fixture.CodexHome)); + Assert.Equal(0, pruned.DeletedCount); + Assert.Equal(before.Order(StringComparer.Ordinal), after.Order(StringComparer.Ordinal)); + Assert.Equal(raw, await File.ReadAllTextAsync(journalPath)); + Assert.True(Directory.Exists(fixture.SourceBackup)); + Assert.True(Directory.Exists(snapshotDir)); + } + + private sealed record RestoreFixture( + string CodexHome, + string RolloutPath, + string SourceBackup) + { + internal static async Task CreateAsync() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string rolloutPath = fixture.RolloutPath( + "sessions", + "rollout-restore-v2-integration.jsonl"); + await fixture.WriteRolloutAsync( + rolloutPath, + "restore-v2-integration", + "apigather"); + SyncResult sync = await new CodexSyncService().RunSyncAsync( + fixture.CodexHome, + provider: "openai"); + Assert.Contains("openai", await File.ReadAllTextAsync(rolloutPath)); + return new RestoreFixture(fixture.CodexHome, rolloutPath, sync.BackupDir); + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/StateDbLockResourceTests.cs b/desktop/CodexProviderSync.Core.Tests/StateDbLockResourceTests.cs new file mode 100644 index 0000000..330609b --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/StateDbLockResourceTests.cs @@ -0,0 +1,244 @@ +using System.Security.Cryptography; +using System.Runtime.InteropServices; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using CodexProviderSync.Core; + +namespace CodexProviderSync.Core.Tests; + +public sealed class StateDbLockResourceTests +{ + [WindowsStateDbAliasFact] + public async Task ResolveAsync_WindowsCaseAliases_UseOneResourceAndContend() + { + using StateDbTempDirectory temporary = new(); + string sqliteHome = Path.Combine(temporary.Path, "CaseSensitiveLookingSqliteHome"); + Directory.CreateDirectory(sqliteHome); + string stateDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await File.WriteAllTextAsync(stateDbPath, "fixture"); + + StateDbLockResource canonical = await StateDbLockResource.ResolveAsync(stateDbPath); + StateDbLockResource alias = await StateDbLockResource.ResolveAsync(stateDbPath.ToUpperInvariant()); + + AssertSameResource(canonical, alias); + await AssertAliasesContendAsync(canonical, alias); + } + + [WindowsStateDbAliasFact] + public async Task ResolveAsync_WindowsDirectorySymlink_UsesOneResourceAndContends() + { + using StateDbTempDirectory temporary = new(); + string sqliteHome = Path.Combine(temporary.Path, "physical-sqlite-home"); + string aliasHome = Path.Combine(temporary.Path, "sqlite-home-alias"); + Directory.CreateDirectory(sqliteHome); + string stateDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await File.WriteAllTextAsync(stateDbPath, "fixture"); + WindowsDirectoryAlias.CreateJunction(aliasHome, sqliteHome); + + StateDbLockResource canonical = await StateDbLockResource.ResolveAsync(stateDbPath); + StateDbLockResource alias = await StateDbLockResource.ResolveAsync( + Path.Combine(aliasHome, AppConstants.DbFileBasename)); + + AssertSameResource(canonical, alias); + await AssertAliasesContendAsync(canonical, alias); + } + + [WindowsStateDbAliasFact] + public async Task ResolveAsync_WindowsShortAndLongDirectoryPaths_UseOneResourceAndContend() + { + using StateDbTempDirectory temporary = new(); + string sqliteHome = Path.Combine(temporary.Path, "Long SQLite Home For Physical Alias"); + Directory.CreateDirectory(sqliteHome); + string stateDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await File.WriteAllTextAsync(stateDbPath, "fixture"); + string shortHome = WindowsDirectoryAlias.GetShortPath(sqliteHome); + if (string.Equals(shortHome, sqliteHome, StringComparison.OrdinalIgnoreCase)) + { + throw Xunit.Sdk.SkipException.ForSkip( + "The temporary volume did not provide an actual Windows 8.3 directory alias."); + } + + StateDbLockResource canonical = await StateDbLockResource.ResolveAsync(stateDbPath); + StateDbLockResource alias = await StateDbLockResource.ResolveAsync( + Path.Combine(shortHome, AppConstants.DbFileBasename)); + + AssertSameResource(canonical, alias); + await AssertAliasesContendAsync(canonical, alias); + } + + [Fact] + public async Task ResolveAsync_UsesNulDelimitedPhysicalIdentityAndLowerSha256() + { + using StateDbTempDirectory temporary = new(); + string sqliteHome = Path.Combine(temporary.Path, "sqlite"); + Directory.CreateDirectory(sqliteHome); + string stateDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await File.WriteAllTextAsync(stateDbPath, "fixture"); + + StateDbLockResource resource = await StateDbLockResource.ResolveAsync(stateDbPath); + string parent = resource.RealDbParent; + string expectedIdentity = (OperatingSystem.IsWindows() ? parent.ToLowerInvariant() : parent) + + "\0" + + (OperatingSystem.IsWindows() ? AppConstants.DbFileBasename.ToLowerInvariant() : AppConstants.DbFileBasename); + string expectedKey = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(expectedIdentity))).ToLowerInvariant(); + + Assert.Equal(expectedIdentity, resource.Identity); + Assert.Equal(expectedKey, resource.ResourceKey); + Assert.Equal( + Path.Combine(parent, ".codex-provider-sync", "locks", expectedKey + ".lock"), + resource.LockPath); + } + + [Fact] + public async Task AcquireStateDbLockAsync_PublishesResourceFieldsAndReportsVerifiedBusy() + { + using StateDbTempDirectory temporary = new(); + string sqliteHome = Path.Combine(temporary.Path, "sqlite"); + Directory.CreateDirectory(sqliteHome); + string stateDbPath = Path.Combine(sqliteHome, AppConstants.DbFileBasename); + await File.WriteAllTextAsync(stateDbPath, "fixture"); + StateDbLockResource resource = await StateDbLockResource.ResolveAsync(stateDbPath); + LockService service = new(); + + await using LockHandle first = await service.AcquireStateDbLockAsync(resource, "first"); + using JsonDocument owner = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(resource.LockPath, "owner.json"))); + Assert.Equal("state-db", owner.RootElement.GetProperty("scope").GetString()); + Assert.Equal(resource.ResourceKey, owner.RootElement.GetProperty("resourceKey").GetString()); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => service.AcquireStateDbLockAsync(resource, "second")); + Assert.True(LockService.IsOperationBusy(error)); + Assert.Equal("state-db", error.Data["codex-provider-sync/lock-scope"]); + Assert.Equal(resource.ResourceKey, error.Data["codex-provider-sync/resource-key"]); + } + + [Fact] + public async Task ResolveAsync_AllowsMissingDatabaseOnlyWithVerifiedParent() + { + using StateDbTempDirectory temporary = new(); + string sqliteHome = Path.Combine(temporary.Path, "sqlite"); + Directory.CreateDirectory(sqliteHome); + StateDbLockResource resource = await StateDbLockResource.ResolveAsync( + Path.Combine(sqliteHome, AppConstants.DbFileBasename)); + Assert.Matches("^[a-f0-9]{64}$", resource.ResourceKey); + + InvalidOperationException error = await Assert.ThrowsAsync( + () => StateDbLockResource.ResolveAsync( + Path.Combine(temporary.Path, "missing", AppConstants.DbFileBasename))); + Assert.True(LockService.IsLockUnverifiable(error)); + } + + private static void AssertSameResource( + StateDbLockResource expected, + StateDbLockResource actual) + { + Assert.Equal(expected.Identity, actual.Identity); + Assert.Equal(expected.ResourceKey, actual.ResourceKey); + Assert.Equal(expected.RealDbParent, actual.RealDbParent); + Assert.Equal(expected.StateDbPath, actual.StateDbPath); + Assert.Equal(expected.LockPath, actual.LockPath); + } + + private static async Task AssertAliasesContendAsync( + StateDbLockResource owner, + StateDbLockResource contender) + { + LockService service = new(); + await using LockHandle held = await service.AcquireStateDbLockAsync(owner, "alias-owner"); + InvalidOperationException error = await Assert.ThrowsAsync( + () => service.AcquireStateDbLockAsync(contender, "alias-contender")); + Assert.True(LockService.IsOperationBusy(error)); + Assert.Equal("state-db", error.Data["codex-provider-sync/lock-scope"]); + Assert.Equal(owner.ResourceKey, error.Data["codex-provider-sync/resource-key"]); + } + +} + +public sealed class WindowsStateDbAliasFactAttribute : FactAttribute +{ + public WindowsStateDbAliasFactAttribute() + { + if (!OperatingSystem.IsWindows()) + { + Skip = "Windows path-alias semantics are not applicable on this platform."; + } + } +} + +internal static class WindowsDirectoryAlias +{ + public static string GetShortPath(string value) + { + StringBuilder buffer = new(32768); + uint length = GetShortPathNameW(value, buffer, (uint)buffer.Capacity); + if (length == 0 || length >= buffer.Capacity) + { + throw new InvalidOperationException( + $"GetShortPathNameW could not resolve an 8.3 alias (Win32 {Marshal.GetLastPInvokeError()})."); + } + return buffer.ToString(); + } + + public static void CreateJunction(string aliasPath, string targetPath) + { + ProcessStartInfo startInfo = new(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe")) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("mklink"); + startInfo.ArgumentList.Add("/J"); + startInfo.ArgumentList.Add(aliasPath); + startInfo.ArgumentList.Add(targetPath); + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start the Windows junction helper."); + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0 || !Directory.Exists(aliasPath)) + { + throw new InvalidOperationException( + $"Could not create the test junction (exit {process.ExitCode}). {stdout} {stderr}".Trim()); + } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetShortPathNameW( + string longPath, + StringBuilder shortPath, + uint shortPathLength); +} + +internal sealed class StateDbTempDirectory : IDisposable +{ + public StateDbTempDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"codex-provider-sync-state-lock-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch + { + // Best-effort test cleanup on Windows antivirus/indexer races. + } + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/StatusCoordinationTests.cs b/desktop/CodexProviderSync.Core.Tests/StatusCoordinationTests.cs new file mode 100644 index 0000000..476acc2 --- /dev/null +++ b/desktop/CodexProviderSync.Core.Tests/StatusCoordinationTests.cs @@ -0,0 +1,98 @@ +using CodexProviderSync.Core; +using Microsoft.Data.Sqlite; + +namespace CodexProviderSync.Core.Tests; + +public sealed class StatusCoordinationTests +{ + [Fact] + public async Task ActiveHomeLock_ReturnsLastCompleteSnapshotWithOperationMarker() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"apigather\""); + await fixture.WriteStateDbAsync([("thread-home-status", "apigather", false)]); + CodexSyncService service = new(); + StatusSnapshot baseline = await service.GetStatusAsync(fixture.CodexHome); + Assert.Equal("apigather", baseline.CurrentProvider.Provider); + + await using (LockHandle held = await new LockService().AcquireLockAsync( + fixture.CodexHome, + "home-status-fixture")) + { + await fixture.WriteConfigAsync("model_provider = \"openai\""); + + StatusSnapshot blocked = await new CodexSyncService().GetStatusAsync(fixture.CodexHome); + + Assert.Equal("apigather", blocked.CurrentProvider.Provider); + Assert.Equal(baseline.StorageRevision, blocked.StorageRevision); + Assert.Equal("codex-home", blocked.OperationInProgress!.BusyScope); + Assert.Equal("home-status-fixture", blocked.OperationInProgress.Operation); + Assert.Equal("active", blocked.OperationInProgress.LockState); + Assert.Equal("codex-home-lock", blocked.StatusReadBlocked!.Reason); + Assert.Contains("last complete snapshot", TextFormatter.FormatStatus(blocked)); + } + + StatusSnapshot refreshed = await service.GetStatusAsync(fixture.CodexHome); + Assert.Equal("openai", refreshed.CurrentProvider.Provider); + Assert.Null(refreshed.OperationInProgress); + } + + [Fact] + public async Task ActiveSharedStateDbLock_DoesNotExposeIntermediateSqliteRows() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + await fixture.WriteStateDbAsync([("thread-state-status", "relay", false)]); + CodexSyncService service = new(); + StatusSnapshot baseline = await service.GetStatusAsync(fixture.CodexHome); + Assert.Equal(1, baseline.SqliteCounts!.Sessions["relay"]); + baseline.SqliteCounts.Sessions["relay"] = 999; + baseline.RolloutCounts.Sessions["caller-poison"] = 999; + + StateDbLockResource resource = await StateDbLockResource.ResolveAsync(fixture.StateDbPath()); + await using (LockHandle held = await new LockService().AcquireStateDbLockAsync( + resource, + "state-status-fixture")) + { + await using SqliteConnection connection = fixture.OpenSqliteConnection(); + await connection.OpenAsync(); + SqliteCommand update = connection.CreateCommand(); + update.CommandText = "UPDATE threads SET model_provider = 'external' WHERE id = 'thread-state-status'"; + Assert.Equal(1, await update.ExecuteNonQueryAsync()); + + StatusSnapshot blocked = await new CodexSyncService().GetStatusAsync(fixture.CodexHome); + + Assert.Equal(1, blocked.SqliteCounts!.Sessions["relay"]); + Assert.False(blocked.SqliteCounts.Sessions.ContainsKey("external")); + Assert.False(blocked.RolloutCounts.Sessions.ContainsKey("caller-poison")); + Assert.Equal("state-db", blocked.OperationInProgress!.BusyScope); + Assert.Equal("state-status-fixture", blocked.OperationInProgress.Operation); + Assert.Equal(resource.ResourceKey, (await new LockService() + .InspectStateDbLockAsync(resource)).ResourceKey); + } + + StatusSnapshot refreshed = await service.GetStatusAsync(fixture.CodexHome); + Assert.Equal(1, refreshed.SqliteCounts!.Sessions["external"]); + Assert.Null(refreshed.OperationInProgress); + } + + [Fact] + public async Task UnverifiableHomeLock_ReturnsAnExplicitlyIncompleteSnapshotWithoutMutation() + { + TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); + await fixture.WriteConfigAsync("model_provider = \"openai\""); + string lockPath = AppConstants.LockPath(fixture.CodexHome); + Directory.CreateDirectory(lockPath); + string ownerPath = Path.Combine(lockPath, "owner.json"); + await File.WriteAllTextAsync(ownerPath, "malformed-owner"); + + StatusSnapshot status = await new CodexSyncService().GetStatusAsync(fixture.CodexHome); + + Assert.Equal("unknown", status.CurrentProvider.Provider); + Assert.False(status.RolloutScanComplete); + Assert.Equal("unverifiable", status.OperationInProgress!.LockState); + Assert.Equal(LockService.LockUnverifiableErrorCode, status.OperationInProgress.ErrorCode); + Assert.Equal("malformed-owner", await File.ReadAllTextAsync(ownerPath)); + Assert.True(Directory.Exists(lockPath)); + } +} diff --git a/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs b/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs index 9a1578d..cbf2ea6 100644 --- a/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/TransactionJournalTests.cs @@ -24,6 +24,7 @@ await Assert.ThrowsAsync( PendingTransactionInfo prepared = await fixture.Journal.ReadCurrentInfoAsync(); Assert.Equal(1, prepared.LastSequence); Assert.Equal("prepared", prepared.State); + Assert.Equal(Path.Combine(fixture.Root, "backup"), prepared.DeclaredBackupDir); fixture.Journal.AppendFaultInjector = null; await fixture.Journal.ApplyingAsync("rollout", fixture.Targets[0]); diff --git a/desktop/CodexProviderSync.Core.Tests/WslSqliteSafetyTests.cs b/desktop/CodexProviderSync.Core.Tests/WslSqliteSafetyTests.cs index b266c6a..3912534 100644 --- a/desktop/CodexProviderSync.Core.Tests/WslSqliteSafetyTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/WslSqliteSafetyTests.cs @@ -11,6 +11,9 @@ public async Task WindowsCore_DoesNotTouchRealWslSqliteHome() string sqliteHome = Environment.GetEnvironmentVariable("CODEX_PROVIDER_SYNC_WSL_SQLITE_HOME")!; Assert.True(OperatingSystem.IsWindows(), "This integration test must run in a Windows process."); + Assert.False( + string.IsNullOrWhiteSpace(sqliteHome), + "CPS_REQUIRE_REAL_WSL=1 requires CODEX_PROVIDER_SYNC_WSL_SQLITE_HOME from a real WSL filesystem."); TestCodexHomeFixture fixture = await TestCodexHomeFixture.CreateAsync(); await fixture.WriteConfigAsync("model_provider = \"openai\""); string rolloutPath = fixture.RolloutPath("sessions", "rollout-wsl-safety.jsonl"); @@ -65,7 +68,12 @@ public sealed class WindowsWslIntegrationFactAttribute : FactAttribute { public WindowsWslIntegrationFactAttribute() { - if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CODEX_PROVIDER_SYNC_WSL_SQLITE_HOME"))) + bool required = string.Equals( + Environment.GetEnvironmentVariable("CPS_REQUIRE_REAL_WSL"), + "1", + StringComparison.Ordinal); + if (!required + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CODEX_PROVIDER_SYNC_WSL_SQLITE_HOME"))) { Skip = "Run scripts/test-wsl-unc-safety.sh from WSL to provide a real ext4 SQLite Home."; } diff --git a/desktop/CodexProviderSync.Core/BackupService.cs b/desktop/CodexProviderSync.Core/BackupService.cs index 8322717..b0b2abd 100644 --- a/desktop/CodexProviderSync.Core/BackupService.cs +++ b/desktop/CodexProviderSync.Core/BackupService.cs @@ -158,7 +158,8 @@ public async Task RestoreBackupAsync( public async Task RestoreBackupAsync( string backupDir, CodexStorageLayout storage, - RestoreBackupOptions? options = null) + RestoreBackupOptions? options = null, + string? expectedStateDbTargetPath = null) { storage.EnsureSqliteAccessSupported("restore"); options ??= new RestoreBackupOptions(); @@ -264,6 +265,13 @@ await _sessionRolloutService.AssertSessionFilesWritableAsync( databaseRestorePlan = (sourcePath, targetPath); } } + if (expectedStateDbTargetPath is not null + && (databaseRestorePlan is null + || !PathsEqual(databaseRestorePlan.Value.TargetPath, expectedStateDbTargetPath))) + { + throw new InvalidOperationException( + "The resolved State DB restore target changed after its resource lock was selected."); + } } if (options.RestoreConfig) @@ -297,6 +305,297 @@ await _sqliteStateService.RestoreSqliteOnlineBackupAsync( }; } + internal async Task PrepareRestoreBackupAsync( + string backupDir, + CodexStorageLayout storage, + RestoreBackupOptions options, + string? expectedStateDbTargetPath = null, + CancellationToken cancellationToken = default) + { + storage.EnsureSqliteAccessSupported("restore"); + string codexHome = storage.CodexHome; + string normalizedBackupDir = Path.GetFullPath(backupDir); + string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json"); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(metadataPath, cancellationToken), + JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); + if (!string.Equals(metadata.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal) + || metadata.Version is not (1 or 2)) + { + throw new InvalidOperationException($"Unsupported backup metadata in {metadataPath}."); + } + if (!PathsEqual(metadata.CodexHome, codexHome)) + { + throw new InvalidOperationException($"Backup was created for {metadata.CodexHome}, not {codexHome}."); + } + + List targets = []; + if (options.RestoreConfig) + { + ValidateGlobalStatePresenceMetadata(metadata); + string configSource = Path.Combine(normalizedBackupDir, "config.toml"); + if (File.Exists(configSource)) + { + targets.Add(new RestoreBackupTarget( + "config", + Path.Combine(codexHome, "config.toml"), + configSource, + "copy")); + } + foreach (string fileName in new[] + { + AppConstants.GlobalStateFileBasename, + AppConstants.GlobalStateBackupFileBasename + }) + { + string sourcePath = Path.Combine(normalizedBackupDir, fileName); + bool? originallyPresent = ResolveGlobalStatePresence(metadata, fileName); + if (originallyPresent == true && !File.Exists(sourcePath)) + { + throw new InvalidOperationException( + $"Backup declares an original file but the backup copy is missing: {sourcePath}"); + } + string sourceAction = originallyPresent == false + ? "delete" + : (originallyPresent == true || File.Exists(sourcePath) ? "copy" : "preserve"); + targets.Add(new RestoreBackupTarget( + "globalState", + Path.Combine(codexHome, fileName), + sourcePath, + sourceAction)); + } + } + + string? targetSqliteHome = null; + string? stateDbTargetPath = null; + if (options.RestoreDatabase) + { + StateDbLocation? stateDb = storage.StateDbLocation ?? _sqliteStateService.DetectStateDb(storage); + if (stateDb is null && storage.HasConfiguredSqliteHome) + { + throw new InvalidOperationException( + $"state_5.sqlite not found in SQLite home {storage.SqliteHome}."); + } + targetSqliteHome = ResolveRestoreSqliteHome(storage, metadata, stateDb); + bool sqliteHomeRelocation = metadata.Version >= 2 + && !string.IsNullOrWhiteSpace(metadata.SqliteHome) + && !PathsEqual(metadata.SqliteHome, targetSqliteHome); + if (sqliteHomeRelocation && !options.AllowSqliteHomeRelocation) + { + throw new InvalidOperationException( + $"Backup SQLite home is {metadata.SqliteHome}, but the current target is {targetSqliteHome}. " + + "Confirm SQLite Home relocation before restoring to a different location."); + } + if (sqliteHomeRelocation && options.RestoreConfig) + { + throw new InvalidOperationException( + "Cannot restore config.toml while relocating SQLite home. " + + "Disable config restore to preserve the current target configuration."); + } + if (stateDb is not null) + { + await _sqliteStateService.AssertSqliteWritableAsync(storage with { StateDbLocation = stateDb }); + } + + IReadOnlyList databaseFiles = metadata.Version >= 2 + ? metadata.SqliteDbFiles ?? [] + : metadata.DbFiles ?? []; + string[] mainFiles = databaseFiles + .Where(static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename) + .ToArray(); + if (mainFiles.Length != 1) + { + throw new InvalidOperationException( + mainFiles.Length == 0 + ? "Backup does not contain state_5.sqlite. Disable database restore to restore the remaining data." + : "Backup must contain exactly one state_5.sqlite restore source."); + } + string databaseBackupRoot = metadata.Version >= 2 + ? Path.Combine(normalizedBackupDir, "db", "sqlite-home") + : Path.Combine(normalizedBackupDir, "db"); + string restoreRoot = metadata.Version >= 2 ? targetSqliteHome : codexHome; + string fileName = mainFiles[0]; + string sourcePath = Path.Combine(databaseBackupRoot, fileName); + if (!File.Exists(sourcePath)) + { + throw new InvalidOperationException($"Backup declares a missing SQLite file: {sourcePath}"); + } + stateDbTargetPath = metadata.Version >= 2 + ? RestoreSqliteTargetPath(restoreRoot, fileName) + : RestoreDbTargetPath(restoreRoot, fileName); + if (expectedStateDbTargetPath is not null + && !PathsEqual(stateDbTargetPath, expectedStateDbTargetPath)) + { + throw new InvalidOperationException( + "The resolved State DB restore target changed after its resource lock was selected."); + } + targets.Add(new RestoreBackupTarget( + "sqlite", + stateDbTargetPath, + sourcePath, + "copy")); + } + + SessionBackupManifest? sessionManifest = null; + if (options.RestoreSessions) + { + string sessionManifestPath = Path.Combine(normalizedBackupDir, "session-meta-backup.json"); + sessionManifest = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(sessionManifestPath, cancellationToken), + JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}"); + ValidateSessionManifest(sessionManifest, codexHome, normalizedBackupDir); + sessionManifest = await SelectSessionEntriesForRestoreAsync(normalizedBackupDir, sessionManifest); + await _sessionRolloutService.AssertSessionFilesWritableAsync( + sessionManifest.Files.Select(static entry => entry.Path)); + targets.AddRange(sessionManifest.Files.Select(entry => new RestoreBackupTarget( + "rollout", + Path.GetFullPath(entry.Path), + null, + "metadata", + entry))); + } + + cancellationToken.ThrowIfCancellationRequested(); + return new RestoreBackupPlan( + normalizedBackupDir, + storage, + options, + metadata, + targetSqliteHome, + stateDbTargetPath, + targets); + } + + internal async Task ApplyRestoreTargetAsync( + RestoreBackupPlan plan, + RestoreBackupTarget target, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + switch (target.Kind) + { + case "config": + if (target.SourcePath is null) + { + throw new InvalidOperationException("Restore config target has no source."); + } + await AtomicFile.CopyAsync(target.SourcePath, target.TargetPath, overwrite: true, cancellationToken); + break; + case "globalState": + if (target.SourceAction == "delete") + { + if (File.Exists(target.TargetPath)) + { + File.Delete(target.TargetPath); + } + } + else if (target.SourceAction == "copy") + { + if (target.SourcePath is null + || !await AtomicFile.CopyAsync( + target.SourcePath, + target.TargetPath, + overwrite: true, + cancellationToken)) + { + throw new InvalidOperationException( + $"Restore global-state source is missing: {target.SourcePath}"); + } + } + else if (target.SourceAction != "preserve") + { + throw new InvalidOperationException( + $"Unsupported global-state Restore action: {target.SourceAction}"); + } + break; + case "sqlite": + if (target.SourcePath is null) + { + throw new InvalidOperationException("Restore SQLite target has no source."); + } + await _sqliteStateService.RestoreSqliteOnlineBackupAsync( + target.SourcePath, + target.TargetPath); + break; + case "rollout": + if (target.SessionEntry is null) + { + throw new InvalidOperationException("Restore rollout target has no manifest entry."); + } + string verifiedPath = ValidateSessionRestorePath(plan.Storage.CodexHome, target.TargetPath); + if (!PathsEqual(verifiedPath, target.SessionEntry.Path)) + { + throw new InvalidOperationException("Restore rollout target changed after validation."); + } + await _sessionRolloutService.AssertSessionFilesWritableAsync([verifiedPath]); + await _sessionRolloutService.RestoreSessionChangesAsync([target.SessionEntry]); + break; + default: + throw new InvalidOperationException($"Unsupported Restore target kind: {target.Kind}"); + } + } + + internal async Task ResolveRestoreStateDbTargetPathAsync( + string backupDir, + CodexStorageLayout storage, + RestoreBackupOptions options) + { + storage.EnsureSqliteAccessSupported("restore"); + string normalizedBackupDir = Path.GetFullPath(backupDir); + string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json"); + BackupMetadataFile metadata = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(metadataPath), + JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}"); + if (!string.Equals(metadata.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal) + || metadata.Version is not (1 or 2)) + { + throw new InvalidOperationException($"Unsupported backup metadata in {metadataPath}."); + } + if (!PathsEqual(metadata.CodexHome, storage.CodexHome)) + { + throw new InvalidOperationException( + $"Backup was created for {metadata.CodexHome}, not {storage.CodexHome}."); + } + + StateDbLocation? stateDb = storage.StateDbLocation ?? _sqliteStateService.DetectStateDb(storage); + if (stateDb is null && storage.HasConfiguredSqliteHome) + { + throw new InvalidOperationException( + $"state_5.sqlite not found in SQLite home {storage.SqliteHome}."); + } + string targetSqliteHome = ResolveRestoreSqliteHome(storage, metadata, stateDb); + bool sqliteHomeRelocation = metadata.Version >= 2 + && !string.IsNullOrWhiteSpace(metadata.SqliteHome) + && !PathsEqual(metadata.SqliteHome, targetSqliteHome); + if (sqliteHomeRelocation && !options.AllowSqliteHomeRelocation) + { + throw new InvalidOperationException( + $"Backup SQLite home is {metadata.SqliteHome}, but the current target is {targetSqliteHome}. " + + "Confirm SQLite Home relocation before restoring to a different location."); + } + if (sqliteHomeRelocation && options.RestoreConfig) + { + throw new InvalidOperationException( + "Cannot restore config.toml while relocating SQLite home. " + + "Disable config restore to preserve the current target configuration."); + } + + IReadOnlyList databaseFiles = metadata.Version >= 2 + ? metadata.SqliteDbFiles ?? [] + : metadata.DbFiles ?? []; + string[] mainFiles = databaseFiles + .Where(static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename) + .ToArray(); + if (mainFiles.Length != 1) + { + throw new InvalidOperationException( + "Backup must contain exactly one state_5.sqlite restore source."); + } + return metadata.Version >= 2 + ? RestoreSqliteTargetPath(targetSqliteHome, mainFiles[0]) + : RestoreDbTargetPath(storage.CodexHome, mainFiles[0]); + } + public async Task UpdateSessionBackupManifestAsync(string backupDir, IReadOnlyList sessionChanges) { string normalizedBackupDir = Path.GetFullPath(backupDir); @@ -652,12 +951,33 @@ private static async Task> GetPruneDeletionCandidatesAsync { string backupRoot = AppConstants.DefaultBackupRoot(codexHome); IReadOnlyList pending = await FileTransactionJournal.FindPendingAsync(codexHome); - HashSet protectedBackups = new( - pending.Select(static transaction => Path.GetFullPath(transaction.BackupDir)), - PathComparer); + RestoreJournalScan restoreScan = await RestoreJournalService.ScanAsync(codexHome); + if (restoreScan.PruneReferencesUnverifiable) + { + // An unreadable Restore journal may reference a source backup that + // cannot be reconstructed safely. Prune is intentionally a no-op + // until explicit recovery resolves that evidence. + return []; + } + HashSet protectedBackups = new(PathComparer); + foreach (string protectedDirectory in pending + .Select(static transaction => transaction.BackupDir) + .Concat(restoreScan.ProtectedDirectories)) + { + string? physical = TryStablePhysicalBackupDirectory(protectedDirectory); + if (physical is null) + { + return []; + } + protectedBackups.Add(physical); + } string? preserved = string.IsNullOrWhiteSpace(preservedBackupDirectory) ? null - : Path.GetFullPath(preservedBackupDirectory); + : TryStablePhysicalBackupDirectory(preservedBackupDirectory); + if (!string.IsNullOrWhiteSpace(preservedBackupDirectory) && preserved is null) + { + return []; + } return await Task.Run>(() => { @@ -667,15 +987,37 @@ private static async Task> GetPruneDeletionCandidatesAsync } int existingKeepSlots = Math.Max(0, keepCount - (reserveNewBackupSlot ? 1 : 0)); - return GetManagedBackupDirectories(backupRoot) - .Where(entry => preserved is null || !PathComparer.Equals(Path.GetFullPath(entry.FullName), preserved)) + List<(DirectoryInfo Entry, string Physical)> directories = []; + foreach (DirectoryInfo entry in GetManagedBackupDirectories(backupRoot)) + { + string? physical = TryStablePhysicalBackupDirectory(entry.FullName); + if (physical is null) + { + return []; + } + directories.Add((entry, physical)); + } + return directories + .Where(item => preserved is null || !PathComparer.Equals(item.Physical, preserved)) .Skip(existingKeepSlots) - .Where(entry => !protectedBackups.Contains(Path.GetFullPath(entry.FullName))) - .Select(static entry => Path.GetFullPath(entry.FullName)) + .Where(item => !protectedBackups.Contains(item.Physical)) + .Select(static item => Path.GetFullPath(item.Entry.FullName)) .ToArray(); }); } + private static string? TryStablePhysicalBackupDirectory(string directory) + { + try + { + return RestoreV2Service.ResolveStablePhysicalDirectory(directory); + } + catch + { + return null; + } + } + private static async Task SelectSessionEntriesForRestoreAsync( string backupDir, SessionBackupManifest manifest) @@ -743,7 +1085,7 @@ private static void ValidateSessionManifest( } } - private static string ValidateSessionRestorePath(string codexHome, string candidatePath) + internal static string ValidateSessionRestorePath(string codexHome, string candidatePath) { if (string.IsNullOrWhiteSpace(candidatePath)) { @@ -1100,3 +1442,19 @@ private sealed class BackupMetadataValidationFile } internal sealed record BackupRecoveryCoverage(bool Config, bool Database, bool Sessions); + +internal sealed record RestoreBackupTarget( + string Kind, + string TargetPath, + string? SourcePath, + string SourceAction, + SessionBackupManifestEntry? SessionEntry = null); + +internal sealed record RestoreBackupPlan( + string BackupDirectory, + CodexStorageLayout Storage, + RestoreBackupOptions Options, + BackupMetadataFile Metadata, + string? TargetSqliteHome, + string? StateDbTargetPath, + IReadOnlyList Targets); diff --git a/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj index 2c51005..89871a4 100644 --- a/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj +++ b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj @@ -4,9 +4,9 @@ net10.0 enable enable - 0.5.0 - 0.5.0.0 - 0.5.0.0 + 1.0.0 + 1.0.0.0 + 1.0.0.0 diff --git a/desktop/CodexProviderSync.Core/CodexSyncService.cs b/desktop/CodexProviderSync.Core/CodexSyncService.cs index 6a7bc2b..7eca8d7 100644 --- a/desktop/CodexProviderSync.Core/CodexSyncService.cs +++ b/desktop/CodexProviderSync.Core/CodexSyncService.cs @@ -1,15 +1,19 @@ +using System.Collections.Concurrent; using System.Diagnostics; namespace CodexProviderSync.Core; public sealed class CodexSyncService { + private static readonly ConcurrentDictionary LastCompleteStatus = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); 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 RestoreV2Service _restoreV2Service; private readonly LockService _lockService; private readonly ProviderDiscoveryService _providerDiscoveryService; private readonly CodexStorageLayoutService _storageLayoutService; @@ -46,6 +50,10 @@ public CodexSyncService( _providerDiscoveryService = providerDiscoveryService; _storageLayoutService = new CodexStorageLayoutService(codexHomeService, configFileService); _backupService = new BackupService(sessionRolloutService, sqliteStateService); + _restoreV2Service = new RestoreV2Service( + _backupService, + sessionRolloutService, + sqliteStateService); } public async Task GetStatusAsync( @@ -55,8 +63,167 @@ public async Task GetStatusAsync( long totalStarted = Stopwatch.GetTimestamp(); string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); + string cacheKey = BuildStatusCacheKey(codexHome, explicitSqliteHome); + LockInspection homeBefore = await _lockService.InspectLockAsync(codexHome); + if (!homeBefore.IsAbsent) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + homeBefore, + "codex-home-lock"); + } + string configText = await _configFileService.ReadConfigTextAsync(_codexHomeService.ConfigPath(codexHome)); CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); + LockInspection homeResolved = await _lockService.InspectLockAsync(codexHome); + if (!SameAbsentObservation(homeBefore, homeResolved)) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + homeResolved, + "codex-home-lock"); + } + + StateDbLockResource? stateResource = null; + LockInspection? stateBefore = null; + if (storage.StateDbLocation is { } stateDbLocation) + { + try + { + stateResource = await StateDbLockResource.ResolveAsync(stateDbLocation.Path); + stateBefore = await _lockService.InspectStateDbLockAsync(stateResource); + } + catch (Exception error) when (error is InvalidOperationException + or IOException + or UnauthorizedAccessException) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + SyntheticUnverifiableInspection("state-db", error), + "state-db-lock"); + } + if (!stateBefore.IsAbsent) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + stateBefore, + "state-db-lock"); + } + } + + CoreWritePlanSnapshot revisionBefore; + try + { + revisionBefore = await BuildStatusRevisionAsync(codexHome, storage); + } + catch (Exception error) when (error is IOException + or UnauthorizedAccessException + or CoreWritePlanStaleException) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + SyntheticUnverifiableInspection("codex-home", error), + "revision-unverifiable"); + } + + StatusSnapshot snapshot = await ScanStatusAsync( + codexHome, + configText, + storage, + revisionBefore.StateFingerprint, + totalStarted); + + LockInspection homeAfter = await _lockService.InspectLockAsync(codexHome); + if (!SameAbsentObservation(homeBefore, homeAfter)) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + homeAfter, + "codex-home-lock"); + } + LockInspection? stateAfter = null; + if (stateResource is not null) + { + stateAfter = await _lockService.InspectStateDbLockAsync(stateResource); + if (!SameAbsentObservation(stateBefore!, stateAfter)) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + stateAfter, + "state-db-lock"); + } + } + + try + { + CoreWritePlanSnapshot revisionAfter = await BuildStatusRevisionAsync(codexHome, storage); + CoreWriteSnapshotBuilder.AssertExactMatch(revisionBefore, revisionAfter); + } + catch (Exception error) when (error is IOException + or UnauthorizedAccessException + or CoreWritePlanStaleException) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + SyntheticUnverifiableInspection("codex-home", error), + "state-changed-during-status", + revisionBefore.StateFingerprint); + } + + LockInspection homeFinal = await _lockService.InspectLockAsync(codexHome); + if (!SameAbsentObservation(homeBefore, homeFinal)) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + homeFinal, + "codex-home-lock"); + } + if (stateResource is not null) + { + LockInspection stateFinal = await _lockService.InspectStateDbLockAsync(stateResource); + if (!SameAbsentObservation(stateBefore!, stateFinal)) + { + return BuildBlockedStatus( + cacheKey, + codexHome, + explicitSqliteHome, + stateFinal, + "state-db-lock"); + } + } + + // Keep the cache isolated from mutable DTO collections returned to + // callers. A consumer must never be able to poison the safety snapshot + // that is served while a write lock is active. + LastCompleteStatus[cacheKey] = CopyStatusSnapshot(snapshot); + return snapshot; + } + + private async Task ScanStatusAsync( + string codexHome, + string configText, + CodexStorageLayout storage, + string storageRevision, + long totalStarted) + { CurrentProviderInfo currentProvider = _configFileService.ReadCurrentProviderFromConfigText(configText); IReadOnlyList configuredProviders = _configFileService.ListConfiguredProviderIds(configText); long rolloutScanStarted = Stopwatch.GetTimestamp(); @@ -80,9 +247,12 @@ public async Task GetStatusAsync( BackupSummary backupSummary = await _backupService.GetBackupSummaryAsync(codexHome); long backupSummaryDurationMs = ElapsedMilliseconds(backupSummaryStarted); IReadOnlyList pendingTransactions = await FileTransactionJournal.FindPendingAsync(codexHome); + IReadOnlyList pendingRestores = await RestoreJournalService.FindBlockingAsync(codexHome); return new StatusSnapshot { + SnapshotAt = DateTimeOffset.UtcNow, + StorageRevision = storageRevision, CodexHome = codexHome, SqliteHome = storage.SqliteHome, SqliteHomeSource = storage.SqliteHomeSource, @@ -107,7 +277,17 @@ public async Task GetStatusAsync( item.State, item.BackupDir, item.JournalPath)) + .Concat(pendingRestores.Select(static item => new TransactionRecoveryInfo( + item.OperationId, + item.State, + item.SnapshotDir, + item.JournalPath) + { + OperationKind = "restore" + })) .ToArray(), + RolloutScanComplete = rolloutInfo.LockedPaths.Count == 0 + && rolloutInfo.UnreadablePaths.Count == 0, PerformanceMetrics = new StatusPerformanceMetrics { TotalDurationMs = ElapsedMilliseconds(totalStarted), @@ -118,6 +298,242 @@ public async Task GetStatusAsync( }; } + private async Task BuildStatusRevisionAsync( + string codexHome, + CodexStorageLayout storage) + { + List targets = + [ + new(_codexHomeService.ConfigPath(codexHome), "read"), + new( + Path.Combine(codexHome, "sessions"), + "scan", + CoreWriteFingerprintMode.RecursiveInventory), + new( + Path.Combine(codexHome, "archived_sessions"), + "scan", + CoreWriteFingerprintMode.RecursiveInventory), + new(_globalStateService.StatePath(codexHome), "read-if-present"), + new(_globalStateService.BackupPath(codexHome), "read-if-present"), + new( + _codexHomeService.BackupRoot(codexHome), + "inventory", + CoreWriteFingerprintMode.RecursiveInventory) + ]; + if (storage.StateDbLocation is { } stateDb) + { + targets.Add(new CoreWriteTargetSpec( + stateDb.Path, + "read", + CoreWriteFingerprintMode.SqliteMainContent)); + targets.Add(new CoreWriteTargetSpec( + stateDb.Path + "-wal", + "read-if-present", + CoreWriteFingerprintMode.SqliteWalContent)); + } + + string binding = System.Text.Json.JsonSerializer.Serialize(new + { + codexHome, + storage.SqliteHome, + storage.SqliteHomeSource, + stateDbPath = storage.StateDbLocation?.Path, + storage.SqliteAccess.Supported, + storage.SqliteAccess.Reason + }); + return await CoreWriteSnapshotBuilder.BuildAsync( + "status", + binding, + targets); + } + + private StatusSnapshot BuildBlockedStatus( + string cacheKey, + string codexHome, + string? explicitSqliteHome, + LockInspection inspection, + string reason, + string? revision = null) + { + StatusOperationInfo operation = new( + inspection.Owner?.InstanceId, + string.IsNullOrWhiteSpace(inspection.Owner?.Label) ? "unknown" : inspection.Owner.Label!, + "external", + inspection.Owner?.Runtime, + inspection.Owner?.StartedAt, + inspection.Scope, + inspection.State == "active" ? "active" : "unverifiable", + inspection.ErrorCode); + StatusReadBlockedInfo blocked = new( + reason, + operation.LockState, + revision); + if (LastCompleteStatus.TryGetValue(cacheKey, out StatusSnapshot? cached)) + { + return CopyStatusSnapshot(cached, operation, blocked); + } + return CreateUnavailableStatus( + codexHome, + explicitSqliteHome, + operation, + blocked); + } + + private static StatusSnapshot CopyStatusSnapshot( + StatusSnapshot source, + StatusOperationInfo? operation = null, + StatusReadBlockedInfo? blocked = null) + { + return new StatusSnapshot + { + SchemaVersion = source.SchemaVersion, + SnapshotAt = source.SnapshotAt, + StorageRevision = source.StorageRevision, + CodexHome = source.CodexHome, + SqliteHome = source.SqliteHome, + SqliteHomeSource = source.SqliteHomeSource, + SqliteAccess = source.SqliteAccess with { }, + CheckedStateDbPaths = source.CheckedStateDbPaths.ToArray(), + CurrentProvider = source.CurrentProvider with { }, + ConfiguredProviders = source.ConfiguredProviders.ToArray(), + RolloutCounts = CopyProviderCounts(source.RolloutCounts), + LockedRolloutFiles = source.LockedRolloutFiles.ToArray(), + UnreadableRolloutFiles = source.UnreadableRolloutFiles.ToArray(), + EncryptedContentCounts = CopyProviderCounts(source.EncryptedContentCounts), + EncryptedContentWarning = source.EncryptedContentWarning, + SqliteCounts = source.SqliteCounts is null ? null : CopyProviderCounts(source.SqliteCounts), + StateDbLocation = source.StateDbLocation is null ? null : source.StateDbLocation with { }, + SqliteRepairStats = source.SqliteRepairStats is null + ? null + : new SqliteRepairStats + { + UserEventRowsNeedingRepair = source.SqliteRepairStats.UserEventRowsNeedingRepair, + CwdRowsNeedingRepair = source.SqliteRepairStats.CwdRowsNeedingRepair + }, + ProjectThreadVisibility = source.ProjectThreadVisibility + .Select(static item => new ProjectThreadVisibility + { + Root = item.Root, + InteractiveThreads = item.InteractiveThreads, + FirstPageThreads = item.FirstPageThreads, + ExactCwdMatches = item.ExactCwdMatches, + VerbatimCwdRows = item.VerbatimCwdRows, + Ranks = item.Ranks.ToArray(), + RankPreview = item.RankPreview, + ProviderCounts = new Dictionary(item.ProviderCounts, StringComparer.Ordinal) + }) + .ToArray(), + BackupRoot = source.BackupRoot, + BackupSummary = new BackupSummary + { + Count = source.BackupSummary.Count, + TotalBytes = source.BackupSummary.TotalBytes + }, + PendingTransactions = source.PendingTransactions.Select(static item => item with { }).ToArray(), + OperationInProgress = operation, + StatusReadBlocked = blocked, + RolloutScanComplete = source.RolloutScanComplete, + PerformanceMetrics = new StatusPerformanceMetrics + { + TotalDurationMs = source.PerformanceMetrics.TotalDurationMs, + RolloutScanDurationMs = source.PerformanceMetrics.RolloutScanDurationMs, + BackupSummaryDurationMs = source.PerformanceMetrics.BackupSummaryDurationMs, + RolloutScan = new SessionScanMetrics + { + EnumeratedRolloutFiles = source.PerformanceMetrics.RolloutScan.EnumeratedRolloutFiles, + ParsedSessionFiles = source.PerformanceMetrics.RolloutScan.ParsedSessionFiles, + ContentScanPasses = source.PerformanceMetrics.RolloutScan.ContentScanPasses, + ModelScanFiles = source.PerformanceMetrics.RolloutScan.ModelScanFiles, + DurationMs = source.PerformanceMetrics.RolloutScan.DurationMs + } + } + }; + } + + private static ProviderCounts CopyProviderCounts(ProviderCounts source) + { + return new ProviderCounts + { + Sessions = new Dictionary(source.Sessions, StringComparer.Ordinal), + ArchivedSessions = new Dictionary(source.ArchivedSessions, StringComparer.Ordinal), + Unreadable = source.Unreadable, + Error = source.Error + }; + } + + private StatusSnapshot CreateUnavailableStatus( + string codexHome, + string? explicitSqliteHome, + StatusOperationInfo operation, + StatusReadBlockedInfo blocked) + { + string sqliteHome = string.IsNullOrWhiteSpace(explicitSqliteHome) + ? string.Empty + : Path.GetFullPath(explicitSqliteHome.Trim()); + ProviderCounts unavailableCounts = new() + { + Unreadable = true, + Error = "Status scanning is blocked by an active or unverifiable write lock." + }; + return new StatusSnapshot + { + SnapshotAt = DateTimeOffset.UtcNow, + CodexHome = codexHome, + SqliteHome = sqliteHome, + SqliteHomeSource = string.IsNullOrWhiteSpace(explicitSqliteHome) ? "unresolved" : "explicit", + SqliteAccess = new SqliteAccessInfo( + false, + "status-blocked", + "Status scanning is blocked by an active or unverifiable write lock."), + CurrentProvider = new CurrentProviderInfo("unknown", false), + ConfiguredProviders = [], + RolloutCounts = unavailableCounts, + LockedRolloutFiles = [], + UnreadableRolloutFiles = [], + EncryptedContentCounts = new ProviderCounts + { + Unreadable = true, + Error = unavailableCounts.Error + }, + SqliteCounts = null, + BackupRoot = _codexHomeService.BackupRoot(codexHome), + BackupSummary = new BackupSummary { Count = 0, TotalBytes = 0 }, + OperationInProgress = operation, + StatusReadBlocked = blocked, + RolloutScanComplete = false + }; + } + + private static string BuildStatusCacheKey(string codexHome, string? explicitSqliteHome) + { + string selector = string.IsNullOrWhiteSpace(explicitSqliteHome) + ? "" + : Path.GetFullPath(explicitSqliteHome.Trim()); + return Path.GetFullPath(codexHome) + "\0" + selector; + } + + private static bool SameAbsentObservation(LockInspection left, LockInspection right) + { + return left.IsAbsent + && right.IsAbsent + && string.Equals( + left.ObservationRevision, + right.ObservationRevision, + StringComparison.Ordinal); + } + + private static LockInspection SyntheticUnverifiableInspection(string scope, Exception error) + { + return new LockInspection( + "unverifiable", + scope, + null, + string.Empty, + null, + LockService.LockUnverifiableErrorCode, + error.Message); + } + public IReadOnlyList BuildProviderOptions(StatusSnapshot status, AppSettings settings) { return _providerDiscoveryService.BuildProviderOptions(status, settings); @@ -164,7 +580,6 @@ public async Task CreateSyncPlanSnapshotAsync( ValidateAutomaticRetention(keepCount); string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); - await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "plan-sync"); await FileTransactionJournal.AssertNoPendingAsync(codexHome); SyncPreparation preparation = await PrepareSyncAsync( codexHome, @@ -395,7 +810,11 @@ private async Task RunSyncCoreAsync( string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); - await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "sync"); + await using LockHandle homeLock = await _lockService.AcquireLockAsync(codexHome, "sync"); + string operationLabel = switchPreparationFactory is null ? "sync" : "switch"; + (StateDbLockResource? lockedStateDb, LockHandle? stateDbHandle) = + await AcquireCurrentStateDbLockAsync(codexHome, explicitSqliteHome, operationLabel, cancellationToken); + await using LockHandle? stateDbLock = stateDbHandle; await FileTransactionJournal.AssertNoPendingAsync(codexHome); long preparationStarted = Stopwatch.GetTimestamp(); SyncPreparation preparation = await PrepareSyncAsync( @@ -406,6 +825,7 @@ private async Task RunSyncCoreAsync( explicitSqliteHome, switchPreparationFactory, cancellationToken); + await AssertStateDbLockMatchesAsync(preparation.Storage, lockedStateDb, cancellationToken); long preparationDurationMs = ElapsedMilliseconds(preparationStarted); string configPath = preparation.ConfigPath; string configText = preparation.ConfigText; @@ -988,7 +1408,6 @@ public async Task CreateSwitchPlanSnapshotAsync( ValidateAutomaticRetention(keepCount); string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); - await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "plan-switch"); await FileTransactionJournal.AssertNoPendingAsync(codexHome); SyncPreparation preparation = await PrepareSyncAsync( codexHome, @@ -1148,7 +1567,6 @@ public async Task CreateRestorePlanSnapshotAsync( ValidateRestoreRequest(backupDir, options); string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); - await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "plan-restore"); RestorePreparation preparation = await PrepareRestoreAsync( codexHome, backupDir, @@ -1188,16 +1606,50 @@ private async Task RunRestoreCoreAsync( CancellationToken cancellationToken) { ValidateRestoreRequest(backupDir, options); + string requestedBackupDir = Path.GetFullPath(backupDir); string codexHome = _codexHomeService.NormalizeCodexHome(explicitCodexHome); await _codexHomeService.EnsureCodexHomeAsync(codexHome); - await using LockHandle _ = await _lockService.AcquireLockAsync(codexHome, "restore"); + await using LockHandle homeLock = await _lockService.AcquireLockAsync(codexHome, "restore"); + string? physicalBackupDir = null; + StateDbLockResource? lockedStateDb = null; + LockHandle? stateDbHandle = null; + if (options.RestoreDatabase) + { + string lockConfigText = await _configFileService.ReadConfigTextAsync( + _codexHomeService.ConfigPath(codexHome)); + CodexStorageLayout lockStorage = await PrepareStorageAsync( + codexHome, + explicitSqliteHome, + lockConfigText); + lockStorage.EnsureSqliteAccessSupported("restore"); + RestoreBackupIdentity lockedSourceBackup = await RestoreV2Service.CaptureSourceIdentityAsync( + requestedBackupDir, + cancellationToken); + physicalBackupDir = lockedSourceBackup.BackupDir; + string stateDbTargetPath = await _backupService.ResolveRestoreStateDbTargetPathAsync( + physicalBackupDir, + lockStorage, + options); + lockedStateDb = await StateDbLockResource.ResolveAsync( + stateDbTargetPath, + cancellationToken); + stateDbHandle = await _lockService.AcquireStateDbLockAsync(lockedStateDb, "restore"); + } + await using LockHandle? stateDbLock = stateDbHandle; + physicalBackupDir ??= (await RestoreV2Service.CaptureSourceIdentityAsync( + requestedBackupDir, + cancellationToken)).BackupDir; RestorePreparation preparation = await PrepareRestoreAsync( codexHome, - backupDir, + physicalBackupDir, options, explicitSqliteHome, cancellationToken); + await AssertStateDbTargetLockMatchesAsync( + preparation.StateDbTargetPath, + lockedStateDb, + cancellationToken); if (expectedSnapshot is not null) { AssertSnapshotFresh(snapshotExpiresAtUtc); @@ -1207,19 +1659,41 @@ private async Task RunRestoreCoreAsync( CoreWriteSnapshotBuilder.AssertExactMatch(expectedSnapshot, actualSnapshot); AssertSnapshotFresh(snapshotExpiresAtUtc); } - // BackupService currently exposes an atomic restore operation rather - // than per-file cancellation. Honor cancellation until the mutation - // boundary, then let that authoritative operation finish and report - // its real result instead of returning a false cancelled outcome. cancellationToken.ThrowIfCancellationRequested(); - RestoreResult result = await _backupService.RestoreBackupAsync( - preparation.BackupDirectory, - preparation.Storage, - preparation.Options); - await FileTransactionJournal.MarkBackupRolledBackAsync( - preparation.BackupDirectory, - codexHome, - result.TargetProvider); + _restoreV2Service.FaultInjector = FaultInjector; + RestoreResult result; + if (preparation.PendingCommitAcknowledgement is { } pendingAcknowledgement) + { + RestoreJournalInfo completed = await _restoreV2Service.AcknowledgePendingAsync( + pendingAcknowledgement, + preparation.Storage, + lockedStateDb, + cancellationToken); + result = new RestoreResult + { + CodexHome = preparation.Storage.CodexHome, + BackupDir = preparation.BackupDirectory, + TargetProvider = preparation.RestorePlan.Metadata.TargetProvider, + CreatedAt = preparation.RestorePlan.Metadata.CreatedAt, + ChangedSessionFiles = preparation.RestorePlan.Metadata.ChangedSessionFiles, + RestoreVersion = 2, + RestoreOperationId = completed.OperationId, + PreRestoreSnapshotId = completed.Prepared?.PreRestoreSnapshot.BackupId, + RestoreJournalState = completed.State, + CommitAcknowledgementRecovered = true, + ResolvedOperationIds = completed.Prepared?.ResolvesOperationIds ?? [] + }; + } + else + { + result = await _restoreV2Service.ExecuteAsync( + preparation.RestorePlan, + preparation.SourceBackup, + lockedStateDb, + preparation.ResolvesOperationIds, + cancellationToken); + } + result = CopyRestoreResult(result, requestedBackupDir); // The restore and its journal marker are already durable. Refreshing the // inventory only corrects metadata.json bookkeeping, so surface a // failure as a warning instead of reporting a completed restore as @@ -1231,15 +1705,10 @@ await FileTransactionJournal.MarkBackupRolledBackAsync( } catch (Exception error) { - return new RestoreResult - { - CodexHome = result.CodexHome, - BackupDir = result.BackupDir, - TargetProvider = result.TargetProvider, - CreatedAt = result.CreatedAt, - ChangedSessionFiles = result.ChangedSessionFiles, - BackupInventoryWarning = $"Backup inventory refresh failed: {error.Message}" - }; + return CopyRestoreResult( + result, + requestedBackupDir, + $"Backup inventory refresh failed: {error.Message}"); } } @@ -1255,12 +1724,25 @@ private async Task PrepareRestoreAsync( string configText = await _configFileService.ReadConfigTextAsync(configPath); CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); storage.EnsureSqliteAccessSupported("restore"); - string normalizedBackupDir = Path.GetFullPath(backupDir); + RestoreBackupIdentity sourceBackup = await RestoreV2Service.CaptureSourceIdentityAsync( + backupDir, + cancellationToken); + string normalizedBackupDir = sourceBackup.BackupDir; + string? stateDbTargetPath = options.RestoreDatabase + ? await _backupService.ResolveRestoreStateDbTargetPathAsync( + normalizedBackupDir, + storage, + options) + : null; + RestoreBackupPlan restorePlan = await _backupService.PrepareRestoreBackupAsync( + normalizedBackupDir, + storage, + options, + expectedStateDbTargetPath: stateDbTargetPath, + cancellationToken); IReadOnlyList pending = await FileTransactionJournal.FindPendingAsync(codexHome); PendingTransactionInfo[] foreignPending = pending - .Where(transaction => !PathComparer.Equals( - Path.GetFullPath(transaction.BackupDir), - normalizedBackupDir)) + .Where(transaction => !PendingTransactionMatchesBackup(transaction, normalizedBackupDir)) .ToArray(); if (foreignPending.Length > 0) { @@ -1271,13 +1753,73 @@ private async Task PrepareRestoreAsync( foreignPending); } await EnsurePendingRecoveryCoverageAsync(normalizedBackupDir, codexHome, options); + IReadOnlyList pendingRestores = + await RestoreJournalService.FindBlockingAsync(codexHome, cancellationToken); + string[] requestedKinds = restorePlan.Targets + .Select(static target => target.Kind) + .Distinct(StringComparer.Ordinal) + .ToArray(); + List boundRestores = []; + List foreignRestores = []; + foreach (RestoreJournalInfo transaction in pendingRestores) + { + RestoreBackupIdentity? preparedSource = transaction.Prepared?.SourceBackup; + bool physicalHomeMatches = RestoreV2Service.JournalMatchesCurrentPhysicalHome( + transaction, + storage); + bool sourceMatches = RestoreV2Service.JournalMatchesSource( + preparedSource, + sourceBackup); + bool committedLocationMatches = transaction.State == "committed-pending-ack" + && RestoreV2Service.JournalMatchesSource( + preparedSource, + sourceBackup, + ignoreRevision: true); + bool coverageComplete = transaction.Prepared?.RequiredTargetKinds.All( + kind => requestedKinds.Contains(kind, StringComparer.Ordinal)) == true; + if ((sourceMatches || committedLocationMatches) + && physicalHomeMatches + && coverageComplete) + { + boundRestores.Add(transaction); + } + else + { + foreignRestores.Add(transaction); + } + } + if (foreignRestores.Count > 0) + { + throw new RecoveryRequiredException( + "An unrelated unfinished Restore transaction must be resolved before this backup can be used.", + foreignRestores.Select(static item => item.SnapshotDir).ToArray()); + } + RestoreJournalInfo[] committedPendingAcknowledgements = boundRestores + .Where(static item => item.State == "committed-pending-ack" && !item.InvalidTail) + .ToArray(); + if (committedPendingAcknowledgements.Length > 0 + && (committedPendingAcknowledgements.Length != 1 || boundRestores.Count != 1)) + { + throw new RecoveryRequiredException( + "Multiple Restore acknowledgements cannot be reconciled automatically.", + boundRestores.Select(static item => item.SnapshotDir).ToArray()); + } cancellationToken.ThrowIfCancellationRequested(); return new RestorePreparation( codexHome, configPath, normalizedBackupDir, options, - storage); + storage, + stateDbTargetPath, + restorePlan, + sourceBackup, + boundRestores + .Select(static item => item.OperationId) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToArray(), + committedPendingAcknowledgements.SingleOrDefault()); } private async Task BuildRestorePlanSnapshotAsync( @@ -1311,8 +1853,8 @@ private async Task BuildRestorePlanSnapshotAsync( } if (preparation.Options.RestoreDatabase) { - string databasePath = preparation.Storage.StateDbLocation?.Path - ?? Path.Combine(preparation.Storage.SqliteHome, AppConstants.DbFileBasename); + string databasePath = preparation.StateDbTargetPath + ?? throw new InvalidOperationException("Restore preparation did not resolve a State DB target."); targets.Add(new CoreWriteTargetSpec( databasePath, "restore", @@ -1578,6 +2120,83 @@ private async Task PrepareStorageAsync( return storage with { StateDbLocation = stateDb }; } + private static bool PathsEqual(string left, string right) => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private async Task<(StateDbLockResource? Resource, LockHandle? Handle)> AcquireCurrentStateDbLockAsync( + string codexHome, + string? explicitSqliteHome, + string label, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string configText = await _configFileService.ReadConfigTextAsync( + _codexHomeService.ConfigPath(codexHome)); + CodexStorageLayout storage = await PrepareStorageAsync(codexHome, explicitSqliteHome, configText); + storage.EnsureSqliteAccessSupported(label); + EnsureWritableStorage(storage); + if (storage.StateDbLocation is null) + { + return (null, null); + } + StateDbLockResource resource = await StateDbLockResource.ResolveAsync( + storage.StateDbLocation.Path, + cancellationToken); + LockHandle handle = await _lockService.AcquireStateDbLockAsync(resource, label); + return (resource, handle); + } + + private static async Task AssertStateDbLockMatchesAsync( + CodexStorageLayout storage, + StateDbLockResource? lockedResource, + CancellationToken cancellationToken) + { + if (storage.StateDbLocation is null) + { + if (lockedResource is null) return; + throw StateDbLockChanged(); + } + StateDbLockResource current = await StateDbLockResource.ResolveAsync( + storage.StateDbLocation.Path, + cancellationToken); + if (lockedResource is null + || !string.Equals(current.ResourceKey, lockedResource.ResourceKey, StringComparison.Ordinal)) + { + throw StateDbLockChanged(); + } + } + + private static async Task AssertStateDbTargetLockMatchesAsync( + string? stateDbTargetPath, + StateDbLockResource? lockedResource, + CancellationToken cancellationToken) + { + if (stateDbTargetPath is null) + { + if (lockedResource is null) return; + throw StateDbLockChanged(); + } + StateDbLockResource current = await StateDbLockResource.ResolveAsync( + stateDbTargetPath, + cancellationToken); + if (lockedResource is null + || !string.Equals(current.ResourceKey, lockedResource.ResourceKey, StringComparison.Ordinal)) + { + throw StateDbLockChanged(); + } + } + + private static InvalidOperationException StateDbLockChanged() + { + InvalidOperationException error = new( + "The resolved State DB resource changed while the write operation was acquiring its locks."); + error.Data["codex-provider-sync/error-code"] = LockService.LockUnverifiableErrorCode; + error.Data["codex-provider-sync/lock-scope"] = "state-db"; + return error; + } + private static void EnsureWritableStorage(CodexStorageLayout storage) { if (storage.StateDbLocation is null && storage.HasConfiguredSqliteHome) @@ -1596,6 +2215,61 @@ private static void AssertSnapshotFresh(DateTimeOffset? expiresAtUtc) } } + private static RestoreResult CopyRestoreResult( + RestoreResult result, + string backupDir, + string? backupInventoryWarning = null) + { + return new RestoreResult + { + CodexHome = result.CodexHome, + BackupDir = backupDir, + TargetProvider = result.TargetProvider, + CreatedAt = result.CreatedAt, + ChangedSessionFiles = result.ChangedSessionFiles, + BackupInventoryWarning = backupInventoryWarning ?? result.BackupInventoryWarning, + RestoreVersion = result.RestoreVersion, + RestoreOperationId = result.RestoreOperationId, + PreRestoreSnapshotId = result.PreRestoreSnapshotId, + RestoreJournalState = result.RestoreJournalState, + CommitAcknowledgementRecovered = result.CommitAcknowledgementRecovered, + ResolvedOperationIds = result.ResolvedOperationIds + }; + } + + private static bool PendingTransactionMatchesBackup( + PendingTransactionInfo transaction, + string physicalBackupDir) + { + try + { + string? journalParent = Path.GetDirectoryName(transaction.JournalPath); + if (string.IsNullOrWhiteSpace(journalParent)) + { + return false; + } + string journalParentPhysical = RestoreV2Service.ResolveStablePhysicalDirectory( + journalParent); + if (string.IsNullOrWhiteSpace(transaction.DeclaredBackupDir)) + { + // A readable but unparseable journal has no trustworthy + // prepared record to bind. Preserve the explicit managed- + // backup repair path only when the journal itself is physically + // inside the exact backup selected by the caller. + return transaction.InvalidTail + && PathComparer.Equals(journalParentPhysical, physicalBackupDir); + } + string declaredPhysical = RestoreV2Service.ResolveStablePhysicalDirectory( + transaction.DeclaredBackupDir); + return PathComparer.Equals(declaredPhysical, physicalBackupDir) + && PathComparer.Equals(journalParentPhysical, physicalBackupDir); + } + catch + { + return false; + } + } + private static StringComparer PathComparer => OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; @@ -1627,5 +2301,10 @@ private sealed record RestorePreparation( string ConfigPath, string BackupDirectory, RestoreBackupOptions Options, - CodexStorageLayout Storage); + CodexStorageLayout Storage, + string? StateDbTargetPath, + RestoreBackupPlan RestorePlan, + RestoreBackupIdentity SourceBackup, + IReadOnlyList ResolvesOperationIds, + RestoreJournalInfo? PendingCommitAcknowledgement); } diff --git a/desktop/CodexProviderSync.Core/LockService.cs b/desktop/CodexProviderSync.Core/LockService.cs index 47dcf62..b09e570 100644 --- a/desktop/CodexProviderSync.Core/LockService.cs +++ b/desktop/CodexProviderSync.Core/LockService.cs @@ -1,8 +1,10 @@ using System.Diagnostics; using System.Globalization; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; namespace CodexProviderSync.Core; @@ -17,11 +19,15 @@ public sealed class LockService private const int DefaultLockCreateRetryDelayMs = 75; private const int DefaultOwnedClaimDeleteRetryCount = 3; private const int DefaultOwnedClaimDeleteRetryDelayMs = 75; - private const string BusyErrorDataKey = "codex-provider-sync/error-code"; - public const string OperationBusyErrorCode = "TARGET_BUSY"; + private const string ErrorCodeDataKey = "codex-provider-sync/error-code"; + private const string LockScopeDataKey = "codex-provider-sync/lock-scope"; + private const string ResourceKeyDataKey = "codex-provider-sync/resource-key"; + public const string OperationBusyErrorCode = "OPERATION_BUSY"; + public const string LockUnverifiableErrorCode = "LOCK_UNVERIFIABLE"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { - WriteIndented = true + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly Func? _testHook; @@ -39,7 +45,35 @@ public Task AcquireLockAsync( string codexHome, string label = "codex-provider-sync") { - return AcquirePathLockAsync(AppConstants.LockPath(codexHome), label); + return AcquirePathLockAsync(AppConstants.LockPath(codexHome), label, "codex-home"); + } + + public Task AcquireStateDbLockAsync( + StateDbLockResource resource, + string label = "codex-provider-sync") + { + ArgumentNullException.ThrowIfNull(resource); + return AcquirePathLockAsync(resource.LockPath, label, "state-db", resource.ResourceKey); + } + + /// + /// Observes the Home lock without creating, reclaiming, or deleting any + /// protocol resource. Status uses this fail-closed probe so it never scans + /// files while another runtime may be mutating them. + /// + public Task InspectLockAsync(string codexHome) + { + return InspectPathLockAsync(AppConstants.LockPath(codexHome), "codex-home", null); + } + + /// + /// Observes the resolved State DB lock without acquiring it. The supplied + /// resource identity must be the same physical identity used by writers. + /// + public Task InspectStateDbLockAsync(StateDbLockResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + return InspectPathLockAsync(resource.LockPath, "state-db", resource.ResourceKey); } /// @@ -49,8 +83,20 @@ public Task AcquireLockAsync( /// public async Task AcquirePathLockAsync( string lockPath, - string label = "codex-provider-sync") + string label = "codex-provider-sync", + string scope = "codex-home", + string? resourceKey = null) { + if (scope is not ("codex-home" or "state-db")) + { + throw new ArgumentOutOfRangeException(nameof(scope), scope, "Lock scope must be codex-home or state-db."); + } + if (scope == "state-db" + && (resourceKey is null || resourceKey.Length != 64 + || resourceKey.Any(static value => !((value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'))))) + { + throw new ArgumentException("State DB resourceKey must be a 64-character SHA-256 hex value.", nameof(resourceKey)); + } string canonicalPath = Path.GetFullPath(lockPath); string parentPath = Path.GetDirectoryName(canonicalPath) ?? throw new InvalidOperationException($"Cannot resolve the parent directory for lock {canonicalPath}."); @@ -59,7 +105,7 @@ public async Task AcquirePathLockAsync( Directory.CreateDirectory(claimsPath); SetOwnerOnlyDirectoryMode(claimsPath); - LockOwner owner = CreateCurrentOwner(label); + LockOwner owner = CreateCurrentOwner(label, scope, resourceKey); string claimPath = Path.Combine(claimsPath, owner.InstanceId + ".json"); string candidatePath = $"{canonicalPath}.candidate.{owner.ProcessId}.{owner.InstanceId}"; string reservationMarkerPath = ReservationMarkerPath(canonicalPath, owner.InstanceId); @@ -76,13 +122,13 @@ public async Task AcquirePathLockAsync( await _testHook("claim-published", owner.InstanceId); } - await AssertSoleLiveClaimAsync(canonicalPath, claimsPath, claimPath, owner); - await ReclaimCanonicalIfStaleAsync(canonicalPath); + await AssertSoleLiveClaimAsync(canonicalPath, claimsPath, claimPath, owner, scope, resourceKey); + await ReclaimCanonicalIfStaleAsync(canonicalPath, scope, resourceKey); // A contender can publish while a stale canonical lock is being // quarantined. Re-check immediately before publishing ours. A new // protocol contender will see this live claim and withdraw. - await AssertSoleLiveClaimAsync(canonicalPath, claimsPath, claimPath, owner); + await AssertSoleLiveClaimAsync(canonicalPath, claimsPath, claimPath, owner, scope, resourceKey); Directory.CreateDirectory(candidatePath); SetOwnerOnlyDirectoryMode(candidatePath); @@ -106,23 +152,30 @@ await AtomicFile.WriteAllTextAsync( } catch (IOException error) when (IsAlreadyExistsError(error)) { - throw LockAlreadyExists( + throw LockUnverifiable( canonicalPath, "another owner populated the canonical reservation before owner.json could be published"); } ownerLinked = true; if (!await ReservationMarkerMatchesAsync(reservationMarkerPath, owner.InstanceId)) { - throw LockAlreadyExists( + throw LockUnverifiable( canonicalPath, "the canonical reservation changed identity while owner.json was being published"); } TryDeleteDirectory(candidatePath); - return new LockHandle(canonicalPath, claimsPath, claimPath, owner.InstanceId); + return new LockHandle( + canonicalPath, + claimsPath, + claimPath, + owner.InstanceId, + scope, + resourceKey); } catch (Exception acquisitionError) { + AnnotateLockError(acquisitionError, scope, resourceKey); TryDeleteDirectory(candidatePath); if (canonicalReserved && !ownerLinked) { @@ -145,24 +198,289 @@ await AtomicFile.WriteAllTextAsync( } catch (Exception cleanupError) { - throw new AggregateException( + AggregateException aggregate = new( $"Lock acquisition failed and owned-claim cleanup threw unexpectedly: {acquisitionError.Message} Cleanup failure: {cleanupError.Message}", acquisitionError, cleanupError); + MarkLockUnverifiable(aggregate, scope, resourceKey); + throw aggregate; } if (!cleanup.Succeeded) { - throw new AggregateException( + AggregateException aggregate = new( $"Lock acquisition failed and owned-claim cleanup was incomplete: {acquisitionError.Message} Cleanup failure: {cleanup.Failure!.Message}", acquisitionError, cleanup.Failure!); + MarkLockUnverifiable(aggregate, scope, resourceKey); + throw aggregate; } } throw; } } - private static LockOwner CreateCurrentOwner(string label) + private static async Task InspectPathLockAsync( + string lockPath, + string scope, + string? resourceKey) + { + string canonicalPath; + string claimsPath; + try + { + canonicalPath = Path.GetFullPath(lockPath); + claimsPath = canonicalPath + ".claims"; + } + catch (Exception error) + { + return LockInspection.Unverifiable(scope, resourceKey, string.Empty, error.Message); + } + + try + { + string observationBefore = CaptureLockObservation(canonicalPath, claimsPath); + FileAttributes? canonicalAttributes = TryGetPathAttributes(canonicalPath); + if (canonicalAttributes is not null) + { + if ((canonicalAttributes.Value & FileAttributes.ReparsePoint) != 0) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + "the canonical lock path is a symbolic link or reparse point"); + } + if ((canonicalAttributes.Value & FileAttributes.Directory) == 0) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + "the canonical lock path is not a directory"); + } + + string ownerPath = Path.Combine(canonicalPath, "owner.json"); + OwnerReadResult first = await ReadOwnerAsync( + ownerPath, + requireVersionTwo: false, + scope, + resourceKey); + if (!first.Valid) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + $"owner.json cannot be verified: {first.Error ?? "unknown owner-read failure"}"); + } + if (!IsOwnerLive(first.Owner!)) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + "the canonical owner is stale and is preserved fail-closed"); + } + + OwnerReadResult second = await ReadOwnerAsync( + ownerPath, + requireVersionTwo: false, + scope, + resourceKey); + string observationAfter = CaptureLockObservation(canonicalPath, claimsPath); + if (!second.Valid + || !SameOwnerGeneration(first.Owner!, second.Owner!) + || !string.Equals(observationBefore, observationAfter, StringComparison.Ordinal)) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationAfter, + "the canonical lock identity changed during read-only inspection"); + } + return LockInspection.Active( + scope, + resourceKey, + observationAfter, + ToInspectionOwner(first.Owner!)); + } + + FileAttributes? claimsAttributes = TryGetPathAttributes(claimsPath); + if (claimsAttributes is not null) + { + if ((claimsAttributes.Value & FileAttributes.ReparsePoint) != 0 + || (claimsAttributes.Value & FileAttributes.Directory) == 0) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + "the claims path is not a verified regular directory"); + } + + string[] claims = Directory + .EnumerateFiles(claimsPath, "*.json", SearchOption.TopDirectoryOnly) + .Order(StringComparer.Ordinal) + .ToArray(); + if (claims.Length > 1) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + "multiple protocol claims are present"); + } + if (claims.Length == 1) + { + OwnerReadResult claim = await ReadOwnerAsync( + claims[0], + requireVersionTwo: true, + scope, + resourceKey); + if (!claim.Valid + || !string.Equals( + Path.GetFileNameWithoutExtension(claims[0]), + claim.Owner?.InstanceId, + StringComparison.OrdinalIgnoreCase)) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + $"the protocol claim cannot be verified: {claim.Error ?? "owner identity mismatch"}"); + } + if (!IsOwnerLive(claim.Owner!)) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + "the protocol claim is stale and is preserved fail-closed"); + } + + OwnerReadResult verified = await ReadOwnerAsync( + claims[0], + requireVersionTwo: true, + scope, + resourceKey); + string observationAfter = CaptureLockObservation(canonicalPath, claimsPath); + if (!verified.Valid + || !SameOwnerGeneration(claim.Owner!, verified.Owner!) + || !string.Equals(observationBefore, observationAfter, StringComparison.Ordinal)) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationAfter, + "the protocol claim changed during read-only inspection"); + } + return LockInspection.Active( + scope, + resourceKey, + observationAfter, + ToInspectionOwner(claim.Owner!)); + } + } + + if (TryGetPathAttributes(canonicalPath) is not null) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + observationBefore, + "a canonical lock appeared during read-only inspection"); + } + string finalObservation = CaptureLockObservation(canonicalPath, claimsPath); + if (!string.Equals(observationBefore, finalObservation, StringComparison.Ordinal)) + { + return LockInspection.Unverifiable( + scope, + resourceKey, + finalObservation, + "the lock protocol resources changed during read-only inspection"); + } + return LockInspection.Absent(scope, resourceKey, finalObservation); + } + catch (Exception error) when (error is IOException + or UnauthorizedAccessException + or System.Security.SecurityException + or InvalidOperationException) + { + string observation; + try + { + observation = CaptureLockObservation(canonicalPath, claimsPath); + } + catch + { + observation = string.Empty; + } + return LockInspection.Unverifiable(scope, resourceKey, observation, error.Message); + } + } + + private static LockInspectionOwner ToInspectionOwner(LockOwnerSnapshot owner) + { + return new LockInspectionOwner( + owner.ProcessId, + owner.InstanceId, + owner.Runtime, + owner.Label, + owner.StartedAt); + } + + private static string CaptureLockObservation(string canonicalPath, string claimsPath) + { + StringBuilder canonical = new(); + AppendPathObservation(canonical, "canonical", canonicalPath, includeChildren: true); + AppendPathObservation(canonical, "claims", claimsPath, includeChildren: true); + return Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()))) + .ToLowerInvariant(); + } + + private static void AppendPathObservation( + StringBuilder destination, + string label, + string observedPath, + bool includeChildren) + { + FileAttributes? attributes = TryGetPathAttributes(observedPath); + destination.Append(label).Append('|').Append(observedPath).Append('|'); + if (attributes is null) + { + destination.Append("missing\n"); + return; + } + + destination.Append((int)attributes.Value).Append('|') + .Append(File.GetLastWriteTimeUtc(observedPath).Ticks).Append('\n'); + if (!includeChildren || (attributes.Value & FileAttributes.Directory) == 0) + { + if ((attributes.Value & FileAttributes.Directory) == 0) + { + destination.Append(new FileInfo(observedPath).Length).Append('\n'); + } + return; + } + + foreach (string entry in Directory + .EnumerateFileSystemEntries(observedPath, "*", SearchOption.TopDirectoryOnly) + .Order(StringComparer.Ordinal)) + { + FileAttributes entryAttributes = File.GetAttributes(entry); + destination.Append(Path.GetFileName(entry)).Append('|') + .Append((int)entryAttributes).Append('|') + .Append(File.GetLastWriteTimeUtc(entry).Ticks).Append('|'); + if ((entryAttributes & FileAttributes.Directory) == 0) + { + destination.Append(new FileInfo(entry).Length); + } + destination.Append('\n'); + } + } + + private static LockOwner CreateCurrentOwner(string label, string scope, string? resourceKey) { using Process process = Process.GetCurrentProcess(); string processStartedAt = FormatUtcSecond(process.StartTime.ToUniversalTime()); @@ -177,7 +495,9 @@ private static LockOwner CreateCurrentOwner(string label) StartedAt = FormatUtcSecond(DateTime.UtcNow), Label = label, Cwd = Environment.CurrentDirectory, - CurrentDirectory = Environment.CurrentDirectory + CurrentDirectory = Environment.CurrentDirectory, + Scope = scope, + ResourceKey = resourceKey }; } @@ -221,7 +541,9 @@ private static async Task AssertSoleLiveClaimAsync( string canonicalPath, string claimsPath, string ownClaimPath, - LockOwner ownOwner) + LockOwner ownOwner, + string expectedScope, + string? expectedResourceKey) { foreach (string candidate in Directory .EnumerateFiles(claimsPath, "*.json", SearchOption.TopDirectoryOnly) @@ -229,26 +551,34 @@ private static async Task AssertSoleLiveClaimAsync( { if (PathComparer.Equals(Path.GetFullPath(candidate), Path.GetFullPath(ownClaimPath))) { - OwnerReadResult ownRead = await ReadOwnerAsync(candidate, requireVersionTwo: true); + OwnerReadResult ownRead = await ReadOwnerAsync( + candidate, + requireVersionTwo: true, + expectedScope, + expectedResourceKey); if (!ownRead.Valid || !string.Equals(ownRead.Owner!.InstanceId, ownOwner.InstanceId, StringComparison.OrdinalIgnoreCase)) { - throw LockAlreadyExists(canonicalPath, "this process's claim identity changed before acquisition"); + throw LockUnverifiable(canonicalPath, "this process's claim identity changed before acquisition"); } continue; } - OwnerReadResult read = await ReadOwnerAsync(candidate, requireVersionTwo: true); + OwnerReadResult read = await ReadOwnerAsync( + candidate, + requireVersionTwo: true, + expectedScope, + expectedResourceKey); if (!read.Valid) { - throw LockAlreadyExists(canonicalPath, $"claim {candidate} cannot be verified and is retained fail-closed"); + throw LockUnverifiable(canonicalPath, $"claim {candidate} cannot be verified and is retained fail-closed"); } if (!string.Equals( Path.GetFileNameWithoutExtension(candidate), read.Owner!.InstanceId, StringComparison.OrdinalIgnoreCase)) { - throw LockAlreadyExists(canonicalPath, $"claim {candidate} does not match its owner instanceId"); + throw LockUnverifiable(canonicalPath, $"claim {candidate} does not match its owner instanceId"); } if (IsOwnerLive(read.Owner)) @@ -258,7 +588,7 @@ private static async Task AssertSoleLiveClaimAsync( if (!await TryQuarantineAndDeleteStaleClaimAsync(candidate, read.Owner!)) { - throw LockAlreadyExists(canonicalPath, $"stale claim {candidate} changed while it was being reclaimed"); + throw LockUnverifiable(canonicalPath, $"stale claim {candidate} changed while it was being reclaimed"); } } @@ -272,7 +602,7 @@ private static async Task AssertSoleLiveClaimAsync( Path.GetFullPath(ownClaimPath))); if (otherClaim is not null) { - throw LockAlreadyExists(canonicalPath, $"a concurrent claim appeared at {otherClaim}"); + throw LockUnverifiable(canonicalPath, $"a concurrent claim appeared at {otherClaim}"); } } @@ -309,7 +639,10 @@ private static async Task TryQuarantineAndDeleteStaleClaimAsync( return !File.Exists(quarantinePath); } - private async Task ReclaimCanonicalIfStaleAsync(string canonicalPath) + private async Task ReclaimCanonicalIfStaleAsync( + string canonicalPath, + string expectedScope, + string? expectedResourceKey) { FileAttributes? attributes = TryGetPathAttributes(canonicalPath); if (attributes is null) @@ -318,18 +651,22 @@ private async Task ReclaimCanonicalIfStaleAsync(string canonicalPath) } if ((attributes.Value & FileAttributes.ReparsePoint) != 0) { - throw LockAlreadyExists(canonicalPath, "the canonical lock path is a symbolic link or reparse point"); + throw LockUnverifiable(canonicalPath, "the canonical lock path is a symbolic link or reparse point"); } if ((attributes.Value & FileAttributes.Directory) == 0) { - throw LockAlreadyExists(canonicalPath, "the canonical lock path is not a directory"); + throw LockUnverifiable(canonicalPath, "the canonical lock path is not a directory"); } string ownerPath = Path.Combine(canonicalPath, "owner.json"); - OwnerReadResult read = await ReadOwnerAsync(ownerPath, requireVersionTwo: false); + OwnerReadResult read = await ReadOwnerAsync( + ownerPath, + requireVersionTwo: false, + expectedScope, + expectedResourceKey); if (!read.Valid) { - throw LockAlreadyExists(canonicalPath, "owner.json cannot be verified and is retained fail-closed"); + throw LockUnverifiable(canonicalPath, "owner.json cannot be verified and is retained fail-closed"); } if (IsOwnerLive(read.Owner!)) { @@ -351,19 +688,21 @@ private async Task ReclaimCanonicalIfStaleAsync(string canonicalPath) } catch (Exception error) when (error is IOException or UnauthorizedAccessException) { - throw LockAlreadyExists(canonicalPath, "the canonical lock changed during stale-owner reclamation"); + throw LockUnverifiable(canonicalPath, "the canonical lock changed during stale-owner reclamation"); } OwnerReadResult moved = await ReadOwnerAsync( Path.Combine(quarantinePath, "owner.json"), - requireVersionTwo: false); + requireVersionTwo: false, + expectedScope, + expectedResourceKey); if (!moved.Valid || !SameOwnerGeneration(moved.Owner!, read.Owner!)) { bool restored = await TryRestoreQuarantinedOwnerAsync( quarantinePath, canonicalPath, moved.Owner?.InstanceId); - throw LockAlreadyExists( + throw LockUnverifiable( canonicalPath, restored ? "the owner changed during reclamation, so the moved lock was restored" @@ -379,7 +718,9 @@ private async Task ReclaimCanonicalIfStaleAsync(string canonicalPath) private static async Task ReadOwnerAsync( string ownerPath, - bool requireVersionTwo) + bool requireVersionTwo, + string? expectedScope = null, + string? expectedResourceKey = null) { string text; try @@ -409,6 +750,11 @@ private static async Task ReadOwnerAsync( } string? instanceId = TryReadString(root, "instanceId"); + string? runtime = TryReadString(root, "runtime"); + string? label = TryReadString(root, "label"); + string? startedAt = TryReadString(root, "startedAt"); + string? scope = TryReadString(root, "scope"); + string? resourceKey = TryReadString(root, "resourceKey"); string? processStartedAtText = TryReadString(root, "processStartedAt"); DateTimeOffset? processStartedAt = null; if (processStartedAtText is not null) @@ -442,6 +788,19 @@ private static async Task ReadOwnerAsync( { return new OwnerReadResult(null, "version 2 owner identity is incomplete"); } + if (expectedScope is not null) + { + if (scope is not null && !string.Equals(scope, expectedScope, StringComparison.Ordinal)) + { + return new OwnerReadResult(null, "owner lock scope does not match the requested resource"); + } + if (expectedScope == "state-db" + && (!string.Equals(scope, expectedScope, StringComparison.Ordinal) + || !string.Equals(resourceKey, expectedResourceKey, StringComparison.Ordinal))) + { + return new OwnerReadResult(null, "State DB owner scope or resourceKey is missing or mismatched"); + } + } if (processStartedAt is null && string.IsNullOrWhiteSpace(processStartMarker)) { // Legacy records without a process start identity can only be @@ -456,6 +815,11 @@ private static async Task ReadOwnerAsync( processStartedAt, processStartMarker, instanceId, + runtime, + label, + startedAt, + scope, + resourceKey, text), null); } catch (Exception error) when (error is JsonException or InvalidOperationException) @@ -592,7 +956,12 @@ private static bool SameOwnerGeneration(LockOwnerSnapshot left, LockOwnerSnapsho return string.Equals(left.InstanceId, right.InstanceId, StringComparison.OrdinalIgnoreCase) && left.ProcessId == right.ProcessId && left.ProcessStartedAt == right.ProcessStartedAt - && string.Equals(left.ProcessStartMarker, right.ProcessStartMarker, StringComparison.Ordinal); + && string.Equals(left.ProcessStartMarker, right.ProcessStartMarker, StringComparison.Ordinal) + && string.Equals(left.Runtime, right.Runtime, StringComparison.Ordinal) + && string.Equals(left.Label, right.Label, StringComparison.Ordinal) + && string.Equals(left.StartedAt, right.StartedAt, StringComparison.Ordinal) + && string.Equals(left.Scope, right.Scope, StringComparison.Ordinal) + && string.Equals(left.ResourceKey, right.ResourceKey, StringComparison.Ordinal); } return string.Equals(left.RawText, right.RawText, StringComparison.Ordinal); } @@ -754,21 +1123,69 @@ internal static async ValueTask ReleaseAsync( public static bool IsOperationBusy(Exception error) { - return error is InvalidOperationException - && string.Equals( - error.Data[BusyErrorDataKey] as string, + return string.Equals( + error.Data[ErrorCodeDataKey] as string, OperationBusyErrorCode, StringComparison.Ordinal); } + public static bool IsLockUnverifiable(Exception error) + { + return string.Equals( + error.Data[ErrorCodeDataKey] as string, + LockUnverifiableErrorCode, + StringComparison.Ordinal); + } + private static InvalidOperationException LockAlreadyExists(string lockPath, string reason) { InvalidOperationException error = new( $"Lock already exists at {lockPath}: {reason}. Close Codex/App and retry; do not remove it unless the recorded owner is known to be gone."); - error.Data[BusyErrorDataKey] = OperationBusyErrorCode; + error.Data[ErrorCodeDataKey] = OperationBusyErrorCode; return error; } + private static InvalidOperationException LockUnverifiable(string lockPath, string reason) + { + InvalidOperationException error = new( + $"Lock ownership cannot be verified at {lockPath}: {reason}. The resource was preserved fail-closed."); + error.Data[ErrorCodeDataKey] = LockUnverifiableErrorCode; + return error; + } + + private static void AnnotateLockError(Exception error, string scope, string? resourceKey) + { + if (error.Data.Contains(ErrorCodeDataKey)) + { + error.Data[LockScopeDataKey] = scope; + if (resourceKey is not null) + { + error.Data[ResourceKeyDataKey] = resourceKey; + } + } + if (error is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + AnnotateLockError(inner, scope, resourceKey); + } + } + } + + internal static void MarkLockUnverifiable( + Exception error, + string scope, + string? resourceKey) + { + error.Data[ErrorCodeDataKey] = LockUnverifiableErrorCode; + error.Data[LockScopeDataKey] = scope; + if (resourceKey is not null) + { + error.Data[ResourceKeyDataKey] = resourceKey; + } + AnnotateLockError(error, scope, resourceKey); + } + private static int? TryReadInt(JsonElement root, string name) { return root.TryGetProperty(name, out JsonElement value) @@ -1076,7 +1493,7 @@ internal static async Task CreateLockDirectoryAsync( if (errorCode == Win32ErrorAlreadyExists) { - throw LockAlreadyExists(lockPath, "the canonical directory already exists"); + throw LockUnverifiable(lockPath, "the canonical directory already exists"); } if (!IsTransientLockCreateError(errorCode) || attempts >= retryCount) @@ -1149,6 +1566,8 @@ private sealed class LockOwner public required string Label { get; init; } public required string Cwd { get; init; } public required string CurrentDirectory { get; init; } + public string? Scope { get; init; } + public string? ResourceKey { get; init; } } private sealed record LockOwnerSnapshot( @@ -1157,6 +1576,11 @@ private sealed record LockOwnerSnapshot( DateTimeOffset? ProcessStartedAt, string? ProcessStartMarker, string? InstanceId, + string? Runtime, + string? Label, + string? StartedAt, + string? Scope, + string? ResourceKey, string RawText); private sealed record OwnerReadResult(LockOwnerSnapshot? Owner, string? Error) @@ -1172,24 +1596,76 @@ private sealed record OwnedClaimDeleteResult(bool Succeeded, Exception? Failure) } } +public sealed record LockInspectionOwner( + int ProcessId, + string? InstanceId, + string? Runtime, + string? Label, + string? StartedAt); + +public sealed record LockInspection( + string State, + string Scope, + string? ResourceKey, + string ObservationRevision, + LockInspectionOwner? Owner, + string? ErrorCode, + string? Error) +{ + public bool IsAbsent => string.Equals(State, "absent", StringComparison.Ordinal); + + internal static LockInspection Absent( + string scope, + string? resourceKey, + string observationRevision) => + new("absent", scope, resourceKey, observationRevision, null, null, null); + + internal static LockInspection Active( + string scope, + string? resourceKey, + string observationRevision, + LockInspectionOwner owner) => + new("active", scope, resourceKey, observationRevision, owner, null, null); + + internal static LockInspection Unverifiable( + string scope, + string? resourceKey, + string observationRevision, + string error) => + new( + "unverifiable", + scope, + resourceKey, + observationRevision, + null, + LockService.LockUnverifiableErrorCode, + error); +} + public sealed class LockHandle : IAsyncDisposable { private readonly string _canonicalPath; private readonly string _claimsPath; private readonly string _claimPath; private readonly string _instanceId; + private readonly string _scope; + private readonly string? _resourceKey; private bool _released; internal LockHandle( string canonicalPath, string claimsPath, string claimPath, - string instanceId) + string instanceId, + string scope, + string? resourceKey) { _canonicalPath = canonicalPath; _claimsPath = claimsPath; _claimPath = claimPath; _instanceId = instanceId; + _scope = scope; + _resourceKey = resourceKey; } public string LockPath => _canonicalPath; @@ -1203,11 +1679,19 @@ public async ValueTask DisposeAsync() return; } - await LockService.ReleaseAsync( - _canonicalPath, - _claimsPath, - _claimPath, - _instanceId); + try + { + await LockService.ReleaseAsync( + _canonicalPath, + _claimsPath, + _claimPath, + _instanceId); + } + catch (Exception error) + { + LockService.MarkLockUnverifiable(error, _scope, _resourceKey); + throw; + } _released = true; } } diff --git a/desktop/CodexProviderSync.Core/Models.cs b/desktop/CodexProviderSync.Core/Models.cs index e7158b7..4842a45 100644 --- a/desktop/CodexProviderSync.Core/Models.cs +++ b/desktop/CodexProviderSync.Core/Models.cs @@ -18,6 +18,9 @@ public sealed class ProviderCounts public sealed class StatusSnapshot { + public int SchemaVersion { get; init; } = 1; + public DateTimeOffset SnapshotAt { get; init; } = DateTimeOffset.UtcNow; + public string StorageRevision { get; init; } = string.Empty; public required string CodexHome { get; init; } public string SqliteHome { get; init; } = string.Empty; public string SqliteHomeSource { get; init; } = "default"; @@ -37,10 +40,28 @@ public sealed class StatusSnapshot public required string BackupRoot { get; init; } public required BackupSummary BackupSummary { get; init; } public IReadOnlyList PendingTransactions { get; init; } = []; + public StatusOperationInfo? OperationInProgress { get; init; } + public StatusReadBlockedInfo? StatusReadBlocked { get; init; } + public bool RolloutScanComplete { get; init; } = true; [JsonIgnore] public StatusPerformanceMetrics PerformanceMetrics { get; init; } = new(); } +public sealed record StatusOperationInfo( + string? OperationId, + string Operation, + string Actor, + string? Runtime, + string? StartedAt, + string BusyScope, + string LockState, + string? ErrorCode = null); + +public sealed record StatusReadBlockedInfo( + string Reason, + string LockState, + string? Revision = null); + public sealed class StatusPerformanceMetrics { public long TotalDurationMs { get; init; } @@ -53,7 +74,10 @@ public sealed record TransactionRecoveryInfo( string? OperationId, string State, string BackupDirectory, - string JournalPath); + string JournalPath) +{ + public string OperationKind { get; init; } = "sync"; +} public sealed record StateDbLocation(string Path, string RelativePath, string Source); @@ -253,6 +277,12 @@ public sealed class RestoreResult /// may be stale. /// public string? BackupInventoryWarning { get; init; } + public int? RestoreVersion { get; init; } + public string? RestoreOperationId { get; init; } + public string? PreRestoreSnapshotId { get; init; } + public string? RestoreJournalState { get; init; } + public bool CommitAcknowledgementRecovered { get; init; } + public IReadOnlyList ResolvedOperationIds { get; init; } = []; } public sealed class BackupStorageInfo diff --git a/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs b/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs index bc56cc4..6a04425 100644 --- a/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs +++ b/desktop/CodexProviderSync.Core/Properties/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("CodexProviderSync.Core.Tests")] [assembly: InternalsVisibleTo("CodexProviderSync.CrashHost")] +[assembly: InternalsVisibleTo("CodexProviderSync.FixtureHost")] diff --git a/desktop/CodexProviderSync.Core/RestoreJournalService.cs b/desktop/CodexProviderSync.Core/RestoreJournalService.cs new file mode 100644 index 0000000..2a8022d --- /dev/null +++ b/desktop/CodexProviderSync.Core/RestoreJournalService.cs @@ -0,0 +1,1275 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexProviderSync.Core; + +internal sealed record RestoreBackupIdentity( + [property: JsonPropertyName("backupId")] string BackupId, + [property: JsonPropertyName("backupDir")] string BackupDir, + [property: JsonPropertyName("revision")] string Revision); + +internal sealed record RestorePreSnapshotIdentity( + [property: JsonPropertyName("backupId")] string BackupId, + [property: JsonPropertyName("backupDir")] string BackupDir, + [property: JsonPropertyName("revision")] string Revision, + [property: JsonPropertyName("manifestSha256")] string ManifestSha256); + +internal sealed record RestoreStorageIdentity( + [property: JsonPropertyName("codexHome")] string CodexHome, + [property: JsonPropertyName("codexHomePhysical")] string CodexHomePhysical, + [property: JsonPropertyName("sqliteHome")] string? SqliteHome, + [property: JsonPropertyName("stateDbResourceKey")] string? StateDbResourceKey, + [property: JsonPropertyName("targetStateDbPath")] string? TargetStateDbPath); + +internal sealed record RestoreDigest( + [property: JsonPropertyName("present")] bool Present, + [property: JsonPropertyName("digestKind")] string DigestKind, + [property: JsonPropertyName("digest")] string Digest, + [property: JsonPropertyName("sizeBytes")] long? SizeBytes = null); + +internal sealed record RestoreJournalTarget( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("kind")] string Kind, + [property: JsonPropertyName("targetPath")] string TargetPath, + [property: JsonPropertyName("pre")] RestoreDigest Pre, + [property: JsonPropertyName("expectedPost")] RestoreDigest ExpectedPost, + [property: JsonPropertyName("snapshotPath")] string? SnapshotPath = null, + [property: JsonPropertyName("snapshotEntryIndex")] int? SnapshotEntryIndex = null); + +internal sealed record RestoreJournalPrepared( + RestoreBackupIdentity SourceBackup, + RestorePreSnapshotIdentity PreRestoreSnapshot, + RestoreStorageIdentity Storage, + IReadOnlyList RequiredTargetKinds, + IReadOnlyList ResolvesOperationIds, + IReadOnlyList Targets); + +internal sealed record RestoreJournalEvent( + int SchemaVersion, + int ProtocolVersion, + string OperationKind, + string OperationId, + int Sequence, + string State, + DateTimeOffset RecordedAt, + string? TargetId = null, + string? TargetPhase = null, + string? TargetDigest = null, + string? PostManifestSha256 = null, + string? ReasonCode = null); + +internal sealed record RestoreJournalProtectionReferences( + string SnapshotDirectory, + string? SourceBackupDirectory, + string? PreRestoreSnapshotDirectory, + bool IsUnverifiable); + +internal sealed record RestoreJournalInfo( + string JournalPath, + string SnapshotDir, + string BackupDir, + string? OperationId, + string State, + RestoreJournalPrepared? Prepared, + IReadOnlyList Events, + int LastSequence, + bool InvalidTail, + string? ValidationError, + bool Terminal, + bool Blocking, + IReadOnlyDictionary TargetPhases, + RestoreJournalProtectionReferences ProtectionReferences); + +internal sealed record RestoreJournalScan( + IReadOnlyList Journals, + IReadOnlyList BlockingJournals, + IReadOnlySet ResolvedOperationIds, + IReadOnlySet ProtectedDirectories, + bool PruneReferencesUnverifiable); + +internal sealed class RestoreJournal +{ + internal const string FileName = "restore-journal.v2.jsonl"; + internal const int SchemaVersion = 2; + internal const int ProtocolVersion = 2; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly SemaphoreSlim _appendGate = new(1, 1); + private readonly string _filePath; + private readonly string _operationId; + private int _sequence; + + private RestoreJournal(string filePath, string operationId, int sequence) + { + _filePath = Path.GetFullPath(filePath); + _operationId = operationId; + _sequence = sequence; + } + + internal string FilePath => _filePath; + + internal static async Task CreateAsync( + string snapshotDir, + string operationId, + RestoreJournalPrepared prepared, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + ArgumentNullException.ThrowIfNull(prepared); + ValidatePrepared(prepared); + + string directory = Path.GetFullPath(snapshotDir); + Directory.CreateDirectory(directory); + string filePath = Path.Combine(directory, FileName); + Dictionary value = BaseEvent(operationId, 1, "prepared"); + value["sourceBackup"] = prepared.SourceBackup; + value["preRestoreSnapshot"] = prepared.PreRestoreSnapshot; + value["storage"] = prepared.Storage; + value["requiredTargetKinds"] = prepared.RequiredTargetKinds; + value["resolvesOperationIds"] = prepared.ResolvesOperationIds; + value["targets"] = prepared.Targets; + byte[] bytes = SerializeLine(value); + + await using (FileStream stream = new( + filePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes, cancellationToken); + await stream.FlushAsync(cancellationToken); + stream.Flush(flushToDisk: true); + } + RestoreJournalDurability.SyncDirectory(directory); + + RestoreJournalInfo verified = await RestoreJournalService.ReadInfoAsync(filePath, cancellationToken); + if (verified.InvalidTail + || verified.State != "prepared" + || verified.OperationId != operationId + || verified.LastSequence != 1) + { + throw new InvalidOperationException("Restore journal prepared event did not persist durably."); + } + return new RestoreJournal(filePath, operationId, 1); + } + + internal static RestoreJournal Reopen(RestoreJournalInfo info) + { + ArgumentNullException.ThrowIfNull(info); + if (info.InvalidTail || string.IsNullOrWhiteSpace(info.OperationId)) + { + throw new InvalidOperationException("Cannot reopen an invalid Restore journal."); + } + return new RestoreJournal(info.JournalPath, info.OperationId, info.LastSequence); + } + + internal Task ApplyingAsync(CancellationToken cancellationToken = default) => + AppendAsync("applying", null, cancellationToken); + + internal Task TargetIntentAsync(string targetId, CancellationToken cancellationToken = default) => + AppendAsync("applying", new Dictionary + { + ["targetId"] = targetId, + ["targetPhase"] = "intent" + }, cancellationToken); + + internal Task TargetCompletedAsync( + string targetId, + string targetDigest, + CancellationToken cancellationToken = default) => + AppendAsync("applying", new Dictionary + { + ["targetId"] = targetId, + ["targetPhase"] = "completed", + ["targetDigest"] = targetDigest + }, cancellationToken); + + internal Task CommittingAsync(string postManifestSha256, CancellationToken cancellationToken = default) => + AppendAsync("committing", new Dictionary + { + ["postManifestSha256"] = postManifestSha256 + }, cancellationToken); + + internal Task CommittedPendingAckAsync( + string postManifestSha256, + CancellationToken cancellationToken = default) => + AppendAsync("committed-pending-ack", new Dictionary + { + ["postManifestSha256"] = postManifestSha256 + }, cancellationToken); + + internal Task CompletedAsync(CancellationToken cancellationToken = default) => + AppendAsync("completed", null, cancellationToken); + + internal Task RollbackPendingAsync(string reasonCode, CancellationToken cancellationToken = default) => + AppendAsync("rollback-pending", new Dictionary + { + ["reasonCode"] = reasonCode + }, cancellationToken); + + internal Task TargetCompensatedAsync( + string targetId, + string targetDigest, + CancellationToken cancellationToken = default) => + AppendAsync("rollback-pending", new Dictionary + { + ["targetId"] = targetId, + ["targetPhase"] = "compensated", + ["targetDigest"] = targetDigest + }, cancellationToken); + + internal Task RolledBackAsync(CancellationToken cancellationToken = default) => + AppendAsync("rolled-back", null, cancellationToken); + + internal Task RecoveryRequiredAsync(string reasonCode, CancellationToken cancellationToken = default) => + AppendAsync("recovery-required", new Dictionary + { + ["reasonCode"] = reasonCode + }, cancellationToken); + + private async Task AppendAsync( + string state, + IReadOnlyDictionary? details, + CancellationToken cancellationToken) + { + await _appendGate.WaitAsync(cancellationToken); + try + { + RestoreJournalInfo before = await RestoreJournalService.ReadInfoAsync(_filePath, cancellationToken); + if (before.InvalidTail + || before.OperationId != _operationId + || before.LastSequence != _sequence) + { + throw new InvalidOperationException("Restore journal changed before append."); + } + RestoreJournalService.ValidateAppend(before, state, details); + + int nextSequence = checked(_sequence + 1); + Dictionary value = BaseEvent(_operationId, nextSequence, state); + if (details is not null) + { + foreach ((string name, object? detail) in details) + { + value[name] = detail; + } + } + byte[] bytes = SerializeLine(value); + try + { + await using FileStream stream = new( + _filePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await stream.WriteAsync(bytes, cancellationToken); + await stream.FlushAsync(cancellationToken); + stream.Flush(flushToDisk: true); + } + catch + { + try + { + RestoreJournalInfo reconciled = await RestoreJournalService.ReadInfoAsync( + _filePath, + CancellationToken.None); + if (!reconciled.InvalidTail + && reconciled.OperationId == _operationId + && reconciled.LastSequence == nextSequence + && reconciled.State == state) + { + _sequence = nextSequence; + } + } + catch + { + // Preserve the original append failure. + } + throw; + } + + RestoreJournalInfo after = await RestoreJournalService.ReadInfoAsync(_filePath, cancellationToken); + if (after.InvalidTail + || after.OperationId != _operationId + || after.LastSequence != nextSequence + || after.State != state) + { + throw new InvalidOperationException("Restore journal append could not be verified."); + } + _sequence = nextSequence; + } + finally + { + _appendGate.Release(); + } + } + + private static Dictionary BaseEvent( + string operationId, + int sequence, + string state) => new() + { + ["schemaVersion"] = SchemaVersion, + ["protocolVersion"] = ProtocolVersion, + ["operationKind"] = "restore", + ["operationId"] = operationId, + ["sequence"] = sequence, + ["state"] = state, + ["recordedAt"] = DateTimeOffset.UtcNow.ToString("O") + }; + + private static byte[] SerializeLine(Dictionary value) => + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetBytes(JsonSerializer.Serialize(value, JsonOptions) + "\n"); + + internal static void ValidatePrepared(RestoreJournalPrepared prepared) + { + RestoreJournalService.ValidateIdentity(prepared.SourceBackup.BackupId, prepared.SourceBackup.BackupDir, prepared.SourceBackup.Revision); + RestoreJournalService.ValidateIdentity( + prepared.PreRestoreSnapshot.BackupId, + prepared.PreRestoreSnapshot.BackupDir, + prepared.PreRestoreSnapshot.Revision); + if (string.IsNullOrWhiteSpace(prepared.PreRestoreSnapshot.ManifestSha256)) + { + throw new InvalidOperationException("Restore snapshot manifest digest is required."); + } + if (!Path.IsPathFullyQualified(prepared.Storage.CodexHome)) + { + throw new InvalidOperationException("Restore storage Codex Home must be absolute."); + } + if (!Path.IsPathFullyQualified(prepared.Storage.CodexHomePhysical)) + { + throw new InvalidOperationException("Restore storage physical Codex Home must be absolute."); + } + if (prepared.Storage.SqliteHome is not null + && !Path.IsPathFullyQualified(prepared.Storage.SqliteHome)) + { + throw new InvalidOperationException("Restore storage SQLite Home must be absolute."); + } + if (prepared.Storage.TargetStateDbPath is not null + && !Path.IsPathFullyQualified(prepared.Storage.TargetStateDbPath)) + { + throw new InvalidOperationException("Restore State DB target must be absolute."); + } + if (prepared.Targets.Select(static target => target.Id).Distinct(StringComparer.Ordinal).Count() + != prepared.Targets.Count) + { + throw new InvalidOperationException("Restore target identifiers must be unique."); + } + if (prepared.Targets.Count == 0) + { + throw new InvalidOperationException("Restore journal must declare at least one target."); + } + foreach (RestoreJournalTarget target in prepared.Targets) + { + if (string.IsNullOrWhiteSpace(target.Id) + || string.IsNullOrWhiteSpace(target.Kind) + || !Path.IsPathFullyQualified(target.TargetPath)) + { + throw new InvalidOperationException("Restore target declaration is invalid."); + } + RestoreJournalService.ValidateDigest(target.Pre); + RestoreJournalService.ValidateDigest(target.ExpectedPost); + } + if (prepared.RequiredTargetKinds.Any(string.IsNullOrWhiteSpace) + || prepared.ResolvesOperationIds.Any(string.IsNullOrWhiteSpace)) + { + throw new InvalidOperationException("Restore journal contains an empty required identifier."); + } + HashSet targetKinds = prepared.Targets + .Select(static target => target.Kind) + .ToHashSet(StringComparer.Ordinal); + HashSet requiredKinds = prepared.RequiredTargetKinds.ToHashSet(StringComparer.Ordinal); + if (requiredKinds.Count != prepared.RequiredTargetKinds.Count + || !targetKinds.SetEquals(requiredKinds)) + { + throw new InvalidOperationException( + "Restore required target kinds must exactly match the declared targets."); + } + } +} + +internal static class RestoreJournalService +{ + private static readonly HashSet ValidStates = new(StringComparer.Ordinal) + { + "prepared", + "applying", + "committing", + "committed-pending-ack", + "completed", + "rollback-pending", + "rolled-back", + "recovery-required" + }; + + private static readonly IReadOnlyDictionary> ValidTransitions = + new Dictionary>(StringComparer.Ordinal) + { + ["prepared"] = new(StringComparer.Ordinal) { "applying", "rollback-pending", "recovery-required" }, + ["applying"] = new(StringComparer.Ordinal) { "applying", "committing", "rollback-pending", "recovery-required" }, + ["committing"] = new(StringComparer.Ordinal) { "committed-pending-ack", "rollback-pending", "recovery-required" }, + ["committed-pending-ack"] = new(StringComparer.Ordinal) { "completed", "recovery-required" }, + ["rollback-pending"] = new(StringComparer.Ordinal) { "rollback-pending", "rolled-back", "recovery-required" }, + ["completed"] = new(StringComparer.Ordinal), + ["rolled-back"] = new(StringComparer.Ordinal), + ["recovery-required"] = new(StringComparer.Ordinal) + }; + + internal static async Task ReadInfoAsync( + string journalPath, + CancellationToken cancellationToken = default) + { + byte[] bytes = await File.ReadAllBytesAsync(journalPath, cancellationToken); + return Parse(Path.GetFullPath(journalPath), bytes); + } + + internal static async Task> FindAsync( + string codexHome, + CancellationToken cancellationToken = default) + { + string root = new CodexHomeService().BackupRoot(codexHome); + if (!Directory.Exists(root)) + { + return []; + } + List journals = []; + foreach (string directory in Directory.EnumerateDirectories(root).Order(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + string journalPath = Path.Combine(directory, RestoreJournal.FileName); + if (!File.Exists(journalPath)) + { + continue; + } + try + { + journals.Add(await ReadInfoAsync(journalPath, cancellationToken)); + } + catch (Exception error) when (error is not OperationCanceledException) + { + string fullDirectory = Path.GetFullPath(directory); + journals.Add(new RestoreJournalInfo( + Path.GetFullPath(journalPath), + fullDirectory, + fullDirectory, + null, + "recovery-required", + null, + [], + 0, + true, + error.Message, + false, + true, + new Dictionary(StringComparer.Ordinal), + new RestoreJournalProtectionReferences( + fullDirectory, + null, + fullDirectory, + true))); + } + } + return journals.OrderBy(static item => item.JournalPath, StringComparer.Ordinal).ToArray(); + } + + internal static async Task ScanAsync( + string codexHome, + CancellationToken cancellationToken = default) + { + IReadOnlyList journals = await FindAsync(codexHome, cancellationToken); + Dictionary journalsByOperationId = journals + .Where(static journal => !string.IsNullOrWhiteSpace(journal.OperationId)) + .GroupBy(static journal => journal.OperationId!, StringComparer.Ordinal) + .ToDictionary( + static group => group.Key, + static group => group.ToArray(), + StringComparer.Ordinal); + HashSet resolvedOperationIds = new(StringComparer.Ordinal); + foreach (RestoreJournalInfo resolver in journals.Where(static journal => + !journal.InvalidTail + && journal.State == "completed" + && journal.Prepared is not null)) + { + foreach (string operationId in resolver.Prepared!.ResolvesOperationIds) + { + if (!journalsByOperationId.TryGetValue(operationId, out RestoreJournalInfo[]? matches) + || matches.Length != 1 + || !ResolverCanResolve(resolver, matches[0])) + { + continue; + } + resolvedOperationIds.Add(operationId); + } + } + RestoreJournalInfo[] blocking = journals + .Where(journal => journal.Blocking + && (string.IsNullOrWhiteSpace(journal.OperationId) + || !resolvedOperationIds.Contains(journal.OperationId))) + .ToArray(); + HashSet protectedDirectories = new(PathComparer); + bool unverifiable = false; + // Resolution may admit a later explicit Restore, but it is not + // authority to delete evidence. Protect every nonterminal journal's + // source and pre-snapshot until that journal itself is terminal. + foreach (RestoreJournalInfo journal in journals.Where(static item => item.Blocking)) + { + RestoreJournalProtectionReferences references = journal.ProtectionReferences; + protectedDirectories.Add(Path.GetFullPath(references.SnapshotDirectory)); + if (!string.IsNullOrWhiteSpace(references.SourceBackupDirectory)) + { + protectedDirectories.Add(Path.GetFullPath(references.SourceBackupDirectory)); + } + if (!string.IsNullOrWhiteSpace(references.PreRestoreSnapshotDirectory)) + { + protectedDirectories.Add(Path.GetFullPath(references.PreRestoreSnapshotDirectory)); + } + unverifiable |= references.IsUnverifiable; + } + return new RestoreJournalScan( + journals, + blocking, + resolvedOperationIds, + protectedDirectories, + unverifiable); + } + + private static bool ResolverCanResolve( + RestoreJournalInfo resolver, + RestoreJournalInfo pending) + { + RestoreJournalPrepared? resolverPrepared = resolver.Prepared; + RestoreJournalPrepared? pendingPrepared = pending.Prepared; + if (!pending.Blocking + || pending.InvalidTail + || resolverPrepared is null + || pendingPrepared is null) + { + return false; + } + RestoreBackupIdentity resolverSource = resolverPrepared.SourceBackup; + RestoreBackupIdentity pendingSource = pendingPrepared.SourceBackup; + string? resolverSourcePath = TryPhysicalDirectoryPathKey(resolverSource.BackupDir); + string? pendingSourcePath = TryPhysicalDirectoryPathKey(pendingSource.BackupDir); + string? resolverHomePath = TryPhysicalDirectoryPathKey(resolverPrepared.Storage.CodexHome); + string? pendingHomePath = TryPhysicalDirectoryPathKey(pendingPrepared.Storage.CodexHome); + string? resolverRecordedHomePath = TryPersistedPhysicalPathKey( + resolverPrepared.Storage.CodexHomePhysical); + string? pendingRecordedHomePath = TryPersistedPhysicalPathKey( + pendingPrepared.Storage.CodexHomePhysical); + if (resolverSourcePath is null + || pendingSourcePath is null + || !PathComparer.Equals(resolverSourcePath, pendingSourcePath) + || !string.Equals(resolverSource.Revision, pendingSource.Revision, StringComparison.Ordinal) + || resolverHomePath is null + || pendingHomePath is null + || resolverRecordedHomePath is null + || pendingRecordedHomePath is null + || !PathComparer.Equals(resolverHomePath, pendingHomePath) + || !PathComparer.Equals(resolverHomePath, resolverRecordedHomePath) + || !PathComparer.Equals(pendingHomePath, pendingRecordedHomePath)) + { + return false; + } + HashSet resolverKinds = resolverPrepared.RequiredTargetKinds + .ToHashSet(StringComparer.Ordinal); + return pendingPrepared.RequiredTargetKinds.All(resolverKinds.Contains); + } + + private static string? TryPhysicalDirectoryPathKey(string path) + { + try + { + string first = StateDbLockResource.ResolveExistingPhysicalPath( + Path.GetFullPath(path), + directory: true); + string second = StateDbLockResource.ResolveExistingPhysicalPath( + Path.GetFullPath(path), + directory: true); + return PathComparer.Equals(first, second) ? Path.GetFullPath(first) : null; + } + catch (Exception error) when (error is IOException + or UnauthorizedAccessException + or System.ComponentModel.Win32Exception + or ArgumentException + or NotSupportedException + or System.Security.SecurityException) + { + return null; + } + } + + private static string? TryPersistedPhysicalPathKey(string path) + { + try + { + return Path.IsPathFullyQualified(path) ? Path.GetFullPath(path) : null; + } + catch (Exception error) when (error is ArgumentException + or NotSupportedException + or System.Security.SecurityException) + { + return null; + } + } + + internal static async Task> FindBlockingAsync( + string codexHome, + CancellationToken cancellationToken = default) => + (await ScanAsync(codexHome, cancellationToken)).BlockingJournals; + + internal static void ValidateAppend( + RestoreJournalInfo before, + string nextState, + IReadOnlyDictionary? details) + { + if (!ValidTransitions.TryGetValue(before.State, out HashSet? allowed) + || !allowed.Contains(nextState)) + { + throw new InvalidOperationException( + $"Restore journal transition {before.State} -> {nextState} is invalid."); + } + string? targetId = ReadDetail(details, "targetId"); + string? targetPhase = ReadDetail(details, "targetPhase"); + string? targetDigest = ReadDetail(details, "targetDigest"); + ValidateTargetTransition(before.Prepared, before.TargetPhases, nextState, targetId, targetPhase, targetDigest); + Dictionary phases = new(before.TargetPhases, StringComparer.Ordinal); + if (targetId is not null && targetPhase is not null) + { + phases[targetId] = targetPhase; + } + string? postManifestSha256 = ReadDetail(details, "postManifestSha256"); + string? committingHash = before.Events + .LastOrDefault(static item => item.State == "committing") + ?.PostManifestSha256; + ValidateStateEvidence( + before.Prepared, + phases, + nextState, + postManifestSha256, + committingHash); + if (nextState is "committing" or "committed-pending-ack" + && string.IsNullOrWhiteSpace(postManifestSha256)) + { + throw new InvalidOperationException("Restore commit event requires postManifestSha256."); + } + } + + private static RestoreJournalInfo Parse(string journalPath, byte[] bytes) + { + string snapshotDir = Path.GetDirectoryName(journalPath) + ?? throw new InvalidOperationException("Restore journal has no parent directory."); + bool missingLf = bytes.Length > 0 && bytes[^1] != (byte)'\n'; + string text; + try + { + text = new UTF8Encoding(false, true).GetString(bytes); + } + catch (DecoderFallbackException error) + { + return Invalid(journalPath, snapshotDir, null, null, [], 0, new Dictionary(), + $"Restore journal is not valid UTF-8: {error.Message}", null, null, true); + } + + List events = []; + string? operationId = null; + string? state = null; + RestoreJournalPrepared? prepared = null; + Dictionary phases = new(StringComparer.Ordinal); + string? committingHash = null; + int expectedSequence = 1; + string? validationError = missingLf + ? "Restore journal is missing its final newline and may contain a torn append." + : null; + string? rawSourceBackupDir = null; + string? rawPreSnapshotDir = null; + + foreach (string line in text.Split('\n')) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + JsonDocument document; + try + { + document = JsonDocument.Parse(line); + } + catch (JsonException) + { + validationError ??= "Restore journal contains a truncated or malformed JSON line."; + break; + } + using (document) + { + JsonElement root = document.RootElement; + if (events.Count == 0 && root.ValueKind == JsonValueKind.Object) + { + rawSourceBackupDir = TryAbsoluteNestedPath(root, "sourceBackup", "backupDir"); + rawPreSnapshotDir = TryAbsoluteNestedPath(root, "preRestoreSnapshot", "backupDir"); + } + try + { + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Restore journal event is not an object."); + } + int schemaVersion = RequiredInt(root, "schemaVersion"); + int protocolVersion = RequiredInt(root, "protocolVersion"); + string operationKind = RequiredString(root, "operationKind"); + string currentOperationId = RequiredString(root, "operationId"); + int sequence = RequiredInt(root, "sequence"); + string currentState = RequiredString(root, "state"); + string recordedAtText = RequiredString(root, "recordedAt"); + if (schemaVersion != RestoreJournal.SchemaVersion + || protocolVersion != RestoreJournal.ProtocolVersion + || operationKind != "restore" + || !ValidStates.Contains(currentState)) + { + throw new InvalidOperationException( + "Restore journal event has an unsupported schema, protocol, kind, or state."); + } + if (sequence != expectedSequence) + { + throw new InvalidOperationException( + $"Restore journal sequence mismatch: expected {expectedSequence}, received {sequence}."); + } + if (!DateTimeOffset.TryParse(recordedAtText, out DateTimeOffset recordedAt)) + { + throw new InvalidOperationException("Restore journal recordedAt is invalid."); + } + if (operationId is null) + { + if (currentState != "prepared") + { + throw new InvalidOperationException("Restore journal must start with prepared."); + } + prepared = ParsePrepared(root); + RestoreJournal.ValidatePrepared(prepared); + operationId = currentOperationId; + state = currentState; + } + else + { + if (currentOperationId != operationId) + { + throw new InvalidOperationException("Restore journal operationId changed."); + } + if (state is null + || !ValidTransitions.TryGetValue(state, out HashSet? allowed) + || !allowed.Contains(currentState)) + { + throw new InvalidOperationException( + $"Restore journal transition {state} -> {currentState} is invalid."); + } + state = currentState; + } + + string? targetId = OptionalString(root, "targetId"); + string? targetPhase = OptionalString(root, "targetPhase"); + string? targetDigest = OptionalString(root, "targetDigest"); + string? postManifestSha256 = OptionalString(root, "postManifestSha256"); + string? reasonCode = OptionalString(root, "reasonCode"); + ValidateTargetTransition(prepared, phases, currentState, targetId, targetPhase, targetDigest); + if (currentState is "committing" or "committed-pending-ack" + && string.IsNullOrWhiteSpace(postManifestSha256)) + { + throw new InvalidOperationException("Restore commit event requires postManifestSha256."); + } + if (targetId is not null && targetPhase is not null) + { + phases[targetId] = targetPhase; + } + ValidateStateEvidence( + prepared, + phases, + currentState, + postManifestSha256, + committingHash); + if (currentState == "committing") + { + committingHash = postManifestSha256; + } + events.Add(new RestoreJournalEvent( + schemaVersion, + protocolVersion, + operationKind, + currentOperationId, + sequence, + currentState, + recordedAt, + targetId, + targetPhase, + targetDigest, + postManifestSha256, + reasonCode)); + expectedSequence++; + } + catch (Exception error) + { + validationError ??= error.Message; + break; + } + } + } + + if (events.Count == 0) + { + validationError ??= "Restore journal contains no valid events."; + } + if (validationError is not null) + { + return Invalid( + journalPath, + snapshotDir, + operationId, + prepared, + events, + events.Count == 0 ? 0 : events[^1].Sequence, + phases, + validationError, + rawSourceBackupDir, + rawPreSnapshotDir, + rawSourceBackupDir is null || rawPreSnapshotDir is null); + } + + string finalState = state ?? "recovery-required"; + bool terminal = finalState is "completed" or "rolled-back" or "recovery-required"; + bool blocking = finalState is not ("completed" or "rolled-back"); + return new RestoreJournalInfo( + journalPath, + snapshotDir, + snapshotDir, + operationId, + finalState, + prepared, + events, + events[^1].Sequence, + false, + null, + terminal, + blocking, + phases, + new RestoreJournalProtectionReferences( + snapshotDir, + prepared?.SourceBackup.BackupDir, + prepared?.PreRestoreSnapshot.BackupDir ?? snapshotDir, + false)); + } + + private static RestoreJournalInfo Invalid( + string journalPath, + string snapshotDir, + string? operationId, + RestoreJournalPrepared? prepared, + IReadOnlyList events, + int lastSequence, + IReadOnlyDictionary phases, + string validationError, + string? rawSourceBackupDir, + string? rawPreSnapshotDir, + bool referencesUnverifiable) => new( + journalPath, + snapshotDir, + snapshotDir, + operationId, + "recovery-required", + prepared, + events, + lastSequence, + true, + validationError, + false, + true, + phases, + new RestoreJournalProtectionReferences( + snapshotDir, + rawSourceBackupDir ?? prepared?.SourceBackup.BackupDir, + rawPreSnapshotDir ?? prepared?.PreRestoreSnapshot.BackupDir ?? snapshotDir, + referencesUnverifiable)); + + private static RestoreJournalPrepared ParsePrepared(JsonElement root) + { + RestoreBackupIdentity source = ParseIdentity(root.GetProperty("sourceBackup")); + JsonElement preElement = root.GetProperty("preRestoreSnapshot"); + RestoreBackupIdentity preBase = ParseIdentity(preElement); + RestorePreSnapshotIdentity pre = new( + preBase.BackupId, + preBase.BackupDir, + preBase.Revision, + RequiredString(preElement, "manifestSha256")); + JsonElement storageElement = root.GetProperty("storage"); + RestoreStorageIdentity storage = new( + RequiredString(storageElement, "codexHome"), + RequiredString(storageElement, "codexHomePhysical"), + OptionalString(storageElement, "sqliteHome"), + OptionalString(storageElement, "stateDbResourceKey"), + OptionalString(storageElement, "targetStateDbPath")); + string[] requiredTargetKinds = RequiredStringArray(root, "requiredTargetKinds"); + string[] resolvesOperationIds = root.TryGetProperty("resolvesOperationIds", out JsonElement resolves) + ? ParseStringArray(resolves, "resolvesOperationIds") + : []; + JsonElement targetsElement = root.GetProperty("targets"); + if (targetsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Restore targets must be an array."); + } + List targets = []; + foreach (JsonElement target in targetsElement.EnumerateArray()) + { + targets.Add(new RestoreJournalTarget( + RequiredString(target, "id"), + RequiredString(target, "kind"), + RequiredString(target, "targetPath"), + ParseDigest(target.GetProperty("pre")), + ParseDigest(target.GetProperty("expectedPost")), + OptionalString(target, "snapshotPath"), + OptionalInt(target, "snapshotEntryIndex"))); + } + return new RestoreJournalPrepared(source, pre, storage, requiredTargetKinds, resolvesOperationIds, targets); + } + + private static RestoreBackupIdentity ParseIdentity(JsonElement value) => new( + RequiredString(value, "backupId"), + RequiredString(value, "backupDir"), + RequiredString(value, "revision")); + + private static RestoreDigest ParseDigest(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object + || !value.TryGetProperty("present", out JsonElement present) + || present.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + throw new InvalidOperationException("Restore digest present flag is invalid."); + } + RestoreDigest digest = new( + present.GetBoolean(), + RequiredString(value, "digestKind"), + RequiredString(value, "digest"), + OptionalLong(value, "sizeBytes")); + ValidateDigest(digest); + return digest; + } + + internal static void ValidateDigest(RestoreDigest digest) + { + if (string.IsNullOrWhiteSpace(digest.DigestKind) + || string.IsNullOrWhiteSpace(digest.Digest) + || digest.SizeBytes < 0) + { + throw new InvalidOperationException("Restore digest is invalid."); + } + } + + internal static void ValidateIdentity(string backupId, string backupDir, string revision) + { + if (string.IsNullOrWhiteSpace(backupId) + || !Path.IsPathFullyQualified(backupDir) + || string.IsNullOrWhiteSpace(revision)) + { + throw new InvalidOperationException("Restore backup identity is invalid."); + } + } + + private static void ValidateTargetTransition( + RestoreJournalPrepared? prepared, + IReadOnlyDictionary phases, + string state, + string? targetId, + string? targetPhase, + string? targetDigest) + { + if (targetId is null && targetPhase is null && targetDigest is null) + { + return; + } + if (targetId is null + || targetPhase is null + || prepared is null + || !prepared.Targets.Any(target => target.Id == targetId)) + { + throw new InvalidOperationException("Restore journal target transition is malformed or undeclared."); + } + phases.TryGetValue(targetId, out string? previous); + RestoreJournalTarget target = prepared.Targets.Single(item => item.Id == targetId); + switch (targetPhase) + { + case "intent" when state == "applying" && previous is null: + return; + case "completed" when state == "applying" + && previous == "intent" + && targetDigest == target.ExpectedPost.Digest: + return; + case "compensated" when state == "rollback-pending" + && previous != "compensated" + && targetDigest == target.Pre.Digest: + return; + default: + throw new InvalidOperationException("Restore journal target transition is invalid."); + } + } + + private static void ValidateStateEvidence( + RestoreJournalPrepared? prepared, + IReadOnlyDictionary phases, + string state, + string? postManifestSha256, + string? committingHash) + { + if (prepared is null) + { + return; + } + if (state == "committing") + { + if (string.IsNullOrWhiteSpace(postManifestSha256) + || prepared.Targets.Any(target => + !phases.TryGetValue(target.Id, out string? phase) || phase != "completed")) + { + throw new InvalidOperationException( + "Restore cannot commit before every declared target is completed."); + } + } + else if (state == "committed-pending-ack") + { + if (string.IsNullOrWhiteSpace(postManifestSha256) + || postManifestSha256 != committingHash) + { + throw new InvalidOperationException( + "Restore commit acknowledgement hash does not match committing evidence."); + } + } + else if (state == "rolled-back" + && prepared.Targets.Any(target => + !phases.TryGetValue(target.Id, out string? phase) || phase != "compensated")) + { + throw new InvalidOperationException( + "Restore cannot become rolled-back before every declared target is compensated."); + } + } + + private static string? ReadDetail(IReadOnlyDictionary? details, string name) => + details is not null + && details.TryGetValue(name, out object? value) + && value is string text + ? text + : null; + + private static string RequiredString(JsonElement value, string name) + { + if (!value.TryGetProperty(name, out JsonElement property) + || property.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(property.GetString())) + { + throw new InvalidOperationException($"Restore journal field {name} is required."); + } + return property.GetString()!; + } + + private static string? OptionalString(JsonElement value, string name) + { + if (!value.TryGetProperty(name, out JsonElement property) + || property.ValueKind == JsonValueKind.Null) + { + return null; + } + if (property.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException($"Restore journal field {name} must be a string."); + } + return property.GetString(); + } + + private static int RequiredInt(JsonElement value, string name) + { + if (!value.TryGetProperty(name, out JsonElement property) + || property.ValueKind != JsonValueKind.Number + || !property.TryGetInt32(out int result)) + { + throw new InvalidOperationException($"Restore journal field {name} must be an integer."); + } + return result; + } + + private static int? OptionalInt(JsonElement value, string name) + { + if (!value.TryGetProperty(name, out JsonElement property) + || property.ValueKind == JsonValueKind.Null) + { + return null; + } + if (property.ValueKind != JsonValueKind.Number || !property.TryGetInt32(out int result)) + { + throw new InvalidOperationException($"Restore journal field {name} must be an integer."); + } + return result; + } + + private static long? OptionalLong(JsonElement value, string name) + { + if (!value.TryGetProperty(name, out JsonElement property) + || property.ValueKind == JsonValueKind.Null) + { + return null; + } + if (property.ValueKind != JsonValueKind.Number || !property.TryGetInt64(out long result)) + { + throw new InvalidOperationException($"Restore journal field {name} must be an integer."); + } + return result; + } + + private static string[] RequiredStringArray(JsonElement value, string name) + { + if (!value.TryGetProperty(name, out JsonElement property)) + { + throw new InvalidOperationException($"Restore journal field {name} is required."); + } + return ParseStringArray(property, name); + } + + private static string[] ParseStringArray(JsonElement value, string name) + { + if (value.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException($"Restore journal field {name} must be an array."); + } + List result = []; + foreach (JsonElement item in value.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(item.GetString())) + { + throw new InvalidOperationException($"Restore journal field {name} contains an invalid value."); + } + result.Add(item.GetString()!); + } + return [.. result]; + } + + private static string? TryAbsoluteNestedPath(JsonElement root, string objectName, string propertyName) + { + if (!root.TryGetProperty(objectName, out JsonElement nested) + || nested.ValueKind != JsonValueKind.Object + || !nested.TryGetProperty(propertyName, out JsonElement property) + || property.ValueKind != JsonValueKind.String) + { + return null; + } + string? value = property.GetString(); + return !string.IsNullOrWhiteSpace(value) && Path.IsPathFullyQualified(value) + ? Path.GetFullPath(value) + : null; + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; +} + +internal static class RestoreJournalDurability +{ + internal static void SyncDirectory(string directory) + { + if (OperatingSystem.IsWindows()) + { + nint handle = CreateFileW( + directory, + 0x80000000, + 0x00000001 | 0x00000002 | 0x00000004, + nint.Zero, + 3, + 0x02000000 | 0x80000000, + nint.Zero); + if (handle == new nint(-1)) + { + int errorCode = Marshal.GetLastPInvokeError(); + if (IsUnsupportedWindowsDirectorySyncError(errorCode)) + { + return; + } + throw new IOException( + "Unable to open Restore journal directory for durability sync.", + new System.ComponentModel.Win32Exception(errorCode)); + } + try + { + if (!FlushFileBuffers(handle)) + { + int errorCode = Marshal.GetLastPInvokeError(); + if (!IsUnsupportedWindowsDirectorySyncError(errorCode)) + { + throw new IOException( + "Unable to sync Restore journal directory.", + new System.ComponentModel.Win32Exception(errorCode)); + } + } + } + finally + { + _ = CloseHandle(handle); + } + return; + } + + int fd = open(directory, 0); + if (fd < 0) + { + throw new IOException($"Unable to open Restore journal directory for durability sync: {directory}"); + } + try + { + if (fsync(fd) != 0) + { + throw new IOException($"Unable to sync Restore journal directory: {directory}"); + } + } + finally + { + _ = close(fd); + } + } + + private static bool IsUnsupportedWindowsDirectorySyncError(int errorCode) => errorCode is + 1 // ERROR_INVALID_FUNCTION / EINVAL + or 5 // ERROR_ACCESS_DENIED / EACCES or EPERM + or 6 // ERROR_INVALID_HANDLE / EBADF + or 50 // ERROR_NOT_SUPPORTED / ENOTSUP + or 87 // ERROR_INVALID_PARAMETER / EINVAL + or 1314; // ERROR_PRIVILEGE_NOT_HELD / EPERM + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern nint CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + nint securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + nint templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FlushFileBuffers(nint handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(nint handle); + + [DllImport("libc", SetLastError = true)] + private static extern int open([MarshalAs(UnmanagedType.LPUTF8Str)] string path, int flags); + + [DllImport("libc", SetLastError = true)] + private static extern int fsync(int fd); + + [DllImport("libc", SetLastError = true)] + private static extern int close(int fd); +} diff --git a/desktop/CodexProviderSync.Core/RestoreV2Service.cs b/desktop/CodexProviderSync.Core/RestoreV2Service.cs new file mode 100644 index 0000000..ee94091 --- /dev/null +++ b/desktop/CodexProviderSync.Core/RestoreV2Service.cs @@ -0,0 +1,1729 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace CodexProviderSync.Core; + +internal sealed record RestoreSnapshotLocator( + [property: JsonPropertyName("backupId")] string BackupId, + [property: JsonPropertyName("backupDir")] string BackupDir); + +internal sealed class RestoreSnapshotManifestFile +{ + public int SchemaVersion { get; init; } + public int ProtocolVersion { get; init; } + public string OperationKind { get; init; } = string.Empty; + public string OperationId { get; init; } = string.Empty; + public DateTimeOffset CreatedAt { get; init; } + public required RestoreBackupIdentity SourceBackup { get; init; } + public required RestoreSnapshotLocator PreRestoreSnapshot { get; init; } + public required RestoreStorageIdentity Storage { get; init; } + public IReadOnlyList RequiredTargetKinds { get; init; } = []; + public IReadOnlyList ResolvesOperationIds { get; init; } = []; + public IReadOnlyList Targets { get; init; } = []; +} + +internal sealed record RestorePreSnapshot( + string BackupId, + string BackupDirectory, + string Revision, + string ManifestSha256, + RestoreSnapshotManifestFile Manifest); + +internal sealed class RestoreV2Service +{ + internal const string SnapshotManifestFileName = "restore-snapshot.v2.json"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private static readonly Regex ModelFieldRegex = new( + "\\\"model\\\"\\s*:\\s*(?\\\"(?:\\\\.|[^\\\"\\\\])*\\\")", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex TurnContextTypeRegex = new( + "\\\"type\\\"\\s*:\\s*\\\"turn_context\\\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly BackupService _backupService; + private readonly SessionRolloutService _sessionRolloutService; + private readonly SqliteStateService _sqliteStateService; + + internal RestoreV2Service( + BackupService backupService, + SessionRolloutService sessionRolloutService, + SqliteStateService sqliteStateService) + { + _backupService = backupService; + _sessionRolloutService = sessionRolloutService; + _sqliteStateService = sqliteStateService; + } + + internal Func? FaultInjector { get; set; } + + internal static async Task CaptureSourceIdentityAsync( + string backupDir, + CancellationToken cancellationToken = default) + { + string root = ResolveStablePhysicalDirectory(backupDir); + if (!Directory.Exists(root)) + { + throw new InvalidOperationException("The selected managed backup is unavailable."); + } + List<(string Path, string Sha256)> files = []; + await CollectIdentityFilesAsync(root, root, files, cancellationToken); + files.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Path, right.Path)); + using MemoryStream json = new(); + using (Utf8JsonWriter writer = new(json, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + writer.WriteStartArray(); + foreach ((string relativePath, string sha256) in files) + { + writer.WriteStartObject(); + writer.WriteString("path", relativePath); + writer.WriteString("sha256", sha256); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + return new RestoreBackupIdentity( + Path.GetFileName(root), + root, + Sha256Base64Url(json.ToArray())); + } + + internal async Task ExecuteAsync( + RestoreBackupPlan plan, + RestoreBackupIdentity sourceBackup, + StateDbLockResource? stateDbResource, + IReadOnlyList resolvesOperationIds, + CancellationToken cancellationToken = default) + { + string operationId = Guid.NewGuid().ToString("D"); + RestoreBackupIdentity initialSource = await CaptureSourceIdentityAsync( + sourceBackup.BackupDir, + cancellationToken); + if (!JournalMatchesSource(initialSource, sourceBackup)) + { + throw new CoreWritePlanStaleException(); + } + RestorePreSnapshot snapshot = await CreatePreSnapshotAsync( + operationId, + plan, + sourceBackup, + stateDbResource, + resolvesOperationIds, + cancellationToken); + RestoreBackupIdentity preApplySource = await CaptureSourceIdentityAsync( + sourceBackup.BackupDir, + cancellationToken); + if (!JournalMatchesSource(preApplySource, sourceBackup)) + { + TryDeleteDirectory(snapshot.BackupDirectory); + throw new CoreWritePlanStaleException(); + } + RestoreJournalPrepared prepared = new( + sourceBackup, + new RestorePreSnapshotIdentity( + snapshot.BackupId, + snapshot.BackupDirectory, + snapshot.Revision, + snapshot.ManifestSha256), + snapshot.Manifest.Storage, + snapshot.Manifest.RequiredTargetKinds, + snapshot.Manifest.ResolvesOperationIds, + snapshot.Manifest.Targets); + + RestoreJournal journal; + try + { + journal = await RestoreJournal.CreateAsync( + snapshot.BackupDirectory, + operationId, + prepared, + cancellationToken); + } + catch (Exception error) + { + TryDeleteDirectory(snapshot.BackupDirectory); + throw new InvalidOperationException( + "Unable to persist the Restore journal before mutation.", + error); + } + + Dictionary targetEvidence = snapshot.Manifest.Targets + .ToDictionary(static target => TargetKey(target.Kind, target.TargetPath), StringComparer.Ordinal); + bool mutationMayHaveOccurred = false; + try + { + await InvokeFaultAsync("after_restore_prepared_before_applying", null, 0); + cancellationToken.ThrowIfCancellationRequested(); + await journal.ApplyingAsync(cancellationToken); + int completedCount = 0; + foreach (RestoreBackupTarget target in plan.Targets) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!targetEvidence.TryGetValue(TargetKey(target.Kind, target.TargetPath), out RestoreJournalTarget? evidence)) + { + throw new InvalidOperationException("Restore attempted an undeclared target."); + } + ValidatePhysicalTargetBoundary( + evidence.Kind, + evidence.TargetPath, + snapshot.Manifest.Storage, + plan.Storage); + await journal.TargetIntentAsync(evidence.Id, cancellationToken); + mutationMayHaveOccurred = true; + await InvokeFaultAsync( + "after_restore_target_intent_before_write", + target.TargetPath, + completedCount); + ValidatePhysicalTargetBoundary( + evidence.Kind, + evidence.TargetPath, + snapshot.Manifest.Storage, + plan.Storage); + await _backupService.ApplyRestoreTargetAsync(plan, target, CancellationToken.None); + await InvokeFaultAsync( + "after_restore_target_write_before_complete", + target.TargetPath, + completedCount); + ValidatePhysicalTargetBoundary( + evidence.Kind, + evidence.TargetPath, + snapshot.Manifest.Storage, + plan.Storage); + RestoreDigest actual = await DigestTargetAsync( + evidence, + snapshot.BackupDirectory, + plan.Storage, + CancellationToken.None); + if (!SameDigest(actual, evidence.ExpectedPost)) + { + throw new InvalidOperationException( + $"Restore target post-write digest verification failed for {evidence.Kind}."); + } + await journal.TargetCompletedAsync(evidence.Id, actual.Digest, CancellationToken.None); + completedCount++; + await InvokeFaultAsync( + "after_restore_target_complete", + target.TargetPath, + completedCount); + } + + string postManifestSha256 = await VerifyManifestTargetsAsync( + snapshot.Manifest, + expectedPre: false, + snapshot.BackupDirectory, + plan.Storage, + CancellationToken.None); + await InvokeFaultAsync( + "after_restore_targets_verify_before_committing", + null, + plan.Targets.Count); + await journal.CommittingAsync(postManifestSha256, CancellationToken.None); + await InvokeFaultAsync( + "after_restore_committing_before_committed_pending_ack", + null, + plan.Targets.Count); + await journal.CommittedPendingAckAsync(postManifestSha256, CancellationToken.None); + await InvokeFaultAsync( + "after_restore_committed_pending_ack_before_completed", + null, + plan.Targets.Count); + RestoreJournalInfo current = await RestoreJournalService.ReadInfoAsync( + journal.FilePath, + CancellationToken.None); + RestoreJournalInfo completed = await AcknowledgeCommittedAsync( + current, + plan.Storage, + stateDbResource, + CancellationToken.None); + return BuildResult( + plan, + operationId, + snapshot.BackupId, + completed.State, + snapshot.Manifest.ResolvesOperationIds, + commitAcknowledgementRecovered: false); + } + catch (Exception originalError) + { + RestoreJournalInfo current; + try + { + current = await RestoreJournalService.ReadInfoAsync(journal.FilePath, CancellationToken.None); + } + catch (Exception journalError) + { + throw RecoveryRequired( + "Restore journal cannot be read after an interrupted operation.", + snapshot, + sourceBackup, + journalError); + } + + if (current.State == "completed" && !current.InvalidTail) + { + return BuildResult( + plan, + operationId, + snapshot.BackupId, + "completed", + snapshot.Manifest.ResolvesOperationIds, + commitAcknowledgementRecovered: false); + } + if (current.State == "committed-pending-ack" && !current.InvalidTail) + { + try + { + RestoreJournalInfo completed = await AcknowledgeCommittedAsync( + current, + plan.Storage, + stateDbResource, + CancellationToken.None); + return BuildResult( + plan, + operationId, + snapshot.BackupId, + completed.State, + snapshot.Manifest.ResolvesOperationIds, + commitAcknowledgementRecovered: true); + } + catch (Exception acknowledgementError) + { + await TryMarkRecoveryRequiredAsync(current, "commit-ack-unverifiable"); + throw RecoveryRequired( + "Restore committed, but its final acknowledgement is unverifiable.", + snapshot, + sourceBackup, + acknowledgementError); + } + } + if (current.InvalidTail || current.Prepared is null) + { + throw RecoveryRequired( + "Restore journal evidence is incomplete; compensation was not attempted.", + snapshot, + sourceBackup, + originalError); + } + + try + { + RestoreJournal writer = RestoreJournal.Reopen(current); + if (current.State != "rollback-pending") + { + await writer.RollbackPendingAsync(ErrorCode(originalError), CancellationToken.None); + } + RestoreJournalInfo rollback = await RestoreJournalService.ReadInfoAsync( + writer.FilePath, + CancellationToken.None); + await CompensateAsync( + rollback, + writer, + plan.Storage, + stateDbResource, + mutationMayHaveOccurred, + CancellationToken.None); + await writer.RolledBackAsync(CancellationToken.None); + RestoreJournalInfo terminal = await RestoreJournalService.ReadInfoAsync( + writer.FilePath, + CancellationToken.None); + if (terminal.InvalidTail || terminal.State != "rolled-back") + { + throw new InvalidOperationException("Restore rollback terminal state did not persist."); + } + } + catch (Exception rollbackError) + { + RestoreJournalInfo latest = await SafeReadAsync(journal.FilePath, current); + await TryMarkRecoveryRequiredAsync(latest, "rollback-unverifiable"); + throw RecoveryRequired( + "Restore failed and its compensation could not be verified.", + snapshot, + sourceBackup, + rollbackError); + } + + if (!mutationMayHaveOccurred) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(originalError).Throw(); + } + throw new SyncTransactionException( + originalError, + [], + snapshot.BackupDirectory, + plan.Targets.Select(static target => target.TargetPath).ToArray(), + [], + rollbackStatus: "complete", + recoveryRequired: false); + } + } + + internal async Task AcknowledgePendingAsync( + RestoreJournalInfo journal, + CodexStorageLayout storage, + StateDbLockResource? stateDbResource, + CancellationToken cancellationToken = default) + { + try + { + return await AcknowledgeCommittedAsync(journal, storage, stateDbResource, cancellationToken); + } + catch + { + await TryMarkRecoveryRequiredAsync(journal, "commit-ack-unverifiable"); + string[] evidenceDirectories = + [ + journal.SnapshotDir, + .. journal.Prepared is null + ? [] + : new[] { journal.Prepared.SourceBackup.BackupDir } + ]; + throw new RecoveryRequiredException( + "Restore committed, but its final acknowledgement evidence is unverifiable.", + evidenceDirectories); + } + } + + private async Task CreatePreSnapshotAsync( + string operationId, + RestoreBackupPlan plan, + RestoreBackupIdentity sourceBackup, + StateDbLockResource? stateDbResource, + IReadOnlyList resolvesOperationIds, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string codexHomePhysical = ResolveStablePhysicalDirectory(plan.Storage.CodexHome); + RestoreStorageIdentity boundaryStorage = new( + Path.GetFullPath(plan.Storage.CodexHome), + codexHomePhysical, + Path.GetFullPath(plan.Storage.SqliteHome), + stateDbResource?.ResourceKey, + plan.StateDbTargetPath is null ? null : Path.GetFullPath(plan.StateDbTargetPath)); + string backupRoot = AppConstants.DefaultBackupRoot(plan.Storage.CodexHome); + Directory.CreateDirectory(backupRoot); + string backupId = $"restore-v2-{operationId}"; + string snapshotDir = Path.Combine(backupRoot, backupId); + Directory.CreateDirectory(snapshotDir); + try + { + List rolloutEntries = []; + List targets = []; + foreach (RestoreBackupTarget sourceTarget in plan.Targets) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidatePhysicalTargetBoundary( + sourceTarget.Kind, + sourceTarget.TargetPath, + boundaryStorage, + plan.Storage); + string id = TargetId(sourceTarget.Kind, sourceTarget.TargetPath); + RestoreDigest pre; + string? snapshotPath = null; + int? snapshotEntryIndex = null; + if (sourceTarget.Kind == "rollout") + { + SessionBackupManifestEntry entry = await CaptureRolloutEntryAsync( + sourceTarget.TargetPath, + cancellationToken); + pre = DigestRolloutEntry(entry); + snapshotEntryIndex = rolloutEntries.Count; + rolloutEntries.Add(entry); + } + else if (sourceTarget.Kind == "sqlite") + { + pre = await DigestSqliteAsync( + sourceTarget.TargetPath, + snapshotDir, + plan.Storage, + cancellationToken); + if (pre.Present) + { + snapshotPath = Path.Combine("db", "sqlite-home", AppConstants.DbFileBasename); + string destination = Path.Combine(snapshotDir, snapshotPath); + CodexStorageLayout sourceStorage = StorageForDatabase( + plan.Storage, + sourceTarget.TargetPath, + "restore-v2-pre-snapshot"); + SqliteOnlineBackupResult backup = await _sqliteStateService.CreateSqliteOnlineBackupAsync( + sourceStorage, + destination); + if (!backup.DatabasePresent) + { + throw new InvalidOperationException( + "The State DB disappeared during the Restore pre-snapshot."); + } + RestoreDigest copied = await DigestSqliteAsync( + destination, + snapshotDir, + plan.Storage, + cancellationToken); + if (!SameDigest(pre, copied)) + { + throw new InvalidOperationException( + "The Restore pre-snapshot SQLite digest did not verify."); + } + } + } + else + { + pre = await DigestFileAsync(sourceTarget.TargetPath, cancellationToken); + if (pre.Present) + { + snapshotPath = SnapshotRelativePath(sourceTarget); + string destination = Path.Combine(snapshotDir, snapshotPath); + if (!await AtomicFile.CopyAsync( + sourceTarget.TargetPath, + destination, + overwrite: false, + cancellationToken)) + { + throw new InvalidOperationException( + "A Restore target disappeared during pre-snapshot copy."); + } + RestoreDigest copied = await DigestFileAsync(destination, cancellationToken); + if (!SameDigest(pre, copied)) + { + throw new InvalidOperationException( + "A Restore pre-snapshot file did not match its source digest."); + } + } + } + await InvokeFaultAsync( + "after_restore_pre_snapshot_target_before_hash", + sourceTarget.TargetPath, + targets.Count); + RestoreDigest expectedPost = await ExpectedPostDigestAsync( + sourceTarget, + pre, + snapshotDir, + plan.Storage, + cancellationToken); + targets.Add(new RestoreJournalTarget( + id, + sourceTarget.Kind, + Path.GetFullPath(sourceTarget.TargetPath), + pre, + expectedPost, + snapshotPath?.Replace(Path.DirectorySeparatorChar, '/'), + snapshotEntryIndex)); + } + + DateTimeOffset createdAt = DateTimeOffset.UtcNow; + SessionBackupManifest sessionManifest = new() + { + Version = 2, + Namespace = AppConstants.BackupNamespace, + CodexHome = plan.Storage.CodexHome, + TargetProvider = plan.Metadata.TargetProvider, + CreatedAt = createdAt, + Files = rolloutEntries + }; + await AtomicFile.WriteAllTextAsync( + Path.Combine(snapshotDir, "session-meta-backup.json"), + JsonSerializer.Serialize(sessionManifest, JsonOptions), + cancellationToken); + + RestoreStorageIdentity storage = new( + Path.GetFullPath(plan.Storage.CodexHome), + codexHomePhysical, + Path.GetFullPath(plan.Storage.SqliteHome), + stateDbResource?.ResourceKey, + plan.StateDbTargetPath is null ? null : Path.GetFullPath(plan.StateDbTargetPath)); + RestoreSnapshotManifestFile manifest = new() + { + SchemaVersion = 2, + ProtocolVersion = 2, + OperationKind = "restore", + OperationId = operationId, + CreatedAt = createdAt, + SourceBackup = sourceBackup, + PreRestoreSnapshot = new RestoreSnapshotLocator(backupId, Path.GetFullPath(snapshotDir)), + Storage = storage, + RequiredTargetKinds = targets + .Select(static target => target.Kind) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(), + ResolvesOperationIds = resolvesOperationIds + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(), + Targets = targets + }; + string manifestText = JsonSerializer.Serialize(manifest, JsonOptions) + "\n"; + string manifestSha256 = Sha256Base64Url(Encoding.UTF8.GetBytes(manifestText)); + await AtomicFile.WriteAllTextAsync( + Path.Combine(snapshotDir, SnapshotManifestFileName), + manifestText, + cancellationToken); + await InvokeFaultAsync( + "after_restore_pre_snapshot_manifest_before_prepared", + null, + targets.Count); + + Dictionary globalStateFiles = new(StringComparer.Ordinal) + { + [AppConstants.GlobalStateFileBasename] = File.Exists( + Path.Combine(snapshotDir, AppConstants.GlobalStateFileBasename)), + [AppConstants.GlobalStateBackupFileBasename] = File.Exists( + Path.Combine(snapshotDir, AppConstants.GlobalStateBackupFileBasename)) + }; + Dictionary metadata = new() + { + ["version"] = 2, + ["namespace"] = AppConstants.BackupNamespace, + ["backupKind"] = "restore-pre-snapshot", + ["restoreOperationId"] = operationId, + ["codexHome"] = plan.Storage.CodexHome, + ["sqliteHome"] = plan.StateDbTargetPath is null + ? plan.Storage.SqliteHome + : Path.GetDirectoryName(plan.StateDbTargetPath), + ["targetProvider"] = plan.Metadata.TargetProvider, + ["createdAt"] = createdAt, + ["dbFiles"] = Array.Empty(), + ["sqliteDbFiles"] = targets.Any(static target => target.Kind == "sqlite" && target.Pre.Present) + ? new[] { AppConstants.DbFileBasename } + : Array.Empty(), + ["changedSessionFiles"] = rolloutEntries.Count, + ["globalStateFiles"] = globalStateFiles, + ["restoreSnapshotManifestSha256"] = manifestSha256 + }; + await AtomicFile.WriteAllTextAsync( + Path.Combine(snapshotDir, "metadata.json"), + JsonSerializer.Serialize(metadata, JsonOptions), + cancellationToken); + return new RestorePreSnapshot( + backupId, + Path.GetFullPath(snapshotDir), + manifestSha256, + manifestSha256, + manifest); + } + catch + { + TryDeleteDirectory(snapshotDir); + throw; + } + } + + private async Task AcknowledgeCommittedAsync( + RestoreJournalInfo journal, + CodexStorageLayout storage, + StateDbLockResource? stateDbResource, + CancellationToken cancellationToken) + { + if (journal.InvalidTail + || journal.State != "committed-pending-ack" + || journal.Prepared is null) + { + throw new InvalidOperationException( + "Restore commit acknowledgement evidence is incomplete."); + } + RestoreSnapshotManifestFile manifest = await ReadVerifiedSnapshotAsync( + journal, + cancellationToken); + ValidateManifestTargetBoundaries(journal, manifest, storage); + if (manifest.RequiredTargetKinds.Contains("sqlite", StringComparer.Ordinal)) + { + if (stateDbResource is null + || manifest.Storage.StateDbResourceKey != stateDbResource.ResourceKey + || string.IsNullOrWhiteSpace(manifest.Storage.TargetStateDbPath)) + { + throw new InvalidOperationException( + "Restore State DB identity changed before commit acknowledgement."); + } + StateDbLockResource current = await StateDbLockResource.ResolveAsync( + manifest.Storage.TargetStateDbPath, + cancellationToken); + if (current.ResourceKey != stateDbResource.ResourceKey) + { + throw new InvalidOperationException( + "Restore State DB physical identity changed before commit acknowledgement."); + } + } + string verifiedManifest = await VerifyManifestTargetsAsync( + manifest, + expectedPre: false, + journal.SnapshotDir, + storage, + cancellationToken); + RestoreJournalEvent? committedEvent = journal.Events + .LastOrDefault(static item => item.State == "committed-pending-ack"); + if (committedEvent?.PostManifestSha256 != verifiedManifest) + { + throw new InvalidOperationException( + "Restore post-commit manifest acknowledgement failed."); + } + + string physicalSourceBackupDir = ResolveStablePhysicalDirectory( + journal.Prepared.SourceBackup.BackupDir); + await FileTransactionJournal.MarkBackupRolledBackAsync( + physicalSourceBackupDir, + storage.CodexHome, + ReadSourceTargetProvider(physicalSourceBackupDir)); + await InvokeFaultAsync( + "after_restore_source_journal_ack_before_completed", + null, + manifest.Targets.Count); + RestoreJournal writer = RestoreJournal.Reopen(journal); + await writer.CompletedAsync(CancellationToken.None); + RestoreJournalInfo completed = await RestoreJournalService.ReadInfoAsync( + writer.FilePath, + CancellationToken.None); + if (completed.InvalidTail || completed.State != "completed") + { + throw new InvalidOperationException( + "Restore completed acknowledgement did not persist."); + } + try + { + await _backupService.RefreshMetadataInventoryAsync( + journal.Prepared.PreRestoreSnapshot.BackupDir); + } + catch + { + // Inventory is bookkeeping; the verified Restore is authoritative. + } + return completed; + } + + private async Task CompensateAsync( + RestoreJournalInfo journal, + RestoreJournal writer, + CodexStorageLayout storage, + StateDbLockResource? stateDbResource, + bool mutateTargets, + CancellationToken cancellationToken) + { + RestoreSnapshotManifestFile manifest = await ReadVerifiedSnapshotAsync(journal, cancellationToken); + ValidateManifestTargetBoundaries(journal, manifest, storage); + foreach (RestoreJournalTarget target in manifest.Targets.Reverse()) + { + if (target.Kind == "sqlite") + { + if (stateDbResource is null + || manifest.Storage.StateDbResourceKey != stateDbResource.ResourceKey) + { + throw new InvalidOperationException( + "Restore compensation has no verified State DB lock identity."); + } + StateDbLockResource current = await StateDbLockResource.ResolveAsync( + target.TargetPath, + cancellationToken); + if (current.ResourceKey != stateDbResource.ResourceKey) + { + throw new InvalidOperationException( + "Restore State DB physical identity changed before compensation."); + } + } + await InvokeFaultAsync( + "after_restore_rollback_pending_before_target", + target.TargetPath, + 0); + ValidatePhysicalTargetBoundary( + target.Kind, + target.TargetPath, + manifest.Storage, + storage); + if (mutateTargets) + { + await RestoreTargetFromSnapshotAsync(target, manifest, storage, cancellationToken); + } + RestoreDigest actual = await DigestTargetAsync( + target, + journal.SnapshotDir, + storage, + cancellationToken); + if (!SameDigest(actual, target.Pre)) + { + throw new InvalidOperationException( + $"Restore compensation digest failed for {target.Kind}."); + } + await writer.TargetCompensatedAsync(target.Id, actual.Digest, CancellationToken.None); + await InvokeFaultAsync( + "after_restore_compensation_verify_before_next", + target.TargetPath, + 0); + } + _ = await VerifyManifestTargetsAsync( + manifest, + expectedPre: true, + journal.SnapshotDir, + storage, + cancellationToken); + } + + private async Task RestoreTargetFromSnapshotAsync( + RestoreJournalTarget target, + RestoreSnapshotManifestFile manifest, + CodexStorageLayout storage, + CancellationToken cancellationToken) + { + ValidatePhysicalTargetBoundary( + target.Kind, + target.TargetPath, + manifest.Storage, + storage); + string snapshotDir = Path.GetFullPath(manifest.PreRestoreSnapshot.BackupDir); + if (target.Kind == "rollout") + { + SessionBackupManifest sessionManifest = JsonSerializer.Deserialize( + await File.ReadAllTextAsync( + Path.Combine(snapshotDir, "session-meta-backup.json"), + cancellationToken), + JsonOptions) ?? throw new InvalidOperationException( + "Restore snapshot session manifest is invalid."); + int index = target.SnapshotEntryIndex + ?? throw new InvalidOperationException("Restore snapshot rollout index is missing."); + if (index < 0 || index >= sessionManifest.Files.Count) + { + throw new InvalidOperationException("Restore snapshot rollout entry is missing."); + } + SessionBackupManifestEntry entry = sessionManifest.Files[index]; + if (!PathsEqual(entry.Path, target.TargetPath)) + { + throw new InvalidOperationException("Restore snapshot rollout entry is mismatched."); + } + ValidatePhysicalTargetBoundary( + target.Kind, + target.TargetPath, + manifest.Storage, + storage); + await _sessionRolloutService.RestoreSessionChangesAsync([entry]); + return; + } + if (target.Kind == "sqlite") + { + if (target.Pre.Present) + { + await _sqliteStateService.RestoreSqliteOnlineBackupAsync( + SafeSnapshotPath(snapshotDir, target.SnapshotPath), + target.TargetPath); + } + else + { + if (File.Exists(target.TargetPath + "-wal") || File.Exists(target.TargetPath + "-shm")) + { + throw new InvalidOperationException( + "Cannot remove a newly created State DB while SQLite sidecars are present."); + } + if (File.Exists(target.TargetPath)) + { + File.Delete(target.TargetPath); + } + } + return; + } + if (target.Pre.Present) + { + await AtomicFile.CopyAsync( + SafeSnapshotPath(snapshotDir, target.SnapshotPath), + target.TargetPath, + overwrite: true, + cancellationToken); + } + else if (File.Exists(target.TargetPath)) + { + File.Delete(target.TargetPath); + } + } + + private static async Task ReadVerifiedSnapshotAsync( + RestoreJournalInfo journal, + CancellationToken cancellationToken) + { + RestoreJournalPrepared prepared = journal.Prepared + ?? throw new InvalidOperationException("Restore journal prepared evidence is missing."); + if (!PathsEqual(journal.SnapshotDir, prepared.PreRestoreSnapshot.BackupDir)) + { + throw new InvalidOperationException("Restore snapshot directory does not match its journal."); + } + string manifestPath = Path.Combine( + prepared.PreRestoreSnapshot.BackupDir, + SnapshotManifestFileName); + string text = await File.ReadAllTextAsync(manifestPath, cancellationToken); + string digest = Sha256Base64Url(Encoding.UTF8.GetBytes(text)); + if (digest != prepared.PreRestoreSnapshot.ManifestSha256) + { + throw new InvalidOperationException("Restore snapshot manifest verification failed."); + } + RestoreSnapshotManifestFile manifest = JsonSerializer.Deserialize( + text, + JsonOptions) ?? throw new InvalidOperationException( + "Restore snapshot manifest is invalid."); + if (manifest.SchemaVersion != 2 + || manifest.ProtocolVersion != 2 + || manifest.OperationKind != "restore" + || manifest.OperationId != journal.OperationId + || manifest.PreRestoreSnapshot.BackupId != prepared.PreRestoreSnapshot.BackupId + || !PathsEqual(manifest.PreRestoreSnapshot.BackupDir, journal.SnapshotDir) + || !ManifestMatchesPrepared(manifest, prepared)) + { + throw new InvalidOperationException("Restore snapshot identity verification failed."); + } + return manifest; + } + + private static bool ManifestMatchesPrepared( + RestoreSnapshotManifestFile manifest, + RestoreJournalPrepared prepared) + { + if (manifest.SourceBackup != prepared.SourceBackup + || manifest.Storage != prepared.Storage + || !manifest.RequiredTargetKinds.SequenceEqual(prepared.RequiredTargetKinds) + || !manifest.ResolvesOperationIds.SequenceEqual(prepared.ResolvesOperationIds) + || manifest.Targets.Count != prepared.Targets.Count) + { + return false; + } + return manifest.Targets.Zip(prepared.Targets).All(pair => pair.First == pair.Second); + } + + internal static string ResolveStablePhysicalDirectory(string directory) + { + try + { + string lexical = Path.GetFullPath(directory); + string first = StateDbLockResource.ResolveExistingPhysicalPath(lexical, directory: true); + string second = StateDbLockResource.ResolveExistingPhysicalPath(lexical, directory: true); + if (!PathsEqual(first, second)) + { + throw new InvalidOperationException( + "Restore physical directory identity changed while it was resolved."); + } + return Path.GetFullPath(first); + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception error) when (error is IOException + or UnauthorizedAccessException + or System.ComponentModel.Win32Exception + or ArgumentException + or NotSupportedException + or System.Security.SecurityException) + { + throw new InvalidOperationException( + "A Restore physical directory identity cannot be verified.", + error); + } + } + + private static string ValidateRestoreHomePhysicalIdentity( + RestoreStorageIdentity manifestStorage, + CodexStorageLayout runtimeStorage) + { + if (!Path.IsPathFullyQualified(manifestStorage.CodexHome) + || !Path.IsPathFullyQualified(manifestStorage.CodexHomePhysical)) + { + throw new InvalidOperationException( + "Restore Codex Home physical identity evidence is missing."); + } + string manifestPhysical = ResolveStablePhysicalDirectory(manifestStorage.CodexHome); + string runtimePhysical = ResolveStablePhysicalDirectory(runtimeStorage.CodexHome); + if (!PathsEqual(manifestPhysical, manifestStorage.CodexHomePhysical) + || !PathsEqual(runtimePhysical, manifestStorage.CodexHomePhysical)) + { + throw new InvalidOperationException( + "Restore Codex Home physical identity changed."); + } + return manifestPhysical; + } + + internal static bool JournalMatchesCurrentPhysicalHome( + RestoreJournalInfo journal, + CodexStorageLayout runtimeStorage) + { + if (journal.Prepared is null) + { + return false; + } + try + { + _ = ValidateRestoreHomePhysicalIdentity(journal.Prepared.Storage, runtimeStorage); + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + + internal static bool JournalMatchesSource( + RestoreBackupIdentity? preparedSource, + RestoreBackupIdentity sourceBackup, + bool ignoreRevision = false) + { + if (preparedSource is null || (!ignoreRevision && preparedSource.Revision != sourceBackup.Revision)) + { + return false; + } + try + { + string preparedPhysical = ResolveStablePhysicalDirectory(preparedSource.BackupDir); + string sourcePhysical = ResolveStablePhysicalDirectory(sourceBackup.BackupDir); + return PathsEqual(preparedPhysical, sourcePhysical); + } + catch + { + return false; + } + } + + private static void ValidatePhysicalTargetBoundary( + string kind, + string targetPath, + RestoreStorageIdentity manifestStorage, + CodexStorageLayout runtimeStorage) + { + if (kind == "sqlite") + { + return; + } + string physicalHome = ValidateRestoreHomePhysicalIdentity(manifestStorage, runtimeStorage); + string lexicalTarget = Path.GetFullPath(targetPath); + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string[] segments; + if (kind == "config" || kind == "globalState") + { + string fileName = Path.GetFileName(lexicalTarget); + bool validFileName = kind == "config" + ? string.Equals(fileName, "config.toml", comparison) + : string.Equals(fileName, AppConstants.GlobalStateFileBasename, comparison) + || string.Equals(fileName, AppConstants.GlobalStateBackupFileBasename, comparison); + string parentPhysical = ResolveStablePhysicalDirectory( + Path.GetDirectoryName(lexicalTarget) + ?? throw new InvalidOperationException("Restore target parent is missing.")); + if (!validFileName || !PathsEqual(parentPhysical, physicalHome)) + { + throw new InvalidOperationException( + "Restore target is outside its kind-specific storage boundary."); + } + segments = [fileName]; + } + else if (kind == "rollout") + { + if (!Regex.IsMatch( + Path.GetFileName(lexicalTarget), + "^rollout-.*\\.jsonl$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + throw new InvalidOperationException( + "Restore target is outside its kind-specific storage boundary."); + } + string? rawRoot = Path.GetDirectoryName(lexicalTarget); + while (rawRoot is not null + && !string.Equals(Path.GetFileName(rawRoot), "sessions", comparison) + && !string.Equals(Path.GetFileName(rawRoot), "archived_sessions", comparison)) + { + string? parent = Path.GetDirectoryName(rawRoot); + if (parent is null || PathsEqual(parent, rawRoot)) + { + rawRoot = null; + break; + } + rawRoot = parent; + } + if (rawRoot is null + || !PathsEqual( + ResolveStablePhysicalDirectory( + Path.GetDirectoryName(rawRoot) + ?? throw new InvalidOperationException("Restore rollout root parent is missing.")), + physicalHome)) + { + throw new InvalidOperationException( + "Restore target is outside its kind-specific storage boundary."); + } + string nested = Path.GetRelativePath(rawRoot, lexicalTarget); + if (string.IsNullOrWhiteSpace(nested) + || nested == ".." + || nested.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(nested)) + { + throw new InvalidOperationException( + "Restore target is outside its kind-specific storage boundary."); + } + segments = [ + Path.GetFileName(rawRoot), + .. nested.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries) + ]; + } + else + { + throw new InvalidOperationException( + "Restore target is outside its kind-specific storage boundary."); + } + + string current = physicalHome; + for (int index = 0; index < segments.Length; index++) + { + current = Path.Combine(current, segments[index]); + FileAttributes attributes; + try + { + attributes = File.GetAttributes(current); + } + catch (Exception error) when (error is FileNotFoundException or DirectoryNotFoundException) + { + bool isLast = index == segments.Length - 1; + if (isLast && (kind == "config" || kind == "globalState")) + { + return; + } + throw new InvalidOperationException( + "Restore target physical boundary is incomplete.", + error); + } + catch (Exception error) when (error is IOException + or UnauthorizedAccessException + or System.Security.SecurityException) + { + throw new InvalidOperationException( + "Restore target physical boundary cannot be verified.", + error); + } + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + "Restore target traverses a reparse point."); + } + bool isDirectory = (attributes & FileAttributes.Directory) != 0; + bool isLastSegment = index == segments.Length - 1; + if ((!isLastSegment && !isDirectory) || (isLastSegment && isDirectory)) + { + throw new InvalidOperationException( + "Restore target physical boundary has an unexpected entry type."); + } + } + } + + private static void ValidateManifestTargetBoundaries( + RestoreJournalInfo journal, + RestoreSnapshotManifestFile manifest, + CodexStorageLayout storage) + { + if (!PathsEqual(manifest.PreRestoreSnapshot.BackupDir, journal.SnapshotDir)) + { + throw new InvalidOperationException("Restore storage identity changed."); + } + ValidateRestoreHomePhysicalIdentity(manifest.Storage, storage); + foreach (RestoreJournalTarget target in manifest.Targets) + { + switch (target.Kind) + { + case "config": + case "globalState": + case "rollout": + ValidatePhysicalTargetBoundary( + target.Kind, + target.TargetPath, + manifest.Storage, + storage); + break; + case "sqlite" when manifest.Storage.TargetStateDbPath is not null + && PathsEqual(target.TargetPath, manifest.Storage.TargetStateDbPath): + break; + default: + throw new InvalidOperationException( + "Restore snapshot contains a target outside the declared storage boundary."); + } + } + } + + private async Task VerifyManifestTargetsAsync( + RestoreSnapshotManifestFile manifest, + bool expectedPre, + string scratchDir, + CodexStorageLayout storage, + CancellationToken cancellationToken) + { + List<(string Id, string Digest)> values = []; + foreach (RestoreJournalTarget target in manifest.Targets) + { + ValidatePhysicalTargetBoundary( + target.Kind, + target.TargetPath, + manifest.Storage, + storage); + RestoreDigest actual = await DigestTargetAsync( + target, + scratchDir, + storage, + cancellationToken); + RestoreDigest expected = expectedPre ? target.Pre : target.ExpectedPost; + if (!SameDigest(actual, expected)) + { + throw new InvalidOperationException( + $"A Restore target {target.Kind} digest does not match durable evidence."); + } + values.Add((target.Id, actual.Digest)); + } + values.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id)); + using MemoryStream json = new(); + using (Utf8JsonWriter writer = new(json, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + writer.WriteStartArray(); + foreach ((string id, string digest) in values) + { + writer.WriteStartObject(); + writer.WriteString("digest", digest); + writer.WriteString("id", id); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + return Sha256Base64Url(json.ToArray()); + } + + private async Task ExpectedPostDigestAsync( + RestoreBackupTarget target, + RestoreDigest pre, + string scratchDir, + CodexStorageLayout storage, + CancellationToken cancellationToken) + { + if (target.Kind == "rollout") + { + return DigestRolloutEntry(target.SessionEntry + ?? throw new InvalidOperationException("Restore rollout source entry is missing.")); + } + if (target.Kind == "sqlite") + { + return await DigestSqliteAsync( + target.SourcePath ?? throw new InvalidOperationException("Restore SQLite source is missing."), + scratchDir, + storage, + cancellationToken); + } + if (target.Kind == "globalState" && target.SourceAction == "delete") + { + return AbsentDigest(); + } + if (target.Kind == "globalState" && target.SourceAction == "preserve") + { + return pre; + } + return await DigestFileAsync( + target.SourcePath ?? throw new InvalidOperationException("Restore file source is missing."), + cancellationToken); + } + + private async Task DigestTargetAsync( + RestoreJournalTarget target, + string scratchDir, + CodexStorageLayout storage, + CancellationToken cancellationToken) => target.Kind switch + { + "rollout" => DigestRolloutEntry( + await CaptureRolloutEntryAsync(target.TargetPath, cancellationToken)), + "sqlite" => await DigestSqliteAsync( + target.TargetPath, + scratchDir, + storage, + cancellationToken), + _ => await DigestFileAsync(target.TargetPath, cancellationToken) + }; + + private async Task DigestSqliteAsync( + string sqlitePath, + string scratchDir, + CodexStorageLayout storage, + CancellationToken cancellationToken) + { + string fullPath = Path.GetFullPath(sqlitePath); + if (!File.Exists(fullPath)) + { + return AbsentDigest(); + } + Directory.CreateDirectory(scratchDir); + string scratchPath = Path.Combine( + scratchDir, + $".sqlite-digest-{Guid.NewGuid():N}.sqlite"); + try + { + SqliteOnlineBackupResult backup = await _sqliteStateService.CreateSqliteOnlineBackupAsync( + StorageForDatabase(storage, fullPath, "restore-v2-digest"), + scratchPath); + if (!backup.DatabasePresent) + { + throw new InvalidOperationException( + "The State DB disappeared while its Restore digest was captured."); + } + byte[] bytes = await File.ReadAllBytesAsync(scratchPath, cancellationToken); + if (bytes.Length < 100 + || !bytes.AsSpan(0, 16).SequenceEqual("SQLite format 3\0"u8)) + { + throw new InvalidOperationException("Restore SQLite digest source has an invalid header."); + } + // SQLite's online-backup API preserves logical pages but keeps the + // destination's rollback/WAL header mode and may rewrite the + // volatile file-change counter pair. None describes logical DB + // content, and Restore intentionally preserves the live mode. + bytes.AsSpan(18, 2).Clear(); + bytes.AsSpan(24, 4).Clear(); + bytes.AsSpan(92, 4).Clear(); + bytes.AsSpan(96, 4).Clear(); + return new RestoreDigest( + true, + "sha256-sqlite-online-backup", + Sha256Base64Url(bytes), + bytes.LongLength); + } + finally + { + TryDeleteFile(scratchPath); + TryDeleteFile(scratchPath + "-wal"); + TryDeleteFile(scratchPath + "-shm"); + } + } + + private static CodexStorageLayout StorageForDatabase( + CodexStorageLayout storage, + string databasePath, + string source) + { + string fullPath = Path.GetFullPath(databasePath); + return storage with + { + SqliteHome = Path.GetDirectoryName(fullPath)!, + StateDbLocation = new StateDbLocation(fullPath, Path.GetFileName(fullPath), source), + StateDbCandidates = [new StateDbLocation(fullPath, Path.GetFileName(fullPath), source)], + AllowLegacyRootFallback = false + }; + } + + private static async Task DigestFileAsync( + string filePath, + CancellationToken cancellationToken) + { + string fullPath = Path.GetFullPath(filePath); + for (int attempt = 0; attempt < 2; attempt++) + { + FileInfo before = new(fullPath); + if (!before.Exists) + { + return AbsentDigest(); + } + if ((before.Attributes & FileAttributes.Directory) != 0) + { + throw new InvalidOperationException("A Restore target is not a regular file."); + } + long length = before.Length; + long lastWriteTicks = before.LastWriteTimeUtc.Ticks; + byte[] hash; + await using (FileStream stream = new( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + hash = await SHA256.HashDataAsync(stream, cancellationToken); + } + FileInfo after = new(fullPath); + after.Refresh(); + if (after.Exists + && after.Length == length + && after.LastWriteTimeUtc.Ticks == lastWriteTicks) + { + return new RestoreDigest( + true, + "sha256-file", + Base64Url(hash), + after.Length); + } + } + throw new InvalidOperationException( + "A Restore target changed while its digest was captured."); + } + + private static async Task CaptureRolloutEntryAsync( + string filePath, + CancellationToken cancellationToken) + { + string fullPath = Path.GetFullPath(filePath); + for (int attempt = 0; attempt < 2; attempt++) + { + FileInfo before = new(fullPath); + if (!before.Exists) + { + throw new FileNotFoundException("Restore rollout target is missing.", fullPath); + } + long length = before.Length; + long lastWriteTicks = before.LastWriteTimeUtc.Ticks; + (string firstLine, string separator) = await ReadFirstLineAsync(fullPath, cancellationToken); + using (JsonDocument first = JsonDocument.Parse(firstLine)) + { + if (!first.RootElement.TryGetProperty("type", out JsonElement type) + || type.GetString() != "session_meta") + { + throw new InvalidOperationException( + "Rollout does not start with a valid session_meta record."); + } + } + List models = []; + using (StreamReader reader = new( + new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan), + new UTF8Encoding(false, true), + detectEncodingFromByteOrderMarks: false)) + { + _ = await reader.ReadLineAsync(cancellationToken); + int lineIndex = 0; + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + lineIndex++; + if (!line.Contains("\"turn_context\"", StringComparison.Ordinal)) + { + continue; + } + if (!TurnContextTypeRegex.IsMatch(line)) + { + continue; + } + string[] values = ModelFieldRegex.Matches(line) + .Select(match => JsonSerializer.Deserialize(match.Groups["value"].Value)) + .Where(static value => value is not null) + .Cast() + .ToArray(); + if (values.Length > 0) + { + models.Add(new TurnContextModelBackup + { + LineIndex = lineIndex, + OriginalModel = values[0], + OriginalModels = values + }); + } + } + } + FileInfo after = new(fullPath); + after.Refresh(); + if (after.Exists + && after.Length == length + && after.LastWriteTimeUtc.Ticks == lastWriteTicks) + { + DateTimeOffset original = new(new DateTime(lastWriteTicks, DateTimeKind.Utc)); + return new SessionBackupManifestEntry + { + Path = fullPath, + OriginalFirstLine = firstLine, + OriginalSeparator = separator, + OriginalLastWriteTimeUtc = original.ToString( + "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", + System.Globalization.CultureInfo.InvariantCulture), + OriginalMtimeMs = original.ToUnixTimeMilliseconds(), + OriginalLastWriteTimeUtcTicks = lastWriteTicks, + ModelOnlyChange = false, + OriginalTurnContextModels = models + }; + } + } + throw new InvalidOperationException( + "Rollout changed while its recovery metadata was captured."); + } + + private static async Task<(string FirstLine, string Separator)> ReadFirstLineAsync( + string filePath, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using MemoryStream bytes = new(); + byte[] one = new byte[1]; + while (await stream.ReadAsync(one, cancellationToken) == 1) + { + if (one[0] == (byte)'\n') + { + byte[] value = bytes.ToArray(); + bool crlf = value.Length > 0 && value[^1] == (byte)'\r'; + int length = crlf ? value.Length - 1 : value.Length; + return (new UTF8Encoding(false, true).GetString(value, 0, length), crlf ? "\r\n" : "\n"); + } + bytes.WriteByte(one[0]); + if (bytes.Length > 16 * 1024 * 1024) + { + throw new InvalidOperationException("Rollout session_meta record is unreasonably large."); + } + } + return (new UTF8Encoding(false, true).GetString(bytes.ToArray()), "\n"); + } + + private static RestoreDigest DigestRolloutEntry(SessionBackupManifestEntry entry) + { + using MemoryStream json = new(); + using (Utf8JsonWriter writer = new(json, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + writer.WriteStartObject(); + writer.WriteString("originalFirstLine", entry.OriginalFirstLine); + writer.WriteString("originalSeparator", entry.OriginalSeparator ?? "\n"); + writer.WriteStartArray("originalTurnContextModels"); + foreach (TurnContextModelBackup model in entry.OriginalTurnContextModels) + { + writer.WriteStartObject(); + writer.WriteNumber("lineIndex", model.LineIndex); + writer.WriteString("originalModel", model.OriginalModel); + writer.WriteStartArray("originalModels"); + foreach (string value in model.OriginalModels) + { + writer.WriteStringValue(value); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + return new RestoreDigest( + true, + "sha256-rollout-metadata", + Sha256Base64Url(json.ToArray())); + } + + private static async Task CollectIdentityFilesAsync( + string root, + string current, + List<(string Path, string Sha256)> files, + CancellationToken cancellationToken) + { + foreach (FileSystemInfo entry in new DirectoryInfo(current) + .EnumerateFileSystemInfos() + .OrderBy(static item => item.Name, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + "A managed Restore source contains an unsupported linked entry."); + } + if ((entry.Attributes & FileAttributes.Directory) != 0) + { + await CollectIdentityFilesAsync(root, entry.FullName, files, cancellationToken); + } + else + { + RestoreDigest digest = await DigestFileAsync(entry.FullName, cancellationToken); + files.Add(( + Path.GetRelativePath(root, entry.FullName).Replace(Path.DirectorySeparatorChar, '/'), + digest.Digest)); + } + } + } + + private static string SnapshotRelativePath(RestoreBackupTarget target) => target.Kind switch + { + "config" => "config.toml", + "globalState" => Path.GetFileName(target.TargetPath), + "sqlite" => Path.Combine("db", "sqlite-home", AppConstants.DbFileBasename), + _ => throw new InvalidOperationException( + $"Restore target {target.Kind} does not use a file snapshot path.") + }; + + private static string SafeSnapshotPath(string snapshotDir, string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath)) + { + throw new InvalidOperationException("Restore snapshot path is missing or rooted."); + } + string root = Path.GetFullPath(snapshotDir); + string fullPath = Path.GetFullPath(Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar))); + string relative = Path.GetRelativePath(root, fullPath); + if (relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + { + throw new InvalidOperationException("Restore snapshot path escapes its managed directory."); + } + return fullPath; + } + + private static RestoreResult BuildResult( + RestoreBackupPlan plan, + string operationId, + string snapshotId, + string journalState, + IReadOnlyList resolvedOperationIds, + bool commitAcknowledgementRecovered) => new() + { + CodexHome = plan.Storage.CodexHome, + BackupDir = plan.BackupDirectory, + TargetProvider = plan.Metadata.TargetProvider, + CreatedAt = plan.Metadata.CreatedAt, + ChangedSessionFiles = plan.Metadata.ChangedSessionFiles, + RestoreVersion = 2, + RestoreOperationId = operationId, + PreRestoreSnapshotId = snapshotId, + RestoreJournalState = journalState, + CommitAcknowledgementRecovered = commitAcknowledgementRecovered, + ResolvedOperationIds = resolvedOperationIds + }; + + private static RestoreDigest AbsentDigest() => new( + false, + "absent", + Sha256Base64Url(Encoding.UTF8.GetBytes("absent"))); + + private static bool SameDigest(RestoreDigest left, RestoreDigest right) => + left.Present == right.Present + && left.DigestKind == right.DigestKind + && left.Digest == right.Digest; + + private static string TargetId(string kind, string targetPath) => + Sha256Base64Url(Encoding.UTF8.GetBytes( + kind + "\0" + ComparablePath(targetPath))); + + private static string TargetKey(string kind, string targetPath) => + kind + "\0" + ComparablePath(targetPath); + + private static string ComparablePath(string value) + { + string fullPath = Path.GetFullPath(value); + return OperatingSystem.IsWindows() ? fullPath.ToLowerInvariant() : fullPath; + } + + private static string Sha256Base64Url(byte[] bytes) => + Base64Url(SHA256.HashData(bytes)); + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static bool PathsEqual(string left, string right) => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private static string ReadSourceTargetProvider(string backupDir) + { + try + { + BackupMetadataFile? metadata = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(backupDir, "metadata.json")), + JsonOptions); + return metadata?.TargetProvider ?? AppConstants.DefaultProvider; + } + catch + { + return AppConstants.DefaultProvider; + } + } + + private static string ErrorCode(Exception error) => error switch + { + RecoveryRequiredException => "RECOVERY_REQUIRED", + OperationCanceledException => "CANCELLED", + SyncTransactionException transaction => transaction.Code, + _ => "RESTORE_FAILED" + }; + + private static RecoveryRequiredException RecoveryRequired( + string message, + RestorePreSnapshot snapshot, + RestoreBackupIdentity sourceBackup, + Exception cause) + { + RecoveryRequiredException error = new( + message + " " + cause.Message, + [snapshot.BackupDirectory, sourceBackup.BackupDir]); + return error; + } + + private static async Task SafeReadAsync( + string filePath, + RestoreJournalInfo fallback) + { + try + { + return await RestoreJournalService.ReadInfoAsync(filePath, CancellationToken.None); + } + catch + { + return fallback; + } + } + + private static async Task TryMarkRecoveryRequiredAsync( + RestoreJournalInfo journal, + string reasonCode) + { + try + { + if (!journal.InvalidTail + && journal.State != "recovery-required" + && !journal.Terminal) + { + await RestoreJournal.Reopen(journal).RecoveryRequiredAsync( + reasonCode, + CancellationToken.None); + } + } + catch + { + // Existing journal evidence remains the authoritative blocker. + } + } + + private async Task InvokeFaultAsync(string point, string? targetPath, int count) + { + if (FaultInjector is not null) + { + await FaultInjector(point, targetPath, count); + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // Cleanup must not hide the primary error. + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Scratch cleanup must not hide the primary result. + } + } +} diff --git a/desktop/CodexProviderSync.Core/SqliteStateService.cs b/desktop/CodexProviderSync.Core/SqliteStateService.cs index b83f694..7ac1c87 100644 --- a/desktop/CodexProviderSync.Core/SqliteStateService.cs +++ b/desktop/CodexProviderSync.Core/SqliteStateService.cs @@ -27,7 +27,7 @@ public sealed record SqliteOnlineBackupResult( public sealed class SqliteStateService { - private const int DefaultBusyTimeoutMs = 5000; + private const int DefaultBusyTimeoutMs = 0; private sealed record StateDbCandidateStats( StateDbLocation Location, @@ -291,8 +291,8 @@ public async Task AssertSqliteWritableAsync(CodexStorageLayout storage, in await connection.OpenAsync(); await SetBusyTimeoutAsync(connection, busyTimeoutMs); await ConfigureSqliteWriteDurabilityAsync(connection); - await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE"); - await ExecuteNonQueryAsync(connection, "ROLLBACK"); + await ExecuteTransactionControlAsync(connection, "BEGIN IMMEDIATE", busyTimeoutMs); + await ExecuteTransactionControlAsync(connection, "ROLLBACK", busyTimeoutMs); return true; } catch (Exception error) @@ -355,7 +355,7 @@ public async Task AssertSqliteWritableAsync(CodexStorageLayout storage, in await connection.OpenAsync(); await SetBusyTimeoutAsync(connection, busyTimeoutMs); await ConfigureSqliteWriteDurabilityAsync(connection); - await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE"); + await ExecuteTransactionControlAsync(connection, "BEGIN IMMEDIATE", busyTimeoutMs); transactionOpen = true; await using SqliteCommand command = connection.CreateCommand(); @@ -443,7 +443,7 @@ UPDATE threads // immediately before the attempt so it can conservatively restore // the bound backup on any acknowledgement failure. onCommitAttempt?.Invoke(result); - await ExecuteNonQueryAsync(connection, "COMMIT"); + await ExecuteTransactionControlAsync(connection, "COMMIT", busyTimeoutMs); transactionOpen = false; if (afterCommitBeforeAcknowledgement is not null) { @@ -457,7 +457,7 @@ UPDATE threads { try { - await ExecuteNonQueryAsync(connection, "ROLLBACK"); + await ExecuteTransactionControlAsync(connection, "ROLLBACK", busyTimeoutMs); } catch { @@ -820,10 +820,33 @@ private static long ExecuteScalarLong(SqliteConnection connection, string comman return value is null || value is DBNull ? 0 : Convert.ToInt64(value); } - private static async Task SetBusyTimeoutAsync(SqliteConnection connection, int? busyTimeoutMs) + private static Task SetBusyTimeoutAsync(SqliteConnection connection, int? busyTimeoutMs) { int timeout = busyTimeoutMs is >= 0 ? busyTimeoutMs.Value : DefaultBusyTimeoutMs; - await ExecuteNonQueryAsync(connection, $"PRAGMA busy_timeout = {timeout}"); + // Microsoft.Data.Sqlite retries SQLITE_BUSY in managed code according + // to CommandTimeout (30 seconds by default), independently of + // PRAGMA busy_timeout. Keep ordinary commands bounded, and use the + // native handle for transaction control so timeout=0 is truly + // fail-fast instead of meaning an infinite managed retry window. + connection.DefaultTimeout = Math.Max( + 1, + timeout / 1000 + (timeout % 1000 == 0 ? 0 : 1)); + int result = SQLitePCL.raw.sqlite3_busy_timeout(connection.Handle, timeout); + SqliteException.ThrowExceptionForRC(result, connection.Handle); + return Task.CompletedTask; + } + + private static Task ExecuteTransactionControlAsync( + SqliteConnection connection, + string commandText, + int? busyTimeoutMs) + { + int timeout = busyTimeoutMs is >= 0 ? busyTimeoutMs.Value : DefaultBusyTimeoutMs; + int timeoutResult = SQLitePCL.raw.sqlite3_busy_timeout(connection.Handle, timeout); + SqliteException.ThrowExceptionForRC(timeoutResult, connection.Handle); + int result = SQLitePCL.raw.sqlite3_exec(connection.Handle, commandText); + SqliteException.ThrowExceptionForRC(result, connection.Handle); + return Task.CompletedTask; } private static async Task ExecuteNonQueryAsync(SqliteConnection connection, string commandText) diff --git a/desktop/CodexProviderSync.Core/StateDbLockResource.cs b/desktop/CodexProviderSync.Core/StateDbLockResource.cs new file mode 100644 index 0000000..58261f7 --- /dev/null +++ b/desktop/CodexProviderSync.Core/StateDbLockResource.cs @@ -0,0 +1,228 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace CodexProviderSync.Core; + +public sealed record StateDbLockResource( + string Identity, + string ResourceKey, + string RealDbParent, + string StateDbPath, + string LockPath) +{ + private const string ErrorCodeDataKey = "codex-provider-sync/error-code"; + private const string LockScopeDataKey = "codex-provider-sync/lock-scope"; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + + public static Task ResolveAsync( + string stateDbTargetPath, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(stateDbTargetPath)) + { + throw Unverifiable("The State DB resource path is missing or invalid."); + } + + string lexicalPath = Path.GetFullPath(stateDbTargetPath); + if (!string.Equals( + Path.GetFileName(lexicalPath), + AppConstants.DbFileBasename, + StringComparison.OrdinalIgnoreCase)) + { + throw Unverifiable("The State DB resource filename is not canonical."); + } + + string lexicalParent = Path.GetDirectoryName(lexicalPath) + ?? throw Unverifiable("The State DB resource parent cannot be resolved."); + string realParent; + try + { + realParent = ResolveExistingPhysicalPath(lexicalParent, directory: true); + string verifiedParent = ResolveExistingPhysicalPath(lexicalParent, directory: true); + if (!PathEquals(realParent, verifiedParent)) + { + throw Unverifiable("The State DB physical parent changed while its identity was resolved."); + } + } + catch (UnauthorizedAccessException error) + { + throw PermissionDenied("Permission denied while resolving the State DB resource identity.", error); + } + catch (InvalidOperationException error) when (IsTyped(error)) + { + throw; + } + catch (Exception error) when (error is IOException or Win32Exception) + { + throw Unverifiable("The State DB physical parent identity cannot be verified.", error); + } + + string physicalFileName = AppConstants.DbFileBasename; + try + { + FileAttributes attributes = File.GetAttributes(lexicalPath); + if ((attributes & FileAttributes.Directory) != 0) + { + throw Unverifiable("The State DB target is not a regular file."); + } + string realFile = ResolveExistingPhysicalPath(lexicalPath, directory: false); + physicalFileName = Path.GetFileName(realFile); + realParent = Path.GetDirectoryName(realFile) + ?? throw Unverifiable("The State DB physical parent cannot be resolved."); + if (!string.Equals(physicalFileName, AppConstants.DbFileBasename, StringComparison.OrdinalIgnoreCase)) + { + throw Unverifiable("The State DB physical filename is not canonical."); + } + } + catch (FileNotFoundException) + { + // A missing database is valid for Restore only after its physical + // parent has been verified above. + } + catch (DirectoryNotFoundException error) + { + throw Unverifiable("The State DB physical parent identity cannot be verified.", error); + } + catch (UnauthorizedAccessException error) + { + throw PermissionDenied("Permission denied while resolving the State DB physical target.", error); + } + catch (InvalidOperationException error) when (IsTyped(error)) + { + throw; + } + catch (Exception error) when (error is IOException or Win32Exception) + { + throw Unverifiable("The State DB physical target identity cannot be verified.", error); + } + + string normalizedParent = NormalizeIdentityPart(Path.GetFullPath(realParent)); + string normalizedFileName = NormalizeIdentityPart(physicalFileName); + string identity = normalizedParent + "\0" + normalizedFileName; + string resourceKey = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant(); + string lockPath = Path.Combine( + Path.GetFullPath(realParent), + ".codex-provider-sync", + "locks", + resourceKey + ".lock"); + return Task.FromResult(new StateDbLockResource( + identity, + resourceKey, + Path.GetFullPath(realParent), + Path.Combine(Path.GetFullPath(realParent), physicalFileName), + lockPath)); + } + + private static string NormalizeIdentityPart(string value) => + OperatingSystem.IsWindows() ? value.ToLowerInvariant() : value; + + private static bool PathEquals(string left, string right) => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + internal static string ResolveExistingPhysicalPath(string path, bool directory) + { + if (OperatingSystem.IsWindows()) + { + using SafeFileHandle handle = CreateFileW( + path, + 0, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + directory ? FileFlagBackupSemantics : 0, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + StringBuilder buffer = new(32768); + uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0); + if (length == 0 || length >= buffer.Capacity) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + return NormalizeWindowsDevicePath(buffer.ToString()); + } + + IntPtr resolved = realpath(path, IntPtr.Zero); + if (resolved == IntPtr.Zero) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + try + { + return Marshal.PtrToStringUTF8(resolved) + ?? throw new IOException("realpath returned an empty path."); + } + finally + { + free(resolved); + } + } + + private static string NormalizeWindowsDevicePath(string path) + { + const string uncPrefix = @"\\?\UNC\"; + const string devicePrefix = @"\\?\"; + if (path.StartsWith(uncPrefix, StringComparison.OrdinalIgnoreCase)) + { + return @"\\" + path[uncPrefix.Length..]; + } + return path.StartsWith(devicePrefix, StringComparison.OrdinalIgnoreCase) + ? path[devicePrefix.Length..] + : path; + } + + private static InvalidOperationException Unverifiable(string message, Exception? inner = null) + { + InvalidOperationException error = new(message, inner); + error.Data[ErrorCodeDataKey] = LockService.LockUnverifiableErrorCode; + error.Data[LockScopeDataKey] = "state-db"; + return error; + } + + private static InvalidOperationException PermissionDenied(string message, Exception inner) + { + InvalidOperationException error = new(message, inner); + error.Data[ErrorCodeDataKey] = "PERMISSION_DENIED"; + error.Data[LockScopeDataKey] = "state-db"; + return error; + } + + private static bool IsTyped(Exception error) => + error.Data.Contains(ErrorCodeDataKey); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle file, + StringBuilder filePath, + uint filePathLength, + uint flags); + + [DllImport("libc", SetLastError = true)] + private static extern IntPtr realpath(string path, IntPtr resolvedPath); + + [DllImport("libc")] + private static extern void free(IntPtr pointer); +} diff --git a/desktop/CodexProviderSync.Core/TextFormatter.cs b/desktop/CodexProviderSync.Core/TextFormatter.cs index b58da7c..36a4e1f 100644 --- a/desktop/CodexProviderSync.Core/TextFormatter.cs +++ b/desktop/CodexProviderSync.Core/TextFormatter.cs @@ -178,6 +178,15 @@ private static string FormatStatusEnglish(StatusSnapshot status) } lines.Insert(6, "Recovery required:"); } + if (status.OperationInProgress is { } operation) + { + lines.Insert( + 6, + $"Status read blocked; showing the last complete snapshot ({status.StatusReadBlocked?.Reason ?? "write-operation"})."); + lines.Insert( + 6, + $"Operation in progress: {operation.Operation} (scope: {operation.BusyScope}; lock: {operation.LockState})"); + } AppendSqliteStatus(lines, status, chinese: false); AppendProjectVisibility(lines, status, chinese: false); @@ -230,6 +239,15 @@ private static string FormatStatusChinese(StatusSnapshot status) } lines.Insert(6, "需要恢复:"); } + if (status.OperationInProgress is { } operation) + { + lines.Insert( + 6, + $"状态读取已阻断;当前显示最后一次完整快照({status.StatusReadBlocked?.Reason ?? "write-operation"})。"); + lines.Insert( + 6, + $"进行中的操作: {operation.Operation}(范围: {operation.BusyScope};锁状态: {operation.LockState})"); + } AppendSqliteStatus(lines, status, chinese: true); AppendProjectVisibility(lines, status, chinese: true); diff --git a/desktop/CodexProviderSync.Core/TransactionJournalService.cs b/desktop/CodexProviderSync.Core/TransactionJournalService.cs index 2c4e608..258b13a 100644 --- a/desktop/CodexProviderSync.Core/TransactionJournalService.cs +++ b/desktop/CodexProviderSync.Core/TransactionJournalService.cs @@ -560,9 +560,19 @@ private PendingTransactionInfo AdvanceJournalState( bool terminal = nextState is "committed" or "rolledBack"; string journalPath = current?.JournalPath ?? _filePath; + string? declaredBackupDir = current?.DeclaredBackupDir; + if (declaredBackupDir is null && nextState == "prepared") + { + string declared = ReadRequiredDetail(details, "backupDir"); + declaredBackupDir = Path.IsPathFullyQualified(declared) + ? Path.GetFullPath(declared) + : throw new InvalidOperationException( + "Transaction journal detail backupDir must be an absolute path."); + } return new PendingTransactionInfo( journalPath, current?.BackupDir ?? Path.GetDirectoryName(_filePath)!, + declaredBackupDir, current?.OperationId ?? _operationId, nextSequence, nextState, @@ -600,6 +610,12 @@ private static void ValidateAppendTransition( { throw new InvalidOperationException("A transaction journal must begin with prepared."); } + string declaredBackupDir = ReadRequiredDetail(details, "backupDir"); + if (!Path.IsPathFullyQualified(declaredBackupDir)) + { + throw new InvalidOperationException( + "Transaction journal detail backupDir must be an absolute path."); + } return; } @@ -722,15 +738,23 @@ internal static async Task> FindPendingAsy internal static async Task AssertNoPendingAsync(string codexHome) { IReadOnlyList pending = await FindPendingAsync(codexHome); - if (pending.Count == 0) + IReadOnlyList pendingRestores = + await RestoreJournalService.FindBlockingAsync(codexHome); + if (pending.Count == 0 && pendingRestores.Count == 0) { return; } - string backups = string.Join(", ", pending.Select(static item => item.BackupDir)); + string[] pendingDirectories = pending + .Select(static item => item.BackupDir) + .Concat(pendingRestores.Select(static item => item.SnapshotDir)) + .Select(Path.GetFullPath) + .Distinct(PathComparer) + .ToArray(); + string backups = string.Join(", ", pendingDirectories); throw new RecoveryRequiredException( $"An unfinished provider-sync transaction requires recovery before another write. Restore the bound backup, then retry. Backup(s): {backups}", - pending); + pendingDirectories); } internal static async Task MarkBackupRolledBackAsync( @@ -841,6 +865,7 @@ private static JournalReadResult ParseJournal(string journalPath, byte[] journal bool invalidTail = false; bool sawRecord = false; bool sawTerminal = false; + string? declaredBackupDir = null; List validLines = []; List potentialTargets = []; Dictionary affectedTargets = new(PathComparer); @@ -905,6 +930,16 @@ private static JournalReadResult ParseJournal(string journalPath, byte[] journal } operationId = recordOperationId; + string? preparedBackupDir = root.TryGetProperty("backupDir", out JsonElement backupDirValue) + ? backupDirValue.GetString() + : null; + if (string.IsNullOrWhiteSpace(preparedBackupDir) + || !Path.IsPathFullyQualified(preparedBackupDir)) + { + throw new InvalidOperationException( + "Prepared journal record must declare an absolute backupDir."); + } + declaredBackupDir = Path.GetFullPath(preparedBackupDir); if (!root.TryGetProperty("potentialTargets", out JsonElement targets) || targets.ValueKind != JsonValueKind.Array) { @@ -1019,6 +1054,7 @@ private static JournalReadResult ParseJournal(string journalPath, byte[] journal PendingTransactionInfo info = new( journalPath, Path.GetDirectoryName(journalPath)!, + declaredBackupDir, operationId, lastSequence, state, @@ -1042,6 +1078,7 @@ internal sealed record TransactionTargetInfo(string Kind, string TargetPath, str internal sealed record PendingTransactionInfo( string JournalPath, string BackupDir, + string? DeclaredBackupDir, string? OperationId, int LastSequence, string State, @@ -1059,6 +1096,12 @@ internal RecoveryRequiredException(string message, IReadOnlyList item.BackupDir).ToArray(); } + internal RecoveryRequiredException(string message, IReadOnlyList pendingBackupDirectories) + : base(message) + { + PendingBackupDirectories = [.. pendingBackupDirectories]; + } + public string Code => "RECOVERY_REQUIRED"; public IReadOnlyList PendingBackupDirectories { get; } diff --git a/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj b/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj index c008ca0..8c819a8 100644 --- a/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj +++ b/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj @@ -7,9 +7,9 @@ enable true CodexProviderSync.GuiE2E - 0.5.0 - 0.5.0.0 - 0.5.0.0 + 1.0.0 + 1.0.0.0 + 1.0.0.0 diff --git a/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj index ec3b6aa..008d225 100644 --- a/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj +++ b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj @@ -17,9 +17,9 @@ CodexProviderSync Codex Provider Sync Dailin521 - 0.5.0 - 0.5.0.0 - 0.5.0.0 + 1.0.0 + 1.0.0.0 + 1.0.0.0 true diff --git a/docs/README_DESKTOP_EN.md b/docs/README_DESKTOP_EN.md new file mode 100644 index 0000000..d7e9d5a --- /dev/null +++ b/docs/README_DESKTOP_EN.md @@ -0,0 +1,56 @@ +# V1 Electron Primary Desktop Candidate + +> **V1 candidate designation: new primary desktop candidate; release status: unreleased.** This PR labels Electron as the new primary desktop candidate and the retained .NET Windows/macOS implementations as post-handoff Legacy fallbacks. Public Releases still provide only the Windows .NET GUI; Electron is not merged into `main`, signed, notarized, downloadable, or on a production update channel. This does not claim that Electron has publicly replaced .NET and authorizes no tag, npm/GitHub Release, signing, notarization, update-channel publication, or merge. + +The new desktop host lives in `apps/desktop` and shares the modern React UI and Node Core across Windows x64, macOS x64/arm64, and Linux x64. `V1` displays those handoff targets in the candidate source and UI; they do not become a public entry-point switch before the final PR and release gates pass, and they do not mark Phase 6 Completed or claim real Beta use, signing/notarization, a public Release, or update validation. + +## Capabilities + +- Overview shows Provider/model distributions, SQLite/Codex Home sources, backups, pending state, the active operation, and locked rollouts. +- Sync and Switch Provider use Prepare, present a plan, and confirm Apply with the same `planId`. Switch supports provider-default, keep-root-model, and explicit-model policies. +- Backups/Restore accepts only a managed `backupId`. Restore snapshots the current target before any target write and uses a durable journal to acknowledge, compensate, or enter recovery required. +- History loads only after explicit navigation and reads details lazily. Message bodies do not enter logs, caches, diagnostics bundles, or bulk exports. +- Profiles exposes only managed profile identifiers and revisions; the Renderer cannot submit arbitrary paths. +- Diagnostics and Settings keep destination selection in Main, support system/light/dark themes, and provide `zh-CN` and `en`. +- Watch reacquires both locks for every Apply and yields to manual operations. Main alone owns updates, and installation is blocked by writes, Watch, or unresolved journals. + +## Security data flow + +```text +Electron Renderer + → DesktopCoreClient + → narrow Preload IPC + → Main (window, lifecycle, picker, update, supervision) + → Utility Process + → Node Core public API + → original Codex storage +``` + +`BrowserWindow` keeps context isolation, sandboxing, and web security enabled while Node integration is disabled. A local protocol uses a strict CSP. The Renderer has no Node, file-system, arbitrary-channel, or arbitrary-path access. The Utility Process completes an app/core/protocol handshake before any business call; a crash rejects pending requests and permits at most one restart after checking unfinished journals. + +Writes preserve backup-first behavior, the fixed Codex Home → State DB two-lock order, transaction journals, rollback/recovery, and the diagnostic-only WSL UNC boundary. The application does not read or export authentication data and does not modify message bodies, session titles, or `updated_at`. + +## Internal acceptance + +The modern workspace and Electron build use Node 24. Automated tests use only temporary directories and redacted fixtures; never use a real user's Codex Home for development tests. + +```powershell +npm ci +npm run desktop:test +$env:CPS_DESKTOP_WINDOW_DISPLAY = "hidden" +npm run desktop:test:e2e +``` + +The hidden policy does not display or occupy the primary screen. Any visual acceptance must explicitly use a controlled test environment; automation remains hidden. + +The C9 matrix fixes Windows x64 NSIS/portable ZIP, macOS x64/arm64 DMG/ZIP, and Linux x64 AppImage/deb as the candidate containers. Every platform must build natively and pass ASAR/native SQLite audits, final-container Status, Sync→Restore, graceful exit, checksum, and SBOM verification before the four-target aggregate and redacted C10 evidence bundle can close. + +## Release and handoff boundary + +- `1.0.0-alpha|beta|rc.` is CI-candidate metadata only, and builds always use `--publish never`. +- Source manifests are `1.0.0`; this denotes a CI-verified source candidate only, not Beta, Stable, a public download, a default update entry point, or a released product. +- The current candidate is unsigned, not notarized, and has no production Release update channel. +- Public release, signing, notarization, update metadata, and cross-version upgrade validation require separate authorization. +- The .NET implementation remains buildable and tested, and the V1 candidate labels it as the post-handoff Legacy fallback target. Removal is outside this PR, waits at least two maintenance cycles after the stable release, and requires a separate project. + +The [Accepted architecture baseline](VNEXT_ELECTRON_NODE_ARCHITECTURE_ZH.md) and [vNext migration execution index](migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md) remain authoritative. diff --git a/docs/README_DESKTOP_ZH.md b/docs/README_DESKTOP_ZH.md new file mode 100644 index 0000000..0698580 --- /dev/null +++ b/docs/README_DESKTOP_ZH.md @@ -0,0 +1,56 @@ +# V1 Electron 主桌面端候选说明 + +> **V1 候选定位:新版主桌面端候选;发行状态:未发布。**本 PR 按 C10 将 Electron 标记为新版主桌面端候选,并把保留的 .NET Windows/macOS 实现标记为交接后的 Legacy fallback。当前公开 Releases 仍只提供 Windows .NET GUI;Electron 尚未合入 `main`、未签名、未公证,不提供下载或生产更新通道。本页不表示 Electron 已公开替代 .NET,也不授权 tag、npm/GitHub Release、签名、公证、更新通道发布或合并。 + +新版桌面端位于 `apps/desktop`,在 Windows x64、macOS x64/arm64 和 Linux x64 上共享现代 React 界面与 Node Core。`V1` 在候选源码和界面中显示上述交接目标;它在最终 PR 合入和发布门槛通过前不生效为公开入口,也不表示 Phase 6 已 Completed、Beta 已验证、签名/公证已完成或已经公开 Release。 + +## 能力 + +- Overview:Provider/model 分布、SQLite/Codex Home 来源、备份、pending、operation 与 locked rollout 状态。 +- Sync、Switch Provider:先 Prepare 展示计划,再用同一 `planId` 确认 Apply;Switch 支持 Provider 默认模型、保留根模型和显式模型。 +- Backups/Restore:只接受受管 `backupId`;Restore 在任何目标写入前创建恢复前快照,并按耐久 journal 完成确认、补偿或进入 recovery required。 +- History:仅在用户明确打开后加载列表,详情延迟读取;消息正文不进入日志、缓存、诊断包或全量导出。 +- Profiles:当前桌面 Host 只公开受管 Profile 标识和 revision,不让 Renderer 提交任意路径。 +- Diagnostics、Settings:Main 选择诊断目标并签发可信 token;主题支持 system/light/dark,语言支持 `zh-CN` 和 `en`。 +- Watch 与 Update:Watch 每次 Apply 都重新获取双层锁并让位于人工操作;更新只由 Main 管理,写操作、Watch 或未解决 journal 存在时禁止安装。 + +## 安全数据流 + +```text +Electron Renderer + → DesktopCoreClient + → Preload 窄 IPC + → Main(窗口、生命周期、选择器、更新、监管) + → Utility Process + → Node Core 公共 API + → Codex 原始存储 +``` + +`BrowserWindow` 固定启用 context isolation、sandbox 和 web security,并关闭 Node integration;本地协议使用严格 CSP。Renderer 不能访问 Node、文件系统、任意 IPC channel 或任意路径。Utility Process 在任何业务调用前完成 app/core/protocol handshake;崩溃会拒绝 pending 请求,并在检查未完成 journal 后最多自动重启一次。 + +写操作保持 backup-first、Codex Home → State DB 固定顺序双层锁、事务 journal、回滚/恢复和 WSL UNC 仅诊断边界。应用不读取或输出认证数据,也不修改消息正文、会话标题或 `updated_at`。 + +## 内部验收 + +现代 workspace 和 Electron 构建使用 Node 24。自动化测试只使用临时目录和脱敏 fixture;不要拿真实用户 Codex Home 做开发测试。 + +```powershell +npm ci +npm run desktop:test +$env:CPS_DESKTOP_WINDOW_DISPLAY = "hidden" +npm run desktop:test:e2e +``` + +隐藏策略不会显示或占用主屏窗口。需要人工可视验收时,必须显式改用受控测试环境;自动化仍保持 hidden。 + +C9 候选矩阵固定为 Windows x64 NSIS/portable ZIP、macOS x64/arm64 DMG/ZIP、Linux x64 AppImage/deb。每个平台必须在原生 runner 上完成打包、ASAR/native SQLite 审计、最终容器 Status、Sync→Restore、正常退出以及 checksum/SBOM 验证,再由四目标 aggregate 和 C10 脱敏 evidence bundle 收口。 + +## 发布与交接边界 + +- `1.0.0-alpha|beta|rc.` 只用于 CI 候选,构建始终 `--publish never`。 +- source manifest 已定为 `1.0.0`,它只表示经 CI 验证的候选源码版本,不等于 Beta、Stable、公开下载、默认更新入口或已发布产品。 +- 当前候选 unsigned、not notarized,未配置正式 Release 更新通道。 +- 公开发布、签名、公证、更新 metadata 和跨版本升级验证都需要另行授权。 +- .NET 实现继续保持可构建、可测试,并在 V1 候选中标记为交接后的 Legacy fallback 目标;删除不属于本 PR,且至少要等稳定版发布后的两个维护周期,再单独立项。 + +实现与阶段状态以 [Accepted 架构基线](VNEXT_ELECTRON_NODE_ARCHITECTURE_ZH.md) 和 [vNext 迁移执行索引](migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md) 为准。 diff --git a/docs/README_EN.md b/docs/README_EN.md index 954005a..12ac08a 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -34,16 +34,18 @@ This tool synchronizes session files and the SQLite index, restoring session vis > The Windows GUI and Local Web UI currently use a Simplified Chinese interface. > -> CLI/Web and the Windows GUI are released independently, so their version numbers may differ. +> CLI/Web and the current Windows GUI are released independently, so their version numbers may differ. +> +> **V1 candidate designation:** This PR labels Electron as the new primary desktop candidate and the retained .NET Windows/macOS implementations as post-handoff Legacy fallbacks. **Public release status is separate:** Releases still provide only the Windows .NET GUI; Electron is not merged into `main`, published, signed, downloadable, or on an update channel. The candidate designation does not claim that Electron has publicly replaced .NET. | Scenario | Recommended interface | | --- | --- | -| Windows desktop | [Download Windows GUI](https://github.com/Dailin521/codex-provider-sync/releases/latest) · [Usage guide](#windows-gui) | +| Windows desktop | [Download the currently published Windows GUI (.NET)](https://github.com/Dailin521/codex-provider-sync/releases/latest) · [`V1` Electron primary desktop candidate guide (no public download)](README_DESKTOP_EN.md) | | macOS desktop | [Local Web UI (CLI required)](#local-web-ui); [native GUI build guide](README_MAC_GUI_EN.md) | | Browser interface or cross-platform use | [Local Web UI (CLI required)](#local-web-ui) | | Scripts, CI, or WSL | [CLI](#cli) | -### Windows GUI +### Currently published Windows GUI (.NET; V1 Legacy fallback target) Download `CodexProviderSync.exe` from [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest): @@ -55,6 +57,8 @@ The application is not code-signed, so Windows may show a security warning. Down [Full Windows GUI guide (Chinese)](README_GUI_ZH.md) +See the [Electron primary desktop candidate guide](README_DESKTOP_EN.md) for its capabilities, security boundaries, and internal acceptance workflow. A source role is not a public release; that guide provides no download and authorizes no release. + ### Local Web UI The Local Web UI is provided by the CLI. Install Node.js `16.20.2+`, then install this project's official npm package and start it: @@ -115,15 +119,21 @@ SQLite Home resolution order: `--sqlite-home` → root-level `sqlite_home` in `c ```mermaid flowchart LR - Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] - WebServer --> NodeService["Node Service"] - CLI["Node CLI"] --> NodeService + Browser["Browser React UI"] --> HttpClient["HttpCoreClient"] + HttpClient --> WebServer["Local Web Host
127.0.0.1 + pairing"] + WebServer --> NodeCore["Node Core public facade"] + CLI["Node CLI"] --> NodeCore - WindowsGUI["Windows GUI"] --> Application[".NET Application"] + ElectronRenderer["Electron Renderer
V1 primary desktop candidate"] --> DesktopClient["DesktopCoreClient"] + DesktopClient --> ElectronHost["Preload / Main
narrow IPC"] + ElectronHost --> Utility["Utility Process"] + Utility --> NodeCore + + WindowsGUI[".NET GUI
published now; V1 Legacy fallback target"] --> Application[".NET Application"] Application --> DotNetCore[".NET Core"] MacGUI["macOS GUI"] --> DotNetCore - NodeService --> Storage["Codex Storage"] + NodeCore --> Storage["Codex Storage"] DotNetCore --> Storage Storage --> Config["config.toml"] @@ -132,10 +142,13 @@ flowchart LR Storage --> Backups["managed backups"] ``` -- The Web UI and CLI share the same Node service logic. +- Web requests flow through `HttpCoreClient → /api/core → Node Core public facade`; the CLI calls the same public Core boundary directly. +- The `V1` Electron primary desktop candidate uses `DesktopCoreClient → narrow Preload/Main IPC → Utility Process → Node Core`; its Renderer has no Node, arbitrary-path, or generic-IPC access. - The Windows GUI calls .NET Core through the Application layer; the macOS GUI currently calls .NET Core directly. - The Node service and .NET Core enforce the same configuration, rollout, SQLite, and backup safety boundaries. +The `V1` candidate carries the C10 handoff target of Electron as the new primary desktop and .NET as a retained Legacy fallback. .NET remains buildable, tested, and retained for at least two maintenance cycles. Public Releases still provide .NET; Electron has not been merged, published, or signed. Do not describe this candidate label as a completed public entry-point switch; remaining release gates are tracked in the [vNext migration execution index](migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md). + ## Safety boundaries - Before every `sync` or `switch`, a backup is created at `/backups_state/provider-sync/`; with the default Codex Home, this is `~/.codex/backups_state/provider-sync/`. @@ -149,6 +162,7 @@ flowchart LR - [AI / Agent Guide](../AGENTS.md) - [Windows GUI guide (Chinese)](README_GUI_ZH.md) +- [V1 Electron primary desktop candidate guide](README_DESKTOP_EN.md) - [Web UI guide (Chinese)](README_WEB_UI_ZH.md) - [中文](../README.md) · [日本語](README_JA.md) · [한국어](README_KO.md) - [macOS GUI: 中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) diff --git a/docs/README_GUI_ZH.md b/docs/README_GUI_ZH.md index 0690d54..aca6362 100644 --- a/docs/README_GUI_ZH.md +++ b/docs/README_GUI_ZH.md @@ -1,5 +1,7 @@ # Codex Provider Sync GUI +> **V1 候选交接目标:保留的 .NET Legacy fallback;公开发行状态:当前仍是已发布的 Windows GUI。**该实现不是已退役产品,仍保持可构建、可测试并作为兼容行为依据。Electron 在 `V1` 中标记为新版主桌面端候选,但尚未合入 `main`、公开发布、签名或进入更新通道;候选角色不等于公开入口已经切换。 + ## 适用场景 这是 Windows 用户可用的图形界面版本。 diff --git a/docs/README_JA.md b/docs/README_JA.md index b896338..b43628e 100644 --- a/docs/README_JA.md +++ b/docs/README_JA.md @@ -35,15 +35,17 @@ > Windows GUI とローカル Web UI の画面表示は現在、簡体字中国語のみです。 > > CLI/Web と Windows GUI は別々にリリースされるため、バージョン番号が異なる場合があります。 +> +> **V1 候補での位置付け:**本 PR は C10 の移行目標として Electron を新しい主デスクトップ候補、既存の .NET Windows/macOS 実装を移行後の Legacy fallback として示します。**公開リリースの状態は別です:**Releases で現在提供されているのは Windows .NET GUI だけで、Electron は `main` 未マージ、未公開、未署名で、ダウンロードや自動更新の対象ではありません。この候補上の表示は、Electron がすでに .NET を公開製品として置き換えたという意味ではありません。 | 利用場面 | 推奨する入口 | | --- | --- | -| Windows デスクトップ | [Windows GUI をダウンロード](https://github.com/Dailin521/codex-provider-sync/releases/latest)・[使い方](#windows-gui) | +| Windows デスクトップ | [現在公開中の Windows GUI(.NET)をダウンロード](https://github.com/Dailin521/codex-provider-sync/releases/latest)・[`V1` Electron 主デスクトップ候補ガイド(公開ダウンロードなし)](README_DESKTOP_EN.md) | | macOS デスクトップ | [ローカル Web UI(CLI が必要)](#ローカル-web-ui)・[ネイティブ GUI のビルド手順(英語)](README_MAC_GUI_EN.md) | | ブラウザ UI またはクロスプラットフォーム利用 | [ローカル Web UI(CLI が必要)](#ローカル-web-ui) | | スクリプト、CI、または WSL | [CLI](#cli) | -### Windows GUI +### 現在公開中の Windows GUI(.NET、V1 の Legacy fallback 目標) [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest) から `CodexProviderSync.exe` をダウンロードします。 @@ -55,6 +57,8 @@ [Windows GUI の詳細(中国語)](README_GUI_ZH.md) +新しい Electron 主デスクトップ候補の機能、安全境界、内部検証は [Electron primary desktop candidate guide(英語)](README_DESKTOP_EN.md) を参照してください。候補上の役割は公開リリースを意味せず、このガイドはダウンロードやリリース権限を提供しません。 + ### ローカル Web UI ローカル Web UI は CLI に含まれています。Node.js `16.20.2+` をインストールし、本プロジェクトの公式 npm パッケージをインストールして起動します。 @@ -115,15 +119,21 @@ SQLite Home の解決順序: `--sqlite-home` → `config.toml` ルートの `sql ```mermaid flowchart LR - Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] - WebServer --> NodeService["Node Service"] - CLI["Node CLI"] --> NodeService + Browser["Browser React UI"] --> HttpClient["HttpCoreClient"] + HttpClient --> WebServer["Local Web Host
127.0.0.1 + pairing"] + WebServer --> NodeCore["Node Core public facade"] + CLI["Node CLI"] --> NodeCore - WindowsGUI["Windows GUI"] --> Application[".NET Application"] + ElectronRenderer["Electron Renderer
V1 primary desktop candidate"] --> DesktopClient["DesktopCoreClient"] + DesktopClient --> ElectronHost["Preload / Main
narrow IPC"] + ElectronHost --> Utility["Utility Process"] + Utility --> NodeCore + + WindowsGUI[".NET GUI
published now; V1 Legacy fallback target"] --> Application[".NET Application"] Application --> DotNetCore[".NET Core"] MacGUI["macOS GUI"] --> DotNetCore - NodeService --> Storage["Codex Storage"] + NodeCore --> Storage["Codex Storage"] DotNetCore --> Storage Storage --> Config["config.toml"] @@ -132,10 +142,13 @@ flowchart LR Storage --> Backups["managed backups"] ``` -- Web UI と CLI は同じ Node サービスロジックを使用します。 +- Web UI は `HttpCoreClient → /api/core → Node Core public facade`、CLI は同じ公開 Core 境界を直接使用します。 +- `V1` の Electron 主デスクトップ候補は `DesktopCoreClient → 制限された Preload/Main IPC → Utility Process → Node Core` を使用し、Renderer から Node、任意パス、汎用 IPC にはアクセスできません。 - Windows GUI は Application 層を通じて .NET Core を呼び出し、macOS GUI は現在 .NET Core を直接呼び出します。 - Node サービスと .NET Core は同じ設定、rollout、SQLite、バックアップの安全境界を扱います。 +`V1` 候補は C10 の移行目標として Electron を新しい主デスクトップ候補、保持する .NET Windows/macOS 実装を移行後の Legacy fallback と表示します。.NET は引き続きビルド・テストされ、少なくとも 2 メンテナンス周期は保持されます。公開 Releases はまだ .NET であり、Electron は未マージ・未公開・未署名です。この候補上の表示を、公開入口の切り替え完了と表現しないでください。 + ## 安全上の境界 - `sync` / `switch` の前に、毎回 `/backups_state/provider-sync/` へバックアップします。デフォルトの Codex Home では `~/.codex/backups_state/provider-sync/` です。 @@ -149,6 +162,7 @@ flowchart LR - [AI / Agent ガイド](../AGENTS.md) - [Windows GUI(中国語)](README_GUI_ZH.md) +- [V1 Electron 主デスクトップ候補(英語)](README_DESKTOP_EN.md) - [Web UI(中国語)](README_WEB_UI_ZH.md) - [中文](../README.md) · [English](README_EN.md) · 日本語 · [한국어](README_KO.md) - [macOS GUI: 中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) diff --git a/docs/README_KO.md b/docs/README_KO.md index 47e6d81..110834e 100644 --- a/docs/README_KO.md +++ b/docs/README_KO.md @@ -35,15 +35,17 @@ > Windows GUI와 로컬 Web UI의 화면 언어는 현재 중국어 간체만 지원합니다. > > CLI/Web과 Windows GUI는 별도로 릴리스되므로 버전 번호가 다를 수 있습니다. +> +> **V1 후보 지정:** 이 PR은 C10 인계 목표로 Electron을 새로운 주 데스크톱 후보로, 기존 .NET Windows/macOS 구현을 인계 후 Legacy fallback으로 표시합니다. **공개 릴리스 상태는 별개입니다:** 현재 Releases는 Windows .NET GUI만 제공하며 Electron은 `main`에 병합되지 않았고, 게시·서명·다운로드·자동 업데이트되지 않습니다. 이 후보 표시는 Electron이 공개 제품에서 이미 .NET을 대체했다는 뜻이 아닙니다. | 상황 | 권장 방법 | | --- | --- | -| Windows 데스크톱 | [Windows GUI 다운로드](https://github.com/Dailin521/codex-provider-sync/releases/latest) / [사용 방법](#windows-gui) | +| Windows 데스크톱 | [현재 공개된 Windows GUI(.NET) 다운로드](https://github.com/Dailin521/codex-provider-sync/releases/latest) / [`V1` Electron 주 데스크톱 후보 안내(공개 다운로드 없음)](README_DESKTOP_EN.md) | | macOS 데스크톱 | [로컬 Web UI (CLI 필요)](#로컬-web-ui) / [네이티브 GUI 빌드 안내 (영문)](README_MAC_GUI_EN.md) | | 브라우저 UI 또는 크로스 플랫폼 사용 | [로컬 Web UI (CLI 필요)](#로컬-web-ui) | | 스크립트, CI 또는 WSL | [CLI](#cli) | -### Windows GUI +### 현재 공개된 Windows GUI (.NET, V1 Legacy fallback 목표) [Releases](https://github.com/Dailin521/codex-provider-sync/releases/latest)에서 `CodexProviderSync.exe`를 다운로드합니다. @@ -55,6 +57,8 @@ [Windows GUI 전체 안내 (중국어)](README_GUI_ZH.md) +새 Electron 주 데스크톱의 기능, 보안 경계, 내부 검증은 [Electron primary desktop candidate guide (영문)](README_DESKTOP_EN.md)를 참조하세요. 소스 역할은 공개 릴리스를 의미하지 않으며 이 문서는 다운로드나 릴리스 권한을 제공하지 않습니다. + ### 로컬 Web UI 로컬 Web UI는 CLI에 포함되어 있습니다. Node.js `16.20.2+`를 설치한 다음 이 프로젝트의 공식 npm 패키지를 설치하고 실행하세요. @@ -115,15 +119,21 @@ SQLite Home 해석 순서: `--sqlite-home` → `config.toml` 루트의 `sqlite_h ```mermaid flowchart LR - Browser["Browser Web UI"] --> WebServer["Local Node Web Server
127.0.0.1"] - WebServer --> NodeService["Node Service"] - CLI["Node CLI"] --> NodeService + Browser["Browser React UI"] --> HttpClient["HttpCoreClient"] + HttpClient --> WebServer["Local Web Host
127.0.0.1 + pairing"] + WebServer --> NodeCore["Node Core public facade"] + CLI["Node CLI"] --> NodeCore - WindowsGUI["Windows GUI"] --> Application[".NET Application"] + ElectronRenderer["Electron Renderer
V1 primary desktop candidate"] --> DesktopClient["DesktopCoreClient"] + DesktopClient --> ElectronHost["Preload / Main
narrow IPC"] + ElectronHost --> Utility["Utility Process"] + Utility --> NodeCore + + WindowsGUI[".NET GUI
published now; V1 Legacy fallback target"] --> Application[".NET Application"] Application --> DotNetCore[".NET Core"] MacGUI["macOS GUI"] --> DotNetCore - NodeService --> Storage["Codex Storage"] + NodeCore --> Storage["Codex Storage"] DotNetCore --> Storage Storage --> Config["config.toml"] @@ -132,10 +142,13 @@ flowchart LR Storage --> Backups["managed backups"] ``` -- Web UI와 CLI는 동일한 Node 서비스 로직을 사용합니다. +- Web UI는 `HttpCoreClient → /api/core → Node Core public facade`를 사용하고 CLI는 같은 공개 Core 경계를 직접 호출합니다. +- `V1` Electron 주 데스크톱 후보는 `DesktopCoreClient → 제한된 Preload/Main IPC → Utility Process → Node Core` 경로를 사용하며 Renderer에서는 Node, 임의 경로 또는 범용 IPC에 접근할 수 없습니다. - Windows GUI는 Application 계층을 통해 .NET Core를 호출하고, macOS GUI는 현재 .NET Core를 직접 호출합니다. - Node 서비스와 .NET Core는 동일한 설정, rollout, SQLite, 백업 안전 범위를 처리합니다. +`V1` 후보는 C10 인계 목표로 Electron을 새로운 주 데스크톱으로, 기존 .NET Windows/macOS 구현을 Legacy fallback으로 표시합니다. .NET은 계속 빌드·테스트되며 최소 두 번의 유지보수 주기 동안 보존됩니다. 공개 Releases는 아직 .NET이고 Electron은 병합·게시·서명되지 않았습니다. 이 후보 표시를 공개 진입점 전환 완료로 표현해서는 안 됩니다. + ## 안전 범위 - 매 `sync` / `switch` 전 `/backups_state/provider-sync/`에 백업합니다. 기본 Codex Home에서는 `~/.codex/backups_state/provider-sync/`입니다. @@ -149,6 +162,7 @@ flowchart LR - [AI / Agent 가이드](../AGENTS.md) - [Windows GUI (중국어)](README_GUI_ZH.md) +- [V1 Electron 주 데스크톱 후보 (영어)](README_DESKTOP_EN.md) - [Web UI (중국어)](README_WEB_UI_ZH.md) - [中文](../README.md) · [English](README_EN.md) · [日本語](README_JA.md) - [macOS GUI: 中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) diff --git a/docs/README_MAC_GUI_EN.md b/docs/README_MAC_GUI_EN.md index 8699d9a..e5c421e 100644 --- a/docs/README_MAC_GUI_EN.md +++ b/docs/README_MAC_GUI_EN.md @@ -2,6 +2,8 @@ [中文](README_MAC_GUI_ZH.md) · English +> This guide covers the .NET/Avalonia local build that `V1` retains for the post-handoff Legacy fallback role; it is not an Electron release guide. The implementation remains buildable and tested, and is neither removed nor retired. Electron is labeled as the new primary desktop candidate in `V1`, but is not merged into `main`, published, signed, or notarized; GitHub Releases still has no downloadable macOS Electron artifact. + `CodexProviderSync.app` is the macOS desktop GUI. It is built with Avalonia and reuses the status, synchronization, switching, restore, and backup-cleanup logic from `desktop/CodexProviderSync.Core`. diff --git a/docs/README_MAC_GUI_ZH.md b/docs/README_MAC_GUI_ZH.md index dd1a32f..0eb7954 100644 --- a/docs/README_MAC_GUI_ZH.md +++ b/docs/README_MAC_GUI_ZH.md @@ -2,6 +2,8 @@ 中文 · [English](README_MAC_GUI_EN.md) +> 本文说明 `V1` 以交接后 Legacy fallback 为目标保留的 .NET/Avalonia 本地构建,不是 Electron 发行说明。该实现仍可构建、测试,不是已删除或退役产品。Electron 在 `V1` 中标记为新版主桌面端候选,但尚未合入 `main`、发布、签名或公证;GitHub Releases 仍没有可下载的 macOS Electron 产物。 + `CodexProviderSync.app` 是 macOS 桌面版 GUI,使用 Avalonia 构建,复用 `desktop/CodexProviderSync.Core` 的状态、同步、切换、恢复和清理逻辑。 ## 构建 diff --git a/docs/README_WEB_UI_ZH.md b/docs/README_WEB_UI_ZH.md index da7a486..df3e353 100644 --- a/docs/README_WEB_UI_ZH.md +++ b/docs/README_WEB_UI_ZH.md @@ -1,6 +1,6 @@ # Web UI 使用说明 -Web UI 是 CLI 提供的本地浏览器界面,与 CLI 共用同一套同步、备份和恢复逻辑。 +Web UI 是 CLI 提供的本地浏览器界面。共享 React UI 通过版本化 `HttpCoreClient` 调用本地 Web Host,再进入与 CLI 相同的 Node Core 公开边界;页面不会解析 CLI 输出或复制同步逻辑。 ## 启动 @@ -45,8 +45,8 @@ npm run web:start 本工具只同步本地元数据,不负责登录或切换账号。已经通过其他工具切换 Provider 时: 1. 使用 CCSwitch 等常用工具切换 Provider,并确认 Codex 可以正常对话。 -2. 回到 Web UI;需要时点击“读取状态”。 -3. 在概览页的“执行同步”中保持“仅同步元数据”,选择目标 Provider(供应商),确认执行。 +2. 回到 Web UI,在“概览”检查当前 Provider、rollout/SQLite 分布和安全状态。 +3. 进入“同步”,生成十分钟内有效的一次性计划,核对影响数量、警告与备份预期后确认。 4. 显示“Provider 元数据已对齐”即完成。切回原 Provider 时重复相同步骤。 rollout 与 SQLite 的会话总数可能因活动会话写入和索引时序短暂相差 1;这不表示 Provider 元数据未对齐。以两侧的 Provider 分布和页面对齐状态为准。 @@ -55,20 +55,24 @@ rollout 与 SQLite 的会话总数可能因活动会话写入和索引时序短 ## 页面功能 -- 概览:显示当前 Provider、rollout/SQLite 分布、修复项和项目可见性。 -- 聊天记录:从 rollout 文件只读读取会话列表和用户/助手消息,支持搜索、Provider/项目/归档筛选、分页和会话详情。 -- 执行同步:区分“仅同步元数据”和“切换 Provider 并同步”。 -- 切换模型:支持跟随 Provider section、保留根级 model 或显式指定 model。 -- 备份:查看当前 Codex Home 下由本工具管理的备份,并按内容恢复。 -- 恢复保护:SQLite Home 不同时显示来源与目标;迁移数据库时禁止同时恢复旧配置。 -- 活动:显示当前 Web UI 进程内存中的同步阶段和操作结果;服务停止后不会作为日志文件保留。 -- 清理:按保留数量删除较旧的托管备份。 +- 概览(Overview):显示当前 Provider、rollout/SQLite 分布、对齐状态、备份数、locked rollout 数和存储来源;公共状态不显示本机绝对路径。 +- 同步(Sync):设置备份保留数,先生成计划,再在确认对话框中 Apply。 +- 切换 Provider(Switch Provider):只允许已配置 Provider,并明确选择跟随 Provider 默认模型、保留根 model 或显式 model 三种模式。 +- 备份/恢复(Backups/Restore):只展示受管 `backupId`;Restore 可选择 config/SQLite/session 范围,跨 SQLite Home 必须选择目标 Profile 并确认 relocation;同页可按保留数清理受管备份。 +- 历史(History):进入页面后才读取会话列表;只有点击某个会话才延迟读取详情。当前列表最多读取 100 项,不提供搜索/筛选承诺;消息正文不进入 Query cache,离开详情即清空并取消未完成请求。 +- Profiles:管理服务端受信任的 Codex/SQLite Home 配置;Core 业务请求只提交 profile ID/revision,不传递任意路径。 +- Diagnostics:只读显示 Core 返回的有界安全诊断字段,不读取凭据、token、消息正文或原始异常。 +- Settings:切换 `zh-CN` / `en`、`system` / `light` / `dark` 主题,管理 Watch 与撤销当前浏览器授权。 + +全局区域显示 Recovery、正在进行的 Operation、结构化错误与 Toast。界面支持键盘操作、可见焦点、reduced motion 和 200% 缩放等效窄视口。 ## 本地安全边界 - 服务只监听 `127.0.0.1`,不要直接暴露到局域网或公网。 - 首次启动使用短时、一次性的配对链接;服务端只保存设备凭证哈希。使用“忘记此浏览器”或 `--reset-access` 可撤销授权。 -- 存储路径由服务端配置管理,写操作串行执行,恢复只能选择当前 Codex Home 下由本工具管理的备份。 +- Core API 请求/响应使用共享版本化 envelope;服务端验证 Origin、64 KiB 请求上限、requestId、Profile/Storage revision 和产品输入 schema。 +- Production HTML 使用每响应随机 nonce 的严格 CSP;不允许远程脚本或跨源 Core 请求。 +- 存储路径由服务端配置管理,写操作 fail-fast 串行;Sync、Switch、Restore 使用 Prepare/Apply,Apply 只接收不透明 `planId`;恢复只能选择受管备份。 - Web UI 不能绕过共享核心逻辑中的锁、SQLite Home、WSL UNC、备份和恢复限制。 ## SSH、无桌面和远程浏览器 diff --git a/docs/README_ZH.md b/docs/README_ZH.md index 718a94f..7bb5dc9 100644 --- a/docs/README_ZH.md +++ b/docs/README_ZH.md @@ -1,11 +1,12 @@ # codex-provider-sync 中文入口 -完整中文说明以仓库根目录的 [README.md](../README.md) 为准,包括适用场景、Windows GUI、可选本地 Web UI、CLI、SQLite Home 解析、安全限制和开发命令。 +完整中文说明以仓库根目录的 [README.md](../README.md) 为准,包括适用场景、当前已发布的 Windows .NET GUI、未发布的 vNext Electron 候选、可选本地 Web UI、CLI、SQLite Home 解析、安全限制和开发命令。 专项文档: - [Web UI 中文指南](README_WEB_UI_ZH.md) - [Windows GUI 说明](README_GUI_ZH.md) +- [vNext Electron 桌面端候选说明](README_DESKTOP_ZH.md) - [工作原理与落盘机制](WORKING_PRINCIPLE_ZH.md) - macOS GUI:[中文](README_MAC_GUI_ZH.md) · [English](README_MAC_GUI_EN.md) diff --git a/docs/VNEXT_ELECTRON_NODE_ARCHITECTURE_ZH.md b/docs/VNEXT_ELECTRON_NODE_ARCHITECTURE_ZH.md index c5cb81e..1a6e025 100644 --- a/docs/VNEXT_ELECTRON_NODE_ARCHITECTURE_ZH.md +++ b/docs/VNEXT_ELECTRON_NODE_ARCHITECTURE_ZH.md @@ -353,10 +353,12 @@ window.sqlite ### 4.7 渐进迁移 - `main` 始终可发布; -- 每个 PR 都可独立审查; +- 默认按 ADR-0008 使用可独立合入的小型 PR; +- 经 ADR-0011 明确批准的 V1 例外使用单一 `V1` 分支和一个最终 PR,但 `C0`~`C10` 必须成为不可变、可独立审查和回退的内部 checkpoint; +- 分支 checkpoint 的验证不等于受保护分支的阶段 Completed,最终合入前 Phase 保持 In Progress/Pending; - 旧 CLI 和旧 GUI 在迁移期间继续工作; - 新 Electron 先只读,再开放写入; -- 不使用长期漂移的“大重写分支”承载全部开发。 +- 不使用该例外放宽 Fixture、兼容、发布或 .NET 保留门槛。 --- @@ -819,17 +821,13 @@ export interface SyncPlan { - 进程锁; - SQLite 可写性。 -若变化,返回: +若任一绑定状态变化,Apply 返回统一的 Canonical Code: ```text -PLAN_STALE -PROFILE_CHANGED -STORAGE_CHANGED -CONFIG_CHANGED -ROLLOUT_CHANGED +STALE_STATE ``` -用户必须刷新并再次确认。 +安全的 `details.reason` 可区分 `profile/config/storage/rollout/state-db`;调用方不得据此绕过重新 Prepare。Plan 超过 TTL 则返回 `PLAN_EXPIRED`。用户必须刷新并再次确认。 ### 9.4 Plan 存储 @@ -1123,8 +1121,7 @@ packages/app-ui/src/ │ ├─ overview/ │ ├─ sync/ │ ├─ switch-provider/ -│ ├─ backups/ -│ ├─ restore/ +│ ├─ backups-restore/ │ ├─ history/ │ ├─ profiles/ │ ├─ diagnostics/ @@ -1414,6 +1411,8 @@ rename - Windows x64、macOS x64/arm64、Linux x64 都运行 packaged smoke test; - 每次 Electron Major 升级都执行 SQLite 驱动矩阵测试。 +C6/Phase 3 先以 Windows、macOS、Linux host-native runner 的 `electron-builder --dir` unpacked app 闭合只读启动、握手、真实 SQLite 与 Renderer 隔离;本节完整 Windows x64、macOS x64/arm64、Linux x64 packaged/native-driver 矩阵仍是 C9 发布工程门槛。两层证据不可互相替代。 + ### 17.3 不使用 ORM 项目只操作少量明确的 Codex 表和字段。继续使用显式 SQL: @@ -1440,7 +1439,7 @@ Core Runtime 使用 `OperationCoordinator`: ### 18.2 跨进程锁 -同一 Codex Home 的所有正式入口必须遵守兼容的跨进程锁合同: +同一 Codex Home 的所有正式入口必须遵守兼容的跨进程锁合同;vNext 对共享 SQLite Home 的双层资源身份、路径、顺序和错误语义由 [ADR-0012](adr/0012-dual-resource-lock-contract.md) 冻结。V1/C3 的 Node 与迁移期 .NET 候选实现已经按 `Codex Home → State DB` 固定顺序落实该合同,但在本 PR 的远端跨运行时门禁和最终合入完成前,不得把候选实现表述为已发布保证: ```text CLI、Web UI、Electron、旧 GUI 同时运行 @@ -1448,7 +1447,7 @@ CLI、Web UI、Electron、旧 GUI 同时运行 跨进程锁保证同一目标不会并行写入 ``` -阶段 0/1 必须记录并验证现有 Node 与 .NET 锁的路径、命名、持有周期和冲突语义;只有跨进程互斥测试通过后,才能宣称迁移期旧 GUI 与新入口共享同一锁合同。若当前实现不兼容,应先统一合同,不能依赖 UI 层互相避让。 +V1/C3 已记录并测试 Node 与 .NET 锁的路径、命名、持有周期和冲突语义;最终交付仍必须在同一 tested commit 上通过真实跨进程互斥、共享 SQLite Home 和不可验证锁的 required CI。任何差异都必须先统一合同,不能依赖 UI 层互相避让。 Electron 的 UI 禁用按钮只是体验优化,不能替代 Core Lock。 @@ -1490,7 +1489,8 @@ interface CoreErrorDto { INVALID_INPUT PROFILE_CHANGED STORAGE_CHANGED -PLAN_STALE +STALE_STATE +PLAN_EXPIRED CODEX_HOME_NOT_FOUND STATE_DB_NOT_FOUND SQLITE_UNSUPPORTED_PATH @@ -1505,6 +1505,7 @@ RECOVERY_REQUIRED RESTORE_VALIDATION_FAILED PERMISSION_DENIED OPERATION_BUSY +LOCK_UNVERIFIABLE OPERATION_CANCELLED CORE_RUNTIME_CRASHED PROTOCOL_VERSION_MISMATCH @@ -2378,9 +2379,18 @@ Electron 只开放: --- -## 31. 首批 PR 拆分建议 +## 31. V1 内部 Checkpoint 序列 -### PR 1:冻结架构合同和 ADR +在 ADR-0011 的单最终 PR 例外下,以下 `C0`~`C10` 是 V1 分支内的不可变 checkpoint,不是已经合入的独立 PR。旧 PR 2~PR 10 的依赖与安全意图按 ADR-0011 映射到这些 checkpoint。每个 checkpoint 必须保留 commit、测试证据和回退点;所有 Phase 状态仍以最终合入受保护分支为准。 + +### C0:V1 交付治理、双层锁与 Restore v2 文档合同 + +- 新增 ADR-0011~ADR-0013; +- 使架构、执行索引、Core/Error/Fixture 合同对单最终 PR、共享 State DB 锁与 Restore v2 目标可互相导航; +- 固化基线测试与依赖审计,并消除现有 Vite 链的 high/moderate 告警; +- 不把目标合同描述为已经实现。 + +### 已完成基线:PR 1(阶段 0 原合同) - 以本文件作为已确认的架构基线; - 新增 ADR-0001~ADR-0010; @@ -2388,74 +2398,76 @@ Electron 只开放: - 更新 `AGENTS.md` 中的 ADR 入口; - 不改运行代码。 -### PR 2:Core Public API +### C1:Core Public API 与结构化错误 - 新增 `src/public-api.js`; - CLI 和 Web 改为只从 Public API 导入; -- 不移动核心模块; -- 原测试全绿。 - -### PR 3:结构化错误 - -- 统一 Error Code; +- 不移动核心模块或改变事务顺序; +- 统一 Canonical Error Code 与 Legacy Adapter; - 保持现有人类提示; -- Web 与 CLI 映射; -- 加错误合同测试。 +- 增加 Public API、错误合同和入口隔离测试。 -### PR 4:CLI `--json` +### C2:CLI `--json` -- Status JSON; -- Sync JSON; -- Exit Code 合同; -- 文档; -- 自动化测试。 +- Human Mode 保持 v0.5 兼容; +- JSON Mode stdout 只输出一个版本化 envelope,进度与诊断进入 stderr; +- 固定 JSON Mode Exit Code 并以真实子进程测试。 -### PR 5:Prepare / Apply +### C3:Prepare / Apply、协调器与双层锁 -- 先把现有 Web 的 Revision 逻辑下沉; -- `prepareSync`; -- `applySync`; +- 把 Web Revision 逻辑下沉为 Core Plan/Apply; +- Sync、Switch、Restore 使用短期、单次、锁内重校验的 planId; +- Node 与迁移期 .NET 实现 Codex Home → State DB resource 双层锁; +- Watch 合并事件并让位于人工操作; - CLI 内部仍一次完成; - 兼容现有命令。 -### PR 6:Workspace 与 Core 骨架 +### C4:Workspace、Contracts 与 Core Client - npm workspaces; - `packages/core`,先包装阶段 1 的 `src/public-api.js`,不改变业务行为; - `packages/contracts`; - `packages/core-client`; -- 不迁移 UI,不在同一 PR 搬迁 Core 内部模块。 +- 根 npm 包继续独立提供 Node 16 CLI,Electron 依赖不进入其 tarball。 -### PR 7:React UI 分解 +### C5:共享 React UI 与 Web 迁移 -- `AppShell`; -- `OverviewFeature`; -- `SyncFeature`; -- `HttpCoreClient`; -- Web UI 保持可用。 +- 建立 AppShell、Design System、八个导航页面(Backups/Restore 合并为一页)、i18n 和主题; +- Web 通过 `HttpCoreClient` 复用 `app-ui`; +- 保留 pairing、Origin、Profile/Storage Revision、History 隐私边界。 -### PR 8:Electron Skeleton +### C6:Electron 安全骨架、Utility Runtime 与只读能力 - electron-vite; - electron-builder; -- Main/Preload/Renderer; -- 安全基线; -- Hello/Version 握手。 +- Main/Preload/Renderer/Core Utility Process 边界; +- 安全 BrowserWindow、白名单 IPC、Hello/Version 握手与 crash recovery; +- 只开放 Profile、Status、Backup、Diagnostics 和按需 History。 -### PR 9:Core Utility Process +### C7:Electron Sync / Switch -- Supervisor; -- Runtime Host; -- Status; -- Crash Test; -- Protocol Test。 +- 只经 Prepare/Confirm/Apply 开放写入; +- Provider 与三种 model 策略; +- Progress、Cancel、Partial、Backup-first 与安全 Fixture。 + +### C8:Restore / Watch / Diagnostics / Update + +- Restore v2 恢复前 snapshot、独立 journal、补偿与 ack reconciliation; +- Foreign Pending、Prune 保护、Watch 优先级、脱敏诊断包; +- Main-only 更新,写入或 Pending Recovery 时禁止安装。 + +### C9:打包、CI 与发布工程 + +- 四个目标平台产物、native SQLite、asar 审计、SBOM 与 checksums; +- Electron integration 与 packaged smoke 纳入唯一 `ci-gate`; +- CI 只生成候选 artifact,不自动发布。 -### PR 10:Read-only Preview Release +### C10:最终证据与 Legacy 交接 -- Windows/macOS/Linux package; -- GitHub prerelease; -- 使用说明; -- 反馈模板。 +- 同步最新 `main` 并重跑全部门禁; +- README 默认推荐 Electron,.NET 保留并标记 Legacy; +- 生成脱敏 evidence bundle; +- tag、npm/GitHub Release、签名、公证和更新通道继续等待单独授权。 --- @@ -2542,7 +2554,7 @@ Electron 只开放: 4. 不把业务规则写进 Renderer、Preload、IPC Handler; 5. 不新增第二套同步实现; 6. 不以“一次性翻译”为理由重写高风险 Core; -7. 一个 PR 只解决一个主要架构目标; +7. 默认一个 PR、或 ADR-0011 的一个内部 checkpoint,只解决一个主要架构目标; 8. 修改外部合同必须更新 Contract Test; 9. 修改安全流程必须补失败/回滚测试; 10. 不读取或输出 `auth.json`、Token、消息正文; @@ -2565,7 +2577,7 @@ Electron 只开放: ## 35. ADR 清单 -本文件确认总方向;以下细分决策在阶段 0 建立并逐项 Accepted: +本文件确认总方向;以下细分决策在阶段 0 和对应迁移 checkpoint 建立并逐项 Accepted: ```text docs/adr/ @@ -2578,7 +2590,11 @@ docs/adr/ ├─ 0007-shared-ui-through-core-client.md ├─ 0008-incremental-migration-no-big-bang-rewrite.md ├─ 0009-plan-confirm-apply-for-writes.md -└─ 0010-electron-vite-and-electron-builder.md +├─ 0010-electron-vite-and-electron-builder.md +├─ 0011-v1-single-branch-single-final-pr.md +├─ 0012-dual-resource-lock-contract.md +├─ 0013-restore-v2-recovery-state-machine.md +└─ 0014-npm-workspace-and-dependency-boundaries.md ``` ADR 一旦 Accepted,不应通过普通重构 PR 静默推翻。 diff --git a/docs/adr/0010-electron-vite-and-electron-builder.md b/docs/adr/0010-electron-vite-and-electron-builder.md index 98868bd..7361d5f 100644 --- a/docs/adr/0010-electron-vite-and-electron-builder.md +++ b/docs/adr/0010-electron-vite-and-electron-builder.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-08-24 +- Amended: 2026-08-27 (C9 release candidate packaging, fuses and final-container audit) - Scope: vNext ## Context @@ -10,7 +11,11 @@ Electron 需要统一构建 Main、Preload、Renderer 和 Core Runtime,并为 ## Decision -采用 `electron-vite` 组织 Electron 构建,采用 `electron-builder` 生成安装包与发布产物。阶段 0 只冻结工具组合,不锁定尚未引入仓库的具体版本号。 +采用 `electron-vite` 组织 Electron 构建,采用 `electron-builder` 生成安装包与发布产物。阶段 0 只冻结工具组合;C6 已按 ADR-0014 锁定 Electron `44.0.0`、electron-vite `5.0.0`、electron-builder `26.15.7`,并使用 Desktop 专属 Vite `7.3.6` / React plugin `5.2.0` 兼容组合。 + +C9 固定生成 Windows x64 NSIS/ZIP、macOS x64/arm64 DMG/ZIP、Linux x64 AppImage/deb。候选版本通过构建参数注入 `1.0.0-alpha|beta|rc.`,不改写根 npm 或 Desktop source manifest;构建命令始终带 `--publish never`。`better-sqlite3 13.0.3` 作为 Electron production fallback 针对当前 ABI 重建,包内只保留当前平台 binding,并将该 binding 单独放入 `app.asar.unpacked`。 + +生产 Fuse 固定关闭 RunAsNode、NODE_OPTIONS、CLI Inspect、Browser Process Custom V8 Snapshot 与 File Protocol Extra Privileges,启用 Cookie Encryption、Embedded ASAR Integrity 与 OnlyLoadAppFromAsar。审计必须读取最终 executable 的 fuse wire;Windows/macOS 还必须把 executable/plist 内嵌的 ASAR header hash 与实际 header 对齐,Linux 明确记录该 runtime binding 为 unsupported-platform,而不能伪报已验证。 ## Decision Drivers @@ -27,6 +32,10 @@ Electron 需要统一构建 Main、Preload、Renderer 和 Core Runtime,并为 - 构建不得把测试 Fixtures、真实用户数据、凭据或开发密钥打入产物; - Electron Major 升级必须运行 SQLite 驱动与 packaged smoke matrix; - 版本、签名、公证、更新通道和回滚策略由 Release 门槛验证。 +- ZIP/Installer/DMG/AppImage/deb 必须逐个解包或安装,再重复 ASAR、Fuse、native binding、敏感路径与 source-map 审计;builder 的 unpacked 目录不能替代最终容器证据; +- checksum 清单必须精确覆盖资产、审计、SBOM、容器报告与 release manifest,候选目录不得夹带未清单文件; +- C9 CI 只上传 unsigned、not-authorized 的短期候选,不自动创建 tag、npm 包或 GitHub Release。 +- Main 的更新能力使用编译期 `releaseAuthorized` fail-closed gate。缺省与所有 C9 candidate 固定为 false,不能创建 updater port、排定网络检查或安装;只有另行获授权且具备签名/metadata/升级证据的正式发布构建才可显式置 true,运行时环境变量不能事后开启。 ## Consequences @@ -40,7 +49,9 @@ Electron 需要统一构建 Main、Preload、Renderer 和 Core Runtime,并为 ## Migration and Validation -阶段 3 引入固定版本与 lockfile,并在 Windows x64、macOS x64/arm64、Linux x64 上验证安装/启动、SQLite、Utility Process 和 Renderer 隔离。版本选择必须处于 Electron 官方支持线。 +阶段 3/C6 在 Windows、macOS、Linux 的 host-native runner 上验证 `electron-builder --dir` unpacked app 启动、真实 SQLite、Utility Process 握手和 Renderer 隔离,不生成或发布安装器。C9 把四个原生目标、native fallback/ASAR、最终容器审计、安装或解包 smoke、SBOM/checksum 与 aggregate index 纳入唯一 `ci-gate`;两层门槛都必须通过,不能用开发态 Electron E2E 替代 unpacked 或最终容器 smoke。 + +V1 的 Windows x64 候选已在本地完成 ZIP/NSIS 最终容器验证;macOS x64/arm64、Linux x64 与四目标 aggregate 必须由对应 host-native CI 闭合。签名、公证、真实更新 metadata 和跨版本升级仍未获授权,不属于该本地证据。 ## Related diff --git a/docs/adr/0011-v1-single-branch-single-final-pr.md b/docs/adr/0011-v1-single-branch-single-final-pr.md new file mode 100644 index 0000000..69f0ae1 --- /dev/null +++ b/docs/adr/0011-v1-single-branch-single-final-pr.md @@ -0,0 +1,45 @@ +# vNext/ADR-0011:V1 单分支、单最终 PR 的受控交付例外 + +- Status: Accepted +- Date: 2026-08-25 +- Scope: V1 delivery governance + +## Context + +ADR-0008 的默认拓扑是小型、可独立合入的 PR。维护者已明确批准一次受控例外:V1 在单一 `V1` 分支中推进,并只创建一个最终 PR。该授权只改变合并拓扑,不改变 vNext 的安全、兼容、验证或发布门槛。 + +## Decision + +V1 使用本次批准计划中的不可变内部 Checkpoint `C0`~`C10`。旧执行索引中的 PR 2~PR 10 仅作为历史依赖来源,按以下方式并入新 checkpoint:Public API 与结构化错误归入 `C1`,CLI JSON 归入 `C2`,Prepare/Apply 与双层锁归入 `C3`,Workspace/Core/Contracts 归入 `C4`,共享 UI/Web 归入 `C5`,Electron Skeleton、Utility Runtime 和只读能力归入 `C6`,后续依次为 `C7` Sync/Switch、`C8` Restore/Watch/Diagnostics/Update、`C9` 打包/CI、`C10` 最终证据与 Legacy 交接。每个 checkpoint 必须有单独 commit、可重复 CI 证据、变更摘要和明确的上一个回退 commit。 + +最终 PR 必须保留这些 checkpoint 的线性、可审查历史。分支上的 checkpoint 可以标注“已验证”或“合入后可完成”,但在最终 PR 合入受保护分支前,不得把任一 Phase 标为 Completed、不得宣称 Electron 已替代 .NET,也不得删除 Legacy 实现或停止其关键 CI。 + +本 ADR 仅局部 supersede ADR-0008 的“每个主要维度必须以独立 PR 合入”这一合并拓扑。ADR-0008 的一个主要维度、可验证入口/退出条件、旧入口可用、测试/Fixture、.NET 保留、只读先行及差异登记等全部不变量继续有效。 + +## Invariants + +- 一个 checkpoint 只改变一个主要架构维度;不得把 Core 搬迁、TypeScript 翻译、算法变更、Electron 写能力和 Legacy 清理混在同一 checkpoint。 +- 每个 checkpoint 必须从干净输入重复运行其适用测试;跨运行时和三平台门槛仍以真实进程/packaged 证据为准,不能用 Mock 或分支声明替代。 +- V1 开始时和最终门禁前必须合并最新 `origin/main`,不得 rebase 或 force-push;若最终合并发生冲突,解决后必须重新验证全部适用证据。其他 checkpoint 只记录基线与回退 commit,不额外制造未经计划批准的合并要求。 +- Electron 写能力、Restore v2、Watch、默认桌面入口和 .NET 清理仍受执行索引对应退出门槛阻断。 +- 不得从 V1 分支对真实 Codex Home、真实凭据或真实消息正文进行测试、迁移或发布验证。 + +## Consequences + +该例外减少 PR 数量,但不减少审查单位;最终 PR 审查必须按 checkpoint 审阅。它增加主线漂移风险,因此需要 checkpoint 证据、频繁同步和可撤回的 feature gate。若真实 Beta、跨平台 package 或兼容门槛无法在最终合入前获得证据,V1 必须停在相应 checkpoint,不能用“单 PR 已获授权”绕过门槛。 + +## Rejected Alternatives + +- **把一条长提交链当作已完成的各阶段**:未合入受保护分支时,阶段状态和发布承诺不成立。 +- **用最终总 diff 代替 checkpoint 审查**:无法隔离安全回归或给出可靠回退点。 +- **为缩短分支周期放宽 Fixture、锁、Restore 或 packaged 验证**:与本 ADR 的授权范围不符。 + +## Migration and Validation + +执行索引记录 `C0`~`C10` 的目标、依赖、证据和回退点。每个 checkpoint 的验证必须更新适用合同和 Fixture;本 ADR 本身不表示任何运行时代码、错误适配、锁协议或 Restore v2 已实现。 + +## Related + +- [渐进迁移 ADR](0008-incremental-migration-no-big-bang-rewrite.md) +- [Node Core 单一权威 ADR](0002-node-core-as-single-authority.md) +- [迁移执行索引](../migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md) diff --git a/docs/adr/0012-dual-resource-lock-contract.md b/docs/adr/0012-dual-resource-lock-contract.md new file mode 100644 index 0000000..51d8ec4 --- /dev/null +++ b/docs/adr/0012-dual-resource-lock-contract.md @@ -0,0 +1,56 @@ +# vNext/ADR-0012:Codex Home 与 State DB 的双层资源锁合同 + +- Status: Accepted +- Date: 2026-08-25 +- Scope: vNext lock contract; implemented by the V1/C3 checkpoint, not yet released or merged + +## Context + +现有入口以 Codex Home 为主要操作边界;但两个不同 Codex Home 可以解析到同一 SQLite Home。仅持有各自 Codex Home 锁时,两个写者仍可能并行修改同一 `state_5.sqlite`。UI 内的“操作中”状态不能提供跨进程或跨运行时互斥。 + +## Decision + +vNext 的每个写操作按解析后的双层资源身份协调: + +1. **Codex Home lock**:绝对规范化的 Codex Home identity,锁路径固定为 `/tmp/provider-sync.lock`;它保护 config、rollout、global state、backup root 和同一 Codex Home 的操作顺序。 +2. **State DB resource lock**:保护最终解析出的权威 `state_5.sqlite`,且独立于 Codex Home identity。资源身份由数据库物理父目录的 `realpath` 与规范化文件名组成;Windows 对完整 identity 进行大小写折叠。数据库存在时使用最终物理路径;Restore 将创建缺失数据库时,先可靠解析父目录,再使用父目录与 basename。无法证明父目录、重解析点或最终目标身份时返回 `LOCK_UNVERIFIABLE`。 + +State DB resource lock 路径固定为: + +```text +/.codex-provider-sync/locks/.lock +``` + +Hash 输入使用 UTF-8 编码的规范化 resource identity;Node 与 .NET 必须产生相同小写十六进制 SHA-256。锁继续使用 protocol v2 的 canonical owner/claims/instance identity;owner/claim 可增加可选 `scope` 与 `resourceKey` 字段,旧 v2 reader 必须仍能读取基础字段并 fail closed。 + +需要 SQLite 的写操作必须按以下顺序执行:获取 Codex Home lock → 在 Home lock 内重新读取 config 并解析权威 State DB → 建立物理 resource identity → 获取 State DB resource lock → 在两锁内重新校验 Revision、pending journal 和 SQLite writable → 创建 backup → mutation/验证/终结 journal → 逆序释放锁。两个锁必须一直持有到 journal 落入耐久终态。`prune-backups` 仅获取 Codex Home lock;Status/History 不获取写锁;Watch 不长期持锁,而是每次 apply 按同一顺序获取。Windows WSL UNC 等不受支持路径在创建任一 backup、journal 或业务 mutation 前失败。 + +Node、迁移期 .NET、CLI、Web、Electron Runtime 必须使用相同锁路径、identity/hash 规则、owner metadata schema、持有周期和冲突语义。现有 protocol v2 Codex Home lock 兼容测试继续是迁移门槛;引入 State DB resource lock 时,双方必须通过真实争锁测试,之后才能宣称双层锁合同成立。 + +## Error Semantics + +- 已证明存在活跃、兼容的 owner 时返回 `OPERATION_BUSY`,`details.busyScope` 为 `codex-home` 或 `state-db`。 +- owner、协议、进程启动身份、锁目录/resource identity 或 ABA 状态不可验证时返回 `LOCK_UNVERIFIABLE`,并保留 `details.lockScope`;不得自动删除锁目录或降级为 Busy。 +- 已获取资源锁但 SQLite 引擎仍拒绝写入时返回 `SQLITE_BUSY`;它不等同于资源锁冲突。 +- 获锁后的 pending journal 返回 `PENDING_TRANSACTION` 或 `RECOVERY_REQUIRED`;该错误优先于普通写入。 +- lock path 创建/访问被拒绝时返回 `PERMISSION_DENIED`,而不是假装无竞争者。 + +## Invariants + +- 锁不是 backup 或 journal 的替代物;获得两锁不允许跳过 Plan、Revision、Backup-first、transaction journal 或验证。 +- 同一 State DB 的败方不得创建 backup、journal、config/rollout/SQLite/global-state mutation,且所有原始 Hash 保持不变。 +- 任一正式入口不得自行发明锁路径、只靠 UI 禁用按钮,或在持有 State DB resource lock 后再以相反顺序等待 Codex Home lock。 +- 已发布的 v0.5 仍是单层锁;V1/C3 的 Node 与迁移期 .NET 实现已采用双层锁,但在最终 PR 合入、完整 CI 与 release evidence 完成前不把它描述为已发布能力。 + +## Migration and Validation + +V1/C3 已建立 `node-dotnet-lock-contention`、`shared-sqlite-home-contention`、双层 lock-order、`lock-unverifiable` 与跨进程 Status Fixture。真实 Node/.NET 进程在同一临时 State DB resource key 上完成双向争锁,Node/.NET 各自验证两个 Home 共享 DB 时败方零 Backup/零业务变更。Windows WSL UNC 全 Hash 门槛仍按执行索引在 Electron 写能力开放前继续保留。任何旧协议的兼容读取或拒绝策略必须有独立 Fixture 和差异登记。 + +缺失数据库只在其物理父目录已经存在且能被 `realpath` 可靠证明时生成 resource identity;不得以 Codex Home lock 替代 State DB lock。Metadata v1 Restore 若目标父目录不存在,Node 与 .NET 均在任何 Backup、Journal 或目标 mutation 前返回 `LOCK_UNVERIFIABLE(state-db)`。 + +## Related + +- [Core 单一权威 ADR](0002-node-core-as-single-authority.md) +- [Plan / Confirm / Apply ADR](0009-plan-confirm-apply-for-writes.md) +- [Error Code 合同](../architecture/contracts/ERROR_CODES_ZH.md) +- [行为 Fixtures](../migration/BEHAVIOR_FIXTURES_ZH.md) diff --git a/docs/adr/0013-restore-v2-recovery-state-machine.md b/docs/adr/0013-restore-v2-recovery-state-machine.md new file mode 100644 index 0000000..0525142 --- /dev/null +++ b/docs/adr/0013-restore-v2-recovery-state-machine.md @@ -0,0 +1,66 @@ +# vNext/ADR-0013:Restore v2 的恢复前快照与独立 Journal 状态机 + +- Status: Accepted +- Date: 2026-08-25 +- Scope: vNext Restore contract; implemented by the V1/C8 candidate, not released until the final PR gates and merge complete + +## Context + +v0.5 Restore 会校验被选中的受管 backup 与已有 journal,但不会在恢复前创建新的 snapshot,也没有独立的 restore journal。若 config、global state、SQLite 或 rollout 已部分替换后进程崩溃,不能以与 Sync 相同的证据自动补偿。该已知安全债已在 Core 外部行为合同中记录。 + +## Decision + +Restore v2 在任何目标 mutation 前,必须在目标 Codex Home 的 managed backup root 创建一个**恢复前 snapshot**,并为这一次 Restore 创建独立、持久、追加式的 restore operation journal。该 snapshot 记录恢复前的允许目标、SQLite Home/DB identity、source backup identity、manifest hash、目标清单和 hashes;它不是对用户所选 source backup 的就地修改,也不改变 source backup v1/v2 的可恢复格式。 + +Restore v2 先校验 source backup、relocation 授权和目标边界,再取得 ADR-0012 所定义的资源锁;在锁内重新验证 preflight snapshot、storage identity、pending journal 和可写性,随后才创建恢复前 snapshot 和 journal。任何 snapshot 失败必须在 mutation boundary 前以 `BACKUP_FAILED` 或更具体错误失败。 + +## Restore Journal State Machine + +每个 Restore v2 journal 具有独立 `operationId`、`operationKind=restore`、source backup identity、pre-restore snapshot identity 和 schema/protocol version。其持久状态只允许: + +```text +prepared + -> applying + -> committing + -> committed-pending-ack + -> completed + +prepared | applying | committing + -> rollback-pending + -> rolled-back + or recovery-required +``` + +`completed`、`rolled-back` 与 `recovery-required` 是耐久终态;其中 `recovery-required` 仍是 write blocker。`committed-pending-ack` 表示目标内容已经提交,但调用方尚未完成终态确认:恢复流程必须重新读取 journal、核对 operationId、目标 manifest 与 hashes,然后只允许前进到 `completed`;不得在该窗口对已提交目标启动补偿。若无法证明目标与 manifest 一致,则进入 `recovery-required` 并保留 snapshot、journal、source backup 和已完成/未完成 targets。 + +pre-restore snapshot manifest 与 durable `prepared` event 必须绑定同一个 schema/protocol、operation、source backup、storage(含持久化的 `codexHomePhysical`)、required target kinds、resolver operation IDs、按顺序排列的完整 targets,以及 journal 所记录的 snapshot 物理目录。任一字段不一致,即使攻击者或故障同时重算 manifest hash,也必须在 compensation 或 commit acknowledgement 前进入 `recovery-required`,不得读取另一个 snapshot、补偿目标或确认完成。 + +历史 `transaction-journal.jsonl` 的 `protocolVersion: 1` 是 Sync/Switch transaction journal,并不是独立 Restore journal;Restore v2 继续通过既有兼容路径校验其 source backup 绑定,并在新 Restore 完成 commit acknowledgement 后标记该 source transaction 已恢复。独立 Restore journal 的首个格式就是 `restore-journal.v2.jsonl`(`schemaVersion: 2`、`protocolVersion: 2`),不存在 standalone Restore journal v1,也不得把旧 transaction journal 的 `committed`、`rolledBack` 或 `recoveryRequired` 状态猜测为 Restore v2 状态。未知 Restore journal schema/version 必须 fail closed 且不得改写;旧 reader 遇到未知 v2 文件同样必须 fail closed。 + +## Compensation, Crash and Foreign Pending Rules + +- mutation 或 crash 发生在 `prepared`、`applying`、`committing` 或 `rollback-pending` 时,后续普通写入必须被阻断。显式 recovery 可以重新打开原 journal 并依据 pre-restore snapshot 补偿到 `rolled-back`;也可以执行下述 evidence-preserving resolver Restore。 +- 补偿失败、目标 identity 改变、snapshot 覆盖不足或 journal 尾部不可信时,必须保留全部证据并返回 `RECOVERY_REQUIRED`;不得无 journal 地报告部分成功。 +- 只有与所选 source backup identity 完全一致、Codex Home 物理 realpath 一致、operationId 唯一且本次 Restore 覆盖其全部必要目标的 pending journal,才可由新的显式 Restore 收敛。新 Restore 必须创建自己的 pre-restore snapshot 和 journal;仅当新 journal 耐久到 `completed` 后,其 `resolvesOperationIds` 才把严格绑定的旧 journal 投影为“已解决”。旧 journal 原始 bytes 和原非终态保持不变,作为 crash evidence 保留;不得伪造其已执行过 `rolled-back` 补偿。 +- resolver projection 只影响 write-blocker 判断,不授予删除证据的权限。Prune 仍必须保护旧 journal 自身、其 source backup 与 pre-restore snapshot。不同 source、不同物理 Home、目标覆盖不足、重复 operationId、invalid tail 或未知 schema 的 pending 都继续阻断,且必须在新 snapshot/journal/mutation 前失败。 +- 物理 Home binding 以每个 journal `prepared.storage.codexHomePhysical` 的持久值为准;completed resolver 只有在 pending、resolver 和当前已加锁 Codex Home 的稳定物理 identity 全部一致时才可解除 blocker。不得重新解析可变的 lexical `codexHome`,把 junction/reparse 换接后的新位置当作历史证据的 identity。 +- Node 与 .NET 都必须识别对方生成的 Restore v2 journal、source backup v1/v2 和 terminal 语义;未知 schema/version 必须 fail closed,而非解析 message 或猜测状态。 +- 用户取消只允许在 Core 定义的安全点;取消后的 journal 是否需要恢复由 durable journal 状态决定,而不是由取消信号本身决定。 + +## Invariants + +- Restore v2 继续保留现有 restore options、relocation 双重授权以及 relocation 时不恢复旧 config 的规则。 +- Restore v2 不读取/复制认证数据、Token 或消息正文;所有测试只使用临时 Fixture。 +- Prune 不得删除 source backup、恢复前 snapshot、任一非 terminal Restore journal 引用的 backup,或被 completed resolver 投影为已解决但原始 journal 尚非 terminal 的证据。 +- 本 ADR 不把已发布 v0.5 Restore 描述为已经事务化;V1/C8 候选实现和 release 仍受执行索引阶段 5、C8 证据与最终合入门槛约束。 + +## Migration and Validation + +必须以真实 crash/fault-injection 验证恢复前 snapshot 失败、每个 journal 状态窗口、commit/rollback acknowledgement、restore-mid-failure、foreign pending 与 Node/.NET 双向恢复。仅当这些证据通过后,才可开放 Electron Restore 或宣称跨入口 Recovery 等价。 + +## Related + +- [Plan / Confirm / Apply ADR](0009-plan-confirm-apply-for-writes.md) +- [双层资源锁 ADR](0012-dual-resource-lock-contract.md) +- [Core 外部行为合同](../architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md) +- [行为 Fixtures](../migration/BEHAVIOR_FIXTURES_ZH.md) diff --git a/docs/adr/0014-npm-workspace-and-dependency-boundaries.md b/docs/adr/0014-npm-workspace-and-dependency-boundaries.md new file mode 100644 index 0000000..bbbcf28 --- /dev/null +++ b/docs/adr/0014-npm-workspace-and-dependency-boundaries.md @@ -0,0 +1,98 @@ +# vNext/ADR-0014:npm Workspace、根发布包与依赖边界 + +- Status: Accepted +- Date: 2026-08-25 +- Amended: 2026-08-27 (C9 Electron native fallback/release audit and C10 shared-UI test hardening) +- Scope: vNext C4 workspace baseline, C5 shared UI/Web, C6 Electron, C8 updater, C9 release engineering and C10 evidence hardening dependency boundaries + +## Context + +vNext 需要让 CLI、Web、Electron、Core、Contracts、CoreClient 与共享 UI 各自拥有明确包边界,同时继续发布现有根包 `@dailin521/codex-provider-sync`、原 `codex-provider` bin 和 Node `>=16.20.2` 运行合同。若根 CLI 在迁移期依赖 workspace symlink,开发仓库可能正常而发布 tarball 会缺包;若 Node 24 的 Vite/Electron 工具链进入根生产树,Node 16 安装合同也会被间接破坏。 + +## Decision + +仓库使用 npm workspaces 和一个根 `package-lock.json`,不引入第二套包管理器。工作区固定为 `apps/*` 与 `packages/*`,初始所有内部包均为 `private: true`;根包继续是唯一 npm 发布面。 + +C4 建立以下依赖方向: + +```text +contracts <- core +contracts <- core-client <- app-ui <- apps/web / apps/desktop +design-system -----------^ +test-fixtures(仅测试,不进入产品依赖图) +``` + +`packages/core` 在 C4 仅通过一个已审计例外导入根 `src/public-api.js`,不移动或翻译锁、备份、journal、SQLite、rollout 和 service 算法。包本身只导出 `createCoreFacade({resolveProfile})`;可信 Host 解析 profile ID/revision 为路径后,facade 实例才提供 vNext 固定方法,不导出 `runSync/runSwitch/runRestore/runWatch` 兼容适配器或存储辅助函数。Core workspace 保持 ESM JavaScript;C4 的 JSDoc、`checkJs`、`tsc --noEmit` 只覆盖该可信边界 bridge,仍在根 `src/` 的高风险算法继续由既有 JS 与集成测试约束,后续迁入时逐模块加入 checkJs。Contracts、CoreClient、App UI 边界和 Desktop 边界使用 TypeScript。 + +根包不得声明 React、Vite、TypeScript 或 Electron 生产依赖。C5 为 Local Web Host 批准一个窄运行时例外:根 `src/web-core-adapter.js` 可以导入随 tarball 检入的 `packages/contracts/dist` 与 `packages/core/src`,根 `files` allowlist 也只允许这两个 `packages/` 子树。例外不包含 workspace manifest、TypeScript source、CoreClient、App UI、Design System、Fixture、Electron 或 node_modules,且必须由 Node 16 安装态 Web smoke 证明不依赖 workspace symlink。其余 `apps/`、`packages/` 和 workspace build output 继续禁止进入根 tarball。 + +C5 已把旧 `web/src`、`web/index.html` 和 Vite 配置迁入 `apps/web`;`web/dist` 仅是根发布包使用的静态部署物。React 与所有现代构建依赖只归 private workspace,根 manifest 不重复声明。这样 npm 8 读取单一 workspace lockfile 并执行 `npm ci --workspaces=false --omit=dev` 时,根生产树不会因 hoisted production 标记误装 React。 + +C6 的 Electron、electron-vite、electron-builder、Desktop Vite/React plugin 和 Playwright 只声明在 private `apps/desktop` workspace。Main、Utility、Preload 与 Renderer 均由 electron-vite 打成自包含边界;根 npm manifest、生产树和 tarball 明确拒绝 `electron` / `electron-*`。正常 production build 以编译期常量移除测试 bridge;只有显式 `--mode test` 的本地/CI 测试构建才包含 crash/raw-request hook,运行时环境变量不能把 production bridge 升格为测试 bridge。 + +## Dependency Resolution + +2026-08-25 通过 npm registry 的 `latest`、`engines` 和 `peerDependencies` 元数据解析 C4 实际引入或迁移的依赖: + +| Dependency | Exact version | Resolution | +| --- | --- | --- | +| TypeScript | `7.0.2` | 最新 stable,非 beta/rc/next;workspace 构建固定 Node 24 | +| `@types/node` | `24.13.3` | Node 24 类型线的最新 stable;仅用于 Core bridge 的 Node 内置模块 checkJs | +| Vite | `8.2.2` | 最新 stable;要求 Node `^20.19.0 || >=22.12.0`,由 Node 24 job 执行 | +| `@vitejs/plugin-react` | `6.1.0` | 最新 stable;peer `vite ^8.0.0`,与 Vite 8.2.2 相容 | +| React / React DOM | `19.2.8` | 最新 stable;React DOM peer `react ^19.2.8` 闭合 | +| React types | `@types/react 19.2.18`、`@types/react-dom 19.2.5` | 仅供 Node 24 Web/共享 UI TypeScript 构建 | +| TanStack Query | `@tanstack/react-query 5.102.3` | C5 页面状态与显式失效;History 正文不进入 Query cache | +| React Hook Form / resolver / Zod | `react-hook-form 7.86.0`、`@hookform/resolvers 5.9.1`、`zod 4.4.3` | C5 产品输入 schema 与表单验证 | +| Radix primitives | `@radix-ui/react-dialog 1.1.23`、`@radix-ui/react-select 2.3.7`、`@radix-ui/react-slot 1.3.3`、`@radix-ui/react-toast 1.2.23` | C5 检入组件的无障碍 primitives;不引入远程运行时 | +| i18n | `i18next 26.4.0`、`react-i18next 17.0.12` | `zh-CN` / `en`,英文 fallback | +| UI utilities | `lucide-react 1.34.0`、`class-variance-authority 0.7.1`、`clsx 2.1.1`、`tailwind-merge 3.6.0` | 图标与检入组件样式组合 | +| Tailwind | `tailwindcss 4.3.3`、`@tailwindcss/vite 4.3.3` | 仅在 Node 24 Web workspace build 使用 | +| Playwright | `@playwright/test 1.62.1` | C5 production bundle 的真实 Chromium 验收;仅 dev dependency | +| Shared UI unit test | `vitest 4.1.11`、`@testing-library/react 16.3.2`、`@testing-library/dom 10.4.1`、`@testing-library/user-event 14.6.6`、`@testing-library/jest-dom 7.0.1` | C10 审计补强;全部只在 private `app-ui` workspace。Vitest 支持 Node 24 且与现有 Vite peer range 闭合 | +| Shared UI DOM runtime | `jsdom 29.1.1` | 支持 Node `>=24.0.0` 的最新稳定线;`30.0.1` 要求 `^24.15.0`,不满足既有 Node 24.11 本地/证据基线,故不采用 | +| Electron | `44.0.0` | C6 解析时最新 stable,并处于 Electron 官方支持线;只在 Desktop workspace | +| `electron-vite` | `5.0.0` | C6 最新 stable;peer 支持 Vite 5~7,与 Desktop Vite 7.3.6 闭合 | +| `electron-builder` | `26.15.7` | C6 最新 stable;先提供三平台 unpacked Alpha 构建,发布目标与 native fallback 留到 C9 | +| `electron-updater` | `6.8.9` | C8 解析时最新 stable,非 `next`/preview;仅 Desktop production Main 使用,与 `electron-builder` 的 GitHub provider 元数据闭合 | +| Desktop Vite / React plugin | `vite 7.3.6`、`@vitejs/plugin-react 5.2.0` | `electron-vite 5.0.0` 的兼容组合;与 Web workspace 的 Vite 8/plugin 6 分开锁定 | +| 根 `better-sqlite3` | `8.7.0` | 保留现有根 optional fallback;更新版本不满足根 Node 16 合同,不进入根生产树升级 | +| Desktop `better-sqlite3` | `13.0.3` | C9 最新 stable;仅 Electron production fallback,按 Electron 44 ABI rebuild,包内只保留 target-native binding | +| `@electron/asar` / `@electron/fuses` | `4.3.0` / `2.1.3` | C9 build-only 审计工具;读取 ASAR header/entry integrity 与最终 executable fuse wire | +| `resedit` / `plist` | `3.1.0` / `5.0.0` | C9 build-only 审计工具;分别验证 Windows PE 与 macOS Info.plist 的 embedded ASAR integrity binding | + +所有直接 dependency/devDependency/optionalDependency/peerDependency 使用精确版本,不使用 `^`、`~`、`workspace:*`、`file:` 或未锁定 URL。传递依赖由唯一 lockfile 锁定。候选经 `npm audit --omit=dev --audit-level=moderate` 与全树 `npm audit --audit-level=high` 检查;任一不合格候选不得进入 checkpoint。 + +Electron、electron-vite 与 electron-builder 已在 C6 按上述规则解析。C9 解析并锁定 Desktop `better-sqlite3`、ASAR/Fuse/PE/plist 审计工具;这些依赖只存在于 private Desktop workspace,不得写入根 manifest、根 production tree 或根 npm tarball。Desktop runtime SBOM 从唯一 lockfile 投影 production closure,必须包含 Electron framework 与 native fallback,但排除 Playwright、builder、Vite、审计工具和 test fixtures。 + +C8 增加的 `electron-updater` 只能由 `apps/desktop/src/main/updater.ts` 动态加载;Renderer、Preload、Utility、共享 UI 和 Core 不得导入 updater、指定 URL/channel 或接触原始 `UpdateInfo`。安装前由同一 `CoreRuntimeSupervisor` 同步关闭 restart gate,排空已 admission 的写请求,再执行 active Watch 与全部 Profile recovery 复核;失败路径必须重新开放 gate。C8 实现受控状态机与安装门禁,但 `apps/desktop` 版本仍为 `0.0.0` 时发布通道保持 disabled;实际版本注入、签名、更新 metadata 与真实跨版本升级 smoke 属于 C9/C10 发布门禁,不因依赖已接入而视为已发布。 + +## Invariants + +- 根 package name、bin、Node engine 和公开 CLI 文件闭包保持兼容; +- Node 16 job 只安装根生产树并运行现有 Node 测试;现代 workspace、Web 和未来 Electron 只在 Node 24 构建; +- `contracts` 不依赖 Node、DOM、React 或 Electron;`core-client` 只依赖 contracts;App UI 不依赖 Node/Electron;Renderer 将来不得导入 Core; +- HTTP、Desktop 和 Mock transport 使用同一版本化 request/response/progress envelope;协议不兼容先于业务失败; +- Apply transport 输入严格为 `{schemaVersion: 1, planId}`;Legacy error adapter 按结构化 code/DTO 分类,不解析 message; +- 根 tarball 必须在真实临时目录安装并执行 help/status/Web health 与 production shell,而不是只依赖 workspace 开发环境或 pack 预览。 + +## Consequences + +现代应用可逐步迁移到共享包而不同时搬动高风险 Core;根 CLI/Web 仍能独立安装。C4 的 `web/` source ownership 过渡已在 C5 结束:唯一现代 Web source 位于 `apps/web` 与 `packages/app-ui`,根只承载静态 `web/dist` 和经过审计的 Host/Core runtime 闭包。窄 tarball 例外增加了 packlist 与 Node 16 回归责任,任何扩大都必须另行修改本 ADR、边界测试和安装态 smoke。 + +## Validation + +- Node 24:完整 `npm ci`、TypeScript build、Core checkJs、workspace contract tests、Web production build; +- Node 16.20.2 + npm 8:根 production-only `npm ci` 无 workspace/UI 链接;根 tarball 分别完成无 lifecycle 内容检查与正常 lifecycle 安装,实际 bin help、synthetic SQLite 创建/打开和显式临时 Codex Home `status --json`; +- packlist:不存在 `apps/`、未批准的 `packages/`、workspace manifest、Electron、Fixture 或 node_modules;只允许 `packages/contracts/dist` 与 `packages/core/src`; +- import contract:除 `packages/core -> src/public-api.js` 的单一过渡例外外,禁止深度导入; +- security:生产树 moderate/high/critical 为零,全树 high/critical 为零。 +- C6:Node 24 production/test 两种 Electron bundle、production bundle test-hook 排除、Windows unpacked production SQLite/Utility smoke、真实 crash/restart/journal preflight E2E;同一 job 在 Windows/macOS/Linux 运行并受唯一 `ci-gate` 约束。 +- C9:Node 24 host-native 四目标 candidate build;Electron ABI fallback probe、最终容器 ASAR/Fuse/敏感内容审计、Status 与 Sync→Restore smoke、SBOM/checksum/manifest,以及四目标 commit/lockfile/tool/policy aggregate;CI 命令固定 `--publish never`。 + +## Related + +- [保留 Node CLI 合同](0003-preserve-node-cli-contract.md) +- [共享 UI 通过 CoreClient](0007-shared-ui-through-core-client.md) +- [渐进迁移](0008-incremental-migration-no-big-bang-rewrite.md) +- [Electron 构建选择](0010-electron-vite-and-electron-builder.md) diff --git a/docs/architecture/contracts/CLI_CONTRACT_ZH.md b/docs/architecture/contracts/CLI_CONTRACT_ZH.md index 219bb45..d1a7b71 100644 --- a/docs/architecture/contracts/CLI_CONTRACT_ZH.md +++ b/docs/architecture/contracts/CLI_CONTRACT_ZH.md @@ -1,10 +1,10 @@ # CLI 命令兼容合同 -> 状态:Phase 0 兼容基线 +> 状态:Accepted(Phase 0 Human 兼容基线;C2 JSON 合同已实现) > > 基线版本:`@dailin521/codex-provider-sync` v0.5.0 > -> 冻结日期:2026-08-24 +> 冻结日期:2026-08-24;C2 增量:2026-08-25 > > 适用入口:`codex-provider` @@ -12,10 +12,11 @@ 本文冻结 vNext 迁移开始时已经存在的 Node CLI 外部行为,防止提取 Node Core、增加 Electron、重组仓库或迁移到 TypeScript 时无意破坏现有用户和自动化脚本。 -本文同时明确区分三类内容: +本文同时明确区分四类内容: - **v0.5 当前合同**:当前已发布且迁移期间必须兼容的行为; - **legacy tolerated 行为**:当前宽松实现碰巧接受,但不升级为长期公开承诺的行为; +- **vNext C2 当前合同**:V1 分支已实现并由真实子进程测试冻结、但尚未公开发布的 opt-in JSON 行为; - **vNext 目标**:需要后续独立设计、测试和发布说明才能生效的新增合同。 目标架构文档中的建议不自动覆盖本文记录的 v0.5 事实。只有对应迁移阶段通过退出条件并更新合同测试后,才能修改当前合同。 @@ -53,6 +54,12 @@ - 若未来提高最低 Node.js 版本,必须独立评估 SemVer、发布说明和安装失败体验。 - npm 包仍应提供可直接运行的 JavaScript;CLI 用户不需要安装 TypeScript。 +### 2.3 vNext C4 安装入口兼容 + +根 npm tarball 仍以 `src/cli.js` 作为 bin 目标。判断该模块是否为直接执行入口时,必须分别规范化 `process.argv[1]` 与 `import.meta.url` 对应文件的物理路径;Windows 比较不区分大小写。这样可兼容 npm shim、临时安装目录、8.3 短路径与符号链接解析后的长路径。 + +该规范化只用于判断是否启动 CLI,不参与 Codex Home、SQLite Home 或任意业务目标解析。无法取得物理路径时可退回绝对词法路径,但不得因为两侧一种是短路径、另一种是长路径而静默跳过 CLI。已打包 tarball 的 `help` 和显式临时 Codex Home `status --json` 是该行为的可执行合同。 + ## 3. Codex Home 与 SQLite Home ### 3.1 Codex Home @@ -106,6 +113,16 @@ codex-provider --help 当前没有稳定的 `--version` 或短选项 `-h` 合同。 +### 4.3 vNext C2 JSON 入口 + +以下有限命令支持 opt-in `--json`: + +- `status`、`sync`、`switch`、`restore`; +- `prune-backups`、`install-windows-launcher`; +- 全局帮助或命令帮助。 + +`--json` 可置于命令前或后。`watch` 与 `web` 是长运行接口,不适用“stdout 只写一个终态 JSON 文档”的合同;C2 在创建 watcher、server、runtime descriptor 或浏览器进程前,以结构化 `INVALID_INPUT` 拒绝二者。若未来需要流式机器接口,必须使用独立协议,不能把日志行混入本合同。 + ## 5. `status` ### 5.1 语法 @@ -274,6 +291,7 @@ codex-provider watch [--codex-home PATH] [--sqlite-home PATH] [--debounce-ms N] ### 8.3 当前行为 - 启动时要求 Codex Home 与 `config.toml` 已存在; +- 启动前按物理 Codex Home 建立唯一 Watch scope;重复或并发启动同一 `realpath` Home 不创建第二个 watcher,Windows 比较不区分大小写;物理路径无法可靠解析时 fail closed; - 默认监听 `config.toml`、当前活动 `state_5.sqlite` 以及 `-wal`、`-shm` sidecar; - 配置变化后重新解析 SQLite Home,并重新绑定 DB watcher; - 每次触发同步时重新读取根级 model; @@ -379,9 +397,9 @@ codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] [--sqli ## 13. stdout、stderr 与退出码 -### 13.1 v0.5 当前合同 +### 13.1 Human Mode 兼容合同 -当前 CLI 是人类可读接口,没有 `--json`。 +未传入 `--json` 时继续使用 v0.5 人类可读接口,并保持原有 `0/1` 退出行为。 | 场景 | stdout | stderr | Exit Code | | --- | --- | --- | --- | @@ -395,14 +413,58 @@ codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] [--sqli 错误默认不输出 JavaScript stack。 -**当前不存在 `2`、`3`、`4`、`5`、`130` 的稳定退出码。** 目标架构中关于参数错误、partial、recovery、busy 和取消的细分退出码是 vNext 建议,不是 v0.5 事实。 +Human Mode 不采用 JSON 模式的 `2`、`3`、`4`、`5`、`130`;partial 仍为 `0`,其余既有失败仍为 `1`。这一区分防止现有脚本因 opt-in JSON 能力而改变行为。 + +### 13.2 vNext C2 JSON 合同 + +JSON Mode 的 stdout 必须恰好包含一个 UTF-8 JSON 文档和结尾换行;进度、运行时 warning 与诊断只能进入 stderr。顶层字段始终全部存在、顺序固定,且不得增加未版本化字段: + +```json +{ + "schemaVersion": 1, + "command": "sync", + "ok": true, + "outcome": "completed", + "result": {}, + "warnings": [], + "error": null +} +``` + +字段合同: + +| 字段 | 合同 | +| --- | --- | +| `schemaVersion` | 固定为整数 `1` | +| `command` | 规范化命令名;全局帮助为 `help` | +| `ok` | 业务调用是否得到成功 Result;`partial` 仍为 `true` | +| `outcome` | `completed`、`noop`、`partial`、`failed`、`failed_rolled_back`、`recovery_required`、`cancelled` 或 `stale` | +| `result` | 成功时为命令结果对象,失败时为 `null` | +| `warnings` | 字符串数组;没有 warning 时为空数组 | +| `error` | 失败时为安全的 `CoreErrorDto`,成功时为 `null`;不得包含 stack、cause、凭据、Token 或消息正文 | + +所有 Canonical Error Code 在 JSON 中使用固定安全 message;未知异常统一输出稳定的 `INTERNAL_ERROR`,不回显参数值或底层 message。Error details 只允许经审计且枚举/格式受限的 scope、reason、SQLite source/cause 字段;operationId 只接受 UUID,suggestedAction 不直接透传。成功 result 按命令使用字段 allowlist,只保留产品 DTO 中已审计的状态、计数、Provider/model 与路径字段,并移除凭据、Token、secret、stack/cause、prompt 和消息正文;底层 warning message 归一为稳定摘要。JSON 参数解析为严格模式:未知 flag、重复 flag、缺值、多余位置参数、布尔 flag 带值和 `--json=` 均以 `INVALID_INPUT` 失败;第 14 节的宽松行为只为 Human Mode 保留。 + +JSON Mode 退出码固定为: + +| Exit Code | 含义 | +| --- | --- | +| `0` | 成功或 noop | +| `1` | 普通失败,包含已安全回滚的失败 | +| `2` | 输入无效、Plan 过期或状态漂移 | +| `3` | partial success | +| `4` | recovery required 或 pending transaction | +| `5` | operation busy、SQLite busy 或 lock unverifiable | +| `130` | cancelled | + +CLI Exit Code 与 Error Code 是两层合同:多个 Canonical Error Code 可以映射到同一退出码。`--help --json` 返回 `ok:true` 的 schema v1 帮助结果;不存在稳定的 `--version` JSON 合同。 -### 13.2 vNext 变更规则 +### 13.3 后续变更规则 -新增细分退出码前必须: +以后新增或重映射退出码前必须: 1. 调查现有脚本是否依赖 `0/1`; -2. 明确旧人类模式与新 `--json` 模式是否共享退出码; +2. 明确 Human Mode 与 JSON Mode 是否改变各自退出码; 3. 增加真实子进程 Contract Test; 4. 在 CHANGELOG 和发布说明中声明; 5. 不把 partial、busy 或 recovery 的退出码变化混入无关重构。 @@ -431,14 +493,12 @@ codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] [--sqli ## 15. vNext 目标合同 -以下内容是目标,不是 v0.5 当前合同: +以下内容是 V1 后续目标;其中 C2 已实现的合同是后续阶段必须保持的不变量: - CLI、Local Web UI 与 Electron Desktop 调用同一个 Core Public API; - Desktop 不启动 CLI,也不解析 CLI 人类文本; -- 新增 opt-in `--json`; -- JSON stdout 使用稳定的 `schemaVersion`; -- JSON 模式 stdout 只含一个结构化结果,日志和进度进入 stderr; -- 建立明确的参数错误、partial、recovery required、busy 和取消退出码; +- 保持 C2 已实现的 opt-in JSON schema、stdout/stderr 分工和退出码矩阵; +- 在 Prepare/Apply 落地后,让 JSON 命令使用相同的 Plan/Result DTO,而不暴露内部适配器; - 使用稳定 Error Code,同时保留现有人类错误提示的核心语义; - 支持取消时使用安全取消点,而不是强制中止事务。 @@ -459,7 +519,11 @@ Phase 0 之后的 CLI 改造至少必须覆盖: - SQLite Home 优先级与 default-only legacy fallback; - Web 默认端口、回环绑定与复用; - Watch 默认 750 ms、once 成功退出与连续失败停止; -- npm `bin`、`engines` 与发布包包含 CLI/Web 运行所需文件。 +- npm `bin`、`engines` 与发布包包含 CLI/Web 运行所需文件;安装态 tarball 必须经真实 bin shim 完成 `sync --json → managed backup → drift → restore --json`,逐字节恢复 config/rollout、恢复 SQLite Provider 且不留下 pending recovery; +- JSON help、输入错误、成功、noop、partial、rolled-back、stale、recovery、busy、lock unverifiable 与 cancelled 均为单一 stdout 文档; +- JSON progress 只进入 stderr,未知异常、底层 warning 与序列化失败不泄漏 stack、cause、凭据、Token、secret 或消息正文; +- `watch --json` 与 `web --json` 在任何长运行副作用前被拒绝; +- Human Mode 的帮助、进度、partial 和 `0/1` 行为保持既有回归。 ## 17. 变更控制 diff --git a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md index 319a3d3..bb9fd4e 100644 --- a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md +++ b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md @@ -103,6 +103,12 @@ Web Profile 虽然不是 CLI,其显式 SQLite Home 仍沿用现有来源值 `c - Windows WSL UNC SQLite Home 不允许安全读写,写操作必须在备份和其他 mutation 之前失败; - 不得因为某个候选 DB 存在,就同时改写多个数据库。 +### 3.4 vNext 双层资源锁(V1/C3 候选已实现,尚未发布) + +已发布 v0.5 只以 Codex Home operation lock 为事实,不具备共享 State DB 的独立资源锁。V1/C3 已按 [ADR-0012](../../adr/0012-dual-resource-lock-contract.md) 实现候选合同:会修改 SQLite 的操作在同一 Codex Home lock 之外还必须持有按物理 DB identity 计算的 State DB resource lock,并以固定顺序在锁内重新校验 storage、Revision、pending journal 和可写性。 + +该合同不改变本节的 storage 优先级,也不授权未经阶段门槛的入口扩展写入。Node/.NET 兼容、owner metadata、Busy/不可验证错误和 Hash 无副作用已有本地 Fixture;只有同一最终提交的 required CI 与最终合入闭合后,才成为发布合同。 + ## 4. `getStatus` ### 4.1 当前输入 @@ -400,9 +406,9 @@ platform? - 恢复目标完成后尝试把绑定 journal 标记为 rolledBack; - 当前 journal terminal 标记失败会使 `runRestore` 整体 reject,即使部分恢复结果已经持久化;只有 inventory 刷新失败会降级为 warning。 -### 7.3 当前 Restore 安全债 +### 7.3 已发布 v0.5 Restore 历史安全债(V1/C8 候选已关闭) -`runRestore` 当前会获取正式 operation lock,也会校验被选中备份及其 journal,但**恢复操作自身不会先创建新的恢复前备份,也没有独立的 restore transaction journal**。因此,若 restore 在 config、global state、SQLite 或 rollout 已部分落盘后发生进程崩溃,当前实现没有与 sync/switch 同等级的自动补偿证据。 +已发布 v0.5 的 `runRestore` 会获取正式 operation lock,也会校验被选中备份及其 journal,但**恢复操作自身不会先创建新的恢复前备份,也没有独立的 restore transaction journal**。因此,该发布版若在 config、global state、SQLite 或 rollout 已部分落盘后发生进程崩溃,没有与 sync/switch 同等级的自动补偿证据。V1 当前分支已由 7.4 的 Restore v2 候选取代这条运行路径;在远端门禁与最终合入前,7.3 仍作为已发布 v0.5 的兼容和迁移依据保留。 这属于 v0.5 已知安全债,不是允许 vNext 继续保留的目标合同: @@ -412,7 +418,17 @@ platform? - 当前恢复目标已落盘但 rolledBack terminal 标记失败时,调用方只会收到失败,不能据此证明目标未恢复或 Journal 已收敛;vNext 必须增加 commit/terminal acknowledgement reconciliation; - 补齐安全机制时必须继续兼容 v1/v2 旧备份格式和现有 restore 选项。 -### 7.4 当前结果 +### 7.4 vNext Restore v2(V1/C8 候选已实现,尚未发布) + +[ADR-0013](../../adr/0013-restore-v2-recovery-state-machine.md) 冻结 Restore v2:在任何 restore mutation 前创建独立的恢复前 snapshot,并用独立 restore operation journal 记录 source backup、目标清单、每目标状态和 durable terminal。Node 与仍受支持的 .NET Core 都实现该协议。`prepared/applying/committing/rollback-pending` crash 必须由显式 recovery 依据该 snapshot 补偿,或由同 source、同物理 Home、完整目标覆盖的新 Restore 创建自己的 snapshot/journal 后以 completed resolver 收敛;`committed-pending-ack` 必须按目标 Hash 前进到 `completed` 或 `recovery-required`,不得对已提交目标启动反向补偿。 + +snapshot manifest 与 durable `prepared` event 必须全量绑定 schema/protocol、operation、source backup、storage(含持久化 `codexHomePhysical`)、required target kinds、resolver operation IDs、按顺序排列的完整 targets,以及 snapshot 的稳定物理目录;任何不一致都在 compensation 或 commit acknowledgement 前进入 `recovery-required`,不能仅凭重算后的 manifest hash 放行。 + +completed resolver 只在 source backup 稳定物理目录/revision、pending 与 resolver 持久化的 `codexHomePhysical`、当前已加锁 Codex Home 的稳定物理 identity、唯一 operationId 与 required target kinds 全部匹配时解除 write blocker;`backupId` 是由最终物理目录 basename 生成的展示/索引字段,不作为旧 journal 的独立安全键。Windows 下 source backup 与 Home 的长路径、8.3 短路径、junction 与大小写别名必须先解析到同一稳定物理目录,不得用 lexical path 或旧式 backupId 差异误判 foreign pending。Prepare、Apply、journal、备份读取和 inventory 刷新全程只使用该物理 source path,并在 mutation 前再次核对 source revision;无法两次可靠 realpath 时仍 fail closed。不得重新解析可变 lexical `codexHome` 来替代历史 physical binding。旧 raw journal 不改写,Prune 按物理目录继续保护其 journal/source/snapshot。foreign pending、重复 operationId、覆盖不足、invalid tail 或未知 journal/schema 必须在新 snapshot/journal/mutation 前 fail closed,不能依赖 message 推断结果。source backup v1/v2、现有 restore options 与 relocation 双重授权保持兼容。 + +已发布 v0.5 仍是 7.3 行为;本节实现只有在 C8、本 PR 远端门禁和最终合入完成后才成为发布合同。 + +### 7.5 当前结果 成功时返回经过验证的备份 `metadata.json` payload。v2 托管备份通常包含: @@ -510,6 +526,7 @@ archived=all | active | archived - 相同 thread id 保留 mtime 更新的文件; - 无 thread id 的会话使用基于 rollout 绝对路径 Hash 的稳定有界 ID; - 结果按 updatedAt、mtime 由新到旧; +- 无 query 的普通列表只读取最大 64 KiB 的首行 `session_meta` 和文件元数据,不读取消息正文;超限、非首行或格式无效的 metadata 不进入列表; - 搜索可以匹配会话标题、cwd、Provider 和安全抽取的用户/助手消息文本。 结果: @@ -535,9 +552,15 @@ archived createdAt updatedAt messageCount -firstUserMessage +messageCountKnown? ``` +vNext 公共 Core/HTTP/IPC 列表投影不返回 `rolloutPath`、`cwd` 或 `firstUserMessage`。`title` 只能来自显式 session metadata;metadata 没有标题时返回空字符串,由 UI 本地化显示“未命名会话”,不得用消息正文回退。列表搜索可以在本次只读扫描内匹配安全抽取文本,但消息正文不能进入列表 DTO、日志或缓存;正文只能由用户明确调用 `getHistorySession` 后返回。 + +无 query 的普通列表以受限首行 metadata 构造摘要:thread id、title、cwd、Provider/model、timestamp 分别限制为 512、1024、32768、512、128 字符,越界字段为空、使用固定缺省值或基于路径的 fallback ID;`updatedAt` 使用经复核的文件 mtime,`messageCount=0` 且 `messageCountKnown=false`;UI 必须隐藏该占位计数,不得显示为“0 条消息”。旧实现或全文扫描结果缺省 `messageCountKnown`,以及显式 `true`,均表示 `messageCount` 精确。用户显式输入 query 时才允许全文流式扫描,以匹配安全正文并返回精确计数和最后可见时间;扫描仍只保留计数、时间与 query 命中等常量聚合状态,不按消息数常驻 descriptor 或正文。 + +详情定位先用同一受限 metadata 路径去重并选定 rollout,只对用户选择的目标文件做一次全文读取;其他 rollout 不得因详情定位而扫描正文。目标必须绑定定位阶段记录的 regular-file identity、稳定物理路径与 sessions 根边界,从同一文件句柄读取并在读前/读后复核;删除、替换、symlink/junction 逃逸或保留 mtime 的换档均返回 `STALE_STATE`,不得返回另一文件的正文。 + ### 9.3 `getHistorySession` - sessionId 必填; @@ -556,6 +579,8 @@ firstUserMessage - 连续 5 次非 busy 失败后停止; - once 在首次成功 sync 后停止; - 返回 handle:Codex Home、config path、动态 state DB path、动态 SQLite Home、`stop()`、`done` 和可选 `signalPromise`。 +- `startWatch` 在创建 watcher 前以 `realpath(Codex Home)` 建立物理 scope(Windows 不区分大小写);同一物理 Home 的并发或重复启动返回同一个活动 snapshot,首个启动的 options 保持权威,不建立第二组 OS watcher; +- 物理 Home 无法可靠解析时 fail closed:权限错误为 `PERMISSION_DENIED`,其它缺失或不可解析状态为 `CODEX_HOME_NOT_FOUND`。手工或自动停止完成后释放活动 scope;终态 registry 只保留最近 64 条 stopped 记录。 ## 10. 跨进程锁与恢复状态 @@ -598,12 +623,9 @@ firstUserMessage ### 10.4 跨 Codex Home 共享 SQLite Home 盲区 -正式 operation lock 当前按 Codex Home 定位。因此,两个不同 Codex Home 如果显式解析到同一个 SQLite Home/同一个 `state_5.sqlite`,会获得不同的 operation lock,当前实现不能阻止它们并发修改同一数据库。 - -该行为是已知并发盲区,不是允许并发的合同。vNext 在宣称多 Profile 安全前必须二选一并形成 ADR: +已发布 v0.5 的正式 operation lock 只按 Codex Home 定位。因此,两个不同 Codex Home 如果显式解析到同一个 SQLite Home/同一个 `state_5.sqlite`,会获得不同的 operation lock,无法阻止它们并发修改同一数据库。V1/C3 已增加按物理数据库 identity 计算的 State DB resource lock,并保留 Codex Home lock 作为第一层。 -- 对规范化后的 SQLite 资源增加跨 Codex Home 的 resource lock;或 -- 检测共享目标并 fail closed,要求用户串行处理。 +该历史行为是已知并发盲区,不是允许并发的合同。ADR-0012 冻结的 vNext 选择已经在 V1/C3 候选实现;在同一最终提交完成真实跨运行时 required CI 与合入验证前,任何入口仍不得把多 Profile/shared SQLite 写入安全表述为已发布保证。 在此问题解决前,测试和文档不得把“不同 Profile/Codex Home”直接等同于“不同存储资源”。 @@ -616,14 +638,14 @@ firstUserMessage - restore 必须覆盖 journal 已开始或无法安全排除的所有目标; - 完成恢复后持久化 rolledBack terminal。 -当前 Node 与 .NET 对“选择的恢复备份之外仍存在其他 pending transaction”的处理尚未形成经过跨运行时测试证明的一致合同。Node restore 主要检查并标记所选 backup 内绑定的 journal;这不能自动证明另一个 foreign pending 已被解决。 +V1/C8 已统一 Node 与 .NET 对“选择的恢复备份之外仍存在其他 pending transaction”的处理:只要存在与所选物理 source/revision 不匹配的 foreign pending,Restore 在新 snapshot、journal 或目标 mutation 前以 `RECOVERY_REQUIRED` fail closed,且不得改写原 pending 证据。 vNext 统一规则必须是: - restore 只解决与所选备份明确绑定且恢复覆盖完整的 transaction; - 其他 foreign pending 保持 recovery blocker,不得被顺带清除或忽略; -- Node/.NET 迁移期需要增加同一 Codex Home 下多 pending/foreign backup 的交叉 Contract Test; -- 在测试证明一致之前,不得用任一运行时的当前行为替代正式合同。 +- Node/.NET 迁移期交叉 Contract Test `restore-v2-cross-runtime-foreign-pending` 必须在两个方向验证:另一运行时创建的 pending 保持字节不变、选择 foreign backup 被拒绝、数据和受管备份树无 mutation; +- 本地 Fixture 通过不等于发布完成;同一最终提交的 required CI 与合入证据仍是正式合同门槛。 ## 11. 当前错误合同 @@ -881,6 +903,9 @@ pruneBackups listHistory getHistorySession startWatch +stopWatch +getWatchStatus +getDiagnostics ``` 目标原则: @@ -894,7 +919,81 @@ startWatch - `--json` 是外部自动化合同,不是 Electron IPC; - Public API 可以新增 schemaVersion,但迁移适配器必须保持本文中的 v0.5 用户行为。 -这些目标在对应实现和测试完成前,不得用来否认当前一次性 `runSync/runSwitch/runRestore` 接口的兼容责任。 +V1/C3 已实现上述边界;`runSync/runSwitch/runRestore/runWatch` 仍作为 CLI 和旧调用方的弃用兼容适配器保留,不得供 Renderer 或新的 HTTP/IPC transport 使用。实现完成不等于已发布:已发布版本的兼容责任持续到对应迁移门槛和最终 PR 合入完成。 + +### 16.1 C3 Plan / Apply 合同 + +- `prepareSync/prepareSwitch/prepareRestore` 返回不可变 `PlanSummary` schema v1。`planId` 为 32-byte 随机不透明标识;TTL 固定 10 分钟;ledger 仅驻留当前进程并单次消费,重启、过期、重放和跨 operation 使用均返回 `PLAN_EXPIRED`。 +- Plan ledger 必须按最早 expiry 使用不阻止进程退出的自治 timer 清理弃置计划;不得依赖后续 consume 或新的 Prepare 才回收。人工 Plan intent 同样按每个 Home 的最早 expiry 自治清理并重新 arm,多个 Watch waiter 不得各自创建 10 分钟 timer。 +- `applySync/applySwitch/applyRestore` 只接受精确的 `{schemaVersion: 1, planId}`。任何附加路径、Provider、model、backupId 或 mutation 参数都返回 `INVALID_INPUT`,且不消费合法 Plan。 +- Apply 在 Home→State DB 双锁内重新读取可信 Profile、config、rollout inventory、State DB main/WAL/SHM 与 Restore source backup revision;任一漂移统一返回 `STALE_STATE`,且在 Backup/Journal/mutation 前停止。 +- Web 只公开 `*/prepare` 与 `*/apply`。旧 `/api/sync`、`/api/switch`、`/api/restore` 固定返回 `410 PLAN_REQUIRED`,不得调用兼容 `run*` 写入口。 +- Switch Plan 固定表达 `provider-default`、`keep-root-model`、`explicit` 三种 model intent;Apply 不再接收 model 参数。 + +### 16.2 协调、Status 与 Watch + +- 本进程协调器为同一 Codex Home 生成 operationId,并缓存最近一次完整 Status。写操作期间 Status 返回该完整 snapshot 加 `operationInProgress`;无缓存时返回 `rolloutScanComplete:false` 的保守快照。 +- Status 不获取写锁,而是只读检查 Home 与解析后的 State DB protocol lock,并在扫描前后核对 config/rollout/State DB revision。外部 Node/.NET 写者活跃或锁不可验证时不得扫描业务中间态;`LOCK_UNVERIFIABLE` 状态不得显示 aligned/healthy。Pending Journal 仍可作为恢复证据读取。 +- Web/Electron 的 Trusted Facade Status 只读取每个 rollout 的首条 `session_meta` 与文件元数据,仍完整返回 Provider 分布、SQLite 分布、锁、pending、backup 和 revision 安全状态;它不扫描 `encrypted_content`、user event 或 `turn_context` 正文。CLI/内部详细 Status 与所有 Prepare/Apply 仍执行完整内容扫描。元数据 rollout revision 只用于只读观察一致性,不得用于 Plan/Apply、Backup、Restore 或写前状态绑定。 +- `ProgressEvent` observer 的异常不能改变 transaction result。成功 OperationResult 的 `outcome` 为 `completed` 或 locked-rollout `partial`;失败通过结构化 Core Error/transport envelope 返回。 +- Watch 保持单飞、合并重复事件,每次重新 Prepare/Apply 并获取双锁。遇到本进程人工操作时保留当前事件批次、等待 operation completion 后只运行一次合并 follow-up;外部 Busy/不可验证锁不轮询、不计入连续业务失败,并等待新的受保护文件事件。 +- Diagnostics 只返回有界安全元数据;不得读取、复制或序列化 `auth.json`、token、凭据或消息正文。 + +### 16.3 C4 Trusted Profile Facade 与 CoreClient + +- `packages/core` 的模块导出仅为 `createCoreFacade({resolveProfile})`;factory 返回对象的业务方法集合精确等于本节 15 个目标方法。根 `src/public-api.js` 继续承载 CLI 与迁移适配器,不被描述为 Renderer 稳定 API。 +- `resolveProfile({profileId, profileRevision?})` 只能由 Local Web Host、Electron Main/Utility Host 或测试 Host 注入,返回可信的 `{id, revision, codexHome, sqliteHome?}`。Facade 必须验证 ID、revision 和绝对路径;selector revision 漂移时 fail closed,不能回退到 `CODEX_HOME` 或默认用户目录。 +- UI/HTTP/IPC 产品输入只包含 profile ID/revision、Provider/model mode、受管 backupId 等产品字段;不得携带 `codexHome`、`sqliteHome`、backup path 或底层 apply 参数。Apply 仍精确只收 schemaVersion/planId。 +- Status 在 facade 处移除 `codexHome`、`sqliteHome` 和 State DB 路径,只保留来源枚举、revision、分布与安全状态;warning 只能由固定类别/固定文案投影,不得透传底层任意字符串。 +- 备份列表在 facade 处移除 backup root、绝对 path 与 metadata 中的存储路径,只返回 `backupId`、size 和有界展示元数据;History 列表移除 rollout path、`cwd` 与首条消息预览,正文只能由用户明确调用详情方法后读取。 +- `TransportCoreClient` 对成功 payload 执行按方法的最小 runtime guard;协议版本不兼容映射为固定 `PROTOCOL_VERSION_MISMATCH`,其他畸形 envelope/result 收口为固定 `INTERNAL_ERROR`。HTTP 非 2xx 不得携带成功 envelope。 + +### 16.4 C5 Local Web Host 与共享 UI + +- `/api/core` 只接受带 `protocolVersion`、`requestId`、可选 `operationId`、`method`、`payload` 的版本化 POST envelope;请求体上限 64 KiB,content type、结构、方法输入和成功输出均由共享 contracts guard 验证。 +- 响应必须保留同一 `requestId`。非 2xx 不得伪装成功 envelope;不可信异常只返回固定、安全的 `INTERNAL_ERROR` DTO,不输出 stack、cause、路径、token、消息正文或原始异常文本。 +- Web Host 在进入 envelope handler 前验证一次性 pairing、设备凭据 hash 与 loopback Origin;Facade 只解析 server-managed profile ID/revision,Prepare 绑定 storage revisions,Apply 在双锁内重新核对。受管 backupId 在可信 Host/Core 边界解析;Renderer 不能通过 Core 输入提交任意路径。 +- React UI 的业务调用固定为 `HttpCoreClient → /api/core → createCoreFacade`;profile 管理、配对和忘记浏览器属于 Host API,不得把业务实现复制进 UI。 +- 共享 UI 的 Status、Watch 与 Update 状态只在首次进入时加载,并仅由用户明确点击刷新;不得使用定时器、窗口聚焦或网络重连触发后台刷新。用户明确执行写操作后的受控安全刷新仍属于该操作的完成确认,不视为后台轮询。 +- History 列表仅在用户进入 History 页面后读取;详情仅在用户明确选择会话后延迟读取,正文不进入 TanStack Query cache,离开页面时清空并取消 pending detail request。 +- Production HTML 使用每响应随机 nonce 的严格 CSP;无 `unsafe-inline`,外部导航、远程脚本和跨源 Core 请求不在允许面内。 + +### 16.5 C6 Electron Read-only Alpha + +- 数据流固定为 `Renderer → DesktopCoreClient → sandboxed Preload → Main IPC/Supervisor → Utility Process → createCoreFacade`。Renderer、Preload 和 Main 都不能导入 Core 实现;Utility 的唯一 Core 业务实现入口是 `@codex-provider-sync/core`,可依赖共享 contracts/client allowlist,但不得深度导入根 `src/`。 +- C6 IPC 仅允许 `getStatus`、`listBackups`、`listHistory`、`getHistorySession`、`getDiagnostics`。Sync/Switch/Restore/Prune/Watch 在 DesktopCoreClient、Preload、Main 和 Utility 四层均 fail closed 为 `PERMISSION_DENIED`;协议漂移在业务调用前返回 `PROTOCOL_VERSION_MISMATCH`。 +- Preload 公开面固定为 version、`core.requestReadOnly` 与 `profiles.list`,不暴露原始 IPC、Node、路径或通用 channel。production build 不含 test bridge;测试 hook 只能存在于编译期 test build。 +- Main 只接受主窗口顶层 `cps-app://app` sender,Core envelope 上限 64 KiB;Profile 列表只返回 `id/name/revision/codexHomeConfigured/sqliteHomeConfigured`,不得返回 Codex/SQLite 路径。 +- Runtime Hello 必须同时匹配 runtime/core protocol、app/core version、buildId、随机 nonce、generation 和精确只读 capability。崩溃立即拒绝全部 pending 为 `CORE_RUNTIME_CRASHED`;不后台重启;下一次用户请求每个 profile/revision 都必须先完成 `getStatus` pending-journal preflight,失败不得被后续请求绕过。 +- request timeout 必须终止当前 Runtime generation,避免迟到响应与复用 requestId 错误关联;下一次用户请求按 crash restart/preflight 规则处理。shutdown 是终结性、幂等操作,调用前后都拒绝新请求,不能产生孤儿 Utility。response 的 requestId/generation/operationId 及 preflight profile 必须与请求关联。 +- History 列表标题只能来自显式 metadata;无标题返回空字符串并由 UI 本地化。消息正文只在用户显式打开详情后返回,离开详情立即清空/abort,不进入 Query cache、日志或 Diagnostics。 + +### 16.6 C7 Electron Sync / Switch 候选边界 + +- DesktopCoreClient、Preload、Main IPC、Supervisor 与 Utility 只增加 `prepareSync/applySync/prepareSwitch/applySwitch`,Apply 仍只接收 `{schemaVersion:1, planId}`;Main 持有并一次性消费 renderer sender 绑定的 Plan ownership。 +- Renderer 只能提交 profile、Provider 和 `provider-default/keep-root-model/explicit` 三种 model intent;不得提交 Codex/SQLite/backup 路径或底层 apply 参数。自定义 Provider 必须由 Core 从可信 config 验证。 +- pending recovery 阻断 Sync/Switch。apply lifecycle 必须以 requestId/operationId 关联进度与取消;Runtime crash/timeout 立即拒绝 pending,下一请求重新 Status preflight。 + +### 16.7 C8 Electron Restore / Watch / Diagnostics / Update 候选边界 + +- DesktopCoreClient、Preload、Main IPC、Supervisor 与 Utility 只按精确方法组增加 `prepareRestore/applyRestore`、`pruneBackups/startWatch/stopWatch/getWatchStatus`。Main 持有 Restore Plan 与 Watch ID;Renderer 只提交 profile、受管 backupId、Restore options、keepCount 或有限 Watch 输入。 +- Recovery Required 时,Sync/Switch/startWatch 继续阻断;Restore 与 Prune 可作为 recovery-safe 操作进入 Core,stop/get Watch status 仍可用。Restore Apply 属于 cancellable write lifecycle,完成后使 Supervisor 的 Status preflight 失效并重新读取。 +- Restore snapshot/journal 持久化 `codexHomePhysical`;pending、resolver 与当前已加锁 Home 必须匹配该稳定物理 identity,不得用可变 lexical 路径的当前 realpath 擦除历史 binding。snapshot manifest 与 durable `prepared` event 必须全量绑定 schema/protocol、operation、source、storage、required kinds、resolver IDs、ordered targets 和 snapshot 物理目录。config、global state 与 rollout 的固定名称、物理 parent、reparse/symlink 边界必须在 snapshot、每目标 apply、补偿与 commit acknowledgement 前反复验证。任一绑定、边界或物理 identity 无法可靠证明时返回 `LOCK_UNVERIFIABLE(codex-home)` 或 `RECOVERY_REQUIRED`,不得读写被换接到 Home 外的目标。无目标 mutation 的取消只能写入验证型 compensation evidence,不得为“回滚”而重写原目标。 +- Watch 每次 apply 都重新 Prepare/Apply 并获取 Home→State DB 双锁。已 Prepare 的人工 Plan 具有优先级;Watch 合并重复事件并等待人工 intent 释放或过期,只运行一次 follow-up。首次遇到 `RECOVERY_REQUIRED/PENDING_TRANSACTION` 即停止,不继续自动写。同一物理 Codex Home 只能有一个 active/pending Watch;停止后释放 scope,终态历史有界。 +- Diagnostics Renderer 请求严格只有 `{schemaVersion:1, profile}`。输出目标由 Main 原生文件选择器产生并转换为 5 分钟、单次消费的随机 capability;最多保留 32 个未消费 capability,同一规范化目标只能被一个 capability 保留,写入前还必须按父目录 realpath 拒绝指向同一物理 ZIP 的并发路径别名。过期、显式 revoke、消费成功或失败都会释放目标 reservation;同 token 并发导出最多一方成功。token 和目标路径不跨 Renderer。ZIP 条目固定且再次执行共享 Diagnostics DTO exact validation,排除 `auth.json`、凭据、token、路径、rollout/DB、消息正文与 `encrypted_content`。 +- Update 只由 Main 的 `electron-updater` controller 管理,固定使用打包 metadata 中的 GitHub provider;不得调用 `setFeedURL`,Renderer 不得提交 URL、channel、路径、版本、silent/force 参数或接收 release notes、下载 URL、缓存路径和原始异常。Preload 仅暴露无参数的 `getStatus/check/download/install`,响应为脱敏 schema v2 状态。 +- `autoDownload` 与 `autoInstallOnAppQuit` 均关闭。检查、下载或更新错误不得改变 Core 结果。安装意图必须在 Supervisor 内同步关闭 restart gate,将已经入场但尚处于 preflight/dispatch 前的写请求计入 admission,并等待这些请求排空;此后新的 Sync/Switch/Restore/Prune/startWatch 立即返回 busy,只有 `getWatchStatus` 仍为只读。排空后,Main 必须通过既有 Utility `getWatchStatus` 重新核对其持有的 Watch ownership,清除已自动停止的缓存;查询失败或仍有 active Watch 时 fail closed。随后还必须确认 update 已下载、无写操作,并对全部已知 Profile 强制刷新 Status、证明无 pending recovery,最后才可调用 `quitAndInstall`;任一 Profile 无法验证、installer 抛错或安装未启动时均 fail closed 并重新开放 gate。 +- C8 只接入受控状态机和门禁。只有 Main 编译期 `releaseAuthorized=true`、packaged、受支持目标且版本/通道已配置时才允许创建 updater port、排定检查或执行任何网络/安装动作;缺省及所有未授权候选固定为 `disabled/not-authorized`。C9 候选显式注入 `releaseAuthorized=false`;签名、Update metadata、跨版本 packaged smoke,以及覆盖外部 CLI/Web/Watch 的跨运行时 maintenance lease 仍属于发布前门禁,未获得发布授权前不得把通道描述为已上线。 + +### 16.8 C9 Electron 候选产物与 CI 边界 + +- 候选版本固定为 `1.0.0-alpha|beta|rc.`,buildId 必须绑定完整 commit、target 与 run;source manifest 不因候选构建被改写。所有 builder 调用带 `--publish never`,候选 manifest 固定 `releaseAuthorized:false`、`signingStatus:unsigned-candidate`、`notarizationStatus:not-authorized`。 +- 目标集合恰为 Windows x64 NSIS/portable ZIP、macOS x64/arm64 DMG/ZIP、Linux x64 AppImage/deb。每个目标必须由同架构 host-native runner 构建;不得 cross-build native SQLite 后冒充实机证据。 +- Electron 优先使用 `node:sqlite`;`better-sqlite3` 作为 production fallback 必须针对当前 Electron ABI 验证加载。ASAR 只能引用当前 target 的一个 native binding,且该 binding 是 `app.asar.unpacked` 中唯一文件;其它平台 prebuild、source、build/deps 不得进入包。 +- 每个最终容器都必须实际解包或安装,并与 staging audit 逐字段一致;审计覆盖 ASAR 全文件/block hash、embedded header binding、fuse wire、敏感路径/文件/高置信 token、fixture/test/source map、native binding 与 production buildId。Windows NSIS 还必须完成静默卸载清理。 +- packaged smoke 只使用临时 synthetic fixture,以隐藏窗口启动正式 executable,验证 production test bridge 不存在、真实 SQLite Status、Sync→Restore byte/hash 回环与正常退出;不得访问真实 Codex Home、`auth.json`、凭据或消息正文。 +- 每个目标输出 CycloneDX SBOM、最终容器报告、release manifest 和 `SHA256SUMS.txt`。checksum 必须精确覆盖所有资产与 metadata;aggregate 必须证明四目标 version/commit/lockfile/tool versions/fuse policy/audit policy 一致,且任一 matrix job 失败、取消或跳过都使唯一 `ci-gate` 失败。 +- 推送 tag 不得自动发布。旧发布工作流改为显式 `workflow_dispatch` 并要求既有 `v` 前缀 tag 位于 `main`;这只是发布授权后的入口,不表示当前已获 tag、npm/GitHub Release、签名、公证或更新通道授权。 ## 17. Phase 1 提取要求 @@ -950,7 +1049,9 @@ Phase 1 只提取边界,不改变算法或结果: ### 18.4 Web -- pairing、Origin 和 device credential; +- 未配对 `/api/core`、一次性 pairing、Origin 和 device credential hash; +- 非 JSON content type、超过 64 KiB、畸形/版本不兼容 envelope 与未知 method; +- requestId correlation、非 2xx success 拒绝、Core/Host error 固定脱敏; - raw storage path 拒绝; - profile/storage revision required/changed; - 单写操作; @@ -958,7 +1059,8 @@ Phase 1 只提取边界,不改变算法或结果: - success/partial mapping; - alignment 不要求总数相等; - runtime storage identity 与安全复用; -- History 只读与分页边界。 +- History 列表/详情必须显式读取,详情正文不缓存且离页清空; +- production CSP nonce、八个共享页面、双语/主题、键盘焦点、reduced motion 与 200% 等效窄视口。 ## 19. 变更控制 diff --git a/docs/architecture/contracts/ERROR_CODES_ZH.md b/docs/architecture/contracts/ERROR_CODES_ZH.md index a1938b8..0c95700 100644 --- a/docs/architecture/contracts/ERROR_CODES_ZH.md +++ b/docs/architecture/contracts/ERROR_CODES_ZH.md @@ -1,8 +1,8 @@ # vNext Error Code 合同 -> **状态:Accepted(阶段 0 合同;代码适配尚未实施)** +> **状态:Accepted(阶段 0 合同;V1 C1 Core DTO、C2 CLI Adapter、C4 CoreClient 与 C5 Web envelope 公共净化已实施)** > -> **日期:2026-08-24** +> **日期:2026-08-24;实现增量:2026-08-26** > > **适用范围:Node Core、CLI、Local Web UI、Electron 与迁移期 .NET 适配层** > @@ -12,7 +12,7 @@ 本文冻结 vNext 的错误分类、兼容映射和演进规则,使调用方依据稳定的 `code` 决策,而不是解析自然语言 `message`、异常类型名或堆栈。 -本文不表示当前代码已经完成统一。当前 Node、Web 与 .NET 仍存在不同大小写、命名和结构;这些现状被列为 Legacy Surface,由后续结构化错误 PR 通过 Adapter 渐进收口。 +V1 的 Node Public API 已实现 Canonical `CoreError`/DTO,CLI JSON Adapter 与 C5 Web Core envelope 已按 Canonical Code 输出;Web 的非 Core Host transport code 与迁移期 .NET 仍存在不同大小写、命名和结构,这些现状继续列为 Legacy Surface,并由后续 Adapter 渐进收口。 ## 2. 稳定边界 @@ -27,15 +27,15 @@ interface CoreErrorDto { recoveryRequired: boolean; operationId?: string; details?: Record; - suggestedAction?: string; } ``` 稳定性规则: - `code` 是程序判断依据;Canonical Code 使用大写蛇形命名。 -- `message`、`suggestedAction` 可以改进、翻译或因平台而不同,不是机器合同。 -- `details` 只承载结构化诊断,不得包含认证信息、Token、消息正文或未脱敏的敏感内容。 +- C4 CoreClient/HTTP/IPC 公共边界的 `message` 按 code 使用固定安全文案;UI 本地化只依据 code,不能回显内部异常原文。Core 内部异常仍可携带操作建议,但 `suggestedAction` 不进入公共 DTO。 +- 公共 `details` 只允许 `busyScope`、`lockScope`、`causeCode`、`reason`、`missing`、`sqliteHomeSource`、SQLite 整数错误码和 `operationKind` 的固定枚举/范围;未知 key、路径、认证信息、Token、消息正文或任意建议文本全部丢弃。 +- 公共 `operationId` 只接受 UUID;不可信值不得透传。 - 普通用户默认不接收 Error Stack;诊断日志也必须遵守相同的隐私边界。 - 一个失败只选择一个最能指导恢复动作的顶层 Canonical Code;底层 OS/SQLite Code 可放入安全的 `details.causeCode`。 - Partial Result 可以携带 warning 级错误码,但不能把未完成写入伪装成完整成功。 @@ -49,7 +49,7 @@ interface CoreErrorDto { | `INVALID_INPUT` | 输入缺失、互斥参数、格式或范围无效 | error | 修改输入后是 | 否 | | `PROFILE_CHANGED` | 已确认的 Profile Revision 已变化 | warning | 重新读取并确认后是 | 否 | | `STORAGE_CHANGED` | 已确认的存储解析结果或 Revision 已变化 | warning | 重新准备 Plan 后是 | 否 | -| `PLAN_STALE` | Plan 绑定的文件、目标或指纹已变化 | warning | 重新准备 Plan 后是 | 否 | +| `PLAN_STALE` | 现有/过渡入口用于表示 Plan 绑定的文件、目标或指纹已变化;vNext Prepare/Apply 统一映射为 `STALE_STATE` | warning | 重新准备 Plan 后是 | 否 | | `CODEX_HOME_NOT_FOUND` | Codex Home 不存在或不可解析 | error | 修正路径后是 | 否 | | `STATE_DB_NOT_FOUND` | 权威 SQLite Home 中缺少要求存在的 `state_5.sqlite` | error | 修复目标后是 | 否 | | `SQLITE_UNSUPPORTED_PATH` | 当前平台不能安全访问该 SQLite 路径,例如 Windows WSL UNC | error | 更换执行环境或路径后是 | 否 | @@ -71,15 +71,22 @@ interface CoreErrorDto { ### 3.2 阶段 0 补充项 -以下代码补充了架构基线已经描述、但初始错误码列表没有单独命名的语义。它们从本合同起属于 vNext Canonical Code;当前实现尚未统一发出这些代码。 +以下代码补充了架构基线已经描述、但初始错误码列表没有单独命名的语义。它们属于 vNext Canonical Code;V1/C1~C3 的 Node 边界及迁移期 .NET 锁入口已经统一发出这些代码,但尚未作为已发布版本合同对外宣称。 | Code | 补充原因 | Severity | Retryable | Recovery Required | | --- | --- | --- | --- | --- | -| `PLAN_EXPIRED` | Plan 已有明确的过期时间和重新准备动作;它与存储内容变化导致的 `PLAN_STALE` 不同 | warning | 重新准备 Plan 后是 | 否 | +| `PLAN_EXPIRED` | Plan 已有明确的过期时间和重新准备动作;它与状态漂移导致的 `STALE_STATE` 不同 | warning | 重新准备 Plan 后是 | 否 | +| `STALE_STATE` | Apply 加锁后发现 profile/config/rollout/state DB 或 storage revision 与 Plan 不一致 | warning | 重新准备 Plan 后是 | 否 | | `LOCK_UNVERIFIABLE` | 无法可靠验证锁所有者、进程启动身份、协议版本或锁目录身份;不能误判为普通 Busy,也不能冒险删除 | error | 消除不确定状态后是 | 否 | `LOCK_UNVERIFIABLE` 必须 fail closed。只有确认存在活跃冲突所有者时才使用 `OPERATION_BUSY`;未来协议、损坏 owner、身份读取失败或 ABA/目录身份不确定均使用 `LOCK_UNVERIFIABLE`。用户提示不得建议盲目删除锁目录。 +V1/C3 的双层资源锁已要求 `OPERATION_BUSY.details.busyScope` 为 `codex-home` 或 `state-db`,`LOCK_UNVERIFIABLE.details.lockScope` 标示同一范围;SQLite 引擎在资源锁已获得后仍 busy 时继续使用 `SQLITE_BUSY`。Lock owner protocol v2 可带 `scope/resourceKey`,旧 Home-lock owner 仍按兼容读取规则处理。 + +vNext Prepare/Apply 对任何加锁后 revision 漂移只发出 `STALE_STATE`,并可用安全的 `details.reason=profile|config|storage|rollout|state-db` 说明维度。`PLAN_STALE`、`PROFILE_CHANGED` 与 `STORAGE_CHANGED` 在旧 Web/适配器完成迁移前仍可读取,但不得成为新 CoreClient/IPC 的稳定写操作结果。 + +ADR-0013 的 Restore v2 实现后,非 terminal restore operation journal 使用 `PENDING_TRANSACTION` 或 `RECOVERY_REQUIRED`;`details` 可安全包含 `operationKind=restore`、operationId、source backup identity、pre-restore snapshot identity 和 completed/uncompleted target 摘要,但不得包含消息正文、认证信息或任意原始路径。 + ## 4. Legacy Surface 现状 ### 4.1 Node Core / CLI @@ -90,7 +97,7 @@ interface CoreErrorDto { | `SYNC_FAILED_ROLLED_BACK` | `SyncTransactionError` | 已与 Canonical 同名 | | `ABORT_ERR` | Node 取消路径 | Node 习惯码,不作为 vNext Canonical Code | | Node/OS `ENOENT`、`EACCES`、`EPERM` 等 | 普通 `Error` 或系统调用 | 只能作为底层 cause;当前大量错误还没有稳定业务码 | -| 无稳定 code 的锁错误 | Node `locking.js` | 后续必须按“已证明 Busy”或“不可验证”结构化,禁止解析 message | +| `OPERATION_BUSY` / `LOCK_UNVERIFIABLE` | Node `locking.js`、State DB resource lock | V1/C3 已按“已证明 Busy”或“不可验证”结构化,并携带 scope;禁止解析 message | 当前 CLI 只把错误 `message` 写入 stderr 并以 `1` 退出。本文不把当前人类提示提升为机器协议。 @@ -125,12 +132,12 @@ interface CoreErrorDto { | `RECOVERY_REQUIRED`、`recovery_required` | `RECOVERY_REQUIRED` | 保留备份路径、operationId 与恢复要求 | | `SYNC_FAILED_ROLLED_BACK`、`sync_failed_rolled_back` | `SYNC_FAILED_ROLLED_BACK` | `recoveryRequired=false`,保留 rollback status | | `ABORT_ERR`、`cancelled` | `OPERATION_CANCELLED` | 映射前检查 Journal;不能仅凭取消认定无需恢复 | -| `TARGET_BUSY`、`target_busy`、`operation_busy` | `OPERATION_BUSY` | 仅适用于已证明存在活跃竞争者;用 `details.busyScope` 区分 coordinator/target | -| 锁 owner/协议/进程身份无法验证,未来协议或目录身份不确定 | `LOCK_UNVERIFIABLE` | 必须 fail closed,不得降级为 Busy 或自动清锁 | -| `plan_stale` | `PLAN_STALE` | 调用方必须丢弃旧 Plan | +| `TARGET_BUSY`、`target_busy`、`operation_busy` | `OPERATION_BUSY` | 仅适用于已证明存在活跃竞争者;vNext 用 `details.busyScope=codex-home|state-db` 区分资源范围 | +| 锁 owner/协议/进程身份无法验证,未来协议或目录身份不确定 | `LOCK_UNVERIFIABLE` | 必须 fail closed;vNext 用 `details.lockScope=codex-home|state-db`;不得降级为 Busy 或自动清锁 | +| `plan_stale`、`PLAN_STALE` | `STALE_STATE` | Prepare/Apply 调用方必须丢弃旧 Plan;旧直连 Web 入口迁移前可保留原码 | | `plan_expired` | `PLAN_EXPIRED` | 调用方必须重新生成 Plan | -| `PROFILE_CHANGED` | `PROFILE_CHANGED` | Web/Client 刷新 Profile 后重新确认 | -| `STORAGE_CHANGED` | `STORAGE_CHANGED` | 重新解析 Storage 与 Revision | +| `PROFILE_CHANGED` | `STALE_STATE` | Prepare/Apply 使用 `details.reason=profile`;旧直连 Web 入口迁移前可保留原码 | +| `STORAGE_CHANGED` | `STALE_STATE` | Prepare/Apply 使用 `details.reason=storage`;旧直连 Web 入口迁移前可保留原码 | | `operation_failed` | 优先映射具体码,否则 `INTERNAL_ERROR` | 禁止把所有失败永久折叠为 `INTERNAL_ERROR` | | Node/OS `ENOENT` | 由操作上下文映射 | Codex Home、State DB、Backup 的缺失必须分别分类 | | Node/OS `EACCES`、`EPERM` | `PERMISSION_DENIED` | 原始系统码可放入安全 details | @@ -143,14 +150,30 @@ Adapter 必须按 typed exception、明确属性和调用上下文映射。禁 ### Core / Core Runtime - 返回 Canonical Code 和稳定 DTO。 +- CoreClient transport 会重新验证固定文案、severity/retryable/recoveryRequired、UUID 与 details 白名单;畸形错误或成功 payload 统一收口为安全 `INTERNAL_ERROR`,protocolVersion 不兼容单独为 `PROTOCOL_VERSION_MISMATCH`。 - Runtime Crash 与业务失败分开;重启后首先检查 Pending Journal。 - Progress Event 失败不能替换最终业务错误。 ### CLI - 当前 Human Mode 保留现有人类提示兼容性。 -- 未来 `--json` 只输出 Canonical Code;stdout 为单一 JSON,日志进入 stderr。 +- C2 `--json` 只输出 Canonical Code;stdout 为单一 schema v1 JSON 文档,日志和进度进入 stderr。 - CLI Exit Code 与 Error Code 是两层合同,不一一等同。 +- CLI JSON presenter 不透传 Core/系统原始 message:每个 Canonical Code 使用固定安全文案,details 使用枚举/格式 allowlist,suggestedAction 不直接透传,operationId 只接受 UUID。 + +JSON Mode 的当前映射为: + +| Exit Code | Canonical Code / Result | +| --- | --- | +| `0` | completed 或 noop | +| `1` | 其他普通失败,包括 `SYNC_FAILED_ROLLED_BACK` | +| `2` | `INVALID_INPUT`、`PLAN_EXPIRED`、`PLAN_STALE`、`STALE_STATE` 及迁移期 revision 漂移码 | +| `3` | partial Result | +| `4` | `RECOVERY_REQUIRED`、`PENDING_TRANSACTION`,或 DTO 标记 `recoveryRequired:true` | +| `5` | `OPERATION_BUSY`、`SQLITE_BUSY`、`LOCK_UNVERIFIABLE` | +| `130` | `OPERATION_CANCELLED` | + +未知或不可信异常必须收口为稳定的 `INTERNAL_ERROR` 文案;CLI JSON 不回显 stack、cause 或任意未知异常 message。Human Mode 的既有 `0/1` 行为不随该表改变。 ### Local Web UI @@ -171,6 +194,6 @@ Adapter 必须按 typed exception、明确属性和调用上下文映射。禁 - 修改 `retryable` 或 `recoveryRequired`:视为合同变更,必须补充故障注入测试。 - Legacy Adapter 可新增映射;不得让已知 Legacy Code 回退为字符串解析。 -## 8. 阶段 0 验收边界 +## 8. 阶段实现边界 -阶段 0 只冻结分类与映射,不修改异常类、CLI 退出码、Web 响应或 .NET protocol 0.4。统一实现、`--json` 与 Contract Test 分别在后续 PR 完成。 +阶段 0 冻结分类与映射;V1 C1 已实现 Node 异常类和 DTO,C2 已实现 opt-in CLI JSON/退出码与真实子进程 Contract Test,C4 已实现 Contracts/CoreClient 的公共错误净化与 runtime guard。V1 C5 的 React Web Core 业务请求已统一经 `HttpCoreClient → POST /api/core` 的 versioned envelope;Host 在调用 Core 前继续执行 pairing、loopback Origin 和可信 profile revision 校验,`PROFILE_REVISION_REQUIRED` / `STORAGE_REVISION_REQUIRED` 等仍是 Web transport code。未知 Host/Core 异常收敛为固定 `INTERNAL_ERROR`,不回显原始 exception。迁移期 .NET protocol 0.4 仍保持旧码兼容,因此尚不能据此声称所有入口的错误合同已完全统一。 diff --git a/docs/migration/BEHAVIOR_FIXTURES_ZH.md b/docs/migration/BEHAVIOR_FIXTURES_ZH.md index bfc2ca1..536dad0 100644 --- a/docs/migration/BEHAVIOR_FIXTURES_ZH.md +++ b/docs/migration/BEHAVIOR_FIXTURES_ZH.md @@ -1,6 +1,6 @@ # vNext 行为兼容 Fixture 清单 -> **状态:Accepted(阶段 0 语义清单;共享 Corpus 尚未创建)** +> **状态:Accepted(阶段 0 语义清单;C2 动态 CLI Fixture、C4 安全 Runner/Schema、C5 首批跨运行时静态 Corpus、C7/C8 Electron 动态 Fixture 与 C9 候选产物 Fixture 已实现)** > > **日期:2026-08-24** > @@ -12,7 +12,7 @@ 本文冻结需要被共享 Fixture 表达的场景、输入语义和验收结果,用于迁移期间比较 Node 与 .NET,并为 Node 单核心提供长期回归证据。 -阶段 0 **不创建** `packages/test-fixtures/`,也不提交伪造的 SQLite、锁文件或平台专属二进制。当前 Node 测试使用临时目录动态构造输入,.NET 测试使用 `TestCodexHomeFixture` 和 GUI E2E `IsolatedFixture`;后续实现共享 Corpus 时应复用这些已验证的构造能力。 +C4 已建立私有 workspace、严格 schema 和只向临时目录复制的安全 Runner。C5 检入首批完全合成的 `bidirectional-backup-roundtrip` 与 `foreign-pending-restore` 静态输入:只有 fake provider、空正文 thread row、`session_meta` 和 SQLite seed SQL,不提交 SQLite 二进制、锁文件、平台专属二进制或真实用户数据。Driver 每次在临时目录 materialize SQLite,并为四个 Node↔.NET 方向复制同一输入;现有动态 Node/.NET fixtures 继续补充更广的行为矩阵。 Fixture 不是用户数据样本,严禁从真实 `~/.codex`、认证文件或私人会话复制内容。 @@ -51,6 +51,7 @@ Fixture 不是用户数据样本,严禁从真实 `~/.codex`、认证文件或 | `root-model` | 根级 model、Provider section model 与 turn_context model 不同 | Follow/Keep/Explicit 三种 Switch 语义清晰;非目标字段和换行符不变 | | `encrypted-content` | rollout 含来自原 Provider 的 `encrypted_content` | 只同步可见性元数据;保留加密内容字节并返回明确 warning | | `large-rollout` | 超大 rollout、超过 64 KiB 的行、Unicode 与特殊 model 字符 | 流式处理且目标字段正确;未修改字节、CRLF 与原 mtime 按合同保持 | +| `status-metadata-boundary` | 大 rollout 首行含 `session_meta`,正文设置禁止读取 sentinel | Web/Electron Facade Status 仅以首行和 stat 完整统计 Provider 并成功;CLI/Prepare 仍触发正文扫描,元数据 revision 在文件 size/mtime/ctime 漂移时变化 | | `malformed-rollout` | 截断、无效 JSONL、文件扫描期间消失等 | 不读取越界、不覆盖无法证明安全的内容;按操作返回 skip/error 并保留原字节 | ## 4. SQLite 与存储布局 @@ -63,31 +64,61 @@ Fixture 不是用户数据样本,严禁从真实 `~/.codex`、认证文件或 | `sqlite-malformed` | `state_5.sqlite` 不是有效数据库或缺少关键结构 | Status 降级报告 `SQLITE_UNREADABLE`;写操作在 Backup/rollout mutation 前停止 | | `sqlite-live-wal` | 数据仍在 WAL,数据库由另一连接保持打开 | 官方 online backup 生成单一可独立打开的 main DB;不把 WAL/SHM 当备份清单文件 | | `wsl-unc-unchanged-hash` | Windows 下 SQLite Home 为 `\\wsl.localhost\...` 或 `\\wsl$\...` | Status 只诊断;写操作返回 `SQLITE_UNSUPPORTED_PATH`,配置、rollout、DB、global state 与 backup root 的 Hash/存在性全部不变 | +| `real-wsl-unc-strict` | 健康 Windows+WSL2 runner 从真实 ext4 Home 调用 Windows Core/Electron | `CPS_REQUIRE_REAL_WSL=1` 时缺少可运行发行版或 `CODEX_PROVIDER_SYNC_WSL_SQLITE_HOME` 必须失败而非 Skip;main DB、WAL、SHM、journal 的存在性与 Hash 全部不变 | ## 5. 锁、并发与恢复 | Fixture ID | 运行方式 | 关键预期 / 安全门槛 | | --- | --- | --- | | `locked-rollout` | 平台真实文件锁 | 锁定文件不被写;其他安全目标可形成 Partial Result;返回 `ROLLOUT_LOCKED` 语义并可重试 | -| `active-rollout-changing` | 扫描后、应用前改变目标 | 变化文件被跳过或 Plan 失效;不覆盖 Codex 新写入,使用 `ROLLOUT_CHANGED`/`PLAN_STALE` | +| `active-rollout-changing` | 扫描后、应用前改变目标 | 变化文件被跳过或 Plan 失效;不覆盖 Codex 新写入;Prepare/Apply 返回 `STALE_STATE`(`details.reason=rollout`),旧直连入口可保留 `ROLLOUT_CHANGED` | | `sqlite-busy` | 真实 SQLite 写锁 | 在 rollout mutation 和 Backup 前阻断,返回 `SQLITE_BUSY`,全部原始 Hash 不变 | | `node-dotnet-lock-contention` | 启动真实 Node 与 .NET 进程争用同一 `/tmp/provider-sync.lock` | 恰有一个写者获得 protocol v2 锁;另一方为 `OPERATION_BUSY`;败方不创建 Backup、不改任何目标 | | `shared-sqlite-home-contention` | 两个不同 Codex Home 指向同一 SQLite Home,并发写 | 不能因 Codex Home 锁不同而同时写同一 DB;阶段 1 必须验证/裁决共享资源锁,在通过前不得开放 Electron 写能力 | +| `dual-resource-lock-order` | 两个 Codex Home 与一个 SQLite Home 由 Node/.NET 交叉并发写 | 两层 lock 路径和顺序一致;不死锁;恰有一个 SQLite writer;败方无 Backup、Journal 或业务 mutation | +| `sqlite-resource-lock-unverifiable` | State DB resource lock 的 owner、协议、物理路径 identity 或 ABA 状态不可验证 | fail closed,返回 `LOCK_UNVERIFIABLE` 且范围为 state-db;不得自动删除或降级为 Busy | +| `restore-missing-state-db-parent` | Metadata v1/v2 Restore 指向缺失 DB,且其物理父目录也不存在 | Node/.NET 都在任何 Backup、Journal、config/rollout/DB mutation 前返回 `LOCK_UNVERIFIABLE(state-db)`;不得用 Home lock 代替资源锁 | | `lock-unverifiable` | future protocol、损坏 owner、进程启动身份不可读、ABA/目录身份变化 | fail closed,返回 `LOCK_UNVERIFIABLE`;不得误报普通 Busy,不得自动删除不可证明归属的锁 | +| `external-write-status-snapshot` | 真实第二进程持有 Home→State 双锁,或另一 Home 只持共享 State DB 锁,并在锁内改变 config/SQLite | Core 与 Local Web 不扫描中间态;有缓存时逐字段保留最后完整 snapshot 并附 operation,无缓存时 `rolloutScanComplete:false`;不可验证锁不得显示 aligned/healthy | +| `plan-ledger-replay-expiry` | Plan 过期、重放、跨 operation、重启失效、篡改 apply payload | 只允许当前进程内 10 分钟单次消费;失效返回 `PLAN_EXPIRED`,附加字段返回 `INVALID_INPUT`,均无 Backup/Journal/mutation | +| `plan-ledger-abandoned-expiry` | Prepare 后调用方离开且没有 consume/waiter;同一 Home 有多个不同 expiry 的人工 intent | 单一最早到期 timer 自治清理并 rearm,不阻止进程退出;Watch 在最后 intent 到期后只恢复一次,不产生每 waiter timer | +| `watch-manual-priority` | 单一文件事件触发 Watch,但人工 Apply 已持有本进程协调器;等待期间继续产生重复事件 | Watch 不并发、不计失败;保留并合并 reasons,人工 operation completion 后恰运行一次 follow-up;stop 后 callback 不再 Apply | +| `watch-physical-scope-dedupe-and-bounded-history` | 同一物理 Codex Home 通过重复、并发或路径别名启动 Watch,并在自动/手工停止后重启 | 只创建一个活动 watcher、返回同一 watchId 且首个 options 生效;停止释放 scope,旧 watch 仍可查询/幂等 stop;最多保留 64 个 stopped 记录 | | `pending-journal` | Managed Backup 中存在未终结 Journal | Status 可读并暴露恢复证据;Sync/Switch 被 `PENDING_TRANSACTION`/`RECOVERY_REQUIRED` 阻断。Prune 仍可作为 recovery-safe maintenance 执行,但必须保护所有 Pending Journal 引用的备份 | | `foreign-pending-restore` | Node 创建 Pending Journal/Backup 后由 .NET Restore,及反方向 | 两个方向都只按受管清单恢复,清除 Pending 前必须落入合法 terminal;差异需显式裁决 | | `restore-mid-failure` | Restore 在某一目标已替换后注入失败 | 不能报告成功;必须完整补偿,或保留可操作证据并返回 `RECOVERY_REQUIRED`,不得留下无 Journal 的半恢复状态 | +| `restore-v2-pre-snapshot-failure` | Restore v2 的恢复前 snapshot 在任何目标 mutation 前失败 | `BACKUP_FAILED` 或更具体失败;不创建 restore mutation,source backup 与原始目标 Hash 不变 | +| `restore-v2-journal-crash-matrix` | Restore v2 在 prepared/applying/committing/committed-pending-ack/rollback-pending 和 ack 窗口终止 | 非 terminal 阻断普通写并可由 pre-restore snapshot 或目标 hash 显式收敛;completed/rolled-back/recovery-required 经重新读取确认,不能反向改写 terminal | +| `restore-v2-foreign-pending` | Node 或 .NET 留下 Restore v2 pending,另一运行时选择不同 source backup 尝试 Restore | 在新 snapshot/journal/mutation 前返回 `RECOVERY_REQUIRED`;foreign raw journal、全部受管 backup inventory 与业务 Hash 不变;未知版本同样 fail closed | +| `restore-v2-resolver-projection` | 一运行时留下同 source pending,另一运行时以相同 source、持久化物理 Home 和完整 target coverage 显式 Restore | pending、resolver 与当前 locked Home 的稳定物理 identity 全部匹配时,新 Restore 创建独立 snapshot/journal 并耐久到 `completed`;旧 raw journal 不改写,由 exact `resolvesOperationIds` 投影为已解决;Prune 继续保护旧证据 | +| `restore-v2-manifest-prepared-binding` | 重写 snapshot manifest 的 source/storage/resolver/target 或 snapshot 目录并同步重算 `prepared.manifestSha256` | Node 与 .NET 都在 compensation/ack 前返回 `RECOVERY_REQUIRED`;不读取替换 snapshot、不补偿、不确认完成,业务 Hash 与 source backup 不变 | +| `restore-v2-persisted-physical-home-binding` | pending 持久化 Home A;相同 lexical Home 经 junction/reparse 换接到 B,或 resolver 持久化 B | B 上 Restore 在新 snapshot/mutation 前返回 `RECOVERY_REQUIRED`;A 的 raw journal 不改写,completed resolver 不能隐藏它;Node↔.NET 双向一致 | +| `restore-v2-windows-path-alias` | 同一 Windows 物理 source 分别以 8.3 短路径、长路径与 junction 创建/恢复 journal | Node↔.NET 两个方向均把 Prepare/Apply/journal 绑定到稳定物理 source,另一运行时可用另一别名恢复;无法证明时 fail closed,不以 lexical 字符串、旧式 backupId 或换接后的当前目标放行 | +| `restore-v2-reparse-swap` | snapshot/apply 后把 rollout 或其父目录换成指向 Home 外的 junction/symlink,再进入 compensation/ack | 每个 mutation/ack 边界重新验证物理 Home 与 reparse segment;外部目标字节不变,journal 进入 `recovery-required`,不得按相同内容 hash 误确认 | +| `restore-v2-ack-reconciliation` | `committed-pending-ack` 已持久化但 API acknowledgement/observer 失败 | 不把已提交 Restore 报为可回滚失败;重新读取 journal 与目标 Hash 后收敛到 `completed` 或 `recovery-required` | +| `desktop-update-install-gate` | Update 已下载,同时注入已入场但未 dispatch 的 write、后续 write、自动停止/active Watch、pending recovery、无法验证 Profile、installer 异常或安装竞争 | 未授权候选的 check/download/install/timer 均不创建 updater port;获授权路径先关闭 gate、排空写、由 Main 重新查询 Utility Watch 状态并刷新全部 Profile。任一条件不安全或查询/installer 失败都不退出并重新开放 gate。IPC 为 null-only,DTO 不含 URL/path/release notes/raw error | +| `desktop-diagnostics-capability-bound` | Renderer 重复请求诊断导出、复用 token、并发消费、选择相同目标或以路径别名指向同一物理 ZIP | 目标仅由 Main 选择;5 分钟 TTL、最多 32 个 pending、规范化目标独占 reservation,写入时按父目录 realpath 拒绝物理目标并发、单次消费;过期/revoke/完成后释放且 ZIP 无路径/凭据/正文 | | `bidirectional-backup-roundtrip` | Node Backup→.NET Restore;.NET Backup→Node Restore | 两个方向恢复到等价语义状态;正文和不应变化字段逐字节一致;Metadata v1/v2 兼容边界明确 | +| `historical-tag-produced-backup-restore` | 从冻结 commit 的 `v0.2.9`/`v0.4.1` tag 源构建历史 .NET Core,真实产生 synthetic metadata v1/v2 backup,再由当前 Node Restore | config/rollout/SQLite 恢复;tag commit、metadata/tree Hash 与 synthetic-only 声明进入 CI artifact。证据等级仅为 repository-tag-source,不等于 hosted formal Release binary 或真实用户数据 | +| `historical-formal-release-backup-restore` | 下载固定 release/tag/asset ID、size 和 SHA-256 的 hosted `v0.4.1` Automation ZIP;同时核对 GitHub Release API、独立 `.sha256`、`checksums.txt`、archive entry set 与 executable Hash 后,才在隔离 synthetic Home 执行 Plan/Apply | 历史正式托管二进制真实生成 metadata v2 managed backup;当前 Node Restore 逐字节恢复 config/rollout,并恢复 SQLite Provider;随机 `auth.json` canary 不进入 backup 或脱敏 artifact。证据必须绑定同一 CI run/tested commit,并明确该历史二进制与 tag 未签名,因此不能替代真实 Beta、代码签名或生产升级验证 | | `journal-crash-matrix` | 在 prepared/applying/applied/commit/rollback 及 ack 窗口真实终止进程 | durable terminal 优先;非 terminal 阻断后续写;不得对 committed 状态补回滚事件;显式 Restore 可收敛 | | `rollback-recovery-required` | mutation 后使自动 rollback 的一个或多个目标失败 | 原始错误与所有 rollback error 均保留;Backup、completed/uncompleted targets 和 `RECOVERY_REQUIRED` 可用于人工恢复 | 真实跨运行时测试不能用 Mock 代替进程争锁。Node 与 .NET 必须在同一临时目标上运行,并以文件/SQLite 最终效果作为独立证据。 +V1/C3 的 executable mapping:Plan/revision 见 `test/plan-ledger.test.js`、`test/operation-revision.test.js`、`test/plan-apply.test.js`;Node 锁与外部 Status 见 `test/state-db-lock.test.js`、`test/status-coordination.test.js`;Watch 见 `test/watch.test.js`;Web transport 见 `test/web-server.test.js`;.NET 与跨运行时锁见 `StateDbLockResourceTests`、`DualResourceLockIntegrationTests`、`CrossRuntimeStateDbLockTests`、`LockServiceTests`。`shared-sqlite-home-contention` 与 `dual-resource-lock-order` 另由 `test-support/cross-runtime-fixtures.mjs`、`test-support/cross-runtime-writer-host.mjs` 和 `.NET FixtureHost` 启动两个方向的真实 Sync writer,winner 在 `before_backup` 持有 Home→State DB 双锁,loser 必须以 `OPERATION_BUSY(state-db)` 且零 Backup/Journal/mutation 退出。C5 的双向 backup/foreign pending executable mapping 为 `test-support/cross-runtime-fixtures.mjs`、`test-support/cross-runtime-node-crash-host.mjs`、`.NET FixtureHost` 与既有 `.NET CrashHost`;完整命令与结果记录在 `evidence/C5_SHARED_UI_WEB_2026-08-26.md`。 + +C7 的 executable mapping 为 `test-support/desktop-sync-switch-fixture.mjs`、`apps/desktop/e2e/desktop-sync-switch.spec.mjs`、`apps/desktop/tests/ipc-router.test.mjs`、`runtime-supervisor.test.mjs` 与 `test/plan-apply.test.js`。它使用临时 Home、真实 SQLite、Windows `FileShare.None`、受控 test-build Utility 终止和完整目标 Hash/语义快照;生产 Core host control 不包含故障注入能力。`scripts/test-wsl-unc-safety.sh` 以 `CPS_REQUIRE_REAL_WSL=1` 提供严格真实 WSL 门;没有与 source commit 绑定的健康 Windows+WSL 结果时仍是 Pending,不能用 synthetic UNC 或代码开关冒充实证。详见 `evidence/C7_ELECTRON_SYNC_SWITCH_2026-08-26.md`。 + +C8 的 executable mapping 为 `test/restore-v2-state-machine.test.js`、`RestoreJournalServiceTests`、`RestoreV2IntegrationTests`、`test-support/cross-runtime-fixtures.mjs`、Node/.NET CrashHost、`test/watch.test.js`、`test/operation-coordinator.test.js`、`apps/desktop/tests/updater.test.mjs`、Desktop contracts/unit tests、`apps/desktop/e2e/desktop-restore-relocation.spec.mjs` 与隐藏模式 Electron E2E。跨运行时 harness 覆盖 applying/prepared/committing/rollback-pending、commit ack、foreign pending、unknown schema、Windows 物理路径 alias、manifest/prepared 全量绑定和 persisted physical Home mismatch;Windows alias 用例通过系统返回的实际 8.3 短路径和真实 junction,让 Node 与 .NET 分别以别名创建 pending、由另一运行时以物理长路径恢复,也保留长路径创建/别名恢复矩阵,并校验原 journal bytes 不改写。原 journal 保留与 resolver projection 是显式裁决,不再把 raw 非终态伪报为 `rolled-back`。Updater fixture 只使用注入 port,不访问真实 Release;C9/C10 必须另做获授权签名产物的检查/下载/重启升级 smoke。 + +历史备份兼容 executable mapping 分为两个证据等级:`test-support/historical-tag-backup-fixtures.mjs` 证明冻结 repository tag-source;`test-support/formal-release-backup-fixtures.mjs` 和 `test-support/formal-release-assets.v1.json` 证明 checksum-bound hosted v0.4.1 Automation Release asset。后者仅在 Windows/Node 24 临时目录、严格环境白名单中执行,先固定并核对 release/tag/asset/archive/executable、解压前拒绝越界条目、执行前二次核对 executable,再执行历史 Plan/Apply 与当前 Node Restore;CI 只上传同一 run/commit 绑定的脱敏 hash evidence,不上传二进制、SQLite、rollout、完整 backup、路径、凭据或正文。fork PR 不执行 hosted binary;同仓库 PR 的 bundle 仍只是未受保护 source 的审查预览,只有受保护 `main` 上由同一 required workflow 重新生成的 artifact 才能成为最终证据。该项不是 vNext/Electron/GUI Release,也不是历史版本自身 Restore;历史 tag 与二进制 `NotSigned`,GitHub API、固定清单和 Hash 只提供完整性与托管来源绑定,真实 Beta、签名和生产升级仍保持独立门禁。 + ## 6. Restore、Backup 与 Prune | Fixture ID | 输入语义 | 关键预期 | | --- | --- | --- | -| `restore-relocation` | Backup 的 SQLite Home 与当前目标不同 | 默认拒绝;只有显式目标和 relocation 确认才允许,且跨 SQLite Home Restore 不恢复 config | +| `restore-relocation` | Backup 的 SQLite Home 与当前目标不同;Electron Renderer 只看到 `sqliteHomeConfigured` 摘要 | 默认拒绝;只有可信命名 profile 的显式目标和 relocation 确认才允许,且跨 SQLite Home Restore 不恢复 config。隐藏 Electron UI 回归必须证明 source 业务状态不变、目标 DB 恢复 | | `prune-managed-only` | backup root 同时包含受管备份、普通目录和 Pending Journal 引用 | 只删除超过保留数的受管备份;普通目录和 Pending Journal 所在目录永不删除 | | `backup-first-no-mutation` | Backup 期间空间、权限或 snapshot 失败 | 返回 `BACKUP_FAILED`;不存在 Journal/目标 mutation;原始 Hash 不变 | @@ -98,11 +129,33 @@ Fixture 不是用户数据样本,严禁从真实 `~/.codex`、认证文件或 | Fixture ID | 输入语义 | 关键预期 | | --- | --- | --- | | `workspace-roots` | global state、rollout cwd 与 SQLite cwd 不一致,含跨平台路径形式 | 只修复合同允许的 workspace/cwd 元数据;路径规范化一致;Backup/Restore 覆盖 global state | -| `history-safe-content` | user/event/response-item 重复消息、无 thread id、同 id 多 rollout | 列表选择稳定会话;详情只在用户主动读取时返回安全消息;正文不进入日志、诊断包或应用数据库 | +| `history-safe-content` | user/event/response-item 重复消息、无 thread id、同 id 多 rollout,并包含大正文 rollout 与多个大 decoy | 无 query 列表只读受限首行 metadata、返回 `messageCountKnown=false` 且 UI 不显示伪 0;显式搜索仍可全文匹配并返回精确计数;详情定位只深读用户选择的 rollout;列表选择稳定会话;正文不进入日志、诊断包、Query cache 或应用数据库 | +| `desktop-readonly-c6` | 临时 Codex Home 含无标题 rollout、真实 SQLite row、valid pending journal 与正文 marker | production bridge 无测试/Node 能力;列表/Profiles/Diagnostics 无路径和正文;显式详情后才显示 marker;写 IPC 拒绝;Utility crash 后按 profile preflight 并恢复;测试前后 Codex Home 全树 Hash 不变 | +| `desktop-release-candidate-c9` | 四个 host-native target 各自生成两个最终发行容器;容器只含 synthetic build content 与 target-native SQLite binding | 每个容器解包/安装后重新审计 ASAR/Fuse/embedded integrity/native binding,隐藏执行 Status 与 Sync→Restore,正常退出;NSIS 卸载清理;SBOM/manifest/checksum 完整闭包;四目标 aggregate 的 version/commit/lock/tool/policy 一致;任何 source map、fixture、凭据名、真实数据、非目标 binding 或未清单文件均阻断 | + +C6 executable mapping:`test-support/desktop-readonly-fixture.mjs`、`apps/desktop/tests/*.test.mjs`、`apps/desktop/e2e/desktop-production-boundary.spec.mjs` 与 `desktop-readonly.spec.mjs`。production unpacked smoke 通过 `apps/desktop/scripts/run-packaged-e2e.mjs` 解析当前平台 builder 输出;Windows/macOS/Linux Node 24 job 同时验证正常 production bundle、真实 SQLite/History 边界和 test build 的 Utility crash/restart。正式安装器、双架构 macOS 发行产物和 native fallback 留在 C9。 + +C9 executable mapping:`apps/desktop/tests/release-candidate.test.mjs`、`apps/desktop/scripts/build-candidate.mjs`、`stage-candidate.mjs`、`release-audit.mjs`、`smoke-candidate-artifacts.mjs`、`verify-candidate-set.mjs`、`apps/desktop/e2e/desktop-production-boundary.spec.mjs` 与 `.github/workflows/ci.yml` 的 `electron-release-candidate`/`electron-candidate-set`。本地 Windows 只证明 Windows x64 ZIP/NSIS;macOS x64/arm64、Linux x64 和四目标 aggregate 必须由 required CI 证明,不能手工补造。 + +## 8. CLI JSON 动态 Fixture + +C2 使用真实 Node 子进程和完全位于临时目录的最小 Core fixture 固化 JSON Mode;这些 harness 不含真实 Codex Home、凭据或消息正文。 + +| Fixture ID | 运行方式 | 关键预期 | +| --- | --- | --- | +| `cli-json-envelope-v1` | 对所有有限命令启动真实 CLI/组合入口 | stdout 恰好一个 JSON 文档,顶层键固定为 schemaVersion/command/ok/outcome/result/warnings/error | +| `cli-json-exit-matrix` | 子进程注入 success/noop/partial/rolled-back/stale/recovery/busy/lock/cancel | 退出码固定为 `0/1/2/3/4/5/130`,且与 Error Code 分层 | +| `cli-json-progress-isolation` | 真实 Sync 与受控 progress observer | 进度仅进入 stderr;stdout 不含阶段文本或 backup path | +| `cli-json-daemon-rejection` | `watch --json`、`web --json` | 在创建长运行状态、runtime descriptor 或浏览器进程前返回 `INVALID_INPUT`/exit 2 | +| `cli-json-redaction` | 非法参数值、unknown/typed error、恶意 details、越权 result 字段、循环结果、stdout EPIPE | 固定错误文案与命令级字段 allowlist 不泄漏 stack/cause/secret/token/prompt/message body;terminal writer 最多尝试一次 stdout | +| `cli-human-compat` | 不传 `--json` 运行既有 help/input/sync 路径 | Human 输出和既有 `0/1`、partial 行为不变 | +| `installed-root-entrypoint` | 从真实根 npm tarball 安装后,经 npm bin shim/Windows 规范化路径运行 `help`、临时 Home `status --json`,再执行 `sync --json → drift → restore --json` | CLI 必须实际执行并创建 managed backup;config/rollout 字节与 SQLite Provider 恢复且无 pending recovery;不得因短/长路径、大小写或链接形式不同而静默退出,也不得引入 Electron/workspace runtime 依赖 | + +这些用例当前由 `test/cli-json-contract.test.js`、`test/cli-json.test.js`、`test-support/cli-json-driver.js` 和真实 Core Sync 回归承载;未来迁入 `packages/test-fixtures` 时必须保持同一外部合同。 -## 8. 未来 Corpus 建议结构 +## 9. Corpus 结构与后续扩展 -本节只定义目标,不表示目录已经存在: +目录骨架、Schema、安全 Runner 与首批 `static/` Corpus 已存在;后续 fixture 按同一边界扩展: ```text packages/test-fixtures/ @@ -120,7 +173,7 @@ packages/test-fixtures/ SQLite live WAL、真实文件锁、跨进程 crash 和 WSL UNC 不能作为静态字节目录伪造,必须由受控 Builder/Harness 在临时目录创建。静态部分只保存最小、无敏感内容、可审计的源输入。 -## 9. Node / .NET 对照与差异登记 +## 10. Node / .NET 对照与差异登记 每个双运行时 Fixture 使用两份相同输入副本: @@ -130,6 +183,6 @@ SQLite live WAL、真实文件锁、跨进程 crash 和 WSL UNC 不能作为静 4. 差异必须记录“Node 行为 / .NET 行为 / 权威选择 / 安全理由 / 对应测试”; 5. Node 是 vNext 目标核心,但不能以“新实现”为理由静默覆盖更安全的既有行为。 -## 10. 阶段 0 验收边界 +## 11. 阶段验收边界 -阶段 0 的完成标志是本文场景、预期和安全门槛获得确认。实际 Corpus、Schema、Builder、跨运行时 Harness 和 CI Matrix 均属于后续阶段;在它们真正通过之前,文档不得宣称行为等价。 +阶段 0 的完成标志是本文场景、预期和安全门槛获得确认。C5 已为 `bidirectional-backup-roundtrip` 与 `foreign-pending-restore` 建立真实跨进程 Windows harness 和 required CI job;这只证明这两个 Phase 2 门槛,不代表 Restore v2、全 crash matrix、WSL 或三平台产物等价。其余 Corpus、Builder 与 CI Matrix 必须在对应 checkpoint 真正通过后才能宣称完成。 diff --git a/docs/migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md b/docs/migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md index 1f9ccdc..43a7129 100644 --- a/docs/migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md +++ b/docs/migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md @@ -1,8 +1,8 @@ # vNext 升级改造执行索引 -> **状态:阶段 0 交付完成(合入 `main` 后生效)** +> **状态:受保护分支上的阶段 0 已完成;V1 的 C0 checkpoint 在单最终 PR 合入前不推进任何后续 Phase 状态。** > -> **日期:2026-08-24** +> **日期:2026-08-28** > > **目标:Electron + React + TypeScript + Node 单核心的渐进迁移** > @@ -21,7 +21,7 @@ 5. [行为兼容 Fixture 清单](BEHAVIOR_FIXTURES_ZH.md); 6. 本执行索引中的阶段状态。 -发生冲突时必须先登记差异并裁决,不能让后提交的新实现自动成为权威。 +发生冲突时必须先登记差异并裁决,不能让后提交的新实现自动成为权威。ADR-0011 的 V1 合并拓扑例外只改变 checkpoint 的承载方式,不改变本权威顺序。 ## 2. 总体状态 @@ -36,7 +36,15 @@ | 6 | Electron Stable,替代 .NET | Pending | Electron 成为默认桌面产品,.NET 标记 Legacy | | 7 | 清理 Legacy | Pending | 移出重复 .NET 业务代码,保留历史标签、分支和迁移说明 | -状态只能按 `Pending → In Progress → Completed` 前进。PR 分支可以把自身交付标为“合入后 Completed”,但受保护分支上的阶段只有在退出门槛全部满足并完成合并后才正式生效。 +状态只能按 `Pending → In Progress → Completed` 前进。V1 的 `C0`~`C10` 仅是内部 checkpoint:可记录“已验证”或“合入后 Completed”,但在最终 PR 合入受保护分支前,Phase 1~7 仍为 Pending 或 In Progress,不能标记 Completed。 + +## 2.1 V1 单最终 PR checkpoint 治理 + +- 本分支受 [ADR-0011](../adr/0011-v1-single-branch-single-final-pr.md) 约束:`C0`~`C10` 采用批准计划中的合并后编号;旧 PR 2~PR 10 只保留为依赖与安全意图来源。 +- 每个 checkpoint 必须记录 commit SHA、范围、适用测试/Fixture、真实平台证据、未满足 gate 和上一个可回退 commit;最终 PR 审查按 checkpoint 进行。 +- checkpoint 通过不自动开放下一阶段能力:Electron 写、Restore v2、Watch、公开默认桌面入口、Phase Completed 和 Legacy 清理由本索引的对应退出门槛继续阻断。V1 候选可按 C10 显示交接目标,但不得把它表述成已经发生的公开替代。 +- checkpoint 无法在同步 `main` 后重放或复验时,停止在最近已验证 checkpoint;不得以合并拓扑例外跳过差异登记、Fixture 或发布验证。 +- 在 V1 分支内,本索引中“进入条件:上一 Phase Completed”表示相应前序 checkpoint 的全部证据已验证;它不改变受保护分支上该 Phase 仍未 Completed 的状态。 ## 3. 阶段门槛 @@ -49,7 +57,7 @@ 退出门槛: -- ADR-0001~ADR-0010 均为 Accepted,历史 v0.4 ADR 不与 vNext 编号混淆; +- 阶段 0 的 ADR-0001~ADR-0013 均为 Accepted,后续 checkpoint ADR 不追溯改变该冻结证据,历史 v0.4 ADR 不与 vNext 编号混淆; - Core 外部行为、CLI、Error Code、Fixture 和本索引可互相导航; - 每份文档明确“当前已实现”与“vNext 目标”; - 本阶段不修改运行代码、CLI 输出、Error Class 或运行代码目录结构; @@ -63,7 +71,7 @@ - 新增 `src/public-api.js`,CLI/Web 不再导入 Core 内部实现; - 展示逻辑与业务结果分离,现有用户可见语义不变; -- PR 2~PR 5 均已完成:Public API、结构化错误、CLI `--json` 与 Prepare/Apply 已分别落地; +- 最终 PR 合入前,C1~C3 的 checkpoint 门槛必须全部通过:Public API/结构化错误、CLI `--json`、Prepare/Apply 与双层锁分别落地; - CLI `--json` 是 opt-in 加法;默认 Human Mode、命令语义和 v0.5 兼容行为保持不变; - 原 Node 测试全绿,新增 Public API Contract Test; - 真实 Node↔.NET 对同一 Codex Home 的 protocol v2 操作锁争用通过:恰有一个写者,败方无副作用; @@ -117,7 +125,7 @@ 退出门槛: -- Restore、Prune、Watch、Recovery Required、Update 和诊断包完整; +- Restore、Prune、Watch、Recovery Required、诊断包以及 Main-only Update 状态机/安装门禁完整;真实版本 metadata、签名、下载与跨版本升级证据继续由 C9/C10 闭合; - `restore-mid-failure` 不产生无证据的半恢复状态; - Node Backup→.NET Restore、.NET Backup→Node Restore 双向通过; - Foreign Pending Journal 可由兼容入口显式恢复并写入合法 terminal; @@ -148,33 +156,34 @@ - 删除的只是重复业务实现,不删除 Node CLI; - 仓库文档、依赖图、发布脚本和安全说明不再引用已移除路径。 -## 4. 首批 PR 依赖 +## 4. V1 checkpoint 依赖 -| PR | 内容 | 依赖 | 关键合入门槛 | 状态 | +| Checkpoint | 内容 | 依赖 | 最终合入前必须保留的证据 | V1 状态 | | --- | --- | --- | --- | --- | -| PR 1 | 冻结架构合同和 ADR | 无 | 仅文档;阶段 0 退出门槛 | **Completed(合入 `main` 后)** | -| PR 2 | Core Public API | PR 1 | CLI/Web 仅走 Public API;现有测试全绿;锁合同验证 | Pending | -| PR 3 | 结构化错误 | PR 2 | Canonical/Legacy Adapter、`LOCK_UNVERIFIABLE`、错误合同测试 | Pending | -| PR 4 | CLI `--json` | PR 2、PR 3 | stdout 单一 JSON、stderr 日志、Exit Code 与 Schema 合同 | Pending | -| PR 5 | Prepare / Apply | PR 2、PR 3 | Revision/Plan/Apply 下沉;CLI Human Mode 兼容 | Pending | -| PR 6 | Workspace 与 Core 骨架 | PR 2~PR 5(阶段 1 Completed) | workspaces/Core/Contracts/CoreClient 骨架;不搬 UI、不改算法 | Pending | -| PR 7 | React UI 分解 | PR 6 | AppShell/Feature/HttpCoreClient;现有 Web UI 可用;阶段 2 退出门槛 | Pending | -| PR 8 | Electron Skeleton | PR 7(阶段 2 Completed) | Main/Preload/Renderer 安全基线与版本握手;无业务写 | Pending | -| PR 9 | Core Utility Process | PR 8 | Supervisor、Status、Crash/Protocol Test;Pending Journal 检查 | Pending | -| PR 10 | Read-only Preview Release | PR 9 | 三平台 package、只读 smoke、使用说明和反馈模板;阶段 3 退出门槛 | Pending | +| C0 | 治理、基线与依赖安全 | 阶段 0 | ADR-0011~0013、合同导航、基线测试、Vite 审计告警清零 | In Progress(V1) | +| C1 | Core Public API 与结构化错误 | C0 | CLI/Web 仅走 Public API;Canonical/Legacy Adapter;错误合同测试 | In Progress(V1,本地门禁通过) | +| C2 | CLI `--json` | C1 | stdout 单一 JSON、stderr 日志、JSON Exit Code 与 Schema 合同 | In Progress(V1,本地门禁通过) | +| C3 | Prepare/Apply、协调器与双层锁 | C1、C2 | Revision/Plan/Apply、Node/.NET 双层资源锁、真实争锁证据 | In Progress(V1,本地门禁通过) | +| C4 | Workspace、Core、Contracts、CoreClient | C1~C3(Phase 1 全部门槛已验证) | 不搬高风险算法;根 npm CLI tarball/Node 16 兼容 | In Progress(V1,本地门禁通过) | +| C5 | 共享 React UI 与 Web | C4 | AppShell/Features/HttpCoreClient;Web 安全与功能等价;阶段 2 门槛 | In Progress(V1 候选实现;`c63a403` checkpoint 的 required CI 已验证,后续 source head 以 PR 最新成功证据为准;等待最终合入) | +| C6 | Electron 安全骨架、Utility Runtime、只读能力 | C5(需 Phase 2 全部门槛闭合;受保护分支状态未满足) | 安全窗口/IPC、握手、crash recovery、三平台只读 smoke | Pending(`c63a403` checkpoint 的 Windows/macOS/Linux Electron 候选门禁已验证;后续 head、Phase 状态、真实 WSL 与最终合入未闭合) | +| C7 | Electron Sync/Switch | C6(Phase 3 全部门槛已验证) | Prepare/Apply、Busy/Partial/Cancel、Backup/Restore 回环 | Pending(`c63a403` checkpoint 的写能力、隐藏 E2E 与三平台候选 CI 已验证;后续 head、Phase 前置、真实 WSL 与最终合入未闭合) | +| C8 | Restore/Watch/Diagnostics/Update | C7(Phase 4 全部门槛已验证) | Restore v2 crash matrix、foreign pending、诊断隐私、Watch/Update | Pending(`c63a403` checkpoint 的候选门禁已验证;后续 hardening head、commit-bound 真实 WSL、获授权真实更新链、Phase 前置与最终合入未闭合) | +| C9 | 打包、CI 与发布工程 | C8(Phase 5 全部门槛已验证) | 四目标产物、native SQLite、packaged smoke、SBOM/checksums | Pending(`c63a403` checkpoint 的四目标候选证据已验证;后续 head 增加 checksum-bound hosted v0.4.1 Automation backup fixture,只有最新成功 C10 artifact 才是当前证明;签名、公证、真实更新与最终合入未闭合) | +| C10 | 最终证据与 Legacy 交接 | C9 | evidence bundle、README/Legacy、全量门禁;不自动发布 | Pending(`c63a403` checkpoint 的 source `1.0.0`、交接目标和 26/26 CI 已验证;后续 source head 的 hosted formal Release backup 兼容只以最新成功 C10 artifact 为准;真实 Beta/WSL、受保护 `main` 合入后复验、签名/公证和发布授权仍未闭合) | -PR 4 与 PR 5 可在 PR 2/3 后并行开发;两者都完成后才能进入 PR 6。PR 6~PR 10 按阶段门槛顺序推进,不能用“只搭骨架”绕过上一阶段的退出条件。 +`C0`~`C10` 按表中依赖和阶段门槛推进,不能用“只搭骨架”绕过上一阶段的退出条件。Checkpoint 内可以有若干小提交,但最终 evidence 必须绑定一个明确 commit。 -PR 到阶段的归属固定为:PR 1 完成阶段 0;PR 2~PR 5 完成阶段 1;PR 6~PR 7 完成阶段 2;PR 8~PR 10 完成阶段 3。阶段 4 之后的 PR 编号在 Read-only Preview 证据完成后确定。 +checkpoint 到阶段的归属固定为:C0 不推进运行 Phase;C1~C3 的完整证据在最终合入后可完成阶段 1;C4~C5 可完成阶段 2;C6 可完成阶段 3;C7 可完成阶段 4;C8 可完成阶段 5;C9~C10 只能形成阶段 6 的 release-ready 证据。阶段 6 仍需真实 Beta、签名/公证与单独授权的发布验证;阶段 7 不属于本 PR。 -阶段 4 之后的写入、Restore/Watch、Stable 与 Legacy 清理 PR,在 Read-only Preview 证据完成后再编号,避免提前承诺不可靠的拆分。 +任何 checkpoint 只有代码、合同、Fixture、CI 和适用真实平台证据同时闭合后才能标记已验证;分支内通过不等于公开发布或稳定用户验证完成。 ## 5. 跨阶段安全门槛矩阵 | 安全证据 | 最晚完成阶段 | 阻断内容 | | --- | --- | --- | | 真实 Node↔.NET 同 Codex Home 争锁 | 阶段 1 | 阻断“迁移期入口共享安全锁合同”的声明 | -| Busy 与不可验证锁的结构化区分 | 阶段 1/PR 3 | 阻断自动重试、自动清锁和 Electron 写入 | +| Busy 与不可验证锁的结构化区分 | 阶段 1/C3 | 阻断自动重试、自动清锁和 Electron 写入 | | 不同 Codex Home 共享 SQLite Home 的互斥 | 阶段 4 进入前 | 阻断所有 Electron 写能力 | | 双向 Backup Round-trip | 阶段 2 建证、阶段 5 全通过 | 阻断 .NET Legacy 替代 | | Foreign Pending Restore | 阶段 2 建证、阶段 5 全通过 | 阻断跨入口 Recovery 声明 | @@ -208,4 +217,6 @@ Node 与 .NET 在同一 Fixture 上不一致时,PR 必须记录: ## 8. 本 PR 完成后的下一步 -阶段 0 合入并标记 Completed 后,下一项是 PR 2:新增 `src/public-api.js`,让 CLI 与 Web 经单一公开入口调用现有 Node 行为。该 PR 不移动 Core、不改同步算法,也不同时引入 `--json`、TypeScript 或 Electron。 +`c63a403688b6d148afa65fba9e1461c7ebcd3331` checkpoint 已包含 `origin/main@c7ff85218a07a8e5f14132c582cad1239c52865e`,补齐两个不同 Codex Home 共用一个 State DB 时的真实 Node writer↔.NET writer 双向争锁,并在本地跨运行时矩阵中达到 12/12。Draft PR #90 的 [CI run 33142610556](https://github.com/Dailin521/codex-provider-sync/actions/runs/33142610556) 是该 checkpoint 的历史快照:测试合并 commit `10047581a46f67993c809bb8fb3b58a89fb42d09` 上 26/26 jobs 成功,source manifest 为 `1.0.0`,候选版本为 `1.0.0-rc.204`;它不自动覆盖后续 V1 source head。 + +V1 候选按 C10 在界面和文档中显示“Electron 新版主桌面端候选 / .NET Legacy fallback”的交接目标;这不表示公开入口已经切换。截至本索引更新时,公开 GitHub Release 仍是 .NET `v0.4.1`,PR 仍为 Draft、未合并,Electron 没有公开下载、签名、公证或生产更新通道。没有与 source commit 绑定的健康 Windows+WSL strict 结果、真实 Beta、受保护 `main` 合入后同 SHA 复验、签名/公证、真实更新升级和独立发布授权时,Phase 1~7 仍保持 Pending/In Progress。`c63a403` 的静态证据见 [C10 最终候选证据快照(2026-08-28)](evidence/C10_FINAL_EVIDENCE_BUNDLE_2026-08-28.md);PR #90 当前 source head 的 commit-bound 证据只以该 PR 最新成功 `c10-evidence-bundle` artifact 为准,不在静态 Markdown 中重复动态 run、merge ref 或 artifact SHA。C1 证据见 [C1 Public API 与结构化错误证据](evidence/C1_PUBLIC_API_ERRORS_2026-08-25.md),C2 证据见 [C2 CLI JSON 合同证据](evidence/C2_CLI_JSON_2026-08-25.md),C3 证据见 [C3 Plan/Apply 与双层锁证据](evidence/C3_PLAN_APPLY_DUAL_LOCK_2026-08-25.md),C4 证据见 [C4 Workspace、Core 与 CoreClient 证据](evidence/C4_WORKSPACE_CORE_CLIENT_2026-08-25.md),C5 证据见 [C5 共享 UI、Web 与跨运行时 Fixture 证据](evidence/C5_SHARED_UI_WEB_2026-08-26.md),C6 证据见 [C6 Electron Read-only Alpha 证据](evidence/C6_ELECTRON_READONLY_2026-08-26.md),C7 证据见 [C7 Electron Sync/Switch 证据](evidence/C7_ELECTRON_SYNC_SWITCH_2026-08-26.md),C8 证据见 [C8 Restore / Watch / Diagnostics / Update 证据](evidence/C8_RESTORE_WATCH_DIAGNOSTICS_UPDATE_2026-08-27.md),C9 证据见 [C9 打包、CI 与发布工程证据](evidence/C9_PACKAGING_CI_RELEASE_ENGINEERING_2026-08-27.md)。 diff --git a/docs/migration/evidence/C0_BASELINE_2026-08-25.md b/docs/migration/evidence/C0_BASELINE_2026-08-25.md new file mode 100644 index 0000000..79b9b10 --- /dev/null +++ b/docs/migration/evidence/C0_BASELINE_2026-08-25.md @@ -0,0 +1,40 @@ +# C0 基线与治理证据(2026-08-25) + +状态:本地门禁通过,等待远端 CI。本文记录 V1 的输入基线与 C0 候选;checkpoint 的最终 commit SHA 在 C10 evidence bundle 中统一索引。 + +## 边界 + +- 输入基线:`c7ff85218a07a8e5f14132c582cad1239c52865e`(当时 `HEAD`、`origin/main`、`origin/V1` 相同)。 +- C0 只冻结治理、锁与 Restore 设计,升级现有 Web 构建工具链并拆分 CI;不声称 Prepare/Apply、双层锁或 Restore v2 已在运行时实现。 +- 未读取真实 Codex Home、认证信息、凭据、token 或消息正文。 +- 未创建 tag,未发布 npm/GitHub Release,未签名、公证或写更新通道。 + +## 工具链选择 + +在 2026-08-25 从 npm 主 registry 查询稳定版本及 peer/engine 约束后,选择精确版本: + +- `vite@8.2.2` +- `@vitejs/plugin-react@6.1.0` + +两者的现代构建 engine 均为 `^20.19.0 || >=22.12.0`,因此 Web build 固定到 Node 24;根 CLI 的 Node `>=16.20.2` 兼容测试不再执行 Vite。这两个构建依赖使用精确版本,不使用 `^` 或 `~`。升级前审计为 2 个 high、1 个 moderate,均来自旧 Vite 构建链;升级后生产树和完整树均为 0。 + +## 本地验证 + +环境:Windows x64,Node `v24.11.1`,npm `11.10.0`。 + +| 命令 | 结果 | +| --- | --- | +| `npm test` | 259 passed,0 failed,0 skipped | +| `npm run web:build` | Vite 8.2.2 production build 成功,21 modules transformed | +| `npm audit --omit=dev --json` | moderate/high/critical 均为 0 | +| `npm audit --audit-level=high --json` | 完整依赖树 high/critical 为 0;当前所有 severity 均为 0 | +| `npm pack --dry-run --json` | 成功;根包仍包含既有 CLI、文档与 Web dist | +| `dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --filter FullyQualifiedName~LockServiceTests --configuration Release` | 28 passed,0 failed,0 skipped | +| `git diff --check` | 通过 | + +## 已知未闭合项 + +- Node 16.20.2 的 Windows/Ubuntu 真正 tarball 安装与执行由后续兼容 CI/产物门禁提供,本地没有重复模拟。 +- 本轮只复跑 .NET `LockServiceTests`,没有把该结果表述为 .NET 全套或三平台实机证据。 +- 当前 npm/Web dist 仍包含 source map;C9 必须从发布产物排除并用包内容扫描证明。 +- 只有远端所有必需 job 成功后,C0 才能在 V1 证据索引中标记为已验证;Phase 状态在最终 PR 合入前仍不得标记 Completed。 diff --git a/docs/migration/evidence/C10_EVIDENCE_BUNDLE.v1.schema.json b/docs/migration/evidence/C10_EVIDENCE_BUNDLE.v1.schema.json new file mode 100644 index 0000000..6020f4f --- /dev/null +++ b/docs/migration/evidence/C10_EVIDENCE_BUNDLE.v1.schema.json @@ -0,0 +1,352 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Dailin521/codex-provider-sync/blob/main/docs/migration/evidence/C10_EVIDENCE_BUNDLE.v1.schema.json", + "title": "codex-provider-sync vNext C10 evidence bundle", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "scope", + "outcome", + "evidenceForCommit", + "createdAt", + "repository", + "workflow", + "sourceVersions", + "checkpoints", + "ci", + "candidateSet", + "historicalFormalRelease", + "assertions", + "pending", + "release", + "redaction" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "scope": { "const": "vnext-c10-evidence" }, + "outcome": { "const": "ci-verified-not-release" }, + "evidenceForCommit": { "$ref": "#/$defs/sha" }, + "createdAt": { "type": "string", "format": "date-time" }, + "repository": { "const": "Dailin521/codex-provider-sync" }, + "workflow": { + "type": "object", + "additionalProperties": false, + "required": ["path", "workflowSha256", "runId", "runAttempt", "event", "ref", "testedCommit", "sourceHeadCommit", "eventBaseCommit", "containsEventBase"], + "properties": { + "path": { "const": ".github/workflows/ci.yml" }, + "workflowSha256": { "$ref": "#/$defs/hash" }, + "runId": { "type": "string", "pattern": "^[0-9]+$" }, + "runAttempt": { "type": "integer", "minimum": 1 }, + "event": { "enum": ["pull_request", "push"] }, + "ref": { "type": "string", "pattern": "^refs/[A-Za-z0-9._/-]+$" }, + "testedCommit": { "$ref": "#/$defs/sha" }, + "sourceHeadCommit": { "$ref": "#/$defs/sha" }, + "eventBaseCommit": { "$ref": "#/$defs/sha" }, + "containsEventBase": { "const": true } + } + }, + "sourceVersions": { + "type": "object", + "additionalProperties": false, + "required": ["rootPackage", "desktopPackage"], + "properties": { + "rootPackage": { "$ref": "#/$defs/version" }, + "desktopPackage": { "$ref": "#/$defs/version" } + } + }, + "checkpoints": { + "type": "array", + "minItems": 10, + "maxItems": 10, + "prefixItems": [ + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C0" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C1" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C2" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C3" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C4" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C5" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C6" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C7" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C8" } } }] }, + { "allOf": [{ "$ref": "#/$defs/checkpoint" }, { "type": "object", "properties": { "id": { "const": "C9" } } }] } + ], + "items": false + }, + "ci": { + "type": "object", + "additionalProperties": false, + "required": ["policy", "requiredJobs"], + "properties": { + "policy": { "const": "all-applicable-jobs-must-succeed" }, + "requiredJobs": { + "type": "array", + "minItems": 13, + "maxItems": 13, + "prefixItems": [ + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "cross-runtime-fixtures" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "dependency-audit" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "desktop-linux-lock" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "desktop-macos" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "desktop-test" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "electron-candidate-set" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "electron-desktop" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "electron-release-candidate" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "root-package-compat" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "test" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "web-browser" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "web-build" } } }] }, + { "allOf": [{ "$ref": "#/$defs/job" }, { "type": "object", "properties": { "id": { "const": "workspace-contract" } } }] } + ], + "items": false + } + } + }, + "candidateSet": { + "type": "object", + "additionalProperties": false, + "required": ["artifactName", "indexSha256", "version", "commit", "lockfileSha256", "targets"], + "properties": { + "artifactName": { "const": "electron-release-candidate-set" }, + "indexSha256": { "$ref": "#/$defs/hash" }, + "version": { "type": "string", "pattern": "^1\\.0\\.0-(alpha|beta|rc)\\.[0-9]+$" }, + "commit": { "$ref": "#/$defs/sha" }, + "lockfileSha256": { "$ref": "#/$defs/hash" }, + "targets": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "prefixItems": [ + { "allOf": [{ "$ref": "#/$defs/target" }, { "type": "object", "properties": { "target": { "const": "linux-x64" } } }] }, + { "allOf": [{ "$ref": "#/$defs/target" }, { "type": "object", "properties": { "target": { "const": "macos-arm64" } } }] }, + { "allOf": [{ "$ref": "#/$defs/target" }, { "type": "object", "properties": { "target": { "const": "macos-x64" } } }] }, + { "allOf": [{ "$ref": "#/$defs/target" }, { "type": "object", "properties": { "target": { "const": "windows-x64" } } }] } + ], + "items": false + } + } + }, + "historicalFormalRelease": { + "type": "object", + "additionalProperties": false, + "required": [ + "evidenceSha256", + "artifactName", + "release", + "asset", + "binary", + "backup", + "syntheticOnly", + "currentNodeRestoreVerified" + ], + "properties": { + "evidenceSha256": { "$ref": "#/$defs/hash" }, + "artifactName": { "const": "historical-formal-release-backup-evidence" }, + "release": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "releaseId", "tag", "tagObjectSha", "commit", "publishedAt", "tagSigned"], + "properties": { + "repository": { "const": "Dailin521/codex-provider-sync" }, + "releaseId": { "const": 366935386 }, + "tag": { "const": "v0.4.1" }, + "tagObjectSha": { "const": "1971fd52c8b9f9e24835dd2b4719137e73b36d77" }, + "commit": { "const": "75f45756cf732333e7c52f45c8cd1b183291a029" }, + "publishedAt": { "const": "2026-08-07T18:21:23Z" }, + "tagSigned": { "const": false } + } + }, + "asset": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "size", "sha256", "checksumAssetSha256", "releaseChecksumsSha256"], + "properties": { + "id": { "const": 505502580 }, + "name": { "const": "codex-provider-sync-v0.4.1-automation-win-x64.zip" }, + "size": { "const": 33692001 }, + "sha256": { "const": "6a8266a38567c56f9c8bb2662a84ac5a7b739c837a92cc4be0f4fdef76058616" }, + "checksumAssetSha256": { "const": "8203a06a475e2d859717cd2072b82a5671fe557eca504aaf38b0080b4d98c2f3" }, + "releaseChecksumsSha256": { "const": "2a1b0426667024f235a49a4dd3a2bd27b3ffa0e3995b6f03468e05f57608cc98" } + } + }, + "binary": { + "type": "object", + "additionalProperties": false, + "required": ["name", "size", "sha256", "authenticodeStatus"], + "properties": { + "name": { "const": "CodexProviderSync.Automation.exe" }, + "size": { "const": 39029116 }, + "sha256": { "const": "e1ed5a75018833ecc80cd2da90b185c9573efcf4d07e13baef2c206fb6b70c64" }, + "authenticodeStatus": { "const": "NotSigned" } + } + }, + "backup": { + "type": "object", + "additionalProperties": false, + "required": ["metadataVersion", "metadataSha256", "producedTreeSha256"], + "properties": { + "metadataVersion": { "const": 2 }, + "metadataSha256": { "$ref": "#/$defs/hash" }, + "producedTreeSha256": { "$ref": "#/$defs/hash" } + } + }, + "syntheticOnly": { "const": true }, + "currentNodeRestoreVerified": { "const": true } + } + }, + "assertions": { + "type": "object", + "additionalProperties": false, + "required": [ + "checkpointChainLinear", + "allEvidenceFilesHashed", + "workflowHeadMatchesEvidenceCommit", + "sourceHeadContainsEventBase", + "allRequiredJobsSucceeded", + "candidateSetComplete", + "candidateCommitMatchesEvidenceCommit", + "candidateReleaseUnauthorized", + "historicalFormalReleaseBackupVerified", + "redactionScanPassed" + ], + "properties": { + "checkpointChainLinear": { "const": true }, + "allEvidenceFilesHashed": { "const": true }, + "workflowHeadMatchesEvidenceCommit": { "const": true }, + "sourceHeadContainsEventBase": { "const": true }, + "allRequiredJobsSucceeded": { "const": true }, + "candidateSetComplete": { "const": true }, + "candidateCommitMatchesEvidenceCommit": { "const": true }, + "candidateReleaseUnauthorized": { "const": true }, + "historicalFormalReleaseBackupVerified": { "const": true }, + "redactionScanPassed": { "const": true } + } + }, + "pending": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "blocking", "reason", "requiredEvidence"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "blocking": { "type": "boolean" }, + "reason": { "type": "string", "minLength": 1, "maxLength": 300 }, + "requiredEvidence": { "type": "string", "minLength": 1, "maxLength": 300 } + } + } + }, + "release": { + "type": "object", + "additionalProperties": false, + "required": [ + "releaseAuthorized", + "tagCreated", + "npmPublished", + "githubReleaseCreated", + "signed", + "notarized", + "updateMetadataPublished", + "crossVersionUpgradeVerified" + ], + "properties": { + "releaseAuthorized": { "const": false }, + "tagCreated": { "const": false }, + "npmPublished": { "const": false }, + "githubReleaseCreated": { "const": false }, + "signed": { "const": false }, + "notarized": { "const": false }, + "updateMetadataPublished": { "const": false }, + "crossVersionUpgradeVerified": { "const": false } + } + }, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["policyVersion", "secretScan", "forbiddenKeyClasses", "forbiddenValueClasses"], + "properties": { + "policyVersion": { "const": "c10-v1" }, + "secretScan": { "const": "passed" }, + "forbiddenKeyClasses": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z-]+$" }, + "minItems": 4 + }, + "forbiddenValueClasses": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z-]+$" }, + "minItems": 4 + } + } + } + }, + "$defs": { + "sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" }, + "job": { + "type": "object", + "additionalProperties": false, + "required": ["id", "conclusion"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "conclusion": { "const": "success" } + } + }, + "checkpoint": { + "type": "object", + "additionalProperties": false, + "required": ["id", "commit", "parentCommit", "evidenceCommit", "evidencePath", "evidenceSha256", "status"], + "properties": { + "id": { "enum": ["C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9"] }, + "commit": { "$ref": "#/$defs/sha" }, + "parentCommit": { "$ref": "#/$defs/sha" }, + "evidenceCommit": { "$ref": "#/$defs/sha" }, + "evidencePath": { "type": "string", "pattern": "^docs/migration/evidence/C[0-9]_[A-Z0-9_/-]+\\.md$" }, + "evidenceSha256": { "$ref": "#/$defs/hash" }, + "status": { "const": "candidate-evidence" } + } + }, + "asset": { + "type": "object", + "additionalProperties": false, + "required": ["name", "sizeBytes", "sha256"], + "properties": { + "name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]+$" }, + "sizeBytes": { "type": "integer", "minimum": 1 }, + "sha256": { "$ref": "#/$defs/hash" } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": ["target", "buildId", "manifestSha256", "toolVersions", "fusePolicy", "artifactAuditPolicy", "assets"], + "properties": { + "target": { "enum": ["windows-x64", "macos-x64", "macos-arm64", "linux-x64"] }, + "buildId": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$" }, + "manifestSha256": { "$ref": "#/$defs/hash" }, + "toolVersions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/version" } + }, + "fusePolicy": { "const": "c9-v1" }, + "artifactAuditPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "sha256"], + "properties": { + "schemaVersion": { "const": 1 }, + "sha256": { "$ref": "#/$defs/hash" } + } + }, + "assets": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { "$ref": "#/$defs/asset" } + } + } + } + } +} diff --git a/docs/migration/evidence/C10_FINAL_EVIDENCE_BUNDLE_2026-08-27.md b/docs/migration/evidence/C10_FINAL_EVIDENCE_BUNDLE_2026-08-27.md new file mode 100644 index 0000000..e7036de --- /dev/null +++ b/docs/migration/evidence/C10_FINAL_EVIDENCE_BUNDLE_2026-08-27.md @@ -0,0 +1,71 @@ +# C10 最终证据与 Legacy 交接候选(2026-08-27) + +状态:C10 的脱敏 evidence bundle 合同、生成器、required CI job、迁移文档和本地 Windows 门禁已准备;远端四平台候选、aggregate、全部 required jobs、最终 `1.0.0` source version、受保护 `main` 合入后复验、真实 WSL UNC、签名/公证和发布授权均未闭合。因此 C10/Phase 6 仍为 Pending,不构成 Beta、Stable、Electron 默认入口、.NET Legacy 或公开发布声明。 + +输入为 C9 实现 commit `73256f3187dd337bb681a1cc9810edad8f6309bb` 及 C9 证据 commit `d34654994ad790b09ed4284ce8f5d87aeace8723`。开始 C10 前已重新 fetch,并确认 `origin/main` 为 `c7ff85218a07a8e5f14132c582cad1239c52865e`;按 ADR-0011 执行 `git merge --no-ff --no-edit origin/main`,结果为 Already up to date,没有 rebase 或 force-push。C10 输出 commit 和 CI-tested commit 只由最终 bundle 记录,本文不预填尚未产生的 SHA。 + +完成性审计后的现代 UI hardening 内容 checkpoint 为 `0bd982c4fb977dc965360e58bb9dfcce75ae5f81`。该提交只绑定下述本地 Windows 验证内容,不替代最终 PR merge commit、远端 required CI 或合入后 `main` evidence bundle;其后只允许追加证据/治理文档,若再改运行代码或产物必须重新生成新的 tested checkpoint。 + +## Evidence bundle 合同 + +- `C10_EVIDENCE_BUNDLE.v1.schema.json` 固定 `scope: vnext-c10-evidence`、`outcome: ci-verified-not-release`,发布授权、tag、npm、GitHub Release、签名、公证、更新 metadata 和跨版本升级字段只能为 `false`。 +- `scripts/write-c10-evidence-bundle.mjs` 只接受 GitHub Actions 的固定 workflow context、13 个 required job 的聚合结论、C0~C9 固定 checkpoint 链,以及 C9 四目标 `candidate-index.v1.json`。它要求 tracked checkout 干净、`GITHUB_SHA` 等于实际 `HEAD`、PR source head 是 tested merge commit 的祖先且包含 webhook 事件携带的 `eventBaseCommit`(push 时 source head 必须就是 tested commit)、C0~C9 证据 commit 是 tested commit 祖先,并校验四个 target、固定资产名、version/commit/lockfile/tool/audit policy 一致。`containsEventBase:true` 只证明事件基线祖先关系,不宣称工作流执行时已包含远端最新 `main`;最终合入前和合入后仍须按当前 main 重新门禁。最终对象还必须通过检入的 JSON Schema 2020-12 严格校验后才可写出。 +- 输出只包含 commit、公共 workflow/job 状态、版本/buildId、公共工具版本、相对证据路径、资产名/大小/hash 和 Pending 条目。绝对路径、UNC、Codex Home/SQLite/backup/profile 标识、认证文件名、凭据标记、History/消息内容与原始日志都会 hard-fail。 +- CI 从 `electron-release-candidate-set` 下载已经过 C9 aggregate 验证的索引,不复制第二套平台 manifest,也不读取 runner 原始日志。输出为 `artifacts/c10/evidence-bundle.v1.json` 与绑定它的 `SHA256SUMS.txt`,只上传 CI artifact,不提交动态实例,避免“提交证据后 HEAD 再变化”的循环;四个平台候选、aggregate index 与 C10 bundle 统一保留 30 天,保证审查期内可重算引用 hash。 +- `c10-evidence-bundle` 直接依赖现有 13 个 required jobs并使用 `always()` 收集终态;任一失败、取消、跳过、缺失候选 artifact 或 schema/脱敏校验失败都会让 C10 job 失败。唯一 `ci-gate` 同时把 C10 job 作为 strict dependency。 + +## README 与交接边界 + +- 中、英、日、韩入口统一说明:当前公开发布桌面端仍是 Windows .NET GUI;仓库内 Electron 是未发布候选,不是默认、Stable 或可下载产品。 +- 当前架构图同时显示 CLI/Web/Electron 候选到同一 Node Core 的数据流,以及仍受支持的 .NET GUI。Electron Renderer 的 Node/任意路径/通用 IPC 禁止边界保持可见。 +- 新增中英文 Electron 候选说明,记录页面能力、Prepare/Apply、Restore journal、Watch/Update、安全窗口、hidden 内部测试、四平台容器和另行发布授权边界。 +- .NET Windows/macOS 文档明确是当前发布或迁移期保留实现,尚未正式标记 Legacy。只有同一最终 commit 的远端门禁闭合、source version 定为 `1.0.0`、README 最终切换并完成发布验证后,才执行 Phase 6 的 Electron 主入口/.NET Legacy 交接。 +- .NET 删除不属于本 PR;稳定版发布并经过两个维护周期后再单独立项。 + +## 本地 Windows 门禁 + +环境:Windows 11 x64 build `26200`,Node `24.11.1`,npm `11.10.0`,PowerShell `7.6.4`,Git `2.52.0.windows.1`,.NET SDK `10.0.400`。全部开发测试使用临时 fixture;Electron 设置 `CPS_DESKTOP_WINDOW_DISPLAY=hidden`,没有显示或占用主屏窗口。 + +最终本地 hardening 只面向新版 Electron/共享 React UI,不改造旧 WinForms 视觉层:持久化主题由 CSP 允许的固定 bootstrap 在 React 前应用;Status 尚未成功时 Sync/Switch/Restore/Prune/Watch 与已打开 Plan 的确认全部 fail closed;Profile revision 在 Status、Prepare 或 Apply 期间变化时只显示一次本地化提示、合并并发 Profile 刷新,并要求使用新 revision 重新 Prepare;Plan、OperationResult、Diagnostics 默认显示双语语义摘要,原始 JSON 只在折叠技术详情中出现;History 正文保持显式打开、无 Query cache,详情/结果关闭后恢复键盘焦点。Design System 增加统一字体、字号、行高和 4~24 px 间距 token,shared primitives 已消费这些 token。真实隐藏 BrowserWindow 在 `760×560`、200% zoom 下遍历八页,逐页证明整页无横向溢出;Web 以 380 CSS px 复核八页及 Plan/Result 对话框。组件级测试另覆盖 Profile Changed、Progress、Cancel、Error Boundary、i18n、Skip Link 与键盘路由。 + +| 门禁 | 结果 | +| --- | --- | +| `npm test` | 428 passed,0 failed/skipped | +| `npm run workspaces:check` | 125 workspace tests passed;build/import/package boundary 通过,其中 App UI 为 4 项静态合同 + 13 项 Vitest | +| `npm run web:build` + `npm run web:test:e2e` | Vite production build 通过(2047 modules,JS 570.06 kB / gzip 173.95 kB);2/2 E2E passed | +| `npm run desktop:test:e2e`(hidden) | 15 passed,1 skipped;唯一 Skip 为不可用的真实 WSL UNC 环境 | +| `npm run desktop:build` + production E2E(hidden) | production bundle verifier 通过;无 test bridge/fallback selector;真实 Status 与 Sync→Restore 2/2 passed | +| `npm run desktop:pack:dir` + packaged production smoke(hidden) | Electron ABI rebuild、unpacked production、真实 fixture Status、Sync→Restore、graceful exit,2/2 passed;本地 executable 为 `NotSigned` | +| `desktop:pack:candidate` + `desktop:stage:candidate` + `desktop:smoke:candidate:artifacts`(hidden) | `84f47d9` 的 unsigned `1.0.0-rc.82701` Windows x64 ZIP/NSIS 均完成最终容器复审、native SQLite、Status、Sync→Restore、graceful exit;NSIS 静默安装/卸载清理通过 | +| `npm run fixtures:cross-runtime` | Node↔.NET Restore/lock/8.3/junction/physical Home 矩阵 11/11 passed | +| .NET Core/Application/Automation/App/GuiE2E Tests | 416 passed,1 skipped;唯一 Skip 为同一真实 WSL UNC 环境 | +| `.NET` Release build | 五个测试项目的传递构建与 macOS Avalonia 项目均为 0 warning / 0 error | +| `npm run package:smoke` | 根 tarball content/help/status/Web shell 与 source-map 拒绝通过(本机 Node 24) | +| `npm run package:smoke:lifecycle` | lifecycle install + SQLite smoke 通过(本机 Node 24) | +| 两层 npm audit | production moderate/high/critical 为 0;完整树 high/critical 为 0 | +| C10 定向合同测试 | required jobs、四目标/资产/commit 绑定、tool/audit 一致性、脱敏拒绝与 release-false-only schema 全部通过 | + +`npm run package:verify-root-tree` 只能在 `npm ci --workspaces=false --omit=dev` 的干净根 production tree 上执行;本机完整 Node 24 workspace 含 React/Electron 开发依赖,本轮直接运行按合同拒绝 `react`,不能把该环境误记为 Node 16 兼容门禁。实际根 tarball 的临时安装态 production tree 已由 `package:smoke:lifecycle` 通过;Windows/Ubuntu Node `16.20.2` 的 clean-install、root-tree、tarball 和 lifecycle 继续由 required `root-package-compat` job 闭合。 + +## 当前 Windows 候选引用 + +完成现代 UI hardening 和证据提交后,已在干净 HEAD `84f47d936afa336de2d871043e237d4c4a432a52` 原生构建、审计并 smoke unsigned `1.0.0-rc.82701` Windows x64 候选,build ID 为 `1.0.0-rc.82701-84f47d936afa-windows-x64`。manifest 固定 `releaseAuthorized:false`、`signingStatus:unsigned-candidate`、`notarizationStatus:not-authorized`;PowerShell Authenticode 对 NSIS 与 unpacked executable 均返回 `NotSigned`。 + +| 资产 | SHA-256 | +| --- | --- | +| Windows x64 portable ZIP | `6eab62e7691e398a8f332ec0bedb207129c2fc5203c7b84dbdc781a7f749669b` | +| Windows x64 NSIS setup | `d4f1b90432b03e742dd0342fa9a5257a8be75a1598076e37dbde9fc2255c0b08` | +| ASAR | `6061b62907d4c40d18bc90bccd1e473943711c36abfe84fa557e7aa59d8f0e22` | +| native binding | `e21e5efd71fba66578e95b62554d9028064a80dafd7221bf8a8ef155de8d240a` | +| release manifest | `f20d036e166e1d81a4f0f1fbeb69156300d9073b31adae194015bb455ec282d9` | +| SBOM | `a65472e7858dc9c5bfd3729c00282efc0973f2941b2281fd11416f1c217130b6` | + +旧 `73256f3 / 1.0.0-rc.0` 本地产物未被覆盖,已移动到忽略目录 `artifacts/c9-local-archive/windows-x64-73256f3-rc0`。当前这些 hash 只证明 `84f47d9` 的本地 Windows 候选;不能替代最终 PR tested commit 的远端 Windows、macOS x64、macOS arm64、Linux x64 原生候选矩阵、四目标 aggregate 或 C10 bundle。 + +## 必须由远端闭合 + +- 最终 PR 同一 tested commit 上,13 个 required jobs、Windows x64、macOS x64、macOS arm64、Linux x64 native candidate、四目标 aggregate 与 C10 bundle 全部只能为 `success`;任何 failed/cancelled/skipped 都阻断。 +- 首轮 RC 全绿后才把根包与 Desktop source manifest 统一为 `1.0.0`,随后在新 commit 上重新运行全部门禁。版本变更不是公开发布授权。 +- 最终 PR 必须保留 C0~C10 checkpoint commits,不 squash;合入后的实际 `main` commit 若与 PR tested commit 不同,必须在该 `main` SHA 再生成 bundle。 +- 真实 WSL UNC、安全签名、公证、update metadata/download/restart upgrade、平台安装/卸载以及 PR 审查/分支保护结果不能从本地 Windows 推断。 +- 没有明确授权时,不创建 tag,不发布 npm/GitHub Release,不签名、公证或写更新通道;README 不切换 Electron 默认入口,.NET 不标记 Legacy。 diff --git a/docs/migration/evidence/C10_FINAL_EVIDENCE_BUNDLE_2026-08-28.md b/docs/migration/evidence/C10_FINAL_EVIDENCE_BUNDLE_2026-08-28.md new file mode 100644 index 0000000..8f40636 --- /dev/null +++ b/docs/migration/evidence/C10_FINAL_EVIDENCE_BUNDLE_2026-08-28.md @@ -0,0 +1,59 @@ +# C10 最终候选证据快照(2026-08-28) + +状态:`c63a403688b6d148afa65fba9e1461c7ebcd3331` checkpoint 的本地门禁、四目标原生候选、required CI、aggregate、C10 脱敏 bundle 和 source `1.0.0` 已闭合,CI 结论为 `ci-verified-not-release`。Draft PR #90 仍为 Open/Draft、未合入 `main`;真实 WSL UNC、真实 Beta、合入后 `main` 复验、签名、公证、生产更新升级和独立发布授权未闭合。因此 Phase 6/C10 仍为 Pending,不构成 Beta、Stable、公开替代或发布声明。 + +本文是 `c63a403` 的静态候选证据快照,不追踪后续 source head;PR #90 的最新 source-head C10 证据只以最新成功 `c10-evidence-bundle` artifact 为准。这样可避免为记录新 SHA 再产生新 commit、进而再次改变被证明的 SHA。[2026-08-27 证据](C10_FINAL_EVIDENCE_BUNDLE_2026-08-27.md) 同样只保留当时本地 Windows checkpoint 的历史事实。 + +## 绑定对象 + +| 项目 | 值 | +| --- | --- | +| Draft PR | [#90 `V1` → `main`](https://github.com/Dailin521/codex-provider-sync/pull/90),Open/Draft,禁止合并 | +| V1 source head | `c63a403688b6d148afa65fba9e1461c7ebcd3331` | +| 事件基线 | `origin/main@c7ff85218a07a8e5f14132c582cad1239c52865e`;source head 包含该 commit | +| CI tested merge commit | `10047581a46f67993c809bb8fb3b58a89fb42d09` | +| CI run | [33142610556](https://github.com/Dailin521/codex-provider-sync/actions/runs/33142610556),`pull_request`,26/26 jobs `success` | +| Source version | 根包与 Desktop manifest 均为 `1.0.0` | +| 注入候选版本 | `1.0.0-rc.204` | +| C10 outcome | `ci-verified-not-release` | + +CI 测试的是 GitHub 为 PR #90 生成的 merge ref;C10 bundle 同时记录 source head、事件基线和 tested merge commit。最终合入后的实际 `main` commit 可能不同,因此必须在获准合并后对该 SHA 重新运行全量门禁,不能用本快照替代。 + +## 远端 CI 与候选产物 + +- 13 个 required jobs、Windows x64、macOS x64、macOS arm64、Linux x64 原生 candidate jobs、`electron-candidate-set`、`c10-evidence-bundle` 和最终 `ci-gate` 全部成功;没有 failed、cancelled 或 skipped job。各 job 内按宿主不适用而条件跳过的步骤不属于跳过适用 job。 +- 四目标最终容器验证覆盖 Windows NSIS/portable ZIP、macOS x64/arm64 DMG/ZIP、Linux AppImage/deb,并包含 native SQLite fallback、Status、Sync→Restore、正常退出、资产清单、SBOM 和 checksum 审计。候选构建固定 `--publish never`。 +- [C10 evidence artifact 9674705528](https://github.com/Dailin521/codex-provider-sync/actions/runs/33142610556/artifacts/9674705528) 的 bundle 内容 SHA-256 为 `fd0e4fa6fbdb2e6f09bfc19906fa27d1836ae62a3f1fb7a0367ed39c6135b92f`。 +- [四目标 candidate-set artifact 9674663683](https://github.com/Dailin521/codex-provider-sync/actions/runs/33142610556/artifacts/9674663683) 的 index SHA-256 为 `a631340011f87481000d5bdf289ce3eb3b9f1cf4e0a2bea2a603dd60f523adf6`。 +- 上述 GitHub Actions artifacts 是审查期内的 unsigned candidate evidence,按 workflow 保留 30 天;不是 GitHub Release 下载资产。 + +## c63 本地门禁 + +环境:Windows 11 x64,Node `24.11.1`,npm `11.10.0`,Git `2.52.0.windows.1`,PowerShell `7.6.4`,.NET SDK `10.0.400`。所有测试使用临时 fixture;Electron 通过 `CPS_DESKTOP_WINDOW_DISPLAY=hidden` 运行,未显示或占用主屏窗口。 + +| 门禁 | 结果 | +| --- | --- | +| `npm test` | 375/375 passed | +| `npm run workspaces:check` | 全部 workspace build/contract/test 通过;Desktop 65/65、App UI 14/14 | +| `npm run web:build` + `npm run web:test:e2e` | production build 通过;2/2 E2E passed,History detail 可见读取闭环通过 | +| `npm run desktop:test:e2e`(hidden) | 15 passed,1 skipped;唯一 Skip 为不可用的真实 WSL UNC 环境 | +| `npm run fixtures:cross-runtime` | 12/12 passed | +| `.NET FixtureHost` Release build | 0 warning,0 error | + +跨运行时矩阵新增两个不同 Codex Home 共用同一个 State DB 的真实 writer 争锁:Node Sync 持有 State-DB 锁时 .NET Sync fail-fast,以及反方向 .NET→Node。两方向都断言败方返回 `OPERATION_BUSY` / `busyScope=state-db`,并且在竞争期间不创建 Backup/Journal、不修改败方 Home 或共享数据库;释放 winner 后再验证真实写入成功。这补齐了仅以裸锁持有者证明协议兼容、却未证明两个真实 Sync writer 写入位置的缺口。 + +## V1 候选角色与公开发行边界 + +- V1 候选按批准的 C10 目标,在 Electron 界面和迁移文档中显示“新版主桌面端候选”,并把保留且继续构建/测试的 .NET Windows/macOS 实现标记为交接后的 Legacy fallback。 +- 该候选标识不等于 Electron 已经公开替代 .NET,也不把 Phase 6 标为 Completed。本快照生成时,[GitHub Release v0.4.1](https://github.com/Dailin521/codex-provider-sync/releases/tag/v0.4.1) 仍只提供 .NET Windows 资产;Electron 没有公开下载或生产更新通道。 +- .NET 实现未删除、未停止关键 CI,是本快照生成时的公开桌面兼容依据。即使未来稳定版交接完成,也至少保留两个维护周期;删除属于后续独立 Phase 7/PR。 + +## 尚未闭合与停止边界 + +1. **真实 WSL UNC:**本快照环境只注册 Ubuntu WSL2,但其 `ext4.vhdx` 缺失,启动报 `CreateInstance/MountDisk/HCS/ERROR_FILE_NOT_FOUND`。没有与 source commit 绑定的健康 Windows+WSL strict artifact 时,该项保持 Pending;不得用模拟 UNC 或代码开关替代,也不得在未授权时 unregister/reinstall 发行版。 +2. **真实 Beta 与历史发布物:**本静态 `c63a403` 快照生成时尚无真实用户 Beta 反馈或受控历史正式 Release binary backup 实物回归,不能外推为用户环境稳定性。后续 source head 新增了固定 v0.4.1 hosted Automation Release asset 的 checksum-bound synthetic backup→当前 Node Restore fixture;只有该 source head 最新成功 C10 artifact 才能关闭“本快照缺少 hosted formal binary”这一项。它仍不能替代真实用户 Beta、真实用户数据、代码签名或生产跨版本升级。 +3. **受保护 `main`:**PR #90 未合并且明确禁止合并。获授权合并后,必须在实际 `main` SHA 重跑全部 applicable jobs 并生成新的 C10 bundle。 +4. **签名与更新:**Windows signing、Apple Developer ID/Notarization、生产 update metadata/download/restart 和跨版本升级验证均未执行。 +5. **发布授权:**未创建 tag,未发布 npm/GitHub Release,未签名、公证或写生产更新通道;本证据不授予这些权限。 + +任何上述边界未闭合时,都不得把 `1.0.0` source manifest、`1.0.0-rc.204` CI candidate、26/26 green jobs 或 V1 候选角色标识表述为 Stable 已发布。 diff --git a/docs/migration/evidence/C1_PUBLIC_API_ERRORS_2026-08-25.md b/docs/migration/evidence/C1_PUBLIC_API_ERRORS_2026-08-25.md new file mode 100644 index 0000000..86897a5 --- /dev/null +++ b/docs/migration/evidence/C1_PUBLIC_API_ERRORS_2026-08-25.md @@ -0,0 +1,50 @@ +# C1 Public API 与结构化错误证据(2026-08-25) + +状态:本地门禁通过,等待远端 CI。输入 checkpoint 为 `29c84ec`;C1 最终 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 边界 + +- `src/public-api.js` 是产品入口使用 Node Core 的唯一公开入口;CLI 与 Web 不再深度导入 Core 实现模块,Watch 的延迟同步调用也经该入口。 +- `runSync`、`runSwitch`、`runRestore`、`runWatch` 仅作为已标记弃用的迁移兼容适配器保留;本 checkpoint 不把它们定义为 Renderer API,也不声称 C3 Prepare/Apply 已实现。 +- Status 的 Human 呈现从业务 service 移到 `cli-presenter.js`,避免公共 Core 方法携带 CLI 输出职责。 +- 未移动同步算法,未改变备份优先、事务、回滚、locked rollout partial 或 WSL UNC 阻断规则。 +- 未读取真实 Codex Home、认证信息、凭据、token 或消息正文;未创建 tag 或执行发布动作。 + +## 公共错误边界 + +- `CoreError` 固化 canonical code、severity、retryable、recoveryRequired、operationId、details 和 suggestedAction;DTO 不包含 stack、cause 或任意异常属性。 +- 新增并测试 `PLAN_EXPIRED`、`STALE_STATE`、`LOCK_UNVERIFIABLE`;`OPERATION_BUSY` 必须携带可信 `busyScope`,`LOCK_UNVERIFIABLE` 必须携带可信 `lockScope`。 +- 普通异常即使伪造 canonical `code` 也不能把任意 `details`、operationId、token 或消息正文注入 DTO;details 在规范化后递归冻结。 +- 公共写入兼容入口、History 与 Watch 的确定输入错误使用 `INVALID_INPUT`;既有确认快照漂移暂用合同中的 `PLAN_STALE`,C3 的正式 Apply revision 复核使用 `STALE_STATE`。 +- SQLite 分类只依赖 driver primary result code 或明确 symbolic code,不依赖英文 message;Web 保持旧 `{error, code?}` 形状,仅对真实 `CoreError` 追加安全 `coreError` DTO。 +- Restore 的未完成事务覆盖不足使用 `RECOVERY_REQUIRED`;文件/目录访问拒绝使用 `PERMISSION_DENIED`。 + +## 锁协议增量 + +- 协议 v2 owner 可选记录 `scope`;当前 Home 锁为 `codex-home`,资源锁入口可显式使用 `state-db`,旧读取器可忽略该字段。 +- 已确认 live owner 返回 `OPERATION_BUSY`;进程代际、目录身份、owner 或 cleanup 无法可靠证明时 fail-closed 返回 `LOCK_UNVERIFIABLE`。 +- 本 checkpoint 只建立 scope/error 基础和跨运行时不确定性判定;真实 State DB identity、固定顺序双层获取与 Node/.NET 同时持锁仍属于 C3,本文不声称已完成。 + +## 本地验证 + +环境:Windows x64,Node `v24.11.1`,npm `11.10.0`。 + +| 命令 | 结果 | +| --- | --- | +| `npm test` | 283 passed,0 failed,0 skipped | +| `npm run web:build` | Vite 8.2.2 production build 成功,21 modules transformed | +| `npm audit --omit=dev --audit-level=moderate` | 0 vulnerabilities | +| `npm audit --audit-level=high` | 0 vulnerabilities | +| `npm pack --dry-run --json` | 成功;新 public API、CoreError 与 presenter 均进入根 npm tarball | +| `dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --filter FullyQualifiedName~LockServiceTests --configuration Release` | 28 passed,0 failed,0 skipped | +| `git diff --check` | 通过,仅有既有 Windows CRLF 工作区提示 | + +另进行了三轮独立只读复审;最终结论无 P0/P1/P2 阻断。复审提出的 DTO 任意属性泄漏、跨运行时 owner 不确定性误分 busy、Restore/Web 未类型化、lock scope 丢失、cleanup AggregateError 丢码及权限错误分类均已修复并有回归覆盖。 + +## 已知未闭合项 + +- CLI 严格单对象 JSON stdout 与退出码矩阵属于 C2,当前 Human CLI 兼容行为保持不变。 +- Plan ledger、10 分钟 TTL、单次消费、revision、双层锁和 operation snapshot 属于 C3。 +- Node 16.20.2 tarball 的 Windows/Ubuntu 真正安装执行仍由后续兼容 CI 证明;本地 Node 24 结果不能替代该证据。 +- 当前 npm/Web dist 仍包含既有 source map;Electron/发布产物必须在 C9 排除并扫描证明。 +- 只有最终 PR 的所有必需 job 成功并合入受保护分支后,对应 Phase 才能标记 Completed。 diff --git a/docs/migration/evidence/C2_CLI_JSON_2026-08-25.md b/docs/migration/evidence/C2_CLI_JSON_2026-08-25.md new file mode 100644 index 0000000..83ccbc8 --- /dev/null +++ b/docs/migration/evidence/C2_CLI_JSON_2026-08-25.md @@ -0,0 +1,44 @@ +# C2 CLI JSON 契约证据(2026-08-25) + +状态:本地门禁通过,等待远端 CI。输入 checkpoint 为 `f008d0e`;C2 最终 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 冻结边界 + +- Human 模式保持既有文本输出和成功/失败 `0/1` 兼容;本 checkpoint 未改变既有业务操作、备份、事务、回滚、WSL 或 locked rollout 语义。 +- `status`、`sync`、`switch`、`restore`、`prune-backups`、`install-windows-launcher` 与 `help` 支持有限终态 JSON;`watch`、`web` 在加载 Core 或创建长运行状态前拒绝 `--json`。 +- JSON stdout 严格只写一个以换行结束的终态对象,固定顶层键为 `{schemaVersion, command, ok, outcome, result, warnings, error}`;进度只写 stderr,stderr observer/EPIPE 失败不改变已完成事务结果或触发第二次 stdout 写入。 +- JSON 退出码冻结为:`0` completed/noop、`1` 普通失败或已回滚、`2` 输入无效/计划失效、`3` partial、`4` recovery required、`5` busy/lock unverifiable、`130` cancelled。 + +## 安全与兼容性 + +- JSON 参数使用逐命令 allowlist、精确 positional 数量、单次 flag 和 value/boolean 类型规则;未知命令归一为固定 `command: "unknown"`,输入错误使用固定文案,不回显命令、参数值或路径。 +- flags/counts 使用 null-prototype map,契约和 canonical error message 查找只接受 own property;`__proto__`、`constructor`、`toString` 等原型键不能绕过校验或伪装错误码。 +- `--flag=value` 按首个 `=` 切分,保留值中后续全部 `=`;真实 status 子进程已覆盖包含 `=` 的 Codex Home。 +- 成功 result、warnings 和错误 details 均按产品 DTO 字段 allowlist 重新构造;任意 message、stack、cause、suggestedAction、token、消息正文及未知嵌套属性均不能穿透 JSON envelope。 +- `OPERATION_BUSY` 保留可信 `busyScope`;SQLite busy 与 `LOCK_UNVERIFIABLE` 均映射 exit `5`。真实 Core 子进程覆盖正常 sync、pending journal/recovery、SQLite busy-before-backup 与 Windows locked-rollout partial。 + +## 本地验证 + +环境:Windows x64,Node `v24.11.1`,npm `11.10.0`;输入 SHA `f008d0ea277b57fd1d027068bfab9c4f80c5ae3a`。 + +| 命令 | 结果 | +| --- | --- | +| `node --test test/cli-json.test.js test/cli-json-contract.test.js` | 22 passed,0 failed,0 skipped | +| `npm exec --yes --package=node@16.20.2 -- node --test test/cli-json.test.js test/cli-json-contract.test.js` | 实际 Node `v16.20.2`;2 个目标测试文件通过,0 failed/skipped | +| `npm test` | 309 passed,0 failed,0 skipped | +| `npm run web:build` | Vite 8.2.2 production build 成功,21 modules transformed | +| `npm audit --omit=dev --audit-level=moderate` | 0 vulnerabilities | +| `npm audit --audit-level=high` | 0 vulnerabilities | +| `npm pack --dry-run --json` | 成功;`src/cli-json.js` 与 C2 evidence 进入根 tarball,`test-support` 未进入;候选产物哈希留待 C9/C10 的不可变 artifact 记录 | +| `dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --filter FullyQualifiedName~LockServiceTests --configuration Release` | 28 passed,0 failed,0 skipped | +| `git diff --check` | 通过,仅有 Windows CRLF 工作区提示 | + +两轮独立最终只读复审均确认无 P0/P1 阻断;复审发现的未知命令回显、内联值截断、原型键 flag 绕过和错误码原型属性误判均已修复并补回归。 + +## 已知未闭合项 + +- C3 的正式 Plan/Apply ledger、10 分钟 TTL、单次消费、revision 复核、双层资源锁和 operation snapshot 尚未实现;C2 只冻结对应未来错误/退出语义。 +- 本地 Node 16.20.2 验证运行了 C2 目标测试,但 Windows/Ubuntu 根 tarball 安装执行仍需后续兼容 CI 证明。 +- 当前 npm/Web dist 仍包含既有 source map;Electron/发布产物必须在 C9 排除并扫描证明。 +- Web/Electron 共享客户端、现代 UI 与桌面 transport 尚未迁移;本 checkpoint 不声称这些目标已完成。 +- 未创建 tag,未发布 npm/GitHub Release,未签名、公证或写更新通道;只有最终 PR 的全部必需 job 成功并合入受保护分支后,对应 Phase 才能标记 Completed。 diff --git a/docs/migration/evidence/C3_PLAN_APPLY_DUAL_LOCK_2026-08-25.md b/docs/migration/evidence/C3_PLAN_APPLY_DUAL_LOCK_2026-08-25.md new file mode 100644 index 0000000..b9ae08b --- /dev/null +++ b/docs/migration/evidence/C3_PLAN_APPLY_DUAL_LOCK_2026-08-25.md @@ -0,0 +1,53 @@ +# C3 Plan/Apply、双层锁与协调状态证据(2026-08-25) + +状态:本地门禁通过,等待远端 CI。输入 checkpoint 为 `13163f5`;C3 最终 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 已实现边界 + +- Node Core 的 Sync、Switch、Restore 已提供 `prepare*/apply*`;Plan 使用 32-byte 随机不透明 ID、schema v1、10 分钟 TTL、进程内单次消费 ledger。Apply 的公共输入严格为 `{schemaVersion: 1, planId}`,旧 `runSync/runSwitch/runRestore/runWatch` 只保留同进程兼容适配。 +- Plan 绑定可信 profile、config、storage、完整 rollout inventory、SQLite main/WAL/SHM 与受管 backup revision;Apply 在取得最终锁后重新解析 storage 并精确复核,任何漂移在创建备份或修改目标前返回 `STALE_STATE`。 +- 写锁顺序固定为 `Codex Home -> State DB`。State DB identity 为物理父目录 realpath、NUL 分隔的规范文件名及 Windows 大小写归一,锁路径为 `/.codex-provider-sync/locks/.lock`。协议 v2 owner/claim 增加 `scope/resourceKey`,旧读取器兼容。 +- Node 与仍受支持的 .NET Core 均实现同一 State DB 资源锁协议;真实 Node/.NET 子进程双向竞争和两个 Codex Home 共用一个 DB 的零备份败方已验证。Restore 目标父目录不能可靠解析时返回 `LOCK_UNVERIFIABLE`,不回退到 Home-only 锁。 +- Status/History 保持只读。Node 与 .NET Status 都在外部 Home/State DB 写锁期间返回最后完整快照和 operation marker;无缓存或锁不可验证时返回显式不完整状态,绝不扫描中间态。锁协议观测与状态 revision 在扫描前后及最终返回前复核;.NET 缓存对所有可变嵌套 DTO defensive clone。 +- Web direct-write 路由固定返回 `410 PLAN_REQUIRED` 且不调用 writer;现代 Prepare 只接收产品输入,Apply 只接收 plan ID。Web Status 直接转发 Core 快照,不混入 live profile/storage revision。 +- Watch 合并 in-flight/busy 事件、不与人工操作重叠;人工操作完成后只重放一个合并 batch,停止 Watch 会取消 completion subscription。observer/progress 失败不改变事务结果。 +- `OPERATION_BUSY` 携带 `busyScope`;`LOCK_UNVERIFIABLE` 携带 lock scope/resource key。Prune 仍只持有 Home 锁;本 checkpoint 未提前改变 C8 Restore journal 状态机。 + +## 关键行为证据 + +- Plan ledger:过期、篡改、跨 operation、重启失效、单次消费、并发双 Apply 仅一方执行。 +- 状态漂移:config、profile、storage、rollout、SQLite main/WAL、backup manifest 漂移均在备份前拒绝;Switch 锁前/锁内候选 DB 发生变化时,锁内重新 detect 并返回 `STALE_STATE(storage)`。 +- 锁:Node↔Node、Node↔.NET、不同 Home 共用 DB、Home/State busyScope、stale/malformed owner、缺失 Restore 父目录、败方零 mutation。 +- Status:真实外部 Node Home 锁、真实 Node State DB 锁、两个 Home 共用 Node-locked DB、.NET Home/State 锁、malformed owner;均返回缓存或显式不完整状态,不报告中间 Provider/SQLite 行为健康。 +- Watch:busy batch 保留、人工优先、合并一次、停止取消订阅、既有连续失败与 watcher rebind 行为不回归。 +- Web:旧直写无副作用;Prepare/Apply、profile revision、managed backup、Restore relocation、WSL UNC、partial 与安全 CoreError DTO 均有契约测试。 + +## 本地验证 + +环境:Windows x64,Node `v24.11.1`,npm `11.10.0`,.NET SDK `10.0.400`;输入 SHA `13163f510a1ac0c245ac992f7a10027f30195300`。 + +| 命令 | 结果 | +| --- | --- | +| `npm test` | 339 passed,0 failed,0 skipped | +| `node --test web/src/api.test.js web/src/operation-state.test.js` | 14 passed,0 failed,0 skipped | +| `npm run web:build` | Vite 8.2.2 production build 成功,21 modules transformed | +| `npm exec --yes --package=node@16.20.2 -- node --test test/plan-ledger.test.js test/operation-revision.test.js test/state-db-lock.test.js test/public-api-contract.test.js` | 实际 Node `v16.20.2`;4 个目标文件通过,0 failed/skipped | +| `npm audit --omit=dev --audit-level=moderate` | 0 vulnerabilities | +| `npm audit --audit-level=high` | 0 vulnerabilities | +| `npm pack --dry-run --json` | 成功;C3 Core 模块、Web dist 与文档进入根 tarball,Electron 依赖不存在 | +| `dotnet build CodexProviderSync.sln --configuration Release` | 13 projects build 成功,0 warnings/errors | +| `dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --configuration Release --no-build` | 220 passed,1 个真实 WSL 条件测试 skipped,0 failed | +| `dotnet test desktop/CodexProviderSync.Application.Tests/CodexProviderSync.Application.Tests.csproj --configuration Release --no-build` | 49 passed,0 failed/skipped | +| `dotnet test desktop/CodexProviderSync.Automation.Tests/CodexProviderSync.Automation.Tests.csproj --configuration Release` | 27 passed,0 failed/skipped | +| `dotnet test desktop/CodexProviderSync.App.Tests/CodexProviderSync.App.Tests.csproj --configuration Release --no-build` | 67 passed,0 failed/skipped | +| `dotnet test desktop/CodexProviderSync.GuiE2E.Tests/CodexProviderSync.GuiE2E.Tests.csproj --configuration Release --no-build` | 36 passed,0 failed/skipped | +| `git diff --check` | 通过,仅有 Windows CRLF 工作区提示 | + +两轮独立只读复审确认无剩余 P0/P1;复审发现并推动修复了 Web status live/cache 混合、外部锁绕过、Watch busy 丢事件、旧 Web 直写、.NET Restore Home-only 降级、.NET Status 中间态扫描、last-complete cache 可变引用污染,以及 Switch 复用锁前 State DB selection 的 TOCTOU。 + +## 已知未闭合项 + +- Windows/Ubuntu/macOS 远端矩阵、Linux Node↔.NET lock contract 与真实 WSL 场景仍由 CI/C10 汇总证明;本地的 Windows 结果不能替代其他平台证据。 +- C8 的 Restore v2 独立快照、完整状态机、启动恢复与 update 安装阻断尚未实现;C3 继续使用现有安全 journal/rollback 合同。 +- 当前根 npm/Web dist 仍包含既有 source map;C9 必须从生产包排除并扫描。Electron、workspace、共享 UI/CoreClient 与平台产物尚未进入本 checkpoint。 +- 未创建 tag,未发布 npm/GitHub Release,未签名、公证或写更新通道;只有最终 PR 的全部必需 job 成功并合入受保护分支后,对应阶段才可标记 Completed。 diff --git a/docs/migration/evidence/C4_WORKSPACE_CORE_CLIENT_2026-08-25.md b/docs/migration/evidence/C4_WORKSPACE_CORE_CLIENT_2026-08-25.md new file mode 100644 index 0000000..bed32be --- /dev/null +++ b/docs/migration/evidence/C4_WORKSPACE_CORE_CLIENT_2026-08-25.md @@ -0,0 +1,55 @@ +# C4 Workspace、Core、Contracts 与 CoreClient 证据(2026-08-25) + +状态:本地门禁通过,等待远端 CI。输入 checkpoint 为 `166f6ff`;C4 最终 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 已实现边界 + +- 根启用 npm workspaces 和单一 lockfile,建立 `apps/cli`、`apps/web`、`apps/desktop`、`packages/core`、`packages/contracts`、`packages/core-client`、`packages/app-ui`、`packages/design-system`、`packages/test-fixtures`。所有内部包均 private,根包仍是唯一 npm 发布面。 +- `packages/core` 是可导入、可测试、checkJs 的 ESM JavaScript bridge,只允许导入 `src/public-api.js`;模块只导出 `createCoreFacade({resolveProfile})`,由可信 Host 将 profile ID/revision 解析为绝对路径,facade 实例精确提供 15 个 Core 方法。无效/mismatched profile 在接触 Core 路径前 fail closed,显式测试证明即使进程 `CODEX_HOME` 指向另一目录也只读取已选 profile。没有搬迁或复制锁、备份、journal、SQLite、rollout 与 service 算法。 +- `packages/contracts` 用 TypeScript 固化 schema/protocol v1、Core Error DTO、Status/Plan/Operation/Progress DTO、固定方法集合以及 request/response/progress envelope。所有产品输入拒绝任意路径/未知字段;Apply 只接受 planId;成功 payload 有按方法 runtime guard;ProgressEvent 拒绝消息正文、路径和诊断扩展字段。 +- `packages/core-client` 用同一接口实现 `TransportCoreClient`、`HttpCoreClient` 与 `MockCoreClient`;HTTP request 上限为 64 KiB,响应必须匹配 protocolVersion/requestId,非 2xx 不能返回成功 envelope。公共错误使用固定 code 文案、canonical severity/retryability、UUID 和 details 白名单;未知异常文本、路径、Token、消息正文和 suggestedAction 不透传,畸形 payload 收口为安全 `INTERNAL_ERROR`。 +- `packages/test-fixtures` 落地 schema v1、严格字段/安全 ID、无真实用户数据标记、临时复制并自动清理的 runner;源根和树拒绝 symlink/reparse、敏感文件名和越界输入,复制后重新验证 staged tree/manifest,callback 失败仍清理。Node/.NET 未裁决差异固定为 `blocked`。 +- `app-ui`、`design-system` 和 `desktop` 是可编译/可测试的受限 ownership boundary。它们明确标为 C4 contract/tokens/not-enabled 状态;没有提前声称 React 页面、Electron BrowserWindow/IPC/Utility Process 或写能力已实现。 +- 根 Web 依赖迁移到 `apps/web` 的精确 devDependencies;根不再声明 React。尚未搬迁的 `web/` 源仍由该 workspace 构建;根 production-only npm 8 安装树不含 React/Vite/TypeScript/Electron,tarball 不依赖 workspace symlink。 +- 真实 tarball smoke 暴露并修复了 Windows 安装目录 8.3 short-path 导致 CLI direct-execution 判断失效的问题;入口现在比较两侧 realpath,并由安装态 help/status 长期回归。 + +## 依赖与安全证据 + +依赖选择和兼容理由记录于 [ADR-0014](../../adr/0014-npm-workspace-and-dependency-boundaries.md):TypeScript `7.0.2`、`@types/node 24.13.3`、Vite `8.2.2`、React plugin `6.1.0`、React/React DOM `19.2.8`,全部为 2026-08-25 对应稳定线的 exact version。现有 `better-sqlite3 8.7.0` 为根 Node 16 optional fallback,未强升到不兼容版本。 + +- 全部仓库直接依赖版本由静态门禁拒绝 `^`、`~`、`workspace:*` 和 `file:`;传递树由单一 lockfile 锁定。 +- `npm audit --omit=dev --audit-level=moderate`:0 vulnerabilities。 +- `npm audit --audit-level=high`:0 vulnerabilities。 +- 根 packlist 为 90 entries;不存在 `apps/`、`packages/`、workspace dist、node_modules 或 Electron runtime。当前既有 Web source map 仍在 packlist,按计划由 C9 移除。 + +## 本地验证 + +环境:Windows x64,Node `v24.11.1`,npm `11.10.0`,Node 16 smoke 使用 Node `v16.20.2` + npm `8.19.4`;输入 SHA `166f6ff`。 + +| 命令 | 结果 | +| --- | --- | +| `npm run workspaces:build` | TypeScript project references 与 Core checkJs 通过,0 errors | +| `npm run workspaces:test` | 29 passed,0 failed/skipped;覆盖可信 Profile facade、Contracts 输入/输出/error guard、HTTP/Mock Client、Fixture hardening 与 ownership boundary | +| `node scripts/verify-workspace-boundaries.js` | package、direct dependency、import direction、root publish allowlist 全部通过 | +| `npm test` | 343 passed,0 failed/skipped | +| `npm run web:build` | Vite 8.2.2 + React 19.2.8 production build 成功,22 modules transformed | +| `npx --yes --package node@16.20.2 node --test test/workspace-boundaries.test.js test/public-api-contract.test.js test/cli-json.test.js` | 实际 Node `v16.20.2`;3 个目标文件通过 | +| Node 16.20.2 + npm 8.19.4:`npm ci --workspaces=false --omit=dev`、`runtime:verify-node16`、`package:verify-root-tree` | 真正 root-only production install 通过;无 React/Vite/TypeScript/Electron/workspace link | +| Node 16.20.2 + npm 8.19.4:`npm run package:smoke:lifecycle` | tarball 正常 lifecycle 安装;真实 bin shim help、`better-sqlite3` 创建/打开 synthetic State DB、显式临时 Home `status --json` 通过 | +| `npm run package:smoke` | Node 24 tarball 安装态 smoke 通过 | +| `npm audit --omit=dev --audit-level=moderate` | 0 vulnerabilities | +| `npm audit --audit-level=high` | 0 vulnerabilities | +| `npm pack --dry-run --json` | 90 entries;无 workspace/Electron/Fixture;既有 source map 已登记为 C9 gap | +| `dotnet build CodexProviderSync.sln --configuration Release` | 13 projects;0 warnings / 0 errors | +| `dotnet test desktop/CodexProviderSync.Core.Tests/... --no-build` | 220 passed,1 个 Windows WSL safety 测试按平台预期 skipped | +| `dotnet test`(Application / Automation / App / GUI E2E,Release `--no-build`) | 49 / 27 / 67 / 36 passed,0 failed/skipped | + +CI 新增 Windows/Ubuntu Node 24 workspace contract job,以及 Windows/Ubuntu Node 16.20.2 + npm 8 root-only install、正常 tarball lifecycle、SQLite driver/bin smoke;所有 job 被 `ci-gate` 视为 required,失败、取消或跳过都会阻断。npm publish 与 tag release workflow 也在任何发布动作前复用 workspace/root package 门禁;本 checkpoint 没有触发发布。 + +## 已知未闭合项 + +- C4 只建立共享 UI ownership 和 CoreClient transport;现有 Web 尚未通过 `HttpCoreClient`,现代页面、i18n/theme/accessibility 与 Web 安全等价属于 C5。C4 不使 Phase 2 Completed。 +- `HttpCoreClient` 当前通过 fake transport 契约验证;Local Web Host 的统一 `/api/core` envelope handler 与现有 pairing/profile/storage 安全规则接入属于 C5,不能把当前 client package 描述为已上线 Web transport。 +- 双向 Node/.NET Backup Round-trip 和 cross-runtime Foreign Pending Restore 的共享 Fixture 证据仍是 Phase 2 退出门槛,必须在 C5 结束前闭合;目前只有 schema/runner/difference format 落地,未宣称运行时等价。 +- `apps/desktop` 不含 Electron 依赖和可运行 runtime;安全 BrowserWindow、Preload、IPC、Utility Process 与只读能力属于 C6。 +- 根 tarball 的既有 Web source map 仍存在,必须在 C9 的包内容扫描中移除;没有 tag、npm/GitHub Release、签名、公证或更新通道写入。 diff --git a/docs/migration/evidence/C5_SHARED_UI_WEB_2026-08-26.md b/docs/migration/evidence/C5_SHARED_UI_WEB_2026-08-26.md new file mode 100644 index 0000000..e3d92da --- /dev/null +++ b/docs/migration/evidence/C5_SHARED_UI_WEB_2026-08-26.md @@ -0,0 +1,61 @@ +# C5 共享 UI、Web 与跨运行时 Fixture 证据(2026-08-26) + +状态:本地门禁通过,等待远端 required CI 与最终 PR 合入。输入 checkpoint 为 `d6b0fef`;C5 输出 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 已实现边界 + +- `apps/web` 是唯一 Web source/build owner;旧 `web/src`、旧 Vite 配置与旧页面测试已移除,`web/dist` 仅保留根 npm 包使用的 production 静态产物。 +- `packages/app-ui` 从零建立共享 React AppShell,固定八个页面:Overview、Sync、Switch Provider、Backups/Restore、History、Profiles、Diagnostics、Settings;Recovery、Operation、Error Boundary 与 Toast 是全局状态,不伪装成额外页面。 +- `App.tsx` 只保留 Query/i18n/ErrorBoundary/Toast provider;页面、Plan/Result 与展示工具按 feature 拆分。静态边界递归扫描完整 `src`,不再用巨型 shell 文件充当能力证明。 +- UI 使用 React、检入的 Radix/shadcn 风格组件、Tailwind、TanStack Query、React Hook Form、Zod、Lucide 和 react-i18next;提供 `zh-CN` / `en`(英文 fallback)与 `system` / `light` / `dark`,并实现键盘操作、可见焦点、reduced motion 和 200% 窄视口布局。C10 hardening 又以 Web 380 CSS px 和真实隐藏 Electron `760×560 @ 200%` 遍历八页,持久主题由固定 bootstrap 在 React 前应用。 +- Core 业务流固定为 `Browser → HttpCoreClient → POST /api/core → Web Core adapter → createCoreFacade`。旧直写路由继续返回 `410 PLAN_REQUIRED`,不调用兼容 `run*`;Sync/Switch/Restore 先 Prepare,再以精确 `{schemaVersion:1, planId}` Apply。 +- Web Host 保留 loopback、一次性 pairing、设备凭据 hash、Origin、64 KiB、server-managed profile/storage revision 和受管 `backupId`。响应保留 requestId;协议/输入/输出由共享 contract guard 校验,非 2xx 不能携带成功 envelope。 +- Public Status 不返回 Codex/SQLite/State DB 路径,History summary 不返回 `cwd`,warning 只映射为固定安全类别/文案;未知异常不回显 message/stack/cause。备份只暴露受管 ID 与有界元数据。 +- History 仅在进入页面后按固定 50 条分页加载 summary,点击会话后才读取详情;正文不进入 TanStack Query cache,离开详情时清空并 abort pending request。Vitest + Testing Library 验证翻页前不请求正文、显式打开后才读取且 Query cache 不含正文 marker;E2E 继续证明 Overview/其他页面不会预取消息正文。 +- History Core 列表扫描按 rollout 使用常量聚合内存;详情以列表记录的文件 identity、稳定物理路径和 sessions 根边界重新打开同一 regular file,并从同一句柄读前/读后复核。大 rollout 有界尾部、同 mtime 换档和外部 sessions junction 均有回归测试。 +- 所有写入口同时服从本地 mutation、新鲜且成功的 Status、`operationInProgress` 与 recovery 状态;首次 Status 未完成或刷新失败时 Sync/Switch/Restore/Prune/Watch/Plan confirm 全部 fail closed。`recovery_required` 结果从出现起即不可关闭,只有一次新鲜 Status 成功且确认 pending recovery 已清除后才解锁。Plan/OperationResult/Diagnostics 默认显示语义化摘要,技术 JSON 折叠;结果关闭后恢复原操作按钮焦点。组件测试覆盖白名单投影、locked rollout 与敏感扩展字段不渲染。 +- 保留的 `/api/status`、`/api/backups`、`/api/history` 与详情 URL 只是 Legacy 兼容投影,全部先转为严格 allowlist 的 CoreFacade request,再从共享 DTO 投影旧响应;不再直接调用底层 service 或向浏览器返回路径。 +- Production HTML 每响应生成随机 CSP nonce,`script-src` / `style-src` 均不使用 `unsafe-inline`;Web Host contract test 与 Chromium E2E 都验证 nonce/header 对应且 CSP 生效。 + +## Phase 2 跨运行时 Fixture + +- 检入 `packages/test-fixtures/static/bidirectional-backup-roundtrip` 与 `foreign-pending-restore`。输入只含 fake `example.invalid` Provider、空正文 SQLite row、`session_meta` rollout 和 seed SQL;无 SQLite 二进制、认证材料、消息正文或真实用户数据。 +- `test-support/cross-runtime-fixtures.mjs` 每次在临时目录 materialize SQLite,并从同一静态输入复制四个独立方向:Node Backup→.NET Restore、.NET Backup→Node Restore、Node crash journal→.NET Restore、.NET crash journal→Node Restore。它只由专用 Windows job 调用,不混入 Node-only/Node 16 matrix。 +- 比较 config、global-state primary/backup 与 rollout 原始字节 hash,并对 SQLite 全部 `threads` 列(含 `updated_at` / `updated_at_ms` / sentinel)、schema、user_version 做固定排序语义 hash 与 `integrity_check`;不把 SQLite page layout、绝对路径、运行时间或 operationId 当作跨实现合同。四个方向均恢复为初态,source journal 均重读为合法 `rolledBack` terminal,无 pending/invalid tail。 +- 首次真实运行发现 .NET 写入长路径而 Node 进程看到同一目录的 Windows 8.3 短路径。Node Restore 改为以存在目录的 `realpath` 证明物理身份,同时对 manifest 原始路径逐段拒绝 symlink/junction/reparse、冻结 canonical identity,并在每个 rollout 写入前重新验证;canonical target 仍须位于 canonical rollout root、无重复且为 regular file,无法证明时 fail closed。目录链接与初检后替换均有回归测试。 +- `.NET FixtureHost` 只调用公开 Core sync/restore API 并输出最小 JSON;Node/.NET CrashHost 在同一 rollout mutation 窗口真实终止。Windows `cross-runtime-fixtures` job 已加入 `ci-gate`,失败、取消或跳过均阻断。 + +这组证据只闭合 Phase 2 的双向 backup round-trip 与 foreign source pending 兼容,不提前声称 C8 Restore v2 自身 snapshot/journal 或完整 crash matrix 已实现。 + +## 依赖与发布面 + +- C5 依赖解析与 exact version 记录在 [ADR-0014](../../adr/0014-npm-workspace-and-dependency-boundaries.md);所有直接依赖不使用 `^`、`~`、`workspace:*` 或 `file:`。 +- C10 审计补入 private `app-ui` 的 Vitest `4.1.11`、Testing Library 与 jsdom `29.1.1` exact dev dependencies;它们不进入根 production tree、tarball 或 Electron runtime closure。 +- 根包继续没有普通 production dependency;为已发布的 Local Web Host 批准窄 tarball runtime:`packages/contracts/dist` 与 `packages/core/src`。边界测试/packlist 禁止其他 workspace、Fixture、Electron、node_modules 和 UI source;Node 16.20.2 与 Node 24 安装态实际执行 CLI、SQLite、Web pairing/profile、真实 `/api/core getStatus` / `PROFILE_CHANGED` 脱敏以及 shell/strict CSP。造库优先使用现代 Node 的 `node:sqlite`,Node 16 才使用安装态 fallback。 +- `npm audit --omit=dev --audit-level=moderate`:0 vulnerabilities;`npm audit --audit-level=high`:0 vulnerabilities。 + +## 本地验证 + +环境:Windows x64,Node `v24.11.1`,npm `11.10.0`;兼容 smoke 使用 Node `v16.20.2` + npm `8.19.4`;输入 SHA `d6b0fef`。 + +| 命令 | 结果 | +| --- | --- | +| `npm run workspaces:check` | build/checkJs、边界与 9 个 workspace tests:34 passed,0 failed/skipped | +| `npm run fixtures:cross-runtime` | 2 top-level tests / 4 directed Node↔.NET cases passed;最终 hash、integrity、journal terminal 全通过 | +| `npm run web:build` | Vite 8.2.2 production build 成功,2032 modules;JS 530.55 kB、gzip 163.97 kB | +| `npm run web:test:e2e` | 2 Chromium tests passed;八页、History lazy/detail/clear、精确 Apply、partial/recovery/operation/error、html lang/本地化读屏标签、双语/主题、Skip/Escape 焦点、CSP nonce、八页 640px/200% 等效 reflow 与 reduced-motion computed style,0 console errors | +| `npm test` | 325 passed,0 failed/skipped;包含 28 个 Web Host/API tests;跨运行时 fixture 由专用命令/job 隔离执行 | +| Node 16:三个边界/API/CLI contract tests | 3 files passed,0 failed/skipped | +| Node 16 + npm 8、Node 24:`npm run package:smoke:lifecycle` | 两个 runtime 均通过安装态 bin、synthetic SQLite、status JSON、Web pairing/profile、真实 Core status/stale error 脱敏、index/strict CSP | +| `npm audit --omit=dev --audit-level=moderate` / full-tree high | 均为 0 vulnerabilities | +| `dotnet build CodexProviderSync.sln --configuration Release`,另显式 build FixtureHost/CrashHost Release | 均为 0 warnings / 0 errors | +| `.NET Core/Application/Automation/App/GUI E2E tests` | Core 220 passed(1 个 Windows WSL safety 按平台预期 skipped);其余 49 / 27 / 67 / 36 passed,0 failed | + +## 已知未闭合项 + +- 远端 required CI 尚未运行,最终 PR 合入前 Phase 2 不标记 Completed;C5 checkpoint 也不等同于发布。 +- 当前 production Web 单 JS chunk 为 530.55 kB,Vite 产生约 2.30 MB source map;这是 C9 的 code-split/生产产物排除与包内容扫描门槛,不能据 C5 声称 release artifact 已安全闭合。 +- Electron BrowserWindow、Preload/IPC、Utility Process、只读 packaged smoke 与 Renderer 原生路径 picker/token 属于 C6;当前 Web Profiles 仍是浏览器 Host 管理界面,不能直接复用于 Electron 任意路径输入。 +- C5 保留既有 atomic rollout Restore,不用 `truncate + copy` 临时方案换取表面上的路径绑定,也不声称最终 namespace commit 已 descriptor-bound。C8 必须把 parent-handle-relative atomic install、恢复前 snapshot/journal、逐阶段故障注入及正文/换行逐字节不变量一起闭合。 +- Restore v2 独立恢复前 snapshot/journal、Watch/Diagnostics export/Update、三平台打包、SBOM/checksum、签名/公证和 C10 evidence bundle 均未在 C5 实现。 +- 本 checkpoint 未创建 tag,未发布 npm/GitHub Release,未签名、公证或写更新通道。 diff --git a/docs/migration/evidence/C6_ELECTRON_READONLY_2026-08-26.md b/docs/migration/evidence/C6_ELECTRON_READONLY_2026-08-26.md new file mode 100644 index 0000000..f123f27 --- /dev/null +++ b/docs/migration/evidence/C6_ELECTRON_READONLY_2026-08-26.md @@ -0,0 +1,55 @@ +# C6 Electron Read-only Alpha 证据(2026-08-26) + +状态:候选实现的本地 Windows x64 门禁通过;C5 required CI、远端 Windows/macOS/Linux C6 CI 与最终 PR 合入均未闭合,因此 C6/Phase 3 仍为 Pending。输入 checkpoint 为 `8a53ce8`;C6 输出 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 已实现边界 + +- 数据流固定为 `Electron Renderer → DesktopCoreClient → sandboxed Preload → Main IPC/Supervisor → Electron Utility Process → @codex-provider-sync/core`。Main 不执行 Core 业务,Utility 不深度导入根 `src/`。 +- BrowserWindow 固定 `nodeIntegration:false`、`nodeIntegrationInWorker:false`、`contextIsolation:true`、`sandbox:true`、`webSecurity:true`,并关闭 insecure content、experimental features 与 webview。Renderer 只从 `cps-app://app` 本地协议加载,CSP 无 `unsafe-inline`/`unsafe-eval`;导航、新窗口、webview 与权限默认拒绝。 +- C6 四层 allowlist 精确为 `getStatus/listBackups/listHistory/getHistorySession/getDiagnostics`。DesktopCoreClient、Preload、Main IPC 与 Utility 都拒绝 Sync/Switch/Restore/Prune/Watch;Renderer 不提交路径或任意 IPC channel。 +- Profile Host 文件由 Main/Utility 可信解析;Renderer 只见 `id/name/revision/codexHomeConfigured/sqliteHomeConfigured`。Status、Backups、Diagnostics 和 History summary 均不回传 Codex Home、SQLite Home、backup/rollout 路径或任意异常原文。 +- production build 通过编译期 flag 移除测试 bridge;设置运行时 `CPS_DESKTOP_E2E=1` 也不能启用 `test.requestRaw/crashRuntime`。只有 `electron-vite --mode test` 的非发布测试构建包含受 sender 校验的 crash hook。 +- History 列表标题只能来自显式 session metadata;无标题由 UI 本地化显示“Untitled session/未命名会话”,不得回退到首条用户正文。正文仅在显式打开详情后读取,返回列表时 abort/清空且不进入 Query cache。 +- 同一临时 fixture 上,`getStatus/listBackups/listHistory/getHistorySession/getDiagnostics` 分别通过独立 CoreFacade 与 Desktop Utility host dispatch,成功 DTO(仅归一化生成时间)及结构化错误 DTO 必须逐字段一致;非详情响应不得含正文、cwd、ciphertext 或工具参数 marker,调用前后 fixture tree hash 不变。 +- 内部 Electron 验收固定 `CPS_DESKTOP_WINDOW_DISPLAY=hidden`;真实 BrowserWindow 断言 `isVisible()===false` 且不聚焦,不占用用户主屏。C10 hardening 进一步在隐藏窗口 `760×560 @ 200%` 遍历八页并逐页验证无整页横向溢出;存在副屏时仍可显式选择 `secondary`,但 CI/本机自动化默认隐藏。 + +## Runtime 与并发证据 + +- Utility Hello 同时绑定 runtime/core protocol、app/core version、buildId、32-byte 随机 nonce、generation 和精确只读 capabilities;任何漂移在业务请求前 fail closed。 +- Runtime crash 立即把全部 pending request 归类为 `CORE_RUNTIME_CRASHED`,不后台重启;下一次用户请求才启动一个新 generation,并对每个并发 profile/revision 先执行 `getStatus` pending-journal preflight。 +- preflight 失败保留“仍需预检”状态,下一次请求重新检查,不能因 Runtime 已完成 Hello 而绕过;真实 E2E fixture 的 valid pending journal 使重启后 `recoveryBlocked:true`。 +- shutdown 是终结性幂等操作,即使 Runtime 尚未启动,调用后也永久拒绝新请求;timeout 会终止当前 generation,杜绝迟到响应与复用 requestId 错误关联。response 的 requestId/generation/operationId 与 preflight profile 必须关联。 +- 独立只读审查首次发现 History 标题正文泄漏及 shutdown/timeout/多 Profile/preflight-failure 竞态;实现与回归测试在本 checkpoint 内修复后重新验证。 + +## 构建、依赖与 CI + +- C6 精确锁定 Electron `44.0.0`、electron-vite `5.0.0`、electron-builder `26.15.7`、Desktop Vite `7.3.6`、React plugin `5.2.0`;版本裁决见 [ADR-0014](../../adr/0014-npm-workspace-and-dependency-boundaries.md)。 +- production bundle 不含 source map、workspace import 或 test hook;sandbox preload 唯一 runtime `require` 为 `electron`。Root manifest/production tree/tarball 显式拒绝 Electron 与 `electron-*`,根 CLI 继续保持 Node `>=16.20.2`。 +- `electron-readonly` required job 使用 Node 24,在 Windows、Ubuntu、macOS 执行 unit/security contract、production bundle、`electron-builder --dir`、unpacked production SQLite/History smoke,以及 test-build Utility crash/restart;Linux 通过 Xvfb。该 matrix 已加入唯一 `ci-gate`,任一失败、取消或跳过都阻断。 +- C6 的 `--dir` 只证明 unpacked Alpha 的 builder 布局与启动,不是 C9 发布产物。Installer/DMG/AppImage/deb、macOS 双架构、native fallback ABI/asar、SBOM/checksum、签名/公证与更新通道仍被 C9 阻断。 + +## 本地验证 + +环境:Windows 11 x64,Node `v24.11.1`,npm `11.10.0`;输入 SHA `8a53ce8`。 + +| 命令 | 结果 | +| --- | --- | +| `npm run desktop:test` | Desktop security/IPC/profile/runtime/protocol unit contracts:26 passed,0 failed/skipped | +| `npm run desktop:build` + `npm run desktop:verify-production-bundle` | production Main/Utility/CJS Preload/Renderer 构建成功;无 source map/workspace import/test hook,Preload 仅 require Electron | +| `npm run desktop:test:e2e:production` | 1/1 passed;production bridge 无 test/Node,真实 SQLite `openai=1`,valid pending journal,写方法 fail closed,History 正文未预取 | +| `npm run desktop:test:e2e` | 1/1 passed;安全 webPreferences/CSP/导航/权限、只读页面、路径脱敏、显式 History detail、Utility crash→generation+1→journal preflight、fixture Hash 不变 | +| `npm run desktop:pack:dir` + `npm run desktop:test:e2e:packaged` | Windows x64 unpacked production app 1/1 passed;asar 内 Main/Utility/Preload/Renderer 实际启动并读取真实 SQLite | +| `npm run workspaces:check` | 9 个 workspace 共 62 passed,0 failed/skipped;TypeScript/checkJs、依赖、导入与 root publish 边界通过 | +| `npm test` | 345 passed,0 failed/skipped | +| `npm run web:build` + `npm run web:test:e2e` | Vite 8.2.2 production build 成功,2034 modules;JS 532.99 kB、gzip 164.78 kB;Chromium 2/2 passed | +| Node 24、Node 16.20.2 + npm 8.19.4:`npm run package:smoke:lifecycle` | 两个 runtime 均通过 root tarball 安装态 bin、synthetic SQLite、JSON status、Web pairing/profile、真实 Core status/stale error 脱敏、strict CSP;安装树无现代 UI/Electron 依赖 | +| `npm audit --omit=dev --audit-level=moderate` / `npm audit --audit-level=high` | 均为 0 vulnerabilities | +| `js-yaml` parse CI / builder YAML | `.github/workflows/ci.yml` 与 `apps/desktop/electron-builder.yml` 均解析成功 | + +## 未闭合项与后续 TODO + +- C5 的远端 required CI 尚未闭合,C6 只能作为候选实现保留;远端 `electron-readonly` matrix 也尚未运行,因此 macOS/Linux unpacked 启动、runner architecture 与平台库证据仍为 Pending,Phase 3/C6 不标记 In Progress 或 Completed。 +- C7 才开放 Electron Sync/Switch 的 Prepare/Apply、model intent、Progress/Cancel、Busy/Partial 和 Backup→Restore 回环;C6 UI 与 IPC 都无写入口。 +- C8 才实现 Restore v2 snapshot/journal/crash matrix、Watch、诊断包落盘、Update 与 recovery action;C6 Diagnostics 只是只读脱敏摘要。 +- C9 才收敛 production app 体积和依赖闭包,完成 `node:sqlite`/`better-sqlite3` fallback、ABI rebuild/`asarUnpack`、四类发行产物、包内容扫描、SBOM/checksum 与全平台安装/卸载 smoke。 +- 本 checkpoint 未创建 tag,未发布 npm/GitHub Release,未签名、公证或写更新通道;.NET 实现保持可构建且未删除。 diff --git a/docs/migration/evidence/C7_ELECTRON_SYNC_SWITCH_2026-08-26.md b/docs/migration/evidence/C7_ELECTRON_SYNC_SWITCH_2026-08-26.md new file mode 100644 index 0000000..cb570a9 --- /dev/null +++ b/docs/migration/evidence/C7_ELECTRON_SYNC_SWITCH_2026-08-26.md @@ -0,0 +1,50 @@ +# C7 Electron Sync/Switch 证据(2026-08-26) + +状态:候选实现的本地 Windows x64 门禁通过;真实 WSL UNC、C5/C6 required CI、远端 Windows/macOS/Linux C7 CI 与最终 PR 合入未闭合,因此 C7/Phase 4 仍为 Pending。输入 checkpoint 为 `6820858`;C7 输出 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 已实现边界 + +- Electron 的写能力只开放 `prepareSync/applySync/prepareSwitch/applySwitch`。Restore、Prune、Watch、Diagnostics 落盘和 Update 仍不在 Desktop allowlist;Renderer 只能提交 profile、Provider 和三种 model intent,Apply 只能提交 `{schemaVersion:1, planId}`。 +- Switch 明确实现 `provider-default`、`keep-root-model`、`explicit` 三种模式;未在 `config.toml` 声明的自定义 Provider 在 Prepare 阶段返回 `INVALID_INPUT`,不创建 Plan、Backup 或业务 mutation。 +- Main 为每个 Plan 绑定 sender、Apply 方法、可信 profile/revision、Utility generation、TTL 和单次消费状态;篡改、跨方法、过期、重放或 generation 漂移均返回 `PLAN_EXPIRED`。未消费 Plan 会按 TTL/generation 清理,并以 256 条上限防止 Renderer 累积内存。 +- Utility runtime protocol v2 使用 Main 生成的 `dispatchId`,并同时核对 generation、requestId 与 operationId。Progress/operation-started envelope 严格、无路径;重复 requestId、未知 event、迟到 response 或 forged early cancel 都 fail closed。 +- Cancel 只经 sender-bound 的窄 IPC 进入对应 AbortController。备份前取消返回 `OPERATION_CANCELLED`;观察到 mutation 后取消必须完成补偿并保留 `SYNC_FAILED_ROLLED_BACK`/`RECOVERY_REQUIRED`,不能用“已取消”掩盖事务结果。 +- 写请求 timeout 被归类为 Runtime crash,不伪装为取消;Utility crash 拒绝全部 pending。下一次请求只重启一次并先检查 pending journal,非 terminal journal 继续阻断写入。 +- `StatusSnapshot` 在 Utility 崩溃持锁且没有 last-complete cache 时仍返回有效 fail-closed DTO:`sqliteHomeSource="unknown"` 是“尚未可靠解析来源”的 sentinel,必须与 `rolloutScanComplete:false`、`LOCK_UNVERIFIABLE` 和 `alignment.aligned=false` 一起消费,不能解释为健康存储。 + +## Backup、Restore 与故障矩阵 + +- Sync/Switch 保持 SQLite 可写预检、Home→State DB 双层锁、Backup-first、journal、rollback 和 locked rollout partial 语义。真实 SQLite writer 在 Backup 前返回 `SQLITE_BUSY`;Windows `FileShare.None` 锁定 rollout 不被覆盖,其他安全目标提交并返回 partial。 +- Electron 首次 Sync 生成的受管 Backup 通过 `@codex-provider-sync/core` 公共 facade 执行 Prepare/Apply Restore。Config、global state 与 rollout 按字节恢复;SQLite online backup/restore 按完整 schema、rows 与 `user_version` 比较语义一致,不把 SQLite 物理页布局误写成跨实现逐字节合同。 +- provider-only Backup 也保存每个可解析 `turn_context` 的 line index/model 元数据,不保存消息正文;在之后发生 model switch 时,恢复旧 Backup 仍能回到一致的原 provider/model 字节状态。 +- Restore 在首行 Provider 已恢复、预期 `turn_context` 又发生并发变化时返回 `ROLLOUT_CHANGED` 路径的失败,不再静默报告 completed。Apply 在 rollout mutation 后收到 Abort 也会完整回滚并保留原事务错误语义。 +- test-only fault gate 只进入 `electron-vite --mode test` 的 Utility chunk;正式 Core `.d.ts`/facade/Runtime host control 不公开或转发故障注入。production verifier 拒绝 test bridge、E2E gate、fault marker 与 crash channel;打包命令自身强制重新生成并验证 production bundle,不能把残留 test `out/` 打包。 +- crash matrix 覆盖 `before_backup`、config mutation、rollout mutation、SQLite commit/ack、transaction journal commit/ack 和 transaction commit 六个窗口;每个场景验证 generation 仅增加一次、journal durable state、pending write 阻断和目标 Hash。 + +## 本地验证 + +环境:Windows 11 x64,Node `v24.11.1`,npm `11.10.0`;输入 SHA `6820858`。所有 Electron E2E 使用 `CPS_DESKTOP_WINDOW_DISPLAY=hidden` 后台运行,不显示或占用主屏窗口。 + +| 命令 | 结果 | +| --- | --- | +| `npm run desktop:test` | Desktop security/IPC/profile/runtime/protocol contracts:35 passed,0 failed/skipped | +| `npm run desktop:test:e2e` | test-build 真实 UI/Core:15 passed,0 failed,1 skipped;三种 model intent、stale/unknown provider/tampered/replay、SQLite Busy、真实 rollout lock、两类 Cancel、六窗口 crash matrix 均通过;唯一 Skip 为损坏的本机 WSL | +| `npm run desktop:build` + `npm run desktop:verify-production-bundle` + `npm run desktop:test:e2e:production` | production 构建/边界通过,2/2 passed;真实 SQLite Status 与 Sync,无 test bridge/fault gate | +| `npm run desktop:pack:dir` + `npm run desktop:test:e2e:packaged` | 命令先覆盖 test residue、验证 production bundle,再生成 Windows x64 unpacked app;真实可执行文件 2/2 passed | +| `npm run workspaces:check` | 9 个 workspace 共 75 passed,0 failed/skipped;TypeScript/checkJs、依赖、导入与 root publish 边界通过 | +| `npm test` | 357 passed,0 failed/skipped | +| `npm run web:build` + `npm run web:test:e2e` | Web production build 成功,2034 modules;Chromium 2/2 passed,共享 UI 的 History lazy-load、opaque Apply 与全局状态未回归 | +| `npm run fixtures:cross-runtime` | Node↔.NET 双向 Backup/Restore 与 foreign pending:2/2 passed | +| `dotnet test CodexProviderSync.sln --configuration Release --no-restore` | Legacy .NET:399 passed,0 failed,1 skipped;Skip 同为不可运行的本机 WSL 实机场景 | +| `npm run package:smoke` + `npm run package:smoke:lifecycle` | Node 24 根 tarball 内容、安装生命周期、CLI/SQLite smoke 通过;Node 16.20.2 安装态由 required CI 继续验证 | +| `npm audit --omit=dev --audit-level=moderate` / `npm audit --audit-level=high` | 均为 0 vulnerabilities | +| `js-yaml` parse CI / builder YAML | `.github/workflows/ci.yml` 与 `apps/desktop/electron-builder.yml` 均解析成功 | +| `git diff --check` | 通过;仅 Git 的既有 CRLF 转换提示 | + +## 未闭合项与后续 TODO + +- `wsl.exe --list --quiet` 可见 Ubuntu,但启动返回 `Wsl/Service/CreateInstance/MountDisk/HCS/ERROR_FILE_NOT_FOUND`,其发行版 `ext4.vhdx` 缺失。根据 Fixture 合同,本机 C7 E2E 明确 Skip,不用伪造 UNC 路径代替真实 WSL;修复 WSL 后或远端 Windows runner 必须重跑并验证所有受保护 Hash 不变。 +- 本地未替代远端 Windows/macOS/Linux required CI;macOS/Linux 的 Electron Runtime、文件锁和 unpacked app 证据仍为 Pending。C5/C6 前置阶段也尚未因远端 CI/最终合入而 Completed,所以 C7 不标记 In Progress/Completed,也不宣称 Beta。 +- C8 才开放 Restore UI、Restore v2 独立 snapshot/journal/crash recovery、Watch、Diagnostics 目标文件、Update 和 Recovery action。C7 的 Restore 只作为受管 Backup 回环验证,不经 Renderer 暴露。 +- C9 才完成 NSIS/portable ZIP、DMG/ZIP、AppImage/deb、native SQLite fallback/ABI/asar、checksum/SBOM、包内容扫描与全平台安装/卸载 smoke;当前 `--dir` 不是发行产物。 +- 本 checkpoint 未创建 tag,未发布 npm/GitHub Release,未签名、公证或写更新通道;.NET 实现保持可构建且未删除。 diff --git a/docs/migration/evidence/C8_RESTORE_WATCH_DIAGNOSTICS_UPDATE_2026-08-27.md b/docs/migration/evidence/C8_RESTORE_WATCH_DIAGNOSTICS_UPDATE_2026-08-27.md new file mode 100644 index 0000000..b141a27 --- /dev/null +++ b/docs/migration/evidence/C8_RESTORE_WATCH_DIAGNOSTICS_UPDATE_2026-08-27.md @@ -0,0 +1,68 @@ +# C8 Restore / Watch / Diagnostics / Update 证据(2026-08-27) + +状态:候选实现的本地 Windows x64 门禁通过;C7 及其前置 C5/C6 的 required CI、真实 WSL UNC、远端 Windows/macOS/Linux C8 CI 与最终 PR 合入均未闭合。因此 C8/Phase 5 仍为 Pending,不构成发布、稳定版或 .NET 替代声明。输入 checkpoint 为 `1ec27a5`;C8 输出 commit SHA 在本 checkpoint 提交后及 C10 evidence bundle 中索引。 + +## 已实现边界 + +- DesktopCoreClient、Preload、Main IPC、Supervisor 与 Utility 只增加 `prepareRestore/applyRestore`、`pruneBackups`、`startWatch/stopWatch/getWatchStatus` 和固定 Diagnostics/Update bridge。Main 持有 Restore Plan、Watch ID、诊断目标 capability 与更新 controller;Renderer 只能提交受管 `backupId`、受限 Restore options、`keepCount`、有限 Watch 输入或无参数 Update 请求。 +- `RECOVERY_REQUIRED` 继续阻断 Sync、Switch 与 `startWatch`;Restore、Prune 是 recovery-safe 操作,`stopWatch/getWatchStatus` 保持可用。Restore Apply 完成后使 Status preflight 失效并强制重新读取。 +- Restore v2 在任何目标 mutation 前创建受管恢复前 snapshot 和独立 journal,覆盖 `prepared`、`applying`、`committing`、`committed-pending-ack`、`completed`、`rollback-pending`、`rolled-back`、`recovery-required`。`committed-pending-ack` 只能按完整目标 Hash 向前确认,不得补偿已提交目标。 +- snapshot manifest 与 durable `prepared` event 全量绑定 schema/protocol、operation、source backup、storage、required target kinds、resolver IDs、ordered targets 和 snapshot 稳定物理目录;即使 manifest hash 被同步重算,任一业务字段不一致也在 compensation/ack 前 fail closed。 +- journal 持久化 `codexHomePhysical`。pending、completed resolver 与当前已加锁 Home 必须匹配同一稳定物理 identity;junction/reparse 换接后不能用可变 lexical Home 的当前 realpath 隐藏历史 pending。 +- Watch 每次 apply 都重新 Prepare/Apply 并取得 Home→State DB 双锁;人工 Plan 优先,重复事件合并为一次 follow-up,首次遇到 `RECOVERY_REQUIRED/PENDING_TRANSACTION` 即停止自动写。弃置 Plan 与人工 intent 由不阻止进程退出的单一最早到期 timer 自治清理。 +- Diagnostics 的目标文件只由 Main 原生选择器产生,并转换为 5 分钟、单次消费 capability。ZIP 仅含固定、二次 schema 校验的脱敏条目,排除 `auth.json`、凭据、token、路径、rollout/SQLite、消息正文和 `encrypted_content`。 +- Update 仅由 Main 的受控 `electron-updater` controller 管理,禁止 `setFeedURL`,关闭自动下载与退出时安装。安装前 Supervisor 同步关闭 restart gate,计入并排空已经 admission 但尚未 dispatch 的写,拒绝后续写,再强制刷新全部 Profile、复核 active Watch/write/pending recovery;installer 异常或任一状态无法验证时不退出并重新开放 gate。 +- 当前 C8 只提供受控状态机和门禁。Desktop 版本为 `0.0.0`、非 packaged 或目标不受支持时 Update 为 disabled,不进行网络检查;真实版本注入、签名、更新 metadata、下载和跨版本 packaged smoke 属于 C9/C10。 + +## Restore v2 与跨运行时证据 + +- Node Restore 状态机覆盖 prepared/applying/committing/rollback-pending 真实进程终止、snapshot 失败、observer 异常、中途失败补偿、forward-only commit acknowledgement、unknown schema、truncated journal、SQLite online snapshot、State DB physical identity 复核和 reparse swap。 +- Node 与仍受支持的 .NET Core 对同一 v2 journal、source backup、snapshot manifest、terminal 与 resolver projection 采用相同 fail-closed 语义。跨运行时 harness 覆盖双向 Backup/Restore、legacy foreign pending、Restore v2 crash matrix、foreign pending、unknown schema、forward-only ack、manifest/prepared mismatch 和 persisted physical Home mismatch。 +- Windows 物理别名使用真实 junction、大小写变体和系统返回的 8.3 短路径:State DB identity 在 Node↔.NET 两个持锁方向都收敛到同一 resourceKey;Restore 同时覆盖长路径创建/别名恢复,以及 Node/.NET 各自以 8.3 或 junction source 创建 pending、另一运行时以物理长路径恢复。Prepare、Apply、journal、源读取与 inventory 全程绑定物理 source,旧式 backupId 不再作为安全判定键;无法 realpath 或 revision 漂移仍 fail closed,旧 journal bytes 保持不变,Prune 以物理目录保护非终态证据。 +- foreign pending、绑定不完整或未知 evidence 均在新 snapshot/journal/mutation 前阻断。completed resolver 不改写旧 raw journal;Prune 继续保护旧 journal、source backup 与 pre-restore snapshot。 +- 独立 Restore journal 的首个格式就是 v2;历史 protocol v1 `transaction-journal.jsonl` 是 Sync/Switch transaction journal,不被伪装为 standalone Restore v1。 + +## 本地验证 + +环境:Windows 11 Pro x64 `10.0.26200`,Node `v24.11.1`,npm `11.10.0`,.NET SDK `10.0.400`;输入 SHA `1ec27a5`。所有 Electron E2E 使用 `CPS_DESKTOP_WINDOW_DISPLAY=hidden` 后台运行,没有显示或占用主屏窗口。所有 Core/Restore/Watch/Diagnostics 用例只使用临时 Fixture,没有读取真实用户 Codex Home、认证数据或消息正文。 + +| 命令 | 结果 | +| --- | --- | +| `npm run desktop:test` | Desktop security/IPC/profile/runtime/diagnostics/updater contracts:56 passed,0 failed/skipped | +| `npm run desktop:test:e2e` | hidden test-build Electron E2E:15 passed,0 failed,1 skipped;Restore/Prune/Watch/Diagnostics/Update surface、Sync/Switch、Busy/Partial/Cancel 与六窗口 crash matrix 通过;唯一 Skip 为损坏的本机 WSL | +| `npm run desktop:build` + `npm run desktop:verify-production-bundle` + `npm run desktop:test:e2e:production` | production 构建/边界通过,hidden E2E 2/2 passed;真实 SQLite Status 与 Sync,无 test bridge/fault gate | +| `npm run desktop:pack:dir` + `npm run desktop:test:e2e:packaged` | 最新 Windows x64 unpacked app 重新构建;hidden 可执行文件 smoke 2/2 passed。该目录包不是 C9 发行产物 | +| `node --test test/restore-v2-state-machine.test.js` | 22/22 passed,含 manifest/prepared 与 persisted physical Home 回归 | +| `.NET RestoreJournalServiceTests + RestoreV2IntegrationTests` | 12/12 passed | +| `npm run fixtures:cross-runtime` | Node↔.NET 9/9 passed,含新增 manifest/prepared 和 persisted physical Home 双向拒绝 | +| `npm run workspaces:check` | 9 个 workspace 共 97 passed,0 failed/skipped;TypeScript/checkJs、依赖、导入与 root publish 边界通过 | +| `npm test` | 408 passed,0 failed/skipped | +| `npm run web:build` + `npm run web:test:e2e` | Web production build 成功,2034 modules;Chromium 2/2 passed,共享 UI 未回归 | +| `dotnet test CodexProviderSync.sln --configuration Release --no-restore` | Legacy .NET:411 passed,0 failed,1 skipped;Skip 同为不可运行的本机 WSL 实机场景 | +| `npm run package:smoke` + `npm run package:smoke:lifecycle` | Node 24 根 tarball 内容、安装生命周期、CLI/SQLite smoke 通过;Node 16.20.2 安装态由 required CI 继续验证 | +| `npm audit --omit=dev --audit-level=moderate` / `npm audit --audit-level=high` | 均为 0 vulnerabilities | +| `js-yaml` parse workflows / builder YAML | `ci.yml`、`publish.yml`、`publish-npm.yml` 与 `electron-builder.yml` 均解析成功 | +| `git diff --check` | 通过;仅 Git 的既有 CRLF 转换提示 | + +独立安全复核在新增三项补丁后重读 Node/.NET Restore binding 与 Electron restart admission gate,未发现新增或遗留的 P0/P1/P2。 + +## 2026-08-28 后续 source-head hardening(不回写旧 checkpoint 结论) + +以下变更发生在本文原始 `1ec27a5` 证据之后;它们必须由 PR #90 最新 source head 的成功 C10 artifact 重新绑定,不能把上表旧计数当作新 head 证据: + +- 未授权 Electron 候选由 Main 编译期 `releaseAuthorized=false` 固定为 `disabled/not-authorized`;定时检查、check/download/install 均不会创建 updater port 或触发网络。未来只有另行获授权的正式构建入口才可显式置 true。 +- 安装 restart gate 排空写请求后,Main 通过既有 Utility `getWatchStatus` 再次验证 Watch;自动停止造成的 stale ownership 会被清除,查询失败或仍 active 时继续 fail closed。外部 CLI/Web/Watch 尚未共享该 Main 内准入门,生产更新授权前仍需独立的跨运行时 maintenance lease 协议与竞争测试。 +- Core Watch 以物理 Codex Home 去重并处理并发 start,停止后释放 scope,stopped registry 有界;Diagnostics capability 为 5 分钟/最多 32 个、规范化目标独占、写入时按父目录 realpath 排除物理路径别名并发、可 revoke、单次并发消费。 +- Electron Restore 页面以不泄露路径的 `sqliteHomeConfigured` 选择可信目标;新增隐藏窗口真实 UI→Preload→Main→Utility→Core 的 database-only relocation 回归,断言 source config/rollout/source DB 不变、目标 DB 恢复,并拒绝无显式 SQLite Home 的 target。 +- `CPS_REQUIRE_REAL_WSL=1` 只把环境缺失从 Skip 提升为失败,并把 main/WAL/SHM/journal 的存在性和 Hash 全部纳入脚本复核;它不是实机通过证据。 + +本次聚焦验证为 Desktop contracts/unit `71/71`、隐藏 Electron relocation `1/1`、历史 tag-source backup Restore `1/1`。完整 root/workspace/Web/Electron/.NET/候选矩阵与远端 CI 在新 checkpoint 提交后重跑。 + +## 未闭合项与后续 TODO + +- `wsl.exe --list --quiet` 可见 Ubuntu,但启动返回 `Wsl/Service/CreateInstance/MountDisk/HCS/ERROR_FILE_NOT_FOUND`,发行版 `ext4.vhdx` 缺失。真实 WSL UNC 场景是明确原因的 expected skip,不是通过;修复后或远端 Windows runner 必须重跑全部受保护 Hash 不变验证。 +- 本地 Windows 不能替代远端 Windows/macOS/Linux required CI;macOS/Linux Runtime、锁语义和 unpacked app 证据仍为 Pending。C5/C6/C7 前置 checkpoint 也尚未因 required CI/最终合入而闭合,因此 C8/Phase 5 不标记 In Progress 或 Completed。 +- C9 仍负责 Windows x64 NSIS/portable ZIP、macOS x64/arm64 DMG/ZIP、Linux x64 AppImage/deb、Electron ABI native SQLite fallback、`asarUnpack`、SBOM/checksum、最终包扫描和各平台安装/解包 smoke。当前 `--dir` 不能替代这些证据。 +- 当前 Update controller 单测和门禁通过不等于更新通道已上线。真实 metadata、签名、公证、下载、重启升级和发布授权仍未发生。 +- Main restart gate 不覆盖同时启动的外部 CLI/Web/Watch 进程。正式启用生产更新前必须把 maintenance admission 变成所有 Node/.NET 写入口共同遵守的跨运行时协议;不得用 Main 私有锁或一次 Status 检查宣称该竞态已关闭。 +- 本 checkpoint 未创建 tag,未发布 npm/GitHub Release,未签名、公证或写更新通道;.NET 保持可构建且未删除,不能据跨运行时 9/9 宣称 Legacy 已可移除。 diff --git a/docs/migration/evidence/C9_PACKAGING_CI_RELEASE_ENGINEERING_2026-08-27.md b/docs/migration/evidence/C9_PACKAGING_CI_RELEASE_ENGINEERING_2026-08-27.md new file mode 100644 index 0000000..612d697 --- /dev/null +++ b/docs/migration/evidence/C9_PACKAGING_CI_RELEASE_ENGINEERING_2026-08-27.md @@ -0,0 +1,76 @@ +# C9 打包、CI 与发布工程证据(2026-08-27) + +状态:候选实现和本地 Windows x64 门禁通过;C8 及其前置 Phase 的 required CI、macOS x64/arm64、Linux x64、四目标 aggregate、真实签名/公证、更新 metadata、跨版本升级与最终 PR 合入均未闭合。因此 C9/Phase 6 仍为 Pending,不构成 Beta、Stable 或发布声明。 + +输入 checkpoint 为 `1673147f6d993d3a5923615d41dec2cf9f37c293`(C8);C9 release-engineering 实现 commit 为 `73256f3187dd337bb681a1cc9810edad8f6309bb`。本文档与执行索引形成后续 C9 evidence commit;C10 最终 bundle 必须同时索引二者。 + +## 实现边界 + +- `.github/workflows/ci.yml` 新增四个 host-native candidate target:Windows x64、macOS x64、macOS arm64、Linux x64。每个目标完成 build、stage audit、最终容器 smoke 后才上传;`electron-candidate-set` 下载四份候选,验证 version/commit/lockfile/tool/fuse/audit policy 一致,两个 job 都是唯一 `ci-gate` 的 strict dependency。 +- 候选版本从 CI run 注入 `1.0.0-alpha|beta|rc.`,buildId 绑定 commit 与 target;根包仍为 `0.5.0`,Desktop source manifest 仍为 `0.0.0`。远端 RC 门禁全部闭合前不把 source version 改为 `1.0.0`。 +- electron-builder 固定 Windows NSIS/ZIP、macOS DMG/ZIP、Linux AppImage/deb;所有候选构建使用 `--publish never`。旧 tag-push 发布入口已改为显式 `workflow_dispatch + release_tag`,且只允许 tag commit 位于 `main`;本 checkpoint 没有触发该入口。 +- Electron runtime 首选 `node:sqlite`,并打包 Desktop 专用 `better-sqlite3 13.0.3` fallback。ABI rebuild 后,ASAR 只引用当前 target 的一个 native binding,`app.asar.unpacked` 也只允许该文件;其它平台 prebuild、native source/build/deps 全部排除。 +- fallback 可执行证据使用独立 `electron-vite --mode test` 的编译期常量剔除 `node:sqlite` import,并验证 bundle 只保留 `better-sqlite3`;运行时环境变量不能切换生产 driver。production verifier 与最终 artifact audit 同时拒绝该 selector symbol,避免测试 gate 进入发布包。 +- production bundle 预检与最终 ASAR 审计共用 `artifact-audit-policy.v1.json` 的完整文本扩展集合和 forbidden-text 规则,覆盖 JS/CJS/MJS/HTML/CSS/JSON/SVG/manifest/文档配置,不再出现早期门只扫描少数扩展的差异。 +- production Fuse、ASAR entry/block integrity、Windows PE/macOS plist embedded ASAR binding、敏感路径/文件、高置信 credential marker、fixture/test/source map 与 native binding 都由数据化 policy 审计。最终 ZIP/Installer/DMG/AppImage/deb 必须逐个解包或安装后再次审计,不能用 builder unpacked 目录替代。 +- CycloneDX SBOM 从唯一 `package-lock.json` 投影 Desktop production closure;Playwright、builder、Vite、审计工具和 fixture 不进入 runtime closure。每个候选的 checksum 精确覆盖资产、audit、SBOM、staging、container report 与 release manifest,候选目录不得夹带未清单文件。 +- 根 Web production build 已关闭 source map;根 npm packlist 从 111 个条目降为 110 个条目,`.map` 为 0。安装态 tarball smoke 固化了这一拒绝规则。 + +## Windows x64 精确候选 + +本地候选版本为 `1.0.0-rc.0`,buildId 为 `1.0.0-rc.0-73256f3187dd-windows-x64`,manifest commit 精确绑定 `73256f3187dd337bb681a1cc9810edad8f6309bb`。窗口策略为 `CPS_DESKTOP_WINDOW_DISPLAY=hidden`;未占用主屏。 + +| 资产 | Size | SHA-256 | +| --- | ---: | --- | +| `CodexProviderSync-1.0.0-rc.0-windows-x64-portable.zip` | 157,668,528 bytes | `96c0ab0c49bce31999e1d45dad01821f4a1433d72350f1366f1464c3fddcd33d` | +| `CodexProviderSync-1.0.0-rc.0-windows-x64-setup.exe` | 123,164,018 bytes | `e5d7076a571ab2742119878ac6d0efb40baf4465c4d5bc057c51bad15ea7619a` | + +审计摘要: + +- ASAR SHA-256:`f60ed82f18f52d25bf4ac9071cc664509817486d70c70aa89d8dd737e3534f0f` +- ASAR header SHA-256:`93a5304bad0ac35bb3638e0549c23de5bd277407d4c58f425dc4dd42c31d0d79` +- ASAR entries:4,538;带 entry/block integrity 的文件:4,177;Windows embedded binding:`verified` +- native binding SHA-256:`e21e5efd71fba66578e95b62554d9028064a80dafd7221bf8a8ef155de8d240a` +- container report SHA-256:`b8470d1ffbb6bf1f2b7fe1ea02f5f7749723acfaea5318b6a95194602e1a166d` +- lockfile SHA-256:`59a6bd220bce2ce5ba0ddd909c6ecbfe70f76aaa0f6376f93b55068551884e57` +- `SHA256SUMS.txt` 的 7 个条目已从磁盘逐项重算通过。 + +ZIP 与 NSIS 都完成:最终容器内容复审、候选 ASAR loader + unpacked binding 的真实 `better-sqlite3` 内存库 probe、production bridge 排除、synthetic SQLite Status、真实 Sync→Restore byte/hash 回环和正常退出。NSIS 另完成静默安装、uninstaller 存在性与卸载目录清理。 + +## 本地门禁 + +| 门禁 | 结果 | +| --- | --- | +| `npm test` | 414 passed,0 failed,0 skipped | +| `npm run workspaces:check` | workspace build/import/package boundary 通过;102 tests passed | +| `npm run desktop:test:e2e`(hidden) | 15 passed,1 skipped;Skip 仅为本机不可用的真实 WSL UNC fixture | +| `npm run web:test:e2e` | 2 passed | +| Windows ZIP/NSIS `desktop:smoke:candidate:artifacts`(hidden) | 两个最终容器各 2/2 production tests passed;container gate passed | +| `npm run package:smoke` | 根 tarball content/help/status/Web shell 通过;source map 为 0 | +| `npm run package:smoke:lifecycle` | lifecycle install + SQLite smoke 通过 | +| `npm audit --omit=dev --audit-level=moderate` | 0 vulnerabilities | +| `npm audit --audit-level=high` | 0 vulnerabilities | +| C9 独立最终代码复核 | 0 P0 / 0 P1 / 0 P2 | + +本机工具:Node `24.11.1`、npm `11.10.0`、PowerShell `7.6.4`、Git `2.52.0.windows.1`、.NET SDK `10.0.400`、Windows build `26200`。本机不是 Node 16.20.2 环境;Node 16 根 tarball 兼容性继续由 required CI 与 C4 已有安装态合同证明,不能把本地 Node 24 smoke 记作 Node 16 实机结果。 + +## 2026-08-28 后续 source-head 发布门 hardening + +这些变更不改变上面的历史资产 Hash,也不能沿用该 checkpoint 的计数;PR #90 后续 source head 必须重新生成候选和 C10 artifact: + +- 根 tarball 安装态 smoke 从 help/status 扩展为真实 npm bin `sync --json → managed backup → synthetic drift → restore --json`,断言 config/rollout 字节、SQLite Provider 与 pending recovery 全部恢复;Windows/Ubuntu Node 16.20.2 required matrix 继续执行同一脚本。 +- Windows cross-runtime job 以 `fetch-depth:0` 获取冻结 tag,校验 `v0.2.9@1a2b290...` 与 `v0.4.1@75f45756...` 后构建历史 .NET Core,真实产生 synthetic metadata v1/v2 backup,再由当前 Node Restore;CI 只上传不含真实数据的 commit/hash evidence。该等级是 repository-tag-source,不是 hosted formal Release binary。 +- 后续 source head 新增独立的 checksum-bound hosted Release fixture:固定 `v0.4.1` Release/tag/commit、Automation ZIP asset ID/size/SHA-256、发布页 checksum 资产及 archive entry Hash,校验通过后才在严格环境白名单中执行正式托管的旧 Automation Plan/Apply,并由当前 Node Restore synthetic backup。fork PR 不执行 hosted binary;同仓库 PR artifact 仍是审查预览,受保护 `main` 必须重新运行。它只上传同一 CI run/tested commit 绑定的脱敏 hash evidence;是否通过只以对应 source head 最新成功的 C10 artifact 为准,不能回填到上方历史 checkpoint。 +- Web 与 npm 手工发布工作流在任何发布动作前都安装 Chromium 并运行 production Web E2E;普通 Web/Desktop Playwright 配置启用 `forbidOnly`。 +- 候选构建显式注入 `CPS_DESKTOP_RELEASE_AUTHORIZED=false`,所以 unsigned/not-authorized 候选不会建立真实 update port。仓库当前没有把该值置 true 的正式发布路径;这是未获发布授权时的预期 fail-closed 状态。 + +聚焦验证已通过安装态 tarball lifecycle、历史 tag-source Restore 和 Desktop 71/71;完整四目标 candidate set、全量门禁与远端 artifact 仍以新 checkpoint 的 CI 为准。 + +## 未闭合项与停止边界 + +- 本地 Windows 不能替代 macOS x64、macOS arm64、Linux x64 的 native build、DMG/ZIP/AppImage/deb 解包/安装、embedded integrity、native SQLite 与 graceful-exit 证据;四目标 aggregate 也尚未产生。required CI 未全绿前 C9 保持 Pending。 +- 本机 Ubuntu 注册项缺少 `ext4.vhdx`,真实 WSL UNC 测试按合同 Skip。C10 必须保留该限制,不得把 synthetic path 测试冒充 WSL 实机。 +- 候选明确是 unsigned、not notarized、release not authorized。没有 tag、npm publish、GitHub Release、签名、公证或更新通道写入;真实 update metadata/download/restart upgrade 仍阻断 Stable。 +- tag-source historical fixture 仍只证明冻结源码;后续 source head 已具备执行固定 v0.4.1 hosted Automation Release binary 的 synthetic backup→当前 Node Restore fixture,但在其 source-head CI/C10 artifact 成功前不得声称闭合。即使该 artifact 成功,它也只闭合“历史正式托管 backup 格式兼容”,不闭合真实 Beta、Windows/macOS 签名、公证、真实用户数据或生产跨版本升级。 +- 当前 builder 使用 Electron 默认应用图标;仓库尚无经确认的跨平台 product icon 资产。它不改变本 checkpoint 的数据安全结论,但在公开 Stable 前应由产品资产验收决定是否补齐。 +- macOS/Linux 任一容器审计、native probe、Status/Sync→Restore、正常退出或 aggregate 失败时,必须停在 `73256f3` 的 Windows-only evidence,不得降级门禁、跳过 job 或写入 `1.0.0` source version。 diff --git a/package-lock.json b/package-lock.json index bbdc827..e5858a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "@dailin521/codex-provider-sync", - "version": "0.5.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dailin521/codex-provider-sync", - "version": "0.5.0", + "version": "1.0.0", "license": "MIT", - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, + "workspaces": [ + "apps/*", + "packages/*" + ], "bin": { "codex-provider": "src/cli.js" }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.4", - "vite": "^4.5.14" + "@types/node": "24.13.3", + "ajv": "8.20.0" }, "engines": { "node": ">=16.20.2" @@ -26,346 +26,354 @@ "better-sqlite3": "8.7.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, + "apps/cli": { + "name": "@codex-provider-sync/cli", + "version": "0.0.0", "engines": { - "node": ">=6.9.0" + "node": ">=16.20.2" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", + "apps/desktop": { + "name": "@codex-provider-sync/desktop", + "version": "1.0.0", + "dependencies": { + "@codex-provider-sync/app-ui": "0.0.0", + "@codex-provider-sync/contracts": "0.0.0", + "@codex-provider-sync/core": "0.0.0", + "@codex-provider-sync/core-client": "0.0.0", + "@codex-provider-sync/design-system": "0.0.0", + "better-sqlite3": "13.0.3", + "electron-updater": "6.8.9", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@codex-provider-sync/test-fixtures": "0.0.0", + "@electron/asar": "4.3.0", + "@electron/fuses": "2.1.3", + "@playwright/test": "1.62.1", + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "5.2.0", + "electron": "44.0.0", + "electron-builder": "26.15.7", + "electron-vite": "5.0.0", + "plist": "5.0.0", + "resedit": "3.1.0", + "tailwindcss": "4.3.3", + "vite": "7.3.6" + }, "engines": { - "node": ">=6.9.0" + "node": ">=24" } }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "apps/desktop/node_modules/@electron/asar": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.0.tgz", + "integrity": "sha512-k/FFC/NQoTykGBi/Ga4L4KorsAKDdkK0LfNVG90eUG6vPVpJaL3iR9V1ZLbzBAF3QpojK5DcaGOdQOheKZv4JQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "glob": "^13.0.2", + "minimatch": "^10.0.1" }, - "engines": { - "node": ">=6.9.0" + "bin": { + "asar": "bin/asar.mjs" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "apps/desktop/node_modules/@electron/fuses": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-2.1.3.tgz", + "integrity": "sha512-LoKJUXNiJ4JM8IIrUltSHI+8pkogaGj5wmJx81jE/Wk3g2w1/kfMbTEKNoY5kitGE8hiC12h32R/1SlywFtxXg==", "dev": true, - "license": "ISC", + "license": "MIT", "bin": { - "semver": "bin/semver.js" + "electron-fuses": "dist/bin.js" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "apps/desktop/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "apps/desktop/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "apps/desktop/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "apps/desktop/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "apps/desktop/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "apps/desktop/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=18" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "apps/desktop/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "apps/desktop/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "apps/desktop/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "apps/desktop/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "apps/desktop/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "apps/desktop/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "apps/desktop/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "apps/desktop/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "apps/desktop/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ - "arm" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "apps/desktop/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ - "arm64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "apps/desktop/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -373,16 +381,16 @@ "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "apps/desktop/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -390,16 +398,16 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "apps/desktop/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -407,16 +415,16 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "apps/desktop/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -424,16 +432,16 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "apps/desktop/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -441,803 +449,7824 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "apps/desktop/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "apps/desktop/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "apps/desktop/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "apps/desktop/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ - "loong64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "apps/desktop/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ - "mips64el" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], + "apps/desktop/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } + "license": "MIT" }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], + "apps/desktop/node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], + "apps/desktop/node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=12" + "node": ">=14.6" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, + "apps/desktop/node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "node-addon-api": "^8.0.0" + }, "engines": { - "node": ">=12" + "node": ">=22" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], + "apps/desktop/node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], + "apps/desktop/node_modules/electron-vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=12" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], + "apps/desktop/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, "engines": { - "node": ">=12" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], + "apps/desktop/node_modules/pe-library": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-2.0.1.tgz", + "integrity": "sha512-/qjYFqNSlq59B5DI36am++5/3gMgh02QnzpYigrwrW6s+QpU0mHf09/iA4wjTu21UUxodyV7ZCetV5MiDhaN/A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=12" + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], + "apps/desktop/node_modules/plist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-5.0.0.tgz", + "integrity": "sha512-20N+g1DvMm/DFRbsvER7tT4wDryq0WunK7VMkDaiJcKNapAnUMkTsAnacFYf8n420F4Hf6/hefgmJRkMb1M0fg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "xmlbuilder": "^15.1.1" + }, "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], + "apps/desktop/node_modules/resedit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-3.1.0.tgz", + "integrity": "sha512-i6CN8E6FzTpzsGq/oUnm2GTWhddpZEbnAE+zeNwqTppucTK7xd178q1xWEMrFb+nLnCh+geDqZ4QwhFN8VEgVQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "pe-library": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "apps/desktop/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "apps/web": { + "name": "@codex-provider-sync/web", + "version": "0.0.0", + "dependencies": { + "@codex-provider-sync/app-ui": "0.0.0", + "@codex-provider-sync/core-client": "0.0.0", + "@codex-provider-sync/design-system": "0.0.0" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "@vitejs/plugin-react": "6.1.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "tailwindcss": "4.3.3", + "vite": "8.2.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.0.0" + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.0", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=6.9.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "@babel/core": "^7.0.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/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.12", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", - "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, + "license": "MIT", "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" } }, - "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, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@codex-provider-sync/app-ui": { + "resolved": "packages/app-ui", + "link": true + }, + "node_modules/@codex-provider-sync/cli": { + "resolved": "apps/cli", + "link": true + }, + "node_modules/@codex-provider-sync/contracts": { + "resolved": "packages/contracts", + "link": true + }, + "node_modules/@codex-provider-sync/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@codex-provider-sync/core-client": { + "resolved": "packages/core-client", + "link": true + }, + "node_modules/@codex-provider-sync/design-system": { + "resolved": "packages/design-system", + "link": true + }, + "node_modules/@codex-provider-sync/desktop": { + "resolved": "apps/desktop", + "link": true + }, + "node_modules/@codex-provider-sync/test-fixtures": { + "resolved": "packages/test-fixtures", + "link": true + }, + "node_modules/@codex-provider-sync/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.34.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.34.0.tgz", + "integrity": "sha512-4Oy5Q6/Ftna9sXyrkdnKypfvm9uWRpxUPvlw4oA192QNMN39aq8k4l36TUUUU/ONw7ivGVi402Ud+UBPVDYh6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.1.tgz", + "integrity": "sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0 || ^18.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=6.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.102.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.3.tgz", + "integrity": "sha512-5O2VEceonqC4uaTLUGglb0hgPouWCJ4K1ykVWyeV8aThhdNCzwpwu01bYoaRNJ9mgFUXS7Kf9utJ46ysT8m+bw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.3.tgz", + "integrity": "sha512-nHazxUEUQSGJOswGgSL2DI77f2K75WRCowDgaiyEi0ACocZTFKewTv+A/rJfq6QkuE35rVPff1QUT3ClbaePGQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.7", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.7.tgz", + "integrity": "sha512-C7APoYISPExUmrEntNhDpz9Tccb4uWuEDfLaC0WPPc7/pwzz0WZGznCz/ycPfkkzw6tKOalceD8g6TgHmVz1QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.7", + "electron-builder-squirrel-windows": "26.15.7" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "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/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "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==", + "devOptional": 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/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.7", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.7.tgz", + "integrity": "sha512-rfo1YyAWO0L3cZLKCqKQiLYbW6ZXebRUfK0kWp4oXxO7dDFLrf7alRkWImNuXvZVQhs6Idzy++cwOk8I+xPDhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.7", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.0.0.tgz", + "integrity": "sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.7", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.7.tgz", + "integrity": "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.7", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.7", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.7", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.7.tgz", + "integrity": "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.7", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "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==", + "devOptional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "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/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/i18next": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz", + "integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "devOptional": 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/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz", + "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "devOptional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "devOptional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "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, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" } }, - "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "minimist": "^1.2.6" }, "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "mkdirp": "bin/cmd.js" } }, - "node_modules/browserslist/node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", - "dev": true, - "license": "MIT" + "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/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" - }, - "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/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "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==", + "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": { - "mimic-response": "^3.1.0" + "semver": "^7.3.5" }, "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, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", "engines": { - "node": ">=8" + "node": "^18 || ^20 || >= 21" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.400", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", - "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==", + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", "dev": true, - "license": "ISC" - }, - "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, + "license": "MIT", "dependencies": { - "once": "^1.4.0" + "semver": "^7.3.5" } }, - "node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, "bin": { - "esbuild": "bin/esbuild" + "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "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, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=6" + "node": ">=20" } }, - "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/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=18.17" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, "engines": { - "node": ">=6.9.0" + "node": "^20.17.0 || >=22.9.0" } }, - "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/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, "license": "MIT" }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, "bin": { - "jsesc": "bin/jsesc" + "nopt": "bin/nopt.js" }, "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "engines": { + "node": ">=10" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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==", + "devOptional": true, "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" + "wrappy": "1" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/mimic-response": { + "node_modules/p-limit": { "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, + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, "engines": { "node": ">=10" }, @@ -1245,85 +8274,205 @@ "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, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "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/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" } }, - "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, - "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, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "semver": "^7.3.5" + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" }, "engines": { - "node": ">=10" + "node": ">=16.0.0" } }, - "node_modules/once": { + "node_modules/pkijs/node_modules/@noble/hashes": { "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, + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "wrappy": "1" + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, - "license": "ISC" + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -1341,7 +8490,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1349,6 +8498,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1356,36 +8535,160 @@ "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" + "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/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" }, "engines": { "node": ">=10" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "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, + "devOptional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -1402,40 +8705,168 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-hook-form": { + "version": "7.86.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz", + "integrity": "sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-i18next": { + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz", + "integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "react": "^18.3.1" + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -1450,20 +8881,199 @@ "node": ">= 6" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, "node_modules/rollup": { - "version": "3.30.0", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.30.0.tgz", - "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", "dev": true, "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=14.18.0", + "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", "fsevents": "~2.3.2" } }, @@ -1487,20 +9097,49 @@ ], "optional": true }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", "dependencies": { - "loose-envify": "^1.1.0" + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "optional": true, + "devOptional": true, "bin": { "semver": "bin/semver.js" }, @@ -1508,6 +9147,68 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -1553,9 +9254,32 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", @@ -1563,6 +9287,49 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1572,6 +9339,47 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1581,6 +9389,87 @@ "node": ">=0.10.0" } }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1593,38 +9482,340 @@ "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, + "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/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", "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" + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" } }, - "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, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", "dependencies": { - "safe-buffer": "^5.0.1" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": "*" + "node": ">=14.14" } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { @@ -1652,57 +9843,132 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "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 + "devOptional": true }, "node_modules/vite": { - "version": "4.5.14", - "resolved": "https://registry.npmmirror.com/vite/-/vite-4.5.14.tgz", - "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { + "@vitejs/devtools": { "optional": true }, - "lightningcss": { + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -1711,21 +9977,400 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "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 + "devOptional": true + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/app-ui": { + "name": "@codex-provider-sync/app-ui", + "version": "0.0.0", + "dependencies": { + "@codex-provider-sync/contracts": "0.0.0", + "@codex-provider-sync/core-client": "0.0.0", + "@codex-provider-sync/design-system": "0.0.0", + "@hookform/resolvers": "5.9.1", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-toast": "1.2.23", + "@tanstack/react-query": "5.102.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "i18next": "26.4.0", + "lucide-react": "1.34.0", + "react-hook-form": "7.86.0", + "react-i18next": "17.0.12", + "tailwind-merge": "3.6.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.6", + "jsdom": "29.1.1", + "vitest": "4.1.11" + }, + "engines": { + "node": ">=24" + }, + "peerDependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + } + }, + "packages/contracts": { + "name": "@codex-provider-sync/contracts", + "version": "0.0.0", + "devDependencies": { + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "packages/core": { + "name": "@codex-provider-sync/core", + "version": "0.0.0", + "dependencies": { + "@codex-provider-sync/contracts": "0.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "packages/core-client": { + "name": "@codex-provider-sync/core-client", + "version": "0.0.0", + "dependencies": { + "@codex-provider-sync/contracts": "0.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "packages/design-system": { + "name": "@codex-provider-sync/design-system", + "version": "0.0.0", + "engines": { + "node": ">=24" + } + }, + "packages/test-fixtures": { + "name": "@codex-provider-sync/test-fixtures", + "version": "0.0.0", + "engines": { + "node": ">=24" + } } } } diff --git a/package.json b/package.json index 476adfd..0449d86 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,12 @@ { "name": "@dailin521/codex-provider-sync", - "version": "0.5.0", + "version": "1.0.0", "description": "Synchronize Codex session provider metadata across rollout files and SQLite state.", "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], "files": [ "README.md", "CHANGELOG.md", @@ -11,6 +15,8 @@ "AGENTS.md", "docs", "images/README", + "packages/contracts/dist", + "packages/core/src", "src", "web/dist" ], @@ -18,9 +24,33 @@ "codex-provider": "src/cli.js" }, "scripts": { - "test": "node --test", - "web:build": "vite build --config web/vite.config.js", + "test": "node scripts/run-root-tests.js", + "web:build": "npm run workspaces:build && npm run build --workspace @codex-provider-sync/web", + "web:test:e2e": "npm run test:e2e --workspace @codex-provider-sync/web", + "desktop:build": "npm run workspaces:build && npm run build:electron --workspace @codex-provider-sync/desktop", + "desktop:build:test": "npm run workspaces:build && npm run build:electron:test --workspace @codex-provider-sync/desktop", + "desktop:test": "npm run workspaces:build && npm run test --workspace @codex-provider-sync/desktop", + "desktop:test:e2e": "npm run desktop:build:test && npm run test:e2e --workspace @codex-provider-sync/desktop", + "desktop:test:e2e:production": "npm run test:e2e:production --workspace @codex-provider-sync/desktop", + "desktop:verify-production-bundle": "npm run verify:production-bundle --workspace @codex-provider-sync/desktop", + "desktop:pack:dir": "npm run pack:dir --workspace @codex-provider-sync/desktop", + "desktop:pack:candidate": "npm run pack:candidate --workspace @codex-provider-sync/desktop", + "desktop:stage:candidate": "npm run stage:candidate --workspace @codex-provider-sync/desktop", + "desktop:smoke:candidate:artifacts": "npm run smoke:candidate:artifacts --workspace @codex-provider-sync/desktop", + "desktop:verify:candidate:set": "npm run verify:candidate:set --workspace @codex-provider-sync/desktop", + "desktop:test:e2e:packaged": "npm run test:e2e:packaged --workspace @codex-provider-sync/desktop", + "fixtures:cross-runtime": "node --test test-support/cross-runtime-fixtures.mjs", + "fixtures:historical-tags": "node --test test-support/historical-tag-backup-fixtures.mjs", + "fixtures:historical-formal-release": "node --test test-support/formal-release-backup-fixtures.mjs", "web:start": "node src/cli.js web", + "workspaces:build": "tsc -b tsconfig.workspaces.json && tsc -p packages/core/tsconfig.json", + "workspaces:test": "npm run test --workspaces --if-present", + "workspaces:check": "npm run workspaces:build && npm run workspaces:test && node scripts/verify-workspace-boundaries.js", + "package:smoke": "node scripts/smoke-root-tarball.js", + "package:smoke:lifecycle": "node scripts/smoke-root-tarball.js --install-lifecycle", + "package:verify-root-tree": "node scripts/verify-root-production-tree.js", + "evidence:c10": "node scripts/write-c10-evidence-bundle.mjs", + "runtime:verify-node16": "node scripts/verify-node16-runtime.js", "publish:npm": "node scripts/publish-npm.js" }, "engines": { @@ -48,13 +78,9 @@ "optionalDependencies": { "better-sqlite3": "8.7.0" }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.4", - "vite": "^4.5.14" + "@types/node": "24.13.3", + "ajv": "8.20.0" }, "overrides": { "node-releases": "2.0.18" diff --git a/packages/app-ui/checks/surface.contract.mjs b/packages/app-ui/checks/surface.contract.mjs new file mode 100644 index 0000000..97cd275 --- /dev/null +++ b/packages/app-ui/checks/surface.contract.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import test from "node:test"; + +import { + APP_ROUTES, + APP_UI_MIGRATION_STATE, + DESKTOP_C8_APP_UI_CAPABILITIES, + FULL_APP_UI_CAPABILITIES, + READ_ONLY_APP_UI_CAPABILITIES, + SYNC_SWITCH_APP_UI_CAPABILITIES, + profileSchema, + resourcesHaveMatchingKeys, + restoreSchema, + syncSchema, + switchSchema +} from "../dist/index.js"; + +test("app-ui owns the complete target navigation vocabulary", () => { + assert.deepEqual(APP_ROUTES, [ + "overview", + "sync", + "switch-provider", + "backups-restore", + "history", + "profiles", + "diagnostics", + "settings" + ]); + assert.equal(APP_UI_MIGRATION_STATE, "shared-ui-c5"); +}); + +test("shared UI exposes explicit read-only, C7, and C8 capability profiles", async () => { + assert.deepEqual(READ_ONLY_APP_UI_CAPABILITIES, { + sync: false, + switchProvider: false, + restore: false, + pruneBackups: false, + watch: false, + manageProfiles: false, + revealProfilePaths: false, + forgetBrowser: false, + exportDiagnostics: false, + viewUpdateStatus: false + }); + assert.equal(Object.values(FULL_APP_UI_CAPABILITIES).every(Boolean), true); + assert.equal(Object.isFrozen(READ_ONLY_APP_UI_CAPABILITIES), true); + assert.deepEqual(SYNC_SWITCH_APP_UI_CAPABILITIES, { + sync: true, + switchProvider: true, + restore: false, + pruneBackups: false, + watch: false, + manageProfiles: false, + revealProfilePaths: false, + forgetBrowser: false, + exportDiagnostics: false, + viewUpdateStatus: false + }); + assert.equal(Object.isFrozen(SYNC_SWITCH_APP_UI_CAPABILITIES), true); + assert.deepEqual(DESKTOP_C8_APP_UI_CAPABILITIES, { + sync: true, + switchProvider: true, + restore: true, + pruneBackups: true, + watch: true, + manageProfiles: false, + revealProfilePaths: false, + forgetBrowser: false, + exportDiagnostics: true, + viewUpdateStatus: true + }); + assert.equal(Object.isFrozen(DESKTOP_C8_APP_UI_CAPABILITIES), true); + const appContentSource = await fs.readFile(new URL("../src/app/AppContent.tsx", import.meta.url), "utf8"); + const appSource = await fs.readFile(new URL("../src/App.tsx", import.meta.url), "utf8"); + const settingsSource = await fs.readFile(new URL("../src/features/settings/SettingsPage.tsx", import.meta.url), "utf8"); + const typesSource = await fs.readFile(new URL("../src/types.ts", import.meta.url), "utf8"); + assert.match(appContentSource, /route === "sync" && capabilities\.sync/); + assert.match(settingsSource, /enabled: capabilities\.watch/); + assert.match(settingsSource, /recoveryBlocked \|\| writeBlocked/); + assert.match(appContentSource, /applySubmissionPending\.current/); + assert.match(appContentSource, /canRestore=\{capabilities\.restore\}/); + assert.match(appContentSource, /canManage=\{capabilities\.manageProfiles\}/); + assert.match(appContentSource, /capabilities\.exportDiagnostics/); + assert.match(settingsSource, /capabilities\.viewUpdateStatus/); + assert.match(settingsSource, /host\.checkForUpdates/); + assert.match(settingsSource, /host\.downloadUpdate/); + assert.match(settingsSource, /host\.installUpdate/); + assert.match(appContentSource, /recoveryWriteDisabled/); + assert.match(typesSource, /AppUiSurface = "desktop" \| "web"/); + assert.match(appContentSource, /brand\.\$\{props\.surface\}/); + assert.match(settingsSource, /settings\.subtitle\.\$\{props\.surface\}/); + assert.doesNotMatch(appContentSource, /refetchInterval/); + assert.doesNotMatch(settingsSource, /refetchInterval/); + assert.match(appSource, /staleTime:\s*Infinity/); + assert.match(appSource, /retry:\s*false/); + assert.match(appSource, /refetchOnReconnect:\s*false/); +}); + +test("shared UI translations and write forms keep one strict schema", () => { + assert.equal(resourcesHaveMatchingKeys(), true); + assert.equal(switchSchema.safeParse({ provider: "relay", modelMode: "explicit", model: "gpt", keepCount: 5 }).success, true); + assert.equal(syncSchema.safeParse({ keepCount: 0 }).success, false); + assert.equal(switchSchema.safeParse({ provider: "relay", modelMode: "provider-default", keepCount: 0 }).success, false); + assert.equal(switchSchema.safeParse({ provider: "relay", modelMode: "explicit", model: "", keepCount: 5 }).success, false); + assert.equal(restoreSchema.safeParse({ backupId: "managed", restoreConfig: false, restoreDatabase: false, restoreSessions: false, allowSqliteHomeRelocation: false }).success, false); + assert.equal(profileSchema.safeParse({ profileId: "safe", name: "Safe", codexHome: "../relative", sqliteHome: "" }).success, false); +}); + +test("shared UI has no transport, Node, Electron or persistent history access", async () => { + const sourceRoot = new URL("../src/", import.meta.url); + const readTree = async (directory) => { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const chunks = []; + for (const entry of entries) { + const target = new URL(entry.name + (entry.isDirectory() ? "/" : ""), directory); + if (entry.isDirectory()) chunks.push(...await readTree(target)); + else if (/\.tsx?$/.test(entry.name)) chunks.push(await fs.readFile(target, "utf8")); + } + return chunks; + }; + const source = (await readTree(sourceRoot)).join("\n"); + const appSource = await fs.readFile(new URL("../src/app/AppContent.tsx", import.meta.url), "utf8"); + const historySource = await fs.readFile(new URL("../src/features/history/HistoryPage.tsx", import.meta.url), "utf8"); + assert.doesNotMatch(source, /\bfetch\s*\(/); + assert.doesNotMatch(source, /localStorage|sessionStorage|from\s+["'](?:node:|electron)/); + assert.doesNotMatch(source, /\/api\//); + assert.match(historySource, /core\.getHistorySession/); + assert.match(historySource, /messageLimit:\s*200/); + assert.doesNotMatch(historySource, /queryKey:\s*\["history-detail"/); + assert.doesNotMatch(historySource, /refetchInterval/); + assert.match(historySource, /staleTime:\s*Infinity/); + assert.match(historySource, /refetchOnWindowFocus:\s*false/); + assert.match(historySource, /refetchOnReconnect:\s*false/); + assert.match(appSource, /schemaVersion:\s*1 as const, planId: summary\.planId/); + assert.match(appSource, /onOperationStarted/); + assert.match(appSource, /onProgress/); + assert.match(appSource, /applyController\.current\?\.abort\(\)/); +}); diff --git a/packages/app-ui/package.json b/packages/app-ui/package.json new file mode 100644 index 0000000..73bb4f8 --- /dev/null +++ b/packages/app-ui/package.json @@ -0,0 +1,50 @@ +{ + "name": "@codex-provider-sync/app-ui", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "test": "node --test checks/surface.contract.mjs && vitest run" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@codex-provider-sync/contracts": "0.0.0", + "@codex-provider-sync/core-client": "0.0.0", + "@codex-provider-sync/design-system": "0.0.0", + "@hookform/resolvers": "5.9.1", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-toast": "1.2.23", + "@tanstack/react-query": "5.102.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "i18next": "26.4.0", + "lucide-react": "1.34.0", + "react-hook-form": "7.86.0", + "react-i18next": "17.0.12", + "tailwind-merge": "3.6.0", + "zod": "4.4.3" + }, + "peerDependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.6", + "jsdom": "29.1.1", + "vitest": "4.1.11" + } +} diff --git a/packages/app-ui/src/App.tsx b/packages/app-ui/src/App.tsx new file mode 100644 index 0000000..859936a --- /dev/null +++ b/packages/app-ui/src/App.tsx @@ -0,0 +1,62 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { I18nextProvider } from "react-i18next"; + +import { AppContent } from "./app/AppContent.js"; +import { AppErrorBoundary } from "./app/AppErrorBoundary.js"; +import { createAppI18n } from "./i18n.js"; +import { APP_ROUTES } from "./routes.js"; +import type { AppUiProps } from "./types.js"; +import { ToastProvider } from "./ui.js"; + +export function AppUi(props: AppUiProps) { + const requestedLocale = props.preferences.getLocale() ?? props.initialLocale; + const requestedTheme = props.preferences.getTheme() ?? props.initialTheme; + const [i18n, setI18n] = useState> | null>(null); + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false + }, + mutations: { retry: false } + } + })); + + useEffect(() => { + let active = true; + void createAppI18n(requestedLocale).then((instance) => { + if (active) setI18n(instance); + }); + return () => { + active = false; + queryClient.clear(); + }; + }, [props.initialTheme, props.preferences, queryClient, requestedLocale]); + + useLayoutEffect(() => { + document.documentElement.dataset.theme = requestedTheme; + }, [requestedTheme]); + + if (!i18n) { + return ( +
+ {requestedLocale === "zh-CN" ? "正在加载…" : "Loading…"} +
+ ); + } + + return ( + + i18n.language}> + + + + + + ); +} + +export { APP_ROUTES }; diff --git a/packages/app-ui/src/app/AppContent.tsx b/packages/app-ui/src/app/AppContent.tsx new file mode 100644 index 0000000..ea03129 --- /dev/null +++ b/packages/app-ui/src/app/AppContent.tsx @@ -0,0 +1,351 @@ +import type { OperationResult, PlanSummary, ProgressEvent, SwitchModelMode } from "@codex-provider-sync/contracts"; +import { CoreClientError } from "@codex-provider-sync/core-client"; +import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Activity, ArchiveRestore, Database, FileClock, FolderCog, Gauge, History, RotateCcw, Settings, ShieldAlert, Workflow } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { BackupsRestorePage } from "../features/backups-restore/BackupsRestorePage.js"; +import { DiagnosticsPage } from "../features/diagnostics/DiagnosticsPage.js"; +import { HistoryPage } from "../features/history/HistoryPage.js"; +import { OperationResultDialog, operationResultPresentation } from "../features/operations/OperationResultDialog.js"; +import { PlanReview } from "../features/operations/PlanReview.js"; +import { OverviewPage } from "../features/overview/OverviewPage.js"; +import { ProfilesPage } from "../features/profiles/ProfilesPage.js"; +import { SettingsPage } from "../features/settings/SettingsPage.js"; +import { SwitchPage } from "../features/switch-provider/SwitchPage.js"; +import { SyncPage } from "../features/sync/SyncPage.js"; +import { type AppRoute } from "../routes.js"; +import { profileSelector, safeErrorText } from "../shared/presentation.js"; +import { FULL_APP_UI_CAPABILITIES, type AppUiCapabilities, type AppUiProps } from "../types.js"; +import { Badge, Card, cn, useToast } from "../ui.js"; + +const navigation = [ + ["overview", "nav.overview", Gauge], + ["sync", "nav.sync", Workflow], + ["switch-provider", "nav.switchProvider", RotateCcw], + ["backups-restore", "nav.backupsRestore", ArchiveRestore], + ["history", "nav.history", History], + ["profiles", "nav.profiles", FolderCog], + ["diagnostics", "nav.diagnostics", Activity], + ["settings", "nav.settings", Settings] +] as const; + +function resolveCapabilities(value: AppUiProps["capabilities"]): AppUiCapabilities { + return { ...FULL_APP_UI_CAPABILITIES, ...value }; +} + +function routeIsAvailable(route: AppRoute, capabilities: AppUiCapabilities): boolean { + if (route === "sync") return capabilities.sync; + if (route === "switch-provider") return capabilities.switchProvider; + return true; +} + +function isProfileStaleError(error: unknown): error is CoreClientError { + return error instanceof CoreClientError + && (error.code === "PROFILE_CHANGED" + || (error.code === "STALE_STATE" && error.dto.details?.reason === "profile")); +} + +export function AppContent({ props }: { props: AppUiProps }) { + const { t, i18n } = useTranslation(); + const toast = useToast(); + const queryClient = useQueryClient(); + const capabilities = useMemo(() => resolveCapabilities(props.capabilities), [props.capabilities]); + const visibleNavigation = useMemo( + () => navigation.filter(([id]) => routeIsAvailable(id, capabilities)), + [capabilities] + ); + const [route, setRoute] = useState("overview"); + const [selectedProfileId, setSelectedProfileId] = useState("default"); + const [plan, setPlan] = useState(null); + const [operationResult, setOperationResult] = useState(null); + const [recoveryResultStatusChecked, setRecoveryResultStatusChecked] = useState(true); + const [operationId, setOperationId] = useState(null); + const [operationProgress, setOperationProgress] = useState(null); + const [cancelling, setCancelling] = useState(false); + const applyController = useRef(null); + const applySubmissionPending = useRef(false); + const planReturnFocus = useRef(null); + const resultOwnsReturnFocus = useRef(false); + const profileStaleNoticeActive = useRef(false); + const profileRefreshInFlight = useRef | null>(null); + const mutationCount = useIsMutating(); + const profilesQuery = useQuery({ + queryKey: ["profiles"], + queryFn: ({ signal }) => props.host.listProfiles(signal) + }); + const profiles = profilesQuery.data ?? []; + const profile = profiles.find((entry) => entry.id === selectedProfileId) ?? profiles[0]; + const handleProfileStale = useCallback(async () => { + if (!profileStaleNoticeActive.current) { + profileStaleNoticeActive.current = true; + toast.push({ + title: t("global.profileChanged"), + description: t("global.profileChangedHint"), + tone: "warning" + }); + } + if (!profileRefreshInFlight.current) { + const refresh = profilesQuery.refetch() + .then(() => undefined) + .finally(() => { + if (profileRefreshInFlight.current === refresh) profileRefreshInFlight.current = null; + }); + profileRefreshInFlight.current = refresh; + } + await profileRefreshInFlight.current; + }, [profilesQuery.refetch, t, toast]); + + useEffect(() => { + document.documentElement.lang = i18n.resolvedLanguage?.toLowerCase().startsWith("zh") ? "zh-CN" : "en"; + }, [i18n.resolvedLanguage]); + useEffect(() => { + if (profiles.length && !profiles.some((entry) => entry.id === selectedProfileId)) { + setSelectedProfileId(profiles[0].id); + } + }, [profiles, selectedProfileId]); + useEffect(() => { + if (!routeIsAvailable(route, capabilities)) setRoute("overview"); + }, [capabilities, route]); + + const statusQuery = useQuery({ + queryKey: ["status", profile?.id, profile?.revision], + queryFn: ({ signal }) => props.core.getStatus({ profile: profileSelector(profile) }, { signal }), + enabled: Boolean(profile) + }); + const status = statusQuery.data; + const statusReady = statusQuery.isSuccess && status !== undefined; + useEffect(() => { + if (statusReady && status.profile.revision === profile?.revision) { + profileStaleNoticeActive.current = false; + return; + } + if (isProfileStaleError(statusQuery.error) && !profileStaleNoticeActive.current) { + void handleProfileStale(); + } + }, [handleProfileStale, profile?.revision, status?.profile.revision, statusQuery.error, statusReady]); + const externalWriteActive = status?.operationInProgress != null; + const writeDisabled = !profile + || !statusReady + || status?.pendingRecovery === true + || externalWriteActive + || mutationCount > 0; + const recoveryWriteDisabled = !profile || !statusReady || externalWriteActive || mutationCount > 0; + const backupsQuery = useQuery({ + queryKey: ["backups", profile?.id, profile?.revision], + queryFn: ({ signal }) => props.core.listBackups({ profile: profileSelector(profile) }, { signal }), + enabled: Boolean(profile && route === "backups-restore") + }); + const diagnosticsQuery = useQuery({ + queryKey: ["diagnostics", profile?.id, profile?.revision], + queryFn: ({ signal }) => props.core.getDiagnostics({ profile: profileSelector(profile) }, { signal }), + enabled: Boolean(profile && route === "diagnostics") + }); + const refreshAfterWrite = useCallback(async ({ refreshStatus = true } = {}) => { + const refreshes = [ + queryClient.invalidateQueries({ queryKey: ["backups"] }), + queryClient.invalidateQueries({ queryKey: ["history"] }), + queryClient.invalidateQueries({ queryKey: ["diagnostics"] }) + ]; + if (refreshStatus) refreshes.push(queryClient.invalidateQueries({ queryKey: ["status"] })); + await Promise.all(refreshes); + }, [queryClient]); + const prepare = useCallback(async (action: () => Promise, trigger: HTMLElement | null) => { + planReturnFocus.current = trigger; + try { + setPlan(await action()); + } catch (error) { + planReturnFocus.current = null; + if (isProfileStaleError(error)) { + await handleProfileStale(); + return; + } + toast.push({ + title: t("global.failed"), + description: safeErrorText(error, t("global.unexpected")), + tone: "danger" + }); + } + }, [handleProfileStale, t, toast]); + const closePlan = useCallback(() => { + const target = planReturnFocus.current; + resultOwnsReturnFocus.current = false; + setPlan(null); + setOperationId(null); + setOperationProgress(null); + setCancelling(false); + globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(() => { + if (target?.isConnected) target.focus(); + if (planReturnFocus.current === target) planReturnFocus.current = null; + })); + }, []); + const closePlanForResult = useCallback(() => { + setPlan(null); + setOperationId(null); + setOperationProgress(null); + setCancelling(false); + }, []); + const restorePlanFocus = useCallback(() => { + if (resultOwnsReturnFocus.current) return; + const target = planReturnFocus.current; + planReturnFocus.current = null; + target?.focus(); + }, []); + const restoreOperationFocus = useCallback(() => { + const target = planReturnFocus.current; + planReturnFocus.current = null; + resultOwnsReturnFocus.current = false; + target?.focus(); + }, []); + const applyMutation = useMutation({ + mutationFn: async (summary: PlanSummary): Promise => { + const input = { schemaVersion: 1 as const, planId: summary.planId }; + const controller = new AbortController(); + applyController.current = controller; + setOperationId(null); + setOperationProgress(null); + setCancelling(false); + const options = { + signal: controller.signal, + onOperationStarted: (event: { operationId: string }) => setOperationId(event.operationId), + onProgress: (event: { progress: ProgressEvent }) => setOperationProgress(event.progress) + }; + try { + if (summary.operation === "sync") return await props.core.applySync(input, options); + if (summary.operation === "switch") return await props.core.applySwitch(input, options); + return await props.core.applyRestore(input, options); + } finally { + if (applyController.current === controller) applyController.current = null; + } + }, + onSuccess: async (result) => { + const presentation = operationResultPresentation(result.outcome); + const requiresRecovery = result.outcome === "recovery_required"; + resultOwnsReturnFocus.current = true; + setRecoveryResultStatusChecked(!requiresRecovery); + setOperationResult(result); + closePlanForResult(); + try { + if (requiresRecovery) { + await refreshAfterWrite({ refreshStatus: false }); + const refreshedStatus = await statusQuery.refetch(); + setRecoveryResultStatusChecked(refreshedStatus.isSuccess); + } else { + await refreshAfterWrite(); + } + } catch { + // Keep recovery-required results non-dismissible until a later fresh + // Status snapshot proves that pending recovery is clear. + } + toast.push({ + title: t(presentation.toastKey), + description: result.backup?.backupId, + tone: presentation.tone + }); + }, + onError: async (error) => { + await refreshAfterWrite(); + closePlan(); + if (error instanceof CoreClientError && error.code === "OPERATION_CANCELLED") { + toast.push({ title: t("global.cancelled"), tone: "warning" }); + return; + } + if (isProfileStaleError(error)) { + await handleProfileStale(); + return; + } + toast.push({ + title: t("global.failed"), + description: safeErrorText(error, t("global.unexpected")), + tone: "danger" + }); + } + }); + const pruneMutation = useMutation({ + mutationFn: async (keepCount: number) => { + if (!profile) throw new Error("No profile is selected."); + return props.core.pruneBackups({ profile: profileSelector(profile), keepCount }); + }, + onSuccess: async () => { + await refreshAfterWrite(); + toast.push({ title: t("global.completed"), tone: "success" }); + }, + onError: (error) => { + toast.push({ + title: t("global.failed"), + description: safeErrorText(error, t("global.unexpected")), + tone: "danger" + }); + } + }); + const exportDiagnostics = useMutation({ + mutationFn: async () => { + if (!profile || !props.host.exportDiagnostics) throw new Error("Diagnostics export is unavailable."); + return props.host.exportDiagnostics(profileSelector(profile)); + }, + onSuccess: (result) => { + toast.push({ + title: result.status === "created" + ? t("diagnostics.exportCreated") + : result.status === "cancelled" + ? t("diagnostics.exportCancelled") + : t("diagnostics.exportFailed"), + tone: result.status === "created" ? "success" : result.status === "cancelled" ? "warning" : "danger" + }); + }, + onError: () => toast.push({ title: t("diagnostics.exportFailed"), tone: "danger" }) + }); + + const configuredProviders = status?.configuredProviders && Array.isArray(status.configuredProviders) + ? status.configuredProviders.filter((value): value is string => typeof value === "string") + : [status?.currentProvider ?? "openai"]; + const page = !profile + ? {profilesQuery.isPending ? t("common.loading") : safeErrorText(profilesQuery.error, t("global.failed"))} + : route === "overview" + ? void statusQuery.refetch()} status={status} /> + : route === "sync" && capabilities.sync + ? prepare(() => props.core.prepareSync({ profile: profileSelector(profile), keepCount: values.keepCount }), trigger)} /> + : route === "switch-provider" && capabilities.switchProvider + ? prepare(() => props.core.prepareSwitch({ profile: profileSelector(profile), provider: values.provider, modelMode: values.modelMode as SwitchModelMode, ...(values.modelMode === "explicit" ? { model: values.model } : {}), keepCount: values.keepCount }), trigger)} providers={configuredProviders} /> + : route === "backups-restore" + ? prepare(() => props.core.prepareRestore({ profile: profileSelector(profile), backupId: values.backupId, restoreConfig: values.restoreConfig, restoreDatabase: values.restoreDatabase, restoreSessions: values.restoreSessions, ...(values.allowSqliteHomeRelocation ? { allowSqliteHomeRelocation: true, relocationTargetProfileId: values.relocationTargetProfileId } : {}) }), trigger)} profile={profile} profiles={profiles} prune={(keepCount) => pruneMutation.mutate(keepCount)} /> + : route === "history" + ? + : route === "profiles" + ? profilesQuery.refetch()} revealPaths={capabilities.revealProfilePaths} surface={props.surface} /> + : route === "diagnostics" + ? exportDiagnostics.mutate()} exporting={exportDiagnostics.isPending} loading={diagnosticsQuery.isFetching} refresh={() => void diagnosticsQuery.refetch()} /> + : route === "settings" + ? 0} /> + : void statusQuery.refetch()} status={status} />; + + return ( +
+ { event.preventDefault(); document.getElementById("main-content")?.focus(); }}>{t("a11y.skipToContent")} +
+
Codex Provider Sync
{t(`brand.${props.surface}.label`)}
{t(`brand.${props.surface}.subtitle`)}
+
0 || externalWriteActive ? "warning" : "success"}>{mutationCount > 0 || externalWriteActive ? t("global.busy") : t("global.ready")}
+
+
+ +
+ {status?.pendingRecovery ?
RECOVERY_REQUIRED
{t("global.recovery")}
: null} + {status?.operationInProgress ?
{t("global.busy")}
{t(`plan.operations.${String(status.operationInProgress.operation ?? "operation")}`, { defaultValue: t("plan.operations.operation") })} · {String(status.operationInProgress.busyScope ?? "")}
: null} + {statusQuery.isError ?
{safeErrorText(statusQuery.error, t("global.failed"))}
: null} + {page} +
+
+ {capabilities.sync || capabilities.switchProvider || capabilities.restore ? { + if (!plan || applySubmissionPending.current || applyMutation.isPending) return; + applySubmissionPending.current = true; + applyMutation.mutate(plan, { + onSettled: () => { applySubmissionPending.current = false; } + }); + }} applying={applyMutation.isPending} cancel={() => { if (!applyMutation.isPending || cancelling) return; setCancelling(true); applyController.current?.abort(); }} cancelling={cancelling} close={closePlan} confirmDisabled={!statusReady || externalWriteActive || status?.pendingRecovery === true} operationId={operationId} plan={plan} progress={operationProgress} restoreFocus={restorePlanFocus} /> : null} + { setOperationResult(null); setRecoveryResultStatusChecked(true); }} closeDisabled={operationResult?.outcome === "recovery_required" && (!recoveryResultStatusChecked || status?.pendingRecovery !== false)} restoreFocus={restoreOperationFocus} result={operationResult} /> +
+ ); +} diff --git a/packages/app-ui/src/app/AppErrorBoundary.tsx b/packages/app-ui/src/app/AppErrorBoundary.tsx new file mode 100644 index 0000000..d2d76e4 --- /dev/null +++ b/packages/app-ui/src/app/AppErrorBoundary.tsx @@ -0,0 +1,38 @@ +import { ShieldAlert } from "lucide-react"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +import { Button, Card } from "../ui.js"; + +export class AppErrorBoundary extends Component<{ + children: ReactNode; + locale(): string; +}, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true }; + } + + componentDidCatch(_error: Error, _info: ErrorInfo): void {} + + render(): ReactNode { + if (!this.state.failed) return this.props.children; + const chinese = this.props.locale().toLowerCase().startsWith("zh"); + return ( +
+ + +

{chinese ? "应用错误" : "Application error"}

+

+ {chinese + ? "页面遇到未预期错误;系统没有自动启动任何写操作。" + : "The page encountered an unexpected error. No write was started automatically."} +

+ +
+
+ ); + } +} diff --git a/packages/app-ui/src/features/backups-restore/BackupsRestorePage.tsx b/packages/app-ui/src/features/backups-restore/BackupsRestorePage.tsx new file mode 100644 index 0000000..035a2e5 --- /dev/null +++ b/packages/app-ui/src/features/backups-restore/BackupsRestorePage.tsx @@ -0,0 +1,144 @@ +import type { ManagedBackup } from "@codex-provider-sync/contracts"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArchiveRestore } from "lucide-react"; +import { Fragment, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; + +import { restoreSchema } from "../../schemas.js"; +import { formatBytes, formatDate, PageHeading } from "../../shared/presentation.js"; +import type { HostProfile } from "../../types.js"; +import { Badge, Button, Card, Field, Input, cn } from "../../ui.js"; + +export type RestoreValues = z.infer; + +function BackupRow({ backup, selected, onSelect }: { + backup: ManagedBackup; + selected: boolean; + onSelect?: () => void; +}) { + const content = ( + +
+ {backup.backupId} + {formatBytes(backup.sizeBytes)} +
+ {backup.createdAt ?
{formatDate(backup.createdAt)}
: null} +
+ ); + const className = cn( + "w-full rounded-lg border p-4 text-left", + selected ? "border-[var(--accent)] bg-[var(--accent-soft)]" : "border-[var(--border)]" + ); + if (!onSelect) return
{content}
; + return ( + + ); +} + +export function BackupsRestorePage({ + profile, + profiles, + backups, + loading, + disabled, + canRestore, + canPrune, + prepare, + prune +}: { + profile: HostProfile; + profiles: HostProfile[]; + backups: ManagedBackup[]; + loading: boolean; + disabled: boolean; + canRestore: boolean; + canPrune: boolean; + prepare(values: RestoreValues, trigger: HTMLButtonElement | null): Promise; + prune(keepCount: number): void; +}) { + const { t } = useTranslation(); + const form = useForm({ + resolver: zodResolver(restoreSchema), + defaultValues: { + backupId: "", + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true, + allowSqliteHomeRelocation: false, + relocationTargetProfileId: "" + } + }); + const relocation = form.watch("allowSqliteHomeRelocation"); + const [keepCount, setKeepCount] = useState(5); + const prepareButton = useRef(null); + const selectedBackupId = form.watch("backupId"); + return ( + + +
+ +
+ {loading + ? {t("common.loading")} + : backups.length === 0 + ? {t("backups.empty")} + : backups.map((backup) => ( + form.setValue("backupId", backup.backupId, { shouldValidate: true }) : undefined} + selected={canRestore && selectedBackupId === backup.backupId} + /> + ))} +
+ {!canRestore && !canPrune ?

{t("backups.readOnly")}

: null} +
+ {canRestore || canPrune ? ( +
+ {canRestore ? ( + +
prepare(values, prepareButton.current))}> + {(["restoreConfig", "restoreDatabase", "restoreSessions"] as const).map((name) => ( + + ))} + + {relocation ? ( + + + + ) : null} + {form.formState.errors.restoreSessions ? {t("validation.restore")} : null} + +
+
+ ) : null} + {canPrune ? ( + + setKeepCount(Number(event.target.value))} type="number" value={keepCount} /> + + + ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/packages/app-ui/src/features/diagnostics/DiagnosticsPage.tsx b/packages/app-ui/src/features/diagnostics/DiagnosticsPage.tsx new file mode 100644 index 0000000..053e29a --- /dev/null +++ b/packages/app-ui/src/features/diagnostics/DiagnosticsPage.tsx @@ -0,0 +1,50 @@ +import type { DiagnosticsSnapshot } from "@codex-provider-sync/contracts"; +import { ArchiveRestore, RefreshCw } from "lucide-react"; +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; + +import { KeyValue, PageHeading } from "../../shared/presentation.js"; +import { Badge, Button, Card } from "../../ui.js"; + +export function DiagnosticsPage({ diagnostics, loading, exporting, canExport, refresh, exportBundle }: { + diagnostics?: DiagnosticsSnapshot; + loading: boolean; + exporting: boolean; + canExport: boolean; + refresh(): void; + exportBundle(): void; +}) { + const { t } = useTranslation(); + const sections = diagnostics + ? [["runtime", diagnostics.runtime], ["storage", diagnostics.storage], ["provider", diagnostics.provider], ["safety", diagnostics.safety]] as const + : []; + const summary = (value: unknown) => { + if (value === null || value === undefined || value === "") return t("common.none"); + if (typeof value === "boolean") return {value ? t("common.yes") : t("common.no")}; + if (typeof value === "string" || typeof value === "number") return String(value); + if (Array.isArray(value)) return t("diagnostics.items", { count: value.length }); + if (typeof value === "object") return t("diagnostics.fieldsAvailable", { count: Object.keys(value).length }); + return t("common.unknown"); + }; + return ( + + {canExport ? : null}} + /> +
+ {sections.map(([key, value]) => ( + +

{t(`diagnostics.${key}`)}

+
{Object.entries(value).map(([field, fieldValue]) => )}
+
+ {t("diagnostics.technicalDetails")} +
{JSON.stringify(value, null, 2)}
+
+
+ ))} +
+
+ ); +} diff --git a/packages/app-ui/src/features/history/HistoryPage.tsx b/packages/app-ui/src/features/history/HistoryPage.tsx new file mode 100644 index 0000000..2f783a6 --- /dev/null +++ b/packages/app-ui/src/features/history/HistoryPage.tsx @@ -0,0 +1,162 @@ +import type { HistorySessionDetail } from "@codex-provider-sync/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { RefreshCw } from "lucide-react"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { formatDate, PageHeading, profileSelector, safeErrorText } from "../../shared/presentation.js"; +import type { AppUiProps, HostProfile } from "../../types.js"; +import { Badge, Button, Card, cn } from "../../ui.js"; + +export const HISTORY_PAGE_SIZE = 50; + +export function HistoryPage({ core, profile }: { + core: AppUiProps["core"]; + profile: HostProfile; +}) { + const { t, i18n } = useTranslation(); + const [page, setPage] = useState(1); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [returnFocusId, setReturnFocusId] = useState(null); + const detailHeadingRef = useRef(null); + const openButtons = useRef(new Map()); + const list = useQuery({ + queryKey: ["history", profile.id, profile.revision, page, HISTORY_PAGE_SIZE], + queryFn: ({ signal }) => core.listHistory({ + profile: profileSelector(profile), + page, + pageSize: HISTORY_PAGE_SIZE + }, { signal }), + gcTime: 0, + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false + }); + + useEffect(() => { + setPage(1); + setSelectedId(null); + setDetail(null); + setDetailError(null); + }, [profile.id, profile.revision]); + + useEffect(() => { + if (!selectedId) { + setDetail(null); + setDetailError(null); + setDetailLoading(false); + return; + } + const controller = new AbortController(); + setDetail(null); + setDetailError(null); + setDetailLoading(true); + void core.getHistorySession({ + profile: profileSelector(profile), + sessionId: selectedId, + messageLimit: 200 + }, { signal: controller.signal }) + .then((value) => { + if (!controller.signal.aborted) setDetail(value); + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) setDetailError(safeErrorText(error, t("global.failed"))); + }) + .finally(() => { + if (!controller.signal.aborted) setDetailLoading(false); + }); + return () => { + controller.abort(); + setDetail(null); + }; + }, [core, profile.id, profile.revision, selectedId, t]); + + useEffect(() => { + if (detail) detailHeadingRef.current?.focus(); + }, [detail]); + + useEffect(() => { + if (selectedId || !returnFocusId) return; + const button = openButtons.current.get(returnFocusId); + if (!button) return; + button.focus(); + setReturnFocusId(null); + }, [list.data, returnFocusId, selectedId]); + + if (selectedId) { + return ( + + { setReturnFocusId(selectedId); setSelectedId(null); }} type="button" variant="secondary">{t("history.back")}} + headingRef={detailHeadingRef} + headingTabIndex={-1} + /> + + {detailLoading + ? {t("common.loading")} + : detailError + ? {detailError} + : detail + ? ( +
+ {detail.messages.map((message) => ( +
+
{t(`history.roles.${message.role}`, { defaultValue: message.role })}{formatDate(message.timestamp, i18n.language)}
+
{message.text}
+
+ ))} +
+ ) + : null} +
+
+ ); + } + + const sessions = list.data?.sessions ?? []; + return ( + + void list.refetch()} type="button" variant="secondary">{t("common.refresh")}} + /> + + {list.isPending + ? {t("common.loading")} + : list.isError + ? {safeErrorText(list.error, t("global.failed"))} + : sessions.length === 0 + ? {t("history.empty")} + : ( +
+ {sessions.map((session) => ( +
+
+
{session.title || t("history.untitled")}
+
{session.provider}{session.messageCountKnown !== false ? {session.messageCount} {t("history.messages")} : null}{formatDate(session.updatedAt, i18n.language)}{session.archived ? {t("history.archived")} : null}
+
+ +
+ ))} +
+ )} + {list.data ? ( + + ) : null} +
+
+ ); +} diff --git a/packages/app-ui/src/features/operations/OperationResultDialog.tsx b/packages/app-ui/src/features/operations/OperationResultDialog.tsx new file mode 100644 index 0000000..7cbdbef --- /dev/null +++ b/packages/app-ui/src/features/operations/OperationResultDialog.tsx @@ -0,0 +1,108 @@ +import type { OperationOutcome, OperationResult } from "@codex-provider-sync/contracts"; +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button, Card, Dialog } from "../../ui.js"; + +export type OperationResultTone = "success" | "warning" | "danger"; + +const OUTCOME_PRESENTATION: Record = { + completed: { tone: "success", titleKey: "operationResult.completed.title", descriptionKey: "operationResult.completed.description", toastKey: "global.completed" }, + partial: { tone: "warning", titleKey: "operationResult.partial.title", descriptionKey: "operationResult.partial.description", toastKey: "global.partial" }, + failed_rolled_back: { tone: "warning", titleKey: "operationResult.failedRolledBack.title", descriptionKey: "operationResult.failedRolledBack.description", toastKey: "global.failed" }, + recovery_required: { tone: "danger", titleKey: "operationResult.recoveryRequired.title", descriptionKey: "operationResult.recoveryRequired.description", toastKey: "global.failed" }, + cancelled: { tone: "warning", titleKey: "operationResult.cancelled.title", descriptionKey: "operationResult.cancelled.description", toastKey: "global.cancelled" }, + stale: { tone: "warning", titleKey: "operationResult.stale.title", descriptionKey: "operationResult.stale.description", toastKey: "global.stale" } +}; + +export function operationResultPresentation(outcome: OperationOutcome) { + return OUTCOME_PRESENTATION[outcome]; +} + +function publicResultEntries(value: OperationResult["result"]): Array<[string, string]> { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const result: Array<[string, string]> = []; + const strings = new Set([ + "targetProvider", + "targetModel", + "modelSource", + "restoreOperationId", + "preRestoreSnapshotId", + "restoreJournalState" + ]); + const numbers = new Set([ + "backupDurationMs", + "changedSessionFiles", + "sqliteRowsUpdated", + "sqliteProviderRowsUpdated", + "sqliteUserEventRowsUpdated", + "sqliteCwdRowsUpdated", + "updatedWorkspaceRoots", + "savedWorkspaceRootCount", + "restoreVersion", + "resolvedOperationCount" + ]); + const booleans = new Set(["commitAcknowledgementRecovered"]); + for (const [key, candidate] of Object.entries(value)) { + if (!(strings.has(key) && typeof candidate === "string") + && !(numbers.has(key) && typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0) + && !(booleans.has(key) && typeof candidate === "boolean")) continue; + result.push([key, String(candidate)]); + } + return result; +} + +function skippedRollouts(value: OperationResult["result"]): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const candidate = value.skippedLockedRolloutFiles; + return Array.isArray(candidate) ? candidate.filter((entry): entry is string => typeof entry === "string") : []; +} + +export function OperationResultDialog({ result, close, closeDisabled = false, restoreFocus }: { + result: OperationResult | null; + close(): void; + closeDisabled?: boolean; + restoreFocus(): void; +}) { + const { t } = useTranslation(); + const presentation = result ? operationResultPresentation(result.outcome) : null; + const entries = result ? publicResultEntries(result.result) : []; + const skipped = result ? skippedRollouts(result.result) : []; + const alert = result?.outcome === "recovery_required"; + return ( + {t("common.close")}} + onOpenChange={(open) => { if (!open && !closeDisabled) close(); }} + open={Boolean(result)} + restoreFocus={restoreFocus} + title={t("operationResult.title")} + > + {result && presentation ? ( +
+
+

{t(presentation.titleKey)}

+

{t(presentation.descriptionKey)}

+
+ +
+
{t("operationResult.operationId")}
{result.operationId}
+ {result.backup ?
{t("operationResult.backupId")}
{result.backup.backupId}
: null} + {entries.map(([key, value]) =>
{t(`operationResult.fields.${key}`, { defaultValue: key })}
{value}
)} +
+
+ {result.warnings.length ?

{t("common.warnings")}

    {result.warnings.map((warning, index) =>
  • {warning}
  • )}
: null} + {skipped.length ?

{t("operationResult.skippedRollouts")}

    {skipped.map((file) =>
  • {file}
  • )}
: null} + {closeDisabled ?

{t("operationResult.resolveBeforeClose")}

: null} +
+ ) : null} +
+ ); +} diff --git a/packages/app-ui/src/features/operations/PlanReview.tsx b/packages/app-ui/src/features/operations/PlanReview.tsx new file mode 100644 index 0000000..a10f704 --- /dev/null +++ b/packages/app-ui/src/features/operations/PlanReview.tsx @@ -0,0 +1,97 @@ +import type { PlanSummary, ProgressEvent } from "@codex-provider-sync/contracts"; +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; + +import { formatDate, KeyValue } from "../../shared/presentation.js"; +import { Button, Card, Dialog } from "../../ui.js"; + +function displayPlanValue(key: string, value: unknown, t: (key: string, options?: Record) => string): string { + if (value === null || value === undefined || value === "") return t("common.none"); + if (typeof value === "boolean") return value ? t("common.yes") : t("common.no"); + if (key === "modelMode" && typeof value === "string") { + return t(`plan.modelModes.${value}`, { defaultValue: value }); + } + if (Array.isArray(value)) return t("plan.items", { count: value.length }); + return String(value); +} + +export function PlanReview({ + plan, + applying, + cancelling, + confirmDisabled = false, + operationId, + progress, + close, + apply, + cancel, + restoreFocus +}: { + plan: PlanSummary | null; + applying: boolean; + cancelling: boolean; + confirmDisabled?: boolean; + operationId: string | null; + progress: ProgressEvent | null; + close(): void; + apply(): void; + cancel(): void; + restoreFocus(): void; +}) { + const { t, i18n } = useTranslation(); + const operationLabel = plan + ? t(`plan.operations.${plan.operation}`, { defaultValue: plan.operation }) + : ""; + const targetRows = plan ? [ + ["provider", t("common.provider")], + ["model", t("common.model")], + ["modelMode", t("plan.fields.modelMode")], + ["backupId", t("operationResult.backupId")], + ["restoreConfig", t("plan.fields.restoreConfig")], + ["restoreDatabase", t("plan.fields.restoreDatabase")], + ["restoreSessions", t("plan.fields.restoreSessions")], + ["allowSqliteHomeRelocation", t("plan.fields.relocation")] + ].filter(([key]) => key in plan.target) : []; + const impactRows = plan ? [ + ["rolloutFilesToChange", t("plan.fields.rolloutFiles")], + ["sqliteRowsToChange", t("plan.fields.sqliteRows")], + ["workspaceRootsToChange", t("plan.fields.workspaceRoots")], + ["stateDbFilesToChange", t("plan.fields.stateDbFiles")], + ["configFilesToChange", t("plan.fields.configFiles")], + ["lockedRolloutFiles", t("plan.fields.lockedRollouts")] + ].filter(([key]) => key in plan.impact) : []; + return ( + {applying ? : }} + onOpenChange={(open) => { if (!open && !applying) close(); }} + open={Boolean(plan)} + restoreFocus={restoreFocus} + title={t("plan.title")} + > + {plan ? ( +
+ +

{t("plan.target")}

+
{targetRows.map(([key, label]) => )}
+
+ +

{t("plan.impact")}

+
{impactRows.map(([key, label]) => )}
+
+ {plan.impact.backupExpected === true ?
{t("plan.backupExpected")}
: null} + {plan.warnings.length ?

{t("common.warnings")}

    {plan.warnings.map((warning, index) =>
  • {warning}
  • )}
: null} + {applying ?

{t("plan.progress")}

{operationId ?? t("plan.starting")}
{progress ?
{t(`plan.stages.${progress.stage}`, { defaultValue: progress.stage })} · {t(`plan.statuses.${progress.status}`, { defaultValue: progress.status })}{progress.count === undefined ? "" : ` · ${progress.count}`}
{progress.progress === undefined ? null : }
: null}{cancelling ?

{t("plan.cancelPending")}

: null}
: null} + {confirmDisabled && !applying ?

{t("plan.writeBlocked")}

: null} +

{t("plan.exactApply")}

+
+ {t("plan.technicalDetails")} +
{JSON.stringify({ target: plan.target, impact: plan.impact }, null, 2)}
+
+
+ ) : null} +
+ ); +} diff --git a/packages/app-ui/src/features/overview/OverviewPage.tsx b/packages/app-ui/src/features/overview/OverviewPage.tsx new file mode 100644 index 0000000..559e3d4 --- /dev/null +++ b/packages/app-ui/src/features/overview/OverviewPage.tsx @@ -0,0 +1,74 @@ +import type { StatusSnapshot } from "@codex-provider-sync/contracts"; +import { AlertTriangle, CheckCircle2, RefreshCw } from "lucide-react"; +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; + +import { formatBytes, formatDate, KeyValue, PageHeading } from "../../shared/presentation.js"; +import { Badge, Button, Card, cn } from "../../ui.js"; + +function Distribution({ title, counts, current }: { title: string; counts: unknown; current: string }) { + const record = counts && typeof counts === "object" && !Array.isArray(counts) + ? counts as Record + : {}; + const merged = new Map(); + for (const scope of ["sessions", "archived_sessions"]) { + const distribution = record[scope]; + if (!distribution || typeof distribution !== "object" || Array.isArray(distribution)) continue; + for (const [provider, count] of Object.entries(distribution as Record)) { + if (typeof count === "number") merged.set(provider, (merged.get(provider) ?? 0) + count); + } + } + const entries = [...merged.entries()].sort((left, right) => right[1] - left[1]); + const total = entries.reduce((sum, [, count]) => sum + count, 0); + return ( + +
+

{title}

{total} +
+
+ {entries.length === 0 ? : entries.map(([provider, count]) => ( +
+
{provider}{count}
+ +
+ ))} +
+
+ ); +} + +export function OverviewPage({ status, loading, refresh }: { + status?: StatusSnapshot; + loading: boolean; + refresh(): void; +}) { + const { t, i18n } = useTranslation(); + const alignment = status?.alignment && typeof status.alignment === "object" + ? (status.alignment as Record).aligned === true + : false; + return ( + + {t("common.refresh")}} + /> +
+
{t("common.provider")}
{status?.currentProvider ?? "—"}
+
{t("overview.alignment")}
{alignment ? : }{alignment ? t("overview.aligned") : t("overview.notAligned")}
+
{t("overview.backupCount")}
{status?.backupSummary.count ?? 0}
{formatBytes(status?.backupSummary.totalBytes ?? 0)}
+
{t("overview.locked")}
{status?.lockedRolloutFiles.length ?? 0}
+
+
+
+ + +
+
+ ); +} diff --git a/packages/app-ui/src/features/profiles/ProfilesPage.tsx b/packages/app-ui/src/features/profiles/ProfilesPage.tsx new file mode 100644 index 0000000..536e36d --- /dev/null +++ b/packages/app-ui/src/features/profiles/ProfilesPage.tsx @@ -0,0 +1,105 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { Fragment, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; + +import { profileSchema } from "../../schemas.js"; +import { PageHeading, safeErrorText } from "../../shared/presentation.js"; +import type { AppUiProps, AppUiSurface, HostProfile } from "../../types.js"; +import { Badge, Button, Card, Field, Input, cn, useToast } from "../../ui.js"; + +type ProfileValues = z.infer; + +export function ProfilesPage({ profiles, refresh, host, canManage, revealPaths, surface }: { + profiles: HostProfile[]; + refresh(): Promise; + host: AppUiProps["host"]; + canManage: boolean; + revealPaths: boolean; + surface: AppUiSurface; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const [editing, setEditing] = useState(null); + const form = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { profileId: "", name: "", codexHome: "", sqliteHome: "" } + }); + useEffect(() => { + form.reset(editing + ? { profileId: editing.id, name: editing.name, codexHome: editing.codexHome ?? "", sqliteHome: editing.sqliteHome ?? "" } + : { profileId: "", name: "", codexHome: "", sqliteHome: "" }); + }, [editing, form]); + const save = useMutation({ + mutationFn: async (values: ProfileValues) => { + if (!canManage || !host.saveProfile) throw new Error("Profile management is unavailable."); + return host.saveProfile({ ...values, ...(editing ? { profileRevision: editing.revision } : {}) }); + }, + onSuccess: async () => { + await refresh(); + setEditing(null); + form.reset(); + toast.push({ title: t("common.save"), tone: "success" }); + }, + onError: (error) => toast.push({ + title: t("global.failed"), + description: safeErrorText(error, t("global.unexpected")), + tone: "danger" + }) + }); + const remove = useMutation({ + mutationFn: (profile: HostProfile) => { + if (!canManage || !host.deleteProfile) throw new Error("Profile management is unavailable."); + return host.deleteProfile(profile.id, profile.revision); + }, + onSuccess: async () => { + await refresh(); + setEditing(null); + toast.push({ title: t("common.delete"), tone: "success" }); + }, + onError: (error) => toast.push({ + title: t("global.failed"), + description: safeErrorText(error, t("global.unexpected")), + tone: "danger" + }) + }); + return ( + + +
+ +
+ {profiles.map((profile) => { + const content = ( + +
{profile.name}{profile.id === "default" ? {t("common.current")} : null}
+
{profile.id}
+ {revealPaths && profile.codexHome + ?
{profile.codexHome}
+ :
{t(`profiles.pathManaged.${surface}`)}
} +
+ ); + if (!canManage || profile.id === "default") return
{content}
; + return ; + })} +
+ {!canManage ?

{t("profiles.readOnly")}

: null} +
+ {canManage ? ( + +
save.mutateAsync(values))}> + + + + +
{editing ? : null}
+
+

{t("profiles.defaultManaged")}

+
+ ) : null} +
+
+ ); +} diff --git a/packages/app-ui/src/features/settings/SettingsPage.tsx b/packages/app-ui/src/features/settings/SettingsPage.tsx new file mode 100644 index 0000000..b66194d --- /dev/null +++ b/packages/app-ui/src/features/settings/SettingsPage.tsx @@ -0,0 +1,141 @@ +import type { WatchSnapshot, WatchStatusList } from "@codex-provider-sync/contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Globe2, Languages, Moon, Play, RefreshCw, Sun } from "lucide-react"; +import { Fragment, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { PageHeading, profileSelector } from "../../shared/presentation.js"; +import type { AppUiCapabilities, AppUiProps, HostProfile, HostUpdateStatus } from "../../types.js"; +import { Badge, Button, Card, cn, Field } from "../../ui.js"; + +function activeWatch(value: WatchSnapshot | WatchStatusList | undefined): WatchSnapshot | null { + if (!value) return null; + if ("watches" in value) return value.watches.find((watch) => watch.status !== "stopped") ?? value.watches[0] ?? null; + return value; +} + +export function SettingsPage({ props, profile, capabilities, recoveryBlocked, writeBlocked }: { + props: AppUiProps; + profile: HostProfile; + capabilities: AppUiCapabilities; + recoveryBlocked: boolean; + writeBlocked: boolean; +}) { + const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); + const [theme, setTheme] = useState(props.preferences.getTheme() ?? props.initialTheme); + const watch = useQuery({ + queryKey: ["watch-status"], + queryFn: () => props.core.getWatchStatus({}), + enabled: capabilities.watch + }); + const currentWatch = activeWatch(watch.data); + const start = useMutation({ + mutationFn: () => props.core.startWatch({ profile: profileSelector(profile), includeStateDb: true }), + onSuccess: (value) => queryClient.setQueryData(["watch-status"], value) + }); + const stop = useMutation({ + mutationFn: (watchId: string) => props.core.stopWatch({ watchId }), + onSuccess: (value) => queryClient.setQueryData(["watch-status"], value) + }); + const update = useQuery({ + queryKey: ["desktop-update-status"], + queryFn: ({ signal }) => props.host.getUpdateStatus?.(signal), + enabled: capabilities.viewUpdateStatus && Boolean(props.host.getUpdateStatus) + }); + const canRefresh = capabilities.watch + || (capabilities.viewUpdateStatus && Boolean(props.host.getUpdateStatus)); + const refreshing = watch.isFetching || update.isFetching; + const refreshStatuses = async () => { + await Promise.all([ + capabilities.watch ? watch.refetch() : Promise.resolve(), + capabilities.viewUpdateStatus && props.host.getUpdateStatus + ? update.refetch() + : Promise.resolve() + ]); + }; + const storeUpdate = (value: HostUpdateStatus) => queryClient.setQueryData(["desktop-update-status"], value); + const checkUpdate = useMutation({ + mutationFn: () => props.host.checkForUpdates?.() ?? Promise.reject(new Error("Update check unavailable.")), + onSuccess: storeUpdate + }); + const downloadUpdate = useMutation({ + mutationFn: () => props.host.downloadUpdate?.() ?? Promise.reject(new Error("Update download unavailable.")), + onSuccess: storeUpdate + }); + const installUpdate = useMutation({ + mutationFn: () => props.host.installUpdate?.() ?? Promise.reject(new Error("Update install unavailable.")), + onSuccess: storeUpdate + }); + const setLocale = async (locale: "zh-CN" | "en") => { + props.preferences.setLocale(locale); + await i18n.changeLanguage(locale); + }; + const applyTheme = (value: "system" | "light" | "dark") => { + setTheme(value); + props.preferences.setTheme(value); + document.documentElement.dataset.theme = value; + }; + return ( + + void refreshStatuses()} type="button" variant="secondary"> + + {t("common.refresh")} + + ) : undefined} + title={t("settings.title")} + subtitle={t(`settings.subtitle.${props.surface}`)} + /> +
+ + + + +
{t("settings.englishFallback")}
+
+ +
+ {t("settings.theme")} +
+ {(["system", "light", "dark"] as const).map((value) => )} +
+
+
+ {capabilities.watch ? ( + +

{t("settings.watch")}

+
+
{currentWatch?.status ?? t("common.none")}{currentWatch ?
{currentWatch.watchId}
: null}
+ {currentWatch?.status === "running" + ? + : } +
+ {recoveryBlocked && currentWatch?.status !== "running" ?

{t("settings.watchRecoveryBlocked")}

: null} +
+ ) : null} + {capabilities.viewUpdateStatus && props.host.getUpdateStatus ? ( + +

{t("settings.update")}

+
+ {update.isPending ? t("common.loading") : update.data ? t(`settings.updateStatus.${update.data.state}`) : t("common.unknown")} + {update.data?.version ?

{t("settings.updateVersion", { version: update.data.version })}

: null} + {update.data?.progressPercent !== undefined ?

{t("settings.updateProgress", { percent: update.data.progressPercent })}

: null} + {update.data?.reason ?

{t(`settings.updateReason.${update.data.reason}`)}

: null} + {update.data?.installBlockedReason ?

{t(`settings.updateBlocked.${update.data.installBlockedReason}`)}

: null} +
+ {update.data && ["idle", "not-available", "error"].includes(update.data.state) && props.host.checkForUpdates ? : null} + {update.data?.state === "available" && props.host.downloadUpdate ? : null} + {update.data?.state === "downloaded" && props.host.installUpdate ? : null} +
+
+
+ ) : null} + {capabilities.forgetBrowser ?

{t("settings.forget")}

{t("settings.forgetHint")}

: null} +
+
+ ); +} diff --git a/packages/app-ui/src/features/switch-provider/SwitchPage.tsx b/packages/app-ui/src/features/switch-provider/SwitchPage.tsx new file mode 100644 index 0000000..018ce1c --- /dev/null +++ b/packages/app-ui/src/features/switch-provider/SwitchPage.tsx @@ -0,0 +1,57 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { RotateCcw } from "lucide-react"; +import { Fragment, useEffect, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; + +import { switchSchema } from "../../schemas.js"; +import { PageHeading } from "../../shared/presentation.js"; +import { Button, Card, Field, Input } from "../../ui.js"; + +export type SwitchValues = z.infer; + +export function SwitchPage({ disabled, providers, prepare }: { + disabled: boolean; + providers: string[]; + prepare(values: SwitchValues, trigger: HTMLButtonElement | null): Promise; +}) { + const { t } = useTranslation(); + const prepareButton = useRef(null); + const form = useForm({ + resolver: zodResolver(switchSchema), + defaultValues: { + provider: providers[0] ?? "openai", + modelMode: "provider-default", + model: "", + keepCount: 5 + } + }); + const modelMode = form.watch("modelMode"); + useEffect(() => { + if (modelMode !== "explicit") form.setValue("model", ""); + }, [form, modelMode]); + return ( + + + +
prepare(values, prepareButton.current))}> + + + + {providers.map((provider) => + + + + {modelMode === "explicit" ? : null} + + +
+
+
+ ); +} diff --git a/packages/app-ui/src/features/sync/SyncPage.tsx b/packages/app-ui/src/features/sync/SyncPage.tsx new file mode 100644 index 0000000..197a406 --- /dev/null +++ b/packages/app-ui/src/features/sync/SyncPage.tsx @@ -0,0 +1,37 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Workflow } from "lucide-react"; +import { Fragment, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; + +import { syncSchema } from "../../schemas.js"; +import { PageHeading } from "../../shared/presentation.js"; +import { Button, Card, Field, Input } from "../../ui.js"; + +export type SyncValues = z.infer; + +export function SyncPage({ disabled, prepare }: { + disabled: boolean; + prepare(values: SyncValues, trigger: HTMLButtonElement | null): Promise; +}) { + const { t } = useTranslation(); + const form = useForm({ + resolver: zodResolver(syncSchema), + defaultValues: { keepCount: 5 } + }); + const prepareButton = useRef(null); + return ( + + + +
prepare(values, prepareButton.current))}> + + + + +
+
+
+ ); +} diff --git a/packages/app-ui/src/i18n.ts b/packages/app-ui/src/i18n.ts new file mode 100644 index 0000000..76484d9 --- /dev/null +++ b/packages/app-ui/src/i18n.ts @@ -0,0 +1,723 @@ +import i18next, { type i18n } from "i18next"; +import type { SupportedLocale } from "@codex-provider-sync/design-system"; + +export const resources = { + en: { + translation: { + brand: { + desktop: { + label: "Desktop", + subtitle: "V1 primary desktop candidate · .NET post-handoff Legacy target" + }, + web: { + label: "Web", + subtitle: "Local Web companion" + } + }, + a11y: { + skipToContent: "Skip to content", + profile: "Profile", + primaryNavigation: "Primary navigation" + }, + nav: { + overview: "Overview", + sync: "Sync", + switchProvider: "Switch Provider", + backupsRestore: "Backups / Restore", + history: "History", + profiles: "Profiles", + diagnostics: "Diagnostics", + settings: "Settings" + }, + common: { + refresh: "Refresh", + loading: "Loading…", + cancel: "Cancel", + confirm: "Confirm and apply", + save: "Save", + delete: "Delete", + close: "Close", + yes: "Yes", + no: "No", + none: "None", + unknown: "Unknown", + current: "Current", + provider: "Provider", + model: "Model", + status: "Status", + warnings: "Warnings", + retry: "Retry" + }, + global: { + ready: "Local service ready", + busy: "Operation in progress", + recovery: "Recovery required. Writes are disabled until the pending transaction is resolved.", + stale: "Protected state changed. Prepare the operation again.", + unexpected: "The page encountered an unexpected error.", + partial: "Completed with locked rollout files skipped.", + completed: "Operation completed.", + cancelled: "Operation cancelled.", + profileChanged: "Profile changed.", + profileChangedHint: "Review the current profile and prepare the operation again.", + failed: "Operation failed." + }, + overview: { + title: "Provider metadata overview", + subtitle: "Compare rollout files, the SQLite thread index, and the selected profile.", + alignment: "Alignment", + aligned: "Aligned", + notAligned: "Needs attention", + rollout: "Rollout metadata", + sqlite: "SQLite metadata", + codexHomeSource: "Codex Home source", + sqliteHomeSource: "SQLite Home source", + snapshot: "Snapshot", + backupCount: "Managed backups", + locked: "Locked rollouts" + }, + sync: { + title: "Sync current Provider", + subtitle: "Use the selected profile's root model_provider and align rollout and SQLite metadata.", + keep: "Backups to keep", + prepare: "Prepare sync" + }, + switchPage: { + title: "Switch Provider", + subtitle: "Update root model_provider and synchronize history in one protected operation.", + provider: "Provider ID", + modelMode: "Model handling", + providerDefault: "Use provider default", + keepModel: "Keep root model", + explicitModel: "Set explicit model", + model: "Model name", + prepare: "Prepare switch" + }, + backups: { + title: "Backups and Restore", + subtitle: "Only managed backup IDs can be restored.", + empty: "No managed backups.", + restoreConfig: "Restore config.toml", + restoreDatabase: "Restore State DB", + restoreSessions: "Restore rollout files", + relocation: "Confirm SQLite Home relocation", + targetProfile: "Relocation target profile", + prepare: "Prepare restore", + pruneKeep: "Keep newest backups", + prune: "Prune older backups", + readOnly: "This build lists managed backups read-only; Restore and Prune are not exposed." + }, + history: { + title: "History", + subtitle: "Session bodies load only after you explicitly open a session.", + empty: "No sessions found.", + untitled: "Untitled session", + open: "Open session", + back: "Back to sessions", + messages: "messages", + archived: "Archived", + active: "Active", + pagination: "History pagination", + pageSummary: "Page {{page}} · {{total}} sessions", + previous: "Previous", + next: "Next", + roles: { + user: "You", + assistant: "Assistant" + } + }, + profiles: { + title: "Profiles", + subtitle: "The host resolves paths; Core requests receive only profile IDs and revisions.", + id: "Profile ID", + name: "Name", + codexHome: "Codex Home", + sqliteHome: "SQLite Home (optional)", + create: "Create profile", + update: "Update profile", + defaultManaged: "The default profile is managed by startup flags.", + pathManaged: { + desktop: "Storage paths are retained by the trusted desktop Host.", + web: "Storage paths are retained by the local Web Host." + }, + readOnly: "This build exposes profile IDs and revisions only; profile editing is not enabled." + }, + diagnostics: { + title: "Diagnostics", + subtitle: "Read-only, redacted runtime and safety state.", + runtime: "Runtime", + storage: "Storage", + provider: "Provider", + safety: "Safety", + items: "{{count}} items", + fieldsAvailable: "{{count}} redacted fields", + technicalDetails: "Show technical details", + fields: { + arch: "Architecture", + node: "Node.js", + platform: "Platform", + sqliteHomeSource: "SQLite Home source", + sqliteSupported: "SQLite supported", + stateDbFound: "State DB found", + configured: "Configured Providers", + current: "Current Provider", + implicit: "Implicit Provider", + rolloutCounts: "Rollout distribution", + sqliteCounts: "SQLite distribution", + lockedRolloutCount: "Locked rollouts", + operationInProgress: "Operation in progress", + pendingRecovery: "Recovery required", + pendingTransactions: "Pending transactions", + projectThreadVisibilityAvailable: "Project visibility available", + rolloutScanComplete: "Rollout scan complete", + storageRevision: "Storage revision" + }, + export: "Export redacted bundle", + exporting: "Exporting…", + exportCreated: "Redacted diagnostics bundle created.", + exportCancelled: "Diagnostics export cancelled.", + exportFailed: "Diagnostics export failed." + }, + settings: { + title: "Settings", + subtitle: { + desktop: "Language and theme preferences stay on this device.", + web: "Language and theme preferences stay in this browser; pairing remains managed by the local Web Host." + }, + language: "Language", + theme: "Theme", + system: "System", + light: "Light", + dark: "Dark", + watch: "Watch", + watchStart: "Start watch", + watchStop: "Stop watch", + watchRecoveryBlocked: "Resolve the pending recovery before starting Watch.", + update: "Updates", + updateStatus: { + disabled: "Unavailable", + idle: "Ready to check", + checking: "Checking", + available: "Update available", + downloading: "Downloading", + downloaded: "Ready to install", + "not-available": "Up to date", + error: "Update failed", + installing: "Restarting to install" + }, + updateReason: { + "not-packaged": "Update checks are available only in a packaged build.", + "not-authorized": "This candidate build is not authorized to use a production update channel.", + "not-configured": "No release update channel is configured.", + "unsupported-target": "Updates are not supported for this platform target.", + "check-failed": "The update check failed without affecting Core operations.", + "download-failed": "The update download failed without affecting Core operations.", + "install-failed": "The installer could not be started; the current version remains active." + }, + updateBlocked: { + "write-in-progress": "An update cannot be installed while a protected operation is running.", + "watch-active": "Stop Watch before installing an update.", + "pending-recovery": "An update cannot be installed while a transaction requires recovery.", + "recovery-unverified": "Recovery state could not be verified; installation remains blocked." + }, + updateVersion: "Version {{version}}", + updateProgress: "{{percent}}% downloaded", + updateCheck: "Check for updates", + updateDownload: "Download update", + updateInstall: "Restart and install", + forget: "Forget this browser", + englishFallback: "English fallback", + forgetHint: "Pairing credentials are removed by the local host." + }, + plan: { + title: "Review plan", + operations: { + sync: "Sync Provider metadata", + switch: "Switch Provider", + restore: "Restore backup", + operation: "Protected operation" + }, + modelModes: { + "provider-default": "Use Provider default model", + "keep-root-model": "Keep root model", + explicit: "Use explicit model" + }, + fields: { + modelMode: "Model handling", + restoreConfig: "Restore config.toml", + restoreDatabase: "Restore State DB", + restoreSessions: "Restore rollout files", + relocation: "SQLite Home relocation", + rolloutFiles: "Rollout files affected", + sqliteRows: "SQLite rows affected", + workspaceRoots: "Workspace roots affected", + stateDbFiles: "State DB files affected", + configFiles: "Config files affected", + lockedRollouts: "Currently locked rollouts" + }, + stages: { + scan_rollout_files: "Scan rollout files", + check_locked_rollout_files: "Check locked rollouts", + create_backup: "Create managed backup", + rewrite_rollout_files: "Update rollout files", + update_sqlite: "Update SQLite metadata", + update_config: "Update config.toml", + clean_backups: "Clean old backups", + create_restore_pre_snapshot: "Create pre-restore snapshot", + persist_restore_journal: "Persist Restore journal", + apply_restore_targets: "Restore selected targets", + commit_restore: "Commit Restore", + acknowledge_restore_commit: "Acknowledge Restore commit", + rollback_restore: "Roll back Restore" + }, + statuses: { + start: "Starting", + progress: "In progress", + complete: "Completed" + }, + target: "Target", + impact: "Impact", + expires: "Expires", + items: "{{count}} items", + backupExpected: "A backup will be created before writes.", + exactApply: "Apply sends only this one-time plan ID.", + writeBlocked: "Another protected operation or recovery state currently blocks confirmation.", + technicalDetails: "Technical details", + progress: "Operation progress", + starting: "Starting protected operation…", + cancelOperation: "Cancel operation", + cancelling: "Cancelling…", + cancelPending: "Cancellation will take effect at the next safe point." + }, + operationResult: { + title: "Operation result", + operationId: "Operation ID", + backupId: "Managed backup ID", + skippedRollouts: "Skipped locked rollout files", + resolveBeforeClose: "Resolve the pending recovery before closing this result.", + fields: { + targetProvider: "Target Provider", + targetModel: "Target model", + modelSource: "Model source", + restoreOperationId: "Restore operation ID", + preRestoreSnapshotId: "Pre-restore snapshot ID", + restoreJournalState: "Restore journal state", + backupDurationMs: "Backup duration (ms)", + changedSessionFiles: "Rollout files changed", + sqliteRowsUpdated: "SQLite rows updated", + sqliteProviderRowsUpdated: "Provider rows updated", + sqliteUserEventRowsUpdated: "User-event rows updated", + sqliteCwdRowsUpdated: "Workspace rows updated", + updatedWorkspaceRoots: "Workspace roots updated", + savedWorkspaceRootCount: "Saved workspace roots", + restoreVersion: "Restore format version", + resolvedOperationCount: "Resolved operations", + commitAcknowledgementRecovered: "Commit acknowledgement recovered" + }, + completed: { + title: "Completed", + description: "The protected operation reached a durable completed state." + }, + partial: { + title: "Partially completed", + description: "Committed changes are durable, but one or more locked rollout files were skipped." + }, + failedRolledBack: { + title: "Failed and rolled back", + description: "The operation failed, and the previous state was restored successfully." + }, + recoveryRequired: { + title: "Recovery required", + description: "A durable journal remains unresolved. Further writes stay blocked until recovery is completed." + }, + cancelled: { + title: "Cancelled", + description: "The operation stopped at a safe cancellation point." + }, + stale: { + title: "Plan became stale", + description: "Protected state changed after planning. Review a newly prepared plan before retrying." + } + }, + validation: { + required: "This field is required.", + keep: "Use a whole number from 1 to 1000.", + provider: "Enter a valid Provider ID.", + model: "Enter a model name for explicit mode.", + restore: "Select at least one item to restore.", + profileId: "Use letters, numbers, dots, underscores, or hyphens.", + path: "Enter an absolute path." + } + } + }, + "zh-CN": { + translation: { + brand: { + desktop: { + label: "桌面端", + subtitle: "V1 新版主桌面端候选 · .NET 交接后 Legacy fallback 目标" + }, + web: { + label: "Web", + subtitle: "本地 Web companion" + } + }, + a11y: { + skipToContent: "跳到主要内容", + profile: "存储配置", + primaryNavigation: "主导航" + }, + nav: { + overview: "概览", + sync: "同步", + switchProvider: "切换 Provider", + backupsRestore: "备份 / 恢复", + history: "聊天记录", + profiles: "存储配置", + diagnostics: "诊断", + settings: "设置" + }, + common: { + refresh: "刷新", + loading: "正在加载…", + cancel: "取消", + confirm: "确认并执行", + save: "保存", + delete: "删除", + close: "关闭", + yes: "是", + no: "否", + none: "无", + unknown: "未知", + current: "当前", + provider: "Provider", + model: "模型", + status: "状态", + warnings: "警告", + retry: "重试" + }, + global: { + ready: "本地服务就绪", + busy: "操作执行中", + recovery: "存在待恢复事务;在明确恢复前已禁用写操作。", + stale: "受保护状态已变化,请重新生成计划。", + unexpected: "页面遇到未预期错误。", + partial: "操作完成,但跳过了仍被锁定的 rollout 文件。", + completed: "操作已完成。", + cancelled: "操作已取消。", + profileChanged: "存储配置已变化。", + profileChangedHint: "请检查当前存储配置,然后重新生成操作计划。", + failed: "操作失败。" + }, + overview: { + title: "Provider 元数据总览", + subtitle: "比较 rollout 文件、SQLite 线程索引与当前存储配置。", + alignment: "对齐状态", + aligned: "已对齐", + notAligned: "需要处理", + rollout: "Rollout 元数据", + sqlite: "SQLite 元数据", + codexHomeSource: "Codex Home 来源", + sqliteHomeSource: "SQLite Home 来源", + snapshot: "快照时间", + backupCount: "受管备份", + locked: "锁定的 rollout" + }, + sync: { + title: "同步当前 Provider", + subtitle: "读取当前配置的根 model_provider,并对齐 rollout 与 SQLite 元数据。", + keep: "保留备份数量", + prepare: "生成同步计划" + }, + switchPage: { + title: "切换 Provider", + subtitle: "在一次受保护操作中修改根 model_provider 并同步历史。", + provider: "Provider ID", + modelMode: "模型处理方式", + providerDefault: "使用 Provider 默认模型", + keepModel: "保留根模型", + explicitModel: "显式设置模型", + model: "模型名称", + prepare: "生成切换计划" + }, + backups: { + title: "备份与恢复", + subtitle: "恢复只能使用服务端管理的 backupId。", + empty: "暂无受管备份。", + restoreConfig: "恢复 config.toml", + restoreDatabase: "恢复 State DB", + restoreSessions: "恢复 rollout 文件", + relocation: "确认 SQLite Home 迁移", + targetProfile: "迁移目标配置", + prepare: "生成恢复计划", + pruneKeep: "保留最新备份数", + prune: "清理旧备份", + readOnly: "此构建仅只读列出受管备份;未开放恢复和清理。" + }, + history: { + title: "聊天记录", + subtitle: "只有在你明确打开会话后才加载消息正文。", + empty: "没有找到会话。", + untitled: "未命名会话", + open: "打开会话", + back: "返回会话列表", + messages: "条消息", + archived: "已归档", + active: "活动", + pagination: "聊天记录分页", + pageSummary: "第 {{page}} 页 · 共 {{total}} 个会话", + previous: "上一页", + next: "下一页", + roles: { + user: "你", + assistant: "助手" + } + }, + profiles: { + title: "存储配置", + subtitle: "路径由 Host 可信解析;Core 请求只携带配置 ID 与 revision。", + id: "配置 ID", + name: "名称", + codexHome: "Codex Home", + sqliteHome: "SQLite Home(可选)", + create: "新建配置", + update: "更新配置", + defaultManaged: "默认配置由启动参数管理。", + pathManaged: { + desktop: "存储路径仅由可信桌面 Host 持有。", + web: "存储路径仅由本地 Web Host 持有。" + }, + readOnly: "此构建只公开配置 ID 与 revision;未开放配置编辑。" + }, + diagnostics: { + title: "诊断", + subtitle: "只读展示经脱敏的运行时与安全状态。", + runtime: "运行时", + storage: "存储", + provider: "Provider", + safety: "安全状态", + items: "{{count}} 项", + fieldsAvailable: "{{count}} 个脱敏字段", + technicalDetails: "显示技术详情", + fields: { + arch: "架构", + node: "Node.js", + platform: "平台", + sqliteHomeSource: "SQLite Home 来源", + sqliteSupported: "SQLite 支持状态", + stateDbFound: "State DB 是否存在", + configured: "已配置 Provider", + current: "当前 Provider", + implicit: "隐式 Provider", + rolloutCounts: "Rollout 分布", + sqliteCounts: "SQLite 分布", + lockedRolloutCount: "锁定的 rollout", + operationInProgress: "执行中的操作", + pendingRecovery: "需要恢复", + pendingTransactions: "待处理事务", + projectThreadVisibilityAvailable: "项目可见性可用", + rolloutScanComplete: "Rollout 扫描完成", + storageRevision: "存储 revision" + }, + export: "导出脱敏诊断包", + exporting: "正在导出…", + exportCreated: "脱敏诊断包已创建。", + exportCancelled: "已取消诊断导出。", + exportFailed: "诊断导出失败。" + }, + settings: { + title: "设置", + subtitle: { + desktop: "语言和主题偏好仅保存在此设备。", + web: "语言和主题偏好仅保存在此浏览器;配对仍由本地 Web Host 管理。" + }, + language: "语言", + theme: "主题", + system: "跟随系统", + light: "浅色", + dark: "深色", + watch: "监视", + watchStart: "启动监视", + watchStop: "停止监视", + watchRecoveryBlocked: "请先解决待恢复事务,再启动监视。", + update: "更新", + updateStatus: { + disabled: "不可用", + idle: "可检查更新", + checking: "正在检查", + available: "发现新版本", + downloading: "正在下载", + downloaded: "可安装", + "not-available": "已是最新版本", + error: "更新失败", + installing: "正在重启安装" + }, + updateReason: { + "not-packaged": "仅打包后的应用可检查更新。", + "not-authorized": "此候选构建未获生产更新通道授权。", + "not-configured": "尚未配置正式 Release 更新通道。", + "unsupported-target": "当前平台目标不支持应用内更新。", + "check-failed": "检查更新失败,不会影响 Core 操作。", + "download-failed": "下载更新失败,不会影响 Core 操作。", + "install-failed": "无法启动安装程序,当前版本仍保持可用。" + }, + updateBlocked: { + "write-in-progress": "受保护操作运行期间不能安装更新。", + "watch-active": "请先停止监视,再安装更新。", + "pending-recovery": "存在待恢复事务时不能安装或重启更新。", + "recovery-unverified": "无法确认所有 Profile 的恢复状态,已阻止安装。" + }, + updateVersion: "版本 {{version}}", + updateProgress: "已下载 {{percent}}%", + updateCheck: "检查更新", + updateDownload: "下载更新", + updateInstall: "重启并安装", + forget: "忘记此浏览器", + englishFallback: "英文为兜底语言", + forgetHint: "配对凭据将由本地 Host 删除。" + }, + plan: { + title: "审核计划", + operations: { + sync: "同步 Provider 元数据", + switch: "切换 Provider", + restore: "恢复备份", + operation: "受保护操作" + }, + modelModes: { + "provider-default": "使用 Provider 默认模型", + "keep-root-model": "保留根模型", + explicit: "使用显式模型" + }, + fields: { + modelMode: "模型处理方式", + restoreConfig: "恢复 config.toml", + restoreDatabase: "恢复 State DB", + restoreSessions: "恢复 rollout 文件", + relocation: "SQLite Home 迁移", + rolloutFiles: "受影响的 rollout 文件", + sqliteRows: "受影响的 SQLite 行", + workspaceRoots: "受影响的工作区根目录", + stateDbFiles: "受影响的 State DB 文件", + configFiles: "受影响的配置文件", + lockedRollouts: "当前锁定的 rollout" + }, + stages: { + scan_rollout_files: "扫描 rollout 文件", + check_locked_rollout_files: "检查锁定的 rollout", + create_backup: "创建受管备份", + rewrite_rollout_files: "更新 rollout 文件", + update_sqlite: "更新 SQLite 元数据", + update_config: "更新 config.toml", + clean_backups: "清理旧备份", + create_restore_pre_snapshot: "创建恢复前快照", + persist_restore_journal: "持久化 Restore journal", + apply_restore_targets: "恢复所选目标", + commit_restore: "提交恢复", + acknowledge_restore_commit: "确认恢复提交", + rollback_restore: "回滚恢复" + }, + statuses: { + start: "正在开始", + progress: "执行中", + complete: "已完成" + }, + target: "目标", + impact: "影响", + expires: "失效时间", + items: "{{count}} 项", + backupExpected: "写入前会先创建备份。", + exactApply: "执行时只提交此一次性 planId。", + writeBlocked: "当前存在其他受保护操作或待恢复状态,暂不能确认执行。", + technicalDetails: "技术详情", + progress: "操作进度", + starting: "正在启动受保护操作…", + cancelOperation: "取消操作", + cancelling: "正在取消…", + cancelPending: "取消将在下一个安全点生效。" + }, + operationResult: { + title: "操作结果", + operationId: "操作 ID", + backupId: "受管备份 ID", + skippedRollouts: "跳过的锁定 rollout 文件", + resolveBeforeClose: "请先完成待处理的恢复,再关闭此结果。", + fields: { + targetProvider: "目标 Provider", + targetModel: "目标模型", + modelSource: "模型来源", + restoreOperationId: "恢复操作 ID", + preRestoreSnapshotId: "恢复前快照 ID", + restoreJournalState: "恢复 journal 状态", + backupDurationMs: "备份耗时(毫秒)", + changedSessionFiles: "已修改 rollout 文件", + sqliteRowsUpdated: "已更新 SQLite 行", + sqliteProviderRowsUpdated: "已更新 Provider 行", + sqliteUserEventRowsUpdated: "已更新用户事件行", + sqliteCwdRowsUpdated: "已更新工作区行", + updatedWorkspaceRoots: "已更新工作区根目录", + savedWorkspaceRootCount: "已保存工作区根目录", + restoreVersion: "恢复格式版本", + resolvedOperationCount: "已解决操作数", + commitAcknowledgementRecovered: "已恢复提交确认" + }, + completed: { + title: "已完成", + description: "受保护操作已进入耐久的完成状态。" + }, + partial: { + title: "部分完成", + description: "已提交的更改已持久化,但仍有一个或多个锁定的 rollout 文件被跳过。" + }, + failedRolledBack: { + title: "失败并已回滚", + description: "操作失败,且先前状态已成功恢复。" + }, + recoveryRequired: { + title: "需要恢复", + description: "仍有未解决的耐久 journal;完成恢复前将继续阻止写操作。" + }, + cancelled: { + title: "已取消", + description: "操作已在安全取消点停止。" + }, + stale: { + title: "计划已失效", + description: "生成计划后受保护状态发生变化;重试前请重新生成并审核计划。" + } + }, + validation: { + required: "此项必填。", + keep: "请输入 1 到 1000 的整数。", + provider: "请输入有效的 Provider ID。", + model: "显式模式必须填写模型名称。", + restore: "至少选择一种恢复内容。", + profileId: "只能使用字母、数字、点、下划线或连字符。", + path: "请输入绝对路径。" + } + } + } +} as const; + +export async function createAppI18n(locale: SupportedLocale): Promise { + const instance = i18next.createInstance(); + await instance.init({ + resources, + lng: locale, + fallbackLng: "en", + interpolation: { escapeValue: false }, + returnNull: false + }); + return instance; +} + +export function resourcesHaveMatchingKeys(): boolean { + const flatten = (value: Record, prefix = ""): string[] => Object.entries(value).flatMap(([key, entry]) => { + const path = prefix ? `${prefix}.${key}` : key; + return entry && typeof entry === "object" + ? flatten(entry as Record, path) + : [path]; + }); + const english = flatten(resources.en.translation as unknown as Record).sort(); + const chinese = flatten(resources["zh-CN"].translation as unknown as Record).sort(); + return english.length === chinese.length && english.every((key, index) => key === chinese[index]); +} diff --git a/packages/app-ui/src/index.ts b/packages/app-ui/src/index.ts new file mode 100644 index 0000000..d74f23c --- /dev/null +++ b/packages/app-ui/src/index.ts @@ -0,0 +1,23 @@ +export { AppUi } from "./App.js"; +export { APP_ROUTES, type AppRoute } from "./routes.js"; +export { createAppI18n, resources, resourcesHaveMatchingKeys } from "./i18n.js"; +export { profileSchema, restoreSchema, switchSchema, syncSchema } from "./schemas.js"; +export { + FULL_APP_UI_CAPABILITIES, + READ_ONLY_APP_UI_CAPABILITIES, + SYNC_SWITCH_APP_UI_CAPABILITIES, + DESKTOP_C8_APP_UI_CAPABILITIES +} from "./types.js"; +export type { + AppUiCapabilities, + AppUiProps, + AppUiSurface, + HostClient, + HostDiagnosticsExportResult, + HostProfile, + HostUpdateStatus, + PreferenceStore, + SaveProfileInput +} from "./types.js"; + +export const APP_UI_MIGRATION_STATE = "shared-ui-c5" as const; diff --git a/packages/app-ui/src/routes.ts b/packages/app-ui/src/routes.ts new file mode 100644 index 0000000..eb33af7 --- /dev/null +++ b/packages/app-ui/src/routes.ts @@ -0,0 +1,12 @@ +export const APP_ROUTES = [ + "overview", + "sync", + "switch-provider", + "backups-restore", + "history", + "profiles", + "diagnostics", + "settings" +] as const; + +export type AppRoute = typeof APP_ROUTES[number]; diff --git a/packages/app-ui/src/schemas.ts b/packages/app-ui/src/schemas.ts new file mode 100644 index 0000000..dc5325e --- /dev/null +++ b/packages/app-ui/src/schemas.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +export const keepCountSchema = z.number().int().min(1).max(1000); + +export const syncSchema = z.object({ + keepCount: keepCountSchema +}); + +export const switchSchema = z.object({ + provider: z.string().trim().min(1).max(200).regex(/^[A-Za-z0-9._-]+$/), + modelMode: z.enum(["provider-default", "keep-root-model", "explicit"]), + model: z.string().trim().max(500).optional(), + keepCount: keepCountSchema +}).superRefine((value, context) => { + if (value.modelMode === "explicit" && !value.model) { + context.addIssue({ code: "custom", path: ["model"], message: "model-required" }); + } + if (value.modelMode !== "explicit" && value.model) { + context.addIssue({ code: "custom", path: ["model"], message: "model-not-accepted" }); + } +}); + +export const restoreSchema = z.object({ + backupId: z.string().trim().min(1).max(300), + restoreConfig: z.boolean(), + restoreDatabase: z.boolean(), + restoreSessions: z.boolean(), + allowSqliteHomeRelocation: z.boolean(), + relocationTargetProfileId: z.string().trim().max(80).optional() +}).superRefine((value, context) => { + if (!value.restoreConfig && !value.restoreDatabase && !value.restoreSessions) { + context.addIssue({ code: "custom", path: ["restoreSessions"], message: "restore-required" }); + } + if (value.allowSqliteHomeRelocation && (!value.relocationTargetProfileId || value.restoreConfig)) { + context.addIssue({ code: "custom", path: ["relocationTargetProfileId"], message: "relocation-invalid" }); + } +}); + +const absolutePath = z.string().trim().min(1).max(4096).refine( + (value) => /^(?:[A-Za-z]:[\\/]|\\\\|\/)/.test(value), + "absolute-path-required" +); + +export const profileSchema = z.object({ + profileId: z.string().trim().min(1).max(80).regex(/^[A-Za-z0-9._-]+$/), + name: z.string().trim().min(1).max(120), + codexHome: absolutePath, + sqliteHome: z.union([absolutePath, z.literal("")]).optional() +}); diff --git a/packages/app-ui/src/shared/presentation.tsx b/packages/app-ui/src/shared/presentation.tsx new file mode 100644 index 0000000..ac07b83 --- /dev/null +++ b/packages/app-ui/src/shared/presentation.tsx @@ -0,0 +1,84 @@ +import type { ProfileSelector } from "@codex-provider-sync/contracts"; +import { CoreClientError } from "@codex-provider-sync/core-client"; +import type { ReactNode, Ref } from "react"; + +import type { HostProfile } from "../types.js"; +import { cn } from "../ui.js"; + +export function profileSelector(profile: HostProfile): ProfileSelector { + return { profileId: profile.id, profileRevision: profile.revision }; +} + +export function formatBytes(bytes: number): string { + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = Number.isFinite(bytes) ? bytes : 0; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return unit === 0 + ? `${value} ${units[unit]}` + : `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`; +} + +export function formatDate(value?: string | null, locale = "en"): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return "—"; + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "medium" + }).format(date); +} + +export function safeErrorText(error: unknown, fallback: string): string { + if (error instanceof CoreClientError) return `${error.dto.message} (${error.code})`; + return fallback; +} + +export function PageHeading({ + title, + subtitle, + action, + headingRef, + headingTabIndex +}: { + title: string; + subtitle: string; + action?: ReactNode; + headingRef?: Ref; + headingTabIndex?: number; +}) { + return ( +
+
+

{title}

+

{subtitle}

+
+ {action} +
+ ); +} + +export function KeyValue({ + label, + value, + mono = false +}: { + label: string; + value: ReactNode; + mono?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/packages/app-ui/src/types.ts b/packages/app-ui/src/types.ts new file mode 100644 index 0000000..d599828 --- /dev/null +++ b/packages/app-ui/src/types.ts @@ -0,0 +1,154 @@ +import type { CoreClient } from "@codex-provider-sync/core-client"; +import type { SupportedLocale, ThemeMode } from "@codex-provider-sync/design-system"; + +export interface HostProfile { + id: string; + name: string; + revision: string; + codexHome?: string; + sqliteHome?: string | null; + codexHomeConfigured?: boolean; + sqliteHomeConfigured?: boolean; +} + +export interface SaveProfileInput { + profileId: string; + profileRevision?: string; + name: string; + codexHome: string; + sqliteHome?: string; +} + +export interface HostClient { + listProfiles(signal?: AbortSignal): Promise; + saveProfile?(input: SaveProfileInput, signal?: AbortSignal): Promise; + deleteProfile?(profileId: string, profileRevision: string, signal?: AbortSignal): Promise; + forgetBrowser?(): Promise; + exportDiagnostics?( + profile: { profileId: string; profileRevision?: string }, + signal?: AbortSignal + ): Promise; + getUpdateStatus?(signal?: AbortSignal): Promise; + checkForUpdates?(signal?: AbortSignal): Promise; + downloadUpdate?(signal?: AbortSignal): Promise; + installUpdate?(signal?: AbortSignal): Promise; +} + +export type HostDiagnosticsExportResult = + | { status: "created" } + | { status: "cancelled" } + | { status: "failed" }; + +export interface HostUpdateStatus { + state: + | "disabled" + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded" + | "not-available" + | "error" + | "installing"; + reason?: + | "not-packaged" + | "not-authorized" + | "not-configured" + | "unsupported-target" + | "check-failed" + | "download-failed" + | "install-failed"; + version?: string; + progressPercent?: number; + installBlockedReason?: + | "write-in-progress" + | "watch-active" + | "pending-recovery" + | "recovery-unverified"; + installAllowed: boolean; +} + +export interface AppUiCapabilities { + sync: boolean; + switchProvider: boolean; + restore: boolean; + pruneBackups: boolean; + watch: boolean; + manageProfiles: boolean; + revealProfilePaths: boolean; + forgetBrowser: boolean; + exportDiagnostics: boolean; + viewUpdateStatus: boolean; +} + +export const FULL_APP_UI_CAPABILITIES: Readonly = Object.freeze({ + sync: true, + switchProvider: true, + restore: true, + pruneBackups: true, + watch: true, + manageProfiles: true, + revealProfilePaths: true, + forgetBrowser: true, + exportDiagnostics: true, + viewUpdateStatus: true +}); + +export const READ_ONLY_APP_UI_CAPABILITIES: Readonly = Object.freeze({ + sync: false, + switchProvider: false, + restore: false, + pruneBackups: false, + watch: false, + manageProfiles: false, + revealProfilePaths: false, + forgetBrowser: false, + exportDiagnostics: false, + viewUpdateStatus: false +}); + +export const SYNC_SWITCH_APP_UI_CAPABILITIES: Readonly = Object.freeze({ + sync: true, + switchProvider: true, + restore: false, + pruneBackups: false, + watch: false, + manageProfiles: false, + revealProfilePaths: false, + forgetBrowser: false, + exportDiagnostics: false, + viewUpdateStatus: false +}); + +export const DESKTOP_C8_APP_UI_CAPABILITIES: Readonly = Object.freeze({ + sync: true, + switchProvider: true, + restore: true, + pruneBackups: true, + watch: true, + manageProfiles: false, + revealProfilePaths: false, + forgetBrowser: false, + exportDiagnostics: true, + viewUpdateStatus: true +}); + +export interface PreferenceStore { + getLocale(): SupportedLocale | null; + setLocale(locale: SupportedLocale): void; + getTheme(): ThemeMode | null; + setTheme(theme: ThemeMode): void; +} + +export type AppUiSurface = "desktop" | "web"; + +export interface AppUiProps { + core: CoreClient; + host: HostClient; + surface: AppUiSurface; + capabilities?: Partial; + preferences: PreferenceStore; + initialLocale: SupportedLocale; + initialTheme: ThemeMode; + onForgetBrowser?: () => void | Promise; +} diff --git a/packages/app-ui/src/ui.tsx b/packages/app-ui/src/ui.tsx new file mode 100644 index 0000000..5ebebf0 --- /dev/null +++ b/packages/app-ui/src/ui.tsx @@ -0,0 +1,184 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Slot } from "@radix-ui/react-slot"; +import * as ToastPrimitive from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useMemo, + useState, + type ButtonHTMLAttributes, + type HTMLAttributes, + type InputHTMLAttributes, + type ReactNode +} from "react"; +import { twMerge } from "tailwind-merge"; +import clsx, { type ClassValue } from "clsx"; + +export function cn(...values: ClassValue[]): string { + return twMerge(clsx(values)); +} + +const buttonVariants = cva( + "inline-flex min-h-[var(--control-height)] items-center justify-center gap-[var(--space-2)] rounded-[var(--radius-control)] px-[var(--space-4)] [font-size:var(--text-sm)] leading-[var(--leading-tight)] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: "bg-[var(--accent)] text-white hover:bg-[var(--accent-strong)]", + secondary: "border border-[var(--border)] bg-[var(--surface-raised)] text-[var(--text)] hover:bg-[var(--surface-hover)]", + danger: "bg-[var(--danger)] text-white hover:brightness-95", + ghost: "text-[var(--muted)] hover:bg-[var(--surface-hover)] hover:text-[var(--text)]" + }, + size: { + default: "h-[var(--control-height)]", + compact: "h-9 min-h-9 px-[var(--space-3)]", + icon: "h-10 w-10 px-0" + } + }, + defaultVariants: { variant: "primary", size: "default" } + } +); + +export interface ButtonProps extends ButtonHTMLAttributes, VariantProps { + asChild?: boolean; +} + +export const Button = forwardRef(function Button( + { asChild = false, className, variant, size, ...props }, + ref +) { + const Component = asChild ? Slot : "button"; + return ; +}); + +export const Card = forwardRef>(function Card( + { className, ...props }, + ref +) { + return
; +}); + +export const Input = forwardRef>(function Input( + { className, ...props }, + ref +) { + return ( + + ); +}); + +export function Field({ label, error, hint, children }: { label: string; error?: string; hint?: string; children: ReactNode }) { + return ( + + ); +} + +export function Badge({ tone = "neutral", children }: { tone?: "neutral" | "success" | "warning" | "danger"; children: ReactNode }) { + const tones = { + neutral: "bg-[var(--surface-hover)] text-[var(--muted)]", + success: "bg-[var(--success-soft)] text-[var(--success)]", + warning: "bg-[var(--warning-soft)] text-[var(--warning)]", + danger: "bg-[var(--danger-soft)] text-[var(--danger)]" + }; + return {children}; +} + +export function Dialog({ + open, + onOpenChange, + restoreFocus, + title, + description, + children, + footer, + closeLabel, + closeDisabled = false +}: { + open: boolean; + onOpenChange(open: boolean): void; + restoreFocus?(): void; + title: string; + description?: string; + children: ReactNode; + footer?: ReactNode; + closeLabel: string; + closeDisabled?: boolean; +}) { + return ( + + + + { + if (!restoreFocus) return; + event.preventDefault(); + restoreFocus(); + }} + > +
+ {title} + {description ? {description} : null} +
+ + + +
{children}
+ {footer ?
{footer}
: null} +
+
+
+ ); +} + +interface ToastItem { id: number; title: string; description?: string; tone: "success" | "warning" | "danger"; } +interface ToastContextValue { push(item: Omit): void; } +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + const push = useCallback((item: Omit) => { + const id = Date.now() + Math.floor(Math.random() * 1000); + setItems((current) => [...current, { ...item, id }]); + }, []); + const value = useMemo(() => ({ push }), [push]); + return ( + + + {children} + {items.map((item) => ( + { if (!open) setItems((current) => current.filter((entry) => entry.id !== item.id)); }} + > + {item.title} + {item.description ? {item.description} : null} + + ))} + + + + ); +} + +export function useToast(): ToastContextValue { + const value = useContext(ToastContext); + if (!value) throw new Error("useToast must be used inside ToastProvider."); + return value; +} diff --git a/packages/app-ui/tests/app-error-boundary.vitest.tsx b/packages/app-ui/tests/app-error-boundary.vitest.tsx new file mode 100644 index 0000000..10a2d90 --- /dev/null +++ b/packages/app-ui/tests/app-error-boundary.vitest.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AppErrorBoundary } from "../src/app/AppErrorBoundary.js"; + +function Thrower(): never { + throw new Error("expected render failure"); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("AppErrorBoundary", () => { + it.each([ + ["en", "Application error", "The page encountered an unexpected error. No write was started automatically.", "Reload"], + ["zh-CN", "应用错误", "页面遇到未预期错误;系统没有自动启动任何写操作。", "重新加载"] + ])("renders the %s fail-closed recovery surface", (locale, heading, message, reload) => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + locale}> + + + ); + + expect(screen.getByRole("heading", { name: heading })).toBeVisible(); + expect(screen.getByText(message)).toBeVisible(); + expect(screen.getByRole("button", { name: reload })).toBeEnabled(); + }); +}); diff --git a/packages/app-ui/tests/app-i18n-a11y.vitest.tsx b/packages/app-ui/tests/app-i18n-a11y.vitest.tsx new file mode 100644 index 0000000..5dc72e6 --- /dev/null +++ b/packages/app-ui/tests/app-i18n-a11y.vitest.tsx @@ -0,0 +1,84 @@ +import { MockCoreClient } from "@codex-provider-sync/core-client"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { AppUi } from "../src/App.js"; +import { resourcesHaveMatchingKeys } from "../src/i18n.js"; +import { statusFor } from "./helpers/app-fixtures.js"; + +describe("App localization and keyboard navigation", () => { + it("supports skip navigation, keyboard routes, and an in-place locale change", async () => { + const setLocale = vi.fn(); + const core = new MockCoreClient({ getStatus: async () => statusFor() }); + const user = userEvent.setup(); + + render( + [{ id: "default", name: "Default", revision: "profile-r1" }] }} + initialLocale="en" + initialTheme="system" + preferences={{ + getLocale: () => "en", + setLocale, + getTheme: () => "system", + setTheme: vi.fn() + }} + surface="desktop" + /> + ); + + await screen.findByRole("button", { name: "Overview", exact: true }); + expect(screen.getByText("V1 primary desktop candidate · .NET post-handoff Legacy target")).toBeVisible(); + await user.tab(); + const skipLink = screen.getByRole("link", { name: "Skip to content" }); + expect(skipLink).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(document.getElementById("main-content")).toHaveFocus(); + + const syncRoute = screen.getByRole("button", { name: "Sync", exact: true }); + syncRoute.focus(); + await user.keyboard("{Enter}"); + expect(await screen.findByRole("heading", { name: "Sync current Provider" })).toBeVisible(); + expect(syncRoute).toHaveAttribute("aria-current", "page"); + + const settingsRoute = screen.getByRole("button", { name: "Settings", exact: true }); + settingsRoute.focus(); + await user.keyboard("{Enter}"); + const language = await screen.findByRole("combobox", { name: "Language" }); + await user.selectOptions(language, "zh-CN"); + + expect(setLocale).toHaveBeenCalledWith("zh-CN"); + await waitFor(() => expect(document.documentElement.lang).toBe("zh-CN")); + expect(await screen.findByRole("button", { name: "设置", exact: true })).toHaveAttribute("aria-current", "page"); + expect(resourcesHaveMatchingKeys()).toBe(true); + }); + + it("renders Web-only host copy without desktop Legacy messaging", async () => { + const core = new MockCoreClient({ getStatus: async () => statusFor() }); + const user = userEvent.setup(); + render( + [{ id: "default", name: "Default", revision: "profile-r1" }] }} + initialLocale="en" + initialTheme="system" + preferences={{ + getLocale: () => "en", + setLocale: vi.fn(), + getTheme: () => "system", + setTheme: vi.fn() + }} + surface="web" + /> + ); + + expect(await screen.findByText("Local Web companion")).toBeVisible(); + expect(screen.queryByText(/Legacy fallback/)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Settings", exact: true })); + expect(await screen.findByText("Language and theme preferences stay in this browser; pairing remains managed by the local Web Host.")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Profiles", exact: true })); + expect(await screen.findByText("Storage paths are retained by the local Web Host.")).toBeVisible(); + }); +}); diff --git a/packages/app-ui/tests/app-operation-lifecycle.vitest.tsx b/packages/app-ui/tests/app-operation-lifecycle.vitest.tsx new file mode 100644 index 0000000..31be387 --- /dev/null +++ b/packages/app-ui/tests/app-operation-lifecycle.vitest.tsx @@ -0,0 +1,217 @@ +import { + createCoreOperationStartedEnvelope, + createCoreProgressEnvelope, + type PlanSummary +} from "@codex-provider-sync/contracts"; +import { MockCoreClient } from "@codex-provider-sync/core-client"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { AppUi } from "../src/App.js"; +import { statusFor, syncPlanFor } from "./helpers/app-fixtures.js"; + +const preferences = { + getLocale: () => "en" as const, + setLocale: vi.fn(), + getTheme: () => "system" as const, + setTheme: vi.fn() +}; + +describe("App operation lifecycle", () => { + it("refreshes a profile that changes while preparing and retries with the new revision", async () => { + let profileRevision = "profile-r1"; + let prepareAttempts = 0; + const preparedRevisions: Array = []; + const host = { + listProfiles: vi.fn(async () => [{ id: "default", name: "Default", revision: profileRevision }]) + }; + const core = new MockCoreClient({ + getStatus: async ({ profile }) => statusFor(profile.profileRevision ?? profileRevision), + prepareSync: async ({ profile }) => { + preparedRevisions.push(profile.profileRevision); + prepareAttempts += 1; + if (prepareAttempts === 1) { + profileRevision = "profile-r2"; + throw { code: "PROFILE_CHANGED" }; + } + return syncPlanFor(profile.profileRevision, "plan-sync-retry"); + } + }); + const user = userEvent.setup(); + + render( + + ); + + await user.click(await screen.findByRole("button", { name: "Sync", exact: true })); + const prepareButton = await screen.findByRole("button", { name: "Prepare sync" }); + await waitFor(() => expect(prepareButton).toBeEnabled()); + await user.click(prepareButton); + + expect(await screen.findByText("Profile changed.")).toBeVisible(); + await waitFor(() => expect(host.listProfiles).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(prepareButton).toBeEnabled()); + await user.click(prepareButton); + + expect(await screen.findByRole("dialog", { name: "Review plan" })).toBeVisible(); + expect(preparedRevisions).toEqual(["profile-r1", "profile-r2"]); + }); + + it("recovers a Status poll after the selected profile revision changes externally", async () => { + let profileRevision = "profile-r1"; + const statusRevisions: Array = []; + const host = { + listProfiles: vi.fn(async () => [{ id: "default", name: "Default", revision: profileRevision }]) + }; + const core = new MockCoreClient({ + getStatus: async ({ profile }) => { + statusRevisions.push(profile.profileRevision); + if (profile.profileRevision === "profile-r1") { + profileRevision = "profile-r2"; + throw { code: "PROFILE_CHANGED" }; + } + return statusFor(profile.profileRevision); + } + }); + const user = userEvent.setup(); + + render( + + ); + + expect(await screen.findByText("Profile changed.", {}, { timeout: 4000 })).toBeVisible(); + await waitFor(() => expect(host.listProfiles).toHaveBeenCalledTimes(2)); + await user.click(screen.getByRole("button", { name: "Sync", exact: true })); + await waitFor(() => expect(screen.getByRole("button", { name: "Prepare sync" })).toBeEnabled()); + expect(statusRevisions.at(-1)).toBe("profile-r2"); + }); + + it("refreshes a changed profile, closes the stale plan, and prepares with the new revision", async () => { + let profileRevision = "profile-r1"; + let planSequence = 0; + const listedRevisions: string[] = []; + const preparedRevisions: Array = []; + const host = { + listProfiles: vi.fn(async () => { + listedRevisions.push(profileRevision); + return [{ id: "default", name: "Default", revision: profileRevision }]; + }) + }; + const core = new MockCoreClient({ + getStatus: async ({ profile }) => statusFor(profile.profileRevision ?? profileRevision), + prepareSync: async ({ profile }): Promise => { + preparedRevisions.push(profile.profileRevision); + planSequence += 1; + return syncPlanFor(profile.profileRevision, `plan-sync-${planSequence}`); + }, + applySync: async () => { + profileRevision = "profile-r2"; + throw { code: "STALE_STATE", details: { reason: "profile" } }; + } + }); + const user = userEvent.setup(); + + render( + + ); + + await user.click(await screen.findByRole("button", { name: "Sync", exact: true })); + const prepareButton = await screen.findByRole("button", { name: "Prepare sync" }); + await waitFor(() => expect(prepareButton).toBeEnabled()); + await user.click(prepareButton); + await user.click(await screen.findByRole("button", { name: "Confirm and apply" })); + + expect(await screen.findByText("Profile changed.")).toBeVisible(); + expect(screen.getByText("Review the current profile and prepare the operation again.")).toBeVisible(); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Review plan" })).not.toBeInTheDocument()); + await waitFor(() => expect(prepareButton).toHaveFocus()); + expect(host.listProfiles).toHaveBeenCalledTimes(2); + expect(listedRevisions).toEqual(["profile-r1", "profile-r2"]); + + await waitFor(() => expect(prepareButton).toBeEnabled()); + await user.click(prepareButton); + expect(await screen.findByRole("dialog", { name: "Review plan" })).toBeVisible(); + expect(preparedRevisions).toEqual(["profile-r1", "profile-r2"]); + }); + + it("renders trusted progress and cancels through the active AbortSignal", async () => { + const operationId = "11111111-1111-4111-8111-111111111111"; + let rejectApply!: (reason: unknown) => void; + let applySignal: AbortSignal | undefined; + const applyPending = new Promise((_resolve, reject) => { rejectApply = reject; }); + const core = new MockCoreClient({ + getStatus: async () => statusFor(), + prepareSync: async () => syncPlanFor(), + applySync: async (_payload, request, control) => { + applySignal = control.signal; + control.onOperationStarted?.(createCoreOperationStartedEnvelope(request.requestId, operationId, "sync")); + control.onProgress?.(createCoreProgressEnvelope(request.requestId, operationId, { + stage: "create_backup", + status: "progress", + progress: 0.5, + count: 1 + })); + return applyPending; + } + }); + const user = userEvent.setup(); + + render( + [{ id: "default", name: "Default", revision: "profile-r1" }] }} + initialLocale="en" + initialTheme="system" + preferences={preferences} + /> + ); + + await user.click(await screen.findByRole("button", { name: "Sync", exact: true })); + const prepareButton = await screen.findByRole("button", { name: "Prepare sync" }); + await waitFor(() => expect(prepareButton).toBeEnabled()); + await user.click(prepareButton); + await user.click(await screen.findByRole("button", { name: "Confirm and apply" })); + + const dialog = await screen.findByRole("dialog", { name: "Review plan" }); + expect(await within(dialog).findByText(operationId)).toBeVisible(); + expect(within(dialog).getByText("Create managed backup · In progress · 1")).toBeVisible(); + expect(within(dialog).getByRole("progressbar", { name: "Operation progress" })).toHaveValue(0.5); + + await user.click(within(dialog).getByRole("button", { name: "Cancel operation" })); + expect(applySignal?.aborted).toBe(true); + expect(within(dialog).getByRole("button", { name: "Cancelling…" })).toBeDisabled(); + expect(within(dialog).getByText("Cancellation will take effect at the next safe point.")).toBeVisible(); + + await act(async () => { + rejectApply({ code: "OPERATION_CANCELLED" }); + await applyPending.catch(() => undefined); + }); + expect(await screen.findByText("Operation cancelled.")).toBeVisible(); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Review plan" })).not.toBeInTheDocument()); + expect(screen.queryByRole("dialog", { name: "Operation result" })).not.toBeInTheDocument(); + await waitFor(() => expect(prepareButton).toHaveFocus()); + }); +}); diff --git a/packages/app-ui/tests/app-refresh-policy.vitest.tsx b/packages/app-ui/tests/app-refresh-policy.vitest.tsx new file mode 100644 index 0000000..9c7295b --- /dev/null +++ b/packages/app-ui/tests/app-refresh-policy.vitest.tsx @@ -0,0 +1,74 @@ +import { MockCoreClient } from "@codex-provider-sync/core-client"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { AppUi } from "../src/App.js"; +import { statusFor } from "./helpers/app-fixtures.js"; + +const preferences = { + getLocale: () => "en" as const, + setLocale: vi.fn(), + getTheme: () => "system" as const, + setTheme: vi.fn() +}; + +describe("App refresh policy", () => { + it("loads Status once and refreshes it only from the explicit action", async () => { + const getStatus = vi.fn(async () => statusFor()); + const core = new MockCoreClient({ getStatus }); + const user = userEvent.setup(); + + render( + [{ id: "default", name: "Default", revision: "profile-r1" }] }} + initialLocale="en" + initialTheme="system" + preferences={preferences} + surface="desktop" + /> + ); + + const refresh = await screen.findByRole("button", { name: "Refresh" }); + await waitFor(() => expect(getStatus).toHaveBeenCalledTimes(1)); + await user.click(refresh); + await waitFor(() => expect(getStatus).toHaveBeenCalledTimes(2)); + }); + + it("loads Watch and update state once and refreshes both from Settings", async () => { + const getWatchStatus = vi.fn(async () => ({ schemaVersion: 1 as const, watches: [] })); + const getUpdateStatus = vi.fn(async () => ({ + state: "disabled" as const, + reason: "not-authorized" as const, + installAllowed: false + })); + const core = new MockCoreClient({ + getStatus: async () => statusFor(), + getWatchStatus + }); + const user = userEvent.setup(); + + render( + [{ id: "default", name: "Default", revision: "profile-r1" }], + getUpdateStatus + }} + initialLocale="en" + initialTheme="system" + preferences={preferences} + surface="desktop" + /> + ); + + await user.click(await screen.findByRole("button", { name: "Settings", exact: true })); + await waitFor(() => expect(getWatchStatus).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(getUpdateStatus).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(getWatchStatus).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(getUpdateStatus).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/packages/app-ui/tests/app-status-gating.vitest.tsx b/packages/app-ui/tests/app-status-gating.vitest.tsx new file mode 100644 index 0000000..b7850f3 --- /dev/null +++ b/packages/app-ui/tests/app-status-gating.vitest.tsx @@ -0,0 +1,62 @@ +import type { StatusSnapshot } from "@codex-provider-sync/contracts"; +import { MockCoreClient } from "@codex-provider-sync/core-client"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { AppUi } from "../src/App.js"; + +const status: StatusSnapshot = { + schemaVersion: 1, + snapshotAt: "2026-08-27T00:00:00.000Z", + storageRevision: "storage-r1", + profile: { id: "default", revision: "profile-r1" }, + currentProvider: "openai", + rolloutCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + sqliteCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [] +}; + +describe("App write gating", () => { + it("keeps protected writes disabled until a successful Status snapshot arrives", async () => { + let resolveStatus!: (value: StatusSnapshot) => void; + const pendingStatus = new Promise((resolve) => { resolveStatus = resolve; }); + const core = new MockCoreClient({ + getStatus: () => pendingStatus, + getWatchStatus: async () => ({ schemaVersion: 1, watches: [] }) + }); + const user = userEvent.setup(); + render( + [{ id: "default", name: "Default", revision: "profile-r1" }] }} + initialLocale="en" + initialTheme="system" + preferences={{ + getLocale: () => "en", + setLocale: () => {}, + getTheme: () => "system", + setTheme: () => {} + }} + /> + ); + + await user.click(await screen.findByRole("button", { name: "Sync", exact: true })); + expect(await screen.findByRole("button", { name: "Prepare sync" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Settings", exact: true })); + expect(await screen.findByRole("button", { name: "Start watch" })).toBeDisabled(); + + resolveStatus(status); + await waitFor(() => expect(screen.getByRole("button", { name: "Start watch" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Sync", exact: true })); + await waitFor(() => expect(screen.getByRole("button", { name: "Prepare sync" })).toBeEnabled()); + }); +}); diff --git a/packages/app-ui/tests/helpers/app-fixtures.ts b/packages/app-ui/tests/helpers/app-fixtures.ts new file mode 100644 index 0000000..517b3e3 --- /dev/null +++ b/packages/app-ui/tests/helpers/app-fixtures.ts @@ -0,0 +1,45 @@ +import type { PlanSummary, StatusSnapshot } from "@codex-provider-sync/contracts"; + +export function statusFor(profileRevision = "profile-r1"): StatusSnapshot { + return { + schemaVersion: 1, + snapshotAt: "2026-08-27T00:00:00.000Z", + storageRevision: `storage-${profileRevision}`, + profile: { id: "default", revision: profileRevision }, + currentProvider: "openai", + configuredProviders: ["openai", "relay"], + rolloutCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + sqliteCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [] + }; +} + +export function syncPlanFor(profileRevision = "profile-r1", planId = "plan-sync-1"): PlanSummary { + return { + schemaVersion: 1, + planId, + operation: "sync", + createdAt: "2026-08-27T00:00:00.000Z", + expiresAt: "2026-08-27T00:10:00.000Z", + profile: { id: "default", revision: profileRevision }, + storageRevision: `storage-${profileRevision}`, + configRevision: "config-r1", + rolloutRevision: "rollout-r1", + stateDbRevision: "state-db-r1", + target: { provider: "openai" }, + impact: { + rolloutFilesToChange: 1, + sqliteRowsToChange: 1, + backupExpected: true + }, + warnings: [], + requiresConfirmation: true + }; +} diff --git a/packages/app-ui/tests/history-page.vitest.tsx b/packages/app-ui/tests/history-page.vitest.tsx new file mode 100644 index 0000000..5cf7adb --- /dev/null +++ b/packages/app-ui/tests/history-page.vitest.tsx @@ -0,0 +1,122 @@ +import type { HistoryPage as HistoryPageDto, HistorySessionDetail } from "@codex-provider-sync/contracts"; +import type { CoreClient } from "@codex-provider-sync/core-client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it, vi } from "vitest"; + +import { HistoryPage, HISTORY_PAGE_SIZE } from "../src/features/history/HistoryPage.js"; +import { createAppI18n } from "../src/i18n.js"; + +const profile = { id: "fixture", name: "Fixture", revision: "rev-1" }; + +function page(pageNumber: number, messageCountKnown = true): HistoryPageDto { + return { + page: pageNumber, + pageSize: HISTORY_PAGE_SIZE, + total: 51, + hasNextPage: pageNumber === 1, + sessions: [{ + id: `session-${pageNumber}`, + title: `Session ${pageNumber}`, + provider: "fixture-provider", + archived: false, + updatedAt: "2026-08-27T00:00:00.000Z", + messageCount: messageCountKnown ? 1 : 0, + messageCountKnown + }] + }; +} + +function detail(): HistorySessionDetail { + return { + session: page(2).sessions[0], + messages: [{ + role: "user", + text: "history-body-marker", + timestamp: "2026-08-27T00:00:00.000Z", + sequence: 1 + }], + truncated: false, + returnedMessageCount: 1 + }; +} + +async function renderHistory({ messageCountKnown = true } = {}) { + const listHistory = vi.fn(async (input: { page: number }) => page(input.page, messageCountKnown)); + const getHistorySession = vi.fn(async () => detail()); + const core = { listHistory, getHistorySession } as unknown as CoreClient; + const i18n = await createAppI18n("en"); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + render( + + + + + + ); + return { getHistorySession, listHistory, queryClient }; +} + +describe("HistoryPage privacy and pagination", () => { + it("paginates summaries and only reads a body after explicit open", async () => { + const user = userEvent.setup(); + const { getHistorySession, listHistory, queryClient } = await renderHistory(); + + expect(await screen.findByText("Session 1")).toBeVisible(); + expect(getHistorySession).not.toHaveBeenCalled(); + expect(listHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 1, pageSize: HISTORY_PAGE_SIZE }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + + await user.click(screen.getByRole("button", { name: "Next" })); + expect(await screen.findByText("Session 2")).toBeVisible(); + expect(listHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 2, pageSize: HISTORY_PAGE_SIZE }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(getHistorySession).not.toHaveBeenCalled(); + + const open = screen.getByRole("button", { name: "Open session" }); + await user.click(open); + expect(await screen.findByText("history-body-marker")).toBeVisible(); + expect(screen.getByRole("heading", { name: "Session 2" })).toHaveFocus(); + expect(getHistorySession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "session-2", messageLimit: 200 }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + await waitFor(() => { + expect(JSON.stringify(queryClient.getQueryCache().getAll().map((query) => query.state.data))) + .not.toContain("history-body-marker"); + }); + await user.click(screen.getByRole("button", { name: "Back to sessions" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Open session" })).toHaveFocus()); + }); + + it("loads once, ignores ambient refresh triggers, and refreshes only on request", async () => { + const user = userEvent.setup(); + const { listHistory } = await renderHistory(); + + expect(await screen.findByText("Session 1")).toBeVisible(); + expect(listHistory).toHaveBeenCalledTimes(1); + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("online")); + await Promise.resolve(); + expect(listHistory).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(listHistory).toHaveBeenCalledTimes(2)); + }); + + it("does not present an unknown lightweight count as zero messages", async () => { + await renderHistory({ messageCountKnown: false }); + + expect(await screen.findByText("Session 1")).toBeVisible(); + expect(screen.queryByText("0 messages")).not.toBeInTheDocument(); + expect(screen.getByText("fixture-provider")).toBeVisible(); + }); +}); diff --git a/packages/app-ui/tests/operation-result.vitest.ts b/packages/app-ui/tests/operation-result.vitest.ts new file mode 100644 index 0000000..1b86e67 --- /dev/null +++ b/packages/app-ui/tests/operation-result.vitest.ts @@ -0,0 +1,107 @@ +import type { OperationOutcome, OperationResult } from "@codex-provider-sync/contracts"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createElement, Fragment, useState } from "react"; +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it } from "vitest"; + +import { OperationResultDialog, operationResultPresentation } from "../src/features/operations/OperationResultDialog.js"; +import { createAppI18n } from "../src/i18n.js"; + +describe("operation result presentation", () => { + it("maps every public outcome without a fallthrough", () => { + const outcomes: OperationOutcome[] = [ + "completed", + "partial", + "failed_rolled_back", + "recovery_required", + "cancelled", + "stale" + ]; + + expect(outcomes.map((outcome) => [outcome, operationResultPresentation(outcome)])).toEqual([ + ["completed", expect.objectContaining({ tone: "success", toastKey: "global.completed" })], + ["partial", expect.objectContaining({ tone: "warning", toastKey: "global.partial" })], + ["failed_rolled_back", expect.objectContaining({ tone: "warning", toastKey: "global.failed" })], + ["recovery_required", expect.objectContaining({ tone: "danger", toastKey: "global.failed" })], + ["cancelled", expect.objectContaining({ tone: "warning", toastKey: "global.cancelled" })], + ["stale", expect.objectContaining({ tone: "warning", toastKey: "global.stale" })] + ]); + expect(new Set(outcomes.map((outcome) => operationResultPresentation(outcome).titleKey)).size).toBe(outcomes.length); + }); + + it("keeps recovery-required details open and only renders whitelisted result fields", async () => { + const user = userEvent.setup(); + const i18n = await createAppI18n("en"); + let closeCalls = 0; + const result: OperationResult = { + schemaVersion: 1, + operationId: "11111111-1111-4111-8111-111111111118", + operation: "restore", + outcome: "recovery_required", + backup: { backupId: "managed-backup" }, + warnings: ["Recovery evidence is pending."], + result: { + restoreJournalState: "recovery-required", + skippedLockedRolloutFiles: ["rollout-safe-name.jsonl"], + token: "must-not-render", + messageBody: "must-not-render", + messageBodyChanged: "suffix-string-must-not-render", + secretCount: 42 + } + }; + render(createElement( + I18nextProvider, + { i18n }, + createElement(OperationResultDialog, { + close: () => { closeCalls += 1; }, + closeDisabled: true, + restoreFocus: () => {}, + result + }) + )); + + expect(await screen.findByRole("alert")).toBeVisible(); + expect(screen.getByText("managed-backup")).toBeVisible(); + expect(screen.getByText("rollout-safe-name.jsonl")).toBeVisible(); + expect(screen.queryByText("must-not-render")).not.toBeInTheDocument(); + expect(screen.queryByText("suffix-string-must-not-render")).not.toBeInTheDocument(); + expect(screen.queryByText("42")).not.toBeInTheDocument(); + const closeButtons = screen.getAllByRole("button", { name: "Close" }); + expect(closeButtons).toHaveLength(2); + for (const close of closeButtons) expect(close).toBeDisabled(); + await user.click(closeButtons.at(-1)!); + expect(closeCalls).toBe(0); + }); + + it("restores focus after a completed result closes", async () => { + const user = userEvent.setup(); + const i18n = await createAppI18n("en"); + const result: OperationResult = { + schemaVersion: 1, + operationId: "11111111-1111-4111-8111-111111111119", + operation: "sync", + outcome: "completed", + backup: null, + warnings: [], + result: {} + }; + function Harness() { + const [current, setCurrent] = useState(result); + return createElement( + Fragment, + null, + createElement("button", { id: "prepare-trigger", type: "button" }, "Prepare sync"), + createElement(OperationResultDialog, { + close: () => setCurrent(null), + restoreFocus: () => document.getElementById("prepare-trigger")?.focus(), + result: current + }) + ); + } + render(createElement(I18nextProvider, { i18n }, createElement(Harness))); + + await user.click((await screen.findAllByRole("button", { name: "Close" })).at(-1)!); + await waitFor(() => expect(screen.getByRole("button", { name: "Prepare sync" })).toHaveFocus()); + }); +}); diff --git a/packages/app-ui/tests/plan-review.vitest.tsx b/packages/app-ui/tests/plan-review.vitest.tsx new file mode 100644 index 0000000..987cf99 --- /dev/null +++ b/packages/app-ui/tests/plan-review.vitest.tsx @@ -0,0 +1,59 @@ +import type { PlanSummary } from "@codex-provider-sync/contracts"; +import { render, screen } from "@testing-library/react"; +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it, vi } from "vitest"; + +import { PlanReview } from "../src/features/operations/PlanReview.js"; +import { createAppI18n } from "../src/i18n.js"; + +const plan: PlanSummary = { + schemaVersion: 1, + planId: "opaque-plan-id", + operation: "sync", + createdAt: "2026-08-27T00:00:00.000Z", + expiresAt: "2026-08-27T00:10:00.000Z", + profile: { id: "default", revision: "r1" }, + storageRevision: "storage-r1", + configRevision: "config-r1", + rolloutRevision: "rollout-r1", + stateDbRevision: "state-r1", + target: { provider: "openai", model: null }, + impact: { + rolloutFilesToChange: 2, + sqliteRowsToChange: 1, + lockedRolloutFiles: [], + backupExpected: true + }, + warnings: [], + requiresConfirmation: true +}; + +describe("PlanReview", () => { + it("presents an auditable product summary and keeps raw JSON collapsed", async () => { + const i18n = await createAppI18n("en"); + render( + + + + ); + + expect(await screen.findByRole("dialog", { name: "Review plan" })).toBeVisible(); + expect(screen.getByText(/Sync Provider metadata/)).toBeVisible(); + expect(screen.getByText("Provider")).toBeVisible(); + expect(screen.getByText("openai")).toBeVisible(); + expect(screen.getByText("Rollout files affected")).toBeVisible(); + expect(screen.getByText("A backup will be created before writes.")).toBeVisible(); + expect(screen.getByText("Technical details")).toBeVisible(); + expect(screen.getByText(/"provider": "openai"/)).not.toBeVisible(); + }); +}); diff --git a/packages/app-ui/tests/setup.ts b/packages/app-ui/tests/setup.ts new file mode 100644 index 0000000..99563d6 --- /dev/null +++ b/packages/app-ui/tests/setup.ts @@ -0,0 +1,20 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +if (!globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame = (callback: FrameRequestCallback): number => globalThis.setTimeout( + () => callback(globalThis.performance.now()), + 0 + ); +} + +if (!globalThis.cancelAnimationFrame) { + globalThis.cancelAnimationFrame = (handle: number): void => globalThis.clearTimeout(handle); +} + +afterEach(() => { + cleanup(); + document.documentElement.lang = ""; + delete document.documentElement.dataset.theme; +}); diff --git a/packages/app-ui/tsconfig.json b/packages/app-ui/tsconfig.json new file mode 100644 index 0000000..0d6c810 --- /dev/null +++ b/packages/app-ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ], + "references": [ + { "path": "../contracts" }, + { "path": "../core-client" }, + { "path": "../design-system" } + ] +} diff --git a/packages/app-ui/vitest.config.ts b/packages/app-ui/vitest.config.ts new file mode 100644 index 0000000..3419c2b --- /dev/null +++ b/packages/app-ui/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["tests/**/*.vitest.ts", "tests/**/*.vitest.tsx"], + setupFiles: ["./tests/setup.ts"] + } +}); diff --git a/packages/contracts/checks/contracts.contract.mjs b/packages/contracts/checks/contracts.contract.mjs new file mode 100644 index 0000000..f309421 --- /dev/null +++ b/packages/contracts/checks/contracts.contract.mjs @@ -0,0 +1,306 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CORE_METHODS, + ContractValidationError, + assertApplyPlanInput, + assertCoreErrorDto, + assertCoreMethodInput, + assertCoreMethodOutput, + assertCoreOperationStartedEnvelope, + assertCoreProgressEnvelope, + assertCoreRequestEnvelope, + assertCoreResponseEnvelope, + assertProgressEvent, + createPublicCoreErrorDto, + createCoreOperationStartedEnvelope, + createCoreProgressEnvelope, + createCoreRequestEnvelope +} from "../dist/index.js"; + +test("contract exposes the complete stable CoreClient method set", () => { + assert.deepEqual(CORE_METHODS, [ + "getStatus", + "prepareSync", + "applySync", + "prepareSwitch", + "applySwitch", + "listBackups", + "prepareRestore", + "applyRestore", + "pruneBackups", + "listHistory", + "getHistorySession", + "startWatch", + "stopWatch", + "getWatchStatus", + "getDiagnostics" + ]); +}); + +test("Apply input is opaque, versioned and exact", () => { + assert.doesNotThrow(() => assertApplyPlanInput({ schemaVersion: 1, planId: "opaque" })); + assert.throws( + () => assertApplyPlanInput({ schemaVersion: 1, planId: "opaque", provider: "openai" }), + ContractValidationError + ); +}); + +test("product inputs cannot carry paths or arbitrary apply/watch fields", () => { + assert.doesNotThrow(() => assertCoreMethodInput("startWatch", { + profile: { profileId: "default", profileRevision: "r1" }, + includeStateDb: true, + debounceMs: 0, + once: true + })); + assert.throws(() => assertCoreMethodInput("getStatus", { + profile: { profileId: "default" }, + codexHome: "C:/private" + })); + assert.throws(() => assertCoreMethodInput("prepareRestore", { + profile: { profileId: "default" }, + backupId: "managed", + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true, + backupDir: "C:/private" + })); +}); + +test("protocol mismatch fails before request business validation", () => { + assert.throws( + () => assertCoreRequestEnvelope({ + protocolVersion: 2, + requestId: "request-1", + method: "getStatus", + payload: null + }), + (error) => error instanceof ContractValidationError + && error.code === "PROTOCOL_VERSION_MISMATCH" + ); +}); + +test("request and response envelopes preserve request correlation", () => { + const request = createCoreRequestEnvelope( + "getStatus", + { profile: { profileId: "default" } }, + "request-1" + ); + assert.doesNotThrow(() => assertCoreRequestEnvelope(request)); + assert.doesNotThrow(() => assertCoreResponseEnvelope({ + protocolVersion: 1, + requestId: "request-1", + ok: true, + result: {} + }, "request-1")); + assert.throws(() => assertCoreResponseEnvelope({ + protocolVersion: 1, + requestId: "request-2", + ok: true, + result: {} + }, "request-1")); +}); + +test("public errors use fixed messages and allowlisted details", () => { + const busy = createPublicCoreErrorDto("OPERATION_BUSY", { + details: { + busyScope: "state-db", + token: "must-not-cross", + path: "C:/private" + } + }); + assert.deepEqual(busy, { + code: "OPERATION_BUSY", + message: "Another write operation is using the protected resource.", + severity: "warning", + retryable: true, + recoveryRequired: false, + details: { busyScope: "state-db" } + }); + assert.doesNotThrow(() => assertCoreErrorDto(busy)); + assert.throws(() => assertCoreErrorDto({ ...busy, message: "private path" })); + assert.throws(() => assertCoreErrorDto({ ...busy, suggestedAction: "token=secret" })); + assert.throws(() => assertCoreErrorDto({ ...busy, details: { busyScope: "state-db", path: "private" } })); +}); + +test("method output guards reject structurally invalid successes", () => { + const status = { + schemaVersion: 1, + snapshotAt: "2026-08-25T00:00:00.000Z", + storageRevision: "storage", + profile: { id: "default", revision: "r1" }, + currentProvider: "openai", + rolloutCounts: { openai: 1 }, + sqliteCounts: { openai: 1 }, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [] + }; + assert.throws(() => assertCoreMethodOutput("getStatus", null)); + assert.throws(() => assertCoreMethodOutput("getStatus", { + schemaVersion: 1, + snapshotAt: "2026-08-25T00:00:00.000Z", + storageRevision: "storage", + profile: { id: "default", revision: "r1" }, + currentProvider: "openai", + lockedRolloutFiles: [] + })); + assert.throws(() => assertCoreMethodOutput("prepareSync", { schemaVersion: 1 })); + assert.throws(() => assertCoreMethodOutput("listBackups", { backups: [{ backupId: "b", sizeBytes: -1, metadata: {} }] })); + const lightweightHistory = { + page: 1, + pageSize: 50, + total: 1, + hasNextPage: false, + sessions: [{ + id: "history-1", + title: "", + provider: "openai", + archived: false, + updatedAt: "2026-08-25T00:00:00.000Z", + messageCount: 0, + messageCountKnown: false + }] + }; + assert.doesNotThrow(() => assertCoreMethodOutput("listHistory", lightweightHistory)); + assert.throws(() => assertCoreMethodOutput("listHistory", { + ...lightweightHistory, + sessions: [{ ...lightweightHistory.sessions[0], messageCountKnown: "unknown" }] + })); + assert.throws(() => assertCoreMethodOutput("getStatus", { + ...status, + pendingTransactions: [{ leak: () => "private" }] + })); + assert.throws(() => assertCoreMethodOutput("getStatus", { + ...status, + operationInProgress: { leak: () => "private" } + })); + assert.throws(() => assertCoreMethodOutput("getStatus", { + ...status, + codexHome: "C:/private" + })); + assert.throws(() => assertCoreMethodOutput("getDiagnostics", { + schemaVersion: 1, + generatedAt: "2026-08-25T00:00:00.000Z", + runtime: { leak: () => "private" }, + storage: {}, + provider: {}, + safety: {} + })); + assert.doesNotThrow(() => assertCoreMethodOutput("getWatchStatus", { + schemaVersion: 1, + watches: [] + })); +}); + +test("DiagnosticsSnapshot is a recursive pathless allowlist", () => { + const diagnostics = { + schemaVersion: 1, + generatedAt: "2026-08-27T00:00:00.000Z", + runtime: { node: "v24.0.0", platform: "win32", arch: "x64" }, + storage: { sqliteHomeSource: "default", stateDbFound: true, sqliteSupported: true }, + provider: { + current: "openai", + implicit: false, + configured: ["openai", "relay-v2"], + rolloutCounts: { sessions: { openai: 2 }, archived_sessions: {} }, + sqliteCounts: { sessions: { openai: 2 }, archived_sessions: {}, unreadable: true } + }, + safety: { + storageRevision: "revision_1", + pendingRecovery: true, + pendingTransactions: [{ + operationId: "11111111-1111-4111-8111-111111111111", + operationKind: "restore", + state: "committed-pending-ack", + sourceBackupId: "provider-sync-source", + preRestoreSnapshotId: "restore-v2-snapshot" + }], + operationInProgress: { + operationId: "22222222-2222-4222-8222-222222222222", + operation: "restore", + actor: "manual", + startedAt: "2026-08-27T00:00:00.000Z", + busyScope: "codex-home" + }, + rolloutScanComplete: true, + lockedRolloutCount: 0, + projectThreadVisibilityAvailable: true + } + }; + assert.doesNotThrow(() => assertCoreMethodOutput("getDiagnostics", diagnostics)); + + for (const [section, field] of [ + ["runtime", "path"], + ["storage", "codexHome"], + ["provider", "token"], + ["safety", "message"] + ]) { + const mutated = structuredClone(diagnostics); + mutated[section][field] = "C:/private/secret"; + assert.throws(() => assertCoreMethodOutput("getDiagnostics", mutated), `${section}.${field}`); + } + const pendingLeak = structuredClone(diagnostics); + pendingLeak.safety.pendingTransactions[0].journalPath = "C:/private/journal"; + assert.throws(() => assertCoreMethodOutput("getDiagnostics", pendingLeak)); + const operationLeak = structuredClone(diagnostics); + operationLeak.safety.operationInProgress.encrypted_content = "message-body"; + assert.throws(() => assertCoreMethodOutput("getDiagnostics", operationLeak)); + const providerPath = structuredClone(diagnostics); + providerPath.provider.rolloutCounts.sessions["C:/private"] = 1; + assert.throws(() => assertCoreMethodOutput("getDiagnostics", providerPath)); +}); + +test("ProgressEvent cannot carry messages, paths or diagnostics", () => { + assert.doesNotThrow(() => assertProgressEvent({ + stage: "sqlite", + status: "running", + progress: 0.5, + count: 2 + })); + assert.throws(() => assertProgressEvent({ + stage: "history", + status: "running", + messageBody: "must not cross the progress channel" + })); +}); + +test("operation lifecycle envelopes are exact, correlated, and pathless", () => { + const operationId = "11111111-1111-4111-8111-111111111111"; + const started = createCoreOperationStartedEnvelope("request-1", operationId, "sync"); + const progress = createCoreProgressEnvelope("request-1", operationId, { + stage: "create_backup", + status: "start", + progress: 0.25, + count: 1 + }); + assert.doesNotThrow(() => assertCoreOperationStartedEnvelope( + started, + "request-1", + operationId + )); + assert.doesNotThrow(() => assertCoreProgressEnvelope( + progress, + "request-1", + operationId + )); + assert.throws(() => assertCoreOperationStartedEnvelope( + { ...started, path: "C:/private" } + )); + assert.throws(() => assertCoreProgressEnvelope({ + ...progress, + progress: { ...progress.progress, backupDir: "C:/private" } + })); + assert.throws(() => assertCoreProgressEnvelope(progress, "request-2", operationId)); + assert.throws(() => assertCoreProgressEnvelope( + progress, + "request-1", + "22222222-2222-4222-8222-222222222222" + )); +}); diff --git a/packages/contracts/dist/dto.d.ts b/packages/contracts/dist/dto.d.ts new file mode 100644 index 0000000..73e920e --- /dev/null +++ b/packages/contracts/dist/dto.d.ts @@ -0,0 +1,311 @@ +import type { JsonObject, JsonValue } from "./json.js"; +export declare const CONTRACT_SCHEMA_VERSION: 1; +export declare const CORE_PROTOCOL_VERSION: 1; +export type ContractSchemaVersion = typeof CONTRACT_SCHEMA_VERSION; +export type CoreProtocolVersion = typeof CORE_PROTOCOL_VERSION; +export type OperationKind = "sync" | "switch" | "restore" | "prune" | "watch"; +export type OperationOutcome = "completed" | "partial" | "failed_rolled_back" | "recovery_required" | "cancelled" | "stale"; +export interface ProfileSelector { + profileId: string; + profileRevision?: string; +} +export interface GetStatusInput { + profile: ProfileSelector; +} +export interface PrepareSyncInput extends GetStatusInput { + keepCount?: number; +} +export type SwitchModelMode = "provider-default" | "keep-root-model" | "explicit"; +export interface PrepareSwitchInput extends GetStatusInput { + provider: string; + modelMode: SwitchModelMode; + model?: string; + keepCount?: number; +} +export interface ApplyPlanInput { + schemaVersion: ContractSchemaVersion; + planId: string; +} +export interface ListBackupsInput extends GetStatusInput { +} +export interface PrepareRestoreInput extends GetStatusInput { + backupId: string; + restoreConfig: boolean; + restoreDatabase: boolean; + restoreSessions: boolean; + allowSqliteHomeRelocation?: boolean; + relocationTargetProfileId?: string; +} +export interface PruneBackupsInput extends GetStatusInput { + keepCount: number; +} +export interface ListHistoryInput extends GetStatusInput { + page?: number; + pageSize?: number; + query?: string; + project?: string; + provider?: string; + archived?: "all" | "active" | "archived"; +} +export interface GetHistorySessionInput extends GetStatusInput { + sessionId: string; + messageLimit?: number; +} +export interface StartWatchInput extends GetStatusInput { + includeStateDb?: boolean; + debounceMs?: number; + once?: boolean; +} +export interface WatchReferenceInput { + watchId: string; +} +export interface GetWatchStatusInput { + watchId?: string; +} +export interface GetDiagnosticsInput extends GetStatusInput { +} +export type ProviderDistribution = Record>; +export interface StatusSnapshot { + schemaVersion: ContractSchemaVersion; + snapshotAt: string; + storageRevision: string; + profile: { + id: string; + revision: string; + }; + currentProvider: string; + currentModel?: string | null; + rolloutCounts: ProviderDistribution; + modelCounts?: ProviderDistribution; + sqliteCounts: JsonValue; + codexHomeSource: string; + sqliteHomeSource: string; + backupSummary: { + count: number; + totalBytes: number; + }; + pendingRecovery: boolean; + pendingTransactions: JsonObject[]; + operationInProgress: JsonObject | null; + rolloutScanComplete: boolean; + lockedRolloutFiles: string[]; + [extension: string]: JsonValue | undefined; +} +export interface PlanSummary { + schemaVersion: ContractSchemaVersion; + planId: string; + operation: "sync" | "switch" | "restore"; + createdAt: string; + expiresAt: string; + profile: { + id: string; + revision: string; + }; + storageRevision: string; + configRevision: string; + rolloutRevision: string; + stateDbRevision: string; + backupRevision?: string; + target: JsonObject; + impact: JsonObject; + warnings: string[]; + requiresConfirmation: boolean; +} +export interface ManagedBackup { + backupId: string; + createdAt?: string; + sizeBytes: number; + metadata: JsonObject; +} +export interface BackupList { + backups: ManagedBackup[]; +} +export interface OperationResult { + schemaVersion: ContractSchemaVersion; + operationId: string; + operation: "sync" | "switch" | "restore"; + outcome: OperationOutcome; + backup: { + backupId: string; + } | null; + warnings: string[]; + result: Result; +} +export interface PruneBackupsResult { + deletedCount: number; + remainingCount: number; + freedBytes: number; +} +export interface HistorySessionSummary { + id: string; + title: string; + provider: string; + model?: string | null; + archived: boolean; + createdAt?: string; + updatedAt: string; + messageCount: number; + messageCountKnown?: boolean; +} +export interface HistoryPage { + page: number; + pageSize: number; + total: number; + hasNextPage: boolean; + sessions: HistorySessionSummary[]; +} +export interface HistoryMessage { + role: string; + text: string; + timestamp?: string; + sequence: number; +} +export interface HistorySessionDetail { + session: HistorySessionSummary; + messages: HistoryMessage[]; + truncated: boolean; + returnedMessageCount: number; +} +export interface WatchSnapshot { + schemaVersion: ContractSchemaVersion; + watchId: string; + status: "running" | "stopping" | "stopped"; + startedAt: string; + stoppedAt: string | null; + stopReason: string | null; + includeStateDb: boolean; + once: boolean; +} +export interface WatchStatusList { + schemaVersion: ContractSchemaVersion; + watches: WatchSnapshot[]; +} +export interface DiagnosticsRuntime { + node: string; + platform: string; + arch: string; +} +export type DiagnosticsSqliteHomeSource = "cli" | "config" | "env" | "default" | "unknown"; +export interface DiagnosticsStorage { + sqliteHomeSource: DiagnosticsSqliteHomeSource; + stateDbFound: boolean; + sqliteSupported: boolean; +} +export interface DiagnosticsProviderDistribution { + sessions: Record; + archived_sessions: Record; +} +export interface DiagnosticsSqliteDistribution extends DiagnosticsProviderDistribution { + unreadable?: true; +} +export interface DiagnosticsProvider { + current: string; + implicit: boolean; + configured: string[]; + rolloutCounts: DiagnosticsProviderDistribution; + sqliteCounts: DiagnosticsSqliteDistribution | null; +} +export type DiagnosticsTransactionState = "prepared" | "applying" | "applied" | "skipped" | "committing" | "committed-pending-ack" | "rollback-pending" | "rollingBack" | "recovery-required" | "recoveryRequired" | "unknown"; +export interface DiagnosticsPendingTransaction { + operationId: string | null; + operationKind: "sync" | "switch" | "restore"; + state: DiagnosticsTransactionState; + sourceBackupId: string | null; + preRestoreSnapshotId: string | null; +} +export interface DiagnosticsOperationState { + operationId?: string; + operation?: "sync" | "switch" | "restore" | "prune" | "watch" | "unknown"; + actor?: "manual" | "watch" | "external"; + startedAt?: string; + busyScope?: "codex-home" | "state-db"; + lockState?: string; + errorCode?: string; +} +export interface DiagnosticsSafety { + storageRevision?: string; + pendingRecovery: boolean; + pendingTransactions: DiagnosticsPendingTransaction[]; + operationInProgress: DiagnosticsOperationState | null; + rolloutScanComplete: boolean; + lockedRolloutCount: number; + projectThreadVisibilityAvailable: boolean; +} +export interface DiagnosticsSnapshot { + schemaVersion: ContractSchemaVersion; + generatedAt: string; + runtime: DiagnosticsRuntime; + storage: DiagnosticsStorage; + provider: DiagnosticsProvider; + safety: DiagnosticsSafety; +} +export interface ProgressEvent { + stage: string; + status: string; + progress?: number; + count?: number; +} +export declare const CORE_METHODS: readonly ["getStatus", "prepareSync", "applySync", "prepareSwitch", "applySwitch", "listBackups", "prepareRestore", "applyRestore", "pruneBackups", "listHistory", "getHistorySession", "startWatch", "stopWatch", "getWatchStatus", "getDiagnostics"]; +export type CoreMethodName = typeof CORE_METHODS[number]; +export interface CoreMethodMap { + getStatus: { + input: GetStatusInput; + output: StatusSnapshot; + }; + prepareSync: { + input: PrepareSyncInput; + output: PlanSummary; + }; + applySync: { + input: ApplyPlanInput; + output: OperationResult; + }; + prepareSwitch: { + input: PrepareSwitchInput; + output: PlanSummary; + }; + applySwitch: { + input: ApplyPlanInput; + output: OperationResult; + }; + listBackups: { + input: ListBackupsInput; + output: BackupList; + }; + prepareRestore: { + input: PrepareRestoreInput; + output: PlanSummary; + }; + applyRestore: { + input: ApplyPlanInput; + output: OperationResult; + }; + pruneBackups: { + input: PruneBackupsInput; + output: PruneBackupsResult; + }; + listHistory: { + input: ListHistoryInput; + output: HistoryPage; + }; + getHistorySession: { + input: GetHistorySessionInput; + output: HistorySessionDetail; + }; + startWatch: { + input: StartWatchInput; + output: WatchSnapshot; + }; + stopWatch: { + input: WatchReferenceInput; + output: WatchSnapshot; + }; + getWatchStatus: { + input: GetWatchStatusInput; + output: WatchSnapshot | WatchStatusList; + }; + getDiagnostics: { + input: GetDiagnosticsInput; + output: DiagnosticsSnapshot; + }; +} diff --git a/packages/contracts/dist/dto.js b/packages/contracts/dist/dto.js new file mode 100644 index 0000000..b00ca92 --- /dev/null +++ b/packages/contracts/dist/dto.js @@ -0,0 +1,19 @@ +export const CONTRACT_SCHEMA_VERSION = 1; +export const CORE_PROTOCOL_VERSION = 1; +export const CORE_METHODS = [ + "getStatus", + "prepareSync", + "applySync", + "prepareSwitch", + "applySwitch", + "listBackups", + "prepareRestore", + "applyRestore", + "pruneBackups", + "listHistory", + "getHistorySession", + "startWatch", + "stopWatch", + "getWatchStatus", + "getDiagnostics" +]; diff --git a/packages/contracts/dist/errors.d.ts b/packages/contracts/dist/errors.d.ts new file mode 100644 index 0000000..235c63d --- /dev/null +++ b/packages/contracts/dist/errors.d.ts @@ -0,0 +1,20 @@ +import type { JsonObject } from "./json.js"; +export declare const CORE_ERROR_CODES: readonly ["INVALID_INPUT", "PROFILE_CHANGED", "STORAGE_CHANGED", "PLAN_STALE", "PLAN_EXPIRED", "STALE_STATE", "CODEX_HOME_NOT_FOUND", "STATE_DB_NOT_FOUND", "SQLITE_UNSUPPORTED_PATH", "SQLITE_BUSY", "SQLITE_UNREADABLE", "ROLLOUT_LOCKED", "ROLLOUT_CHANGED", "PENDING_TRANSACTION", "BACKUP_FAILED", "SYNC_FAILED_ROLLED_BACK", "RECOVERY_REQUIRED", "RESTORE_VALIDATION_FAILED", "PERMISSION_DENIED", "OPERATION_BUSY", "LOCK_UNVERIFIABLE", "OPERATION_CANCELLED", "CORE_RUNTIME_CRASHED", "PROTOCOL_VERSION_MISMATCH", "INTERNAL_ERROR"]; +export type CoreErrorCode = typeof CORE_ERROR_CODES[number]; +export type CoreErrorSeverity = "info" | "warning" | "error" | "fatal"; +export interface CoreErrorDto { + code: CoreErrorCode; + message: string; + severity: CoreErrorSeverity; + retryable: boolean; + recoveryRequired: boolean; + operationId?: string; + details?: JsonObject; +} +export declare const PUBLIC_CORE_ERROR_MESSAGES: Readonly>; +export declare function createPublicCoreErrorDto(code: CoreErrorCode, options?: { + operationId?: unknown; + details?: unknown; +}): CoreErrorDto; +export declare function sanitizePublicCoreErrorDto(value: unknown): CoreErrorDto; +export declare function isCanonicalPublicCoreErrorDto(value: unknown): value is CoreErrorDto; diff --git a/packages/contracts/dist/errors.js b/packages/contracts/dist/errors.js new file mode 100644 index 0000000..dfdfba9 --- /dev/null +++ b/packages/contracts/dist/errors.js @@ -0,0 +1,224 @@ +export const CORE_ERROR_CODES = [ + "INVALID_INPUT", + "PROFILE_CHANGED", + "STORAGE_CHANGED", + "PLAN_STALE", + "PLAN_EXPIRED", + "STALE_STATE", + "CODEX_HOME_NOT_FOUND", + "STATE_DB_NOT_FOUND", + "SQLITE_UNSUPPORTED_PATH", + "SQLITE_BUSY", + "SQLITE_UNREADABLE", + "ROLLOUT_LOCKED", + "ROLLOUT_CHANGED", + "PENDING_TRANSACTION", + "BACKUP_FAILED", + "SYNC_FAILED_ROLLED_BACK", + "RECOVERY_REQUIRED", + "RESTORE_VALIDATION_FAILED", + "PERMISSION_DENIED", + "OPERATION_BUSY", + "LOCK_UNVERIFIABLE", + "OPERATION_CANCELLED", + "CORE_RUNTIME_CRASHED", + "PROTOCOL_VERSION_MISMATCH", + "INTERNAL_ERROR" +]; +export const PUBLIC_CORE_ERROR_MESSAGES = Object.freeze({ + INVALID_INPUT: "The command input is invalid.", + PROFILE_CHANGED: "The selected profile changed. Prepare the operation again.", + STORAGE_CHANGED: "The resolved storage changed. Prepare the operation again.", + PLAN_STALE: "The prepared operation is stale. Prepare it again.", + PLAN_EXPIRED: "The prepared operation expired. Prepare it again.", + STALE_STATE: "The protected state changed. Prepare the operation again.", + CODEX_HOME_NOT_FOUND: "The selected Codex Home was not found.", + STATE_DB_NOT_FOUND: "The selected state database was not found.", + SQLITE_UNSUPPORTED_PATH: "The selected SQLite path is not supported by this runtime.", + SQLITE_BUSY: "The state database is busy. Close Codex processes and retry.", + SQLITE_UNREADABLE: "The state database is unreadable or malformed.", + ROLLOUT_LOCKED: "One or more rollout files are locked.", + ROLLOUT_CHANGED: "One or more rollout files changed during the operation.", + PENDING_TRANSACTION: "An unfinished transaction must be resolved before another write.", + BACKUP_FAILED: "The required backup could not be completed.", + SYNC_FAILED_ROLLED_BACK: "The operation failed and its changes were rolled back.", + RECOVERY_REQUIRED: "The operation requires explicit recovery.", + RESTORE_VALIDATION_FAILED: "The selected backup or restore target failed validation.", + PERMISSION_DENIED: "The operation does not have permission to access a required resource.", + OPERATION_BUSY: "Another write operation is using the protected resource.", + LOCK_UNVERIFIABLE: "The lock owner or protected resource identity cannot be verified.", + OPERATION_CANCELLED: "The operation was cancelled.", + CORE_RUNTIME_CRASHED: "The Core runtime stopped unexpectedly.", + PROTOCOL_VERSION_MISMATCH: "The client and Core protocol versions are incompatible.", + INTERNAL_ERROR: "An internal error occurred." +}); +const WARNING_CODES = new Set([ + "PROFILE_CHANGED", + "STORAGE_CHANGED", + "PLAN_STALE", + "PLAN_EXPIRED", + "STALE_STATE", + "SQLITE_BUSY", + "ROLLOUT_LOCKED", + "ROLLOUT_CHANGED", + "OPERATION_BUSY" +]); +const RECOVERY_CODES = new Set(["PENDING_TRANSACTION", "RECOVERY_REQUIRED"]); +const LOCK_SCOPES = new Set(["codex-home", "state-db"]); +const SAFE_REASONS = new Set([ + "profile", + "config", + "storage", + "rollout", + "state-db", + "windows-wsl-unc" +]); +const SAFE_CAUSE_CODES = new Set([ + "ENOENT", + "EACCES", + "EPERM", + "EIO", + "EBUSY", + "SQLITE_BUSY", + "SQLITE_LOCKED", + "SQLITE_CORRUPT", + "SQLITE_NOTADB", + "ERR_SQLITE_ERROR" +]); +const SQLITE_HOME_SOURCES = new Set(["cli", "config", "env", "default"]); +const OPERATION_KINDS = new Set(["sync", "switch", "restore", "prune-backups", "watch"]); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CORE_ERROR_CODE_SET = new Set(CORE_ERROR_CODES); +function objectRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value + : null; +} +function record(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return null; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null + ? value + : null; +} +function ownValue(source, key) { + if (!source) + return undefined; + const descriptor = Object.getOwnPropertyDescriptor(source, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} +function publicSeverity(code) { + if (code === "OPERATION_CANCELLED") + return "info"; + if (code === "CORE_RUNTIME_CRASHED" || code === "INTERNAL_ERROR") + return "fatal"; + return WARNING_CODES.has(code) ? "warning" : "error"; +} +function sanitizeDetails(value) { + const source = record(value); + if (!source) + return undefined; + const details = {}; + const busyScope = ownValue(source, "busyScope"); + const lockScope = ownValue(source, "lockScope"); + const causeCode = ownValue(source, "causeCode"); + const reason = ownValue(source, "reason"); + const missing = ownValue(source, "missing"); + const sqliteHomeSource = ownValue(source, "sqliteHomeSource"); + const operationKind = ownValue(source, "operationKind"); + if (LOCK_SCOPES.has(String(busyScope))) + details.busyScope = String(busyScope); + if (LOCK_SCOPES.has(String(lockScope))) + details.lockScope = String(lockScope); + if (SAFE_CAUSE_CODES.has(String(causeCode))) + details.causeCode = String(causeCode); + if (SAFE_REASONS.has(String(reason))) + details.reason = String(reason); + if (missing === "config.toml" || missing === "state_5.sqlite") + details.missing = missing; + if (SQLITE_HOME_SOURCES.has(String(sqliteHomeSource))) { + details.sqliteHomeSource = String(sqliteHomeSource); + } + for (const key of ["sqlitePrimaryCode", "sqliteExtendedCode"]) { + const candidate = ownValue(source, key); + if (Number.isInteger(candidate) && Number(candidate) >= 0 && Number(candidate) <= 0xffff) { + details[key] = Number(candidate); + } + } + if (OPERATION_KINDS.has(String(operationKind))) + details.operationKind = String(operationKind); + return Object.keys(details).length > 0 ? details : undefined; +} +export function createPublicCoreErrorDto(code, options = {}) { + if (!CORE_ERROR_CODE_SET.has(code)) + code = "INTERNAL_ERROR"; + if (code === "INTERNAL_ERROR") { + return { + code, + message: PUBLIC_CORE_ERROR_MESSAGES[code], + severity: "fatal", + retryable: false, + recoveryRequired: false + }; + } + const details = sanitizeDetails(options.details); + if ((code === "OPERATION_BUSY" && details?.busyScope === undefined) + || (code === "LOCK_UNVERIFIABLE" && details?.lockScope === undefined)) { + return createPublicCoreErrorDto("INTERNAL_ERROR"); + } + const operationId = typeof options.operationId === "string" && UUID_PATTERN.test(options.operationId) + ? options.operationId + : undefined; + return { + code, + message: PUBLIC_CORE_ERROR_MESSAGES[code], + severity: publicSeverity(code), + retryable: true, + recoveryRequired: RECOVERY_CODES.has(code), + ...(operationId ? { operationId } : {}), + ...(details ? { details } : {}) + }; +} +export function sanitizePublicCoreErrorDto(value) { + const source = objectRecord(value); + const candidate = ownValue(source, "code"); + const code = typeof candidate === "string" && CORE_ERROR_CODE_SET.has(candidate) + ? candidate + : "INTERNAL_ERROR"; + return createPublicCoreErrorDto(code, { + operationId: ownValue(source, "operationId"), + details: ownValue(source, "details") + }); +} +export function isCanonicalPublicCoreErrorDto(value) { + const source = record(value); + if (!source) + return false; + const code = ownValue(source, "code"); + if (typeof code !== "string" || !CORE_ERROR_CODE_SET.has(code)) + return false; + const expected = sanitizePublicCoreErrorDto(source); + const sourceKeys = Object.keys(source).sort(); + const expectedKeys = Object.keys(expected).sort(); + if (sourceKeys.length !== expectedKeys.length + || sourceKeys.some((key, index) => key !== expectedKeys[index])) + return false; + for (const key of expectedKeys) { + if (key === "details") + continue; + if (ownValue(source, key) !== expected[key]) + return false; + } + const actualDetails = record(ownValue(source, "details")); + const expectedDetails = expected.details; + if (expectedDetails === undefined) + return actualDetails === null; + if (!actualDetails) + return false; + const actualDetailKeys = Object.keys(actualDetails).sort(); + const expectedDetailKeys = Object.keys(expectedDetails).sort(); + return actualDetailKeys.length === expectedDetailKeys.length + && actualDetailKeys.every((key, index) => (key === expectedDetailKeys[index] + && ownValue(actualDetails, key) === expectedDetails[key])); +} diff --git a/packages/contracts/dist/index.d.ts b/packages/contracts/dist/index.d.ts new file mode 100644 index 0000000..5fcb9fd --- /dev/null +++ b/packages/contracts/dist/index.d.ts @@ -0,0 +1,4 @@ +export * from "./dto.js"; +export * from "./errors.js"; +export * from "./json.js"; +export * from "./protocol.js"; diff --git a/packages/contracts/dist/index.js b/packages/contracts/dist/index.js new file mode 100644 index 0000000..5fcb9fd --- /dev/null +++ b/packages/contracts/dist/index.js @@ -0,0 +1,4 @@ +export * from "./dto.js"; +export * from "./errors.js"; +export * from "./json.js"; +export * from "./protocol.js"; diff --git a/packages/contracts/dist/json.d.ts b/packages/contracts/dist/json.d.ts new file mode 100644 index 0000000..7b7ad79 --- /dev/null +++ b/packages/contracts/dist/json.d.ts @@ -0,0 +1,5 @@ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject; +export type JsonObject = { + [key: string]: JsonValue; +}; diff --git a/packages/contracts/dist/json.js b/packages/contracts/dist/json.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/contracts/dist/json.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/contracts/dist/protocol.d.ts b/packages/contracts/dist/protocol.d.ts new file mode 100644 index 0000000..4e86b00 --- /dev/null +++ b/packages/contracts/dist/protocol.d.ts @@ -0,0 +1,59 @@ +import { type ApplyPlanInput, type CoreMethodMap, type CoreMethodName, type CoreProtocolVersion, type ProgressEvent } from "./dto.js"; +import { type CoreErrorCode, type CoreErrorDto, type CoreErrorSeverity } from "./errors.js"; +export interface CoreRequestEnvelope { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId?: string; + method: M; + payload: CoreMethodMap[M]["input"]; +} +export type CoreResponseEnvelope = { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId?: string; + ok: true; + result: CoreMethodMap[M]["output"]; +} | { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId?: string; + ok: false; + error: CoreErrorDto; +}; +export interface CoreProgressEnvelope { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId: string; + event: "progress"; + progress: ProgressEvent; +} +export interface CoreOperationStartedEnvelope { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId: string; + event: "operation-started"; + operation: "sync" | "switch" | "restore"; +} +export type CoreOperationEventEnvelope = CoreOperationStartedEnvelope | CoreProgressEnvelope; +export declare class ContractValidationError extends Error { + readonly code: "INVALID_INPUT" | "PROTOCOL_VERSION_MISMATCH"; + constructor(code: "INVALID_INPUT" | "PROTOCOL_VERSION_MISMATCH", message: string); +} +export declare function assertProtocolVersion(value: unknown): asserts value is CoreProtocolVersion; +export declare function assertApplyPlanInput(value: unknown): asserts value is ApplyPlanInput; +export declare function assertCoreMethodInput(method: M, value: unknown): asserts value is CoreMethodMap[M]["input"]; +export declare function assertCoreErrorDto(value: unknown): asserts value is CoreErrorDto; +export declare function assertCoreRequestEnvelope(value: unknown): asserts value is CoreRequestEnvelope; +export declare function assertCoreResponseEnvelope(value: unknown, expectedRequestId?: string): asserts value is CoreResponseEnvelope; +export declare function assertCoreMethodOutput(method: M, value: unknown): asserts value is CoreMethodMap[M]["output"]; +export declare function assertProgressEvent(value: unknown): asserts value is ProgressEvent; +export declare function assertCoreOperationStartedEnvelope(value: unknown, expectedRequestId?: string, expectedOperationId?: string): asserts value is CoreOperationStartedEnvelope; +export declare function assertCoreProgressEnvelope(value: unknown, expectedRequestId?: string, expectedOperationId?: string): asserts value is CoreProgressEnvelope; +export declare function assertCoreOperationEventEnvelope(value: unknown, expectedRequestId?: string, expectedOperationId?: string): asserts value is CoreOperationEventEnvelope; +export declare function createCoreOperationStartedEnvelope(requestId: string, operationId: string, operation: CoreOperationStartedEnvelope["operation"]): CoreOperationStartedEnvelope; +export declare function createCoreProgressEnvelope(requestId: string, operationId: string, progress: ProgressEvent): CoreProgressEnvelope; +export declare function createCoreRequestEnvelope(method: M, payload: CoreMethodMap[M]["input"], requestId: string, operationId?: string): CoreRequestEnvelope; +export declare function createCoreSuccessEnvelope(request: CoreRequestEnvelope, result: CoreMethodMap[M]["output"], operationId?: string): CoreResponseEnvelope; +export declare function createCoreFailureEnvelope(request: CoreRequestEnvelope, error: CoreErrorDto, operationId?: string): CoreResponseEnvelope; +export declare function isCoreErrorCode(value: unknown): value is CoreErrorCode; +export declare function isCoreErrorSeverity(value: unknown): value is CoreErrorSeverity; diff --git a/packages/contracts/dist/protocol.js b/packages/contracts/dist/protocol.js new file mode 100644 index 0000000..78bbf11 --- /dev/null +++ b/packages/contracts/dist/protocol.js @@ -0,0 +1,712 @@ +import { CORE_METHODS, CORE_PROTOCOL_VERSION } from "./dto.js"; +import { CORE_ERROR_CODES, isCanonicalPublicCoreErrorDto } from "./errors.js"; +const METHOD_SET = new Set(CORE_METHODS); +const ERROR_CODE_SET = new Set(CORE_ERROR_CODES); +const SEVERITY_SET = new Set(["info", "warning", "error", "fatal"]); +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function isNonEmptyString(value) { + return typeof value === "string" && value.length > 0; +} +function exactObjectKeys(value, allowed) { + const allowedSet = new Set(allowed); + return Object.keys(value).every((key) => allowedSet.has(key)); +} +function assertProfileSelector(value) { + if (!isRecord(value) + || !exactObjectKeys(value, ["profileId", "profileRevision"]) + || typeof value.profileId !== "string" + || !/^[A-Za-z0-9._-]{1,80}$/.test(value.profileId) + || (value.profileRevision !== undefined + && (!isNonEmptyString(value.profileRevision) || value.profileRevision.length > 512))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid profile selector."); + } +} +function assertProfileInput(value, allowed) { + if (!isRecord(value) + || !exactObjectKeys(value, ["profile", ...allowed])) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core method input."); + } + assertProfileSelector(value.profile); +} +export class ContractValidationError extends Error { + code; + constructor(code, message) { + super(message); + this.name = "ContractValidationError"; + this.code = code; + } +} +export function assertProtocolVersion(value) { + if (value !== CORE_PROTOCOL_VERSION) { + throw new ContractValidationError("PROTOCOL_VERSION_MISMATCH", `Unsupported Core protocol version: ${String(value)}.`); + } +} +export function assertApplyPlanInput(value) { + if (!isRecord(value) + || Object.keys(value).sort().join(",") !== "planId,schemaVersion" + || value.schemaVersion !== 1 + || !isNonEmptyString(value.planId)) { + throw new ContractValidationError("INVALID_INPUT", "Apply accepts exactly { schemaVersion: 1, planId }."); + } +} +export function assertCoreMethodInput(method, value) { + switch (method) { + case "applySync": + case "applySwitch": + case "applyRestore": + assertApplyPlanInput(value); + return; + case "getStatus": + case "listBackups": + case "getDiagnostics": + assertProfileInput(value, []); + return; + case "prepareSync": + assertProfileInput(value, ["keepCount"]); + if (value.keepCount !== undefined + && (!Number.isSafeInteger(value.keepCount) || Number(value.keepCount) < 1)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Sync retention count."); + } + return; + case "prepareSwitch": + assertProfileInput(value, ["provider", "modelMode", "model", "keepCount"]); + if (!isNonEmptyString(value.provider) + || !["provider-default", "keep-root-model", "explicit"].includes(String(value.modelMode)) + || (value.modelMode === "explicit" && !isNonEmptyString(value.model)) + || (value.modelMode !== "explicit" && value.model !== undefined) + || (value.keepCount !== undefined + && (!Number.isSafeInteger(value.keepCount) || Number(value.keepCount) < 1))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Switch Provider input."); + } + return; + case "prepareRestore": + assertProfileInput(value, [ + "backupId", + "restoreConfig", + "restoreDatabase", + "restoreSessions", + "allowSqliteHomeRelocation", + "relocationTargetProfileId" + ]); + if (!isNonEmptyString(value.backupId) + || typeof value.restoreConfig !== "boolean" + || typeof value.restoreDatabase !== "boolean" + || typeof value.restoreSessions !== "boolean" + || (value.allowSqliteHomeRelocation !== undefined + && typeof value.allowSqliteHomeRelocation !== "boolean") + || (value.relocationTargetProfileId !== undefined + && (typeof value.relocationTargetProfileId !== "string" + || !/^[A-Za-z0-9._-]{1,80}$/.test(value.relocationTargetProfileId))) + || (value.allowSqliteHomeRelocation === true + && (value.restoreConfig !== false || value.relocationTargetProfileId === undefined)) + || (value.relocationTargetProfileId !== undefined + && value.allowSqliteHomeRelocation !== true)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Restore input."); + } + return; + case "pruneBackups": + assertProfileInput(value, ["keepCount"]); + if (!Number.isSafeInteger(value.keepCount) || Number(value.keepCount) < 0) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Prune retention count."); + } + return; + case "listHistory": + assertProfileInput(value, ["page", "pageSize", "query", "project", "provider", "archived"]); + if ((value.page !== undefined && (!Number.isSafeInteger(value.page) || Number(value.page) < 1)) + || (value.pageSize !== undefined + && (!Number.isSafeInteger(value.pageSize) + || Number(value.pageSize) < 10 + || Number(value.pageSize) > 100)) + || ["query", "project", "provider"].some((key) => (value[key] !== undefined && typeof value[key] !== "string")) + || (value.archived !== undefined + && !["all", "active", "archived"].includes(String(value.archived)))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid History list input."); + } + return; + case "getHistorySession": + assertProfileInput(value, ["sessionId", "messageLimit"]); + if (!isNonEmptyString(value.sessionId) + || (value.messageLimit !== undefined + && (!Number.isSafeInteger(value.messageLimit) + || Number(value.messageLimit) < 1 + || Number(value.messageLimit) > 200))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid History detail input."); + } + return; + case "startWatch": + assertProfileInput(value, ["includeStateDb", "debounceMs", "once"]); + if ((value.includeStateDb !== undefined && typeof value.includeStateDb !== "boolean") + || (value.once !== undefined && typeof value.once !== "boolean") + || (value.debounceMs !== undefined + && (!Number.isSafeInteger(value.debounceMs) || Number(value.debounceMs) < 0))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Watch input."); + } + return; + case "stopWatch": + if (!isRecord(value) + || !exactObjectKeys(value, ["watchId"]) + || !isNonEmptyString(value.watchId)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Watch reference."); + } + return; + case "getWatchStatus": + if (!isRecord(value) + || !exactObjectKeys(value, ["watchId"]) + || (value.watchId !== undefined && !isNonEmptyString(value.watchId))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Watch status input."); + } + return; + default: + throw new ContractValidationError("INVALID_INPUT", "Unknown Core method input."); + } +} +export function assertCoreErrorDto(value) { + if (!isCanonicalPublicCoreErrorDto(value)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid public CoreErrorDto."); + } +} +export function assertCoreRequestEnvelope(value) { + if (!isRecord(value)) { + throw new ContractValidationError("INVALID_INPUT", "Core request envelope must be an object."); + } + const allowedKeys = new Set(["protocolVersion", "requestId", "operationId", "method", "payload"]); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new ContractValidationError("INVALID_INPUT", "Core request envelope has unknown fields."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || !isNonEmptyString(value.method) + || !METHOD_SET.has(value.method) + || !isRecord(value.payload)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core request envelope."); + } + if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core request operationId."); + } + assertCoreMethodInput(value.method, value.payload); +} +export function assertCoreResponseEnvelope(value, expectedRequestId) { + if (!isRecord(value)) { + throw new ContractValidationError("INVALID_INPUT", "Core response envelope must be an object."); + } + const allowedKeys = value.ok === true + ? new Set(["protocolVersion", "requestId", "operationId", "ok", "result"]) + : new Set(["protocolVersion", "requestId", "operationId", "ok", "error"]); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new ContractValidationError("INVALID_INPUT", "Core response envelope has unknown fields."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || (expectedRequestId !== undefined && value.requestId !== expectedRequestId) + || typeof value.ok !== "boolean") { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core response envelope."); + } + if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core response operationId."); + } + if (value.ok) { + if (!("result" in value) || "error" in value) { + throw new ContractValidationError("INVALID_INPUT", "Invalid successful Core response."); + } + } + else { + if (!("error" in value) || "result" in value) { + throw new ContractValidationError("INVALID_INPUT", "Invalid failed Core response."); + } + assertCoreErrorDto(value.error); + } +} +function requireSchemaObject(value, label) { + if (!isRecord(value) || value.schemaVersion !== 1) { + throw new ContractValidationError("INVALID_INPUT", `Invalid ${label}.`); + } + return value; +} +function requireStringArray(value, label) { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new ContractValidationError("INVALID_INPUT", `Invalid ${label}.`); + } +} +function isNonNegativeInteger(value) { + return Number.isSafeInteger(value) && Number(value) >= 0; +} +function isNullableString(value) { + return value === null || typeof value === "string"; +} +function isJsonValue(value, depth = 0) { + if (depth > 16) + return false; + if (value === null || typeof value === "string" || typeof value === "boolean") + return true; + if (typeof value === "number") + return Number.isFinite(value); + if (Array.isArray(value)) + return value.every((entry) => isJsonValue(entry, depth + 1)); + if (!isRecord(value)) + return false; + return Object.values(value).every((entry) => isJsonValue(entry, depth + 1)); +} +function isProviderDistribution(value) { + if (!isRecord(value)) + return false; + return Object.values(value).every((counts) => (isRecord(counts) && Object.values(counts).every(isNonNegativeInteger))); +} +const DIAGNOSTIC_TRANSACTION_STATES = new Set([ + "prepared", + "applying", + "applied", + "skipped", + "committing", + "committed-pending-ack", + "rollback-pending", + "rollingBack", + "recovery-required", + "recoveryRequired", + "unknown" +]); +function isDiagnosticIdentifier(value) { + return typeof value === "string" + && /^[A-Za-z0-9._()-]{1,200}$/.test(value); +} +function isUuid(value) { + return typeof value === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} +function isDiagnosticCountMap(value) { + if (!isRecord(value) || Object.keys(value).length > 512) + return false; + return Object.entries(value).every(([provider, count]) => isDiagnosticIdentifier(provider) && isNonNegativeInteger(count)); +} +function isDiagnosticDistribution(value, allowUnreadable = false) { + if (!isRecord(value) + || !exactObjectKeys(value, allowUnreadable + ? ["sessions", "archived_sessions", "unreadable"] + : ["sessions", "archived_sessions"]) + || !("sessions" in value) + || !("archived_sessions" in value) + || !isDiagnosticCountMap(value.sessions) + || !isDiagnosticCountMap(value.archived_sessions)) { + return false; + } + return !allowUnreadable || value.unreadable === undefined || value.unreadable === true; +} +function isDiagnosticPendingTransaction(value) { + return isRecord(value) + && Object.keys(value).sort().join(",") + === "operationId,operationKind,preRestoreSnapshotId,sourceBackupId,state" + && (value.operationId === null || isUuid(value.operationId)) + && ["sync", "switch", "restore"].includes(String(value.operationKind)) + && DIAGNOSTIC_TRANSACTION_STATES.has(String(value.state)) + && (value.sourceBackupId === null || isDiagnosticIdentifier(value.sourceBackupId)) + && (value.preRestoreSnapshotId === null + || isDiagnosticIdentifier(value.preRestoreSnapshotId)); +} +function isDiagnosticOperationState(value) { + if (value === null) + return true; + if (!isRecord(value) + || !exactObjectKeys(value, [ + "operationId", + "operation", + "actor", + "startedAt", + "busyScope", + "lockState", + "errorCode" + ])) { + return false; + } + return (value.operationId === undefined || isUuid(value.operationId)) + && (value.operation === undefined + || ["sync", "switch", "restore", "prune", "watch", "unknown"].includes(String(value.operation))) + && (value.actor === undefined || ["manual", "watch", "external"].includes(String(value.actor))) + && (value.startedAt === undefined + || (isNonEmptyString(value.startedAt) && value.startedAt.length <= 64)) + && (value.busyScope === undefined || ["codex-home", "state-db"].includes(String(value.busyScope))) + && (value.lockState === undefined + || (isDiagnosticIdentifier(value.lockState) && value.lockState.length <= 80)) + && (value.errorCode === undefined + || (typeof value.errorCode === "string" && /^[A-Z0-9_]{1,80}$/.test(value.errorCode))); +} +function assertDiagnosticsSnapshot(value) { + const diagnostics = requireSchemaObject(value, "DiagnosticsSnapshot"); + const runtime = isRecord(diagnostics.runtime) ? diagnostics.runtime : null; + const storage = isRecord(diagnostics.storage) ? diagnostics.storage : null; + const provider = isRecord(diagnostics.provider) ? diagnostics.provider : null; + const safety = isRecord(diagnostics.safety) ? diagnostics.safety : null; + const valid = exactObjectKeys(diagnostics, [ + "schemaVersion", + "generatedAt", + "runtime", + "storage", + "provider", + "safety" + ]) + && isNonEmptyString(diagnostics.generatedAt) + && diagnostics.generatedAt.length <= 64 + && runtime !== null + && Object.keys(runtime).sort().join(",") === "arch,node,platform" + && [runtime.node, runtime.platform, runtime.arch].every((entry) => typeof entry === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(entry)) + && storage !== null + && Object.keys(storage).sort().join(",") + === "sqliteHomeSource,sqliteSupported,stateDbFound" + && ["cli", "config", "env", "default", "unknown"].includes(String(storage.sqliteHomeSource)) + && typeof storage.stateDbFound === "boolean" + && typeof storage.sqliteSupported === "boolean" + && provider !== null + && Object.keys(provider).sort().join(",") + === "configured,current,implicit,rolloutCounts,sqliteCounts" + && isDiagnosticIdentifier(provider.current) + && typeof provider.implicit === "boolean" + && Array.isArray(provider.configured) + && provider.configured.length <= 256 + && provider.configured.every(isDiagnosticIdentifier) + && isDiagnosticDistribution(provider.rolloutCounts) + && (provider.sqliteCounts === null + || isDiagnosticDistribution(provider.sqliteCounts, true)) + && safety !== null + && exactObjectKeys(safety, [ + "storageRevision", + "pendingRecovery", + "pendingTransactions", + "operationInProgress", + "rolloutScanComplete", + "lockedRolloutCount", + "projectThreadVisibilityAvailable" + ]) + && (safety.storageRevision === undefined + || (typeof safety.storageRevision === "string" + && /^[A-Za-z0-9_-]{1,256}$/.test(safety.storageRevision))) + && typeof safety.pendingRecovery === "boolean" + && Array.isArray(safety.pendingTransactions) + && safety.pendingTransactions.length <= 256 + && safety.pendingTransactions.every(isDiagnosticPendingTransaction) + && isDiagnosticOperationState(safety.operationInProgress) + && typeof safety.rolloutScanComplete === "boolean" + && isNonNegativeInteger(safety.lockedRolloutCount) + && typeof safety.projectThreadVisibilityAvailable === "boolean"; + if (!valid) { + throw new ContractValidationError("INVALID_INPUT", "Invalid DiagnosticsSnapshot."); + } +} +function isHistorySummary(value) { + if (!isRecord(value)) + return false; + return isNonEmptyString(value.id) + && typeof value.title === "string" + && !("cwd" in value) + && isNonEmptyString(value.provider) + && typeof value.archived === "boolean" + && isNonEmptyString(value.updatedAt) + && isNonNegativeInteger(value.messageCount) + && (value.messageCountKnown === undefined || typeof value.messageCountKnown === "boolean") + && (value.model === undefined || isNullableString(value.model)) + && (value.createdAt === undefined || isNonEmptyString(value.createdAt)); +} +function assertWatchSnapshot(value) { + const snapshot = requireSchemaObject(value, "WatchSnapshot"); + if (!isNonEmptyString(snapshot.watchId) + || !["running", "stopping", "stopped"].includes(String(snapshot.status)) + || !isNonEmptyString(snapshot.startedAt) + || !isNullableString(snapshot.stoppedAt) + || !isNullableString(snapshot.stopReason) + || typeof snapshot.includeStateDb !== "boolean" + || typeof snapshot.once !== "boolean") { + throw new ContractValidationError("INVALID_INPUT", "Invalid WatchSnapshot."); + } +} +export function assertCoreMethodOutput(method, value) { + switch (method) { + case "getStatus": { + const status = requireSchemaObject(value, "StatusSnapshot"); + const profile = isRecord(status.profile) ? status.profile : null; + if (!isNonEmptyString(status.snapshotAt) + || !isNonEmptyString(status.storageRevision) + || !profile + || !isNonEmptyString(profile.id) + || !isNonEmptyString(profile.revision) + || !isNonEmptyString(status.currentProvider) + || !isProviderDistribution(status.rolloutCounts) + || (status.modelCounts !== undefined && !isProviderDistribution(status.modelCounts)) + || !("sqliteCounts" in status) + || !isJsonValue(status.sqliteCounts) + || "codexHome" in status + || "sqliteHome" in status + || !isNonEmptyString(status.codexHomeSource) + || !isNonEmptyString(status.sqliteHomeSource) + || !isRecord(status.backupSummary) + || !isNonNegativeInteger(status.backupSummary.count) + || !isNonNegativeInteger(status.backupSummary.totalBytes) + || typeof status.pendingRecovery !== "boolean" + || !Array.isArray(status.pendingTransactions) + || status.pendingTransactions.some((entry) => !isRecord(entry) || !isJsonValue(entry)) + || !(status.operationInProgress === null + || (isRecord(status.operationInProgress) && isJsonValue(status.operationInProgress))) + || typeof status.rolloutScanComplete !== "boolean" + || !Array.isArray(status.lockedRolloutFiles) + || status.lockedRolloutFiles.some((entry) => typeof entry !== "string") + || (status.currentModel !== undefined && !isNullableString(status.currentModel))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid StatusSnapshot."); + } + return; + } + case "prepareSync": + case "prepareSwitch": + case "prepareRestore": { + const plan = requireSchemaObject(value, "PlanSummary"); + if (!isNonEmptyString(plan.planId) + || !["sync", "switch", "restore"].includes(String(plan.operation)) + || !isNonEmptyString(plan.createdAt) + || !isNonEmptyString(plan.expiresAt) + || !isRecord(plan.profile) + || !isNonEmptyString(plan.profile.id) + || !isNonEmptyString(plan.profile.revision) + || !isNonEmptyString(plan.storageRevision) + || !isNonEmptyString(plan.configRevision) + || !isNonEmptyString(plan.rolloutRevision) + || !isNonEmptyString(plan.stateDbRevision) + || (plan.backupRevision !== undefined && !isNonEmptyString(plan.backupRevision)) + || !isRecord(plan.target) + || !isJsonValue(plan.target) + || !isRecord(plan.impact) + || !isJsonValue(plan.impact) + || !Array.isArray(plan.warnings) + || plan.warnings.some((entry) => typeof entry !== "string") + || typeof plan.requiresConfirmation !== "boolean") { + throw new ContractValidationError("INVALID_INPUT", "Invalid PlanSummary."); + } + return; + } + case "applySync": + case "applySwitch": + case "applyRestore": { + const result = requireSchemaObject(value, "OperationResult"); + if (!isNonEmptyString(result.operationId) + || !["sync", "switch", "restore"].includes(String(result.operation)) + || !["completed", "partial", "failed_rolled_back", "recovery_required", "cancelled", "stale"].includes(String(result.outcome)) + || !(result.backup === null + || (isRecord(result.backup) && isNonEmptyString(result.backup.backupId)))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid OperationResult."); + } + requireStringArray(result.warnings, "OperationResult warnings"); + if (!("result" in result) || !isJsonValue(result.result)) { + throw new ContractValidationError("INVALID_INPUT", "OperationResult result is required."); + } + return; + } + case "listBackups": { + if (!isRecord(value) || !Array.isArray(value.backups) + || value.backups.some((entry) => { + const backup = isRecord(entry) ? entry : null; + return !backup + || !isNonEmptyString(backup.backupId) + || !isNonNegativeInteger(backup.sizeBytes) + || !isRecord(backup.metadata) + || !isJsonValue(backup.metadata) + || (backup.createdAt !== undefined && !isNonEmptyString(backup.createdAt)); + })) { + throw new ContractValidationError("INVALID_INPUT", "Invalid BackupList."); + } + return; + } + case "pruneBackups": { + if (!isRecord(value) + || !isNonNegativeInteger(value.deletedCount) + || !isNonNegativeInteger(value.remainingCount) + || !isNonNegativeInteger(value.freedBytes)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid PruneBackupsResult."); + } + return; + } + case "listHistory": { + if (!isRecord(value) + || !Number.isSafeInteger(value.page) + || Number(value.page) < 1 + || !Number.isSafeInteger(value.pageSize) + || Number(value.pageSize) < 1 + || !isNonNegativeInteger(value.total) + || typeof value.hasNextPage !== "boolean" + || !Array.isArray(value.sessions) + || value.sessions.some((entry) => !isHistorySummary(entry))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid HistoryPage."); + } + return; + } + case "getHistorySession": { + if (!isRecord(value) + || !isHistorySummary(value.session) + || !Array.isArray(value.messages) + || value.messages.some((entry) => { + const message = isRecord(entry) ? entry : null; + return !message + || !isNonEmptyString(message.role) + || typeof message.text !== "string" + || !isNonNegativeInteger(message.sequence) + || (message.timestamp !== undefined && !isNonEmptyString(message.timestamp)); + }) + || typeof value.truncated !== "boolean" + || !isNonNegativeInteger(value.returnedMessageCount) + || Number(value.returnedMessageCount) !== value.messages.length) { + throw new ContractValidationError("INVALID_INPUT", "Invalid HistorySessionDetail."); + } + return; + } + case "startWatch": + case "stopWatch": + assertWatchSnapshot(value); + return; + case "getWatchStatus": { + if (isRecord(value) && Array.isArray(value.watches)) { + requireSchemaObject(value, "WatchStatusList"); + value.watches.forEach(assertWatchSnapshot); + return; + } + assertWatchSnapshot(value); + return; + } + case "getDiagnostics": { + assertDiagnosticsSnapshot(value); + return; + } + default: + throw new ContractValidationError("INVALID_INPUT", "Unknown Core method output."); + } +} +export function assertProgressEvent(value) { + if (!isRecord(value)) { + throw new ContractValidationError("INVALID_INPUT", "Progress event must be an object."); + } + const allowed = new Set(["stage", "status", "progress", "count"]); + if (Object.keys(value).some((key) => !allowed.has(key)) + || !isNonEmptyString(value.stage) + || !isNonEmptyString(value.status) + || value.stage.length > 80 + || value.status.length > 40 + || (value.progress !== undefined + && (typeof value.progress !== "number" + || !Number.isFinite(value.progress) + || value.progress < 0 + || value.progress > 1)) + || (value.count !== undefined + && (!Number.isSafeInteger(value.count) || Number(value.count) < 0))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid ProgressEvent."); + } +} +export function assertCoreOperationStartedEnvelope(value, expectedRequestId, expectedOperationId) { + if (!isRecord(value) + || !exactObjectKeys(value, [ + "protocolVersion", + "requestId", + "operationId", + "event", + "operation" + ])) { + throw new ContractValidationError("INVALID_INPUT", "Invalid operation-started envelope."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || value.requestId.length > 512 + || (expectedRequestId !== undefined && value.requestId !== expectedRequestId) + || !isNonEmptyString(value.operationId) + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.operationId) + || (expectedOperationId !== undefined && value.operationId !== expectedOperationId) + || value.event !== "operation-started" + || !["sync", "switch", "restore"].includes(String(value.operation))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid operation-started envelope."); + } +} +export function assertCoreProgressEnvelope(value, expectedRequestId, expectedOperationId) { + if (!isRecord(value) + || !exactObjectKeys(value, [ + "protocolVersion", + "requestId", + "operationId", + "event", + "progress" + ])) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core progress envelope."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || value.requestId.length > 512 + || (expectedRequestId !== undefined && value.requestId !== expectedRequestId) + || !isNonEmptyString(value.operationId) + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.operationId) + || (expectedOperationId !== undefined && value.operationId !== expectedOperationId) + || value.event !== "progress") { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core progress envelope."); + } + assertProgressEvent(value.progress); +} +export function assertCoreOperationEventEnvelope(value, expectedRequestId, expectedOperationId) { + if (isRecord(value) && value.event === "operation-started") { + assertCoreOperationStartedEnvelope(value, expectedRequestId, expectedOperationId); + return; + } + assertCoreProgressEnvelope(value, expectedRequestId, expectedOperationId); +} +export function createCoreOperationStartedEnvelope(requestId, operationId, operation) { + const envelope = { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId, + operationId, + event: "operation-started", + operation + }; + assertCoreOperationStartedEnvelope(envelope); + return envelope; +} +export function createCoreProgressEnvelope(requestId, operationId, progress) { + const envelope = { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId, + operationId, + event: "progress", + progress + }; + assertCoreProgressEnvelope(envelope); + return envelope; +} +export function createCoreRequestEnvelope(method, payload, requestId, operationId) { + const envelope = { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId, + ...(operationId ? { operationId } : {}), + method, + payload + }; + assertCoreRequestEnvelope(envelope); + return envelope; +} +export function createCoreSuccessEnvelope(request, result, operationId) { + assertCoreMethodOutput(request.method, result); + return { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId: request.requestId, + ...(operationId ?? request.operationId + ? { operationId: operationId ?? request.operationId } + : {}), + ok: true, + result + }; +} +export function createCoreFailureEnvelope(request, error, operationId) { + assertCoreErrorDto(error); + return { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId: request.requestId, + ...(operationId ?? error.operationId ?? request.operationId + ? { operationId: operationId ?? error.operationId ?? request.operationId } + : {}), + ok: false, + error + }; +} +export function isCoreErrorCode(value) { + return typeof value === "string" && ERROR_CODE_SET.has(value); +} +export function isCoreErrorSeverity(value) { + return typeof value === "string" && SEVERITY_SET.has(value); +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..536f7f6 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,22 @@ +{ + "name": "@codex-provider-sync/contracts", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "test": "node --test checks/contracts.contract.mjs" + }, + "engines": { + "node": ">=24" + }, + "devDependencies": { + "typescript": "7.0.2" + } +} diff --git a/packages/contracts/src/dto.ts b/packages/contracts/src/dto.ts new file mode 100644 index 0000000..bbce304 --- /dev/null +++ b/packages/contracts/src/dto.ts @@ -0,0 +1,340 @@ +import type { JsonObject, JsonValue } from "./json.js"; + +export const CONTRACT_SCHEMA_VERSION = 1 as const; +export const CORE_PROTOCOL_VERSION = 1 as const; + +export type ContractSchemaVersion = typeof CONTRACT_SCHEMA_VERSION; +export type CoreProtocolVersion = typeof CORE_PROTOCOL_VERSION; +export type OperationKind = "sync" | "switch" | "restore" | "prune" | "watch"; +export type OperationOutcome = + | "completed" + | "partial" + | "failed_rolled_back" + | "recovery_required" + | "cancelled" + | "stale"; + +export interface ProfileSelector { + profileId: string; + profileRevision?: string; +} + +export interface GetStatusInput { + profile: ProfileSelector; +} + +export interface PrepareSyncInput extends GetStatusInput { + keepCount?: number; +} + +export type SwitchModelMode = "provider-default" | "keep-root-model" | "explicit"; + +export interface PrepareSwitchInput extends GetStatusInput { + provider: string; + modelMode: SwitchModelMode; + model?: string; + keepCount?: number; +} + +export interface ApplyPlanInput { + schemaVersion: ContractSchemaVersion; + planId: string; +} + +export interface ListBackupsInput extends GetStatusInput {} + +export interface PrepareRestoreInput extends GetStatusInput { + backupId: string; + restoreConfig: boolean; + restoreDatabase: boolean; + restoreSessions: boolean; + allowSqliteHomeRelocation?: boolean; + relocationTargetProfileId?: string; +} + +export interface PruneBackupsInput extends GetStatusInput { + keepCount: number; +} + +export interface ListHistoryInput extends GetStatusInput { + page?: number; + pageSize?: number; + query?: string; + project?: string; + provider?: string; + archived?: "all" | "active" | "archived"; +} + +export interface GetHistorySessionInput extends GetStatusInput { + sessionId: string; + messageLimit?: number; +} + +export interface StartWatchInput extends GetStatusInput { + includeStateDb?: boolean; + debounceMs?: number; + once?: boolean; +} + +export interface WatchReferenceInput { + watchId: string; +} + +export interface GetWatchStatusInput { + watchId?: string; +} + +export interface GetDiagnosticsInput extends GetStatusInput {} + +export type ProviderDistribution = Record>; + +export interface StatusSnapshot { + schemaVersion: ContractSchemaVersion; + snapshotAt: string; + storageRevision: string; + profile: { + id: string; + revision: string; + }; + currentProvider: string; + currentModel?: string | null; + rolloutCounts: ProviderDistribution; + modelCounts?: ProviderDistribution; + sqliteCounts: JsonValue; + codexHomeSource: string; + sqliteHomeSource: string; + backupSummary: { + count: number; + totalBytes: number; + }; + pendingRecovery: boolean; + pendingTransactions: JsonObject[]; + operationInProgress: JsonObject | null; + rolloutScanComplete: boolean; + lockedRolloutFiles: string[]; + [extension: string]: JsonValue | undefined; +} + +export interface PlanSummary { + schemaVersion: ContractSchemaVersion; + planId: string; + operation: "sync" | "switch" | "restore"; + createdAt: string; + expiresAt: string; + profile: { + id: string; + revision: string; + }; + storageRevision: string; + configRevision: string; + rolloutRevision: string; + stateDbRevision: string; + backupRevision?: string; + target: JsonObject; + impact: JsonObject; + warnings: string[]; + requiresConfirmation: boolean; +} + +export interface ManagedBackup { + backupId: string; + createdAt?: string; + sizeBytes: number; + metadata: JsonObject; +} + +export interface BackupList { + backups: ManagedBackup[]; +} + +export interface OperationResult { + schemaVersion: ContractSchemaVersion; + operationId: string; + operation: "sync" | "switch" | "restore"; + outcome: OperationOutcome; + backup: { backupId: string } | null; + warnings: string[]; + result: Result; +} + +export interface PruneBackupsResult { + deletedCount: number; + remainingCount: number; + freedBytes: number; +} + +export interface HistorySessionSummary { + id: string; + title: string; + provider: string; + model?: string | null; + archived: boolean; + createdAt?: string; + updatedAt: string; + messageCount: number; + messageCountKnown?: boolean; +} + +export interface HistoryPage { + page: number; + pageSize: number; + total: number; + hasNextPage: boolean; + sessions: HistorySessionSummary[]; +} + +export interface HistoryMessage { + role: string; + text: string; + timestamp?: string; + sequence: number; +} + +export interface HistorySessionDetail { + session: HistorySessionSummary; + messages: HistoryMessage[]; + truncated: boolean; + returnedMessageCount: number; +} + +export interface WatchSnapshot { + schemaVersion: ContractSchemaVersion; + watchId: string; + status: "running" | "stopping" | "stopped"; + startedAt: string; + stoppedAt: string | null; + stopReason: string | null; + includeStateDb: boolean; + once: boolean; +} + +export interface WatchStatusList { + schemaVersion: ContractSchemaVersion; + watches: WatchSnapshot[]; +} + +export interface DiagnosticsRuntime { + node: string; + platform: string; + arch: string; +} + +export type DiagnosticsSqliteHomeSource = "cli" | "config" | "env" | "default" | "unknown"; + +export interface DiagnosticsStorage { + sqliteHomeSource: DiagnosticsSqliteHomeSource; + stateDbFound: boolean; + sqliteSupported: boolean; +} + +export interface DiagnosticsProviderDistribution { + sessions: Record; + archived_sessions: Record; +} + +export interface DiagnosticsSqliteDistribution extends DiagnosticsProviderDistribution { + unreadable?: true; +} + +export interface DiagnosticsProvider { + current: string; + implicit: boolean; + configured: string[]; + rolloutCounts: DiagnosticsProviderDistribution; + sqliteCounts: DiagnosticsSqliteDistribution | null; +} + +export type DiagnosticsTransactionState = + | "prepared" + | "applying" + | "applied" + | "skipped" + | "committing" + | "committed-pending-ack" + | "rollback-pending" + | "rollingBack" + | "recovery-required" + | "recoveryRequired" + | "unknown"; + +export interface DiagnosticsPendingTransaction { + operationId: string | null; + operationKind: "sync" | "switch" | "restore"; + state: DiagnosticsTransactionState; + sourceBackupId: string | null; + preRestoreSnapshotId: string | null; +} + +export interface DiagnosticsOperationState { + operationId?: string; + operation?: "sync" | "switch" | "restore" | "prune" | "watch" | "unknown"; + actor?: "manual" | "watch" | "external"; + startedAt?: string; + busyScope?: "codex-home" | "state-db"; + lockState?: string; + errorCode?: string; +} + +export interface DiagnosticsSafety { + storageRevision?: string; + pendingRecovery: boolean; + pendingTransactions: DiagnosticsPendingTransaction[]; + operationInProgress: DiagnosticsOperationState | null; + rolloutScanComplete: boolean; + lockedRolloutCount: number; + projectThreadVisibilityAvailable: boolean; +} + +export interface DiagnosticsSnapshot { + schemaVersion: ContractSchemaVersion; + generatedAt: string; + runtime: DiagnosticsRuntime; + storage: DiagnosticsStorage; + provider: DiagnosticsProvider; + safety: DiagnosticsSafety; +} + +export interface ProgressEvent { + stage: string; + status: string; + progress?: number; + count?: number; +} + +export const CORE_METHODS = [ + "getStatus", + "prepareSync", + "applySync", + "prepareSwitch", + "applySwitch", + "listBackups", + "prepareRestore", + "applyRestore", + "pruneBackups", + "listHistory", + "getHistorySession", + "startWatch", + "stopWatch", + "getWatchStatus", + "getDiagnostics" +] as const; + +export type CoreMethodName = typeof CORE_METHODS[number]; + +export interface CoreMethodMap { + getStatus: { input: GetStatusInput; output: StatusSnapshot }; + prepareSync: { input: PrepareSyncInput; output: PlanSummary }; + applySync: { input: ApplyPlanInput; output: OperationResult }; + prepareSwitch: { input: PrepareSwitchInput; output: PlanSummary }; + applySwitch: { input: ApplyPlanInput; output: OperationResult }; + listBackups: { input: ListBackupsInput; output: BackupList }; + prepareRestore: { input: PrepareRestoreInput; output: PlanSummary }; + applyRestore: { input: ApplyPlanInput; output: OperationResult }; + pruneBackups: { input: PruneBackupsInput; output: PruneBackupsResult }; + listHistory: { input: ListHistoryInput; output: HistoryPage }; + getHistorySession: { input: GetHistorySessionInput; output: HistorySessionDetail }; + startWatch: { input: StartWatchInput; output: WatchSnapshot }; + stopWatch: { input: WatchReferenceInput; output: WatchSnapshot }; + getWatchStatus: { input: GetWatchStatusInput; output: WatchSnapshot | WatchStatusList }; + getDiagnostics: { input: GetDiagnosticsInput; output: DiagnosticsSnapshot }; +} diff --git a/packages/contracts/src/errors.ts b/packages/contracts/src/errors.ts new file mode 100644 index 0000000..190a5ff --- /dev/null +++ b/packages/contracts/src/errors.ts @@ -0,0 +1,235 @@ +import type { JsonObject } from "./json.js"; + +export const CORE_ERROR_CODES = [ + "INVALID_INPUT", + "PROFILE_CHANGED", + "STORAGE_CHANGED", + "PLAN_STALE", + "PLAN_EXPIRED", + "STALE_STATE", + "CODEX_HOME_NOT_FOUND", + "STATE_DB_NOT_FOUND", + "SQLITE_UNSUPPORTED_PATH", + "SQLITE_BUSY", + "SQLITE_UNREADABLE", + "ROLLOUT_LOCKED", + "ROLLOUT_CHANGED", + "PENDING_TRANSACTION", + "BACKUP_FAILED", + "SYNC_FAILED_ROLLED_BACK", + "RECOVERY_REQUIRED", + "RESTORE_VALIDATION_FAILED", + "PERMISSION_DENIED", + "OPERATION_BUSY", + "LOCK_UNVERIFIABLE", + "OPERATION_CANCELLED", + "CORE_RUNTIME_CRASHED", + "PROTOCOL_VERSION_MISMATCH", + "INTERNAL_ERROR" +] as const; + +export type CoreErrorCode = typeof CORE_ERROR_CODES[number]; +export type CoreErrorSeverity = "info" | "warning" | "error" | "fatal"; + +export interface CoreErrorDto { + code: CoreErrorCode; + message: string; + severity: CoreErrorSeverity; + retryable: boolean; + recoveryRequired: boolean; + operationId?: string; + details?: JsonObject; +} + +export const PUBLIC_CORE_ERROR_MESSAGES: Readonly> = Object.freeze({ + INVALID_INPUT: "The command input is invalid.", + PROFILE_CHANGED: "The selected profile changed. Prepare the operation again.", + STORAGE_CHANGED: "The resolved storage changed. Prepare the operation again.", + PLAN_STALE: "The prepared operation is stale. Prepare it again.", + PLAN_EXPIRED: "The prepared operation expired. Prepare it again.", + STALE_STATE: "The protected state changed. Prepare the operation again.", + CODEX_HOME_NOT_FOUND: "The selected Codex Home was not found.", + STATE_DB_NOT_FOUND: "The selected state database was not found.", + SQLITE_UNSUPPORTED_PATH: "The selected SQLite path is not supported by this runtime.", + SQLITE_BUSY: "The state database is busy. Close Codex processes and retry.", + SQLITE_UNREADABLE: "The state database is unreadable or malformed.", + ROLLOUT_LOCKED: "One or more rollout files are locked.", + ROLLOUT_CHANGED: "One or more rollout files changed during the operation.", + PENDING_TRANSACTION: "An unfinished transaction must be resolved before another write.", + BACKUP_FAILED: "The required backup could not be completed.", + SYNC_FAILED_ROLLED_BACK: "The operation failed and its changes were rolled back.", + RECOVERY_REQUIRED: "The operation requires explicit recovery.", + RESTORE_VALIDATION_FAILED: "The selected backup or restore target failed validation.", + PERMISSION_DENIED: "The operation does not have permission to access a required resource.", + OPERATION_BUSY: "Another write operation is using the protected resource.", + LOCK_UNVERIFIABLE: "The lock owner or protected resource identity cannot be verified.", + OPERATION_CANCELLED: "The operation was cancelled.", + CORE_RUNTIME_CRASHED: "The Core runtime stopped unexpectedly.", + PROTOCOL_VERSION_MISMATCH: "The client and Core protocol versions are incompatible.", + INTERNAL_ERROR: "An internal error occurred." +}); + +const WARNING_CODES = new Set([ + "PROFILE_CHANGED", + "STORAGE_CHANGED", + "PLAN_STALE", + "PLAN_EXPIRED", + "STALE_STATE", + "SQLITE_BUSY", + "ROLLOUT_LOCKED", + "ROLLOUT_CHANGED", + "OPERATION_BUSY" +]); +const RECOVERY_CODES = new Set(["PENDING_TRANSACTION", "RECOVERY_REQUIRED"]); +const LOCK_SCOPES = new Set(["codex-home", "state-db"]); +const SAFE_REASONS = new Set([ + "profile", + "config", + "storage", + "rollout", + "state-db", + "windows-wsl-unc" +]); +const SAFE_CAUSE_CODES = new Set([ + "ENOENT", + "EACCES", + "EPERM", + "EIO", + "EBUSY", + "SQLITE_BUSY", + "SQLITE_LOCKED", + "SQLITE_CORRUPT", + "SQLITE_NOTADB", + "ERR_SQLITE_ERROR" +]); +const SQLITE_HOME_SOURCES = new Set(["cli", "config", "env", "default"]); +const OPERATION_KINDS = new Set(["sync", "switch", "restore", "prune-backups", "watch"]); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CORE_ERROR_CODE_SET = new Set(CORE_ERROR_CODES); + +function objectRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function record(value: unknown): Record | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null + ? value as Record + : null; +} + +function ownValue(source: Record | null, key: string): unknown { + if (!source) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(source, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} + +function publicSeverity(code: CoreErrorCode): CoreErrorSeverity { + if (code === "OPERATION_CANCELLED") return "info"; + if (code === "CORE_RUNTIME_CRASHED" || code === "INTERNAL_ERROR") return "fatal"; + return WARNING_CODES.has(code) ? "warning" : "error"; +} + +function sanitizeDetails(value: unknown): JsonObject | undefined { + const source = record(value); + if (!source) return undefined; + const details: JsonObject = {}; + const busyScope = ownValue(source, "busyScope"); + const lockScope = ownValue(source, "lockScope"); + const causeCode = ownValue(source, "causeCode"); + const reason = ownValue(source, "reason"); + const missing = ownValue(source, "missing"); + const sqliteHomeSource = ownValue(source, "sqliteHomeSource"); + const operationKind = ownValue(source, "operationKind"); + if (LOCK_SCOPES.has(String(busyScope))) details.busyScope = String(busyScope); + if (LOCK_SCOPES.has(String(lockScope))) details.lockScope = String(lockScope); + if (SAFE_CAUSE_CODES.has(String(causeCode))) details.causeCode = String(causeCode); + if (SAFE_REASONS.has(String(reason))) details.reason = String(reason); + if (missing === "config.toml" || missing === "state_5.sqlite") details.missing = missing; + if (SQLITE_HOME_SOURCES.has(String(sqliteHomeSource))) { + details.sqliteHomeSource = String(sqliteHomeSource); + } + for (const key of ["sqlitePrimaryCode", "sqliteExtendedCode"] as const) { + const candidate = ownValue(source, key); + if (Number.isInteger(candidate) && Number(candidate) >= 0 && Number(candidate) <= 0xffff) { + details[key] = Number(candidate); + } + } + if (OPERATION_KINDS.has(String(operationKind))) details.operationKind = String(operationKind); + return Object.keys(details).length > 0 ? details : undefined; +} + +export function createPublicCoreErrorDto( + code: CoreErrorCode, + options: { operationId?: unknown; details?: unknown } = {} +): CoreErrorDto { + if (!CORE_ERROR_CODE_SET.has(code)) code = "INTERNAL_ERROR"; + if (code === "INTERNAL_ERROR") { + return { + code, + message: PUBLIC_CORE_ERROR_MESSAGES[code], + severity: "fatal", + retryable: false, + recoveryRequired: false + }; + } + const details = sanitizeDetails(options.details); + if ((code === "OPERATION_BUSY" && details?.busyScope === undefined) + || (code === "LOCK_UNVERIFIABLE" && details?.lockScope === undefined)) { + return createPublicCoreErrorDto("INTERNAL_ERROR"); + } + const operationId = typeof options.operationId === "string" && UUID_PATTERN.test(options.operationId) + ? options.operationId + : undefined; + return { + code, + message: PUBLIC_CORE_ERROR_MESSAGES[code], + severity: publicSeverity(code), + retryable: true, + recoveryRequired: RECOVERY_CODES.has(code), + ...(operationId ? { operationId } : {}), + ...(details ? { details } : {}) + }; +} + +export function sanitizePublicCoreErrorDto(value: unknown): CoreErrorDto { + const source = objectRecord(value); + const candidate = ownValue(source, "code"); + const code = typeof candidate === "string" && CORE_ERROR_CODE_SET.has(candidate) + ? candidate as CoreErrorCode + : "INTERNAL_ERROR"; + return createPublicCoreErrorDto(code, { + operationId: ownValue(source, "operationId"), + details: ownValue(source, "details") + }); +} + +export function isCanonicalPublicCoreErrorDto(value: unknown): value is CoreErrorDto { + const source = record(value); + if (!source) return false; + const code = ownValue(source, "code"); + if (typeof code !== "string" || !CORE_ERROR_CODE_SET.has(code)) return false; + const expected = sanitizePublicCoreErrorDto(source); + const sourceKeys = Object.keys(source).sort(); + const expectedKeys = Object.keys(expected).sort(); + if (sourceKeys.length !== expectedKeys.length + || sourceKeys.some((key, index) => key !== expectedKeys[index])) return false; + for (const key of expectedKeys) { + if (key === "details") continue; + if (ownValue(source, key) !== expected[key as keyof CoreErrorDto]) return false; + } + const actualDetails = record(ownValue(source, "details")); + const expectedDetails = expected.details; + if (expectedDetails === undefined) return actualDetails === null; + if (!actualDetails) return false; + const actualDetailKeys = Object.keys(actualDetails).sort(); + const expectedDetailKeys = Object.keys(expectedDetails).sort(); + return actualDetailKeys.length === expectedDetailKeys.length + && actualDetailKeys.every((key, index) => ( + key === expectedDetailKeys[index] + && ownValue(actualDetails, key) === expectedDetails[key] + )); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..5fcb9fd --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,4 @@ +export * from "./dto.js"; +export * from "./errors.js"; +export * from "./json.js"; +export * from "./protocol.js"; diff --git a/packages/contracts/src/json.ts b/packages/contracts/src/json.ts new file mode 100644 index 0000000..ca82299 --- /dev/null +++ b/packages/contracts/src/json.ts @@ -0,0 +1,3 @@ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject; +export type JsonObject = { [key: string]: JsonValue }; diff --git a/packages/contracts/src/protocol.ts b/packages/contracts/src/protocol.ts new file mode 100644 index 0000000..b39b160 --- /dev/null +++ b/packages/contracts/src/protocol.ts @@ -0,0 +1,864 @@ +import { + CORE_METHODS, + CORE_PROTOCOL_VERSION, + type ApplyPlanInput, + type CoreMethodMap, + type CoreMethodName, + type CoreProtocolVersion, + type ProgressEvent +} from "./dto.js"; +import { + CORE_ERROR_CODES, + isCanonicalPublicCoreErrorDto, + type CoreErrorCode, + type CoreErrorDto, + type CoreErrorSeverity +} from "./errors.js"; + +export interface CoreRequestEnvelope { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId?: string; + method: M; + payload: CoreMethodMap[M]["input"]; +} + +export type CoreResponseEnvelope = + | { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId?: string; + ok: true; + result: CoreMethodMap[M]["output"]; + } + | { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId?: string; + ok: false; + error: CoreErrorDto; + }; + +export interface CoreProgressEnvelope { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId: string; + event: "progress"; + progress: ProgressEvent; +} + +export interface CoreOperationStartedEnvelope { + protocolVersion: CoreProtocolVersion; + requestId: string; + operationId: string; + event: "operation-started"; + operation: "sync" | "switch" | "restore"; +} + +export type CoreOperationEventEnvelope = CoreOperationStartedEnvelope | CoreProgressEnvelope; + +const METHOD_SET = new Set(CORE_METHODS); +const ERROR_CODE_SET = new Set(CORE_ERROR_CODES); +const SEVERITY_SET = new Set(["info", "warning", "error", "fatal"]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function exactObjectKeys(value: Record, allowed: readonly string[]): boolean { + const allowedSet = new Set(allowed); + return Object.keys(value).every((key) => allowedSet.has(key)); +} + +function assertProfileSelector(value: unknown): void { + if (!isRecord(value) + || !exactObjectKeys(value, ["profileId", "profileRevision"]) + || typeof value.profileId !== "string" + || !/^[A-Za-z0-9._-]{1,80}$/.test(value.profileId) + || (value.profileRevision !== undefined + && (!isNonEmptyString(value.profileRevision) || value.profileRevision.length > 512))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid profile selector."); + } +} + +function assertProfileInput( + value: unknown, + allowed: readonly string[] +): asserts value is Record { + if (!isRecord(value) + || !exactObjectKeys(value, ["profile", ...allowed])) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core method input."); + } + assertProfileSelector(value.profile); +} + +export class ContractValidationError extends Error { + readonly code: "INVALID_INPUT" | "PROTOCOL_VERSION_MISMATCH"; + + constructor( + code: "INVALID_INPUT" | "PROTOCOL_VERSION_MISMATCH", + message: string + ) { + super(message); + this.name = "ContractValidationError"; + this.code = code; + } +} + +export function assertProtocolVersion(value: unknown): asserts value is CoreProtocolVersion { + if (value !== CORE_PROTOCOL_VERSION) { + throw new ContractValidationError( + "PROTOCOL_VERSION_MISMATCH", + `Unsupported Core protocol version: ${String(value)}.` + ); + } +} + +export function assertApplyPlanInput(value: unknown): asserts value is ApplyPlanInput { + if (!isRecord(value) + || Object.keys(value).sort().join(",") !== "planId,schemaVersion" + || value.schemaVersion !== 1 + || !isNonEmptyString(value.planId)) { + throw new ContractValidationError( + "INVALID_INPUT", + "Apply accepts exactly { schemaVersion: 1, planId }." + ); + } +} + +export function assertCoreMethodInput( + method: M, + value: unknown +): asserts value is CoreMethodMap[M]["input"] { + switch (method) { + case "applySync": + case "applySwitch": + case "applyRestore": + assertApplyPlanInput(value); + return; + case "getStatus": + case "listBackups": + case "getDiagnostics": + assertProfileInput(value, []); + return; + case "prepareSync": + assertProfileInput(value, ["keepCount"]); + if (value.keepCount !== undefined + && (!Number.isSafeInteger(value.keepCount) || Number(value.keepCount) < 1)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Sync retention count."); + } + return; + case "prepareSwitch": + assertProfileInput(value, ["provider", "modelMode", "model", "keepCount"]); + if (!isNonEmptyString(value.provider) + || !["provider-default", "keep-root-model", "explicit"].includes(String(value.modelMode)) + || (value.modelMode === "explicit" && !isNonEmptyString(value.model)) + || (value.modelMode !== "explicit" && value.model !== undefined) + || (value.keepCount !== undefined + && (!Number.isSafeInteger(value.keepCount) || Number(value.keepCount) < 1))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Switch Provider input."); + } + return; + case "prepareRestore": + assertProfileInput(value, [ + "backupId", + "restoreConfig", + "restoreDatabase", + "restoreSessions", + "allowSqliteHomeRelocation", + "relocationTargetProfileId" + ]); + if (!isNonEmptyString(value.backupId) + || typeof value.restoreConfig !== "boolean" + || typeof value.restoreDatabase !== "boolean" + || typeof value.restoreSessions !== "boolean" + || (value.allowSqliteHomeRelocation !== undefined + && typeof value.allowSqliteHomeRelocation !== "boolean") + || (value.relocationTargetProfileId !== undefined + && (typeof value.relocationTargetProfileId !== "string" + || !/^[A-Za-z0-9._-]{1,80}$/.test(value.relocationTargetProfileId))) + || (value.allowSqliteHomeRelocation === true + && (value.restoreConfig !== false || value.relocationTargetProfileId === undefined)) + || (value.relocationTargetProfileId !== undefined + && value.allowSqliteHomeRelocation !== true)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Restore input."); + } + return; + case "pruneBackups": + assertProfileInput(value, ["keepCount"]); + if (!Number.isSafeInteger(value.keepCount) || Number(value.keepCount) < 0) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Prune retention count."); + } + return; + case "listHistory": + assertProfileInput(value, ["page", "pageSize", "query", "project", "provider", "archived"]); + if ((value.page !== undefined && (!Number.isSafeInteger(value.page) || Number(value.page) < 1)) + || (value.pageSize !== undefined + && (!Number.isSafeInteger(value.pageSize) + || Number(value.pageSize) < 10 + || Number(value.pageSize) > 100)) + || ["query", "project", "provider"].some((key) => ( + value[key] !== undefined && typeof value[key] !== "string" + )) + || (value.archived !== undefined + && !["all", "active", "archived"].includes(String(value.archived)))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid History list input."); + } + return; + case "getHistorySession": + assertProfileInput(value, ["sessionId", "messageLimit"]); + if (!isNonEmptyString(value.sessionId) + || (value.messageLimit !== undefined + && (!Number.isSafeInteger(value.messageLimit) + || Number(value.messageLimit) < 1 + || Number(value.messageLimit) > 200))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid History detail input."); + } + return; + case "startWatch": + assertProfileInput(value, ["includeStateDb", "debounceMs", "once"]); + if ((value.includeStateDb !== undefined && typeof value.includeStateDb !== "boolean") + || (value.once !== undefined && typeof value.once !== "boolean") + || (value.debounceMs !== undefined + && (!Number.isSafeInteger(value.debounceMs) || Number(value.debounceMs) < 0))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Watch input."); + } + return; + case "stopWatch": + if (!isRecord(value) + || !exactObjectKeys(value, ["watchId"]) + || !isNonEmptyString(value.watchId)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Watch reference."); + } + return; + case "getWatchStatus": + if (!isRecord(value) + || !exactObjectKeys(value, ["watchId"]) + || (value.watchId !== undefined && !isNonEmptyString(value.watchId))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Watch status input."); + } + return; + default: + throw new ContractValidationError("INVALID_INPUT", "Unknown Core method input."); + } +} + +export function assertCoreErrorDto(value: unknown): asserts value is CoreErrorDto { + if (!isCanonicalPublicCoreErrorDto(value)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid public CoreErrorDto."); + } +} + +export function assertCoreRequestEnvelope( + value: unknown +): asserts value is CoreRequestEnvelope { + if (!isRecord(value)) { + throw new ContractValidationError("INVALID_INPUT", "Core request envelope must be an object."); + } + const allowedKeys = new Set(["protocolVersion", "requestId", "operationId", "method", "payload"]); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new ContractValidationError("INVALID_INPUT", "Core request envelope has unknown fields."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || !isNonEmptyString(value.method) + || !METHOD_SET.has(value.method) + || !isRecord(value.payload)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core request envelope."); + } + if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core request operationId."); + } + assertCoreMethodInput(value.method as M, value.payload); +} + +export function assertCoreResponseEnvelope( + value: unknown, + expectedRequestId?: string +): asserts value is CoreResponseEnvelope { + if (!isRecord(value)) { + throw new ContractValidationError("INVALID_INPUT", "Core response envelope must be an object."); + } + const allowedKeys = value.ok === true + ? new Set(["protocolVersion", "requestId", "operationId", "ok", "result"]) + : new Set(["protocolVersion", "requestId", "operationId", "ok", "error"]); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new ContractValidationError("INVALID_INPUT", "Core response envelope has unknown fields."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || (expectedRequestId !== undefined && value.requestId !== expectedRequestId) + || typeof value.ok !== "boolean") { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core response envelope."); + } + if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core response operationId."); + } + if (value.ok) { + if (!("result" in value) || "error" in value) { + throw new ContractValidationError("INVALID_INPUT", "Invalid successful Core response."); + } + } else { + if (!("error" in value) || "result" in value) { + throw new ContractValidationError("INVALID_INPUT", "Invalid failed Core response."); + } + assertCoreErrorDto(value.error); + } +} + +function requireSchemaObject(value: unknown, label: string): Record { + if (!isRecord(value) || value.schemaVersion !== 1) { + throw new ContractValidationError("INVALID_INPUT", `Invalid ${label}.`); + } + return value; +} + +function requireStringArray(value: unknown, label: string): asserts value is string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new ContractValidationError("INVALID_INPUT", `Invalid ${label}.`); + } +} + +function isNonNegativeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function isNullableString(value: unknown): boolean { + return value === null || typeof value === "string"; +} + +function isJsonValue(value: unknown, depth = 0): boolean { + if (depth > 16) return false; + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.every((entry) => isJsonValue(entry, depth + 1)); + if (!isRecord(value)) return false; + return Object.values(value).every((entry) => isJsonValue(entry, depth + 1)); +} + +function isProviderDistribution(value: unknown): boolean { + if (!isRecord(value)) return false; + return Object.values(value).every((counts) => ( + isRecord(counts) && Object.values(counts).every(isNonNegativeInteger) + )); +} + +const DIAGNOSTIC_TRANSACTION_STATES = new Set([ + "prepared", + "applying", + "applied", + "skipped", + "committing", + "committed-pending-ack", + "rollback-pending", + "rollingBack", + "recovery-required", + "recoveryRequired", + "unknown" +]); + +function isDiagnosticIdentifier(value: unknown): value is string { + return typeof value === "string" + && /^[A-Za-z0-9._()-]{1,200}$/.test(value); +} + +function isUuid(value: unknown): value is string { + return typeof value === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function isDiagnosticCountMap(value: unknown): boolean { + if (!isRecord(value) || Object.keys(value).length > 512) return false; + return Object.entries(value).every(([provider, count]) => + isDiagnosticIdentifier(provider) && isNonNegativeInteger(count) + ); +} + +function isDiagnosticDistribution(value: unknown, allowUnreadable = false): boolean { + if (!isRecord(value) + || !exactObjectKeys(value, allowUnreadable + ? ["sessions", "archived_sessions", "unreadable"] + : ["sessions", "archived_sessions"]) + || !("sessions" in value) + || !("archived_sessions" in value) + || !isDiagnosticCountMap(value.sessions) + || !isDiagnosticCountMap(value.archived_sessions)) { + return false; + } + return !allowUnreadable || value.unreadable === undefined || value.unreadable === true; +} + +function isDiagnosticPendingTransaction(value: unknown): boolean { + return isRecord(value) + && Object.keys(value).sort().join(",") + === "operationId,operationKind,preRestoreSnapshotId,sourceBackupId,state" + && (value.operationId === null || isUuid(value.operationId)) + && ["sync", "switch", "restore"].includes(String(value.operationKind)) + && DIAGNOSTIC_TRANSACTION_STATES.has(String(value.state)) + && (value.sourceBackupId === null || isDiagnosticIdentifier(value.sourceBackupId)) + && (value.preRestoreSnapshotId === null + || isDiagnosticIdentifier(value.preRestoreSnapshotId)); +} + +function isDiagnosticOperationState(value: unknown): boolean { + if (value === null) return true; + if (!isRecord(value) + || !exactObjectKeys(value, [ + "operationId", + "operation", + "actor", + "startedAt", + "busyScope", + "lockState", + "errorCode" + ])) { + return false; + } + return (value.operationId === undefined || isUuid(value.operationId)) + && (value.operation === undefined + || ["sync", "switch", "restore", "prune", "watch", "unknown"].includes(String(value.operation))) + && (value.actor === undefined || ["manual", "watch", "external"].includes(String(value.actor))) + && (value.startedAt === undefined + || (isNonEmptyString(value.startedAt) && value.startedAt.length <= 64)) + && (value.busyScope === undefined || ["codex-home", "state-db"].includes(String(value.busyScope))) + && (value.lockState === undefined + || (isDiagnosticIdentifier(value.lockState) && value.lockState.length <= 80)) + && (value.errorCode === undefined + || (typeof value.errorCode === "string" && /^[A-Z0-9_]{1,80}$/.test(value.errorCode))); +} + +function assertDiagnosticsSnapshot(value: unknown): void { + const diagnostics = requireSchemaObject(value, "DiagnosticsSnapshot"); + const runtime = isRecord(diagnostics.runtime) ? diagnostics.runtime : null; + const storage = isRecord(diagnostics.storage) ? diagnostics.storage : null; + const provider = isRecord(diagnostics.provider) ? diagnostics.provider : null; + const safety = isRecord(diagnostics.safety) ? diagnostics.safety : null; + const valid = exactObjectKeys(diagnostics, [ + "schemaVersion", + "generatedAt", + "runtime", + "storage", + "provider", + "safety" + ]) + && isNonEmptyString(diagnostics.generatedAt) + && diagnostics.generatedAt.length <= 64 + && runtime !== null + && Object.keys(runtime).sort().join(",") === "arch,node,platform" + && [runtime.node, runtime.platform, runtime.arch].every((entry) => + typeof entry === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(entry) + ) + && storage !== null + && Object.keys(storage).sort().join(",") + === "sqliteHomeSource,sqliteSupported,stateDbFound" + && ["cli", "config", "env", "default", "unknown"].includes(String(storage.sqliteHomeSource)) + && typeof storage.stateDbFound === "boolean" + && typeof storage.sqliteSupported === "boolean" + && provider !== null + && Object.keys(provider).sort().join(",") + === "configured,current,implicit,rolloutCounts,sqliteCounts" + && isDiagnosticIdentifier(provider.current) + && typeof provider.implicit === "boolean" + && Array.isArray(provider.configured) + && provider.configured.length <= 256 + && provider.configured.every(isDiagnosticIdentifier) + && isDiagnosticDistribution(provider.rolloutCounts) + && (provider.sqliteCounts === null + || isDiagnosticDistribution(provider.sqliteCounts, true)) + && safety !== null + && exactObjectKeys(safety, [ + "storageRevision", + "pendingRecovery", + "pendingTransactions", + "operationInProgress", + "rolloutScanComplete", + "lockedRolloutCount", + "projectThreadVisibilityAvailable" + ]) + && (safety.storageRevision === undefined + || (typeof safety.storageRevision === "string" + && /^[A-Za-z0-9_-]{1,256}$/.test(safety.storageRevision))) + && typeof safety.pendingRecovery === "boolean" + && Array.isArray(safety.pendingTransactions) + && safety.pendingTransactions.length <= 256 + && safety.pendingTransactions.every(isDiagnosticPendingTransaction) + && isDiagnosticOperationState(safety.operationInProgress) + && typeof safety.rolloutScanComplete === "boolean" + && isNonNegativeInteger(safety.lockedRolloutCount) + && typeof safety.projectThreadVisibilityAvailable === "boolean"; + if (!valid) { + throw new ContractValidationError("INVALID_INPUT", "Invalid DiagnosticsSnapshot."); + } +} + +function isHistorySummary(value: unknown): boolean { + if (!isRecord(value)) return false; + return isNonEmptyString(value.id) + && typeof value.title === "string" + && !("cwd" in value) + && isNonEmptyString(value.provider) + && typeof value.archived === "boolean" + && isNonEmptyString(value.updatedAt) + && isNonNegativeInteger(value.messageCount) + && (value.messageCountKnown === undefined || typeof value.messageCountKnown === "boolean") + && (value.model === undefined || isNullableString(value.model)) + && (value.createdAt === undefined || isNonEmptyString(value.createdAt)); +} + +function assertWatchSnapshot(value: unknown): void { + const snapshot = requireSchemaObject(value, "WatchSnapshot"); + if (!isNonEmptyString(snapshot.watchId) + || !["running", "stopping", "stopped"].includes(String(snapshot.status)) + || !isNonEmptyString(snapshot.startedAt) + || !isNullableString(snapshot.stoppedAt) + || !isNullableString(snapshot.stopReason) + || typeof snapshot.includeStateDb !== "boolean" + || typeof snapshot.once !== "boolean") { + throw new ContractValidationError("INVALID_INPUT", "Invalid WatchSnapshot."); + } +} + +export function assertCoreMethodOutput( + method: M, + value: unknown +): asserts value is CoreMethodMap[M]["output"] { + switch (method) { + case "getStatus": { + const status = requireSchemaObject(value, "StatusSnapshot"); + const profile = isRecord(status.profile) ? status.profile : null; + if (!isNonEmptyString(status.snapshotAt) + || !isNonEmptyString(status.storageRevision) + || !profile + || !isNonEmptyString(profile.id) + || !isNonEmptyString(profile.revision) + || !isNonEmptyString(status.currentProvider) + || !isProviderDistribution(status.rolloutCounts) + || (status.modelCounts !== undefined && !isProviderDistribution(status.modelCounts)) + || !("sqliteCounts" in status) + || !isJsonValue(status.sqliteCounts) + || "codexHome" in status + || "sqliteHome" in status + || !isNonEmptyString(status.codexHomeSource) + || !isNonEmptyString(status.sqliteHomeSource) + || !isRecord(status.backupSummary) + || !isNonNegativeInteger(status.backupSummary.count) + || !isNonNegativeInteger(status.backupSummary.totalBytes) + || typeof status.pendingRecovery !== "boolean" + || !Array.isArray(status.pendingTransactions) + || status.pendingTransactions.some((entry) => !isRecord(entry) || !isJsonValue(entry)) + || !(status.operationInProgress === null + || (isRecord(status.operationInProgress) && isJsonValue(status.operationInProgress))) + || typeof status.rolloutScanComplete !== "boolean" + || !Array.isArray(status.lockedRolloutFiles) + || status.lockedRolloutFiles.some((entry) => typeof entry !== "string") + || (status.currentModel !== undefined && !isNullableString(status.currentModel))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid StatusSnapshot."); + } + return; + } + case "prepareSync": + case "prepareSwitch": + case "prepareRestore": { + const plan = requireSchemaObject(value, "PlanSummary"); + if (!isNonEmptyString(plan.planId) + || !["sync", "switch", "restore"].includes(String(plan.operation)) + || !isNonEmptyString(plan.createdAt) + || !isNonEmptyString(plan.expiresAt) + || !isRecord(plan.profile) + || !isNonEmptyString(plan.profile.id) + || !isNonEmptyString(plan.profile.revision) + || !isNonEmptyString(plan.storageRevision) + || !isNonEmptyString(plan.configRevision) + || !isNonEmptyString(plan.rolloutRevision) + || !isNonEmptyString(plan.stateDbRevision) + || (plan.backupRevision !== undefined && !isNonEmptyString(plan.backupRevision)) + || !isRecord(plan.target) + || !isJsonValue(plan.target) + || !isRecord(plan.impact) + || !isJsonValue(plan.impact) + || !Array.isArray(plan.warnings) + || plan.warnings.some((entry) => typeof entry !== "string") + || typeof plan.requiresConfirmation !== "boolean") { + throw new ContractValidationError("INVALID_INPUT", "Invalid PlanSummary."); + } + return; + } + case "applySync": + case "applySwitch": + case "applyRestore": { + const result = requireSchemaObject(value, "OperationResult"); + if (!isNonEmptyString(result.operationId) + || !["sync", "switch", "restore"].includes(String(result.operation)) + || !["completed", "partial", "failed_rolled_back", "recovery_required", "cancelled", "stale"].includes(String(result.outcome)) + || !(result.backup === null + || (isRecord(result.backup) && isNonEmptyString(result.backup.backupId)))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid OperationResult."); + } + requireStringArray(result.warnings, "OperationResult warnings"); + if (!("result" in result) || !isJsonValue(result.result)) { + throw new ContractValidationError("INVALID_INPUT", "OperationResult result is required."); + } + return; + } + case "listBackups": { + if (!isRecord(value) || !Array.isArray(value.backups) + || value.backups.some((entry) => { + const backup = isRecord(entry) ? entry : null; + return !backup + || !isNonEmptyString(backup.backupId) + || !isNonNegativeInteger(backup.sizeBytes) + || !isRecord(backup.metadata) + || !isJsonValue(backup.metadata) + || (backup.createdAt !== undefined && !isNonEmptyString(backup.createdAt)); + })) { + throw new ContractValidationError("INVALID_INPUT", "Invalid BackupList."); + } + return; + } + case "pruneBackups": { + if (!isRecord(value) + || !isNonNegativeInteger(value.deletedCount) + || !isNonNegativeInteger(value.remainingCount) + || !isNonNegativeInteger(value.freedBytes)) { + throw new ContractValidationError("INVALID_INPUT", "Invalid PruneBackupsResult."); + } + return; + } + case "listHistory": { + if (!isRecord(value) + || !Number.isSafeInteger(value.page) + || Number(value.page) < 1 + || !Number.isSafeInteger(value.pageSize) + || Number(value.pageSize) < 1 + || !isNonNegativeInteger(value.total) + || typeof value.hasNextPage !== "boolean" + || !Array.isArray(value.sessions) + || value.sessions.some((entry) => !isHistorySummary(entry))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid HistoryPage."); + } + return; + } + case "getHistorySession": { + if (!isRecord(value) + || !isHistorySummary(value.session) + || !Array.isArray(value.messages) + || value.messages.some((entry) => { + const message = isRecord(entry) ? entry : null; + return !message + || !isNonEmptyString(message.role) + || typeof message.text !== "string" + || !isNonNegativeInteger(message.sequence) + || (message.timestamp !== undefined && !isNonEmptyString(message.timestamp)); + }) + || typeof value.truncated !== "boolean" + || !isNonNegativeInteger(value.returnedMessageCount) + || Number(value.returnedMessageCount) !== value.messages.length) { + throw new ContractValidationError("INVALID_INPUT", "Invalid HistorySessionDetail."); + } + return; + } + case "startWatch": + case "stopWatch": + assertWatchSnapshot(value); + return; + case "getWatchStatus": { + if (isRecord(value) && Array.isArray(value.watches)) { + requireSchemaObject(value, "WatchStatusList"); + value.watches.forEach(assertWatchSnapshot); + return; + } + assertWatchSnapshot(value); + return; + } + case "getDiagnostics": { + assertDiagnosticsSnapshot(value); + return; + } + default: + throw new ContractValidationError("INVALID_INPUT", "Unknown Core method output."); + } +} + +export function assertProgressEvent(value: unknown): asserts value is ProgressEvent { + if (!isRecord(value)) { + throw new ContractValidationError("INVALID_INPUT", "Progress event must be an object."); + } + const allowed = new Set(["stage", "status", "progress", "count"]); + if (Object.keys(value).some((key) => !allowed.has(key)) + || !isNonEmptyString(value.stage) + || !isNonEmptyString(value.status) + || value.stage.length > 80 + || value.status.length > 40 + || (value.progress !== undefined + && (typeof value.progress !== "number" + || !Number.isFinite(value.progress) + || value.progress < 0 + || value.progress > 1)) + || (value.count !== undefined + && (!Number.isSafeInteger(value.count) || Number(value.count) < 0))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid ProgressEvent."); + } +} + +export function assertCoreOperationStartedEnvelope( + value: unknown, + expectedRequestId?: string, + expectedOperationId?: string +): asserts value is CoreOperationStartedEnvelope { + if (!isRecord(value) + || !exactObjectKeys(value, [ + "protocolVersion", + "requestId", + "operationId", + "event", + "operation" + ])) { + throw new ContractValidationError("INVALID_INPUT", "Invalid operation-started envelope."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || value.requestId.length > 512 + || (expectedRequestId !== undefined && value.requestId !== expectedRequestId) + || !isNonEmptyString(value.operationId) + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.operationId) + || (expectedOperationId !== undefined && value.operationId !== expectedOperationId) + || value.event !== "operation-started" + || !["sync", "switch", "restore"].includes(String(value.operation))) { + throw new ContractValidationError("INVALID_INPUT", "Invalid operation-started envelope."); + } +} + +export function assertCoreProgressEnvelope( + value: unknown, + expectedRequestId?: string, + expectedOperationId?: string +): asserts value is CoreProgressEnvelope { + if (!isRecord(value) + || !exactObjectKeys(value, [ + "protocolVersion", + "requestId", + "operationId", + "event", + "progress" + ])) { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core progress envelope."); + } + assertProtocolVersion(value.protocolVersion); + if (!isNonEmptyString(value.requestId) + || value.requestId.length > 512 + || (expectedRequestId !== undefined && value.requestId !== expectedRequestId) + || !isNonEmptyString(value.operationId) + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.operationId) + || (expectedOperationId !== undefined && value.operationId !== expectedOperationId) + || value.event !== "progress") { + throw new ContractValidationError("INVALID_INPUT", "Invalid Core progress envelope."); + } + assertProgressEvent(value.progress); +} + +export function assertCoreOperationEventEnvelope( + value: unknown, + expectedRequestId?: string, + expectedOperationId?: string +): asserts value is CoreOperationEventEnvelope { + if (isRecord(value) && value.event === "operation-started") { + assertCoreOperationStartedEnvelope(value, expectedRequestId, expectedOperationId); + return; + } + assertCoreProgressEnvelope(value, expectedRequestId, expectedOperationId); +} + +export function createCoreOperationStartedEnvelope( + requestId: string, + operationId: string, + operation: CoreOperationStartedEnvelope["operation"] +): CoreOperationStartedEnvelope { + const envelope: CoreOperationStartedEnvelope = { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId, + operationId, + event: "operation-started", + operation + }; + assertCoreOperationStartedEnvelope(envelope); + return envelope; +} + +export function createCoreProgressEnvelope( + requestId: string, + operationId: string, + progress: ProgressEvent +): CoreProgressEnvelope { + const envelope: CoreProgressEnvelope = { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId, + operationId, + event: "progress", + progress + }; + assertCoreProgressEnvelope(envelope); + return envelope; +} + +export function createCoreRequestEnvelope( + method: M, + payload: CoreMethodMap[M]["input"], + requestId: string, + operationId?: string +): CoreRequestEnvelope { + const envelope = { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId, + ...(operationId ? { operationId } : {}), + method, + payload + } satisfies CoreRequestEnvelope; + assertCoreRequestEnvelope(envelope); + return envelope; +} + +export function createCoreSuccessEnvelope( + request: CoreRequestEnvelope, + result: CoreMethodMap[M]["output"], + operationId?: string +): CoreResponseEnvelope { + assertCoreMethodOutput(request.method, result); + return { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId: request.requestId, + ...(operationId ?? request.operationId + ? { operationId: operationId ?? request.operationId } + : {}), + ok: true, + result + }; +} + +export function createCoreFailureEnvelope( + request: CoreRequestEnvelope, + error: CoreErrorDto, + operationId?: string +): CoreResponseEnvelope { + assertCoreErrorDto(error); + return { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId: request.requestId, + ...(operationId ?? error.operationId ?? request.operationId + ? { operationId: operationId ?? error.operationId ?? request.operationId } + : {}), + ok: false, + error + }; +} + +export function isCoreErrorCode(value: unknown): value is CoreErrorCode { + return typeof value === "string" && ERROR_CODE_SET.has(value); +} + +export function isCoreErrorSeverity(value: unknown): value is CoreErrorSeverity { + return typeof value === "string" && SEVERITY_SET.has(value); +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..c7a132f --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/core-client/checks/core-client.contract.mjs b/packages/core-client/checks/core-client.contract.mjs new file mode 100644 index 0000000..e95a80b --- /dev/null +++ b/packages/core-client/checks/core-client.contract.mjs @@ -0,0 +1,903 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CoreClientError, + CoreTransportError, + DESKTOP_MAINTENANCE_METHODS, + DESKTOP_READ_METHODS, + DESKTOP_RESTORE_METHODS, + DESKTOP_SYNC_SWITCH_METHODS, + DesktopCoreClient, + HttpCoreClient, + HttpCoreTransport, + MockCoreClient, + legacyErrorToDto +} from "../dist/index.js"; + +const profile = { profile: { profileId: "default", profileRevision: "r1" } }; + +test("MockCoreClient uses the same versioned request envelope", async () => { + const client = new MockCoreClient({ + getStatus: async () => ({ + schemaVersion: 1, + snapshotAt: "2026-08-25T00:00:00.000Z", + storageRevision: "storage", + profile: { id: "default", revision: "r1" }, + currentProvider: "openai", + rolloutCounts: {}, + sqliteCounts: null, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [] + }) + }, { requestIdFactory: () => "mock-request" }); + + const status = await client.getStatus(profile); + assert.equal(status.currentProvider, "openai"); + assert.deepEqual(client.requests[0], { + protocolVersion: 1, + requestId: "mock-request", + method: "getStatus", + payload: profile + }); +}); + +test("HttpCoreClient validates response correlation and sends one envelope", async () => { + let captured; + const client = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "http-request", + fetch: async (_url, init) => { + captured = JSON.parse(String(init.body)); + return new Response(JSON.stringify({ + protocolVersion: 1, + requestId: "http-request", + ok: true, + result: { schemaVersion: 1, watches: [] } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + }); + + const result = await client.getWatchStatus({}); + assert.deepEqual(result, { schemaVersion: 1, watches: [] }); + assert.deepEqual(captured, { + protocolVersion: 1, + requestId: "http-request", + method: "getWatchStatus", + payload: {} + }); +}); + +test("HttpCoreClient streams lifecycle events and cancels an apply by request correlation", async () => { + const operationId = "11111111-1111-4111-8111-111111111111"; + const cancellations = []; + let stream; + let mainSignal; + const client = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "http-stream-apply", + fetch: async (url, init) => { + if (String(url).endsWith("/api/core/cancel")) { + cancellations.push(JSON.parse(String(init.body))); + stream.enqueue(new TextEncoder().encode(`${JSON.stringify({ + protocolVersion: 1, + requestId: "http-stream-apply", + operationId, + ok: false, + error: { + code: "OPERATION_CANCELLED", + message: "The operation was cancelled.", + severity: "info", + retryable: true, + recoveryRequired: false, + operationId + } + })}\n`)); + stream.close(); + return new Response(JSON.stringify({ accepted: true }), { status: 200 }); + } + mainSignal = init.signal; + assert.equal(init.headers.Accept, "application/x-ndjson"); + return new Response(new ReadableStream({ + start(controller) { + stream = controller; + for (const event of [{ + protocolVersion: 1, + requestId: "http-stream-apply", + operationId, + event: "operation-started", + operation: "sync" + }, { + protocolVersion: 1, + requestId: "http-stream-apply", + operationId, + event: "progress", + progress: { stage: "create_backup", status: "start" } + }]) controller.enqueue(new TextEncoder().encode(`${JSON.stringify(event)}\n`)); + } + }), { status: 200, headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } }); + } + }); + const controller = new AbortController(); + const started = []; + const progress = []; + let progressSeen; + const sawProgress = new Promise((resolve) => { progressSeen = resolve; }); + const applying = client.applySync( + { schemaVersion: 1, planId: "a".repeat(32) }, + { + signal: controller.signal, + onOperationStarted: (event) => started.push(event), + onProgress: (event) => { progress.push(event); progressSeen(); } + } + ); + await sawProgress; + controller.abort(); + await assert.rejects(applying, (error) => ( + error instanceof CoreClientError && error.code === "OPERATION_CANCELLED" + )); + assert.equal(mainSignal, undefined); + assert.equal(started.length, 1); + assert.equal(progress.length, 1); + assert.deepEqual(cancellations, [{ + protocolVersion: 1, + requestId: "http-stream-apply", + operationId + }]); +}); + +function ndjsonResponse(frames) { + return new Response( + frames.map((frame) => `${JSON.stringify(frame)}\n`).join(""), + { status: 200, headers: { "Content-Type": "application/x-ndjson" } } + ); +} + +function completedSyncResult(operationId) { + return { + schemaVersion: 1, + operationId, + operation: "sync", + outcome: "completed", + backup: { backupId: "managed" }, + warnings: [], + result: {} + }; +} + +test("HttpCoreClient rejects a terminal operationId that differs from the lifecycle", async () => { + const startedOperationId = "11111111-1111-4111-8111-111111111113"; + const terminalOperationId = "11111111-1111-4111-8111-111111111114"; + const client = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "http-mismatched-terminal", + fetch: async () => ndjsonResponse([{ + protocolVersion: 1, + requestId: "http-mismatched-terminal", + operationId: startedOperationId, + event: "operation-started", + operation: "sync" + }, { + protocolVersion: 1, + requestId: "http-mismatched-terminal", + operationId: terminalOperationId, + ok: true, + result: completedSyncResult(terminalOperationId) + }]) + }); + + await assert.rejects( + client.applySync({ schemaVersion: 1, planId: "a".repeat(32) }), + (error) => error instanceof CoreTransportError + && error.message === "Core HTTP stream terminal operationId did not match its lifecycle." + ); +}); + +test("HttpCoreClient rejects result and error operationIds that differ from the lifecycle", async () => { + const lifecycleOperationId = "11111111-1111-4111-8111-111111111119"; + const mismatchedOperationId = "11111111-1111-4111-8111-111111111120"; + for (const terminal of [{ + protocolVersion: 1, + requestId: "http-inner-operation-mismatch", + operationId: lifecycleOperationId, + ok: true, + result: completedSyncResult(mismatchedOperationId) + }, { + protocolVersion: 1, + requestId: "http-inner-operation-mismatch", + operationId: lifecycleOperationId, + ok: false, + error: { + code: "OPERATION_CANCELLED", + message: "The operation was cancelled.", + severity: "info", + retryable: true, + recoveryRequired: false, + operationId: mismatchedOperationId + } + }]) { + const client = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "http-inner-operation-mismatch", + fetch: async () => ndjsonResponse([{ + protocolVersion: 1, + requestId: "http-inner-operation-mismatch", + operationId: lifecycleOperationId, + event: "operation-started", + operation: "sync" + }, terminal]) + }); + await assert.rejects( + client.applySync({ schemaVersion: 1, planId: "a".repeat(32) }), + (error) => error instanceof CoreTransportError + && /operationId did not match its lifecycle/.test(error.message) + ); + } +}); + +test("HttpCoreClient rejects progress before operation-started", async () => { + const operationId = "11111111-1111-4111-8111-111111111115"; + const client = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "http-progress-before-start", + fetch: async () => ndjsonResponse([{ + protocolVersion: 1, + requestId: "http-progress-before-start", + operationId, + event: "progress", + progress: { stage: "create_backup", status: "start" } + }]) + }); + + await assert.rejects( + client.applySync({ schemaVersion: 1, planId: "a".repeat(32) }), + (error) => error instanceof CoreTransportError + && error.message === "Core HTTP stream emitted progress before operation-started." + ); +}); + +test("HttpCoreClient rejects lifecycle events on read methods", async () => { + const client = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "http-read-lifecycle", + fetch: async () => ndjsonResponse([{ + protocolVersion: 1, + requestId: "http-read-lifecycle", + operationId: "11111111-1111-4111-8111-111111111116", + event: "operation-started", + operation: "sync" + }]) + }); + + await assert.rejects( + client.getWatchStatus({}), + (error) => error instanceof CoreTransportError + && error.message === "Core HTTP read stream contained an operation event." + ); +}); + +test("HttpCoreClient rejects successful apply streams without operation-started", async () => { + const operationId = "11111111-1111-4111-8111-111111111117"; + const client = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "http-apply-without-start", + fetch: async () => ndjsonResponse([{ + protocolVersion: 1, + requestId: "http-apply-without-start", + ok: true, + result: completedSyncResult(operationId) + }]) + }); + + await assert.rejects( + client.applySync({ schemaVersion: 1, planId: "a".repeat(32) }), + (error) => error instanceof CoreTransportError + && error.message === "Core HTTP apply stream ended without operation-started." + ); +}); + +test("MockCoreClient exposes observer-safe lifecycle controls to UI handlers", async () => { + const operationId = "11111111-1111-4111-8111-111111111112"; + const controller = new AbortController(); + const progress = []; + const client = new MockCoreClient({ + applySync: async (_payload, request, control) => { + assert.equal(control.signal, controller.signal); + control.onOperationStarted?.({ + protocolVersion: 1, + requestId: request.requestId, + operationId, + event: "operation-started", + operation: "sync" + }); + control.onProgress?.({ + protocolVersion: 1, + requestId: request.requestId, + operationId, + event: "progress", + progress: { stage: "create_backup", status: "complete" } + }); + return { + schemaVersion: 1, + operationId, + operation: "sync", + outcome: "completed", + backup: { backupId: "managed" }, + warnings: [], + result: {} + }; + } + }, { requestIdFactory: () => "mock-stream-apply" }); + const result = await client.applySync( + { schemaVersion: 1, planId: "a".repeat(32) }, + { + signal: controller.signal, + onOperationStarted: () => { throw new Error("observer failure"); }, + onProgress: (event) => progress.push(event) + } + ); + assert.equal(result.outcome, "completed"); + assert.equal(progress.length, 1); +}); + +test("HttpCoreClient rejects an oversized envelope before calling fetch", async () => { + let fetchCalls = 0; + const transport = new HttpCoreTransport({ + baseUrl: "http://127.0.0.1:31337/", + fetch: async () => { + fetchCalls += 1; + throw new Error("fetch must not be called"); + } + }); + + await assert.rejects( + transport.request({ + protocolVersion: 1, + requestId: "oversized-request", + method: "getWatchStatus", + payload: { oversized: "x".repeat(65 * 1024) } + }), + (error) => error instanceof CoreTransportError + && error.status === null + && error.message === "Core request exceeds the 64 KiB transport limit." + ); + assert.equal(fetchCalls, 0); +}); + +test("canonical failed envelopes become CoreClientError", async () => { + const client = new MockCoreClient({ + prepareSync: async () => { + throw Object.assign(new Error("busy"), { + code: "OPERATION_BUSY", + severity: "warning", + retryable: true, + recoveryRequired: false, + details: { busyScope: "codex-home" } + }); + } + }); + await assert.rejects( + client.prepareSync(profile), + (error) => error instanceof CoreClientError + && error.code === "OPERATION_BUSY" + && error.dto.details.busyScope === "codex-home" + ); +}); + +test("legacy error adapter classifies by code and never parses message text", () => { + const dto = legacyErrorToDto(new Error("OPERATION_BUSY and RECOVERY_REQUIRED are only words")); + assert.equal(dto.code, "INTERNAL_ERROR"); + assert.equal(dto.message, "An internal error occurred."); +}); + +test("legacy error adapter never leaks exception text or arbitrary details", () => { + const dto = legacyErrorToDto(Object.assign(new Error("token=secret C:/private message body"), { + code: "OPERATION_BUSY", + details: { + busyScope: "codex-home", + token: "secret", + path: "C:/private", + messageBody: "private" + }, + suggestedAction: "send token=secret" + })); + assert.deepEqual(dto, { + code: "OPERATION_BUSY", + message: "Another write operation is using the protected resource.", + severity: "warning", + retryable: true, + recoveryRequired: false, + details: { busyScope: "codex-home" } + }); + assert.doesNotMatch(JSON.stringify(dto), /secret|private|message body/i); +}); + +test("malformed protocol and success payloads become canonical client errors", async () => { + const protocolClient = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "protocol-request", + fetch: async () => new Response(JSON.stringify({ + protocolVersion: 2, + requestId: "protocol-request", + ok: true, + result: { schemaVersion: 1, watches: [] } + }), { status: 200 }) + }); + await assert.rejects( + protocolClient.getWatchStatus({}), + (error) => error instanceof CoreClientError + && error.code === "PROTOCOL_VERSION_MISMATCH" + && error.message === "The client and Core protocol versions are incompatible." + ); + + const malformedClient = new MockCoreClient({ + getStatus: async () => null + }); + await assert.rejects( + malformedClient.getStatus(profile), + (error) => error instanceof CoreClientError + && error.code === "INTERNAL_ERROR" + && error.message === "An internal error occurred." + ); +}); + +test("HTTP status cannot turn a failed request into a success envelope", async () => { + const invalid = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "invalid-http", + fetch: async () => new Response(JSON.stringify({ + protocolVersion: 1, + requestId: "invalid-http", + ok: true, + result: { schemaVersion: 1, watches: [] } + }), { status: 500 }) + }); + await assert.rejects(invalid.getWatchStatus({}), CoreTransportError); + + const invalidStream = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "invalid-http-stream", + fetch: async () => new Response(`${JSON.stringify({ + protocolVersion: 1, + requestId: "invalid-http-stream", + ok: true, + result: { schemaVersion: 1, watches: [] } + })}\n`, { + status: 500, + headers: { "Content-Type": "application/x-ndjson" } + }) + }); + await assert.rejects(invalidStream.getWatchStatus({}), CoreTransportError); + + const busy = new HttpCoreClient({ + baseUrl: "http://127.0.0.1:31337/", + requestIdFactory: () => "busy-http", + fetch: async () => new Response(JSON.stringify({ + protocolVersion: 1, + requestId: "busy-http", + ok: false, + error: { + code: "OPERATION_BUSY", + message: "Another write operation is using the protected resource.", + severity: "warning", + retryable: true, + recoveryRequired: false, + details: { busyScope: "codex-home" } + } + }), { status: 409 }) + }); + await assert.rejects( + busy.getWatchStatus({}), + (error) => error instanceof CoreClientError && error.code === "OPERATION_BUSY" + ); +}); + +test("DesktopCoreClient reuses the Core envelope through one read-only bridge", async () => { + const requests = []; + const client = new DesktopCoreClient({ + async requestReadOnly(request) { + requests.push(request); + return { + protocolVersion: 1, + requestId: request.requestId, + ok: true, + result: { + schemaVersion: 1, + snapshotAt: "2026-08-26T00:00:00.000Z", + storageRevision: "storage", + profile: { id: "default", revision: "r1" }, + currentProvider: "openai", + rolloutCounts: {}, + sqliteCounts: {}, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [] + } + }; + }, + async requestSyncSwitch() { throw new Error("unexpected write"); }, + async requestRestore() { throw new Error("unexpected restore"); }, + async requestMaintenance() { throw new Error("unexpected maintenance"); }, + subscribeOperation() { return () => {}; }, + async cancelOperation() { return { accepted: false }; } + }, { requestIdFactory: () => "desktop-status" }); + assert.equal((await client.getStatus(profile)).currentProvider, "openai"); + assert.deepEqual(requests, [{ + protocolVersion: 1, + requestId: "desktop-status", + method: "getStatus", + payload: profile + }]); + assert.deepEqual(DESKTOP_READ_METHODS, [ + "getStatus", + "listBackups", + "listHistory", + "getHistorySession", + "getDiagnostics" + ]); +}); + +test("DesktopCoreClient routes the exact C8 surface and forwards lifecycle cancellation", async () => { + let calls = 0; + let listener = null; + const cancellations = []; + const routed = []; + let finishApply; + const client = new DesktopCoreClient({ + async requestReadOnly() { + calls += 1; + throw new Error("unexpected read"); + }, + async requestSyncSwitch(request) { + calls += 1; + routed.push(request.method); + if (request.method === "prepareSync") { + return { + protocolVersion: 1, + requestId: request.requestId, + ok: true, + result: { + schemaVersion: 1, + planId: "a".repeat(32), + operation: "sync", + createdAt: "2026-08-26T00:00:00.000Z", + expiresAt: "2026-08-26T00:10:00.000Z", + profile: { id: "default", revision: "r1" }, + storageRevision: "storage", + configRevision: "config", + rolloutRevision: "rollout", + stateDbRevision: "state-db", + target: { provider: "openai" }, + impact: { backupExpected: true }, + warnings: [], + requiresConfirmation: true + } + }; + } + return new Promise((resolve) => { finishApply = () => resolve({ + protocolVersion: 1, + requestId: request.requestId, + operationId: "11111111-1111-4111-8111-111111111111", + ok: false, + error: { + code: "OPERATION_CANCELLED", + message: "The operation was cancelled.", + severity: "info", + retryable: true, + recoveryRequired: false, + operationId: "11111111-1111-4111-8111-111111111111" + } + }); }); + }, + async requestRestore(request) { + calls += 1; + routed.push(request.method); + return { + protocolVersion: 1, + requestId: request.requestId, + ok: true, + result: { + schemaVersion: 1, + planId: "r".repeat(32), + operation: "restore", + createdAt: "2026-08-26T00:00:00.000Z", + expiresAt: "2026-08-26T00:10:00.000Z", + profile: { id: "default", revision: "r1" }, + storageRevision: "storage", + configRevision: "config", + rolloutRevision: "rollout", + stateDbRevision: "state-db", + backupRevision: "backup", + target: { backupId: "managed" }, + impact: { backupExpected: true }, + warnings: [], + requiresConfirmation: true + } + }; + }, + async requestMaintenance(request) { + calls += 1; + routed.push(request.method); + return { + protocolVersion: 1, + requestId: request.requestId, + ok: true, + result: request.method === "pruneBackups" + ? { deletedCount: 0, remainingCount: 1, freedBytes: 0 } + : { + schemaVersion: 1, + watchId: "11111111-1111-4111-8111-111111111112", + status: "running", + startedAt: "2026-08-26T00:00:00.000Z", + stoppedAt: null, + stopReason: null, + includeStateDb: true, + once: false + } + }; + }, + subscribeOperation(next) { listener = next; return () => { listener = null; }; }, + async cancelOperation(input) { cancellations.push(input); return { accepted: true }; } + }, { requestIdFactory: (() => { + const ids = ["desktop-prepare", "desktop-apply", "desktop-restore", "desktop-prune", "desktop-watch"]; + return () => ids.shift() ?? `desktop-denied-${calls}`; + })() }); + const plan = await client.prepareSync({ ...profile, keepCount: 5 }); + assert.equal(plan.operation, "sync"); + const controller = new AbortController(); + const started = []; + const progress = []; + const applying = client.applySync( + { schemaVersion: 1, planId: plan.planId }, + { + signal: controller.signal, + onOperationStarted: (event) => started.push(event), + onProgress: (event) => progress.push(event) + } + ); + listener({ + protocolVersion: 1, + requestId: "desktop-apply", + operationId: "11111111-1111-4111-8111-111111111111", + event: "operation-started", + operation: "sync" + }); + listener({ + protocolVersion: 1, + requestId: "desktop-apply", + operationId: "11111111-1111-4111-8111-111111111111", + event: "progress", + progress: { stage: "create_backup", status: "start" } + }); + controller.abort(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(started.length, 1); + assert.equal(progress.length, 1); + assert.deepEqual(cancellations.at(-1), { + requestId: "desktop-apply", + operationId: "11111111-1111-4111-8111-111111111111" + }); + finishApply(); + await assert.rejects(applying, (error) => ( + error instanceof CoreClientError && error.code === "OPERATION_CANCELLED" + )); + const restore = await client.prepareRestore({ + ...profile, + backupId: "managed", + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true + }); + assert.equal(restore.operation, "restore"); + assert.equal((await client.pruneBackups({ ...profile, keepCount: 5 })).remainingCount, 1); + assert.equal((await client.startWatch({ ...profile, includeStateDb: true })).status, "running"); + assert.deepEqual(DESKTOP_SYNC_SWITCH_METHODS, [ + "prepareSync", + "applySync", + "prepareSwitch", + "applySwitch" + ]); + assert.deepEqual(DESKTOP_RESTORE_METHODS, ["prepareRestore", "applyRestore"]); + assert.deepEqual(DESKTOP_MAINTENANCE_METHODS, [ + "pruneBackups", + "startWatch", + "stopWatch", + "getWatchStatus" + ]); + assert.deepEqual(routed, [ + "prepareSync", + "applySync", + "prepareRestore", + "pruneBackups", + "startWatch" + ]); + assert.equal(calls, 5); +}); + +test("DesktopCoreClient retries an unaccepted Apply cancellation until it is acknowledged", async () => { + let listener = null; + let finishApply; + const cancellations = []; + const operationId = "11111111-1111-4111-8111-111111111111"; + const client = new DesktopCoreClient({ + async requestReadOnly() { throw new Error("unexpected read"); }, + async requestSyncSwitch(request) { + return new Promise((resolve) => { + finishApply = () => resolve({ + protocolVersion: 1, + requestId: request.requestId, + operationId, + ok: false, + error: { + code: "OPERATION_CANCELLED", + message: "The operation was cancelled.", + severity: "info", + retryable: true, + recoveryRequired: false, + operationId + } + }); + }); + }, + async requestRestore() { throw new Error("unexpected restore"); }, + async requestMaintenance() { throw new Error("unexpected maintenance"); }, + subscribeOperation(next) { listener = next; return () => { listener = null; }; }, + async cancelOperation(input) { + cancellations.push(input); + const accepted = cancellations.length > 1; + if (accepted) queueMicrotask(finishApply); + return { accepted }; + } + }, { requestIdFactory: () => "desktop-cancel-retry" }); + const controller = new AbortController(); + const applying = client.applySync( + { schemaVersion: 1, planId: "p".repeat(48) }, + { signal: controller.signal } + ); + listener({ + protocolVersion: 1, + requestId: "desktop-cancel-retry", + operationId, + event: "operation-started", + operation: "sync" + }); + controller.abort(); + await assert.rejects(applying, (error) => ( + error instanceof CoreClientError && error.code === "OPERATION_CANCELLED" + )); + assert.deepEqual(cancellations, [ + { requestId: "desktop-cancel-retry", operationId }, + { requestId: "desktop-cancel-retry", operationId } + ]); +}); + +test("DesktopCoreClient re-confirms an early accepted cancellation after operation-started", async () => { + let listener = null; + let finishApply; + const cancellations = []; + const operationId = "11111111-1111-4111-8111-111111111111"; + const client = new DesktopCoreClient({ + async requestReadOnly() { throw new Error("unexpected read"); }, + async requestSyncSwitch(request) { + return new Promise((resolve) => { + finishApply = () => resolve({ + protocolVersion: 1, + requestId: request.requestId, + operationId, + ok: false, + error: { + code: "OPERATION_CANCELLED", + message: "The operation was cancelled.", + severity: "info", + retryable: true, + recoveryRequired: false, + operationId + } + }); + }); + }, + async requestRestore() { throw new Error("unexpected restore"); }, + async requestMaintenance() { throw new Error("unexpected maintenance"); }, + subscribeOperation(next) { listener = next; return () => { listener = null; }; }, + async cancelOperation(input) { + cancellations.push(input); + if (input.operationId === operationId) queueMicrotask(finishApply); + return { accepted: true }; + } + }, { requestIdFactory: () => "desktop-early-cancel" }); + const controller = new AbortController(); + const applying = client.applySync( + { schemaVersion: 1, planId: "p".repeat(48) }, + { signal: controller.signal } + ); + controller.abort(); + await new Promise((resolve) => setImmediate(resolve)); + listener({ + protocolVersion: 1, + requestId: "desktop-early-cancel", + operationId, + event: "operation-started", + operation: "sync" + }); + await assert.rejects(applying, (error) => ( + error instanceof CoreClientError && error.code === "OPERATION_CANCELLED" + )); + assert.deepEqual(cancellations, [ + { requestId: "desktop-early-cancel" }, + { requestId: "desktop-early-cancel", operationId } + ]); +}); + +test("DesktopCoreClient backs off rejected cancellations and stops after the request settles", async () => { + let listener = null; + let finishApply; + let activeCancellations = 0; + let maximumActiveCancellations = 0; + let cancellationCalls = 0; + const operationId = "11111111-1111-4111-8111-111111111111"; + const client = new DesktopCoreClient({ + async requestReadOnly() { throw new Error("unexpected read"); }, + async requestSyncSwitch(request) { + return new Promise((resolve) => { + finishApply = () => resolve({ + protocolVersion: 1, + requestId: request.requestId, + operationId, + ok: false, + error: { + code: "OPERATION_CANCELLED", + message: "The operation was cancelled.", + severity: "info", + retryable: true, + recoveryRequired: false, + operationId + } + }); + }); + }, + async requestRestore() { throw new Error("unexpected restore"); }, + async requestMaintenance() { throw new Error("unexpected maintenance"); }, + subscribeOperation(next) { listener = next; return () => { listener = null; }; }, + async cancelOperation() { + cancellationCalls += 1; + activeCancellations += 1; + maximumActiveCancellations = Math.max(maximumActiveCancellations, activeCancellations); + await new Promise((resolve) => setTimeout(resolve, 40)); + activeCancellations -= 1; + throw new Error("transient cancellation invoke failure"); + } + }, { requestIdFactory: () => "desktop-cancel-backoff" }); + const controller = new AbortController(); + const applying = client.applySync( + { schemaVersion: 1, planId: "p".repeat(48) }, + { signal: controller.signal } + ); + listener({ + protocolVersion: 1, + requestId: "desktop-cancel-backoff", + operationId, + event: "operation-started", + operation: "sync" + }); + controller.abort(); + setTimeout(finishApply, 110); + await assert.rejects(applying, (error) => ( + error instanceof CoreClientError && error.code === "OPERATION_CANCELLED" + )); + const callsAtSettlement = cancellationCalls; + assert.equal(maximumActiveCancellations, 1); + assert.ok(callsAtSettlement >= 1 && callsAtSettlement <= 2); + await new Promise((resolve) => setTimeout(resolve, 175)); + assert.equal(cancellationCalls, callsAtSettlement); +}); diff --git a/packages/core-client/package.json b/packages/core-client/package.json new file mode 100644 index 0000000..d8c2c72 --- /dev/null +++ b/packages/core-client/package.json @@ -0,0 +1,22 @@ +{ + "name": "@codex-provider-sync/core-client", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "test": "node --test checks/core-client.contract.mjs" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@codex-provider-sync/contracts": "0.0.0" + } +} diff --git a/packages/core-client/src/client.ts b/packages/core-client/src/client.ts new file mode 100644 index 0000000..43ae4f5 --- /dev/null +++ b/packages/core-client/src/client.ts @@ -0,0 +1,210 @@ +import { + ContractValidationError, + assertCoreErrorDto, + assertCoreMethodOutput, + assertCoreResponseEnvelope, + createPublicCoreErrorDto, + createCoreRequestEnvelope, + type ApplyPlanInput, + type BackupList, + type CoreErrorDto, + type CoreMethodMap, + type CoreMethodName, + type CoreRequestEnvelope, + type CoreOperationStartedEnvelope, + type CoreProgressEnvelope, + type DiagnosticsSnapshot, + type GetDiagnosticsInput, + type GetHistorySessionInput, + type GetStatusInput, + type GetWatchStatusInput, + type HistoryPage, + type HistorySessionDetail, + type ListBackupsInput, + type ListHistoryInput, + type OperationResult, + type PlanSummary, + type PrepareRestoreInput, + type PrepareSwitchInput, + type PrepareSyncInput, + type PruneBackupsInput, + type PruneBackupsResult, + type StartWatchInput, + type StatusSnapshot, + type WatchReferenceInput, + type WatchSnapshot, + type WatchStatusList +} from "@codex-provider-sync/contracts"; + +export interface CoreCallOptions { + signal?: AbortSignal; + operationId?: string; + requestId?: string; + onOperationStarted?(event: CoreOperationStartedEnvelope): void; + onProgress?(event: CoreProgressEnvelope): void; +} + +export type CoreTransportCallOptions = Pick< + CoreCallOptions, + "signal" | "onOperationStarted" | "onProgress" +>; + +export interface CoreTransport { + request( + envelope: CoreRequestEnvelope, + options?: CoreTransportCallOptions + ): Promise; +} + +export interface CoreClient { + getStatus(input: GetStatusInput, options?: CoreCallOptions): Promise; + prepareSync(input: PrepareSyncInput, options?: CoreCallOptions): Promise; + applySync(input: ApplyPlanInput, options?: CoreCallOptions): Promise; + prepareSwitch(input: PrepareSwitchInput, options?: CoreCallOptions): Promise; + applySwitch(input: ApplyPlanInput, options?: CoreCallOptions): Promise; + listBackups(input: ListBackupsInput, options?: CoreCallOptions): Promise; + prepareRestore(input: PrepareRestoreInput, options?: CoreCallOptions): Promise; + applyRestore(input: ApplyPlanInput, options?: CoreCallOptions): Promise; + pruneBackups(input: PruneBackupsInput, options?: CoreCallOptions): Promise; + listHistory(input: ListHistoryInput, options?: CoreCallOptions): Promise; + getHistorySession(input: GetHistorySessionInput, options?: CoreCallOptions): Promise; + startWatch(input: StartWatchInput, options?: CoreCallOptions): Promise; + stopWatch(input: WatchReferenceInput, options?: CoreCallOptions): Promise; + getWatchStatus(input: GetWatchStatusInput, options?: CoreCallOptions): Promise; + getDiagnostics(input: GetDiagnosticsInput, options?: CoreCallOptions): Promise; +} + +export class CoreClientError extends Error { + readonly dto: CoreErrorDto; + readonly code: CoreErrorDto["code"]; + + constructor(dto: CoreErrorDto) { + assertCoreErrorDto(dto); + super(dto.message); + this.name = "CoreClientError"; + this.dto = dto; + this.code = dto.code; + } +} + +export type RequestIdFactory = () => string; + +function defaultRequestIdFactory(): string { + return globalThis.crypto?.randomUUID?.() + ?? `request-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export class TransportCoreClient implements CoreClient { + readonly #transport: CoreTransport; + readonly #requestIdFactory: RequestIdFactory; + + constructor( + transport: CoreTransport, + { requestIdFactory = defaultRequestIdFactory }: { requestIdFactory?: RequestIdFactory } = {} + ) { + this.#transport = transport; + this.#requestIdFactory = requestIdFactory; + } + + async #invoke( + method: M, + payload: CoreMethodMap[M]["input"], + options: CoreCallOptions = {} + ): Promise { + const requestId = options.requestId ?? this.#requestIdFactory(); + const request = createCoreRequestEnvelope( + method, + payload, + requestId, + options.operationId + ); + const response = await this.#transport.request(request, { + signal: options.signal, + onOperationStarted: options.onOperationStarted, + onProgress: options.onProgress + }); + try { + assertCoreResponseEnvelope(response, requestId); + if (response.ok) assertCoreMethodOutput(method, response.result); + } catch (error) { + if (error instanceof ContractValidationError) { + const code = error.code === "PROTOCOL_VERSION_MISMATCH" + ? "PROTOCOL_VERSION_MISMATCH" + : "INTERNAL_ERROR"; + throw new CoreClientError(createPublicCoreErrorDto(code)); + } + throw error; + } + if (!response.ok) throw new CoreClientError(response.error); + return response.result; + } + + getStatus(input: GetStatusInput, options?: CoreCallOptions): Promise { + return this.#invoke("getStatus", input, options); + } + + prepareSync(input: PrepareSyncInput, options?: CoreCallOptions): Promise { + return this.#invoke("prepareSync", input, options); + } + + applySync(input: ApplyPlanInput, options?: CoreCallOptions): Promise { + return this.#invoke("applySync", input, options); + } + + prepareSwitch(input: PrepareSwitchInput, options?: CoreCallOptions): Promise { + return this.#invoke("prepareSwitch", input, options); + } + + applySwitch(input: ApplyPlanInput, options?: CoreCallOptions): Promise { + return this.#invoke("applySwitch", input, options); + } + + listBackups(input: ListBackupsInput, options?: CoreCallOptions): Promise { + return this.#invoke("listBackups", input, options); + } + + prepareRestore(input: PrepareRestoreInput, options?: CoreCallOptions): Promise { + return this.#invoke("prepareRestore", input, options); + } + + applyRestore(input: ApplyPlanInput, options?: CoreCallOptions): Promise { + return this.#invoke("applyRestore", input, options); + } + + pruneBackups(input: PruneBackupsInput, options?: CoreCallOptions): Promise { + return this.#invoke("pruneBackups", input, options); + } + + listHistory(input: ListHistoryInput, options?: CoreCallOptions): Promise { + return this.#invoke("listHistory", input, options); + } + + getHistorySession( + input: GetHistorySessionInput, + options?: CoreCallOptions + ): Promise { + return this.#invoke("getHistorySession", input, options); + } + + startWatch(input: StartWatchInput, options?: CoreCallOptions): Promise { + return this.#invoke("startWatch", input, options); + } + + stopWatch(input: WatchReferenceInput, options?: CoreCallOptions): Promise { + return this.#invoke("stopWatch", input, options); + } + + getWatchStatus( + input: GetWatchStatusInput, + options?: CoreCallOptions + ): Promise { + return this.#invoke("getWatchStatus", input, options); + } + + getDiagnostics( + input: GetDiagnosticsInput, + options?: CoreCallOptions + ): Promise { + return this.#invoke("getDiagnostics", input, options); + } +} diff --git a/packages/core-client/src/desktop.ts b/packages/core-client/src/desktop.ts new file mode 100644 index 0000000..a52d8a0 --- /dev/null +++ b/packages/core-client/src/desktop.ts @@ -0,0 +1,282 @@ +import { + createCoreFailureEnvelope, + createPublicCoreErrorDto, + type CoreOperationEventEnvelope, + type CoreMethodName, + type CoreRequestEnvelope +} from "@codex-provider-sync/contracts"; + +import { + TransportCoreClient, + type CoreTransportCallOptions, + type CoreTransport, + type RequestIdFactory +} from "./client.js"; + +export const DESKTOP_READ_METHODS = Object.freeze([ + "getStatus", + "listBackups", + "listHistory", + "getHistorySession", + "getDiagnostics" +] as const satisfies readonly CoreMethodName[]); + +export type DesktopReadMethod = typeof DESKTOP_READ_METHODS[number]; + +export const DESKTOP_SYNC_SWITCH_METHODS = Object.freeze([ + "prepareSync", + "applySync", + "prepareSwitch", + "applySwitch" +] as const satisfies readonly CoreMethodName[]); + +export type DesktopSyncSwitchMethod = typeof DESKTOP_SYNC_SWITCH_METHODS[number]; + +export const DESKTOP_RESTORE_METHODS = Object.freeze([ + "prepareRestore", + "applyRestore" +] as const satisfies readonly CoreMethodName[]); + +export type DesktopRestoreMethod = typeof DESKTOP_RESTORE_METHODS[number]; + +export const DESKTOP_MAINTENANCE_METHODS = Object.freeze([ + "pruneBackups", + "startWatch", + "stopWatch", + "getWatchStatus" +] as const satisfies readonly CoreMethodName[]); + +export type DesktopMaintenanceMethod = typeof DESKTOP_MAINTENANCE_METHODS[number]; +export type DesktopManagedMethod = + | DesktopSyncSwitchMethod + | DesktopRestoreMethod + | DesktopMaintenanceMethod; +export type DesktopRuntimeMethod = DesktopReadMethod | DesktopManagedMethod; +export const DESKTOP_RUNTIME_METHODS = Object.freeze([ + ...DESKTOP_READ_METHODS, + ...DESKTOP_SYNC_SWITCH_METHODS, + ...DESKTOP_RESTORE_METHODS, + ...DESKTOP_MAINTENANCE_METHODS +] as const satisfies readonly DesktopRuntimeMethod[]); + +const DESKTOP_READ_METHOD_SET = new Set(DESKTOP_READ_METHODS); +const DESKTOP_SYNC_SWITCH_METHOD_SET = new Set(DESKTOP_SYNC_SWITCH_METHODS); +const DESKTOP_RESTORE_METHOD_SET = new Set(DESKTOP_RESTORE_METHODS); +const DESKTOP_MAINTENANCE_METHOD_SET = new Set(DESKTOP_MAINTENANCE_METHODS); + +export function isDesktopReadMethod(method: CoreMethodName): method is DesktopReadMethod { + return DESKTOP_READ_METHOD_SET.has(method); +} + +export function isDesktopSyncSwitchMethod( + method: CoreMethodName +): method is DesktopSyncSwitchMethod { + return DESKTOP_SYNC_SWITCH_METHOD_SET.has(method); +} + +export function isDesktopRestoreMethod(method: CoreMethodName): method is DesktopRestoreMethod { + return DESKTOP_RESTORE_METHOD_SET.has(method); +} + +export function isDesktopMaintenanceMethod( + method: CoreMethodName +): method is DesktopMaintenanceMethod { + return DESKTOP_MAINTENANCE_METHOD_SET.has(method); +} + +export function isDesktopManagedMethod(method: CoreMethodName): method is DesktopManagedMethod { + return isDesktopSyncSwitchMethod(method) + || isDesktopRestoreMethod(method) + || isDesktopMaintenanceMethod(method); +} + +export function isDesktopRuntimeMethod(method: CoreMethodName): method is DesktopRuntimeMethod { + return isDesktopReadMethod(method) || isDesktopManagedMethod(method); +} + +export interface DesktopCancelOperationInput { + requestId: string; + operationId?: string; +} + +export interface DesktopCancelOperationResult { + accepted: boolean; +} + +export interface DesktopCoreBridge { + requestReadOnly( + envelope: CoreRequestEnvelope + ): Promise; + requestSyncSwitch( + envelope: CoreRequestEnvelope + ): Promise; + requestRestore( + envelope: CoreRequestEnvelope + ): Promise; + requestMaintenance( + envelope: CoreRequestEnvelope + ): Promise; + subscribeOperation(listener: (event: CoreOperationEventEnvelope) => void): () => void; + cancelOperation(input: DesktopCancelOperationInput): Promise; +} + +function abortError(): DOMException { + return new DOMException("The desktop Core request was cancelled.", "AbortError"); +} + +function safeNotify(observer: ((event: T) => void) | undefined, event: T): void { + if (!observer) return; + try { observer(event); } catch {} +} + +const DESKTOP_CANCEL_RETRY_MIN_DELAY_MS = 25; +const DESKTOP_CANCEL_RETRY_MAX_DELAY_MS = 1_000; + +class DesktopCoreTransport implements CoreTransport { + readonly #bridge: DesktopCoreBridge; + + constructor(bridge: DesktopCoreBridge) { + this.#bridge = bridge; + } + + async request( + envelope: CoreRequestEnvelope, + options: CoreTransportCallOptions = {} + ): Promise { + if (!isDesktopRuntimeMethod(envelope.method)) { + return createCoreFailureEnvelope( + envelope, + createPublicCoreErrorDto("PERMISSION_DENIED") + ); + } + if (options.signal?.aborted) { + throw abortError(); + } + if (isDesktopReadMethod(envelope.method)) { + const request = this.#bridge.requestReadOnly( + envelope as CoreRequestEnvelope + ); + if (!options.signal) return request; + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + options.signal?.addEventListener("abort", onAbort, { once: true }); + void request.then(resolve, reject).finally(() => { + options.signal?.removeEventListener("abort", onAbort); + }); + }); + } + + const requestManaged = (): Promise => { + if (isDesktopSyncSwitchMethod(envelope.method)) { + return this.#bridge.requestSyncSwitch( + envelope as CoreRequestEnvelope + ); + } + if (isDesktopRestoreMethod(envelope.method)) { + return this.#bridge.requestRestore( + envelope as CoreRequestEnvelope + ); + } + return this.#bridge.requestMaintenance( + envelope as CoreRequestEnvelope + ); + }; + const isApply = envelope.method === "applySync" + || envelope.method === "applySwitch" + || envelope.method === "applyRestore"; + if (!isApply) { + const request = requestManaged(); + if (!options.signal) return request; + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + options.signal?.addEventListener("abort", onAbort, { once: true }); + void request.then(resolve, reject).finally(() => { + options.signal?.removeEventListener("abort", onAbort); + }); + }); + } + + let operationId: string | undefined; + let cancelRequested = false; + let requestSettled = false; + let cancellationInFlight = false; + let cancellationRetryTimer: ReturnType | undefined; + let cancellationRetryDelayMs = DESKTOP_CANCEL_RETRY_MIN_DELAY_MS; + const scheduleCancellationRetry = (): void => { + if (requestSettled || !cancelRequested || cancellationRetryTimer !== undefined) return; + const delayMs = cancellationRetryDelayMs; + cancellationRetryDelayMs = Math.min( + cancellationRetryDelayMs * 2, + DESKTOP_CANCEL_RETRY_MAX_DELAY_MS + ); + cancellationRetryTimer = setTimeout(() => { + cancellationRetryTimer = undefined; + void sendCancellation(); + }, delayMs); + }; + const sendCancellation = async (): Promise => { + if (requestSettled || !cancelRequested || cancellationInFlight) return; + cancellationInFlight = true; + const attemptedOperationId = operationId; + let accepted = false; + try { + accepted = (await this.#bridge.cancelOperation({ + requestId: envelope.requestId, + ...(attemptedOperationId ? { operationId: attemptedOperationId } : {}) + })).accepted; + } catch { + // A transient invoke failure is equivalent to an unaccepted cancel. + } finally { + cancellationInFlight = false; + } + if (requestSettled || !cancelRequested) return; + if (!accepted || operationId !== attemptedOperationId) { + scheduleCancellationRetry(); + } + }; + const requestCancellation = (): void => { + if (cancellationRetryTimer !== undefined) { + clearTimeout(cancellationRetryTimer); + cancellationRetryTimer = undefined; + } + void sendCancellation(); + }; + const unsubscribe = this.#bridge.subscribeOperation((event) => { + if (event.requestId !== envelope.requestId) return; + if (event.event === "operation-started") { + operationId = event.operationId; + safeNotify(options.onOperationStarted, event); + if (cancelRequested) { + cancellationRetryDelayMs = DESKTOP_CANCEL_RETRY_MIN_DELAY_MS; + requestCancellation(); + } + } else { + safeNotify(options.onProgress, event); + } + }); + const onAbort = () => { + cancelRequested = true; + cancellationRetryDelayMs = DESKTOP_CANCEL_RETRY_MIN_DELAY_MS; + requestCancellation(); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + const request = requestManaged(); + return new Promise((resolve, reject) => { + void request.then(resolve, reject).finally(() => { + requestSettled = true; + if (cancellationRetryTimer !== undefined) clearTimeout(cancellationRetryTimer); + options.signal?.removeEventListener("abort", onAbort); + unsubscribe(); + }); + }); + } +} + +export class DesktopCoreClient extends TransportCoreClient { + constructor( + bridge: DesktopCoreBridge, + { requestIdFactory }: { requestIdFactory?: RequestIdFactory } = {} + ) { + super(new DesktopCoreTransport(bridge), { requestIdFactory }); + } +} diff --git a/packages/core-client/src/http.ts b/packages/core-client/src/http.ts new file mode 100644 index 0000000..95bf9b3 --- /dev/null +++ b/packages/core-client/src/http.ts @@ -0,0 +1,304 @@ +import type { + CoreOperationEventEnvelope, + CoreMethodName, + CoreRequestEnvelope, + CoreResponseEnvelope +} from "@codex-provider-sync/contracts"; +import { + assertCoreOperationEventEnvelope, + assertCoreMethodOutput, + assertCoreResponseEnvelope, + createCoreFailureEnvelope, + createPublicCoreErrorDto +} from "@codex-provider-sync/contracts"; + +import { + TransportCoreClient, + type CoreTransportCallOptions, + type CoreTransport, + type RequestIdFactory +} from "./client.js"; + +export const MAX_CORE_REQUEST_BYTES = 64 * 1024; +export const MAX_CORE_STREAM_BYTES = 16 * 1024 * 1024; +const CORE_STREAM_CONTENT_TYPE = "application/x-ndjson"; + +function safeNotify(observer: ((event: T) => void) | undefined, event: T): void { + if (!observer) return; + try { observer(event); } catch {} +} + +export class CoreTransportError extends Error { + readonly status: number | null; + + constructor(message: string, status: number | null = null) { + super(message); + this.name = "CoreTransportError"; + this.status = status; + } +} + +export interface HttpCoreTransportOptions { + baseUrl: string; + endpoint?: string; + fetch?: typeof globalThis.fetch; + headers?: Readonly>; +} + +export class HttpCoreTransport implements CoreTransport { + readonly #url: URL; + readonly #cancelUrl: URL; + readonly #fetch: typeof globalThis.fetch; + readonly #headers: Readonly>; + + constructor({ + baseUrl, + endpoint = "/api/core", + fetch: fetchImplementation = globalThis.fetch, + headers = {} + }: HttpCoreTransportOptions) { + if (typeof fetchImplementation !== "function") { + throw new TypeError("HttpCoreTransport requires a Fetch implementation."); + } + this.#url = new URL(endpoint, baseUrl); + this.#cancelUrl = new URL(`${endpoint.replace(/\/$/, "")}/cancel`, baseUrl); + this.#fetch = fetchImplementation; + this.#headers = Object.freeze({ ...headers }); + } + + async request( + envelope: CoreRequestEnvelope, + options: CoreTransportCallOptions = {} + ): Promise { + const body = JSON.stringify(envelope); + if (new TextEncoder().encode(body).byteLength > MAX_CORE_REQUEST_BYTES) { + throw new CoreTransportError("Core request exceeds the 64 KiB transport limit."); + } + const isApply = envelope.method === "applySync" + || envelope.method === "applySwitch" + || envelope.method === "applyRestore"; + if (options.signal?.aborted) { + if (isApply) { + return createCoreFailureEnvelope( + envelope, + createPublicCoreErrorDto("OPERATION_CANCELLED") + ); + } + throw new DOMException("The Core HTTP request was cancelled.", "AbortError"); + } + let operationId: string | undefined; + let cancellationRequested = false; + const requestCancellation = () => { + cancellationRequested = true; + void this.#fetch(this.#cancelUrl, { + method: "POST", + credentials: "same-origin", + redirect: "error", + headers: { + "Content-Type": "application/json", + ...this.#headers + }, + body: JSON.stringify({ + protocolVersion: envelope.protocolVersion, + requestId: envelope.requestId, + ...(operationId ? { operationId } : {}) + }) + }).catch(() => undefined); + }; + const onAbort = isApply ? requestCancellation : undefined; + if (onAbort) options.signal?.addEventListener("abort", onAbort, { once: true }); + let response: Response; + try { + response = await this.#fetch(this.#url, { + method: "POST", + credentials: "same-origin", + redirect: "error", + headers: { + "Content-Type": "application/json", + "Accept": CORE_STREAM_CONTENT_TYPE, + ...this.#headers + }, + body, + signal: isApply ? undefined : options.signal + }); + } catch { + if (onAbort) options.signal?.removeEventListener("abort", onAbort); + throw new CoreTransportError("Core HTTP request failed."); + } + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.startsWith(CORE_STREAM_CONTENT_TYPE)) { + try { + const payload = await this.#readStream(response, envelope, options, { + get operationId() { return operationId; }, + set operationId(value: string | undefined) { operationId = value; }, + get cancellationRequested() { return cancellationRequested; }, + requestCancellation + }); + if (!response.ok + && payload !== null + && typeof payload === "object" + && !Array.isArray(payload) + && "ok" in payload + && payload.ok === true) { + throw new CoreTransportError("Core HTTP request failed.", response.status); + } + return payload; + } finally { + if (onAbort) options.signal?.removeEventListener("abort", onAbort); + } + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + if (onAbort) options.signal?.removeEventListener("abort", onAbort); + throw new CoreTransportError( + "Core HTTP response was not valid JSON.", + response.status + ); + } + if (onAbort) options.signal?.removeEventListener("abort", onAbort); + if (!response.ok) { + // A valid Core failure envelope still carries the canonical error DTO; + // TransportCoreClient performs the protocol and DTO checks. + if (payload === null + || typeof payload !== "object" + || Array.isArray(payload) + || !("ok" in payload) + || payload.ok !== false) { + throw new CoreTransportError("Core HTTP request failed.", response.status); + } + } + return payload; + } + + async #readStream( + response: Response, + request: CoreRequestEnvelope, + options: CoreTransportCallOptions, + cancellation: { + operationId?: string; + cancellationRequested: boolean; + requestCancellation(): void; + } + ): Promise { + if (!response.body) throw new CoreTransportError("Core HTTP stream has no body.", response.status); + const isApply = request.method === "applySync" + || request.method === "applySwitch" + || request.method === "applyRestore"; + const expectedOperation = request.method === "applySync" + ? "sync" + : request.method === "applySwitch" + ? "switch" + : request.method === "applyRestore" + ? "restore" + : null; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + let received = 0; + let finalEnvelope: unknown; + const consume = (line: string) => { + if (!line.trim()) return; + if (finalEnvelope !== undefined) { + throw new CoreTransportError("Core HTTP stream contained data after its terminal envelope.", response.status); + } + let value: unknown; + try { value = JSON.parse(line); } + catch { throw new CoreTransportError("Core HTTP stream contained invalid JSON.", response.status); } + if (value !== null && typeof value === "object" && !Array.isArray(value) && "event" in value) { + if (!isApply) { + throw new CoreTransportError("Core HTTP read stream contained an operation event.", response.status); + } + const eventKind = "event" in value ? value.event : undefined; + if (cancellation.operationId === undefined && eventKind !== "operation-started") { + throw new CoreTransportError("Core HTTP stream emitted progress before operation-started.", response.status); + } + if (cancellation.operationId !== undefined && eventKind === "operation-started") { + throw new CoreTransportError("Core HTTP stream emitted multiple operation-started events.", response.status); + } + try { + assertCoreOperationEventEnvelope(value, request.requestId, cancellation.operationId); + } catch { + throw new CoreTransportError("Core HTTP stream contained an invalid operation event.", response.status); + } + const event = value as CoreOperationEventEnvelope; + if (event.event === "operation-started" && event.operation !== expectedOperation) { + throw new CoreTransportError("Core HTTP stream started the wrong operation.", response.status); + } + cancellation.operationId = event.operationId; + if (event.event === "operation-started") safeNotify(options.onOperationStarted, event); + else safeNotify(options.onProgress, event); + if (cancellation.cancellationRequested) cancellation.requestCancellation(); + return; + } + try { + assertCoreResponseEnvelope(value, request.requestId); + } catch { + throw new CoreTransportError("Core HTTP stream contained an invalid terminal envelope.", response.status); + } + const terminal = value as CoreResponseEnvelope; + const boundOperationId = cancellation.operationId; + if (boundOperationId !== undefined) { + if (terminal.operationId !== boundOperationId + || (!terminal.ok + && terminal.error.operationId !== undefined + && terminal.error.operationId !== boundOperationId)) { + throw new CoreTransportError("Core HTTP stream terminal operationId did not match its lifecycle.", response.status); + } + } else if (isApply && terminal.operationId !== undefined) { + throw new CoreTransportError("Core HTTP stream ended an unannounced operation.", response.status); + } + if (terminal.ok) { + try { + assertCoreMethodOutput(request.method, terminal.result); + } catch { + throw new CoreTransportError("Core HTTP stream contained an invalid terminal result.", response.status); + } + if (isApply) { + if (boundOperationId === undefined) { + throw new CoreTransportError("Core HTTP apply stream ended without operation-started.", response.status); + } + const operationResult = terminal.result as { operationId?: unknown }; + if (operationResult.operationId !== boundOperationId) { + throw new CoreTransportError("Core HTTP stream result operationId did not match its lifecycle.", response.status); + } + } + } + finalEnvelope = terminal; + }; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > MAX_CORE_STREAM_BYTES) { + await reader.cancel(); + throw new CoreTransportError("Core HTTP stream exceeded its response limit.", response.status); + } + buffered += decoder.decode(value, { stream: true }); + let newline; + while ((newline = buffered.indexOf("\n")) >= 0) { + consume(buffered.slice(0, newline)); + buffered = buffered.slice(newline + 1); + } + } + buffered += decoder.decode(); + consume(buffered); + if (finalEnvelope === undefined) { + throw new CoreTransportError("Core HTTP stream ended without a terminal envelope.", response.status); + } + return finalEnvelope; + } +} + +export interface HttpCoreClientOptions extends HttpCoreTransportOptions { + requestIdFactory?: RequestIdFactory; +} + +export class HttpCoreClient extends TransportCoreClient { + constructor(options: HttpCoreClientOptions) { + super(new HttpCoreTransport(options), { + requestIdFactory: options.requestIdFactory + }); + } +} diff --git a/packages/core-client/src/index.ts b/packages/core-client/src/index.ts new file mode 100644 index 0000000..95ec55a --- /dev/null +++ b/packages/core-client/src/index.ts @@ -0,0 +1,4 @@ +export * from "./client.js"; +export * from "./desktop.js"; +export * from "./http.js"; +export * from "./mock.js"; diff --git a/packages/core-client/src/mock.ts b/packages/core-client/src/mock.ts new file mode 100644 index 0000000..6c7dc7f --- /dev/null +++ b/packages/core-client/src/mock.ts @@ -0,0 +1,95 @@ +import { + createCoreFailureEnvelope, + createPublicCoreErrorDto, + createCoreSuccessEnvelope, + sanitizePublicCoreErrorDto, + type CoreErrorDto, + type CoreMethodMap, + type CoreMethodName, + type CoreRequestEnvelope +} from "@codex-provider-sync/contracts"; + +import { + TransportCoreClient, + type CoreTransportCallOptions, + type CoreTransport, + type RequestIdFactory +} from "./client.js"; + +type MaybePromise = T | Promise; + +export type MockCoreHandler = ( + payload: CoreMethodMap[M]["input"], + request: CoreRequestEnvelope, + control: CoreTransportCallOptions +) => MaybePromise; + +export type MockCoreHandlers = { + [M in CoreMethodName]?: MockCoreHandler; +}; + +export function legacyErrorToDto(error: unknown): CoreErrorDto { + return sanitizePublicCoreErrorDto(error); +} + +class MockCoreTransport implements CoreTransport { + readonly #handlers: MockCoreHandlers; + readonly requests: CoreRequestEnvelope[] = []; + + constructor(handlers: MockCoreHandlers) { + this.#handlers = { ...handlers }; + } + + async request( + request: CoreRequestEnvelope, + options: CoreTransportCallOptions = {} + ): Promise { + this.requests.push(request); + const handler = this.#handlers[request.method] as MockCoreHandler | undefined; + if (!handler) { + return createCoreFailureEnvelope(request, createPublicCoreErrorDto("INTERNAL_ERROR")); + } + let result: CoreMethodMap[M]["output"]; + try { + result = await handler(request.payload, request, { + ...(options.signal ? { signal: options.signal } : {}), + ...(options.onOperationStarted ? { + onOperationStarted(event) { + try { options.onOperationStarted?.(event); } catch {} + } + } : {}), + ...(options.onProgress ? { + onProgress(event) { + try { options.onProgress?.(event); } catch {} + } + } : {}) + }); + } catch (error) { + return createCoreFailureEnvelope(request, legacyErrorToDto(error)); + } + const operationId = result !== null + && typeof result === "object" + && "operationId" in result + && typeof result.operationId === "string" + ? result.operationId + : undefined; + try { + return createCoreSuccessEnvelope(request, result, operationId); + } catch { + return createCoreFailureEnvelope(request, createPublicCoreErrorDto("INTERNAL_ERROR")); + } + } +} + +export class MockCoreClient extends TransportCoreClient { + readonly requests: readonly CoreRequestEnvelope[]; + + constructor( + handlers: MockCoreHandlers, + { requestIdFactory }: { requestIdFactory?: RequestIdFactory } = {} + ) { + const transport = new MockCoreTransport(handlers); + super(transport, { requestIdFactory }); + this.requests = transport.requests; + } +} diff --git a/packages/core-client/tsconfig.json b/packages/core-client/tsconfig.json new file mode 100644 index 0000000..935ab5f --- /dev/null +++ b/packages/core-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { "path": "../contracts" } + ] +} diff --git a/packages/core/checks/core-surface.contract.mjs b/packages/core/checks/core-surface.contract.mjs new file mode 100644 index 0000000..5d3218d --- /dev/null +++ b/packages/core/checks/core-surface.contract.mjs @@ -0,0 +1,371 @@ +import assert from "node:assert/strict"; +import nodeFs from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import * as core from "../src/index.js"; +import { toPublicProgress } from "../src/progress.js"; + +const EXPECTED_METHODS = [ + "applyRestore", + "applySwitch", + "applySync", + "getDiagnostics", + "getHistorySession", + "getStatus", + "getWatchStatus", + "listBackups", + "listHistory", + "prepareRestore", + "prepareSwitch", + "prepareSync", + "pruneBackups", + "startWatch", + "stopWatch" +]; + +test("Core workspace exposes only the stable vNext method surface", () => { + assert.deepEqual(Object.keys(core), ["createCoreFacade"]); + const facade = core.createCoreFacade({ + resolveProfile: async ({ profileId }) => ({ + id: profileId, + revision: "r1", + codexHome: process.cwd() + }) + }); + assert.deepEqual(Object.keys(facade).sort(), EXPECTED_METHODS); + assert.equal("runSync" in core, false); + assert.equal("resolveStorageLayout" in core, false); +}); + +test("Core progress projection enforces the shared DTO numeric ranges", () => { + assert.deepEqual( + toPublicProgress({ stage: "scan", status: "running", progress: 0.5, count: 2 }), + { stage: "scan", status: "running", progress: 0.5, count: 2 } + ); + assert.deepEqual( + toPublicProgress({ stage: "scan", status: "running", progress: 1.1, count: -1 }), + { stage: "scan", status: "running" } + ); + assert.deepEqual( + toPublicProgress({ stage: "scan", status: "running", progress: -0.1, count: 1.5 }), + { stage: "scan", status: "running" } + ); + assert.equal(toPublicProgress({ stage: "", status: "running" }), null); +}); + +test("Core workspace bridge imports only the root public API", async () => { + const source = await fs.readFile(new URL("../src/index.js", import.meta.url), "utf8"); + const declarations = await fs.readFile(new URL("../src/index.d.ts", import.meta.url), "utf8"); + const rootDeclarations = await fs.readFile( + new URL("../../../src/public-api.d.ts", import.meta.url), + "utf8" + ); + const rootImports = [...source.matchAll(/from\s+["'](\.\.\/\.\.\/\.\.\/src\/[^"']+)["']/g)] + .map((match) => match[1]); + assert.deepEqual(rootImports, ["../../../src/public-api.js"]); + assert.doesNotMatch(source, /src\/(service|locking|backup|history|watch)\.js/); + assert.doesNotMatch(source, /faultInjector/); + assert.doesNotMatch(declarations, /faultInjector/); + assert.doesNotMatch(rootDeclarations, /faultInjector/); +}); + +test("profile resolution fails closed before a Core path can be selected", async () => { + let calls = 0; + const facade = core.createCoreFacade({ + resolveProfile: async ({ profileId }) => { + calls += 1; + return { + id: `${profileId}-wrong`, + revision: "r1", + codexHome: process.cwd() + }; + } + }); + await assert.rejects( + facade.getStatus({ profile: { profileId: "selected" } }), + (error) => error?.code === "INVALID_INPUT" + ); + assert.equal(calls, 1); +}); + +test("trusted profile selection never falls back to the process default Codex Home", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-core-facade-")); + const defaultHome = path.join(testRoot, "default-home"); + const selectedHome = path.join(testRoot, "selected-home"); + const originalCodexHome = process.env.CODEX_HOME; + try { + for (const home of [defaultHome, selectedHome]) { + await fs.mkdir(path.join(home, "sessions"), { recursive: true }); + await fs.mkdir(path.join(home, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.join(home, "sqlite"), { recursive: true }); + } + await fs.writeFile(path.join(defaultHome, "config.toml"), 'model_provider = "wrong-default"\n'); + await fs.writeFile(path.join(selectedHome, "config.toml"), 'model_provider = "openai"\n'); + await fs.writeFile(path.join(selectedHome, "sessions", "rollout-synthetic.jsonl"), `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-25T00:00:00.000Z", + payload: { + id: "synthetic-session", + title: "Synthetic session", + cwd: path.join(selectedHome, "private-project"), + model_provider: "relay", + encrypted_content: "synthetic-ciphertext" + } + })}\n`); + process.env.CODEX_HOME = defaultHome; + const selectors = []; + const facade = core.createCoreFacade({ + resolveProfile: async (selector) => { + selectors.push(selector); + return { + id: "selected", + revision: "selected-r1", + codexHome: selectedHome + }; + } + }); + const input = { profile: { profileId: "selected", profileRevision: "selected-r1" } }; + const status = await facade.getStatus(input); + const backups = await facade.listBackups(input); + const history = await facade.listHistory(input); + const diagnostics = await facade.getDiagnostics(input); + const pruned = await facade.pruneBackups({ ...input, keepCount: 1 }); + const plan = await facade.prepareSync({ ...input, keepCount: 1 }); + assert.equal(status.currentProvider, "openai"); + assert.deepEqual(status.profile, { id: "selected", revision: "selected-r1" }); + assert.equal(status.codexHomeSource, "profile"); + assert.equal("codexHome" in status, false); + assert.equal("sqliteHome" in status, false); + assert.deepEqual(backups, { backups: [] }); + assert.equal(history.sessions.length, 1); + assert.equal(history.sessions[0].id, "synthetic-session"); + assert.equal(history.sessions[0].messageCount, 0); + assert.equal(history.sessions[0].messageCountKnown, false); + assert.equal("cwd" in history.sessions[0], false); + assert.ok(plan.warnings.includes( + "Some encrypted histories may require their original Provider or account for continuation." + )); + assert.equal(plan.warnings.every((warning) => [ + "Some encrypted histories may require their original Provider or account for continuation.", + "Project visibility diagnostics are unavailable; backup-first protection remains enabled." + ].includes(warning)), true); + assert.doesNotMatch(JSON.stringify(plan), /private-project|synthetic-ciphertext/); + assert.equal(diagnostics.storage.sqliteHomeSource, "default"); + assert.equal("codexHome" in diagnostics.storage, false); + assert.deepEqual(Object.keys(diagnostics.runtime).sort(), ["arch", "node", "platform"]); + assert.deepEqual( + Object.keys(diagnostics.storage).sort(), + ["sqliteHomeSource", "sqliteSupported", "stateDbFound"] + ); + assert.deepEqual( + Object.keys(diagnostics.provider).sort(), + ["configured", "current", "implicit", "rolloutCounts", "sqliteCounts"] + ); + assert.deepEqual( + Object.keys(diagnostics.safety).sort(), + [ + "lockedRolloutCount", + "operationInProgress", + "pendingRecovery", + "pendingTransactions", + "projectThreadVisibilityAvailable", + "rolloutScanComplete", + "storageRevision" + ] + ); + assert.doesNotMatch( + JSON.stringify(diagnostics), + new RegExp(selectedHome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") + ); + assert.doesNotMatch(JSON.stringify(diagnostics), /private-project|synthetic-ciphertext/i); + assert.equal(pruned.deletedCount, 0); + assert.equal(selectors.length, 6); + assert.equal(selectors.every((selector) => selector.profileId === "selected"), true); + } finally { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("public Status reads rollout metadata only while write preparation keeps the deep scan", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-core-status-metadata-")); + const codexHome = path.join(testRoot, "codex-home"); + const rollout = path.join(codexHome, "sessions", "rollout-large.jsonl"); + const originalCreateReadStream = nodeFs.createReadStream; + const originalReadFile = nodeFs.promises.readFile; + let streamBodyReadAttempts = 0; + let revisionBodyReadAttempts = 0; + try { + await fs.mkdir(path.dirname(rollout), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\n'); + await fs.writeFile(rollout, [ + JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-28T00:00:00.000Z", + payload: { id: "large", cwd: "C:\\private", model_provider: "relay" } + }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "private body" } }), + JSON.stringify({ type: "turn_context", payload: { model: "private-model" } }) + ].join("\n") + "\n"); + + nodeFs.createReadStream = ((filePath, ...args) => { + if (path.resolve(String(filePath)) === path.resolve(rollout)) { + streamBodyReadAttempts += 1; + throw new Error("rollout body scan sentinel"); + } + return originalCreateReadStream.call(nodeFs, filePath, ...args); + }); + nodeFs.promises.readFile = (async (filePath, ...args) => { + if (path.resolve(String(filePath)) === path.resolve(rollout)) { + revisionBodyReadAttempts += 1; + throw new Error("rollout body revision sentinel"); + } + return originalReadFile.call(nodeFs.promises, filePath, ...args); + }); + + const facade = core.createCoreFacade({ + resolveProfile: async () => ({ + id: "default", + revision: "r1", + codexHome + }) + }); + const input = { profile: { profileId: "default", profileRevision: "r1" } }; + const status = await facade.getStatus(input); + + assert.equal(status.rolloutCounts.sessions.relay, 1); + assert.equal(status.rolloutScanComplete, true); + assert.equal(streamBodyReadAttempts, 0); + assert.equal(revisionBodyReadAttempts, 0); + assert.doesNotMatch(JSON.stringify(status), /private body|private-model|C:\\private/); + + nodeFs.promises.readFile = originalReadFile; + await assert.rejects( + facade.prepareSync({ ...input, keepCount: 1 }), + /rollout body scan sentinel/ + ); + assert.equal(streamBodyReadAttempts, 1); + } finally { + nodeFs.createReadStream = originalCreateReadStream; + nodeFs.promises.readFile = originalReadFile; + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("public Status bounds malformed oversized session metadata and fails alignment closed", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-core-status-limit-")); + const codexHome = path.join(testRoot, "codex-home"); + try { + await fs.mkdir(path.join(codexHome, "sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\n'); + await fs.writeFile( + path.join(codexHome, "sessions", "rollout-oversized.jsonl"), + Buffer.alloc((1024 * 1024) + 1, 0x78) + ); + const facade = core.createCoreFacade({ + resolveProfile: async () => ({ id: "default", revision: "r1", codexHome }) + }); + + const status = await facade.getStatus({ + profile: { profileId: "default", profileRevision: "r1" } + }); + + assert.equal(status.rolloutScanComplete, false); + assert.equal(status.alignment.aligned, false); + assert.deepEqual(status.rolloutCounts.sessions, {}); + assert.deepEqual(status.lockedRolloutFiles, []); + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("an unverifiable Home lock remains a valid fail-closed public StatusSnapshot", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-core-blocked-status-")); + const codexHome = path.join(testRoot, "codex-home"); + const lockDir = path.join(codexHome, "tmp", "provider-sync.lock"); + try { + await fs.mkdir(path.join(codexHome, "sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\n'); + await fs.writeFile(path.join(lockDir, "owner.json"), "{malformed", "utf8"); + const facade = core.createCoreFacade({ + resolveProfile: async () => ({ + id: "default", + revision: "r1", + codexHome + }) + }); + + const status = await facade.getStatus({ + profile: { profileId: "default", profileRevision: "r1" } + }); + + assert.equal(status.sqliteHomeSource, "unknown"); + assert.equal(typeof status.storageRevision, "string"); + assert.ok(status.storageRevision.length > 0); + assert.equal(status.operationInProgress.lockState, "unverifiable"); + assert.equal(status.operationInProgress.errorCode, "LOCK_UNVERIFIABLE"); + assert.equal(status.rolloutScanComplete, false); + assert.equal(status.alignment.aligned, false); + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("host operation controls stay off the method surface and project pathless progress", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-core-control-")); + const codexHome = path.join(testRoot, "codex-home"); + const rollout = path.join(codexHome, "sessions", "rollout-control.jsonl"); + try { + await fs.mkdir(path.dirname(rollout), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\n'); + await fs.writeFile(rollout, `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-26T00:00:00.000Z", + payload: { id: "control", cwd: "C:\\private", model_provider: "legacy" } + })}\n`); + const facade = core.createCoreFacade({ + resolveProfile: async () => ({ + id: "default", + revision: "r1", + codexHome + }) + }); + const plan = await facade.prepareSync({ + profile: { profileId: "default", profileRevision: "r1" }, + keepCount: 1 + }); + const started = []; + const progress = []; + const result = await facade.applySync( + { schemaVersion: 1, planId: plan.planId }, + { + onOperationStarted(event) { started.push(event); }, + onProgress(event) { + progress.push(event); + throw new Error("observer failure must not change the transaction"); + } + } + ); + assert.equal(result.outcome, "completed"); + assert.equal(started.length, 1); + assert.equal(started[0].operationId, result.operationId); + assert.ok(progress.length > 0); + assert.equal(progress.every((event) => ( + Object.keys(event).every((key) => ["stage", "status", "progress", "count"].includes(key)) + )), true); + assert.doesNotMatch(JSON.stringify(progress), /private|backupDir|state_5\.sqlite/i); + assert.deepEqual(Object.keys(facade).sort(), EXPECTED_METHODS); + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..64ddf85 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@codex-provider-sync/core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test checks/core-surface.contract.mjs" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@codex-provider-sync/contracts": "0.0.0" + } +} diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts new file mode 100644 index 0000000..3914609 --- /dev/null +++ b/packages/core/src/index.d.ts @@ -0,0 +1,68 @@ +import type { + ApplyPlanInput, + BackupList, + DiagnosticsSnapshot, + GetDiagnosticsInput, + GetHistorySessionInput, + GetStatusInput, + GetWatchStatusInput, + HistoryPage, + HistorySessionDetail, + ListBackupsInput, + ListHistoryInput, + OperationResult, + PlanSummary, + ProgressEvent, + PrepareRestoreInput, + PrepareSwitchInput, + PrepareSyncInput, + ProfileSelector, + PruneBackupsInput, + PruneBackupsResult, + StartWatchInput, + StatusSnapshot, + WatchReferenceInput, + WatchSnapshot, + WatchStatusList +} from "@codex-provider-sync/contracts"; + +export interface ResolvedProfile { + id: string; + revision: string; + codexHome: string; + sqliteHome?: string; +} + +export type ProfileResolver = ( + selector: ProfileSelector +) => ResolvedProfile | Promise; + +/** @internal Trusted host control. Never expose this object to HTTP, IPC, or Renderer input. */ +export interface CoreHostOperationControl { + signal?: AbortSignal; + onOperationStarted?(value: { + operationId: string; + operation: "sync" | "switch" | "restore"; + }): void | Promise; + onProgress?(event: ProgressEvent): void | Promise; +} + +export interface CoreFacade { + getStatus(input: GetStatusInput): Promise; + prepareSync(input: PrepareSyncInput): Promise; + applySync(input: ApplyPlanInput, control?: CoreHostOperationControl): Promise; + prepareSwitch(input: PrepareSwitchInput): Promise; + applySwitch(input: ApplyPlanInput, control?: CoreHostOperationControl): Promise; + listBackups(input: ListBackupsInput): Promise; + prepareRestore(input: PrepareRestoreInput): Promise; + applyRestore(input: ApplyPlanInput, control?: CoreHostOperationControl): Promise; + pruneBackups(input: PruneBackupsInput): Promise; + listHistory(input: ListHistoryInput): Promise; + getHistorySession(input: GetHistorySessionInput): Promise; + startWatch(input: StartWatchInput): Promise; + stopWatch(input: WatchReferenceInput): Promise; + getWatchStatus(input?: GetWatchStatusInput): Promise; + getDiagnostics(input: GetDiagnosticsInput): Promise; +} + +export function createCoreFacade(options: { resolveProfile: ProfileResolver }): CoreFacade; diff --git a/packages/core/src/index.js b/packages/core/src/index.js new file mode 100644 index 0000000..65a0ed4 --- /dev/null +++ b/packages/core/src/index.js @@ -0,0 +1,815 @@ +// @ts-check + +// C4 keeps the proven high-risk implementation in root src/. This factory is +// the only transitional import allowed to cross that boundary. Product inputs +// contain a profile selector only; trusted hosts resolve all filesystem paths. +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + CoreError, + applyRestore as applyRestoreInternal, + applySwitch as applySwitchInternal, + applySync as applySyncInternal, + getDiagnostics as getDiagnosticsInternal, + getHistorySession as getHistorySessionInternal, + getStatus as getStatusInternal, + getWatchStatus as getWatchStatusInternal, + listBackups as listBackupsInternal, + listHistory as listHistoryInternal, + prepareRestore as prepareRestoreInternal, + prepareSwitch as prepareSwitchInternal, + prepareSync as prepareSyncInternal, + pruneBackups as pruneBackupsInternal, + startWatch as startWatchInternal, + stopWatch as stopWatchInternal +} from "../../../src/public-api.js"; +import { toPublicProgress } from "./progress.js"; + +/** @typedef {{profileId: string, profileRevision?: string}} ProfileSelector */ +/** @typedef {{id: string, revision: string, codexHome: string, sqliteHome?: string}} ResolvedProfile */ +/** @typedef {(selector: ProfileSelector) => ResolvedProfile | Promise} ProfileResolver */ +/** @typedef {Record} JsonRecord */ +/** @typedef {{stage: string, status: string, progress?: number, count?: number}} PublicProgress */ +/** @typedef {{ + * signal?: AbortSignal, + * onOperationStarted?: (value: {operationId: string, operation: "sync" | "switch" | "restore"}) => void | Promise, + * onProgress?: (event: PublicProgress) => void | Promise + * }} CoreHostOperationControl */ +/** @typedef {{ + * getStatus: (input: JsonRecord) => Promise, + * prepareSync: (input: JsonRecord) => Promise, + * applySync: (input: JsonRecord, control?: CoreHostOperationControl) => Promise, + * prepareSwitch: (input: JsonRecord) => Promise, + * applySwitch: (input: JsonRecord, control?: CoreHostOperationControl) => Promise, + * listBackups: (input: JsonRecord) => Promise, + * prepareRestore: (input: JsonRecord) => Promise, + * applyRestore: (input: JsonRecord, control?: CoreHostOperationControl) => Promise, + * pruneBackups: (input: JsonRecord) => Promise, + * listHistory: (input: JsonRecord) => Promise, + * getHistorySession: (input: JsonRecord) => Promise, + * startWatch: (input: JsonRecord) => Promise, + * stopWatch: (input: JsonRecord) => Promise, + * getWatchStatus: (input?: JsonRecord) => Promise, + * getDiagnostics: (input: JsonRecord) => Promise + * }} CoreFacade */ + +const PROFILE_ID_PATTERN = /^[A-Za-z0-9._-]{1,80}$/; + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** @param {unknown} input */ +function requireProfileSelector(input) { + if (!isRecord(input) || !isRecord(input.profile)) { + throw new CoreError("INVALID_INPUT", "A trusted profile selector is required."); + } + const selector = input.profile; + const allowedKeys = new Set(["profileId", "profileRevision"]); + if (Object.keys(selector).some((key) => !allowedKeys.has(key)) + || typeof selector.profileId !== "string" + || !PROFILE_ID_PATTERN.test(selector.profileId) + || (selector.profileRevision !== undefined + && (typeof selector.profileRevision !== "string" + || !selector.profileRevision + || selector.profileRevision.length > 512))) { + throw new CoreError("INVALID_INPUT", "The profile selector is invalid."); + } + return /** @type {ProfileSelector} */ ({ + profileId: selector.profileId, + ...(selector.profileRevision === undefined + ? {} + : { profileRevision: selector.profileRevision }) + }); +} + +/** @param {ResolvedProfile} value @param {ProfileSelector} selector */ +function validateResolvedProfile(value, selector) { + if (!isRecord(value) + || typeof value.id !== "string" + || value.id !== selector.profileId + || !PROFILE_ID_PATTERN.test(value.id) + || typeof value.revision !== "string" + || !value.revision + || value.revision.length > 512 + || typeof value.codexHome !== "string" + || !path.isAbsolute(value.codexHome) + || (value.sqliteHome !== undefined + && (typeof value.sqliteHome !== "string" || !path.isAbsolute(value.sqliteHome)))) { + throw new CoreError("INVALID_INPUT", "The trusted profile resolver returned an invalid profile."); + } + if (selector.profileRevision !== undefined && selector.profileRevision !== value.revision) { + throw new CoreError("PROFILE_CHANGED", "The selected profile changed. Prepare the operation again."); + } + return Object.freeze({ + id: value.id, + revision: value.revision, + codexHome: path.resolve(value.codexHome), + ...(value.sqliteHome ? { sqliteHome: path.resolve(value.sqliteHome) } : {}) + }); +} + +/** @param {ResolvedProfile} profile @param {string} [revision] */ +function rootProfileInput(profile, revision = profile.revision) { + return { + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}), + profileId: profile.id, + profileRevision: revision + }; +} + +/** @param {unknown} value @param {ResolvedProfile} profile */ +function withPublicProfile(value, profile) { + if (!isRecord(value)) return value; + return { ...value, profile: { id: profile.id, revision: profile.revision } }; +} + +/** @param {unknown} value */ +function publicWarnings(value) { + if (!Array.isArray(value)) return []; + /** @type {string[]} */ + const result = []; + for (const warning of value.filter((entry) => typeof entry === "string")) { + let projected; + if (warning.startsWith("Backup inventory refresh failed:")) { + projected = "Backup inventory refresh failed."; + } else if (warning.startsWith("Automatic backup cleanup failed:")) { + projected = "Automatic backup cleanup failed."; + } else if (warning.startsWith("Encrypted content warning:")) { + projected = "Some encrypted histories may require their original Provider or account for continuation."; + } else if (/^\d+ rollout file\(s\) are currently locked/.test(warning)) { + projected = "One or more rollout files are locked and may be skipped."; + } else if (warning.startsWith("Provider \"") && warning.includes("has no model field")) { + projected = "The selected Provider has no default model; the root model will remain unchanged."; + } else if (warning === "Project visibility diagnostics are unavailable; the write operation will still validate and protect the global state with backup-first recovery.") { + projected = "Project visibility diagnostics are unavailable; backup-first protection remains enabled."; + } else if (warning === "SQLite Home relocation is explicit; config.toml will not be restored.") { + projected = "SQLite Home relocation is confirmed; config.toml will not be restored."; + } else { + projected = "The operation produced an additional warning."; + } + if (!result.includes(projected)) result.push(projected); + } + return result; +} + +/** @param {unknown} value */ +function publicOperationState(value) { + if (!isRecord(value)) return null; + /** @type {Record} */ + const result = {}; + for (const key of [ + "operationId", + "operation", + "actor", + "runtime", + "startedAt", + "busyScope", + "lockState", + "errorCode" + ]) { + const candidate = value[key]; + if (typeof candidate === "string") result[key] = candidate; + } + return result; +} + +const DIAGNOSTIC_IDENTIFIER = /^[A-Za-z0-9._()-]{1,200}$/; +const DIAGNOSTIC_TRANSACTION_STATES = new Set([ + "prepared", + "applying", + "applied", + "skipped", + "committing", + "committed-pending-ack", + "rollback-pending", + "rollingBack", + "recovery-required", + "recoveryRequired", + "unknown" +]); + +/** @param {unknown} value */ +function diagnosticIdentifier(value) { + return typeof value === "string" && DIAGNOSTIC_IDENTIFIER.test(value) ? value : null; +} + +/** @param {unknown} value */ +function diagnosticDistribution(value) { + const source = isRecord(value) ? value : {}; + /** @param {unknown} counts */ + const project = (counts) => Object.fromEntries( + Object.entries(isRecord(counts) ? counts : {}) + .filter(([provider, count]) => + DIAGNOSTIC_IDENTIFIER.test(provider) + && Number.isSafeInteger(count) + && Number(count) >= 0 + ) + .slice(0, 512) + ); + return { + sessions: project(source.sessions), + archived_sessions: project(source.archived_sessions) + }; +} + +/** @param {unknown} value */ +function diagnosticOperationState(value) { + if (!isRecord(value)) return null; + /** @type {Record} */ + const result = {}; + if (typeof value.operationId === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.operationId)) { + result.operationId = value.operationId; + } + if (typeof value.operation === "string" + && ["sync", "switch", "restore", "prune", "watch", "unknown"].includes(value.operation)) { + result.operation = value.operation; + } + if (typeof value.actor === "string" + && ["manual", "watch", "external"].includes(value.actor)) result.actor = value.actor; + if (typeof value.startedAt === "string" && value.startedAt.length <= 64) { + result.startedAt = value.startedAt; + } + if (typeof value.busyScope === "string" + && ["codex-home", "state-db"].includes(value.busyScope)) result.busyScope = value.busyScope; + const lockState = diagnosticIdentifier(value.lockState); + if (lockState && lockState.length <= 80) result.lockState = lockState; + if (typeof value.errorCode === "string" && /^[A-Z0-9_]{1,80}$/.test(value.errorCode)) { + result.errorCode = value.errorCode; + } + return result; +} + +/** @param {unknown} value */ +function publicStatus(value) { + if (!isRecord(value)) return value; + const rolloutCounts = isRecord(value.rolloutCounts) ? value.rolloutCounts : {}; + const sqliteCounts = value.sqliteCounts ?? {}; + const provider = typeof value.currentProvider === "string" && value.currentProvider + ? value.currentProvider + : "openai"; + /** @param {unknown} distribution */ + const matchesProvider = (distribution) => isRecord(distribution) + && ["sessions", "archived_sessions"].every((scope) => { + const counts = isRecord(distribution[scope]) ? distribution[scope] : {}; + return Object.entries(counts).every(([candidate, count]) => Number(count) === 0 || candidate === provider); + }); + const operation = publicOperationState(value.operationInProgress); + const locked = Array.isArray(value.lockedRolloutFiles) + ? value.lockedRolloutFiles.filter((entry) => typeof entry === "string").map((entry) => path.basename(entry)) + : []; + const pending = Array.isArray(value.pendingTransactions) + ? value.pendingTransactions.filter(isRecord).map((transaction) => ({ + operationId: typeof transaction.operationId === "string" ? transaction.operationId : null, + operationKind: typeof transaction.operationKind === "string" ? transaction.operationKind : "sync", + state: typeof transaction.state === "string" ? transaction.state : "unknown", + sourceBackupId: typeof transaction.sourceBackupId === "string" ? transaction.sourceBackupId : null, + preRestoreSnapshotId: typeof transaction.preRestoreSnapshotId === "string" + ? transaction.preRestoreSnapshotId + : null + })) + : []; + const backupSummary = isRecord(value.backupSummary) ? value.backupSummary : {}; + const blocked = isRecord(value.statusReadBlocked) && typeof value.statusReadBlocked.reason === "string" + ? { reason: value.statusReadBlocked.reason } + : undefined; + const sqliteReadable = isRecord(sqliteCounts) && sqliteCounts.unreadable !== true; + const storageRevision = typeof value.storageRevision === "string" && value.storageRevision + ? value.storageRevision + : compositeRevision(JSON.stringify({ + schemaVersion: 1, + profileRevision: isRecord(value.profile) ? value.profile.revision ?? null : null, + operation, + blocked: blocked ?? null + })); + return { + schemaVersion: 1, + snapshotAt: value.snapshotAt, + // With no last-complete cache, a fail-closed lock inspection can happen + // before the internal scanner has a storage revision. Emit a deterministic + // degraded revision so clients can cache the blocked snapshot without + // mistaking it for a complete scan. + storageRevision, + profile: value.profile, + currentProvider: provider, + ...(typeof value.currentModel === "string" || value.currentModel === null + ? { currentModel: value.currentModel } + : {}), + rolloutCounts, + ...(isRecord(value.modelCounts) ? { modelCounts: value.modelCounts } : {}), + sqliteCounts, + codexHomeSource: "profile", + // A fail-closed status read can be blocked before storage resolution has + // produced a source label (for example, immediately after a Utility + // Process dies while holding the Home lock). The public Status contract + // still requires a non-empty source and must not collapse that safe + // degraded snapshot into INVALID_INPUT. + sqliteHomeSource: typeof value.sqliteHomeSource === "string" && value.sqliteHomeSource + ? value.sqliteHomeSource + : "unknown", + backupSummary: { + count: Number.isSafeInteger(backupSummary.count) ? backupSummary.count : 0, + totalBytes: Number.isSafeInteger(backupSummary.totalBytes) ? backupSummary.totalBytes : 0 + }, + pendingRecovery: pending.length > 0 || value.pendingRecovery === true, + pendingTransactions: pending, + operationInProgress: operation, + rolloutScanComplete: value.rolloutScanComplete === true && locked.length === 0, + lockedRolloutFiles: locked, + currentProviderImplicit: value.currentProviderImplicit === true, + configuredProviders: Array.isArray(value.configuredProviders) + ? value.configuredProviders.filter((entry) => typeof entry === "string") + : [], + alignment: { + aligned: Boolean(!operation + && !blocked + && sqliteReadable + && value.rolloutScanComplete === true + && locked.length === 0 + && matchesProvider(rolloutCounts) + && matchesProvider(sqliteCounts)), + sqliteReadable, + targetProvider: provider + }, + ...(blocked ? { statusReadBlocked: blocked } : {}) + }; +} + +/** @param {unknown} value */ +function publicPlan(value) { + if (!isRecord(value)) return value; + const target = isRecord(value.target) ? value.target : {}; + const impact = isRecord(value.impact) ? value.impact : {}; + /** @type {Record} */ + const publicTarget = {}; + for (const key of ["provider", "model", "modelMode", "backupId"]) { + const candidate = target[key]; + if (typeof candidate === "string" || candidate === null) publicTarget[key] = candidate; + } + for (const key of ["restoreConfig", "restoreDatabase", "restoreSessions", "allowSqliteHomeRelocation"]) { + if (typeof target[key] === "boolean") publicTarget[key] = target[key]; + } + /** @type {Record} */ + const publicImpact = {}; + for (const [key, candidate] of Object.entries(impact)) { + if (typeof candidate === "boolean" || (Number.isSafeInteger(candidate) && Number(candidate) >= 0)) { + publicImpact[key] = candidate; + } + } + if (Array.isArray(impact.lockedRolloutFiles)) { + publicImpact.lockedRolloutFiles = impact.lockedRolloutFiles + .filter((entry) => typeof entry === "string") + .map((entry) => path.basename(entry)); + } + return { + schemaVersion: 1, + planId: value.planId, + operation: value.operation, + createdAt: value.createdAt, + expiresAt: value.expiresAt, + profile: value.profile, + storageRevision: value.storageRevision, + configRevision: value.configRevision, + rolloutRevision: value.rolloutRevision, + stateDbRevision: value.stateDbRevision, + ...(typeof value.backupRevision === "string" ? { backupRevision: value.backupRevision } : {}), + target: publicTarget, + impact: publicImpact, + warnings: publicWarnings(value.warnings), + requiresConfirmation: value.requiresConfirmation === true + }; +} + +/** @param {unknown} value */ +function publicOperationResult(value) { + if (!isRecord(value)) return value; + const backup = isRecord(value.backup) && typeof value.backup.backupId === "string" + ? { backupId: value.backup.backupId } + : null; + const source = isRecord(value.result) ? value.result : {}; + /** @type {Record} */ + const result = {}; + for (const key of [ + "targetProvider", + "targetModel", + "modelSource", + "restoreOperationId", + "preRestoreSnapshotId", + "restoreJournalState" + ]) { + const candidate = source[key]; + if (typeof candidate === "string" || candidate === null) result[key] = candidate; + } + if (Number.isSafeInteger(source.restoreVersion) && Number(source.restoreVersion) >= 1) { + result.restoreVersion = Number(source.restoreVersion); + } + if (typeof source.commitAcknowledgementRecovered === "boolean") { + result.commitAcknowledgementRecovered = source.commitAcknowledgementRecovered; + } + if (Array.isArray(source.resolvedOperationIds)) { + result.resolvedOperationCount = source.resolvedOperationIds + .filter((entry) => typeof entry === "string" && entry.length > 0).length; + } + for (const key of [ + "backupDurationMs", + "changedSessionFiles", + "sqliteRowsUpdated", + "sqliteProviderRowsUpdated", + "sqliteUserEventRowsUpdated", + "sqliteCwdRowsUpdated", + "updatedWorkspaceRoots", + "savedWorkspaceRootCount" + ]) { + const candidate = source[key]; + if (Number.isSafeInteger(candidate) && Number(candidate) >= 0) result[key] = candidate; + } + if (Array.isArray(source.skippedLockedRolloutFiles)) { + result.skippedLockedRolloutFiles = source.skippedLockedRolloutFiles + .filter((entry) => typeof entry === "string") + .map((entry) => path.basename(entry)); + } + return { + schemaVersion: 1, + operationId: value.operationId, + operation: value.operation, + outcome: value.outcome, + backup, + warnings: publicWarnings(value.warnings), + result + }; +} + +/** @param {unknown} value */ +function publicBackupMetadata(value) { + const metadata = isRecord(value) ? value : {}; + /** @type {Record} */ + const result = {}; + for (const key of ["version", "namespace", "targetProvider", "createdAt"] ) { + const candidate = metadata[key]; + if (typeof candidate === "string") result[key] = candidate; + else if (typeof candidate === "number" && Number.isSafeInteger(candidate)) result[key] = candidate; + } + for (const key of ["changedSessionFiles", "fileCount"] ) { + const candidate = metadata[key]; + if (Number.isSafeInteger(candidate) && Number(candidate) >= 0) result[key] = Number(candidate); + } + return result; +} + +/** @param {unknown} value */ +function publicHistorySummary(value) { + if (!isRecord(value)) return value; + return { + id: value.id, + title: value.title, + provider: value.provider, + ...(value.model === undefined ? {} : { model: value.model }), + archived: value.archived, + ...(typeof value.createdAt === "string" ? { createdAt: value.createdAt } : {}), + updatedAt: value.updatedAt, + messageCount: value.messageCount, + ...(typeof value.messageCountKnown === "boolean" + ? { messageCountKnown: value.messageCountKnown } + : {}) + }; +} + +/** @param {string} value */ +function compositeRevision(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +/** + * Create the shared Core facade for a trusted host. The resolver is the only + * component allowed to translate product profile identifiers into paths. + * @param {{resolveProfile: ProfileResolver}} options + */ +export function createCoreFacade({ resolveProfile }) { + if (typeof resolveProfile !== "function") { + throw new TypeError("createCoreFacade requires a trusted resolveProfile function."); + } + + /** @param {ProfileSelector} selector */ + async function resolveTrusted(selector) { + return validateResolvedProfile(await resolveProfile(selector), selector); + } + + function currentProfileResolver() { + /** @param {string} profileId */ + return async (profileId) => resolveTrusted({ profileId: String(profileId) }); + } + + /** @param {unknown} input */ + async function trustedInput(input) { + const selector = requireProfileSelector(input); + const profile = await resolveTrusted(selector); + return { input: /** @type {JsonRecord} */ (input), profile }; + } + + /** @param {unknown} control @returns {CoreHostOperationControl | undefined} */ + function trustedOperationControl(control) { + if (!isRecord(control)) return undefined; + const progressObserver = typeof control.onProgress === "function" + ? /** @type {CoreHostOperationControl["onProgress"]} */ (control.onProgress) + : undefined; + const onProgress = progressObserver + ? /** @param {unknown} event */ (event) => { + const projected = toPublicProgress(event); + if (projected) return progressObserver(projected); + } + : undefined; + const startedObserver = typeof control.onOperationStarted === "function" + ? /** @type {CoreHostOperationControl["onOperationStarted"]} */ (control.onOperationStarted) + : undefined; + const signal = control.signal + ? /** @type {AbortSignal} */ (control.signal) + : undefined; + return { + ...(signal ? { signal } : {}), + ...(startedObserver + ? { onOperationStarted: startedObserver } + : {}), + ...(onProgress ? { onProgress } : {}) + }; + } + + /** @type {CoreFacade} */ + const facade = { + async getStatus(input) { + const trusted = await trustedInput(input); + return publicStatus(withPublicProfile( + await getStatusInternal({ + ...rootProfileInput(trusted.profile), + // Trusted host-only optimization. This option is intentionally not + // represented in the public CoreClient/HTTP/IPC input schemas. + rolloutScanMode: "metadata" + }), + trusted.profile + )); + }, + + async prepareSync(input) { + const trusted = await trustedInput(input); + const plan = await prepareSyncInternal({ + ...rootProfileInput(trusted.profile), + ...(trusted.input.keepCount === undefined ? {} : { keepCount: trusted.input.keepCount }), + profileResolver: currentProfileResolver() + }); + return publicPlan(withPublicProfile(plan, trusted.profile)); + }, + + async applySync(input, control) { + return publicOperationResult(await applySyncInternal(input, trustedOperationControl(control))); + }, + + async prepareSwitch(input) { + const trusted = await trustedInput(input); + const provider = trusted.input.provider; + const modelMode = trusted.input.modelMode; + if (typeof provider !== "string" || !provider + || !["provider-default", "keep-root-model", "explicit"].includes(String(modelMode))) { + throw new CoreError("INVALID_INPUT", "The Switch Provider input is invalid."); + } + if ((modelMode === "explicit" && (typeof trusted.input.model !== "string" || !trusted.input.model)) + || (modelMode !== "explicit" && trusted.input.model !== undefined)) { + throw new CoreError("INVALID_INPUT", "The selected model mode and model are inconsistent."); + } + const plan = await prepareSwitchInternal({ + ...rootProfileInput(trusted.profile), + provider, + ...(modelMode === "explicit" ? { model: trusted.input.model } : {}), + ...(modelMode === "keep-root-model" ? { keepRootModel: true } : {}), + ...(trusted.input.keepCount === undefined ? {} : { keepCount: trusted.input.keepCount }), + profileResolver: currentProfileResolver() + }); + return publicPlan(withPublicProfile(plan, trusted.profile)); + }, + + async applySwitch(input, control) { + return publicOperationResult(await applySwitchInternal(input, trustedOperationControl(control))); + }, + + async listBackups(input) { + const trusted = await trustedInput(input); + const inventoryValue = await listBackupsInternal(trusted.profile.codexHome); + const inventory = isRecord(inventoryValue) ? inventoryValue : {}; + const backups = Array.isArray(inventory.backups) ? inventory.backups : []; + return { + backups: backups.filter(isRecord).map((backup) => ({ + backupId: backup.id, + sizeBytes: backup.sizeBytes, + metadata: publicBackupMetadata(backup.metadata) + })) + }; + }, + + async prepareRestore(input) { + const trusted = await trustedInput(input); + let executionProfile = trusted.profile; + let profileResolver = currentProfileResolver(); + if (trusted.input.relocationTargetProfileId !== undefined) { + if (trusted.input.allowSqliteHomeRelocation !== true + || trusted.input.restoreConfig !== false + || typeof trusted.input.relocationTargetProfileId !== "string") { + throw new CoreError("INVALID_INPUT", "SQLite relocation requires an explicit target and config restore disabled."); + } + const targetId = trusted.input.relocationTargetProfileId; + const target = await resolveTrusted({ profileId: targetId }); + if (!target.sqliteHome) { + throw new CoreError("INVALID_INPUT", "The relocation target profile has no explicit SQLite Home."); + } + const revision = compositeRevision(JSON.stringify([ + trusted.profile.id, + trusted.profile.revision, + target.id, + target.revision + ])); + executionProfile = { ...trusted.profile, sqliteHome: target.sqliteHome, revision }; + profileResolver = async (/** @type {string} */ profileId) => { + const [current, currentTarget] = await Promise.all([ + resolveTrusted({ profileId: String(profileId) }), + resolveTrusted({ profileId: targetId }) + ]); + if (!currentTarget.sqliteHome) { + throw new CoreError("PROFILE_CHANGED", "The relocation target profile changed."); + } + return { + ...current, + sqliteHome: currentTarget.sqliteHome, + revision: compositeRevision(JSON.stringify([ + current.id, + current.revision, + currentTarget.id, + currentTarget.revision + ])) + }; + }; + } + const plan = await prepareRestoreInternal({ + ...rootProfileInput(executionProfile), + backupId: trusted.input.backupId, + restoreConfig: trusted.input.restoreConfig, + restoreDatabase: trusted.input.restoreDatabase, + restoreSessions: trusted.input.restoreSessions, + ...(trusted.input.allowSqliteHomeRelocation === undefined + ? {} + : { allowSqliteHomeRelocation: trusted.input.allowSqliteHomeRelocation }), + profileResolver + }); + return publicPlan(withPublicProfile(plan, trusted.profile)); + }, + + async applyRestore(input, control) { + return publicOperationResult(await applyRestoreInternal(input, trustedOperationControl(control))); + }, + + async pruneBackups(input) { + const trusted = await trustedInput(input); + const result = await pruneBackupsInternal({ + codexHome: trusted.profile.codexHome, + keepCount: trusted.input.keepCount + }); + if (!isRecord(result)) return result; + return { deletedCount: result.deletedCount, remainingCount: result.remainingCount, freedBytes: result.freedBytes }; + }, + + async listHistory(input) { + const trusted = await trustedInput(input); + const { profile: _profile, ...options } = trusted.input; + const resultValue = await listHistoryInternal(trusted.profile.codexHome, options); + if (!isRecord(resultValue)) return resultValue; + return { + ...resultValue, + sessions: Array.isArray(resultValue.sessions) ? resultValue.sessions.map(publicHistorySummary) : [] + }; + }, + + async getHistorySession(input) { + const trusted = await trustedInput(input); + if (typeof trusted.input.sessionId !== "string" || !trusted.input.sessionId) { + throw new CoreError("INVALID_INPUT", "sessionId is required."); + } + const resultValue = await getHistorySessionInternal( + trusted.profile.codexHome, + trusted.input.sessionId, + trusted.input.messageLimit === undefined + ? {} + : { messageLimit: trusted.input.messageLimit } + ); + if (!isRecord(resultValue)) return resultValue; + return { ...resultValue, session: publicHistorySummary(resultValue.session) }; + }, + + async startWatch(input) { + const trusted = await trustedInput(input); + return startWatchInternal({ + ...rootProfileInput(trusted.profile), + ...(trusted.input.includeStateDb === undefined ? {} : { includeStateDb: trusted.input.includeStateDb }), + ...(trusted.input.debounceMs === undefined ? {} : { debounceMs: trusted.input.debounceMs }), + ...(trusted.input.once === undefined ? {} : { once: trusted.input.once }) + }); + }, + + async stopWatch(input) { + return stopWatchInternal(input); + }, + + async getWatchStatus(input = {}) { + return input.watchId + ? getWatchStatusInternal({ watchId: input.watchId }) + : getWatchStatusInternal(); + }, + + async getDiagnostics(input) { + const trusted = await trustedInput(input); + const value = await getDiagnosticsInternal(rootProfileInput(trusted.profile)); + if (!isRecord(value)) return value; + const runtime = isRecord(value.runtime) ? value.runtime : {}; + const storage = isRecord(value.storage) ? value.storage : {}; + const provider = isRecord(value.provider) ? value.provider : {}; + const safety = isRecord(value.safety) ? value.safety : {}; + const sqliteHomeSource = typeof storage.sqliteHomeSource === "string" + && ["cli", "config", "env", "default"].includes(storage.sqliteHomeSource) + ? storage.sqliteHomeSource + : "unknown"; + const sqliteCounts = provider.sqliteCounts === null + ? null + : { + ...diagnosticDistribution(provider.sqliteCounts), + ...(isRecord(provider.sqliteCounts) && provider.sqliteCounts.unreadable === true + ? { unreadable: true } + : {}) + }; + return { + schemaVersion: 1, + generatedAt: typeof value.generatedAt === "string" + && Number.isFinite(Date.parse(value.generatedAt)) + ? new Date(value.generatedAt).toISOString() + : new Date().toISOString(), + runtime: { + node: typeof runtime.node === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(runtime.node) + ? runtime.node + : "unknown", + platform: typeof runtime.platform === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(runtime.platform) + ? runtime.platform + : "unknown", + arch: typeof runtime.arch === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(runtime.arch) + ? runtime.arch + : "unknown" + }, + storage: { + sqliteHomeSource, + stateDbFound: storage.stateDbLocation !== null, + sqliteSupported: !isRecord(storage.sqliteAccess) || storage.sqliteAccess.supported !== false + }, + provider: { + current: diagnosticIdentifier(provider.current) ?? "unknown", + implicit: provider.implicit === true, + configured: Array.isArray(provider.configured) + ? provider.configured.map(diagnosticIdentifier).filter(Boolean).slice(0, 256) + : [], + rolloutCounts: diagnosticDistribution(provider.rolloutCounts), + sqliteCounts + }, + safety: { + ...(typeof safety.storageRevision === "string" + && /^[A-Za-z0-9_-]{1,256}$/.test(safety.storageRevision) + ? { storageRevision: safety.storageRevision } + : {}), + pendingRecovery: safety.pendingRecovery === true, + pendingTransactions: Array.isArray(safety.pendingTransactions) + ? safety.pendingTransactions.filter(isRecord).slice(0, 256).map((transaction) => ({ + operationId: typeof transaction.operationId === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(transaction.operationId) + ? transaction.operationId + : null, + operationKind: typeof transaction.operationKind === "string" + && ["sync", "switch", "restore"].includes(transaction.operationKind) + ? transaction.operationKind + : "sync", + state: typeof transaction.state === "string" + && DIAGNOSTIC_TRANSACTION_STATES.has(transaction.state) + ? transaction.state + : "unknown", + sourceBackupId: diagnosticIdentifier(transaction.sourceBackupId), + preRestoreSnapshotId: diagnosticIdentifier(transaction.preRestoreSnapshotId) + })) + : [], + operationInProgress: diagnosticOperationState(safety.operationInProgress), + rolloutScanComplete: safety.rolloutScanComplete === true, + lockedRolloutCount: Number.isSafeInteger(safety.lockedRolloutCount) + && Number(safety.lockedRolloutCount) >= 0 + ? safety.lockedRolloutCount + : 0, + projectThreadVisibilityAvailable: safety.projectThreadVisibilityAvailable === true + } + }; + } + }; + + return Object.freeze(facade); +} diff --git a/packages/core/src/progress.js b/packages/core/src/progress.js new file mode 100644 index 0000000..8e64183 --- /dev/null +++ b/packages/core/src/progress.js @@ -0,0 +1,37 @@ +/** + * Project an internal observer event onto the public, pathless ProgressEvent DTO. + * Invalid optional numeric fields are omitted instead of causing the trusted host + * observer to fail and silently lose the whole progress event. + * + * @param {unknown} event + * @returns {{stage: string, status: string, progress?: number, count?: number} | null} + */ +export function toPublicProgress(event) { + if (!event + || typeof event !== "object" + || Array.isArray(event)) { + return null; + } + const source = /** @type {Record} */ (event); + if (typeof source.stage !== "string" + || !source.stage + || source.stage.length > 80 + || typeof source.status !== "string" + || !source.status + || source.status.length > 40) return null; + return { + stage: source.stage, + status: source.status, + ...(typeof source.progress === "number" + && Number.isFinite(source.progress) + && source.progress >= 0 + && source.progress <= 1 + ? { progress: source.progress } + : {}), + ...(typeof source.count === "number" + && Number.isSafeInteger(source.count) + && source.count >= 0 + ? { count: source.count } + : {}) + }; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..f30a4bb --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "declaration": false, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.js" + ] +} diff --git a/packages/design-system/checks/surface.contract.mjs b/packages/design-system/checks/surface.contract.mjs new file mode 100644 index 0000000..20e7c04 --- /dev/null +++ b/packages/design-system/checks/surface.contract.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import test from "node:test"; + +import { + DESIGN_SYSTEM_MIGRATION_STATE, + SUPPORTED_LOCALES, + THEME_MODES +} from "../dist/index.js"; + +test("design-system owns the frozen locale and theme vocabulary", async () => { + assert.deepEqual(THEME_MODES, ["system", "light", "dark"]); + assert.deepEqual(SUPPORTED_LOCALES, ["zh-CN", "en"]); + assert.equal(DESIGN_SYSTEM_MIGRATION_STATE, "tokens-and-primitives-c5"); + const tokens = await fs.readFile(new URL("../src/tokens.css", import.meta.url), "utf8"); + assert.match(tokens, /data-theme="dark"/); + assert.match(tokens, /prefers-color-scheme:\s*dark/); + assert.match(tokens, /prefers-reduced-motion:\s*reduce/); + assert.match(tokens, /--focus:/); + assert.match(tokens, /--font-sans:/); + assert.match(tokens, /--text-(?:base|sm):/); + assert.match(tokens, /--leading-(?:normal|relaxed):/); + assert.match(tokens, /--space-1:/); + assert.match(tokens, /--space-6:/); +}); diff --git a/packages/design-system/package.json b/packages/design-system/package.json new file mode 100644 index 0000000..89fd037 --- /dev/null +++ b/packages/design-system/package.json @@ -0,0 +1,20 @@ +{ + "name": "@codex-provider-sync/design-system", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tokens.css": "./src/tokens.css" + }, + "scripts": { + "build": "tsc -b", + "test": "node --test checks/surface.contract.mjs" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts new file mode 100644 index 0000000..50a0f99 --- /dev/null +++ b/packages/design-system/src/index.ts @@ -0,0 +1,7 @@ +export const THEME_MODES = ["system", "light", "dark"] as const; +export type ThemeMode = typeof THEME_MODES[number]; + +export const SUPPORTED_LOCALES = ["zh-CN", "en"] as const; +export type SupportedLocale = typeof SUPPORTED_LOCALES[number]; + +export const DESIGN_SYSTEM_MIGRATION_STATE = "tokens-and-primitives-c5" as const; diff --git a/packages/design-system/src/tokens.css b/packages/design-system/src/tokens.css new file mode 100644 index 0000000..370e16e --- /dev/null +++ b/packages/design-system/src/tokens.css @@ -0,0 +1,95 @@ +:root, +:root[data-theme="light"] { + color-scheme: light; + --surface: #f6f7fb; + --surface-raised: #ffffff; + --surface-hover: #eef1f7; + --input: #ffffff; + --border: #dce1eb; + --text: #172033; + --muted: #657086; + --accent: #4867e8; + --accent-strong: #3452ce; + --accent-soft: #e9edff; + --focus: #315ee8; + --success: #16734a; + --success-soft: #e6f6ee; + --warning: #9a5b00; + --warning-soft: #fff3d8; + --danger: #b42335; + --danger-soft: #fdebed; + --control-height: 2.5rem; + --radius-control: 0.5rem; + --radius-panel: 0.75rem; + --shadow-panel: 0 1px 2px rgb(23 32 51 / 0.08), 0 8px 24px rgb(23 32 51 / 0.03); + --font-sans: "Segoe UI Variable Text", "Segoe UI", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + font-family: var(--font-sans); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --surface: #11141b; + --surface-raised: #181d27; + --surface-hover: #242b38; + --input: #111722; + --border: #30394a; + --text: #eef2f8; + --muted: #a6b0c1; + --accent: #7189ff; + --accent-strong: #8fa1ff; + --accent-soft: #222d58; + --focus: #91a3ff; + --success: #67d8a4; + --success-soft: #17392d; + --warning: #f2bb61; + --warning-soft: #3d2e17; + --danger: #ff8c99; + --danger-soft: #461e26; +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="system"] { + color-scheme: dark; + --surface: #11141b; + --surface-raised: #181d27; + --surface-hover: #242b38; + --input: #111722; + --border: #30394a; + --text: #eef2f8; + --muted: #a6b0c1; + --accent: #7189ff; + --accent-strong: #8fa1ff; + --accent-soft: #222d58; + --focus: #91a3ff; + --success: #67d8a4; + --success-soft: #17392d; + --warning: #f2bb61; + --warning-soft: #3d2e17; + --danger: #ff8c99; + --danger-soft: #461e26; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/packages/design-system/tsconfig.json b/packages/design-system/tsconfig.json new file mode 100644 index 0000000..c7a132f --- /dev/null +++ b/packages/design-system/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/test-fixtures/checks/runner.contract.mjs b/packages/test-fixtures/checks/runner.contract.mjs new file mode 100644 index 0000000..b18bd2a --- /dev/null +++ b/packages/test-fixtures/checks/runner.contract.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + createRuntimeDifference, + readFixtureManifest, + runFixtureInTemp, + validateFixtureManifest +} from "../src/index.js"; + +async function createMinimalFixture(parent, name = "source") { + const sourceRoot = path.join(parent, name); + const codexHome = path.join(sourceRoot, "codex-home"); + await fs.mkdir(codexHome, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, "fixture.json"), JSON.stringify({ + schemaVersion: 1, + id: "minimal", + description: "Synthetic fixture", + containsRealUserData: false, + inputs: { codexHome: "codex-home" }, + expected: {} + })); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\n'); + return { sourceRoot, codexHome }; +} + +test("fixture runner copies only into a temporary directory and cleans it", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-fixture-contract-")); + const { sourceRoot } = await createMinimalFixture(testRoot); + + let stagedRoot; + try { + const result = await runFixtureInTemp(sourceRoot, async (fixture) => { + stagedRoot = fixture.root; + assert.notEqual(fixture.root, sourceRoot); + assert.equal(await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"), 'model_provider = "openai"\n'); + return fixture.manifest.id; + }, { tempParent: testRoot }); + assert.equal(result, "minimal"); + await assert.rejects(fs.access(stagedRoot)); + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("fixture manifest validation is strict and rejects sensitive fields", () => { + const base = { + schemaVersion: 1, + id: "safe-fixture", + description: "Synthetic fixture", + containsRealUserData: false, + inputs: { codexHome: "codex-home" }, + expected: {} + }; + assert.doesNotThrow(() => validateFixtureManifest(base)); + assert.throws(() => validateFixtureManifest({ ...base, extra: true })); + assert.throws(() => validateFixtureManifest({ ...base, inputs: { ...base.inputs, path: "outside" } })); + assert.throws(() => validateFixtureManifest({ ...base, id: "Unsafe ID" })); + assert.throws(() => validateFixtureManifest({ ...base, inputs: { codexHome: "../outside" } })); + assert.throws(() => validateFixtureManifest({ ...base, expected: { messageBody: "private" } })); + assert.throws(() => validateFixtureManifest({ ...base, expected: { apiKey: "private" } })); + assert.throws(() => validateFixtureManifest({ ...base, expected: { apiToken: "private" } })); + assert.throws(() => validateFixtureManifest({ ...base, expected: { sessionCookie: "private" } })); + const { expected: _expected, ...withoutExpected } = base; + assert.throws(() => validateFixtureManifest(withoutExpected)); +}); + +test("fixture trees reject sensitive files and nested symbolic links", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-fixture-safety-")); + try { + const sensitive = await createMinimalFixture(testRoot, "sensitive"); + await fs.writeFile(path.join(sensitive.codexHome, "access-token.json"), "{}"); + await assert.rejects(readFixtureManifest(sensitive.sourceRoot), /forbidden file/); + + const apiKey = await createMinimalFixture(testRoot, "api-key"); + await fs.writeFile(path.join(apiKey.codexHome, "api-key.json"), "{}"); + await assert.rejects(readFixtureManifest(apiKey.sourceRoot), /forbidden file/); + + const apiToken = await createMinimalFixture(testRoot, "api-token"); + await fs.writeFile(path.join(apiToken.codexHome, "openaiApiToken.json"), "{}"); + await assert.rejects(readFixtureManifest(apiToken.sourceRoot), /forbidden file/); + + const linked = await createMinimalFixture(testRoot, "linked"); + const outside = path.join(testRoot, "outside.txt"); + await fs.writeFile(outside, "outside"); + try { + await fs.symlink(outside, path.join(linked.codexHome, "linked.txt"), "file"); + await assert.rejects(readFixtureManifest(linked.sourceRoot), /symbolic links/); + } catch (error) { + if (error?.code !== "EPERM" && error?.code !== "EACCES") throw error; + } + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("fixture root itself cannot be a symbolic link or junction", async (t) => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-fixture-root-link-")); + try { + const { sourceRoot } = await createMinimalFixture(testRoot); + const linkRoot = path.join(testRoot, "source-link"); + try { + await fs.symlink(sourceRoot, linkRoot, process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + if (error?.code === "EPERM" || error?.code === "EACCES") { + t.skip("Creating a directory link is not permitted on this host."); + return; + } + throw error; + } + await assert.rejects(readFixtureManifest(linkRoot), /real directory/); + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("fixture runner cleans staged data when the callback fails", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-fixture-cleanup-")); + let stagedRoot; + const expected = new Error("synthetic callback failure"); + try { + const { sourceRoot } = await createMinimalFixture(testRoot); + await assert.rejects( + runFixtureInTemp(sourceRoot, async (fixture) => { + stagedRoot = fixture.root; + throw expected; + }, { tempParent: testRoot }), + (error) => error === expected + ); + assert.ok(stagedRoot); + await assert.rejects(fs.access(stagedRoot)); + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + +test("difference records make unresolved runtime mismatches blocking", () => { + assert.deepEqual(createRuntimeDifference({ + fixtureId: "backup-roundtrip", + status: "blocked", + node: { hash: "node" }, + dotnet: { hash: "dotnet" }, + decision: "Do not enable the next write stage.", + notes: ["hash mismatch"] + }), { + schemaVersion: 1, + fixtureId: "backup-roundtrip", + status: "blocked", + node: { hash: "node" }, + dotnet: { hash: "dotnet" }, + decision: "Do not enable the next write stage.", + notes: ["hash mismatch"] + }); +}); + +test("the Phase 2 static corpus is synthetic and accepted by the safe runner", async () => { + const staticRoot = fileURLToPath(new URL("../static/", import.meta.url)); + for (const fixtureId of ["bidirectional-backup-roundtrip", "foreign-pending-restore"]) { + const fixtureRoot = path.join(staticRoot, fixtureId); + const manifest = await readFixtureManifest(fixtureRoot); + assert.equal(manifest.id, fixtureId); + assert.equal(manifest.containsRealUserData, false); + const rollout = await fs.readFile(path.join(fixtureRoot, "input", "codex-home", "sessions", "2026", "08", "26", "rollout-synthetic.jsonl"), "utf8"); + assert.match(rollout, /"type":"session_meta"/); + assert.doesNotMatch(rollout, /"type":"event_msg"|"message"|encrypted_content/); + } +}); diff --git a/packages/test-fixtures/package.json b/packages/test-fixtures/package.json new file mode 100644 index 0000000..d254aca --- /dev/null +++ b/packages/test-fixtures/package.json @@ -0,0 +1,19 @@ +{ + "name": "@codex-provider-sync/test-fixtures", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js", + "./desktop-faults": { + "types": "./src/desktop-faults.d.ts", + "import": "./src/desktop-faults.js" + } + }, + "scripts": { + "test": "node --test checks/runner.contract.mjs" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/test-fixtures/schema/difference.schema.json b/packages/test-fixtures/schema/difference.schema.json new file mode 100644 index 0000000..9ba9db4 --- /dev/null +++ b/packages/test-fixtures/schema/difference.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codex-provider-sync.local/contracts/runtime-difference.schema.json", + "title": "Node and .NET runtime difference record", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "fixtureId", + "status", + "node", + "dotnet", + "decision", + "notes" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "fixtureId": { "type": "string", "minLength": 1 }, + "status": { "enum": ["matched", "accepted", "blocked"] }, + "node": { "type": "object" }, + "dotnet": { "type": "object" }, + "decision": { "type": "string" }, + "notes": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/packages/test-fixtures/schema/fixture.schema.json b/packages/test-fixtures/schema/fixture.schema.json new file mode 100644 index 0000000..0ad0d70 --- /dev/null +++ b/packages/test-fixtures/schema/fixture.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codex-provider-sync.local/contracts/fixture.schema.json", + "title": "Codex Provider Sync behavior fixture", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "id", + "description", + "containsRealUserData", + "inputs", + "expected" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$" }, + "description": { "type": "string", "minLength": 1 }, + "containsRealUserData": { "const": false }, + "inputs": { + "type": "object", + "additionalProperties": false, + "required": ["codexHome"], + "properties": { + "codexHome": { "type": "string", "minLength": 1 }, + "sqliteHome": { "type": "string", "minLength": 1 } + } + }, + "expected": { "type": "object" } + } +} diff --git a/packages/test-fixtures/src/desktop-faults.d.ts b/packages/test-fixtures/src/desktop-faults.d.ts new file mode 100644 index 0000000..db23826 --- /dev/null +++ b/packages/test-fixtures/src/desktop-faults.d.ts @@ -0,0 +1,18 @@ +export function applyPreparedDesktopOperationForTest( + method: "applySync" | "applySwitch" | "applyRestore", + input: { schemaVersion: 1; planId: string }, + control: { + signal?: AbortSignal; + onOperationStarted?(value: { + operationId: string; + operation: "sync" | "switch" | "restore"; + }): void; + onProgress?(event: { + stage: string; + status: string; + progress?: number; + count?: number; + }): void; + }, + faultInjector: (event: Record) => void | Promise +): Promise; diff --git a/packages/test-fixtures/src/desktop-faults.js b/packages/test-fixtures/src/desktop-faults.js new file mode 100644 index 0000000..24e9b33 --- /dev/null +++ b/packages/test-fixtures/src/desktop-faults.js @@ -0,0 +1,24 @@ +import { + applyRestore, + applySwitch, + applySync +} from "../../../src/public-api.js"; + +/** + * Test-build-only bridge into the legacy internal fault hook. The package is + * private, remains a desktop devDependency, and is excluded from production + * Electron bundles and the root npm tarball. + */ +export function applyPreparedDesktopOperationForTest( + method, + input, + control, + faultInjector +) { + const apply = method === "applySync" + ? applySync + : method === "applySwitch" + ? applySwitch + : applyRestore; + return apply(input, { ...control, faultInjector }); +} diff --git a/packages/test-fixtures/src/index.js b/packages/test-fixtures/src/index.js new file mode 100644 index 0000000..90514ee --- /dev/null +++ b/packages/test-fixtures/src/index.js @@ -0,0 +1,255 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const FIXTURE_SCHEMA_VERSION = 1; +export const DIFFERENCE_SCHEMA_VERSION = 1; + +const FORBIDDEN_NAMES = new Set(["auth.json", ".env"]); +const SENSITIVE_FILE_NAME = /(^|[._-])(auth|credentials?|tokens?|secrets?|api[._-]?keys?|access[._-]?keys?|private[._-]?keys?|keys?|passwords?|passwds?|cookies?)([._-]|$)/i; +const SENSITIVE_NORMALIZED_FRAGMENTS = [ + "authorization", + "credential", + "password", + "passwd", + "messagebody", + "message", + "secret", + "token", + "cookie", + "apikey", + "accesskey", + "privatekey" +]; +const SENSITIVE_MANIFEST_KEYS = new Set([ + "accesskey", + "accesskeys", + "apikey", + "apikeys", + "auth", + "authorization", + "body", + "cookie", + "cookies", + "credential", + "credentials", + "key", + "keys", + "message", + "messagebody", + "messages", + "password", + "passwords", + "passwd", + "privatekey", + "privatekeys", + "secret", + "secrets", + "token", + "tokens" +]); +const FIXTURE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,79}$/; + +function record(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value + : null; +} + +function relativeFixturePath(value, field) { + if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value)) { + throw new TypeError(`${field} must be a non-empty relative path.`); + } + const normalized = path.normalize(value); + if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) { + throw new TypeError(`${field} must stay inside the fixture root.`); + } + return normalized; +} + +function exactKeys(value, allowed) { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function normalizedSensitiveName(value) { + const normalized = value.toLowerCase().replaceAll(/[^a-z]/g, ""); + return SENSITIVE_MANIFEST_KEYS.has(normalized) + || SENSITIVE_NORMALIZED_FRAGMENTS.some((fragment) => normalized.includes(fragment)); +} + +function assertNoSensitiveManifestFields(value, currentPath = "manifest", depth = 0) { + if (depth > 16) throw new TypeError("Fixture manifest is too deeply nested."); + if (Array.isArray(value)) { + value.forEach((entry, index) => assertNoSensitiveManifestFields(entry, `${currentPath}[${index}]`, depth + 1)); + return; + } + const object = record(value); + if (!object) return; + for (const [key, entry] of Object.entries(object)) { + if (normalizedSensitiveName(key)) { + throw new TypeError(`Fixture manifest cannot contain sensitive field ${currentPath}.${key}.`); + } + assertNoSensitiveManifestFields(entry, `${currentPath}.${key}`, depth + 1); + } +} + +export function validateFixtureManifest(value) { + const manifest = record(value); + if (!manifest + || !exactKeys(manifest, new Set([ + "schemaVersion", + "id", + "description", + "containsRealUserData", + "inputs", + "expected" + ])) + || manifest.schemaVersion !== FIXTURE_SCHEMA_VERSION + || typeof manifest.id !== "string" + || !FIXTURE_ID_PATTERN.test(manifest.id) + || typeof manifest.description !== "string" + || !manifest.description.trim() + || manifest.containsRealUserData !== false) { + throw new TypeError("Invalid or unsafe fixture manifest."); + } + const inputs = record(manifest.inputs); + if (!inputs + || !exactKeys(inputs, new Set(["codexHome", "sqliteHome"]))) { + throw new TypeError("Fixture manifest inputs are invalid."); + } + const expected = record(manifest.expected); + if (!expected) throw new TypeError("Fixture manifest expected is required."); + assertNoSensitiveManifestFields(expected, "manifest.expected"); + const normalizedInputs = { + codexHome: relativeFixturePath(inputs.codexHome, "inputs.codexHome"), + ...(inputs.sqliteHome === undefined + ? {} + : { sqliteHome: relativeFixturePath(inputs.sqliteHome, "inputs.sqliteHome") }) + }; + return Object.freeze({ + schemaVersion: FIXTURE_SCHEMA_VERSION, + id: manifest.id, + description: manifest.description, + containsRealUserData: false, + inputs: Object.freeze(normalizedInputs), + expected: Object.freeze({ ...expected }) + }); +} + +async function assertFixtureRootSafe(root) { + const stat = await fs.lstat(root); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new TypeError("Fixture root must be a real directory, not a symbolic link or reparse point."); + } +} + +async function assertFixtureTreeSafe(current) { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + const stat = await fs.lstat(entryPath); + if (stat.isSymbolicLink()) { + throw new TypeError(`Fixture tree cannot contain symbolic links: ${entry.name}`); + } + if (FORBIDDEN_NAMES.has(entry.name.toLowerCase()) + || entry.name.toLowerCase().startsWith(".env.") + || SENSITIVE_FILE_NAME.test(entry.name) + || normalizedSensitiveName(path.parse(entry.name).name)) { + throw new TypeError(`Fixture tree contains a forbidden file: ${entry.name}`); + } + if (stat.isDirectory()) await assertFixtureTreeSafe(entryPath); + else if (!stat.isFile()) { + throw new TypeError(`Fixture tree contains an unsupported filesystem entry: ${entry.name}`); + } + } +} + +function pathIsWithin(root, target) { + const normalizedRoot = process.platform === "win32" ? root.toLowerCase() : root; + const normalizedTarget = process.platform === "win32" ? target.toLowerCase() : target; + const relative = path.relative(normalizedRoot, normalizedTarget); + return relative === "" || (relative !== ".." + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative)); +} + +async function assertFixtureInput(root, relativePath, field) { + const target = path.join(root, relativePath); + const [canonicalRoot, canonicalTarget, stat] = await Promise.all([ + fs.realpath(root), + fs.realpath(target), + fs.stat(target) + ]); + if (!pathIsWithin(canonicalRoot, canonicalTarget) || !stat.isDirectory()) { + throw new TypeError(`${field} must resolve to a directory inside the fixture root.`); + } +} + +export async function readFixtureManifest(fixtureRoot) { + const absoluteRoot = path.resolve(fixtureRoot); + await assertFixtureRootSafe(absoluteRoot); + const manifest = JSON.parse(await fs.readFile(path.join(absoluteRoot, "fixture.json"), "utf8")); + const validated = validateFixtureManifest(manifest); + await assertFixtureTreeSafe(absoluteRoot); + await assertFixtureInput(absoluteRoot, validated.inputs.codexHome, "inputs.codexHome"); + if (validated.inputs.sqliteHome) { + await assertFixtureInput(absoluteRoot, validated.inputs.sqliteHome, "inputs.sqliteHome"); + } + return validated; +} + +export async function runFixtureInTemp(fixtureRoot, run, { tempParent = os.tmpdir() } = {}) { + if (typeof run !== "function") throw new TypeError("Fixture runner callback is required."); + const sourceRoot = path.resolve(fixtureRoot); + const manifest = await readFixtureManifest(sourceRoot); + const tempRoot = await fs.mkdtemp(path.join(path.resolve(tempParent), "codex-provider-sync-fixture-")); + const stagedRoot = path.join(tempRoot, "fixture"); + try { + await fs.cp(sourceRoot, stagedRoot, { + recursive: true, + force: false, + errorOnExist: true, + dereference: false + }); + const stagedManifest = await readFixtureManifest(stagedRoot); + if (JSON.stringify(stagedManifest) !== JSON.stringify(manifest)) { + throw new TypeError("Fixture manifest changed while it was being staged."); + } + return await run({ + root: stagedRoot, + codexHome: path.join(stagedRoot, stagedManifest.inputs.codexHome), + sqliteHome: stagedManifest.inputs.sqliteHome + ? path.join(stagedRoot, stagedManifest.inputs.sqliteHome) + : null, + manifest: stagedManifest + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +} + +export function createRuntimeDifference({ + fixtureId, + status, + node, + dotnet, + decision, + notes = [] +}) { + if (typeof fixtureId !== "string" || !fixtureId) throw new TypeError("fixtureId is required."); + if (!["matched", "accepted", "blocked"].includes(status)) { + throw new TypeError("Difference status must be matched, accepted, or blocked."); + } + if (!Array.isArray(notes) || notes.some((entry) => typeof entry !== "string")) { + throw new TypeError("Difference notes must be strings."); + } + return { + schemaVersion: DIFFERENCE_SCHEMA_VERSION, + fixtureId, + status, + node: record(node) ?? {}, + dotnet: record(dotnet) ?? {}, + decision: typeof decision === "string" ? decision : "", + notes: [...notes] + }; +} diff --git a/packages/test-fixtures/static/bidirectional-backup-roundtrip/fixture.json b/packages/test-fixtures/static/bidirectional-backup-roundtrip/fixture.json new file mode 100644 index 0000000..c869e30 --- /dev/null +++ b/packages/test-fixtures/static/bidirectional-backup-roundtrip/fixture.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "bidirectional-backup-roundtrip", + "description": "Synthetic Node and .NET backup/restore interoperability corpus.", + "containsRealUserData": false, + "inputs": { + "codexHome": "input/codex-home", + "sqliteHome": "input/codex-home/sqlite" + }, + "expected": { + "directions": [ + "node-to-dotnet", + "dotnet-to-node" + ], + "restoredProvider": "relay", + "journalTerminal": "committed" + } +} diff --git a/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/.codex-global-state.json b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/.codex-global-state.json new file mode 100644 index 0000000..c6845c1 --- /dev/null +++ b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/.codex-global-state.json @@ -0,0 +1,12 @@ +{ + "electron-saved-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "project-order": [ + "C:\\synthetic\\fixture" + ], + "active-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "fixture-marker": "primary-global-state-sentinel" +} diff --git a/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/.codex-global-state.json.bak b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/.codex-global-state.json.bak new file mode 100644 index 0000000..29ca3a1 --- /dev/null +++ b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/.codex-global-state.json.bak @@ -0,0 +1,12 @@ +{ + "electron-saved-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "project-order": [ + "C:\\synthetic\\fixture" + ], + "active-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "fixture-marker": "backup-global-state-sentinel" +} diff --git a/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/config.toml b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/config.toml new file mode 100644 index 0000000..ee2ca3a --- /dev/null +++ b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/config.toml @@ -0,0 +1,5 @@ +model_provider = "relay" + +[model_providers.relay] +name = "Synthetic Relay" +base_url = "https://example.invalid/v1" diff --git a/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/sessions/2026/08/26/rollout-synthetic.jsonl b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/sessions/2026/08/26/rollout-synthetic.jsonl new file mode 100644 index 0000000..1c87e39 --- /dev/null +++ b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/sessions/2026/08/26/rollout-synthetic.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-08-26T00:00:00.000Z","type":"session_meta","payload":{"id":"fixture-thread","timestamp":"2026-08-26T00:00:00.000Z","cwd":"C:\\synthetic\\fixture","source":"cli","cli_version":"1.0.0-fixture","model_provider":"relay"}} diff --git a/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/sqlite/.gitkeep b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/sqlite/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/test-fixtures/static/bidirectional-backup-roundtrip/input/codex-home/sqlite/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/test-fixtures/static/bidirectional-backup-roundtrip/sqlite-seed.sql b/packages/test-fixtures/static/bidirectional-backup-roundtrip/sqlite-seed.sql new file mode 100644 index 0000000..4461346 --- /dev/null +++ b/packages/test-fixtures/static/bidirectional-backup-roundtrip/sqlite-seed.sql @@ -0,0 +1,20 @@ +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 '', + model TEXT, + has_user_event INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL DEFAULT 0, + protected_marker TEXT NOT NULL DEFAULT '' +); +INSERT INTO threads ( + id, model_provider, cwd, archived, first_user_message, model, + has_user_event, updated_at, updated_at_ms, protected_marker +) +VALUES ( + 'fixture-thread', 'relay', 'C:\synthetic\fixture', 0, '', NULL, + 0, 1700000000, 1700000000123, 'fixture-db-sentinel' +); diff --git a/packages/test-fixtures/static/foreign-pending-restore/fixture.json b/packages/test-fixtures/static/foreign-pending-restore/fixture.json new file mode 100644 index 0000000..55aeedd --- /dev/null +++ b/packages/test-fixtures/static/foreign-pending-restore/fixture.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "id": "foreign-pending-restore", + "description": "Synthetic cross-runtime recovery of a transaction interrupted after rollout mutation.", + "containsRealUserData": false, + "inputs": { + "codexHome": "input/codex-home", + "sqliteHome": "input/codex-home/sqlite" + }, + "expected": { + "directions": [ + "node-pending-to-dotnet", + "dotnet-pending-to-node" + ], + "restoredProvider": "relay", + "journalTerminal": "rolledBack", + "pendingCount": 0 + } +} diff --git a/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/.codex-global-state.json b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/.codex-global-state.json new file mode 100644 index 0000000..c6845c1 --- /dev/null +++ b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/.codex-global-state.json @@ -0,0 +1,12 @@ +{ + "electron-saved-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "project-order": [ + "C:\\synthetic\\fixture" + ], + "active-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "fixture-marker": "primary-global-state-sentinel" +} diff --git a/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/.codex-global-state.json.bak b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/.codex-global-state.json.bak new file mode 100644 index 0000000..29ca3a1 --- /dev/null +++ b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/.codex-global-state.json.bak @@ -0,0 +1,12 @@ +{ + "electron-saved-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "project-order": [ + "C:\\synthetic\\fixture" + ], + "active-workspace-roots": [ + "C:\\synthetic\\fixture" + ], + "fixture-marker": "backup-global-state-sentinel" +} diff --git a/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/config.toml b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/config.toml new file mode 100644 index 0000000..ee2ca3a --- /dev/null +++ b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/config.toml @@ -0,0 +1,5 @@ +model_provider = "relay" + +[model_providers.relay] +name = "Synthetic Relay" +base_url = "https://example.invalid/v1" diff --git a/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/sessions/2026/08/26/rollout-synthetic.jsonl b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/sessions/2026/08/26/rollout-synthetic.jsonl new file mode 100644 index 0000000..1c87e39 --- /dev/null +++ b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/sessions/2026/08/26/rollout-synthetic.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-08-26T00:00:00.000Z","type":"session_meta","payload":{"id":"fixture-thread","timestamp":"2026-08-26T00:00:00.000Z","cwd":"C:\\synthetic\\fixture","source":"cli","cli_version":"1.0.0-fixture","model_provider":"relay"}} diff --git a/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/sqlite/.gitkeep b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/sqlite/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/test-fixtures/static/foreign-pending-restore/input/codex-home/sqlite/.gitkeep @@ -0,0 +1 @@ + diff --git a/packages/test-fixtures/static/foreign-pending-restore/sqlite-seed.sql b/packages/test-fixtures/static/foreign-pending-restore/sqlite-seed.sql new file mode 100644 index 0000000..4461346 --- /dev/null +++ b/packages/test-fixtures/static/foreign-pending-restore/sqlite-seed.sql @@ -0,0 +1,20 @@ +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 '', + model TEXT, + has_user_event INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL DEFAULT 0, + protected_marker TEXT NOT NULL DEFAULT '' +); +INSERT INTO threads ( + id, model_provider, cwd, archived, first_user_message, model, + has_user_event, updated_at, updated_at_ms, protected_marker +) +VALUES ( + 'fixture-thread', 'relay', 'C:\synthetic\fixture', 0, '', NULL, + 0, 1700000000, 1700000000123, 'fixture-db-sentinel' +); diff --git a/scripts/run-root-tests.js b/scripts/run-root-tests.js new file mode 100644 index 0000000..a467622 --- /dev/null +++ b/scripts/run-root-tests.js @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const testRoot = path.join(repositoryRoot, "test"); +const testFiles = fs.readdirSync(testRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".test.js")) + .map((entry) => path.join("test", entry.name)) + .sort((left, right) => left.localeCompare(right)); + +if (testFiles.length === 0) { + throw new Error("No root test files were found."); +} + +const result = spawnSync(process.execPath, ["--test", ...testFiles], { + cwd: repositoryRoot, + env: process.env, + stdio: "inherit" +}); +if (result.error) throw result.error; +if (result.signal) { + throw new Error(`Root tests were terminated by ${result.signal}.`); +} +process.exitCode = result.status ?? 1; diff --git a/scripts/run-web-ui-fixture.js b/scripts/run-web-ui-fixture.js new file mode 100644 index 0000000..94022fb --- /dev/null +++ b/scripts/run-web-ui-fixture.js @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createWebUiServer } from "../src/web-server.js"; +import { createMemoryWebUiState } from "../src/web-state.js"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export async function createWebUiFixture() { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-c5-browser-")); + const codexHome = path.join(fixtureRoot, ".codex"); + const rolloutPath = path.join(codexHome, "sessions", "2026", "08", "25", "rollout-c5-browser.jsonl"); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\nmodel = "gpt-5"\n', "utf8"); + await fs.writeFile(rolloutPath, [ + { type: "session_meta", timestamp: "2026-08-25T00:00:00.000Z", payload: { id: "c5-browser-session", title: "Synthetic History", cwd: "C:\\synthetic\\project", model_provider: "openai", model: "gpt-5" } }, + { type: "event_msg", timestamp: "2026-08-25T00:01:00.000Z", payload: { type: "user_message", message: "C5_BODY_ONLY_MARKER" } }, + { type: "event_msg", timestamp: "2026-08-25T00:02:00.000Z", payload: { type: "assistant_message", message: "Synthetic response for browser validation." } } + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n", "utf8"); + + const stateStore = createMemoryWebUiState({ codexHome }); + const handle = createWebUiServer({ + webRoot: path.join(repositoryRoot, "web", "dist"), + stateStore + }); + try { + await new Promise((resolve, reject) => { + handle.server.once("error", reject); + handle.server.listen(0, "127.0.0.1", resolve); + }); + } catch (error) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + throw error; + } + const address = handle.server.address(); + if (!address || typeof address === "string") { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + throw new Error("Fixture Web server did not bind a TCP port."); + } + const origin = `http://127.0.0.1:${address.port}`; + handle.setBaseUrl(origin); + const issuePairingUrl = () => `${origin}/#pair=${encodeURIComponent(handle.issuePairing())}`; + let closing = false; + return { + origin, + pairingUrl: issuePairingUrl(), + issuePairingUrl, + async close() { + if (closing) return; + closing = true; + await new Promise((resolve) => handle.server.close(resolve)); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const fixture = await createWebUiFixture(); + process.stdout.write(`CPS_FIXTURE_URL=${fixture.pairingUrl}\n`); + let closing = false; + const close = async () => { + if (closing) return; + closing = true; + await fixture.close(); + }; + for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => void close().finally(() => process.exit(0))); + } + process.stdin.resume(); +} diff --git a/scripts/smoke-root-tarball.js b/scripts/smoke-root-tarball.js new file mode 100644 index 0000000..7889001 --- /dev/null +++ b/scripts/smoke-root-tarball.js @@ -0,0 +1,487 @@ +import fs from "node:fs/promises"; +import crypto from "node:crypto"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const installLifecycle = process.argv.slice(2).includes("--install-lifecycle"); +const npmCliPath = process.env.npm_execpath; +if (!npmCliPath) { + throw new Error("Run this smoke through npm so the exact npm CLI path is available."); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repositoryRoot, + encoding: "utf8", + windowsHide: true, + env: { ...process.env, NO_COLOR: "1", ...(options.env ?? {}) } + }); + if (result.status !== 0) { + throw new Error([ + `${command} ${args.join(" ")} failed with exit code ${String(result.status)}.`, + result.stdout, + result.stderr + ].filter(Boolean).join("\n")); + } + return result; +} + +function dependencyNames(tree, result = new Set()) { + for (const [name, metadata] of Object.entries(tree?.dependencies ?? {})) { + result.add(name); + dependencyNames(metadata, result); + } + return result; +} + +function containsPrivateStorageField(value) { + if (Array.isArray(value)) { + return value.some(containsPrivateStorageField); + } + if (!value || typeof value !== "object") { + return false; + } + for (const [key, child] of Object.entries(value)) { + if (["codexHome", "sqliteHome", "cwd"].includes(key)) { + return true; + } + if (containsPrivateStorageField(child)) { + return true; + } + } + return false; +} + +function reserveLoopbackPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not reserve a loopback port.")); + return; + } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); +} + +function requestPage(url, options = {}) { + return new Promise((resolve, reject) => { + const request = http.request(url, { + method: options.method ?? "GET", + headers: options.headers ?? {} + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString("utf8") + })); + }); + request.once("error", reject); + request.end(options.body ?? null); + }); +} + +async function smokeInstalledWeb(tempRoot, codexHome) { + const port = await reserveLoopbackPort(); + const installedCliPath = path.join( + tempRoot, + "node_modules", + "@dailin521", + "codex-provider-sync", + "src", + "cli.js" + ); + const child = spawn(process.execPath, [ + installedCliPath, + "web", + "--no-open", + "--port", + String(port), + "--codex-home", + codexHome + ], { + cwd: tempRoot, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" } + }); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk.toString("utf8"); }); + child.stderr.on("data", (chunk) => { output += chunk.toString("utf8"); }); + try { + const deadline = Date.now() + 10_000; + let health = null; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Installed Web UI exited early.\n${output}`); + try { + health = await requestPage(`http://127.0.0.1:${port}/api/health`); + if (health.status === 200) break; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!health || health.status !== 200) throw new Error(`Installed Web UI did not become healthy.\n${output}`); + const page = await requestPage(`http://127.0.0.1:${port}/`); + if (page.status !== 200 || !page.body.includes("Codex Provider Sync")) { + throw new Error("Installed Web UI did not serve the production application shell."); + } + if (!String(page.headers["content-security-policy"] ?? "").includes("style-src 'self'")) { + throw new Error("Installed Web UI did not serve the strict production CSP."); + } + const pairingUrl = output.match(/One-time pairing link:\s+(\S+)/)?.[1]; + if (!pairingUrl) throw new Error("Installed Web UI did not emit a one-time pairing link."); + const pairingToken = decodeURIComponent(new URL(pairingUrl).hash.replace(/^#pair=/, "")); + const origin = `http://127.0.0.1:${port}`; + const paired = await requestPage(`${origin}/api/pair`, { + method: "POST", + headers: { Origin: origin, "X-Codex-Provider-Pairing": pairingToken } + }); + const pairedPayload = JSON.parse(paired.body); + if (paired.status !== 200 || typeof pairedPayload.deviceCredential !== "string") { + throw new Error("Installed Web UI pairing smoke failed."); + } + const authenticatedHeaders = { + Origin: origin, + "X-Codex-Provider-Device": pairedPayload.deviceCredential + }; + const profiles = await requestPage(`${origin}/api/profiles`, { headers: authenticatedHeaders }); + const profilesPayload = JSON.parse(profiles.body); + const profile = profilesPayload.profiles?.find((entry) => entry.id === "default"); + if (profiles.status !== 200 || !profile?.revision) { + throw new Error("Installed Web UI profile smoke failed."); + } + const coreHeaders = { ...authenticatedHeaders, "Content-Type": "application/json" }; + const statusRequest = { + protocolVersion: 1, + requestId: crypto.randomUUID(), + method: "getStatus", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision } } + }; + const coreStatus = await requestPage(`${origin}/api/core`, { + method: "POST", + headers: coreHeaders, + body: JSON.stringify(statusRequest) + }); + const coreStatusPayload = JSON.parse(coreStatus.body); + const serializedStatus = JSON.stringify(coreStatusPayload); + if (coreStatus.status !== 200 || coreStatusPayload.ok !== true) { + throw new Error("Installed Web UI Core facade smoke failed."); + } + if (containsPrivateStorageField(coreStatusPayload) || serializedStatus.includes(codexHome)) { + throw new Error("Installed Web UI Core facade exposed a private storage path."); + } + const staleStatus = await requestPage(`${origin}/api/core`, { + method: "POST", + headers: coreHeaders, + body: JSON.stringify({ + ...statusRequest, + requestId: crypto.randomUUID(), + payload: { + profile: { + profileId: profile.id, + profileRevision: `${profile.revision}-stale` + } + } + }) + }); + const stalePayload = JSON.parse(staleStatus.body); + const serializedError = JSON.stringify(stalePayload); + if (staleStatus.status !== 409 + || stalePayload.ok !== false + || stalePayload.error?.code !== "PROFILE_CHANGED" + || serializedError.includes(codexHome)) { + throw new Error("Installed Web UI Core error redaction smoke failed."); + } + } finally { + child.kill(); + await Promise.race([ + new Promise((resolve) => child.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 3000)) + ]); + } +} + +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-pack-smoke-")); +let tarballPath = null; +try { + const packed = run(process.execPath, [npmCliPath, "pack", "--json", "--ignore-scripts"]); + const packResult = JSON.parse(packed.stdout); + if (!Array.isArray(packResult) || packResult.length !== 1) { + throw new Error("npm pack did not return exactly one root package."); + } + const tarballCandidates = [ + path.resolve(repositoryRoot, packResult[0].filename), + path.join( + repositoryRoot, + `${packResult[0].name.replace(/^@/, "").replaceAll("/", "-")}-${packResult[0].version}.tgz` + ) + ]; + for (const candidate of tarballCandidates) { + try { + await fs.access(candidate); + tarballPath = candidate; + break; + } catch { + // npm 8 reports a scoped filename while writing a sanitized basename. + } + } + if (!tarballPath) throw new Error("Could not locate the npm pack tarball."); + const packedPaths = new Set(packResult[0].files.map((entry) => entry.path.replaceAll("\\", "/"))); + for (const required of [ + "src/cli.js", + "src/public-api.js", + "src/web-core-adapter.js", + "packages/contracts/dist/index.js", + "packages/core/src/index.js", + "web/dist/index.html" + ] ) { + if (!packedPaths.has(required)) throw new Error(`Root tarball is missing ${required}.`); + } + for (const packedPath of packedPaths) { + if (packedPath.endsWith(".map")) { + throw new Error(`Root tarball contains a production source map: ${packedPath}`); + } + const allowedExact = new Set([ + "AGENTS.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "CONTRIBUTORS.md", + "LICENSE", + "README.md", + "package.json" + ]); + const allowedPrefix = [ + "docs/", + "images/README/", + "packages/contracts/dist/", + "packages/core/src/", + "src/", + "web/dist/" + ]; + if (!allowedExact.has(packedPath) + && !allowedPrefix.some((prefix) => packedPath.startsWith(prefix))) { + throw new Error(`Root tarball contains an unapproved path: ${packedPath}`); + } + } + + await fs.writeFile(path.join(tempRoot, "package.json"), JSON.stringify({ + name: "codex-provider-sync-pack-smoke", + version: "0.0.0", + private: true, + scripts: { provider: "codex-provider" } + })); + const installArgs = [npmCliPath, "install", "--omit=dev"]; + if (installLifecycle) installArgs.push("--ignore-scripts=false"); + else installArgs.push("--ignore-scripts"); + installArgs.push(tarballPath); + run(process.execPath, installArgs, { cwd: tempRoot }); + + const help = run(process.execPath, [ + npmCliPath, + "run", + "--silent", + "provider", + "--", + "help" + ], { cwd: tempRoot }); + if (!`${help.stdout}\n${help.stderr}`.includes("codex-provider status")) { + throw new Error(`Installed CLI help smoke failed. stdout=${JSON.stringify(help.stdout)} stderr=${JSON.stringify(help.stderr)}`); + } + + const codexHome = path.join(tempRoot, "synthetic-codex-home"); + const rolloutPath = path.join(codexHome, "sessions", "2026", "08", "28", "rollout-synthetic.jsonl"); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); + const configPath = path.join(codexHome, "config.toml"); + const initialConfig = 'model_provider = "openai"\n'; + const initialRollout = `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-28T00:00:00.000Z", + payload: { + id: "synthetic-thread", + cwd: "synthetic", + model_provider: "apigather" + } + })}\n`; + await fs.writeFile(configPath, initialConfig); + await fs.writeFile(rolloutPath, initialRollout); + if (installLifecycle) { + const stateDbPath = path.join(codexHome, "sqlite", "state_5.sqlite"); + const createDatabase = [ + 'let Database;', + 'try {', + ' Database = require("node:sqlite").DatabaseSync;', + '} catch (error) {', + ' if (!["ERR_UNKNOWN_BUILTIN_MODULE", "MODULE_NOT_FOUND"].includes(error?.code)) throw error;', + ' Database = require("better-sqlite3");', + '}', + 'const database = new Database(process.env.PROVIDER_SYNC_SMOKE_DB);', + 'database.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 '',", + ' model TEXT', + ');`);', + 'database.prepare("INSERT INTO threads (id, model_provider, cwd, archived, first_user_message) VALUES (?, ?, ?, ?, ?)")', + ' .run("synthetic-thread", "apigather", "synthetic", 0, "");', + 'database.close();' + ].join("\n"); + run(process.execPath, ["-e", createDatabase], { + cwd: tempRoot, + env: { PROVIDER_SYNC_SMOKE_DB: stateDbPath } + }); + } + const status = run(process.execPath, [ + npmCliPath, + "run", + "--silent", + "provider", + "--", + "status", + "--json", + "--codex-home", + codexHome + ], { cwd: tempRoot }); + const statusEnvelope = JSON.parse(status.stdout); + if (statusEnvelope.schemaVersion !== 1 + || statusEnvelope.command !== "status" + || statusEnvelope.ok !== true) { + throw new Error("Installed CLI JSON status smoke failed."); + } + if (installLifecycle + && (statusEnvelope.result?.sqliteCounts === null + || statusEnvelope.result?.sqliteCounts?.unreadable === true)) { + throw new Error("Installed CLI did not open the synthetic SQLite database."); + } + if (installLifecycle) { + const stateDbPath = path.join(codexHome, "sqlite", "state_5.sqlite"); + const databaseProvider = (nextProvider) => { + const script = [ + 'let Database;', + 'try { Database = require("node:sqlite").DatabaseSync; }', + 'catch (error) {', + ' if (!["ERR_UNKNOWN_BUILTIN_MODULE", "MODULE_NOT_FOUND"].includes(error?.code)) throw error;', + ' Database = require("better-sqlite3");', + '}', + 'const database = new Database(process.env.PROVIDER_SYNC_SMOKE_DB);', + 'if (process.env.PROVIDER_SYNC_SMOKE_NEXT) {', + ' database.prepare("UPDATE threads SET model_provider = ? WHERE id = ?")', + ' .run(process.env.PROVIDER_SYNC_SMOKE_NEXT, "synthetic-thread");', + '}', + 'const row = database.prepare("SELECT model_provider AS provider FROM threads WHERE id = ?")', + ' .get("synthetic-thread");', + 'database.close();', + 'process.stdout.write(String(row.provider));' + ].join("\n"); + return run(process.execPath, ["-e", script], { + cwd: tempRoot, + env: { + PROVIDER_SYNC_SMOKE_DB: stateDbPath, + ...(nextProvider ? { PROVIDER_SYNC_SMOKE_NEXT: nextProvider } : {}) + } + }).stdout.trim(); + }; + const rolloutProvider = async () => JSON.parse( + (await fs.readFile(rolloutPath, "utf8")).trim().split(/\r?\n/)[0] + ).payload.model_provider; + const sync = run(process.execPath, [ + npmCliPath, + "run", + "--silent", + "provider", + "--", + "sync", + "--json", + "--codex-home", + codexHome + ], { cwd: tempRoot }); + const syncEnvelope = JSON.parse(sync.stdout); + const backupDir = syncEnvelope.result?.backupDir; + if (syncEnvelope.ok !== true + || syncEnvelope.command !== "sync" + || typeof backupDir !== "string" + || !path.isAbsolute(backupDir)) { + throw new Error("Installed CLI JSON sync did not create an auditable managed backup."); + } + await fs.access(path.join(backupDir, "metadata.json")); + if (databaseProvider() !== "openai" || await rolloutProvider() !== "openai") { + throw new Error("Installed CLI sync did not align the synthetic rollout and SQLite row."); + } + + await fs.writeFile(configPath, 'model_provider = "relay"\n'); + await fs.writeFile(rolloutPath, initialRollout.replace("apigather", "relay")); + if (databaseProvider("relay") !== "relay") { + throw new Error("Could not create the synthetic pre-Restore drift state."); + } + const restore = run(process.execPath, [ + npmCliPath, + "run", + "--silent", + "provider", + "--", + "restore", + backupDir, + "--json", + "--codex-home", + codexHome + ], { cwd: tempRoot }); + const restoreEnvelope = JSON.parse(restore.stdout); + if (restoreEnvelope.ok !== true || restoreEnvelope.command !== "restore") { + throw new Error("Installed CLI JSON restore failed for its own managed backup."); + } + if (await fs.readFile(configPath, "utf8") !== initialConfig + || await fs.readFile(rolloutPath, "utf8") !== initialRollout + || databaseProvider() !== "apigather") { + throw new Error("Installed CLI restore did not recover the original synthetic bytes and SQLite provider."); + } + const restoredStatus = run(process.execPath, [ + npmCliPath, + "run", + "--silent", + "provider", + "--", + "status", + "--json", + "--codex-home", + codexHome + ], { cwd: tempRoot }); + const restoredStatusEnvelope = JSON.parse(restoredStatus.stdout); + if (restoredStatusEnvelope.ok !== true + || restoredStatusEnvelope.result?.pendingRecovery === true + || restoredStatusEnvelope.result?.pendingTransactions?.length > 0) { + throw new Error("Installed CLI restore left a pending recovery transaction."); + } + await smokeInstalledWeb(tempRoot, codexHome); + } + + const productionTree = JSON.parse(run(process.execPath, [npmCliPath, "ls", "--omit=dev", "--json"], { + cwd: tempRoot + }).stdout); + const installedNames = dependencyNames(productionTree); + for (const forbidden of ["react", "react-dom", "vite", "typescript", "electron"]) { + if (installedNames.has(forbidden)) throw new Error(`Root production tree contains ${forbidden}.`); + } + for (const name of installedNames) { + if (name.startsWith("electron-")) throw new Error(`Root production tree contains ${name}.`); + } + + process.stdout.write( + `Root tarball ${installLifecycle ? "lifecycle + SQLite" : "content"} smoke passed on Node ${process.version}.\n` + ); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + if (tarballPath) await fs.rm(tarballPath, { force: true }); +} diff --git a/scripts/test-wsl-unc-safety.sh b/scripts/test-wsl-unc-safety.sh index 29f3536..b605d56 100755 --- a/scripts/test-wsl-unc-safety.sh +++ b/scripts/test-wsl-unc-safety.sh @@ -37,14 +37,27 @@ NODE sqlite_home_windows="$(wslpath -w "$sqlite_home")" project_windows="$(wslpath -w "$repo_dir/desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj")" -database_hash_before="$(sha256sum "$sqlite_home/state_5.sqlite")" +sqlite_artifact_state() { + local suffix + for suffix in "" "-wal" "-shm" "-journal"; do + local target="$sqlite_home/state_5.sqlite$suffix" + if [[ -f "$target" ]]; then + printf '%s present %s\n' "$suffix" "$(sha256sum "$target" | cut -d' ' -f1)" + else + printf '%s missing\n' "$suffix" + fi + done +} + +database_state_before="$(sqlite_artifact_state)" "$dotnet_exe" test "$project_windows" --no-restore \ + --environment "CPS_REQUIRE_REAL_WSL=1" \ --environment "CODEX_PROVIDER_SYNC_WSL_SQLITE_HOME=$sqlite_home_windows" \ --filter "Category=WindowsWslIntegration" -database_hash_after="$(sha256sum "$sqlite_home/state_5.sqlite")" -if [[ "$database_hash_after" != "$database_hash_before" ]]; then - echo "WSL SQLite database changed during the Windows safety test." >&2 +database_state_after="$(sqlite_artifact_state)" +if [[ "$database_state_after" != "$database_state_before" ]]; then + echo "WSL SQLite database or sidecar state changed during the Windows safety test." >&2 exit 1 fi diff --git a/scripts/verify-node16-runtime.js b/scripts/verify-node16-runtime.js new file mode 100644 index 0000000..6772b48 --- /dev/null +++ b/scripts/verify-node16-runtime.js @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import path from "node:path"; + +const expectedNode = "v16.20.2"; + +function installedNpmVersion() { + const npmExecPath = process.env.npm_execpath; + if (!npmExecPath) return null; + let current = path.dirname(path.resolve(npmExecPath)); + for (let depth = 0; depth < 8; depth += 1) { + const manifestPath = path.join(current, "package.json"); + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (manifest.name === "npm" && typeof manifest.version === "string") return manifest.version; + } catch { + // Continue toward the filesystem root. + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +const npmVersion = installedNpmVersion(); +const npmMajor = npmVersion?.split(".")[0]; + +if (process.version !== expectedNode || npmMajor !== "8") { + process.stderr.write( + `Expected Node ${expectedNode} with npm 8; found Node ${process.version} and npm ${npmVersion ?? "unknown"}.\n` + ); + process.exitCode = 1; +} else { + process.stdout.write(`Verified Node ${process.version} with npm ${npmVersion}.\n`); +} diff --git a/scripts/verify-root-production-tree.js b/scripts/verify-root-production-tree.js new file mode 100644 index 0000000..8896541 --- /dev/null +++ b/scripts/verify-root-production-tree.js @@ -0,0 +1,26 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const forbidden = [ + "react", + "react-dom", + "vite", + "typescript", + "electron", + "electron-vite", + "electron-builder", + "@codex-provider-sync/core", + "@codex-provider-sync/contracts", + "@codex-provider-sync/core-client" +]; + +for (const name of forbidden) { + const candidate = path.join(repositoryRoot, "node_modules", ...name.split("/")); + if (fs.existsSync(candidate)) { + throw new Error(`Root production install contains forbidden dependency or workspace link: ${name}.`); + } +} + +process.stdout.write("Root production install contains no modern UI, Electron, TypeScript, or workspace links.\n"); diff --git a/scripts/verify-workspace-boundaries.js b/scripts/verify-workspace-boundaries.js new file mode 100644 index 0000000..24c2806 --- /dev/null +++ b/scripts/verify-workspace-boundaries.js @@ -0,0 +1,176 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const REQUIRED_WORKSPACES = [ + "apps/cli", + "apps/web", + "apps/desktop", + "packages/core", + "packages/contracts", + "packages/core-client", + "packages/app-ui", + "packages/design-system", + "packages/test-fixtures" +]; +const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; + +async function readJson(relativePath) { + return JSON.parse(await fs.readFile(path.join(repositoryRoot, relativePath), "utf8")); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function assertExactDependencies(manifest, label) { + for (const field of DEPENDENCY_FIELDS) { + for (const [name, specification] of Object.entries(manifest[field] ?? {})) { + assert( + typeof specification === "string" + && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(specification), + `${label} ${field}.${name} must use an exact version; found ${String(specification)}.` + ); + } + } +} + +async function sourceFiles(relativeRoot) { + const root = path.join(repositoryRoot, relativeRoot); + const result = []; + async function visit(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(fullPath); + else if (/\.(?:js|mjs|ts|tsx)$/.test(entry.name)) result.push(fullPath); + } + } + await visit(root); + return result; +} + +const rootManifest = await readJson("package.json"); +assert(rootManifest.name === "@dailin521/codex-provider-sync", "Root npm package name changed."); +assert(rootManifest.bin?.["codex-provider"] === "src/cli.js", "Root CLI bin changed."); +assert(rootManifest.engines?.node === ">=16.20.2", "Root Node 16 compatibility contract changed."); +assert(Array.isArray(rootManifest.workspaces), "Root npm workspaces are not enabled."); +assert(rootManifest.workspaces.includes("apps/*") && rootManifest.workspaces.includes("packages/*"), "Root workspace globs are incomplete."); +assert(Object.keys(rootManifest.dependencies ?? {}).length === 0, "Root runtime dependencies must stay Core-only."); +assertExactDependencies(rootManifest, "root package"); +const rootDependencyNames = DEPENDENCY_FIELDS.flatMap((field) => Object.keys(rootManifest[field] ?? {})); +assert( + !rootDependencyNames.some((name) => name === "electron" || name.startsWith("electron-")), + "Root npm manifest must not contain Electron dependencies." +); + +const rootPublishAllowlist = new Set([ + "README.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "CONTRIBUTORS.md", + "AGENTS.md", + "docs", + "images/README", + "src", + "web/dist", + "packages/contracts/dist", + "packages/core/src" +]); +for (const entry of rootManifest.files ?? []) { + assert(rootPublishAllowlist.has(entry), `Root tarball allowlist contains an unapproved path: ${entry}`); +} +for (const required of rootPublishAllowlist) { + assert(rootManifest.files?.includes(required), `Root tarball allowlist is missing ${required}.`); +} + +const manifests = new Map(); +for (const workspace of REQUIRED_WORKSPACES) { + const manifest = await readJson(`${workspace}/package.json`); + manifests.set(workspace, manifest); + assert(manifest.private === true, `${workspace} must remain private.`); + assertExactDependencies(manifest, workspace); +} + +const coreSource = await fs.readFile(path.join(repositoryRoot, "packages/core/src/index.js"), "utf8"); +const rootImports = [...coreSource.matchAll(/from\s+["'](\.\.\/\.\.\/\.\.\/src\/[^"']+)["']/g)] + .map((match) => match[1]); +assert(rootImports.length === 1 && rootImports[0] === "../../../src/public-api.js", "Core bridge may import only root src/public-api.js."); + +for (const boundary of [ + "packages/contracts/src", + "packages/core-client/src", + "packages/app-ui/src", + "packages/design-system/src" +]) { + for (const filePath of await sourceFiles(boundary)) { + const source = await fs.readFile(filePath, "utf8"); + assert(!/from\s+["'](?:node:|electron)/.test(source), `${path.relative(repositoryRoot, filePath)} imports a forbidden platform module.`); + assert(!/\.\.\/.*src\//.test(source), `${path.relative(repositoryRoot, filePath)} deep-imports implementation source.`); + } +} + +const desktopManifest = manifests.get("apps/desktop"); +const desktopDependencies = { + ...(desktopManifest.dependencies ?? {}), + ...(desktopManifest.devDependencies ?? {}), + ...(desktopManifest.optionalDependencies ?? {}) +}; +assert( + desktopManifest.dependencies?.["@codex-provider-sync/test-fixtures"] === undefined + && desktopManifest.devDependencies?.["@codex-provider-sync/test-fixtures"] === "0.0.0", + "Desktop fault fixtures must remain an exact private devDependency, never a production dependency." +); +const approvedDesktopElectronDependencies = new Map([ + ["electron", { version: "44.0.0", field: "devDependencies", checkpoint: "C6" }], + ["electron-vite", { version: "5.0.0", field: "devDependencies", checkpoint: "C6" }], + ["electron-builder", { version: "26.15.7", field: "devDependencies", checkpoint: "C6" }], + ["electron-updater", { version: "6.8.9", field: "dependencies", checkpoint: "C8" }], + ["better-sqlite3", { version: "13.0.3", field: "dependencies", checkpoint: "C9" }], + ["@electron/asar", { version: "4.3.0", field: "devDependencies", checkpoint: "C9" }], + ["@electron/fuses", { version: "2.1.3", field: "devDependencies", checkpoint: "C9" }], + ["plist", { version: "5.0.0", field: "devDependencies", checkpoint: "C9" }], + ["resedit", { version: "3.1.0", field: "devDependencies", checkpoint: "C9" }] +]); +for (const [name, approval] of approvedDesktopElectronDependencies) { + assert( + desktopManifest[approval.field]?.[name] === approval.version, + `apps/desktop must pin ${name} to the reviewed ${approval.checkpoint} version ${approval.version}.` + ); +} +for (const name of Object.keys(desktopDependencies)) { + if (name === "electron" || name.startsWith("electron-") || name.startsWith("@electron/")) { + assert(approvedDesktopElectronDependencies.has(name), `Unreviewed Electron dependency: ${name}`); + } +} +for (const [workspace, manifest] of manifests) { + if (workspace === "apps/desktop") continue; + const dependencies = DEPENDENCY_FIELDS.flatMap((field) => Object.keys(manifest[field] ?? {})); + assert( + !dependencies.some((name) => name === "electron" || name.startsWith("electron-")), + `${workspace} must not depend on Electron.` + ); +} + +for (const filePath of await sourceFiles("apps/desktop/src/renderer")) { + const source = await fs.readFile(filePath, "utf8"); + assert(!/from\s+["'](?:node:|electron)/.test(source), `${path.relative(repositoryRoot, filePath)} imports Node or Electron.`); + assert(!/@codex-provider-sync\/core(?:["'/])/.test(source), `${path.relative(repositoryRoot, filePath)} imports Core directly.`); +} +for (const filePath of await sourceFiles("apps/desktop/src/preload")) { + const source = await fs.readFile(filePath, "utf8"); + assert(!/from\s+["']node:/.test(source), `${path.relative(repositoryRoot, filePath)} imports Node in sandboxed preload.`); + assert(!/@codex-provider-sync\/core(?:["'/])/.test(source), `${path.relative(repositoryRoot, filePath)} imports Core in preload.`); +} +for (const filePath of await sourceFiles("apps/desktop/src/main")) { + const source = await fs.readFile(filePath, "utf8"); + assert(!/@codex-provider-sync\/core(?:["'/])/.test(source), `${path.relative(repositoryRoot, filePath)} runs Core in Main.`); + assert(!/\.\.\/\.\.\/\.\.\/src\//.test(source), `${path.relative(repositoryRoot, filePath)} deep-imports root implementation.`); +} +for (const filePath of await sourceFiles("apps/desktop/src/runtime")) { + const source = await fs.readFile(filePath, "utf8"); + assert(!/\.\.\/main\//.test(source), `${path.relative(repositoryRoot, filePath)} depends on Electron Main.`); + assert(!/\.\.\/\.\.\/\.\.\/src\//.test(source), `${path.relative(repositoryRoot, filePath)} deep-imports root implementation.`); +} + +process.stdout.write("Workspace package, Electron, dependency, import and root publish boundaries are valid.\n"); diff --git a/scripts/write-c10-evidence-bundle.mjs b/scripts/write-c10-evidence-bundle.mjs new file mode 100644 index 0000000..1724bf5 --- /dev/null +++ b/scripts/write-c10-evidence-bundle.mjs @@ -0,0 +1,720 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { execFile } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const execFileAsync = promisify(execFile); +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +export const REPOSITORY_ROOT = path.resolve(scriptDirectory, ".."); + +export const REQUIRED_JOBS = Object.freeze([ + "cross-runtime-fixtures", + "dependency-audit", + "desktop-linux-lock", + "desktop-macos", + "desktop-test", + "electron-candidate-set", + "electron-desktop", + "electron-release-candidate", + "root-package-compat", + "test", + "web-browser", + "web-build", + "workspace-contract" +]); + +export const REQUIRED_TARGETS = Object.freeze([ + "linux-x64", + "macos-arm64", + "macos-x64", + "windows-x64" +]); + +const EXPECTED_ASSETS = Object.freeze({ + "windows-x64": (version) => [ + `CodexProviderSync-${version}-windows-x64-portable.zip`, + `CodexProviderSync-${version}-windows-x64-setup.exe` + ], + "macos-x64": (version) => [ + `CodexProviderSync-${version}-macos-x64.dmg`, + `CodexProviderSync-${version}-macos-x64.zip` + ], + "macos-arm64": (version) => [ + `CodexProviderSync-${version}-macos-arm64.dmg`, + `CodexProviderSync-${version}-macos-arm64.zip` + ], + "linux-x64": (version) => [ + `CodexProviderSync-${version}-linux-x64.AppImage`, + `CodexProviderSync-${version}-linux-x64.deb` + ] +}); + +const CHECKPOINTS = Object.freeze([ + { + id: "C0", + commit: "29c84ec5c3f8e614b050d5645734a4d8df67956d", + parentCommit: "c7ff85218a07a8e5f14132c582cad1239c52865e", + evidenceCommit: "29c84ec5c3f8e614b050d5645734a4d8df67956d", + evidencePath: "docs/migration/evidence/C0_BASELINE_2026-08-25.md" + }, + { + id: "C1", + commit: "f008d0ea277b57fd1d027068bfab9c4f80c5ae3a", + parentCommit: "29c84ec5c3f8e614b050d5645734a4d8df67956d", + evidenceCommit: "f008d0ea277b57fd1d027068bfab9c4f80c5ae3a", + evidencePath: "docs/migration/evidence/C1_PUBLIC_API_ERRORS_2026-08-25.md" + }, + { + id: "C2", + commit: "13163f510a1ac0c245ac992f7a10027f30195300", + parentCommit: "f008d0ea277b57fd1d027068bfab9c4f80c5ae3a", + evidenceCommit: "13163f510a1ac0c245ac992f7a10027f30195300", + evidencePath: "docs/migration/evidence/C2_CLI_JSON_2026-08-25.md" + }, + { + id: "C3", + commit: "166f6ff94aa27c029e546b5e98d145dd4915bee4", + parentCommit: "13163f510a1ac0c245ac992f7a10027f30195300", + evidenceCommit: "166f6ff94aa27c029e546b5e98d145dd4915bee4", + evidencePath: "docs/migration/evidence/C3_PLAN_APPLY_DUAL_LOCK_2026-08-25.md" + }, + { + id: "C4", + commit: "d6b0fef593968d402b290f0dff180c84b0fc9325", + parentCommit: "166f6ff94aa27c029e546b5e98d145dd4915bee4", + evidenceCommit: "d6b0fef593968d402b290f0dff180c84b0fc9325", + evidencePath: "docs/migration/evidence/C4_WORKSPACE_CORE_CLIENT_2026-08-25.md" + }, + { + id: "C5", + commit: "8a53ce8d775996b59fce587ef639c0e00e62fcd1", + parentCommit: "d6b0fef593968d402b290f0dff180c84b0fc9325", + evidenceCommit: "8a53ce8d775996b59fce587ef639c0e00e62fcd1", + evidencePath: "docs/migration/evidence/C5_SHARED_UI_WEB_2026-08-26.md" + }, + { + id: "C6", + commit: "6820858833f4e011ba59f38d0281e1ba4a2fde06", + parentCommit: "8a53ce8d775996b59fce587ef639c0e00e62fcd1", + evidenceCommit: "6820858833f4e011ba59f38d0281e1ba4a2fde06", + evidencePath: "docs/migration/evidence/C6_ELECTRON_READONLY_2026-08-26.md" + }, + { + id: "C7", + commit: "1ec27a5b09b955586b472ef18029258e8ff0532c", + parentCommit: "6820858833f4e011ba59f38d0281e1ba4a2fde06", + evidenceCommit: "1ec27a5b09b955586b472ef18029258e8ff0532c", + evidencePath: "docs/migration/evidence/C7_ELECTRON_SYNC_SWITCH_2026-08-26.md" + }, + { + id: "C8", + commit: "1673147f6d993d3a5923615d41dec2cf9f37c293", + parentCommit: "1ec27a5b09b955586b472ef18029258e8ff0532c", + evidenceCommit: "1673147f6d993d3a5923615d41dec2cf9f37c293", + evidencePath: "docs/migration/evidence/C8_RESTORE_WATCH_DIAGNOSTICS_UPDATE_2026-08-27.md" + }, + { + id: "C9", + commit: "73256f3187dd337bb681a1cc9810edad8f6309bb", + parentCommit: "1673147f6d993d3a5923615d41dec2cf9f37c293", + evidenceCommit: "d34654994ad790b09ed4284ce8f5d87aeace8723", + evidencePath: "docs/migration/evidence/C9_PACKAGING_CI_RELEASE_ENGINEERING_2026-08-27.md" + } +]); + +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const HASH_PATTERN = /^[0-9a-f]{64}$/; +const VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; +const CANDIDATE_VERSION_PATTERN = /^1\.0\.0-(?:alpha|beta|rc)\.[0-9]+$/; + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +async function sha256File(filePath) { + return sha256(await fs.readFile(filePath)); +} + +function exactSorted(values) { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function assertExactKeys(value, expected, label) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object.`); + assert.deepEqual(exactSorted(Object.keys(value)), exactSorted(expected), `${label} fields changed.`); +} + +export function normalizeRequiredJobs(raw) { + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + assert.ok(parsed && typeof parsed === "object" && !Array.isArray(parsed), "Required job results must be an object."); + const ids = exactSorted(Object.keys(parsed)); + assert.deepEqual(ids, REQUIRED_JOBS, "C10 required-job inventory changed without an evidence-policy update."); + return ids.map((id) => { + assert.equal(parsed[id]?.result, "success", `Required CI job did not succeed: ${id}.`); + return Object.freeze({ id, conclusion: "success" }); + }); +} + +function sanitizeToolVersions(value) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), "Candidate tool versions are missing."); + const result = {}; + for (const key of exactSorted(Object.keys(value))) { + assert.match(key, /^[A-Za-z][A-Za-z0-9]*$/, "Candidate tool-version key is unsafe."); + assert.match(value[key], VERSION_PATTERN, `Candidate tool version is invalid: ${key}.`); + result[key] = value[key]; + } + assert.ok(Object.keys(result).length > 0, "Candidate tool versions are empty."); + return result; +} + +export function normalizeCandidateIndex(index, evidenceForCommit) { + assert.equal(index?.schemaVersion, 1); + assert.equal(index?.scope, "ci-candidate-index"); + assert.equal(index?.releaseAuthorized, false); + assert.match(index?.version || "", CANDIDATE_VERSION_PATTERN); + assert.equal(index?.commit, evidenceForCommit, "Candidate set must be built from the workflow-tested commit."); + assert.match(index?.commit || "", SHA_PATTERN); + assert.ok(Array.isArray(index?.targets), "Candidate index targets are missing."); + assert.deepEqual(exactSorted(index.targets.map((record) => record.target)), REQUIRED_TARGETS); + + const lockfiles = new Set(); + const toolVersionSets = new Set(); + const auditPolicies = new Set(); + const targets = index.targets.map((record) => { + assert.equal(record.version, index.version, `Candidate version mismatch: ${record.target}.`); + assert.equal(record.commit, index.commit, `Candidate commit mismatch: ${record.target}.`); + assert.match(record.buildId || "", /^[A-Za-z0-9._-]+$/); + assert.match(record.lockfileSha256 || "", HASH_PATTERN); + assert.match(record.manifestSha256 || "", HASH_PATTERN); + assert.equal(record.fusePolicy, "c9-v1"); + assert.equal(record.artifactAuditPolicy?.schemaVersion, 1); + assert.match(record.artifactAuditPolicy?.sha256 || "", HASH_PATTERN); + assert.ok(Array.isArray(record.assets) && record.assets.length === 2, `Candidate assets are incomplete: ${record.target}.`); + lockfiles.add(record.lockfileSha256); + const toolVersions = sanitizeToolVersions(record.toolVersions); + toolVersionSets.add(JSON.stringify(toolVersions)); + auditPolicies.add(JSON.stringify(record.artifactAuditPolicy)); + assert.deepEqual( + exactSorted(record.assets.map((asset) => asset.name)), + exactSorted(EXPECTED_ASSETS[record.target](index.version)), + `Candidate asset names are invalid: ${record.target}.` + ); + return Object.freeze({ + target: record.target, + buildId: record.buildId, + manifestSha256: record.manifestSha256, + toolVersions, + fusePolicy: record.fusePolicy, + artifactAuditPolicy: { + schemaVersion: 1, + sha256: record.artifactAuditPolicy.sha256 + }, + assets: record.assets.map((asset) => { + assert.match(asset.name || "", /^[A-Za-z0-9][A-Za-z0-9._-]+$/); + assert.ok(Number.isSafeInteger(asset.sizeBytes) && asset.sizeBytes > 0); + assert.match(asset.sha256 || "", HASH_PATTERN); + return Object.freeze({ name: asset.name, sizeBytes: asset.sizeBytes, sha256: asset.sha256 }); + }).sort((left, right) => left.name.localeCompare(right.name)) + }); + }).sort((left, right) => left.target.localeCompare(right.target)); + assert.equal(lockfiles.size, 1, "Candidate targets do not share one lockfile."); + assert.equal(toolVersionSets.size, 1, "Candidate targets do not share one tool-version set."); + assert.equal(auditPolicies.size, 1, "Candidate targets do not share one artifact-audit policy."); + return Object.freeze({ + version: index.version, + commit: index.commit, + lockfileSha256: [...lockfiles][0], + targets + }); +} + +export function normalizeFormalReleaseEvidence( + evidence, + { manifest, evidenceForCommit, repository, runId, runAttempt } +) { + assertExactKeys(evidence, [ + "schemaVersion", + "scope", + "containsRealUserData", + "syntheticOnly", + "generatedAt", + "workflow", + "release", + "asset", + "binary", + "backup", + "verification", + "limitation" + ], "Formal Release evidence"); + assert.equal(evidence.schemaVersion, 1); + assert.equal(evidence.scope, "historical-formal-release-backup-evidence"); + assert.equal(evidence.containsRealUserData, false); + assert.equal(evidence.syntheticOnly, true); + assert.equal(Number.isNaN(Date.parse(evidence.generatedAt)), false); + + assertExactKeys(evidence.workflow, ["repository", "runId", "runAttempt", "testedCommit"], "Formal Release workflow binding"); + assert.equal(evidence.workflow.repository, repository); + assert.equal(evidence.workflow.runId, runId); + assert.equal(evidence.workflow.runAttempt, runAttempt); + assert.equal(evidence.workflow.testedCommit, evidenceForCommit); + + assertExactKeys(evidence.release, [ + "repository", + "releaseId", + "tag", + "tagObjectSha", + "commit", + "publishedAt", + "tagSigned" + ], "Formal Release provenance"); + assert.equal(evidence.release.repository, manifest.repository); + assert.equal(evidence.release.releaseId, manifest.release.id); + assert.equal(evidence.release.tag, manifest.release.tag); + assert.equal(evidence.release.tagObjectSha, manifest.release.tagObjectSha); + assert.equal(evidence.release.commit, manifest.release.commit); + assert.equal(evidence.release.publishedAt, manifest.release.publishedAt); + assert.equal(evidence.release.tagSigned, manifest.release.tagSigned); + + const expectedAsset = manifest.assets.automationZip; + assertExactKeys(evidence.asset, [ + "id", + "name", + "size", + "sha256", + "checksumAssetSha256", + "releaseChecksumsSha256" + ], "Formal Release asset evidence"); + assert.equal(evidence.asset.id, expectedAsset.id); + assert.equal(evidence.asset.name, expectedAsset.name); + assert.equal(evidence.asset.size, expectedAsset.size); + assert.equal(evidence.asset.sha256, expectedAsset.sha256); + assert.equal(evidence.asset.checksumAssetSha256, manifest.assets.automationChecksum.sha256); + assert.equal(evidence.asset.releaseChecksumsSha256, manifest.assets.releaseChecksums.sha256); + + const expectedBinary = manifest.archiveEntries.find((entry) => entry.name === "CodexProviderSync.Automation.exe"); + assert.ok(expectedBinary, "The formal Release manifest has no Automation binary."); + assertExactKeys(evidence.binary, ["name", "size", "sha256", "authenticodeStatus"], "Formal Release binary evidence"); + assert.equal(evidence.binary.name, expectedBinary.name); + assert.equal(evidence.binary.size, expectedBinary.size); + assert.equal(evidence.binary.sha256, expectedBinary.sha256); + assert.equal(evidence.binary.authenticodeStatus, "NotSigned"); + + assertExactKeys(evidence.backup, ["metadataVersion", "metadataSha256", "producedTreeSha256"], "Formal Release backup evidence"); + assert.equal(evidence.backup.metadataVersion, 2); + assert.match(evidence.backup.metadataSha256 || "", HASH_PATTERN); + assert.match(evidence.backup.producedTreeSha256 || "", HASH_PATTERN); + assertExactKeys(evidence.verification, [ + "releaseApiPinned", + "releaseChecksumPinned", + "isolatedEnvironment", + "authCanaryExcluded", + "currentNodeRestoreVerified", + "pendingRecoveryCount" + ], "Formal Release verification"); + assert.equal(evidence.verification.releaseApiPinned, true); + assert.equal(evidence.verification.releaseChecksumPinned, true); + assert.equal(evidence.verification.isolatedEnvironment, true); + assert.equal(evidence.verification.authCanaryExcluded, true); + assert.equal(evidence.verification.currentNodeRestoreVerified, true); + assert.equal(evidence.verification.pendingRecoveryCount, 0); + assert.match(evidence.limitation || "", /^The formal v0\.4\.1 Automation binary is unsigned;/); + + return Object.freeze({ + artifactName: "historical-formal-release-backup-evidence", + release: Object.freeze({ + repository: evidence.release.repository, + releaseId: evidence.release.releaseId, + tag: evidence.release.tag, + tagObjectSha: evidence.release.tagObjectSha, + commit: evidence.release.commit, + publishedAt: evidence.release.publishedAt, + tagSigned: evidence.release.tagSigned + }), + asset: Object.freeze({ ...evidence.asset }), + binary: Object.freeze({ ...evidence.binary }), + backup: Object.freeze({ ...evidence.backup }), + syntheticOnly: true, + currentNodeRestoreVerified: true + }); +} + +const FORBIDDEN_KEYS = new Set([ + "auth", + "authjson", + "apikey", + "backuppath", + "clientsecret", + "codexhome", + "credential", + "credentials", + "historytitle", + "log", + "logs", + "message", + "messagebody", + "password", + "privatekey", + "profileid", + "refreshtoken", + "rolloutcontent", + "secret", + "sessiontoken", + "sshkey", + "sqlitehome", + "sqlitepath", + "token", + "xapikey", + "accesstoken" +]); + +export function assertRedacted(value) { + function visit(current) { + if (Array.isArray(current)) { + current.forEach(visit); + return; + } + if (current && typeof current === "object") { + for (const [key, child] of Object.entries(current)) { + const canonicalKey = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + assert.equal(FORBIDDEN_KEYS.has(canonicalKey), false, `Evidence contains a forbidden key class: ${key}.`); + visit(child); + } + return; + } + if (typeof current !== "string") return; + assert.doesNotMatch(current, /(?:^|[^A-Za-z])[A-Za-z]:[\\/]/, "Evidence contains an absolute Windows path."); + assert.doesNotMatch(current, /\\\\[^\\\s]+\\/, "Evidence contains a UNC path."); + assert.doesNotMatch( + current, + /(?:^|[\s("'=,])\/\/[^/\s]+\/[^/\s]+/, + "Evidence contains an absolute network path." + ); + assert.doesNotMatch(current, /(?:^|[^A-Za-z0-9._~\/-])\/(?!\/)/, "Evidence contains an absolute POSIX path."); + assert.doesNotMatch( + current, + /(?:authorization\s*:|bearer\s+|private[-_ ]key|api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|client[-_ ]?secret|session[-_ ]?token|sk-(?:proj-)?[A-Za-z0-9_-]{8,}|AKIA[A-Z0-9]{12,})/i, + "Evidence contains a credential marker." + ); + assert.doesNotMatch(current, /auth\.json/i, "Evidence names a protected authentication file."); + } + visit(value); +} + +async function git(repositoryRoot, args, options = {}) { + const result = await execFileAsync("git", args, { + cwd: repositoryRoot, + encoding: options.encoding, + maxBuffer: 8 * 1024 * 1024 + }); + return result.stdout; +} + +async function assertAncestor(repositoryRoot, ancestor, descendant) { + try { + await git(repositoryRoot, ["merge-base", "--is-ancestor", ancestor, descendant], { encoding: "utf8" }); + } catch { + throw new Error(`Checkpoint ${ancestor} is not an ancestor of the tested commit.`); + } +} + +async function assertTestedCheckout(repositoryRoot, evidenceForCommit) { + const currentHead = String(await git(repositoryRoot, ["rev-parse", "HEAD"], { encoding: "utf8" })).trim().toLowerCase(); + assert.equal(currentHead, evidenceForCommit, "GITHUB_SHA must equal the checked-out commit."); + try { + await git(repositoryRoot, ["diff", "--quiet", "--exit-code"]); + await git(repositoryRoot, ["diff", "--cached", "--quiet", "--exit-code"]); + } catch { + throw new Error("C10 evidence requires a clean tracked checkout of the tested commit."); + } +} + +export async function assertEventBaseContained( + repositoryRoot, + { event, evidenceForCommit, sourceHeadCommit, eventBaseCommit } +) { + if (event === "push") { + assert.equal( + sourceHeadCommit, + evidenceForCommit, + "Push evidence must bind the source head to the tested commit." + ); + } + await assertAncestor(repositoryRoot, sourceHeadCommit, evidenceForCommit); + try { + await assertAncestor(repositoryRoot, eventBaseCommit, sourceHeadCommit); + } catch { + throw new Error( + "The source branch does not contain the workflow event base commit; merge that base before generating C10 evidence." + ); + } +} + +export async function assertEvidenceSchema(bundle, repositoryRoot = REPOSITORY_ROOT) { + const [{ default: Ajv2020 }, schemaText] = await Promise.all([ + import("ajv/dist/2020.js"), + fs.readFile( + path.join(repositoryRoot, "docs", "migration", "evidence", "C10_EVIDENCE_BUNDLE.v1.schema.json"), + "utf8" + ) + ]); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + ajv.addFormat("date-time", { + type: "string", + validate(value) { + return typeof value === "string" + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) + && !Number.isNaN(Date.parse(value)); + } + }); + const validate = ajv.compile(JSON.parse(schemaText)); + assert.equal( + validate(bundle), + true, + `C10 evidence does not match its JSON Schema: ${ajv.errorsText(validate.errors, { separator: "; " })}` + ); +} + +async function collectCheckpoints(repositoryRoot, evidenceForCommit) { + const records = []; + for (const descriptor of CHECKPOINTS) { + const parents = String(await git(repositoryRoot, ["show", "-s", "--format=%P", descriptor.commit], { encoding: "utf8" })).trim().split(/\s+/); + assert.equal(parents.length, 1, `Checkpoint ${descriptor.id} must be a single-parent commit.`); + assert.equal(parents[0], descriptor.parentCommit, `Checkpoint ${descriptor.id} parent changed.`); + if (descriptor.evidenceCommit !== descriptor.commit) { + const evidenceParents = String(await git(repositoryRoot, ["show", "-s", "--format=%P", descriptor.evidenceCommit], { encoding: "utf8" })).trim().split(/\s+/); + assert.deepEqual(evidenceParents, [descriptor.commit], `Checkpoint ${descriptor.id} evidence commit must immediately follow its implementation.`); + } + await assertAncestor(repositoryRoot, descriptor.evidenceCommit, evidenceForCommit); + const evidenceBlob = await git(repositoryRoot, ["show", `${descriptor.evidenceCommit}:${descriptor.evidencePath}`]); + records.push(Object.freeze({ + ...descriptor, + evidenceSha256: sha256(evidenceBlob), + status: "candidate-evidence" + })); + } + return records; +} + +function requiredEnvironment(environment, key, pattern) { + const value = environment[key]; + assert.equal(typeof value, "string", `${key} is required.`); + assert.match(value, pattern, `${key} is invalid.`); + return value; +} + +function pendingItems({ event, ref, sourceVersions }) { + const pending = []; + if (!(event === "push" && ref === "refs/heads/main")) { + pending.push({ + id: "protected-main-merge-and-rerun", + blocking: true, + reason: "Pull-request evidence does not prove the protected main merge result.", + requiredEvidence: "Run the same required CI and C10 bundle on the resulting main commit." + }); + } + if (sourceVersions.rootPackage !== "1.0.0" || sourceVersions.desktopPackage !== "1.0.0") { + pending.push({ + id: "final-source-version", + blocking: true, + reason: "The source manifests have not been set to the gated 1.0.0 version.", + requiredEvidence: "After RC gates pass, set both source manifests to 1.0.0 and rerun every required job." + }); + } + pending.push( + { + id: "real-wsl-unc-validation", + blocking: true, + reason: "No commit-bound evidence from a healthy Windows plus real WSL distribution was supplied.", + requiredEvidence: "Run the strict Windows WSL boundary case with CPS_REQUIRE_REAL_WSL=1 and retain redacted commit-bound evidence." + }, + { + id: "release-authorization", + blocking: true, + reason: "No public release action has been authorized.", + requiredEvidence: "Obtain explicit authorization before any tag, package publication, or hosted release." + }, + { + id: "signing-notarization", + blocking: true, + reason: "Candidate artifacts are intentionally unsigned and not notarized.", + requiredEvidence: "Produce and verify signed and notarized release artifacts on the authorized release commit." + }, + { + id: "cross-version-update", + blocking: true, + reason: "No production update metadata or restart-upgrade path has been published.", + requiredEvidence: "Verify an authorized cross-version update without an active write or unresolved journal." + }, + { + id: "cross-runtime-update-admission", + blocking: true, + reason: "The Main restart gate does not reserve external CLI, Web, or Watch writers.", + requiredEvidence: "Define and verify one cross-runtime maintenance lease before authorizing production update installation." + }, + { + id: "real-beta-validation", + blocking: true, + reason: "No authorized real-user Beta validation has been completed.", + requiredEvidence: "Complete a privacy-safe Beta and record platform, upgrade, backup, Restore, and known-limit evidence." + } + ); + return pending; +} + +export async function createEvidenceBundle({ + repositoryRoot = REPOSITORY_ROOT, + candidateIndexPath, + formalReleaseEvidencePath, + environment = process.env, + now = new Date() +}) { + const repository = requiredEnvironment(environment, "GITHUB_REPOSITORY", /^Dailin521\/codex-provider-sync$/); + const evidenceForCommit = requiredEnvironment(environment, "GITHUB_SHA", SHA_PATTERN).toLowerCase(); + const sourceHeadCommit = requiredEnvironment(environment, "CPS_SOURCE_HEAD_SHA", SHA_PATTERN).toLowerCase(); + const eventBaseCommit = requiredEnvironment(environment, "CPS_EVENT_BASE_SHA", SHA_PATTERN).toLowerCase(); + const runId = requiredEnvironment(environment, "GITHUB_RUN_ID", /^[0-9]+$/); + const runAttemptText = requiredEnvironment(environment, "GITHUB_RUN_ATTEMPT", /^[1-9][0-9]*$/); + const event = requiredEnvironment(environment, "GITHUB_EVENT_NAME", /^(?:pull_request|push)$/); + const ref = requiredEnvironment(environment, "GITHUB_REF", /^refs\/[A-Za-z0-9._/-]+$/); + const requiredJobs = normalizeRequiredJobs(requiredEnvironment(environment, "CPS_REQUIRED_JOB_RESULTS_JSON", /^[\s\S]+$/)); + await assertTestedCheckout(repositoryRoot, evidenceForCommit); + await assertEventBaseContained(repositoryRoot, { + event, + evidenceForCommit, + sourceHeadCommit, + eventBaseCommit + }); + const checkpointRecords = await collectCheckpoints(repositoryRoot, evidenceForCommit); + const candidateIndex = JSON.parse(await fs.readFile(candidateIndexPath, "utf8")); + const candidateSet = normalizeCandidateIndex(candidateIndex, evidenceForCommit); + const formalReleaseManifest = JSON.parse(await fs.readFile( + path.join(repositoryRoot, "test-support", "formal-release-assets.v1.json"), + "utf8" + )); + const formalReleaseEvidence = JSON.parse(await fs.readFile(formalReleaseEvidencePath, "utf8")); + const historicalFormalRelease = normalizeFormalReleaseEvidence(formalReleaseEvidence, { + manifest: formalReleaseManifest, + evidenceForCommit, + repository, + runId, + runAttempt: Number(runAttemptText) + }); + const rootPackage = JSON.parse(await fs.readFile(path.join(repositoryRoot, "package.json"), "utf8")); + const desktopPackage = JSON.parse(await fs.readFile(path.join(repositoryRoot, "apps", "desktop", "package.json"), "utf8")); + assert.match(rootPackage.version || "", VERSION_PATTERN); + assert.match(desktopPackage.version || "", VERSION_PATTERN); + const sourceVersions = Object.freeze({ rootPackage: rootPackage.version, desktopPackage: desktopPackage.version }); + const workflowBlob = await git(repositoryRoot, ["show", `${evidenceForCommit}:.github/workflows/ci.yml`]); + const createdAt = now.toISOString(); + assert.equal(Number.isNaN(Date.parse(createdAt)), false, "C10 creation time is invalid."); + + const bundle = { + schemaVersion: 1, + scope: "vnext-c10-evidence", + outcome: "ci-verified-not-release", + evidenceForCommit, + createdAt, + repository, + workflow: { + path: ".github/workflows/ci.yml", + workflowSha256: sha256(workflowBlob), + runId, + runAttempt: Number(runAttemptText), + event, + ref, + testedCommit: evidenceForCommit, + sourceHeadCommit, + eventBaseCommit, + containsEventBase: true + }, + sourceVersions, + checkpoints: checkpointRecords, + ci: { + policy: "all-applicable-jobs-must-succeed", + requiredJobs + }, + candidateSet: { + artifactName: "electron-release-candidate-set", + indexSha256: await sha256File(candidateIndexPath), + ...candidateSet + }, + historicalFormalRelease: { + evidenceSha256: await sha256File(formalReleaseEvidencePath), + ...historicalFormalRelease + }, + assertions: { + checkpointChainLinear: true, + allEvidenceFilesHashed: true, + workflowHeadMatchesEvidenceCommit: true, + sourceHeadContainsEventBase: true, + allRequiredJobsSucceeded: true, + candidateSetComplete: true, + candidateCommitMatchesEvidenceCommit: true, + candidateReleaseUnauthorized: true, + historicalFormalReleaseBackupVerified: true, + redactionScanPassed: true + }, + pending: pendingItems({ event, ref, sourceVersions }), + release: { + releaseAuthorized: false, + tagCreated: false, + npmPublished: false, + githubReleaseCreated: false, + signed: false, + notarized: false, + updateMetadataPublished: false, + crossVersionUpgradeVerified: false + }, + redaction: { + policyVersion: "c10-v1", + secretScan: "passed", + forbiddenKeyClasses: ["authentication", "credentials", "message-content", "storage-paths"], + forbiddenValueClasses: ["absolute-paths", "authentication-files", "credential-markers", "raw-logs"] + } + }; + assertRedacted(bundle); + await assertEvidenceSchema(bundle, repositoryRoot); + return bundle; +} + +export async function writeEvidenceBundle({ bundle, outputRoot }) { + try { + await fs.lstat(outputRoot); + throw new Error("C10 evidence output already exists."); + } catch (error) { + if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error; + } + await fs.mkdir(outputRoot, { recursive: true }); + const bundlePath = path.join(outputRoot, "evidence-bundle.v1.json"); + const body = `${JSON.stringify(bundle, null, 2)}\n`; + await fs.writeFile(bundlePath, body, { encoding: "utf8", flag: "wx" }); + const checksum = sha256(Buffer.from(body, "utf8")); + await fs.writeFile(path.join(outputRoot, "SHA256SUMS.txt"), `${checksum} evidence-bundle.v1.json\n`, { + encoding: "utf8", + flag: "wx" + }); + return Object.freeze({ bundlePath, checksum }); +} + +async function main() { + const candidateIndexPath = path.resolve( + process.env.CPS_CANDIDATE_INDEX || path.join(REPOSITORY_ROOT, "artifacts", "c9-index", "candidate-index.v1.json") + ); + const outputRoot = path.resolve( + process.env.CPS_C10_OUTPUT_ROOT || path.join(REPOSITORY_ROOT, "artifacts", "c10") + ); + const formalReleaseEvidencePath = path.resolve( + process.env.CPS_FORMAL_RELEASE_EVIDENCE + || path.join(REPOSITORY_ROOT, "artifacts", "test-fixtures", "historical-formal-release-backup-evidence.json") + ); + const bundle = await createEvidenceBundle({ candidateIndexPath, formalReleaseEvidencePath }); + const result = await writeEvidenceBundle({ bundle, outputRoot }); + process.stdout.write(`C10 evidence bundle written: ${result.checksum}\n`); +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + await main(); +} diff --git a/src/backup.js b/src/backup.js index fbbc20e..9607d4b 100644 --- a/src/backup.js +++ b/src/backup.js @@ -28,8 +28,11 @@ import { getStartedJournalTargets, readTransactionJournal } from "./transaction-journal.js"; +import { findRestoreJournals } from "./restore-journal.js"; import { syncDirectory, writeFileAtomic } from "./atomic-file.js"; +const SESSION_CANONICAL_TARGET = Symbol("codex-provider-sync.session-canonical-target"); + function timestampSlug(date = new Date()) { return date.toISOString().replaceAll(":", "").replaceAll("-", "").replace(".", ""); } @@ -47,7 +50,7 @@ async function copyIfPresent(sourcePath, destinationPath) { return true; } -async function copyFileAtomic(sourcePath, destinationPath) { +export async function copyFileAtomic(sourcePath, destinationPath) { const fullDestination = path.resolve(destinationPath); const directory = path.dirname(fullDestination); const tempPath = path.join( @@ -111,6 +114,21 @@ function storagePathsEqual(left, right) { : normalizedLeft === normalizedRight; } +async function storagePathsEqualPhysical(left, right) { + if (storagePathsEqual(left, right)) { + return true; + } + try { + const [physicalLeft, physicalRight] = await Promise.all([ + fs.realpath(path.resolve(left)), + fs.realpath(path.resolve(right)) + ]); + return storagePathsEqual(physicalLeft, physicalRight); + } catch { + return false; + } +} + function pathComparisonKey(value) { const resolved = path.resolve(value); return process.platform === "win32" ? resolved.toLowerCase() : resolved; @@ -124,6 +142,21 @@ function pathIsWithin(root, target) { && !path.isAbsolute(relativePath); } +function findRawRolloutRoot(target) { + let current = path.dirname(target); + while (true) { + const name = path.basename(current).toLowerCase(); + if (name === "sessions" || name === "archived_sessions") { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + async function assertNoLinkedPathSegments(root, target) { const relativePath = path.relative(root, target); const segments = relativePath.split(path.sep).filter(Boolean); @@ -141,34 +174,64 @@ async function assertNoLinkedPathSegments(root, target) { async function validateSessionManifestEntries(entries, codexHome) { const roots = ["sessions", "archived_sessions"].map((name) => path.resolve(codexHome, name)); + const canonicalRoots = (await Promise.all(roots.map(async (root) => { + try { + return await fs.realpath(root); + } catch (error) { + if (error?.code === "ENOENT") { + return null; + } + throw error; + } + }))).filter(Boolean); const seen = new Set(); + const validated = []; for (const entry of entries) { if (!entry || typeof entry.path !== "string" || !path.isAbsolute(entry.path)) { throw new Error("Backup session manifest contains a missing or non-absolute rollout path."); } const target = path.resolve(entry.path); - const lexicalRoot = roots.find((root) => pathIsWithin(root, target)); - if (!lexicalRoot || !/^rollout-.*\.jsonl$/i.test(path.basename(target))) { + if (!/^rollout-.*\.jsonl$/i.test(path.basename(target))) { throw new Error(`Backup session target is outside the allowed rollout roots: ${entry.path}`); } - const key = pathComparisonKey(target); - if (seen.has(key)) { - throw new Error(`Backup session manifest contains a duplicate rollout target: ${entry.path}`); + const rawRoot = findRawRolloutRoot(target); + if (!rawRoot) { + throw new Error(`Backup session target is outside the allowed rollout roots: ${entry.path}`); } - seen.add(key); - await assertNoLinkedPathSegments(lexicalRoot, target); - const [canonicalRoot, canonicalTarget] = await Promise.all([ - fs.realpath(lexicalRoot), + await assertNoLinkedPathSegments(rawRoot, target); + const [canonicalRawRoot, canonicalTarget] = await Promise.all([ + fs.realpath(rawRoot), fs.realpath(target) ]); - if (!pathIsWithin(canonicalRoot, canonicalTarget)) { + const canonicalRoot = canonicalRoots.find((root) => + storagePathsEqual(root, canonicalRawRoot) && pathIsWithin(root, canonicalTarget) + ); + if (!canonicalRoot) { throw new Error(`Backup session target resolves outside the allowed rollout roots: ${entry.path}`); } + const key = pathComparisonKey(canonicalTarget); + if (seen.has(key)) { + throw new Error(`Backup session manifest contains a duplicate rollout target: ${entry.path}`); + } + seen.add(key); + await assertNoLinkedPathSegments(canonicalRoot, canonicalTarget); const stat = await fs.stat(canonicalTarget); if (!stat.isFile()) { throw new Error(`Backup session target is not a regular file: ${entry.path}`); } + const normalizedEntry = { ...entry, path: target }; + Object.defineProperty(normalizedEntry, SESSION_CANONICAL_TARGET, { + configurable: true, + enumerable: false, + value: canonicalTarget + }); + validated.push({ + originalPath: target, + canonicalPath: canonicalTarget, + entry: normalizedEntry + }); } + return validated; } function resolveRestoreSqliteHome(storage, metadata, stateDb) { @@ -186,6 +249,17 @@ function resolveRestoreSqliteHome(storage, metadata, stateDb) { return storage.sqliteHome; } +export async function resolveRestoreStateDbTargetPath(backupDir, storage) { + const metadata = await readValidatedBackupMetadata(backupDir, storage.codexHome); + const stateDb = Object.hasOwn(storage, "stateDbLocation") + ? storage.stateDbLocation + : await detectStateDb(storage); + if (!stateDb && storage.sqliteHomeSource !== "default") { + throw new Error(`state_5.sqlite not found in SQLite home ${storage.sqliteHome}.`); + } + return path.join(resolveRestoreSqliteHome(storage, metadata, stateDb), DB_FILE_BASENAME); +} + async function removeIfPresent(targetPath) { await fs.rm(targetPath, { force: true }); } @@ -220,6 +294,17 @@ export async function restoreGlobalStateFilesFromBackup(backupDir, codexHome, op } const sourcePath = path.join(backupDir, fileName); const originalPresent = metadata?.globalStateFiles?.[fileName]; + const target = { + kind: "globalState", + targetPath, + sourcePath, + sourceAction: originalPresent === false + ? "delete" + : (originalPresent === true || await backupFileExists(sourcePath) ? "copy" : "preserve"), + sourcePresent: originalPresent === true + || (originalPresent !== false && await backupFileExists(sourcePath)) + }; + await options.onBeforeTarget?.(target); if (originalPresent === true) { try { await fs.access(sourcePath); @@ -234,6 +319,7 @@ export async function restoreGlobalStateFilesFromBackup(backupDir, codexHome, op // behavior instead of deleting a file we cannot classify safely. await copyIfPresent(sourcePath, targetPath); } + await options.onAfterTarget?.(target); } } @@ -429,13 +515,53 @@ export async function pruneBackups(codexHome, keepCount = DEFAULT_BACKUP_RETENTI const backupRoot = defaultBackupRoot(codexHome); const backupDirs = await listManagedBackupDirectories(backupRoot); - const pending = await findPendingTransactions(codexHome); - const protectedBackups = new Set( - pending.map((transaction) => pathComparisonKey(path.dirname(transaction.filePath))) - ); - const toDelete = backupDirs - .slice(keepCount) - .filter((entry) => !protectedBackups.has(pathComparisonKey(entry.fullPath))); + const [pending, restoreJournals] = await Promise.all([ + findPendingTransactions(codexHome), + findRestoreJournals(codexHome) + ]); + // A completed Restore may resolve an older nonterminal journal for write + // admission, but that does not authorize Prune to delete its evidence. + // Protect every still-blocking Restore journal independently of resolution. + const pruneTransactions = [ + ...pending.filter((transaction) => transaction.operationKind !== "restore"), + ...restoreJournals.filter((transaction) => transaction.blocking) + ]; + const protectedBackups = new Set(); + let restoreReferencesUnverifiable = false; + for (const transaction of pruneTransactions) { + try { + protectedBackups.add(pathComparisonKey(await fs.realpath(path.dirname(transaction.filePath)))); + } catch { + if (transaction.operationKind === "restore") restoreReferencesUnverifiable = true; + } + for (const referencedDir of [ + transaction.prepared?.sourceBackup?.backupDir, + transaction.prepared?.preRestoreSnapshot?.backupDir, + transaction.protectionReferences?.sourceBackupDir, + transaction.protectionReferences?.preRestoreSnapshotDir + ]) { + if (typeof referencedDir === "string" && path.isAbsolute(referencedDir)) { + try { + protectedBackups.add(pathComparisonKey(await fs.realpath(referencedDir))); + } catch { + if (transaction.operationKind === "restore") restoreReferencesUnverifiable = true; + } + } + } + restoreReferencesUnverifiable ||= transaction.operationKind === "restore" + && transaction.protectionReferencesUnverifiable === true; + } + const toDelete = []; + for (const entry of backupDirs.slice(keepCount)) { + if (restoreReferencesUnverifiable) break; + let entryKey; + try { + entryKey = pathComparisonKey(await fs.realpath(entry.fullPath)); + } catch { + continue; + } + if (!protectedBackups.has(entryKey)) toDelete.push(entry); + } let freedBytes = 0; for (const entry of toDelete) { freedBytes += await getBackupDirectorySize(entry.fullPath); @@ -487,7 +613,8 @@ async function readValidatedBackupMetadata(backupDir, codexHome) { if (metadata.namespace !== BACKUP_NAMESPACE || ![1, 2].includes(metadata.version)) { throw new Error(`Unsupported backup metadata in ${metadataPath}.`); } - if (typeof metadata.codexHome !== "string" || !storagePathsEqual(metadata.codexHome, codexHome)) { + if (typeof metadata.codexHome !== "string" + || !await storagePathsEqualPhysical(metadata.codexHome, codexHome)) { throw new Error(`Backup was created for ${metadata.codexHome}, not ${codexHome}.`); } return metadata; @@ -530,7 +657,7 @@ export async function getBackupRecoveryCoverage(backupDir, storageOrCodexHome) { throw new Error(`Unsupported session backup manifest in ${sessionManifestPath}.`); } if (typeof sessionManifest.codexHome !== "string" - || !storagePathsEqual(sessionManifest.codexHome, codexHome)) { + || !await storagePathsEqualPhysical(sessionManifest.codexHome, codexHome)) { throw new Error(`Session backup was created for ${sessionManifest.codexHome}, not ${codexHome}.`); } if (!Array.isArray(sessionManifest.files)) { @@ -577,7 +704,11 @@ export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) restoreSessions = true, allowSqliteHomeRelocation = false, globalStateTargetPaths = null, - sessionTargetPaths = null + sessionTargetPaths = null, + onBeforeSessionRestore = null, + onBeforeTarget = null, + onAfterTarget = null, + dryRun = false } = options; const storage = typeof storageOrCodexHome === "string" ? resolveStorageLayout({ codexHome: storageOrCodexHome, env: {} }) @@ -595,16 +726,26 @@ export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) throw new Error(`Unsupported session backup manifest in ${sessionManifestPath}.`); } if (typeof sessionManifest.codexHome !== "string" - || !storagePathsEqual(sessionManifest.codexHome, codexHome)) { + || !await storagePathsEqualPhysical(sessionManifest.codexHome, codexHome)) { throw new Error(`Session backup was created for ${sessionManifest.codexHome}, not ${codexHome}.`); } - await validateSessionManifestEntries(sessionManifest.files ?? [], codexHome); + const validatedEntries = await validateSessionManifestEntries(sessionManifest.files ?? [], codexHome); if (sessionTargetPaths) { const selected = new Set(sessionTargetPaths.map(pathComparisonKey)); - sessionRestoreEntries = (sessionManifest.files ?? []) - .filter((entry) => selected.has(pathComparisonKey(entry.path))); + for (const selectedPath of sessionTargetPaths) { + selected.add(pathComparisonKey(await fs.realpath(selectedPath))); + } + sessionRestoreEntries = validatedEntries + .filter(({ originalPath, canonicalPath }) => + selected.has(pathComparisonKey(originalPath)) || selected.has(pathComparisonKey(canonicalPath)) + ) + .map(({ entry }) => entry); } else { - sessionRestoreEntries = await selectSessionRestoreEntries(backupDir, sessionManifest); + const selectedEntries = await selectSessionRestoreEntries(backupDir, sessionManifest); + const selected = new Set(selectedEntries.map((entry) => pathComparisonKey(entry.path))); + sessionRestoreEntries = validatedEntries + .filter(({ originalPath }) => selected.has(pathComparisonKey(originalPath))) + .map(({ entry }) => entry); } } @@ -621,7 +762,7 @@ export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) targetSqliteHome = resolveRestoreSqliteHome(storage, metadata, stateDb); const sqliteHomeRelocation = metadata.version >= 2 && metadata.sqliteHome - && !storagePathsEqual(metadata.sqliteHome, targetSqliteHome); + && !await storagePathsEqualPhysical(metadata.sqliteHome, targetSqliteHome); if (sqliteHomeRelocation && !allowSqliteHomeRelocation) { throw new Error( `Backup SQLite home is ${metadata.sqliteHome}, but the current target is ${targetSqliteHome}. ` @@ -669,29 +810,135 @@ export async function restoreBackup(backupDir, storageOrCodexHome, options = {}) } const configBackupPath = path.join(backupDir, "config.toml"); + const configTargetPath = path.join(codexHome, "config.toml"); + const configSourcePresent = restoreConfig && await backupFileExists(configBackupPath); + const globalStateTargets = []; + if (restoreGlobalState) { + const selectedTargets = globalStateTargetPaths + ? new Set(globalStateTargetPaths.map(pathComparisonKey)) + : null; + for (const fileName of [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]) { + const targetPath = path.join(codexHome, fileName); + if (selectedTargets && !selectedTargets.has(pathComparisonKey(targetPath))) { + continue; + } + const sourcePath = path.join(backupDir, fileName); + const originalPresent = metadata?.globalStateFiles?.[fileName]; + if (originalPresent === true && !await backupFileExists(sourcePath)) { + throw new Error(`Backup metadata says ${fileName} was present, but its backup copy is missing.`); + } + globalStateTargets.push({ + kind: "globalState", + targetPath, + sourcePath, + sourceAction: originalPresent === false + ? "delete" + : (originalPresent === true || await backupFileExists(sourcePath) ? "copy" : "preserve"), + sourcePresent: originalPresent === true + || (originalPresent !== false && await backupFileExists(sourcePath)) + }); + } + } + const targets = [ + ...(configSourcePresent + ? [{ + kind: "config", + targetPath: configTargetPath, + sourcePath: configBackupPath, + sourcePresent: true + }] + : []), + ...globalStateTargets, + ...(databaseRestorePlan + ? [{ + kind: "sqlite", + targetPath: databaseRestorePlan.targetPath, + sourcePath: databaseRestorePlan.sourcePath, + sourcePresent: true + }] + : []), + ...sessionRestoreEntries.map((entry) => ({ + kind: "rollout", + targetPath: entry.path, + sourceEntry: entry, + sourcePresent: true + })) + ]; + + if (dryRun) { + return { + metadata, + backupDir: path.resolve(backupDir), + codexHome, + targetSqliteHome, + targets + }; + } + if (restoreConfig) { - await copyIfPresent(configBackupPath, path.join(codexHome, "config.toml")); + if (configSourcePresent) { + const target = targets.find((item) => item.kind === "config"); + await onBeforeTarget?.(target); + await copyFileAtomic(configBackupPath, configTargetPath); + await onAfterTarget?.(target); + } } if (restoreGlobalState) { await restoreGlobalStateFilesFromBackup(backupDir, codexHome, { - targetPaths: globalStateTargetPaths + targetPaths: globalStateTargetPaths, + onBeforeTarget, + onAfterTarget }); } if (databaseRestorePlan) { + const target = targets.find((item) => item.kind === "sqlite"); + await onBeforeTarget?.(target); await restoreSqliteOnlineBackup( databaseRestorePlan.sourcePath, databaseRestorePlan.targetPath ); + await onAfterTarget?.(target); } if (restoreSessions) { - await restoreSessionChanges(sessionRestoreEntries); + await restoreSessionChanges(sessionRestoreEntries, { + onBeforeRestore: async (entry) => { + await onBeforeSessionRestore?.(entry); + const [validated] = await validateSessionManifestEntries([entry], codexHome); + if (!validated + || !storagePathsEqual( + validated.entry[SESSION_CANONICAL_TARGET], + entry[SESSION_CANONICAL_TARGET] + )) { + throw new Error(`Backup session target changed after validation: ${entry.path}`); + } + const target = targets.find((item) => + item.kind === "rollout" + && pathComparisonKey(item.targetPath) === pathComparisonKey(entry.path) + ); + await onBeforeTarget?.(target); + }, + onRestored: async (entry) => { + const target = targets.find((item) => + item.kind === "rollout" + && pathComparisonKey(item.targetPath) === pathComparisonKey(entry.path) + ); + await onAfterTarget?.(target); + } + }); } return metadata; } +export async function prepareRestoreBackup(backupDir, storageOrCodexHome, options = {}) { + return restoreBackup(backupDir, storageOrCodexHome, { + ...options, + dryRun: true + }); +} + async function listManagedBackupDirectories(backupRoot) { let entries; try { diff --git a/src/cli-json.js b/src/cli-json.js new file mode 100644 index 0000000..6876b27 --- /dev/null +++ b/src/cli-json.js @@ -0,0 +1,613 @@ +export const CLI_JSON_SCHEMA_VERSION = 1; + +const SUCCESS_OUTCOMES = new Set(["completed", "noop", "partial"]); +const FAILURE_OUTCOMES = new Set([ + "failed", + "failed_rolled_back", + "recovery_required", + "cancelled", + "stale" +]); +const STALE_CODES = new Set([ + "INVALID_INPUT", + "PLAN_EXPIRED", + "PLAN_STALE", + "STALE_STATE", + "PROFILE_CHANGED", + "STORAGE_CHANGED" +]); +const RECOVERY_CODES = new Set(["RECOVERY_REQUIRED", "PENDING_TRANSACTION"]); +const BUSY_CODES = new Set(["OPERATION_BUSY", "LOCK_UNVERIFIABLE", "SQLITE_BUSY"]); +const SEVERITIES = new Set(["info", "warning", "error", "fatal"]); +const WARNING_ERROR_CODES = new Set([ + "PROFILE_CHANGED", + "STORAGE_CHANGED", + "PLAN_STALE", + "PLAN_EXPIRED", + "STALE_STATE", + "SQLITE_BUSY", + "ROLLOUT_LOCKED", + "ROLLOUT_CHANGED", + "OPERATION_BUSY" +]); +const LOCK_SCOPES = new Set(["codex-home", "state-db"]); +const SAFE_REASONS = new Set([ + "profile", + "config", + "storage", + "rollout", + "state-db", + "windows-wsl-unc" +]); +const SQLITE_HOME_SOURCES = new Set(["cli", "config", "env", "default"]); +const OPERATION_KINDS = new Set(["sync", "switch", "restore", "prune-backups", "watch"]); +const PENDING_STATES = new Set([ + "prepared", + "applying", + "committing", + "committed", + "committed-pending-ack", + "rollback-pending", + "rollingBack", + "rolledBack", + "rolled-back", + "recoveryRequired", + "recovery-required" +]); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SAFE_CAUSE_CODES = new Set([ + "ENOENT", + "EACCES", + "EPERM", + "EIO", + "EBUSY", + "SQLITE_BUSY", + "SQLITE_LOCKED", + "SQLITE_CORRUPT", + "SQLITE_NOTADB", + "ERR_SQLITE_ERROR" +]); + +const CLI_ERROR_MESSAGES = Object.freeze({ + INVALID_INPUT: "The command input is invalid.", + PROFILE_CHANGED: "The selected profile changed. Prepare the operation again.", + STORAGE_CHANGED: "The resolved storage changed. Prepare the operation again.", + PLAN_STALE: "The prepared operation is stale. Prepare it again.", + PLAN_EXPIRED: "The prepared operation expired. Prepare it again.", + STALE_STATE: "The protected state changed. Prepare the operation again.", + CODEX_HOME_NOT_FOUND: "The selected Codex Home was not found.", + STATE_DB_NOT_FOUND: "The selected state database was not found.", + SQLITE_UNSUPPORTED_PATH: "The selected SQLite path is not supported by this runtime.", + SQLITE_BUSY: "The state database is busy. Close Codex processes and retry.", + SQLITE_UNREADABLE: "The state database is unreadable or malformed.", + ROLLOUT_LOCKED: "One or more rollout files are locked.", + ROLLOUT_CHANGED: "One or more rollout files changed during the operation.", + PENDING_TRANSACTION: "An unfinished transaction must be resolved before another write.", + BACKUP_FAILED: "The required backup could not be completed.", + SYNC_FAILED_ROLLED_BACK: "The operation failed and its changes were rolled back.", + RECOVERY_REQUIRED: "The operation requires explicit recovery.", + RESTORE_VALIDATION_FAILED: "The selected backup or restore target failed validation.", + PERMISSION_DENIED: "The operation does not have permission to access a required resource.", + OPERATION_BUSY: "Another write operation is using the protected resource.", + LOCK_UNVERIFIABLE: "The lock owner or protected resource identity cannot be verified.", + OPERATION_CANCELLED: "The operation was cancelled.", + CORE_RUNTIME_CRASHED: "The Core runtime stopped unexpectedly.", + PROTOCOL_VERSION_MISMATCH: "The client and Core protocol versions are incompatible.", + INTERNAL_ERROR: "An internal error occurred." +}); + +const WARNING_MESSAGES = Object.freeze({ + warnings: "The operation completed with a warning.", + encryptedContentWarning: "Existing encrypted content may not be usable with the target provider.", + autoPruneWarning: "Automatic backup cleanup did not complete.", + backupInventoryWarning: "Backup inventory refresh did not complete.", + modelWarning: "The selected provider has no default model; the root model was not changed." +}); + +function assertPlainJsonData(value, seen = new WeakSet(), depth = 0) { + if (depth > 16) throw new TypeError("CLI JSON data exceeds the maximum nesting depth."); + if (value === undefined || value === null + || typeof value === "string" || typeof value === "boolean") return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (typeof value !== "object") throw new TypeError("CLI JSON data must be plain JSON data."); + if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError("CLI JSON objects must use the plain object prototype."); + } + if (seen.has(value)) throw new TypeError("CLI JSON data must not contain cycles."); + seen.add(value); + for (const entry of Array.isArray(value) ? value : Object.values(value)) { + assertPlainJsonData(entry, seen, depth + 1); + } + seen.delete(value); +} + +function uniqueStrings(values) { + return [...new Set(values.filter((value) => typeof value === "string" && value))]; +} + +function safeString(value, maxLength = 4096, { allowEmpty = false } = {}) { + return typeof value === "string" + && value.length <= maxLength + && (allowEmpty || value.length > 0) + ? value + : undefined; +} + +function safeNumber(value, { integer = false, minimum = Number.NEGATIVE_INFINITY } = {}) { + return typeof value === "number" + && Number.isFinite(value) + && (!integer || Number.isInteger(value)) + && value >= minimum + ? value + : undefined; +} + +function safeBoolean(value) { + return typeof value === "boolean" ? value : undefined; +} + +function safeUuid(value) { + return typeof value === "string" && UUID_PATTERN.test(value) ? value : undefined; +} + +function put(target, key, value) { + if (value !== undefined) target[key] = value; +} + +function putNullable(target, key, value, normalize) { + if (value === null) target[key] = null; + else put(target, key, normalize(value)); +} + +function sanitizeStringArray(value, maxLength = 32768) { + if (!Array.isArray(value)) return undefined; + return value + .map((entry) => safeString(entry, maxLength, { allowEmpty: true })) + .filter((entry) => entry !== undefined); +} + +function sanitizeNumberArray(value) { + if (!Array.isArray(value)) return undefined; + return value + .map((entry) => safeNumber(entry, { integer: true, minimum: 0 })) + .filter((entry) => entry !== undefined); +} + +function sanitizeCountMap(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + for (const [key, count] of Object.entries(value)) { + if (!safeString(key, 256) || key.includes("\0")) continue; + const normalized = safeNumber(count, { integer: true, minimum: 0 }); + if (normalized !== undefined) result[key] = normalized; + } + return result; +} + +function sanitizeDistribution(value, { includeReadState = false } = {}) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + put(result, "sessions", sanitizeCountMap(value.sessions)); + put(result, "archived_sessions", sanitizeCountMap(value.archived_sessions)); + if (includeReadState) { + put(result, "unreadable", safeBoolean(value.unreadable)); + if (value.error) result.error = "state_5.sqlite is unavailable."; + } + return result; +} + +function sanitizePruneResult(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + put(result, "backupRoot", safeString(value.backupRoot, 32768)); + put(result, "deletedCount", safeNumber(value.deletedCount, { integer: true, minimum: 0 })); + put(result, "remainingCount", safeNumber(value.remainingCount, { integer: true, minimum: 0 })); + put(result, "freedBytes", safeNumber(value.freedBytes, { integer: true, minimum: 0 })); + return result; +} + +function sanitizeBackupInfo(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + put(result, "backupId", safeString(value.backupId, 256)); + put(result, "backupDir", safeString(value.backupDir, 32768)); + put(result, "createdAt", safeString(value.createdAt, 64)); + put(result, "sizeBytes", safeNumber(value.sizeBytes, { integer: true, minimum: 0 })); + put(result, "fileCount", safeNumber(value.fileCount, { integer: true, minimum: 0 })); + return result; +} + +function sanitizeModelSync(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + put(result, "applied", safeBoolean(value.applied)); + put(result, "source", safeString(value.source, 64)); + putNullable(result, "model", value.model, (entry) => safeString(entry, 512, { allowEmpty: true })); + putNullable( + result, + "warning", + value.warning, + () => WARNING_MESSAGES.modelWarning + ); + return result; +} + +function sanitizeSyncResult(value) { + const result = {}; + for (const key of ["codexHome", "sqliteHome", "backupDir"]) { + put(result, key, safeString(value[key], 32768)); + } + for (const key of ["sqliteHomeSource", "targetProvider", "previousProvider"]) { + putNullable(result, key, value[key], (entry) => safeString(entry, 512, { allowEmpty: true })); + } + for (const key of [ + "backupDurationMs", + "changedSessionFiles", + "sqliteRowsUpdated", + "sqliteProviderRowsUpdated", + "sqliteUserEventRowsUpdated", + "sqliteCwdRowsUpdated", + "updatedWorkspaceRoots", + "savedWorkspaceRootCount" + ]) { + put(result, key, safeNumber(value[key], { integer: true, minimum: 0 })); + } + put(result, "sqlitePresent", safeBoolean(value.sqlitePresent)); + put(result, "skippedLockedRolloutFiles", sanitizeStringArray(value.skippedLockedRolloutFiles)); + put(result, "rolloutCountsBefore", sanitizeDistribution(value.rolloutCountsBefore)); + put(result, "encryptedContentCounts", sanitizeDistribution(value.encryptedContentCounts)); + if (value.encryptedContentWarning) { + result.encryptedContentWarning = WARNING_MESSAGES.encryptedContentWarning; + } else if (value.encryptedContentWarning === null) { + result.encryptedContentWarning = null; + } + putNullable(result, "autoPruneResult", value.autoPruneResult, sanitizePruneResult); + if (value.autoPruneWarning) result.autoPruneWarning = WARNING_MESSAGES.autoPruneWarning; + else if (value.autoPruneWarning === null) result.autoPruneWarning = null; + put(result, "modelSync", sanitizeModelSync(value.modelSync)); + put(result, "noop", safeBoolean(value.noop)); + put(result, "operationId", safeUuid(value.operationId)); + put(result, "backup", sanitizeBackupInfo(value.backup)); + if (Array.isArray(value.warnings)) { + result.warnings = value.warnings.length > 0 ? [WARNING_MESSAGES.warnings] : []; + } + return result; +} + +function sanitizeStatusResult(value) { + const result = {}; + put(result, "schemaVersion", safeNumber(value.schemaVersion, { integer: true, minimum: 1 })); + put(result, "snapshotAt", safeString(value.snapshotAt, 64)); + put(result, "storageRevision", safeString(value.storageRevision, 256)); + if (value.profile && typeof value.profile === "object" && !Array.isArray(value.profile)) { + const profile = {}; + put(profile, "id", safeString(value.profile.id, 80)); + put(profile, "revision", safeString(value.profile.revision, 256)); + result.profile = profile; + } + put(result, "codexHome", safeString(value.codexHome, 32768)); + put(result, "sqliteHome", safeString(value.sqliteHome, 32768)); + put(result, "sqliteHomeSource", SQLITE_HOME_SOURCES.has(value.sqliteHomeSource) + ? value.sqliteHomeSource + : undefined); + if (value.sqliteAccess && typeof value.sqliteAccess === "object") { + const sqliteAccess = {}; + put(sqliteAccess, "supported", safeBoolean(value.sqliteAccess.supported)); + putNullable(sqliteAccess, "reason", value.sqliteAccess.reason, (entry) => ( + SAFE_REASONS.has(entry) ? entry : undefined + )); + result.sqliteAccess = sqliteAccess; + } + put(result, "checkedStateDbPaths", sanitizeStringArray(value.checkedStateDbPaths)); + put(result, "currentProvider", safeString(value.currentProvider, 512)); + put(result, "currentProviderImplicit", safeBoolean(value.currentProviderImplicit)); + put(result, "configuredProviders", sanitizeStringArray(value.configuredProviders, 512)); + put(result, "rolloutCounts", sanitizeDistribution(value.rolloutCounts)); + put(result, "lockedRolloutFiles", sanitizeStringArray(value.lockedRolloutFiles)); + put(result, "encryptedContentCounts", sanitizeDistribution(value.encryptedContentCounts)); + if (value.encryptedContentWarning) { + result.encryptedContentWarning = WARNING_MESSAGES.encryptedContentWarning; + } else if (value.encryptedContentWarning === null) { + result.encryptedContentWarning = null; + } + putNullable(result, "sqliteCounts", value.sqliteCounts, (entry) => ( + sanitizeDistribution(entry, { includeReadState: true }) + )); + if (value.stateDbLocation && typeof value.stateDbLocation === "object") { + const location = {}; + put(location, "path", safeString(value.stateDbLocation.path, 32768)); + put(location, "relativePath", safeString(value.stateDbLocation.relativePath, 32768)); + put(location, "source", safeString(value.stateDbLocation.source, 64)); + result.stateDbLocation = location; + } else if (value.stateDbLocation === null) { + result.stateDbLocation = null; + } + if (value.sqliteRepairStats && typeof value.sqliteRepairStats === "object") { + const stats = {}; + put(stats, "userEventRowsNeedingRepair", safeNumber( + value.sqliteRepairStats.userEventRowsNeedingRepair, + { integer: true, minimum: 0 } + )); + put(stats, "cwdRowsNeedingRepair", safeNumber( + value.sqliteRepairStats.cwdRowsNeedingRepair, + { integer: true, minimum: 0 } + )); + result.sqliteRepairStats = stats; + } else if (value.sqliteRepairStats === null) { + result.sqliteRepairStats = null; + } + if (Array.isArray(value.projectThreadVisibility)) { + result.projectThreadVisibility = value.projectThreadVisibility.map((project) => { + const item = {}; + if (!project || typeof project !== "object" || Array.isArray(project)) return item; + put(item, "root", safeString(project.root, 32768)); + for (const key of [ + "interactiveThreads", + "firstPageThreads", + "exactCwdMatches", + "verbatimCwdRows", + "topRank" + ]) { + putNullable(item, key, project[key], (entry) => ( + safeNumber(entry, { integer: true, minimum: 0 }) + )); + } + put(item, "ranks", sanitizeNumberArray(project.ranks)); + put(item, "rankPreview", safeString(project.rankPreview, 2048, { allowEmpty: true })); + put(item, "providerCounts", sanitizeCountMap(project.providerCounts)); + return item; + }); + } + put(result, "projectThreadVisibilityAvailable", safeBoolean(value.projectThreadVisibilityAvailable)); + put(result, "backupRoot", safeString(value.backupRoot, 32768)); + if (value.backupSummary && typeof value.backupSummary === "object") { + const summary = {}; + put(summary, "count", safeNumber(value.backupSummary.count, { integer: true, minimum: 0 })); + put(summary, "totalBytes", safeNumber(value.backupSummary.totalBytes, { integer: true, minimum: 0 })); + result.backupSummary = summary; + } + if (Array.isArray(value.pendingTransactions)) { + result.pendingTransactions = value.pendingTransactions.map((transaction) => { + const item = {}; + if (!transaction || typeof transaction !== "object" || Array.isArray(transaction)) return item; + putNullable(item, "operationId", transaction.operationId, safeUuid); + put(item, "state", PENDING_STATES.has(transaction.state) ? transaction.state : undefined); + put(item, "backupDir", safeString(transaction.backupDir, 32768)); + put(item, "journalPath", safeString(transaction.journalPath, 32768)); + return item; + }); + } + put(result, "pendingRecovery", safeBoolean(value.pendingRecovery)); + if (value.operationInProgress && typeof value.operationInProgress === "object" + && !Array.isArray(value.operationInProgress)) { + const operation = {}; + put(operation, "operationId", safeUuid(value.operationInProgress.operationId)); + put(operation, "operation", OPERATION_KINDS.has(value.operationInProgress.operation) + ? value.operationInProgress.operation + : undefined); + put(operation, "actor", new Set(["manual", "watch"]).has(value.operationInProgress.actor) + ? value.operationInProgress.actor + : undefined); + put(operation, "startedAt", safeString(value.operationInProgress.startedAt, 64)); + result.operationInProgress = operation; + } else if (value.operationInProgress === null) { + result.operationInProgress = null; + } + put(result, "rolloutScanComplete", safeBoolean(value.rolloutScanComplete)); + return result; +} + +function sanitizeBooleanMap(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result = {}; + for (const [key, present] of Object.entries(value)) { + const normalizedKey = safeString(key, 128); + const normalizedValue = safeBoolean(present); + if (normalizedKey && normalizedValue !== undefined) result[normalizedKey] = normalizedValue; + } + return result; +} + +function sanitizeRestoreResult(value) { + const result = {}; + put(result, "version", safeNumber(value.version, { integer: true, minimum: 1 })); + put(result, "namespace", value.namespace === "provider-sync" ? value.namespace : undefined); + for (const key of ["codexHome", "sqliteHome", "backupDir"]) { + put(result, key, safeString(value[key], 32768)); + } + put(result, "targetProvider", safeString(value.targetProvider, 512)); + put(result, "createdAt", safeString(value.createdAt, 64)); + put(result, "dbFiles", sanitizeStringArray(value.dbFiles)); + put(result, "sqliteDbFiles", sanitizeStringArray(value.sqliteDbFiles)); + put(result, "globalStateFiles", sanitizeBooleanMap(value.globalStateFiles)); + put(result, "changedSessionFiles", safeNumber(value.changedSessionFiles, { integer: true, minimum: 0 })); + put(result, "sizeBytes", safeNumber(value.sizeBytes, { integer: true, minimum: 0 })); + put(result, "fileCount", safeNumber(value.fileCount, { integer: true, minimum: 0 })); + if (value.backupInventoryWarning) { + result.backupInventoryWarning = WARNING_MESSAGES.backupInventoryWarning; + } + put(result, "operationId", safeUuid(value.operationId)); + put(result, "backup", sanitizeBackupInfo(value.backup)); + if (Array.isArray(value.warnings)) { + result.warnings = value.warnings.length > 0 ? [WARNING_MESSAGES.warnings] : []; + } + return result; +} + +function sanitizeLauncherResult(value) { + const result = {}; + for (const key of ["targetDir", "cmdPath", "vbsPath"]) { + put(result, key, safeString(value[key], 32768)); + } + putNullable(result, "codexHome", value.codexHome, (entry) => safeString(entry, 32768)); + putNullable(result, "sqliteHome", value.sqliteHome, (entry) => safeString(entry, 32768)); + return result; +} + +function sanitizeCommandResult(command, value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + if (command === "help") { + const result = {}; + put(result, "text", safeString(value.text, 65536, { allowEmpty: true })); + putNullable(result, "requestedCommand", value.requestedCommand, (entry) => safeString(entry, 128)); + return result; + } + if (command === "status") return sanitizeStatusResult(value); + if (command === "sync" || command === "switch") return sanitizeSyncResult(value); + if (command === "restore") return sanitizeRestoreResult(value); + if (command === "prune-backups") return sanitizePruneResult(value) ?? {}; + if (command === "install-windows-launcher") return sanitizeLauncherResult(value); + throw new TypeError(`Unsupported CLI JSON command result: ${String(command)}`); +} + +export function collectCliWarnings(result) { + if (!result || typeof result !== "object") return []; + return uniqueStrings([ + ...(Array.isArray(result.warnings) && result.warnings.length > 0 + ? [WARNING_MESSAGES.warnings] + : []), + result.encryptedContentWarning ? WARNING_MESSAGES.encryptedContentWarning : null, + result.autoPruneWarning ? WARNING_MESSAGES.autoPruneWarning : null, + result.backupInventoryWarning ? WARNING_MESSAGES.backupInventoryWarning : null, + result.modelSync?.warning ? WARNING_MESSAGES.modelWarning : null + ]); +} + +export function inferCliSuccessOutcome(result) { + if (SUCCESS_OUTCOMES.has(result?.outcome)) return result.outcome; + if (Array.isArray(result?.skippedLockedRolloutFiles) + && result.skippedLockedRolloutFiles.length > 0) return "partial"; + if (result?.noop === true) return "noop"; + return "completed"; +} + +export function createCliSuccessEnvelope(command, result, options = {}) { + assertPlainJsonData(result); + const outcome = options.outcome ?? inferCliSuccessOutcome(result); + if (!SUCCESS_OUTCOMES.has(outcome)) { + throw new TypeError(`Invalid successful CLI outcome: ${String(outcome)}`); + } + const warnings = options.warnings === undefined + ? collectCliWarnings(result) + : (Array.isArray(options.warnings) && options.warnings.length > 0 + ? [WARNING_MESSAGES.warnings] + : []); + return { + schemaVersion: CLI_JSON_SCHEMA_VERSION, + command, + ok: true, + outcome, + result: sanitizeCommandResult(command, result ?? {}), + warnings, + error: null + }; +} + +function internalErrorDto() { + return { + code: "INTERNAL_ERROR", + message: CLI_ERROR_MESSAGES.INTERNAL_ERROR, + severity: "fatal", + retryable: false, + recoveryRequired: false + }; +} + +function canonicalErrorSeverity(code) { + if (code === "OPERATION_CANCELLED") return "info"; + if (code === "CORE_RUNTIME_CRASHED") return "fatal"; + if (WARNING_ERROR_CODES.has(code)) return "warning"; + return "error"; +} + +function normalizePublicDetails(details) { + if (!details || typeof details !== "object" || Array.isArray(details)) return undefined; + const normalized = {}; + if (LOCK_SCOPES.has(details.busyScope)) normalized.busyScope = details.busyScope; + if (LOCK_SCOPES.has(details.lockScope)) normalized.lockScope = details.lockScope; + if (SAFE_CAUSE_CODES.has(details.causeCode)) { + normalized.causeCode = details.causeCode; + } + if (SAFE_REASONS.has(details.reason)) normalized.reason = details.reason; + if (details.missing === "config.toml" || details.missing === "state_5.sqlite") { + normalized.missing = details.missing; + } + if (SQLITE_HOME_SOURCES.has(details.sqliteHomeSource)) { + normalized.sqliteHomeSource = details.sqliteHomeSource; + } + for (const key of ["sqlitePrimaryCode", "sqliteExtendedCode"]) { + const code = safeNumber(details[key], { integer: true, minimum: 0 }); + if (code !== undefined && code <= 0xffff) normalized[key] = code; + } + if (OPERATION_KINDS.has(details.operationKind)) normalized.operationKind = details.operationKind; + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function normalizeCliErrorDto(dto) { + try { + assertPlainJsonData(dto); + if (!dto || typeof dto !== "object" || Array.isArray(dto)) return internalErrorDto(); + const code = dto.code; + const message = typeof code === "string" && Object.hasOwn(CLI_ERROR_MESSAGES, code) + ? CLI_ERROR_MESSAGES[code] + : undefined; + if (typeof message !== "string" + || typeof dto.message !== "string" + || !dto.message + || !SEVERITIES.has(dto.severity) + || typeof dto.retryable !== "boolean" + || typeof dto.recoveryRequired !== "boolean") { + return internalErrorDto(); + } + if (code === "INTERNAL_ERROR") return internalErrorDto(); + const details = normalizePublicDetails(dto.details); + const operationId = safeUuid(dto.operationId); + return { + code, + message, + severity: canonicalErrorSeverity(code), + retryable: true, + recoveryRequired: RECOVERY_CODES.has(code), + ...(operationId ? { operationId } : {}), + ...(details ? { details } : {}) + }; + } catch { + return internalErrorDto(); + } +} + +export function inferCliFailureOutcome(errorDto) { + const code = errorDto?.code; + if (code === "OPERATION_CANCELLED") return "cancelled"; + if (RECOVERY_CODES.has(code) || errorDto?.recoveryRequired === true) return "recovery_required"; + if (code === "SYNC_FAILED_ROLLED_BACK") return "failed_rolled_back"; + if (["PLAN_EXPIRED", "PLAN_STALE", "STALE_STATE", "PROFILE_CHANGED", "STORAGE_CHANGED"].includes(code)) { + return "stale"; + } + return "failed"; +} + +export function createCliFailureEnvelope(command, dto) { + const error = normalizeCliErrorDto(dto); + const outcome = inferCliFailureOutcome(error); + if (!FAILURE_OUTCOMES.has(outcome)) throw new TypeError("Invalid CLI failure outcome."); + return { + schemaVersion: CLI_JSON_SCHEMA_VERSION, + command, + ok: false, + outcome, + result: null, + warnings: [], + error + }; +} + +export function cliJsonExitCode(envelope) { + if (envelope?.ok) return envelope.outcome === "partial" ? 3 : 0; + const code = envelope?.error?.code; + if (code === "OPERATION_CANCELLED") return 130; + if (RECOVERY_CODES.has(code) || envelope?.error?.recoveryRequired === true) return 4; + if (BUSY_CODES.has(code)) return 5; + if (STALE_CODES.has(code)) return 2; + return 1; +} diff --git a/src/cli-presenter.js b/src/cli-presenter.js new file mode 100644 index 0000000..b5fca42 --- /dev/null +++ b/src/cli-presenter.js @@ -0,0 +1,95 @@ +// Human-readable CLI presentation is deliberately outside the Core public +// API. These helpers have no storage or mutation authority. + +export function formatCounts(counts) { + return Object.entries(counts ?? {}) + .map(([provider, count]) => `${provider}: ${count}`) + .join(", ") || "(none)"; +} + +export 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]}`; +} + +export function renderStatus(status) { + const lines = [ + `Codex home: ${status.codexHome}`, + `SQLite home: ${status.sqliteHome} (source: ${status.sqliteHomeSource})`, + `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}` + ]; + + if (status.pendingTransactions?.length) { + lines.push(""); + lines.push("Recovery required:"); + for (const transaction of status.pendingTransactions) { + lines.push(` ${transaction.state}: ${transaction.backupDir}`); + } + lines.push(" Run restore with the listed backup before the next write operation."); + } + + 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.sqliteAccess?.supported) { + lines.push(` ${status.sqliteAccess.message}`); + return lines.join("\n"); + } + 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 ${status.checkedStateDbPaths.join(", ")})`); + } + 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"); +} diff --git a/src/cli.js b/src/cli.js index 271b28d..b93a61d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,28 +1,39 @@ #!/usr/bin/env node +import fs from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + cliJsonExitCode, + createCliFailureEnvelope, + createCliSuccessEnvelope +} from "./cli-json.js"; import { DEFAULT_BACKUP_RETENTION_COUNT } from "./constants.js"; +import { formatBytes, renderStatus } from "./cli-presenter.js"; import { installWindowsLauncher } from "./launcher.js"; import { assertSupportedNodeVersion } from "./node-version.js"; -async function loadService() { +async function loadCore() { assertSupportedNodeVersion(); - return import("./service.js"); + return import("./public-api.js"); } -function printHelp() { - console.log(`codex-provider +const HELP_TEXT = `codex-provider Usage: - codex-provider status [--codex-home PATH] [--sqlite-home PATH] - codex-provider sync [--provider ID] [--keep N] [--codex-home PATH] [--sqlite-home PATH] - codex-provider switch [--model NAME] [--keep-root-model] [--keep N] [--codex-home PATH] [--sqlite-home PATH] + codex-provider status [--json] [--codex-home PATH] [--sqlite-home PATH] + codex-provider sync [--json] [--provider ID] [--keep N] [--codex-home PATH] [--sqlite-home PATH] + codex-provider switch [--json] [--model NAME] [--keep-root-model] [--keep N] [--codex-home PATH] [--sqlite-home PATH] codex-provider watch [--codex-home PATH] [--sqlite-home PATH] [--debounce-ms N] [--once] [--no-state-db] codex-provider web [--port N] [--no-open] [--reset-access] [--codex-home PATH] [--sqlite-home PATH] - codex-provider prune-backups [--keep N] [--codex-home PATH] - codex-provider restore [--no-config] [--no-db] [--no-sessions] [--allow-sqlite-home-relocation] [--codex-home PATH] [--sqlite-home PATH] - codex-provider install-windows-launcher [--dir PATH] [--codex-home PATH] [--sqlite-home PATH] + codex-provider prune-backups [--json] [--keep N] [--codex-home PATH] + codex-provider restore [--json] [--no-config] [--no-db] [--no-sessions] [--allow-sqlite-home-relocation] [--codex-home PATH] [--sqlite-home PATH] + codex-provider install-windows-launcher [--json] [--dir PATH] [--codex-home PATH] [--sqlite-home PATH] + +JSON mode: + --json emit one schemaVersion 1 envelope on stdout; progress goes to stderr + (not supported by long-running watch or web commands) switch flags: --model NAME override root-level model field with NAME (e.g. "MiniMax-M3") @@ -41,12 +52,16 @@ web flags: --reset-access invalidate all paired browsers before creating a new pairing --codex-home PATH set the default server-managed storage profile --sqlite-home PATH set the default profile SQLite Home override -`); +`; + +function printHelp(writeLine) { + writeLine(HELP_TEXT); } function parseArgs(argv) { const positionals = []; - const flags = {}; + const flags = Object.create(null); + const flagCounts = Object.create(null); for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; @@ -54,12 +69,19 @@ function parseArgs(argv) { positionals.push(value); continue; } - const [flagName, inlineValue] = value.split("=", 2); + const separatorIndex = value.indexOf("="); + const flagName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value; + const inlineValue = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : undefined; const normalizedName = flagName.slice(2); + flagCounts[normalizedName] = (flagCounts[normalizedName] ?? 0) + 1; if (inlineValue !== undefined) { flags[normalizedName] = inlineValue; continue; } + if (normalizedName === "json") { + flags[normalizedName] = true; + continue; + } const nextValue = argv[index + 1]; if (nextValue && !nextValue.startsWith("--")) { flags[normalizedName] = nextValue; @@ -69,7 +91,120 @@ function parseArgs(argv) { } } - return { positionals, flags }; + return { positionals, flags, flagCounts }; +} + +const JSON_COMMAND_CONTRACTS = Object.freeze({ + status: { + flags: ["json", "help", "codex-home", "sqlite-home"], + valueFlags: ["codex-home", "sqlite-home"], + booleanFlags: ["json", "help"], + positionalCount: 1 + }, + sync: { + flags: ["json", "help", "provider", "keep", "codex-home", "sqlite-home"], + valueFlags: ["provider", "keep", "codex-home", "sqlite-home"], + booleanFlags: ["json", "help"], + positionalCount: 1 + }, + switch: { + flags: ["json", "help", "model", "keep-root-model", "keep", "codex-home", "sqlite-home"], + valueFlags: ["model", "keep", "codex-home", "sqlite-home"], + booleanFlags: ["json", "help", "keep-root-model"], + positionalCount: 2 + }, + "prune-backups": { + flags: ["json", "help", "keep", "codex-home"], + valueFlags: ["keep", "codex-home"], + booleanFlags: ["json", "help"], + positionalCount: 1 + }, + restore: { + flags: [ + "json", + "help", + "no-config", + "no-db", + "no-sessions", + "allow-sqlite-home-relocation", + "codex-home", + "sqlite-home" + ], + valueFlags: ["codex-home", "sqlite-home"], + booleanFlags: [ + "json", + "help", + "no-config", + "no-db", + "no-sessions", + "allow-sqlite-home-relocation" + ], + positionalCount: 2 + }, + "install-windows-launcher": { + flags: ["json", "help", "dir", "codex-home", "sqlite-home"], + valueFlags: ["dir", "codex-home", "sqlite-home"], + booleanFlags: ["json", "help"], + positionalCount: 1 + } +}); + +const JSON_KNOWN_COMMANDS = new Set([ + ...Object.keys(JSON_COMMAND_CONTRACTS), + "watch", + "web", + "help" +]); + +function invalidInputError(message) { + const error = new Error(message); + error.code = "INVALID_INPUT"; + return error; +} + +function validateJsonFlag(flags) { + if (Object.hasOwn(flags, "json") && flags.json !== true) { + throw invalidInputError("--json is a standalone boolean flag and does not accept a value."); + } +} + +function validateJsonCommandArgs(command, parsed) { + const contract = Object.hasOwn(JSON_COMMAND_CONTRACTS, command) + ? JSON_COMMAND_CONTRACTS[command] + : undefined; + if (!contract) { + if (command === "watch" || command === "web") { + throw invalidInputError( + `${command} does not support --json because it is a long-running command. Use Human mode.` + ); + } + throw invalidInputError(`Unknown command: ${command}`); + } + + const allowedFlags = new Set(contract.flags); + const valueFlags = new Set(contract.valueFlags); + const booleanFlags = new Set(contract.booleanFlags); + for (const [name, count] of Object.entries(parsed.flagCounts)) { + if (!allowedFlags.has(name)) { + throw invalidInputError(`Unknown option for ${command}: --${name}`); + } + if (count !== 1) { + throw invalidInputError(`Option --${name} may only be specified once in JSON mode.`); + } + if (valueFlags.has(name) + && (parsed.flags[name] === true || String(parsed.flags[name]).length === 0)) { + throw invalidInputError(`Option --${name} requires a value.`); + } + if (booleanFlags.has(name) && parsed.flags[name] !== true) { + throw invalidInputError(`Option --${name} is a boolean flag and does not accept a value.`); + } + } + if (parsed.positionals.length !== contract.positionalCount) { + const expected = contract.positionalCount - 1; + throw invalidInputError( + `${command} expects exactly ${expected} positional argument${expected === 1 ? "" : "s"}.` + ); + } } function summarizeSync(result, label) { @@ -120,17 +255,6 @@ function summarizePrune(result) { ].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`; @@ -159,22 +283,26 @@ const SYNC_PROGRESS_STAGE_INDEX = new Map( SYNC_PROGRESS_STAGES.map(([stage], index) => [stage, index + 1]) ); -function createSyncProgressReporter() { +function createSyncProgressReporter(writeLine, { includeBackupPath = true } = {}) { return (event) => { if (event?.stage === "update_config" && event.status === "start") { - console.log(`Updating config.toml root model_provider to ${event.provider}...`); + writeLine(includeBackupPath + ? `Updating config.toml root model_provider to ${event.provider}...` + : "Updating config.toml root model_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}`); + writeLine(includeBackupPath + ? ` Backup created in ${formatDuration(event.durationMs)}: ${event.backupDir}` + : ` Backup created in ${formatDuration(event.durationMs)}`); } return; } - console.log(`[${stageIndex}/${SYNC_PROGRESS_STAGES.length}] ${SYNC_PROGRESS_STAGES[stageIndex - 1][1]}`); + writeLine(`[${stageIndex}/${SYNC_PROGRESS_STAGES.length}] ${SYNC_PROGRESS_STAGES[stageIndex - 1][1]}`); }; } @@ -185,43 +313,154 @@ function parseKeepCount(rawValue, { allowZero = false } = {}) { 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}.`); + throw invalidInputError(`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}.`); + throw invalidInputError(`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]; +function createLineWriter(stream) { + return (value) => stream.write(`${String(value)}\n`); +} + +const BEST_EFFORT_STREAMS = new WeakSet(); + +function createBestEffortLineWriter(stream) { + if (stream && typeof stream === "object" + && typeof stream.on === "function" + && !BEST_EFFORT_STREAMS.has(stream)) { + stream.on("error", () => {}); + BEST_EFFORT_STREAMS.add(stream); + } + return (value) => { + try { + stream.write(`${String(value)}\n`, () => {}); + } catch { + // Progress and diagnostics are observer output and cannot change the operation result. + } + }; +} - if (!command || command === "help" || flags.help) { - printHelp(); - return; +function writeJsonDocument(stream, serialized) { + const document = `${serialized}\n`; + if (typeof stream.once !== "function" + || typeof stream.removeListener !== "function" + || stream.write.length < 2) { + stream.write(document); + return Promise.resolve(); } - assertSupportedNodeVersion(); + return new Promise((resolve, reject) => { + let settled = false; + const removeErrorListener = () => stream.removeListener("error", onError); + const finish = (error) => { + if (settled) return; + settled = true; + setImmediate(removeErrorListener); + if (error) reject(error); + else resolve(); + }; + const onError = (error) => finish(error); + stream.once("error", onError); + try { + stream.write(document, (error) => finish(error)); + } catch (error) { + finish(error); + } + }); +} + +function validateJsonHelpArgs(parsed) { + if (parsed.positionals.length > 1 + || (parsed.positionals[0] && !JSON_KNOWN_COMMANDS.has(parsed.positionals[0]))) { + throw invalidInputError("Unknown command in JSON help request."); + } + for (const [name, count] of Object.entries(parsed.flagCounts)) { + if (!new Set(["json", "help"]).has(name)) { + throw invalidInputError(`Unknown option in JSON help request: --${name}`); + } + if (count !== 1 || parsed.flags[name] !== true) { + throw invalidInputError(`Option --${name} must be a standalone flag specified once.`); + } + } +} + +function fallbackCliErrorDto(error) { + if (error?.code === "INVALID_INPUT") { + return { + code: "INVALID_INPUT", + message: error instanceof Error ? error.message : "Invalid CLI input.", + severity: "error", + retryable: true, + recoveryRequired: false + }; + } + if (error?.name === "AbortError" && error?.code === "ABORT_ERR") { + return { + code: "OPERATION_CANCELLED", + message: "The operation was cancelled.", + severity: "info", + retryable: true, + recoveryRequired: false + }; + } + return { + code: "INTERNAL_ERROR", + message: "An internal error occurred.", + severity: "fatal", + retryable: false, + recoveryRequired: false + }; +} + +async function cliErrorDto(error, loadCoreImpl) { + if (error?.code === "INVALID_INPUT" + || (error?.name === "AbortError" && error?.code === "ABORT_ERR")) { + return fallbackCliErrorDto(error); + } + try { + const core = await loadCoreImpl(); + if (typeof core.toCoreErrorDto !== "function") { + return fallbackCliErrorDto(error); + } + return core.toCoreErrorDto(error); + } catch { + return fallbackCliErrorDto(error); + } +} + +async function executeCommand({ positionals, flags }, context) { + const { + command, + jsonMode, + stdoutLine, + stderrLine, + environment, + signalTarget, + loadCoreImpl, + startWebUiImpl, + installWindowsLauncherImpl + } = context; if (command === "status") { - const { getStatus, renderStatus } = await loadService(); + const { getStatus } = await loadCoreImpl(); const status = await getStatus({ codexHome: flags["codex-home"], sqliteHome: flags["sqlite-home"] }); - console.log(renderStatus(status)); - return; + if (!jsonMode) stdoutLine(renderStatus(status)); + return status; } if (command === "sync") { - const { runSync } = await loadService(); + const { runSync, readConfigText, readRootModelFromConfigText } = await loadCoreImpl(); const { defaultCodexHome } = await import("./constants.js"); - const { readConfigText, readRootModelFromConfigText } = await import("./config-file.js"); const codexHome = path.resolve( - flags["codex-home"] ?? process.env.CODEX_HOME ?? defaultCodexHome() + flags["codex-home"] ?? environment.CODEX_HOME ?? defaultCodexHome() ); const configPath = path.join(codexHome, "config.toml"); let rootModel = null; @@ -229,23 +468,24 @@ async function main() { const cfg = await readConfigText(configPath); rootModel = readRootModelFromConfigText(cfg); } catch { - // config may be missing in degraded scenarios; carry on without a - // model rewrite so the rest of the sync still runs. + // Degraded compatibility path: continue without a per-thread model rewrite. } const result = await runSync({ codexHome: flags["codex-home"], sqliteHome: flags["sqlite-home"], provider: flags.provider, keepCount: parseKeepCount(flags.keep), - onProgress: createSyncProgressReporter(), + onProgress: createSyncProgressReporter(jsonMode ? stderrLine : stdoutLine, { + includeBackupPath: !jsonMode + }), model: rootModel }); - console.log(summarizeSync(result, "Synchronized")); - return; + if (!jsonMode) stdoutLine(summarizeSync(result, "Synchronized")); + return result; } if (command === "switch") { - const { runSwitch } = await loadService(); + const { runSwitch } = await loadCoreImpl(); const provider = positionals[1] ?? flags.provider; const result = await runSwitch({ codexHome: flags["codex-home"], @@ -254,34 +494,38 @@ async function main() { model: flags.model, keepRootModel: Boolean(flags["keep-root-model"]), keepCount: parseKeepCount(flags.keep), - onProgress: createSyncProgressReporter() + onProgress: createSyncProgressReporter(jsonMode ? stderrLine : stdoutLine, { + includeBackupPath: !jsonMode + }) }); - console.log(summarizeSync(result, "Switched to")); - if (result.modelSync) { - const { applied, source, model, warning } = result.modelSync; - if (applied) { - console.log(`Root-level model: ${model} (source: ${source})`); - } else if (warning) { - console.log(`Root-level model: unchanged (${warning})`); - } else { - console.log("Root-level model: unchanged (keep-root-model flag set)"); + if (!jsonMode) { + stdoutLine(summarizeSync(result, "Switched to")); + if (result.modelSync) { + const { applied, source, model, warning } = result.modelSync; + if (applied) { + stdoutLine(`Root-level model: ${model} (source: ${source})`); + } else if (warning) { + stdoutLine(`Root-level model: unchanged (${warning})`); + } else { + stdoutLine("Root-level model: unchanged (keep-root-model flag set)"); + } } } - return; + return result; } if (command === "prune-backups") { - const { runPruneBackups } = await loadService(); + const { runPruneBackups } = await loadCoreImpl(); const result = await runPruneBackups({ codexHome: flags["codex-home"], keepCount: parseKeepCount(flags.keep, { allowZero: true }) }); - console.log(summarizePrune(result)); - return; + if (!jsonMode) stdoutLine(summarizePrune(result)); + return result; } if (command === "watch") { - const { runWatch } = await import("./watch.js"); + const { runWatch } = await loadCoreImpl(); const debounceMs = flags["debounce-ms"] !== undefined ? parseKeepCount(flags["debounce-ms"], { allowZero: true }) : undefined; @@ -292,24 +536,15 @@ async function main() { includeStateDb: !flags["no-state-db"], once: Boolean(flags.once) }); - // Race the watcher's own `done` promise (which resolves when - // `--once` completes or the consecutive-failure auto-shutdown - // fires) against the external SIGINT/SIGTERM handler. Whichever - // wins, we stop the watcher cleanly and let the process exit. - // Without this race, the CLI sits in the event loop forever - // after a `--once` run, because Node only exits on its own - // when there are no more pending handles. await new Promise((resolve) => { let settled = false; const finish = async (source) => { - if (settled) { - return; - } + if (settled) return; settled = true; try { await handle.stop(); } catch { - // best effort: stop() may already be in flight + // Best effort: stop() may already be in flight. } resolve(source); }; @@ -317,14 +552,14 @@ async function main() { if (handle.signalPromise) { handle.signalPromise.then(() => finish("signal"), () => finish("signal-rejected")); } - process.once("SIGINT", () => finish("SIGINT")); - process.once("SIGTERM", () => finish("SIGTERM")); + signalTarget.once("SIGINT", () => finish("SIGINT")); + signalTarget.once("SIGTERM", () => finish("SIGTERM")); }); - return; + return {}; } if (command === "web") { - const { startWebUi } = await import("./web-server.js"); + const startWebUi = startWebUiImpl ?? (await import("./web-server.js")).startWebUi; const port = flags.port === undefined ? 8791 : parseKeepCount(flags.port, { allowZero: true }); const handle = await startWebUi({ port, @@ -333,33 +568,31 @@ async function main() { codexHome: flags["codex-home"], sqliteHome: flags["sqlite-home"] }); - console.log(`Codex Provider Sync Web UI: ${handle.url}`); + stdoutLine(`Codex Provider Sync Web UI: ${handle.url}`); if (flags["no-open"] || !handle.browserOpened) { - console.log(`One-time pairing link: ${handle.pairingUrl}`); + stdoutLine(`One-time pairing link: ${handle.pairingUrl}`); } if (handle.reused) { - console.log("Opened the existing Codex Provider Sync Web UI instance."); - return; + stdoutLine("Opened the existing Codex Provider Sync Web UI instance."); + return {}; } - console.log("The server only listens on 127.0.0.1. Press Ctrl+C to stop it."); + stdoutLine("The server only listens on 127.0.0.1. Press Ctrl+C to stop it."); await new Promise((resolve) => { let closing = false; const close = async () => { - if (closing) { - return; - } + if (closing) return; closing = true; await handle.close().catch(() => {}); resolve(); }; - process.once("SIGINT", close); - process.once("SIGTERM", close); + signalTarget.once("SIGINT", close); + signalTarget.once("SIGTERM", close); }); - return; + return {}; } if (command === "restore") { - const { runRestore } = await loadService(); + const { runRestore } = await loadCoreImpl(); const backupDir = positionals[1] ?? flags.backup; const result = await runRestore({ codexHome: flags["codex-home"], @@ -370,42 +603,141 @@ async function main() { restoreSessions: !flags["no-sessions"], allowSqliteHomeRelocation: Boolean(flags["allow-sqlite-home-relocation"]) }); - console.log(`Restored backup from ${path.resolve(backupDir)}`); - console.log(`Codex home: ${result.codexHome}`); - console.log(`Provider at backup time: ${result.targetProvider}`); - if (result.backupInventoryWarning) { - console.log(`Backup inventory warning: ${result.backupInventoryWarning}`); + const jsonResult = { ...result, backupDir: path.resolve(backupDir) }; + if (!jsonMode) { + stdoutLine(`Restored backup from ${path.resolve(backupDir)}`); + stdoutLine(`Codex home: ${result.codexHome}`); + stdoutLine(`Provider at backup time: ${result.targetProvider}`); + if (result.backupInventoryWarning) { + stdoutLine(`Backup inventory warning: ${result.backupInventoryWarning}`); + } } - return; + return jsonResult; } if (command === "install-windows-launcher") { - const result = await installWindowsLauncher({ + const result = await installWindowsLauncherImpl({ dir: flags.dir, codexHome: flags["codex-home"], sqliteHome: flags["sqlite-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"); + if (!jsonMode) { + stdoutLine("Installed Windows launcher files:"); + stdoutLine(` Hidden double-click launcher: ${result.vbsPath}`); + stdoutLine(` Visible console launcher: ${result.cmdPath}`); + stdoutLine(` Target directory: ${result.targetDir}`); + if (result.codexHome) { + stdoutLine(` Fixed CODEX_HOME: ${result.codexHome}`); + } else { + stdoutLine(" CODEX_HOME: default current environment / ~/.codex"); + } + if (result.sqliteHome) { + stdoutLine(` Fixed SQLite home: ${result.sqliteHome}`); + } else { + stdoutLine(" SQLite home: config / environment / Codex default"); + } } - if (result.sqliteHome) { - console.log(` Fixed SQLite home: ${result.sqliteHome}`); - } else { - console.log(" SQLite home: config / environment / Codex default"); + return result; + } + + throw invalidInputError(`Unknown command: ${command}`); +} + +export async function runCli(argv, options = {}) { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const parsed = parseArgs(argv); + const jsonMode = Object.hasOwn(parsed.flags, "json"); + const stdoutLine = createLineWriter(stdout); + const stderrLine = jsonMode + ? createBestEffortLineWriter(stderr) + : createLineWriter(stderr); + const requestedCommand = parsed.positionals[0]; + let command = requestedCommand ?? "help"; + if (jsonMode && !JSON_KNOWN_COMMANDS.has(command)) command = "unknown"; + let terminalWritten = false; + const loadCoreImpl = options.loadCoreImpl ?? loadCore; + + const writeEnvelope = async (envelope) => { + const serialized = JSON.stringify(envelope); + if (terminalWritten) { + throw new Error("CLI JSON terminal envelope was already written."); + } + terminalWritten = true; + await writeJsonDocument(stdout, serialized); + }; + + try { + validateJsonFlag(parsed.flags); + if (!requestedCommand || requestedCommand === "help" || parsed.flags.help) { + command = "help"; + if (jsonMode) { + validateJsonHelpArgs(parsed); + const envelope = createCliSuccessEnvelope("help", { + text: HELP_TEXT.trimEnd(), + requestedCommand: requestedCommand && requestedCommand !== "help" + ? requestedCommand + : null + }); + await writeEnvelope(envelope); + return cliJsonExitCode(envelope); + } + printHelp(stdoutLine); + return 0; + } + + assertSupportedNodeVersion(); + if (jsonMode) validateJsonCommandArgs(command, parsed); + const result = await executeCommand(parsed, { + command, + jsonMode, + stdoutLine, + stderrLine, + environment: options.environment ?? process.env, + signalTarget: options.signalTarget ?? process, + loadCoreImpl, + startWebUiImpl: options.startWebUiImpl, + installWindowsLauncherImpl: options.installWindowsLauncherImpl ?? installWindowsLauncher + }); + if (jsonMode) { + const envelope = createCliSuccessEnvelope(command, result); + await writeEnvelope(envelope); + return cliJsonExitCode(envelope); + } + return 0; + } catch (error) { + if (!jsonMode) { + stderrLine(error instanceof Error ? error.message : String(error)); + return 1; } - return; + const envelope = createCliFailureEnvelope(command, await cliErrorDto(error, loadCoreImpl)); + if (!terminalWritten) await writeEnvelope(envelope); + return cliJsonExitCode(envelope); } +} - throw new Error(`Unknown command: ${command}`); +async function isDirectExecution() { + if (!process.argv[1]) return false; + const canonicalPath = async (value) => { + try { + return await fs.realpath(value); + } catch { + return path.resolve(value); + } + }; + const [executedPath, modulePath] = await Promise.all([ + canonicalPath(process.argv[1]), + canonicalPath(fileURLToPath(import.meta.url)) + ]); + return process.platform === "win32" + ? executedPath.toLowerCase() === modulePath.toLowerCase() + : executedPath === modulePath; } -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); +if (await isDirectExecution()) { + try { + process.exitCode = await runCli(process.argv.slice(2)); + } catch { + process.exitCode = 1; + } +} diff --git a/src/core-error.js b/src/core-error.js new file mode 100644 index 0000000..3986a05 --- /dev/null +++ b/src/core-error.js @@ -0,0 +1,202 @@ +const ERROR_DEFINITIONS = Object.freeze({ + INVALID_INPUT: { severity: "error", retryable: true, recoveryRequired: false }, + PROFILE_CHANGED: { severity: "warning", retryable: true, recoveryRequired: false }, + STORAGE_CHANGED: { severity: "warning", retryable: true, recoveryRequired: false }, + PLAN_STALE: { severity: "warning", retryable: true, recoveryRequired: false }, + PLAN_EXPIRED: { severity: "warning", retryable: true, recoveryRequired: false }, + STALE_STATE: { severity: "warning", retryable: true, recoveryRequired: false }, + CODEX_HOME_NOT_FOUND: { severity: "error", retryable: true, recoveryRequired: false }, + STATE_DB_NOT_FOUND: { severity: "error", retryable: true, recoveryRequired: false }, + SQLITE_UNSUPPORTED_PATH: { severity: "error", retryable: true, recoveryRequired: false }, + SQLITE_BUSY: { severity: "warning", retryable: true, recoveryRequired: false }, + SQLITE_UNREADABLE: { severity: "error", retryable: true, recoveryRequired: false }, + ROLLOUT_LOCKED: { severity: "warning", retryable: true, recoveryRequired: false }, + ROLLOUT_CHANGED: { severity: "warning", retryable: true, recoveryRequired: false }, + PENDING_TRANSACTION: { severity: "error", retryable: true, recoveryRequired: true }, + BACKUP_FAILED: { severity: "error", retryable: true, recoveryRequired: false }, + SYNC_FAILED_ROLLED_BACK: { severity: "error", retryable: true, recoveryRequired: false }, + RECOVERY_REQUIRED: { severity: "error", retryable: true, recoveryRequired: true }, + RESTORE_VALIDATION_FAILED: { severity: "error", retryable: true, recoveryRequired: false }, + PERMISSION_DENIED: { severity: "error", retryable: true, recoveryRequired: false }, + OPERATION_BUSY: { severity: "warning", retryable: true, recoveryRequired: false }, + LOCK_UNVERIFIABLE: { severity: "error", retryable: true, recoveryRequired: false }, + OPERATION_CANCELLED: { severity: "info", retryable: true, recoveryRequired: false }, + CORE_RUNTIME_CRASHED: { severity: "fatal", retryable: true, recoveryRequired: false }, + PROTOCOL_VERSION_MISMATCH: { severity: "error", retryable: true, recoveryRequired: false }, + INTERNAL_ERROR: { severity: "fatal", retryable: false, recoveryRequired: false } +}); + +const SEVERITIES = new Set(["info", "warning", "error", "fatal"]); +const LOCK_SCOPES = new Set(["codex-home", "state-db"]); +const CORE_ERROR_CODE_SET = new Set(Object.keys(ERROR_DEFINITIONS)); + +export const CORE_ERROR_CODES = Object.freeze(Object.keys(ERROR_DEFINITIONS)); + +function cloneJsonValue(value, fieldName, depth = 0) { + if (depth > 12) { + throw new TypeError(`${fieldName} exceeds the maximum supported nesting depth.`); + } + if (value === null || typeof value === "string" || typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError(`${fieldName} may only contain finite numbers.`); + } + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => cloneJsonValue(entry, fieldName, depth + 1)); + } + if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) { + const result = {}; + for (const [key, entry] of Object.entries(value)) { + if (entry !== undefined) { + result[key] = cloneJsonValue(entry, fieldName, depth + 1); + } + } + return result; + } + throw new TypeError(`${fieldName} must be JSON-serializable plain data.`); +} + +function deepFreezeJsonValue(value) { + if (value && typeof value === "object") { + for (const entry of Object.values(value)) { + deepFreezeJsonValue(entry); + } + Object.freeze(value); + } + return value; +} + +function normalizeDetails(details) { + if (details === undefined) return undefined; + const normalized = cloneJsonValue(details, "CoreError details"); + if (normalized === null || Array.isArray(normalized) || typeof normalized !== "object") { + throw new TypeError("CoreError details must be a plain object."); + } + return deepFreezeJsonValue(normalized); +} + +function validateScopedError(code, details) { + if (code === "OPERATION_BUSY" && !LOCK_SCOPES.has(details?.busyScope)) { + throw new TypeError("OPERATION_BUSY requires details.busyScope to be codex-home or state-db."); + } + if (code === "LOCK_UNVERIFIABLE" && !LOCK_SCOPES.has(details?.lockScope)) { + throw new TypeError("LOCK_UNVERIFIABLE requires details.lockScope to be codex-home or state-db."); + } +} + +function normalizeCode(code) { + if (!CORE_ERROR_CODE_SET.has(code)) { + throw new TypeError(`Unknown CoreError code: ${String(code)}`); + } + return code; +} + +function optionalString(value, fieldName) { + if (value === undefined) return undefined; + if (typeof value !== "string" || !value) { + throw new TypeError(`${fieldName} must be a non-empty string when provided.`); + } + return value; +} + +export class CoreError extends Error { + constructor(code, message, options = {}) { + const normalizedCode = normalizeCode(code); + if (typeof message !== "string" || !message) { + throw new TypeError("CoreError message must be a non-empty string."); + } + const details = normalizeDetails(options.details); + validateScopedError(normalizedCode, details); + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + + const defaults = ERROR_DEFINITIONS[normalizedCode]; + const severity = options.severity ?? defaults.severity; + if (!SEVERITIES.has(severity)) { + throw new TypeError(`Invalid CoreError severity: ${String(severity)}`); + } + if (options.retryable !== undefined && typeof options.retryable !== "boolean") { + throw new TypeError("CoreError retryable must be boolean when provided."); + } + if (options.recoveryRequired !== undefined && typeof options.recoveryRequired !== "boolean") { + throw new TypeError("CoreError recoveryRequired must be boolean when provided."); + } + + this.name = "CoreError"; + this.code = normalizedCode; + this.severity = severity; + this.retryable = options.retryable ?? defaults.retryable; + this.recoveryRequired = options.recoveryRequired ?? defaults.recoveryRequired; + this.operationId = optionalString(options.operationId, "CoreError operationId"); + this.details = details; + this.suggestedAction = optionalString(options.suggestedAction, "CoreError suggestedAction"); + } + + toDto() { + return { + code: this.code, + message: this.message, + severity: this.severity, + retryable: this.retryable, + recoveryRequired: this.recoveryRequired, + ...(this.operationId ? { operationId: this.operationId } : {}), + ...(this.details ? { details: this.details } : {}), + ...(this.suggestedAction ? { suggestedAction: this.suggestedAction } : {}) + }; + } +} + +function safeCauseCode(error) { + return typeof error?.code === "string" && error.code && error.code.length <= 120 + ? error.code + : undefined; +} + +function mappedCode(error, fallbackCode) { + if (error?.name === "AbortError" && error?.code === "ABORT_ERR") { + return "OPERATION_CANCELLED"; + } + if (CORE_ERROR_CODE_SET.has(error?.code)) { + return error.code; + } + if (error?.code === "EACCES" || error?.code === "EPERM") { + return "PERMISSION_DENIED"; + } + return normalizeCode(fallbackCode); +} + +export function toCoreErrorDto(error, { + fallbackCode = "INTERNAL_ERROR", + operationId, + details, + suggestedAction +} = {}) { + if (error instanceof CoreError) { + return error.toDto(); + } + + const message = error instanceof Error ? error.message : String(error); + const causeCode = safeCauseCode(error); + const normalizedDetails = { + ...(details ?? {}), + ...(causeCode && !CORE_ERROR_CODE_SET.has(causeCode) ? { causeCode } : {}) + }; + const code = mappedCode(error, fallbackCode); + try { + return new CoreError(code, message || "An unknown Core error occurred.", { + operationId, + details: Object.keys(normalizedDetails).length > 0 ? normalizedDetails : undefined, + suggestedAction, + cause: error instanceof Error ? error : undefined + }).toDto(); + } catch (conversionError) { + if (code === "INTERNAL_ERROR") throw conversionError; + return new CoreError("INTERNAL_ERROR", message || "An unknown Core error occurred.", { + details: causeCode ? { causeCode } : undefined, + cause: error instanceof Error ? error : conversionError + }).toDto(); + } +} diff --git a/src/diagnostics.js b/src/diagnostics.js new file mode 100644 index 0000000..43c5291 --- /dev/null +++ b/src/diagnostics.js @@ -0,0 +1,49 @@ +import { getStatus } from "./service.js"; + +function countDistribution(distribution) { + return Object.fromEntries(Object.entries(distribution ?? {}).map(([scope, counts]) => [ + scope, + Object.values(counts ?? {}).reduce((total, count) => total + (Number.isSafeInteger(count) ? count : 0), 0) + ])); +} + +export async function getDiagnostics(options = {}) { + const status = await getStatus(options); + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + runtime: { + node: process.version, + platform: process.platform, + arch: process.arch + }, + storage: { + codexHome: status.codexHome, + sqliteHome: status.sqliteHome, + sqliteHomeSource: status.sqliteHomeSource, + stateDbLocation: status.stateDbLocation, + sqliteAccess: status.sqliteAccess + }, + provider: { + current: status.currentProvider, + implicit: status.currentProviderImplicit, + configured: status.configuredProviders, + rolloutCounts: status.rolloutCounts, + sqliteCounts: status.sqliteCounts, + rolloutTotals: countDistribution(status.rolloutCounts), + sqliteTotals: countDistribution(status.sqliteCounts) + }, + safety: { + storageRevision: status.storageRevision, + pendingRecovery: status.pendingRecovery, + pendingTransactions: (status.pendingTransactions ?? []).map((transaction) => ({ + operationId: transaction.operationId, + state: transaction.state + })), + operationInProgress: status.operationInProgress, + rolloutScanComplete: status.rolloutScanComplete, + lockedRolloutCount: status.lockedRolloutFiles?.length ?? 0, + projectThreadVisibilityAvailable: status.projectThreadVisibilityAvailable + } + }; +} diff --git a/src/history.js b/src/history.js index 065932a..8fb63c5 100644 --- a/src/history.js +++ b/src/history.js @@ -2,13 +2,31 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; -import readline from "node:readline"; import { SESSION_DIRS } from "./constants.js"; +import { CoreError } from "./core-error.js"; const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 100; const DEFAULT_MESSAGE_LIMIT = 200; +const HISTORY_METADATA_MAX_BYTES = 64 * 1024; +const HISTORY_METADATA_READ_CHUNK_BYTES = 16 * 1024; +const HISTORY_THREAD_ID_MAX_CHARS = 512; +const HISTORY_TITLE_MAX_CHARS = 1024; +const HISTORY_CWD_MAX_CHARS = 32 * 1024; +const HISTORY_PROVIDER_MAX_CHARS = 512; +const HISTORY_MODEL_MAX_CHARS = 512; +const HISTORY_TIMESTAMP_MAX_CHARS = 128; + +function historyFileError(error, action) { + if (error?.code === "EACCES" || error?.code === "EPERM") { + return new CoreError("PERMISSION_DENIED", `Permission denied while ${action}.`, { + cause: error, + details: { causeCode: error.code } + }); + } + return error; +} function normalizedRolloutPath(rolloutPath) { const absolutePath = path.resolve(rolloutPath); @@ -35,6 +53,14 @@ function firstText(...values) { return ""; } +function firstBoundedText(maxChars, ...values) { + for (const value of values) { + const text = normalizeText(value); + if (text && text.length <= maxChars) return text; + } + return ""; +} + function contentText(value) { if (typeof value === "string") return normalizeText(value); if (!Array.isArray(value)) return ""; @@ -45,111 +71,462 @@ function contentText(value) { .join("\n"); } -function messageFromRecord(record) { +function pathKey(value) { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function isWithinRoot(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || (!path.isAbsolute(relative) + && relative !== ".." + && !relative.startsWith(`..${path.sep}`)); +} + +function fileIdentity(stat) { + return { + dev: String(stat.dev), + ino: String(stat.ino), + size: String(stat.size), + mtimeNs: String(stat.mtimeNs), + ctimeNs: String(stat.ctimeNs), + birthtimeNs: String(stat.birthtimeNs) + }; +} + +function sameFileObject(left, right) { + return left.dev === right.dev + && left.ino === right.ino + && left.birthtimeNs === right.birthtimeNs; +} + +function sameFileIdentity(left, right) { + return sameFileObject(left, right) + && left.size === right.size + && left.mtimeNs === right.mtimeNs + && left.ctimeNs === right.ctimeNs; +} + +function staleHistoryError(cause) { + return new CoreError( + "STALE_STATE", + "The selected session changed before its messages could be read.", + { cause: cause instanceof Error ? cause : undefined, details: { reason: "history-rollout" } } + ); +} + +function sessionMetaFromRecord(record) { + if (record?.type !== "session_meta" || !record.payload || typeof record.payload !== "object") { + return null; + } + const payload = record.payload; + const timestamp = record.timestamp ?? payload.timestamp ?? null; + return { + threadId: typeof payload.id === "string" + && payload.id.length > 0 + && payload.id.length <= HISTORY_THREAD_ID_MAX_CHARS + ? payload.id + : null, + title: firstBoundedText(HISTORY_TITLE_MAX_CHARS, payload.title, payload.name), + cwd: firstBoundedText(HISTORY_CWD_MAX_CHARS, payload.cwd), + provider: firstBoundedText(HISTORY_PROVIDER_MAX_CHARS, payload.model_provider) || "(missing)", + model: firstBoundedText(HISTORY_MODEL_MAX_CHARS, payload.model), + createdAt: typeof timestamp === "string" && timestamp.length <= HISTORY_TIMESTAMP_MAX_CHARS + ? timestamp + : null + }; +} + +async function openRolloutCandidate(candidate, expectedIdentity = null) { + const { filePath, lexicalRoot, physicalRoot } = candidate; + let handle; + try { + const [currentRootPhysical, lexicalStat, currentPhysicalPath] = await Promise.all([ + fs.realpath(lexicalRoot), + fs.lstat(filePath, { bigint: true }), + fs.realpath(filePath) + ]); + if (pathKey(currentRootPhysical) !== pathKey(physicalRoot) + || lexicalStat.isSymbolicLink() + || !lexicalStat.isFile() + || !isWithinRoot(physicalRoot, currentPhysicalPath)) { + throw staleHistoryError(); + } + const physicalPath = path.resolve(currentPhysicalPath); + handle = await fs.open(filePath, fsSync.constants.O_RDONLY); + const openedStat = await handle.stat({ bigint: true }); + const openedIdentity = fileIdentity(openedStat); + if (!sameFileObject(fileIdentity(lexicalStat), openedIdentity) + || (expectedIdentity && !sameFileIdentity(expectedIdentity, openedIdentity))) { + throw staleHistoryError(); + } + return { handle, physicalPath }; + } catch (error) { + await handle?.close().catch(() => {}); + if (error?.code === "STALE_STATE") throw error; + throw historyFileError(error, "opening a history rollout"); + } +} + +async function validateOpenedRollout(candidate, handle, physicalPath, expectedIdentity = null) { + const { filePath, lexicalRoot, physicalRoot } = candidate; + try { + const finalStat = await handle.stat({ bigint: true }); + const [currentRootPhysical, currentPhysicalPath, namedStat] = await Promise.all([ + fs.realpath(lexicalRoot), + fs.realpath(filePath), + fs.lstat(filePath, { bigint: true }) + ]); + const finalIdentity = fileIdentity(finalStat); + const namedIdentity = fileIdentity(namedStat); + if (pathKey(currentRootPhysical) !== pathKey(physicalRoot) + || pathKey(currentPhysicalPath) !== pathKey(physicalPath) + || namedStat.isSymbolicLink() + || !namedStat.isFile() + || !sameFileIdentity(namedIdentity, finalIdentity) + || (expectedIdentity && !sameFileIdentity(expectedIdentity, finalIdentity))) { + throw staleHistoryError(); + } + return { finalStat, finalIdentity }; + } catch (error) { + if (error?.code === "STALE_STATE") throw error; + throw staleHistoryError(error); + } +} + +async function readBoundedFirstLine(handle) { + const chunks = []; + let position = 0; + let totalBytes = 0; + while (totalBytes <= HISTORY_METADATA_MAX_BYTES) { + const chunkLength = Math.min( + HISTORY_METADATA_READ_CHUNK_BYTES, + HISTORY_METADATA_MAX_BYTES + 1 - totalBytes + ); + if (chunkLength <= 0) return null; + const chunk = Buffer.allocUnsafe(chunkLength); + const { bytesRead } = await handle.read(chunk, 0, chunk.length, position); + if (bytesRead === 0) break; + const data = chunk.subarray(0, bytesRead); + const relativeNewline = data.indexOf(0x0a); + position += bytesRead; + if (relativeNewline >= 0) { + const lineLength = totalBytes + relativeNewline; + if (lineLength > HISTORY_METADATA_MAX_BYTES) return null; + const line = Buffer.concat([...chunks, data.subarray(0, relativeNewline)], lineLength); + const end = line.length > 0 && line[line.length - 1] === 0x0d ? line.length - 1 : line.length; + return line.subarray(0, end).toString("utf8"); + } + chunks.push(data); + totalBytes += bytesRead; + } + if (totalBytes > HISTORY_METADATA_MAX_BYTES) return null; + return Buffer.concat(chunks, totalBytes).toString("utf8"); +} + +async function* readHandleLines(handle) { + const buffer = Buffer.allocUnsafe(64 * 1024); + const decoder = new TextDecoder(); + let pending = ""; + let position = 0; + while (true) { + const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); + if (bytesRead === 0) break; + position += bytesRead; + pending += decoder.decode(buffer.subarray(0, bytesRead), { stream: true }); + let newline; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + yield line.endsWith("\r") ? line.slice(0, -1) : line; + } + } + pending += decoder.decode(); + if (pending) yield pending.endsWith("\r") ? pending.slice(0, -1) : pending; +} + +function hasText(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function hasContentText(value) { + if (hasText(value)) return true; + if (!Array.isArray(value)) return false; + return value.some((item) => item + && typeof item === "object" + && (item.type === "output_text" || item.type === "text" || item.type === "input_text") + && hasText(item.text)); +} + +function messageFromRecord(record, { includeText = true } = {}) { if (!record || typeof record !== "object") return null; const timestamp = record.timestamp ?? record.payload?.timestamp ?? null; const eventType = record.payload?.type; if (record.type === "event_msg" && (eventType === "user_message" || eventType === "assistant_message")) { const role = eventType === "user_message" ? "user" : "assistant"; - const text = firstText(record.payload?.message, record.payload?.text); - return text ? { role, text, timestamp, canonicalUser: role === "user" } : null; + const values = [record.payload?.message, record.payload?.text]; + if (!values.some(hasText)) return null; + return { + role, + ...(includeText ? { text: firstText(...values) } : {}), + timestamp, + canonicalUser: role === "user" + }; } for (const key of ["payload", "item", "msg"]) { const value = record[key]; if (!value || typeof value !== "object" || !["user", "assistant"].includes(value.role)) continue; - const text = firstText(contentText(value.content), value.message, value.text); - return text ? { role: value.role, text, timestamp, canonicalUser: false } : null; + if (!hasContentText(value.content) && !hasText(value.message) && !hasText(value.text)) return null; + return { + role: value.role, + ...(includeText ? { text: firstText(contentText(value.content), value.message, value.text) } : {}), + timestamp, + canonicalUser: false + }; } if (record.type === "user_message" || record.type === "assistant_message") { - const text = firstText(record.message, record.text, record.payload?.message, record.payload?.text); + const values = [record.message, record.text, record.payload?.message, record.payload?.text]; + if (!values.some(hasText)) return null; const role = record.type === "user_message" ? "user" : "assistant"; - return text ? { role, text, timestamp, canonicalUser: role === "user" } : null; + return { + role, + ...(includeText ? { text: firstText(...values) } : {}), + timestamp, + canonicalUser: role === "user" + }; } return null; } -async function listRolloutFiles(root) { +async function listRolloutFiles(root, codexHomePhysical) { const result = []; + const lexicalRoot = path.resolve(root); + let rootStat; + let physicalRoot; + try { + rootStat = await fs.lstat(lexicalRoot); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) return result; + physicalRoot = path.resolve(await fs.realpath(lexicalRoot)); + } catch (error) { + if (error?.code === "ENOENT") return result; + throw historyFileError(error, "scanning history rollouts"); + } + if (!isWithinRoot(codexHomePhysical, physicalRoot)) return result; async function walk(directory) { let entries; try { entries = await fs.readdir(directory, { withFileTypes: true }); } catch (error) { if (error?.code === "ENOENT") return; - throw error; + throw historyFileError(error, "scanning history rollouts"); } for (const entry of entries) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) await walk(fullPath); - else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) result.push(fullPath); + else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) { + result.push({ filePath: fullPath, lexicalRoot, physicalRoot }); + } } } - await walk(root); + await walk(lexicalRoot); return result; } -async function readRollout(filePath, archived) { - const stat = await fs.stat(filePath); - const stream = fsSync.createReadStream(filePath, { encoding: "utf8" }); - const lines = readline.createInterface({ input: stream, crlfDelay: Infinity }); +async function readRolloutMetadata(candidate, archived) { + const { filePath, lexicalRoot, physicalRoot } = candidate; + let handle; + try { + const opened = await openRolloutCandidate(candidate); + handle = opened.handle; + const firstLine = await readBoundedFirstLine(handle); + let record = null; + if (firstLine !== null) { + try { + record = JSON.parse(firstLine); + } catch { + // A malformed or oversized metadata line is not safe to treat as a session. + } + } + const meta = sessionMetaFromRecord(record); + const { finalStat, finalIdentity } = await validateOpenedRollout( + candidate, + handle, + opened.physicalPath + ); + if (!meta) return null; + const rolloutPath = path.resolve(filePath); + return { + ...meta, + id: meta.threadId ?? fallbackSessionId(rolloutPath), + rolloutPath, + updatedAt: new Date(Number(finalStat.mtimeMs)).toISOString(), + archived, + messageCount: 0, + messageCountKnown: false, + messageQueryMatched: false, + filePath, + lexicalRoot, + physicalRoot, + physicalPath: opened.physicalPath, + fileIdentity: finalIdentity, + mtimeMs: Number(finalStat.mtimeMs) + }; + } catch (error) { + if (error?.code === "STALE_STATE") throw error; + throw historyFileError(error, "reading history rollout metadata"); + } finally { + await handle?.close().catch(() => {}); + } +} + +async function readRollout( + candidate, + archived, + { includeMessages = false, searchQuery = "", messageLimit = DEFAULT_MESSAGE_LIMIT, expectedIdentity = null } = {} +) { + const { filePath, lexicalRoot, physicalRoot } = candidate; + let handle; + let physicalPath; + const opened = await openRolloutCandidate(candidate, expectedIdentity); + handle = opened.handle; + physicalPath = opened.physicalPath; let meta = null; - const messages = []; let sequence = 0; + let assistantCount = 0; + let canonicalUserCount = 0; + let legacyUserCount = 0; + let assistantQueryMatched = false; + let canonicalUserQueryMatched = false; + let legacyUserQueryMatched = false; + let lastAssistant = null; + let lastCanonicalUser = null; + let lastLegacyUser = null; + const assistantMessages = []; + const canonicalUserMessages = []; + const legacyUserMessages = []; + const boundedLimit = Math.max(1, Math.min(messageLimit, DEFAULT_MESSAGE_LIMIT)); + const retain = (items, message) => { + if (!includeMessages) return; + items.push(message); + if (items.length > boundedLimit) items.shift(); + }; + let readFailure = null; try { - for await (const line of lines) { + for await (const line of readHandleLines(handle)) { if (!line.trim()) continue; let record; try { record = JSON.parse(line); } catch { continue; } - if (!meta && record.type === "session_meta" && record.payload && typeof record.payload === "object") { - const payload = record.payload; - meta = { - threadId: typeof payload.id === "string" && payload.id ? payload.id : null, - title: firstText(payload.title, payload.name), - cwd: firstText(payload.cwd), - provider: firstText(payload.model_provider) || "(missing)", - model: firstText(payload.model), - createdAt: record.timestamp ?? payload.timestamp ?? null + if (!meta) meta = sessionMetaFromRecord(record); + const message = messageFromRecord(record, { + includeText: includeMessages || Boolean(searchQuery) + }); + if (message) { + const descriptor = { + role: message.role, + timestamp: message.timestamp, + canonicalUser: message.canonicalUser, + sequence: ++sequence, + ...(searchQuery ? { queryMatched: message.text.toLowerCase().includes(searchQuery) } : {}), + ...(includeMessages ? { text: message.text } : {}) }; + if (message.role === "assistant") { + assistantCount += 1; + assistantQueryMatched ||= descriptor.queryMatched === true; + lastAssistant = descriptor; + retain(assistantMessages, descriptor); + } else if (message.canonicalUser) { + canonicalUserCount += 1; + canonicalUserQueryMatched ||= descriptor.queryMatched === true; + lastCanonicalUser = descriptor; + retain(canonicalUserMessages, descriptor); + } else { + legacyUserCount += 1; + legacyUserQueryMatched ||= descriptor.queryMatched === true; + lastLegacyUser = descriptor; + retain(legacyUserMessages, descriptor); + } } - const message = messageFromRecord(record); - if (message) messages.push({ ...message, sequence: ++sequence }); } + } catch (error) { + readFailure = error; + throw historyFileError(error, "reading a history rollout"); + } finally { + if (readFailure) await handle.close().catch(() => {}); + } + let finalStat; + let identity; + try { + const validated = await validateOpenedRollout(candidate, handle, physicalPath, expectedIdentity); + finalStat = validated.finalStat; + identity = validated.finalIdentity; } finally { - lines.close(); - stream.destroy(); + await handle.close().catch(() => {}); } if (!meta) return null; - const hasCanonicalUserMessages = messages.some((message) => message.canonicalUser); - const visibleMessages = messages - .filter((message) => message.role !== "user" || !hasCanonicalUserMessages || message.canonicalUser) - .map(({ canonicalUser: _canonicalUser, sequence: _sequence, ...message }, index) => ({ ...message, sequence: index + 1 })); + const useCanonicalUsers = canonicalUserCount > 0; + const selectedUserCount = useCanonicalUsers ? canonicalUserCount : legacyUserCount; + const selectedUserMessages = useCanonicalUsers ? canonicalUserMessages : legacyUserMessages; + const selectedLastUser = useCanonicalUsers ? lastCanonicalUser : lastLegacyUser; + const messageCount = assistantCount + selectedUserCount; + const messageQueryMatched = assistantQueryMatched + || (useCanonicalUsers ? canonicalUserQueryMatched : legacyUserQueryMatched); + const retainedMessages = includeMessages + ? [...assistantMessages, ...selectedUserMessages] + .sort((left, right) => left.sequence - right.sequence) + .slice(-boundedLimit) + : []; + const visibleMessages = retainedMessages.map( + ({ canonicalUser: _canonicalUser, sequence: _sequence, queryMatched: _queryMatched, ...message }, index) => ({ + ...message, + sequence: messageCount - retainedMessages.length + index + 1 + }) + ); + const lastVisible = [lastAssistant, selectedLastUser] + .filter(Boolean) + .sort((left, right) => left.sequence - right.sequence) + .at(-1); const rolloutPath = path.resolve(filePath); - const updatedAt = visibleMessages.at(-1)?.timestamp ?? stat.mtime.toISOString(); + const updatedAt = lastVisible?.timestamp ?? new Date(Number(finalStat.mtimeMs)).toISOString(); return { ...meta, id: meta.threadId ?? fallbackSessionId(rolloutPath), rolloutPath, updatedAt, archived, - messages: visibleMessages, - messageCount: visibleMessages.length, + ...(includeMessages ? { messages: visibleMessages } : {}), + messageCount, + messageCountKnown: true, + messageQueryMatched, filePath, - mtimeMs: stat.mtimeMs + lexicalRoot, + physicalRoot, + physicalPath, + fileIdentity: identity, + mtimeMs: Number(finalStat.mtimeMs) }; } -async function collectHistory(codexHome) { +async function collectHistory(codexHome, options = {}) { const sessions = []; + let codexHomePhysical; + try { + codexHomePhysical = path.resolve(await fs.realpath(path.resolve(codexHome))); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw historyFileError(error, "resolving the Codex Home for history"); + } for (const dirName of SESSION_DIRS) { - const files = await listRolloutFiles(path.join(codexHome, dirName)); - for (const filePath of files) { + const files = await listRolloutFiles(path.join(codexHome, dirName), codexHomePhysical); + for (const candidate of files) { let session; try { - session = await readRollout(filePath, dirName === "archived_sessions"); + session = options.metadataOnly + ? await readRolloutMetadata(candidate, dirName === "archived_sessions") + : await readRollout(candidate, dirName === "archived_sessions", options); } catch (error) { - if (error?.code === "ENOENT") continue; - throw error; + if (error?.code === "ENOENT" || error?.code === "STALE_STATE") continue; + throw historyFileError(error, "reading a history rollout"); } if (session) sessions.push(session); } @@ -166,11 +543,10 @@ async function collectHistory(codexHome) { } function publicSession(session) { - const firstUserMessage = session.messages.find((message) => message.role === "user")?.text ?? ""; return { id: session.id, rolloutPath: session.rolloutPath, - title: session.title || firstUserMessage.slice(0, 80) || "未命名会话", + title: session.title || "", cwd: session.cwd, provider: session.provider, model: session.model, @@ -178,15 +554,24 @@ function publicSession(session) { createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, - firstUserMessage: firstUserMessage.slice(0, 240) + ...(typeof session.messageCountKnown === "boolean" + ? { messageCountKnown: session.messageCountKnown } + : {}) }; } export function validateHistoryPage(pageValue, pageSizeValue = DEFAULT_PAGE_SIZE) { const page = pageValue === undefined ? 1 : Number(pageValue); const pageSize = pageSizeValue === undefined ? DEFAULT_PAGE_SIZE : Number(pageSizeValue); - if (!Number.isInteger(page) || page < 1) throw new Error("page must be a positive integer."); - if (!Number.isInteger(pageSize) || pageSize < 10 || pageSize > MAX_PAGE_SIZE) throw new Error(`pageSize must be an integer between 10 and ${MAX_PAGE_SIZE}.`); + if (!Number.isInteger(page) || page < 1) { + throw new CoreError("INVALID_INPUT", "page must be a positive integer."); + } + if (!Number.isInteger(pageSize) || pageSize < 10 || pageSize > MAX_PAGE_SIZE) { + throw new CoreError( + "INVALID_INPUT", + `pageSize must be an integer between 10 and ${MAX_PAGE_SIZE}.` + ); + } return { page, pageSize }; } @@ -196,15 +581,20 @@ export async function listHistory(codexHome, options = {}) { const project = normalizeText(options.project).toLowerCase(); const provider = normalizeText(options.provider); const archived = options.archived ?? "all"; - if (!["all", "active", "archived"].includes(archived)) throw new Error("archived must be all, active, or archived."); - const sessions = await collectHistory(codexHome); + if (!["all", "active", "archived"].includes(archived)) { + throw new CoreError("INVALID_INPUT", "archived must be all, active, or archived."); + } + const sessions = await collectHistory(codexHome, { + searchQuery: query, + metadataOnly: !query + }); const filtered = sessions.filter((session) => { if (provider && session.provider !== provider) return false; if (archived !== "all" && session.archived !== (archived === "archived")) return false; if (project && !session.cwd.toLowerCase().includes(project)) return false; if (query) { - const haystack = [session.title, session.cwd, session.provider, session.messages[0]?.text, ...session.messages.map((message) => message.text)].join("\n").toLowerCase(); - if (!haystack.includes(query)) return false; + const metadata = [session.title, session.cwd, session.provider].join("\n").toLowerCase(); + if (!metadata.includes(query) && !session.messageQueryMatched) return false; } return true; }); @@ -213,11 +603,40 @@ export async function listHistory(codexHome, options = {}) { } export async function getHistorySession(codexHome, sessionId, { messageLimit = DEFAULT_MESSAGE_LIMIT } = {}) { - if (typeof sessionId !== "string" || !sessionId.trim()) throw new Error("sessionId is required."); - const sessions = await collectHistory(codexHome); - const session = sessions.find((item) => item.id === sessionId); - if (!session) throw new Error("The selected session was not found in this Codex Home."); - const safeLimit = Number.isInteger(messageLimit) && messageLimit > 0 ? Math.min(messageLimit, DEFAULT_MESSAGE_LIMIT) : DEFAULT_MESSAGE_LIMIT; - const messages = session.messages.slice(-safeLimit); - return { session: publicSession(session), messages, truncated: messages.length < session.messages.length, returnedMessageCount: messages.length }; + if (typeof sessionId !== "string" || !sessionId.trim()) { + throw new CoreError("INVALID_INPUT", "sessionId is required."); + } + const summaries = await collectHistory(codexHome, { metadataOnly: true }); + const summary = summaries.find((item) => item.id === sessionId); + if (!summary) { + throw new CoreError( + "INVALID_INPUT", + "The selected session was not found in this Codex Home." + ); + } + const safeLimit = Number.isInteger(messageLimit) && messageLimit > 0 + ? Math.min(messageLimit, DEFAULT_MESSAGE_LIMIT) + : DEFAULT_MESSAGE_LIMIT; + let session; + try { + session = await readRollout({ + filePath: summary.filePath, + lexicalRoot: summary.lexicalRoot, + physicalRoot: summary.physicalRoot + }, summary.archived, { + includeMessages: true, + messageLimit: safeLimit, + expectedIdentity: summary.fileIdentity + }); + } catch (error) { + if (error?.code !== "ENOENT") throw historyFileError(error, "reading a history rollout"); + } + if (!session || session.id !== sessionId) { + throw new CoreError( + "STALE_STATE", + "The selected session changed before its messages could be read." + ); + } + const messages = session.messages; + return { session: publicSession(session), messages, truncated: messages.length < session.messageCount, returnedMessageCount: messages.length }; } diff --git a/src/locking.js b/src/locking.js index b90ab30..f317816 100644 --- a/src/locking.js +++ b/src/locking.js @@ -6,11 +6,13 @@ import { promisify } from "node:util"; import { DEFAULT_LOCK_NAME } from "./constants.js"; import { syncDirectory } from "./atomic-file.js"; +import { CoreError } from "./core-error.js"; const execFileAsync = promisify(execFile); const DEFAULT_LOCK_CREATE_RETRY_COUNT = 3; const DEFAULT_LOCK_CREATE_RETRY_DELAY_MS = 75; const DEFAULT_STALE_RECLAIM_ATTEMPT_LIMIT = 8; +const LOCK_SCOPES = new Set(["codex-home", "state-db"]); function isTransientLockCreateError(error) { return error?.code === "EPERM" || error?.code === "EACCES"; @@ -80,10 +82,33 @@ async function getProcessStartMarker(pid) { return marker ? `${process.platform}:${marker}` : null; } -function lockExistsError(lockDir, reason) { - return new Error( - `Lock already exists at ${lockDir}. ${reason} Close Codex/App and retry; do not remove it unless the recorded owner is known to be gone.` - ); +function lockUnverifiableError( + lockDir, + message, + { cause, causeCode, lockScope = "codex-home" } = {} +) { + return new CoreError("LOCK_UNVERIFIABLE", message, { + cause, + details: { + lockScope, + ...(causeCode ? { causeCode } : {}) + } + }); +} + +function lockExistsError( + lockDir, + reason, + { busy = false, cause, causeCode, lockScope = "codex-home" } = {} +) { + const message = `Lock already exists at ${lockDir}. ${reason} Close Codex/App and retry; do not remove it unless the recorded owner is known to be gone.`; + if (busy) { + return new CoreError("OPERATION_BUSY", message, { + cause, + details: { busyScope: lockScope } + }); + } + return lockUnverifiableError(lockDir, message, { cause, causeCode, lockScope }); } function processStartedAtFromMarker(marker) { @@ -137,35 +162,25 @@ function ownerMatchesExpected(actual, expected) { && actual.processStartedAt === expected.processStartedAt; } -function liveIdentityMatchesOwner(liveMarker, liveStartedAt, owner) { - if (!liveMarker) { - return false; - } - if (owner.processStartMarker - && (owner.runtime === "node" || owner.protocolVersion !== 2)) { - return liveMarker === owner.processStartMarker; - } - if (owner.protocolVersion >= 2 && owner.processStartedAt) { - if (!liveStartedAt) { - // A live PID whose start time cannot be compared safely is retained. - // This is preferable to reclaiming another runtime's active lock. - return true; - } - return Math.abs(Date.parse(liveStartedAt) - Date.parse(owner.processStartedAt)) < 1000; - } - if (owner.processStartMarker) { - return liveMarker === owner.processStartMarker; +function assertOwnerResource(owner, lockDir, lockScope, resourceKey = null) { + if (owner.scope !== undefined && owner.scope !== lockScope) { + throw lockExistsError( + lockDir, + `owner.json declares scope ${String(owner.scope)} instead of ${lockScope}.`, + { lockScope } + ); } - if (owner.processStartedAt) { - if (!liveStartedAt) { - return true; - } - return Math.abs(Date.parse(liveStartedAt) - Date.parse(owner.processStartedAt)) < 1000; + if (lockScope === "state-db" + && (owner.scope !== "state-db" || owner.resourceKey !== resourceKey)) { + throw lockExistsError( + lockDir, + "owner.json is missing the expected State DB scope/resourceKey identity.", + { lockScope } + ); } - return true; } -async function readLockOwner(ownerPath, fsImpl) { +async function readLockOwner(ownerPath, fsImpl, lockScope = "codex-home") { let text; try { text = await fsImpl.readFile(ownerPath, "utf8"); @@ -173,9 +188,9 @@ async function readLockOwner(ownerPath, fsImpl) { if (error?.code === "ENOENT") { const missingOwner = lockExistsError( path.dirname(ownerPath), - "owner.json is not visible yet, so ownership cannot be proven safely." + "owner.json is not visible yet, so ownership cannot be proven safely.", + { cause: error, causeCode: "ENOENT", lockScope } ); - missingOwner.code = "ENOENT"; throw missingOwner; } throw error; @@ -184,20 +199,29 @@ async function readLockOwner(ownerPath, fsImpl) { try { owner = JSON.parse(text); } catch { - throw lockExistsError(path.dirname(ownerPath), "owner.json is malformed, so the lock is retained fail-closed."); + throw lockExistsError( + path.dirname(ownerPath), + "owner.json is malformed, so the lock is retained fail-closed.", + { lockScope } + ); } const protocolVersion = owner?.protocolVersion; if (protocolVersion !== undefined && (!Number.isInteger(protocolVersion) || protocolVersion < 1 || protocolVersion > 2)) { throw lockExistsError( path.dirname(ownerPath), - `owner.json uses unsupported lock protocol ${String(protocolVersion)}.` + `owner.json uses unsupported lock protocol ${String(protocolVersion)}.`, + { lockScope } ); } const hasPid = Number.isInteger(owner?.pid); const hasProcessId = Number.isInteger(owner?.processId); if (hasPid && hasProcessId && owner.pid !== owner.processId) { - throw lockExistsError(path.dirname(ownerPath), "owner.json has conflicting pid and processId values."); + throw lockExistsError( + path.dirname(ownerPath), + "owner.json has conflicting pid and processId values.", + { lockScope } + ); } const pid = hasPid ? owner.pid : owner?.processId; const processStartMarker = typeof owner?.processStartMarker === "string" @@ -215,13 +239,18 @@ async function readLockOwner(ownerPath, fsImpl) { && (!hasPid || !hasProcessId || !processStartedAt || !instanceId)) { throw lockExistsError( path.dirname(ownerPath), - "owner.json is missing required version 2 identity fields." + "owner.json is missing required version 2 identity fields.", + { lockScope } ); } if (!Number.isInteger(pid) || pid <= 0 || (!processStartMarker && !processStartedAt && !Number.isInteger(owner?.processId))) { - throw lockExistsError(path.dirname(ownerPath), "owner.json lacks a verifiable process identity."); + throw lockExistsError( + path.dirname(ownerPath), + "owner.json lacks a verifiable process identity.", + { lockScope } + ); } return { ...owner, @@ -253,16 +282,24 @@ async function lstatOrNull(targetPath, fsImpl) { } } -async function inspectCanonicalDirectory(lockDir, fsImpl) { +async function inspectCanonicalDirectory(lockDir, fsImpl, lockScope = "codex-home") { const stats = await lstatOrNull(lockDir, fsImpl); if (!stats) { return null; } if (stats.isSymbolicLink()) { - throw lockExistsError(lockDir, "The canonical lock path is a symbolic link and is retained fail-closed."); + throw lockExistsError( + lockDir, + "The canonical lock path is a symbolic link and is retained fail-closed.", + { lockScope } + ); } if (!stats.isDirectory()) { - throw lockExistsError(lockDir, "The canonical lock path is not a directory and is retained fail-closed."); + throw lockExistsError( + lockDir, + "The canonical lock path is not a directory and is retained fail-closed.", + { lockScope } + ); } return directoryIdentity(stats); } @@ -272,18 +309,19 @@ async function restoreQuarantinedOwner( lockDir, fsImpl, syncDirectoryImpl, - platform + platform, + lockScope ) { const parentDir = path.dirname(lockDir); let reservationIdentity = null; try { await fsImpl.mkdir(lockDir, { mode: 0o700 }); - reservationIdentity = await inspectCanonicalDirectory(lockDir, fsImpl); + reservationIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, lockScope); await fsImpl.link( path.join(sourceDir, "owner.json"), path.join(lockDir, "owner.json") ); - const publishedIdentity = await inspectCanonicalDirectory(lockDir, fsImpl); + const publishedIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, lockScope); if (!sameDirectoryIdentity(reservationIdentity, publishedIdentity)) { return false; } @@ -303,7 +341,8 @@ async function quarantineStaleLock( expectedDirectoryIdentity, fsImpl, syncDirectoryImpl, - platform + platform, + lockScope ) { const quarantinePath = `${lockDir}.stale.${Date.now()}.${randomUUID()}`; try { @@ -317,14 +356,19 @@ async function quarantineStaleLock( let quarantinedOwner; try { - quarantinedOwner = await readLockOwner(path.join(quarantinePath, "owner.json"), fsImpl); + quarantinedOwner = await readLockOwner( + path.join(quarantinePath, "owner.json"), + fsImpl, + lockScope + ); } catch (error) { await restoreQuarantinedOwner( quarantinePath, lockDir, fsImpl, syncDirectoryImpl, - platform + platform, + lockScope ); throw error; } @@ -339,13 +383,15 @@ async function quarantineStaleLock( lockDir, fsImpl, syncDirectoryImpl, - platform + platform, + lockScope ); throw lockExistsError( lockDir, restored ? "The lock generation changed during stale-lock reclamation, so its owner was restored without replacing another directory." - : `The owner changed during stale-lock reclamation; its lock is preserved at ${quarantinePath}.` + : `The owner changed during stale-lock reclamation; its lock is preserved at ${quarantinePath}.`, + { lockScope } ); } await fsImpl.rm(quarantinePath, { recursive: true, force: true }); @@ -375,16 +421,25 @@ async function removeOwnedCanonical( syncDirectoryImpl, platform, expectedDirectoryIdentity, + lockScope, suffix = "release" ) { const parentDir = path.dirname(lockDir); const removalPath = `${lockDir}.${suffix}.${process.pid}.${randomUUID()}`; - const currentDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl); + const currentDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, lockScope); if (!sameDirectoryIdentity(currentDirectoryIdentity, expectedDirectoryIdentity)) { - throw new Error(`Refusing to remove lock ${lockDir} because its directory identity changed.`); + throw lockUnverifiableError( + lockDir, + `Refusing to remove lock ${lockDir} because its directory identity changed.`, + { lockScope } + ); } await fsImpl.rename(lockDir, removalPath); - const currentOwner = await readLockOwner(path.join(removalPath, "owner.json"), fsImpl); + const currentOwner = await readLockOwner( + path.join(removalPath, "owner.json"), + fsImpl, + lockScope + ); const removalStats = await lstatOrNull(removalPath, fsImpl); const removalIdentity = removalStats?.isDirectory() ? directoryIdentity(removalStats) @@ -396,12 +451,15 @@ async function removeOwnedCanonical( lockDir, fsImpl, syncDirectoryImpl, - platform + platform, + lockScope ); - throw new Error( + throw lockUnverifiableError( + lockDir, restored ? `Refusing to remove lock ${lockDir} because its generation changed; its owner was restored safely.` - : `Refusing to remove lock ${lockDir}; the changed owner is preserved at ${removalPath}.` + : `Refusing to remove lock ${lockDir}; the changed owner is preserved at ${removalPath}.`, + { lockScope } ); } await fsImpl.rm(removalPath, { recursive: true, force: true }); @@ -436,15 +494,32 @@ async function isOwnerLive(owner, getProcessIdentity, getProcessStartedAtIdentit if (!liveMarker) { return false; } - let liveStartedAt = null; + + // A Node owner publishes the exact platform process-start marker, so this + // comparison proves both liveness and generation without a second probe. + if (owner.processStartMarker + && (owner.runtime === "node" || owner.protocolVersion !== 2)) { + return liveMarker === owner.processStartMarker; + } + + let liveStartedAt; try { liveStartedAt = await getProcessStartedAtIdentity(owner.pid, liveMarker); - } catch { - // The exact Node process marker remains sufficient for legacy/current - // Node owners. Cross-runtime owners fail closed if their start time cannot - // be inspected. + } catch (error) { + throw new Error(`Unable to verify the recorded owner's process start time for PID ${owner.pid}.`, { + cause: error + }); + } + if (!liveStartedAt) { + throw new Error(`Unable to verify the recorded owner's process start time for PID ${owner.pid}.`); + } + if (owner.processStartedAt) { + return Math.abs(Date.parse(liveStartedAt) - Date.parse(owner.processStartedAt)) < 1000; + } + if (owner.processStartMarker) { + return liveMarker === owner.processStartMarker; } - return liveIdentityMatchesOwner(liveMarker, liveStartedAt, owner); + throw new Error(`The live PID ${owner.pid} has no comparable process generation identity.`); } async function establishUniqueClaim({ @@ -455,7 +530,9 @@ async function establishUniqueClaim({ getProcessIdentity, getProcessStartedAtIdentity, syncDirectoryImpl, - platform + platform, + lockScope, + resourceKey }) { for (let scan = 0; scan < 2; scan += 1) { const entries = await fsImpl.readdir(claimsDir, { withFileTypes: true }); @@ -468,9 +545,14 @@ async function establishUniqueClaim({ if (path.resolve(otherPath) === path.resolve(claimPath)) { continue; } - const otherOwner = await readLockOwner(otherPath, fsImpl); + const otherOwner = await readLockOwner(otherPath, fsImpl, lockScope); + assertOwnerResource(otherOwner, otherPath, lockScope, resourceKey); if (!otherOwner.instanceId || entry.name !== `${otherOwner.instanceId}.json`) { - throw lockExistsError(claimsDir, `Claim ${otherPath} has no matching immutable instance identity.`); + throw lockExistsError( + claimsDir, + `Claim ${otherPath} has no matching immutable instance identity.`, + { lockScope } + ); } let live; try { @@ -478,11 +560,16 @@ async function establishUniqueClaim({ } catch (identityError) { throw lockExistsError( claimsDir, - `Claim ${otherPath} could not be verified (${identityError.message}).` + `Claim ${otherPath} could not be verified (${identityError.message}).`, + { lockScope } ); } if (live) { - throw lockExistsError(claimsDir, `PID ${otherOwner.pid} holds live claim ${entry.name}.`); + throw lockExistsError( + claimsDir, + `PID ${otherOwner.pid} holds live claim ${entry.name}.`, + { busy: true, lockScope } + ); } // The filename is a never-reused instance generation. Removing this one // stale file cannot delete a newer claimant's record. @@ -491,24 +578,39 @@ async function establishUniqueClaim({ } } - const currentOwner = await readLockOwner(claimPath, fsImpl); + const currentOwner = await readLockOwner(claimPath, fsImpl, lockScope); if (currentOwner.instanceId !== owner.instanceId) { - throw new Error(`Refusing to use claim ${claimPath} because its owner identity changed.`); + throw lockUnverifiableError( + claimPath, + `Refusing to use claim ${claimPath} because its owner identity changed.`, + { lockScope } + ); } } -async function removeOwnedClaim(claimPath, owner, fsImpl, syncDirectoryImpl, platform) { +async function removeOwnedClaim( + claimPath, + owner, + fsImpl, + syncDirectoryImpl, + platform, + lockScope +) { let currentOwner; try { - currentOwner = await readLockOwner(claimPath, fsImpl); + currentOwner = await readLockOwner(claimPath, fsImpl, lockScope); } catch (error) { - if (error?.code === "ENOENT") { + if (error?.details?.causeCode === "ENOENT") { return; } throw error; } if (currentOwner.instanceId !== owner.instanceId) { - throw new Error(`Refusing to remove claim ${claimPath} because its owner identity changed.`); + throw lockUnverifiableError( + claimPath, + `Refusing to remove claim ${claimPath} because its owner identity changed.`, + { lockScope } + ); } await fsImpl.rm(claimPath, { force: true }); await syncDirectoryImpl(path.dirname(claimPath), { fsImpl, platform }); @@ -534,9 +636,18 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o syncDirectoryImpl = syncDirectory, onCandidateReady, onBeforeStaleReclaim, - platform = process.platform + platform = process.platform, + scope = "codex-home", + resourceKey = null } = options; const lockDir = path.resolve(lockPath); + if (!LOCK_SCOPES.has(scope)) { + throw new TypeError(`scope must be one of: ${[...LOCK_SCOPES].join(", ")}.`); + } + if (resourceKey !== null + && (scope !== "state-db" || typeof resourceKey !== "string" || !/^[a-f0-9]{64}$/.test(resourceKey))) { + throw new TypeError("resourceKey must be a lowercase SHA-256 hex string for a state-db lock."); + } if (!Number.isInteger(staleReclaimAttemptLimit) || staleReclaimAttemptLimit < 0) { throw new TypeError("staleReclaimAttemptLimit must be a non-negative integer."); } @@ -547,16 +658,53 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o parentDir, `.${path.basename(lockDir)}.candidate.${process.pid}.${randomUUID()}` ); - await fsImpl.mkdir(parentDir, { recursive: true }); - await fsImpl.mkdir(claimsDir, { recursive: true, mode: 0o700 }); - const processStartMarker = await getProcessIdentity(process.pid); + try { + await fsImpl.mkdir(parentDir, { recursive: true }); + await fsImpl.mkdir(claimsDir, { recursive: true, mode: 0o700 }); + } catch (error) { + if (error?.code === "EACCES" || error?.code === "EPERM") { + throw new CoreError( + "PERMISSION_DENIED", + `Permission denied while preparing ${scope} lock storage at ${parentDir}.`, + { cause: error, details: { causeCode: error.code, lockScope: scope } } + ); + } + throw error; + } + let processStartMarker; + try { + processStartMarker = await getProcessIdentity(process.pid); + } catch (error) { + throw lockUnverifiableError( + lockDir, + `Unable to establish the current process identity for lock ${lockDir}.`, + { cause: error, causeCode: error?.code, lockScope: scope } + ); + } if (!processStartMarker) { - throw new Error(`Unable to establish the current process identity for lock ${lockDir}.`); + throw lockUnverifiableError( + lockDir, + `Unable to establish the current process identity for lock ${lockDir}.`, + { lockScope: scope } + ); } - const processStartedAt = await getProcessStartedAtIdentity(process.pid, processStartMarker); + let processStartedAt; + try { + processStartedAt = await getProcessStartedAtIdentity(process.pid, processStartMarker); + } catch (error) { + throw lockUnverifiableError( + lockDir, + `Unable to establish the current process start time for lock ${lockDir}.`, + { cause: error, causeCode: error?.code, lockScope: scope } + ); + } if (!processStartedAt) { - throw new Error(`Unable to establish the current process start time for lock ${lockDir}.`); + throw lockUnverifiableError( + lockDir, + `Unable to establish the current process start time for lock ${lockDir}.`, + { lockScope: scope } + ); } const owner = { protocolVersion: 2, @@ -567,6 +715,8 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o processStartedAt: toUtcSecond(processStartedAt), instanceId: randomUUID(), startedAt: new Date().toISOString(), + scope, + ...(resourceKey ? { resourceKey } : {}), label, cwd: process.cwd(), currentDirectory: process.cwd() @@ -592,7 +742,9 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o getProcessIdentity, getProcessStartedAtIdentity, syncDirectoryImpl, - platform + platform, + lockScope: scope, + resourceKey }); await createCandidateDirectory( candidateDir, @@ -621,14 +773,15 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o // then publish the already-durable owner inode with a no-replace link. await fsImpl.mkdir(lockDir, { mode: 0o700 }); canonicalReserved = true; - canonicalDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl); + canonicalDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, scope); await fsImpl.link(candidateOwnerPath, ownerPath); ownerLinked = true; - const publishedDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl); + const publishedDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, scope); if (!sameDirectoryIdentity(canonicalDirectoryIdentity, publishedDirectoryIdentity)) { throw lockExistsError( lockDir, - "The canonical reservation changed identity while owner.json was being published; the live claim is retained because publication is uncertain." + "The canonical reservation changed identity while owner.json was being published; the live claim is retained because publication is uncertain.", + { lockScope: scope } ); } published = true; @@ -640,9 +793,10 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o if (canonicalReserved) { throw error; } - const existingDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl); + const existingDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, scope); if (existingDirectoryIdentity !== null) { - const existingOwner = await readLockOwner(ownerPath, fsImpl); + const existingOwner = await readLockOwner(ownerPath, fsImpl, scope); + assertOwnerResource(existingOwner, lockDir, scope, resourceKey); let live; try { live = await isOwnerLive( @@ -651,15 +805,24 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o getProcessStartedAtIdentity ); } catch (identityError) { - throw lockExistsError(lockDir, `The recorded owner could not be verified (${identityError.message}).`); + throw lockExistsError( + lockDir, + `The recorded owner could not be verified (${identityError.message}).`, + { lockScope: scope } + ); } if (live) { - throw lockExistsError(lockDir, `PID ${existingOwner.pid} is still the verified owner.`); + throw lockExistsError( + lockDir, + `PID ${existingOwner.pid} is still the verified owner.`, + { busy: true, lockScope: scope } + ); } if (staleReclaimAttempts >= staleReclaimAttemptLimit) { throw lockExistsError( lockDir, - `Stale-lock reclamation exceeded the bounded limit of ${staleReclaimAttemptLimit} attempts.` + `Stale-lock reclamation exceeded the bounded limit of ${staleReclaimAttemptLimit} attempts.`, + { lockScope: scope } ); } staleReclaimAttempts += 1; @@ -670,7 +833,8 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o existingDirectoryIdentity, fsImpl, syncDirectoryImpl, - platform + platform, + scope )) { await syncDirectoryImpl(parentDir, { fsImpl, platform }); } @@ -695,6 +859,7 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o syncDirectoryImpl, platform, canonicalDirectoryIdentity, + scope, "acquire-failed" ); canonicalCleanupSafe = true; @@ -706,10 +871,12 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o // rmdir is intentionally non-recursive: if another runtime populated // the reserved directory, preserve it. Since link never succeeded, our // independent claim can still be released safely below. - const cleanupDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl); + const cleanupDirectoryIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, scope); if (!sameDirectoryIdentity(cleanupDirectoryIdentity, canonicalDirectoryIdentity)) { - throw new Error( - `Refusing to remove empty reservation ${lockDir} because its directory identity changed.` + throw lockUnverifiableError( + lockDir, + `Refusing to remove empty reservation ${lockDir} because its directory identity changed.`, + { lockScope: scope } ); } await fsImpl.rmdir(lockDir); @@ -726,17 +893,29 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o } if (claimPath && (canonicalCleanupSafe || !ownerLinked)) { try { - await removeOwnedClaim(claimPath, owner, fsImpl, syncDirectoryImpl, platform); + await removeOwnedClaim( + claimPath, + owner, + fsImpl, + syncDirectoryImpl, + platform, + scope + ); } catch (cleanupError) { cleanupFailures.push(cleanupError); } } if (cleanupFailures.length > 0) { - throw new AggregateError( + const aggregate = new AggregateError( [error, ...cleanupFailures], `Lock acquisition failed and cleanup was incomplete: ${error.message}`, { cause: error } ); + throw lockUnverifiableError(lockDir, aggregate.message, { + cause: aggregate, + causeCode: error?.code, + lockScope: scope + }); } throw error; } @@ -752,9 +931,124 @@ export async function acquirePathLock(lockPath, label = "codex-provider-sync", o fsImpl, syncDirectoryImpl, platform, - canonicalDirectoryIdentity + canonicalDirectoryIdentity, + scope + ); + await removeOwnedClaim( + claimPath, + owner, + fsImpl, + syncDirectoryImpl, + platform, + scope ); - await removeOwnedClaim(claimPath, owner, fsImpl, syncDirectoryImpl, platform); released = true; }; } + +/** + * Read-only inspection of a protocol lock. This never reclaims or removes a + * stale/ambiguous owner: callers use it to avoid observing protected state + * while another runtime may be mutating it. + */ +export async function inspectPathLock(lockPath, options = {}) { + const { + fsImpl = fs, + getProcessIdentity = getProcessStartMarker, + getProcessStartedAtIdentity = getProcessStartedAt, + scope = "codex-home", + resourceKey = null + } = options; + if (!LOCK_SCOPES.has(scope)) { + throw new TypeError(`scope must be one of: ${[...LOCK_SCOPES].join(", ")}.`); + } + if (scope === "state-db" + && (typeof resourceKey !== "string" || !/^[a-f0-9]{64}$/.test(resourceKey))) { + throw new TypeError("resourceKey must be a lowercase SHA-256 hex string for a state-db lock."); + } + + const lockDir = path.resolve(lockPath); + const claimsDir = `${lockDir}.claims`; + const inspectOwner = async (ownerPath) => { + const owner = await readLockOwner(ownerPath, fsImpl, scope); + assertOwnerResource(owner, lockDir, scope, resourceKey); + let live; + try { + live = await isOwnerLive(owner, getProcessIdentity, getProcessStartedAtIdentity); + } catch (error) { + throw lockExistsError( + lockDir, + `The recorded owner could not be verified (${error.message}).`, + { lockScope: scope } + ); + } + if (!live) { + throw lockExistsError( + lockDir, + "The recorded owner is stale; read-only inspection preserves it fail-closed.", + { lockScope: scope } + ); + } + return owner; + }; + + const canonicalIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, scope); + if (canonicalIdentity !== null) { + const owner = await inspectOwner(path.join(lockDir, "owner.json")); + const verifiedIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, scope); + if (!sameDirectoryIdentity(canonicalIdentity, verifiedIdentity)) { + throw lockExistsError( + lockDir, + "The canonical lock identity changed during read-only inspection.", + { lockScope: scope } + ); + } + const verifiedOwner = await readLockOwner(path.join(lockDir, "owner.json"), fsImpl, scope); + assertOwnerResource(verifiedOwner, lockDir, scope, resourceKey); + if (!ownerMatchesExpected(verifiedOwner, owner)) { + throw lockExistsError( + lockDir, + "The canonical owner identity changed during read-only inspection.", + { lockScope: scope } + ); + } + return Object.freeze({ state: "active", scope, resourceKey, owner }); + } + + let entries = []; + try { + entries = await fsImpl.readdir(claimsDir, { withFileTypes: true }); + } catch (error) { + if (error?.code !== "ENOENT") { + throw lockExistsError( + claimsDir, + "The claims directory cannot be inspected safely.", + { cause: error, causeCode: error?.code, lockScope: scope } + ); + } + } + const claims = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .sort((left, right) => left.name.localeCompare(right.name)); + if (claims.length > 0) { + const owner = await inspectOwner(path.join(claimsDir, claims[0].name)); + if (!owner.instanceId || claims[0].name !== `${owner.instanceId}.json`) { + throw lockExistsError( + claimsDir, + "A live claim has no matching immutable instance identity.", + { lockScope: scope } + ); + } + return Object.freeze({ state: "active", scope, resourceKey, owner }); + } + + const finalIdentity = await inspectCanonicalDirectory(lockDir, fsImpl, scope); + if (finalIdentity !== null) { + throw lockExistsError( + lockDir, + "A lock appeared during read-only inspection.", + { lockScope: scope } + ); + } + return Object.freeze({ state: "absent", scope, resourceKey, owner: null }); +} diff --git a/src/operation-coordinator.js b/src/operation-coordinator.js new file mode 100644 index 0000000..f4a4b10 --- /dev/null +++ b/src/operation-coordinator.js @@ -0,0 +1,225 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +import { CoreError } from "./core-error.js"; + +function homeKey(codexHome, platform = process.platform) { + const resolved = path.resolve(codexHome); + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function clone(value) { + return value === null || value === undefined + ? value + : JSON.parse(JSON.stringify(value)); +} + +export class OperationCoordinator { + constructor({ + randomOperationId = randomUUID, + now = () => Date.now(), + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout + } = {}) { + this.randomOperationId = randomOperationId; + this.now = now; + this.setTimeoutImpl = setTimeoutImpl; + this.clearTimeoutImpl = clearTimeoutImpl; + this.active = new Map(); + this.snapshots = new Map(); + this.manualIntents = new Map(); + this.manualIntentExpiryTimers = new Map(); + this.manualPriorityWaiters = new Map(); + } + + _sweepManualIntents(key) { + const intents = this.manualIntents.get(key); + if (!intents) return null; + const now = this.now(); + for (const [planId, expiresAtMs] of intents) { + if (now >= expiresAtMs) intents.delete(planId); + } + if (intents.size === 0) { + this.manualIntents.delete(key); + return null; + } + return intents; + } + + _hasManualPriority(key) { + return this.active.get(key)?.actor === "manual" + || Boolean(this._sweepManualIntents(key)?.size); + } + + _clearManualIntentExpiryTimer(key) { + const timer = this.manualIntentExpiryTimers.get(key); + if (timer !== undefined) this.clearTimeoutImpl(timer); + this.manualIntentExpiryTimers.delete(key); + } + + _armManualIntentExpiryTimer(key) { + this._clearManualIntentExpiryTimer(key); + const intents = this._sweepManualIntents(key); + if (!intents?.size) return; + const earliestExpiry = Math.min(...intents.values()); + const timer = this.setTimeoutImpl(() => { + if (this.manualIntentExpiryTimers.get(key) !== timer) return; + this.manualIntentExpiryTimers.delete(key); + this._sweepManualIntents(key); + this._armManualIntentExpiryTimer(key); + this._settleManualPriorityWaiters(key); + }, Math.max(0, earliestExpiry - this.now())); + timer?.unref?.(); + this.manualIntentExpiryTimers.set(key, timer); + } + + _settleManualPriorityWaiters(key) { + const waiters = this.manualPriorityWaiters.get(key); + if (!waiters) return; + if (this._hasManualPriority(key)) { + return; + } + this.manualPriorityWaiters.delete(key); + for (const waiter of waiters) { + waiter.resolve(); + } + } + + registerManualIntent(codexHome, planId, expiresAt, platform = process.platform) { + if (typeof planId !== "string" || !planId) { + throw new TypeError("Manual intent planId must be a non-empty string."); + } + const expiresAtMs = typeof expiresAt === "number" ? expiresAt : Date.parse(expiresAt); + if (!Number.isFinite(expiresAtMs)) { + throw new TypeError("Manual intent expiresAt must be a valid timestamp."); + } + const key = homeKey(codexHome, platform); + const intents = this._sweepManualIntents(key) ?? new Map(); + intents.set(planId, expiresAtMs); + this.manualIntents.set(key, intents); + this._armManualIntentExpiryTimer(key); + } + + releaseManualIntent(codexHome, planId, platform = process.platform) { + const key = homeKey(codexHome, platform); + const intents = this.manualIntents.get(key); + if (intents) { + intents.delete(planId); + if (intents.size === 0) this.manualIntents.delete(key); + } + this._armManualIntentExpiryTimer(key); + this._settleManualPriorityWaiters(key); + } + + cacheStatus(codexHome, snapshot, platform = process.platform) { + this.snapshots.set(homeKey(codexHome, platform), clone(snapshot)); + } + + statusDuringWrite(codexHome, platform = process.platform, expectedProfile = null) { + const key = homeKey(codexHome, platform); + const operation = this.active.get(key); + if (!operation) return null; + return this.statusForBlockedWrite(codexHome, operation, platform, expectedProfile); + } + + statusForBlockedWrite(codexHome, operation, platform = process.platform, expectedProfile = null) { + const key = homeKey(codexHome, platform); + const candidate = this.snapshots.get(key); + const cached = candidate + && (!expectedProfile + || (candidate.profileId === expectedProfile.id + && candidate.profileRevision === expectedProfile.publicRevision)) + ? candidate + : null; + if (!cached) { + return { + schemaVersion: 1, + snapshotAt: new Date(this.now()).toISOString(), + codexHome: path.resolve(codexHome), + operationInProgress: clone(operation), + rolloutScanComplete: false, + lockedRolloutFiles: [] + }; + } + return { + ...clone(cached), + operationInProgress: clone(operation) + }; + } + + begin(codexHome, operation, { + actor = "manual", + planId = null, + platform = process.platform + } = {}) { + const key = homeKey(codexHome, platform); + if (this.active.has(key)) { + if (actor === "manual" && planId) { + this.releaseManualIntent(codexHome, planId, platform); + } + throw new CoreError("OPERATION_BUSY", "Lock already exists for this Codex Home; another write operation is active.", { + details: { busyScope: "codex-home" } + }); + } + if (actor === "watch" && this._sweepManualIntents(key)?.size) { + throw new CoreError("OPERATION_BUSY", "A confirmed manual operation has priority for this Codex Home.", { + details: { busyScope: "codex-home", reason: "manual-intent" } + }); + } + try { + const active = Object.freeze({ + operationId: this.randomOperationId(), + operation, + actor, + startedAt: new Date(this.now()).toISOString() + }); + this.active.set(key, active); + if (actor === "manual" && planId) { + this.releaseManualIntent(codexHome, planId, platform); + } + return active; + } catch (error) { + if (actor === "manual" && planId) { + this.releaseManualIntent(codexHome, planId, platform); + } + throw error; + } + } + + end(codexHome, operationId, platform = process.platform) { + const key = homeKey(codexHome, platform); + if (this.active.get(key)?.operationId !== operationId) return; + this.active.delete(key); + this._settleManualPriorityWaiters(key); + } + + waitForManualOperation(codexHome, platform = process.platform) { + const key = homeKey(codexHome, platform); + if (!this._hasManualPriority(key)) return null; + let waiter; + const promise = new Promise((resolve) => { + waiter = { resolve }; + const waiters = this.manualPriorityWaiters.get(key) ?? new Set(); + waiters.add(waiter); + this.manualPriorityWaiters.set(key, waiters); + }); + return { + promise, + cancel: () => { + const waiters = this.manualPriorityWaiters.get(key); + waiters?.delete(waiter); + if (waiters?.size === 0) this.manualPriorityWaiters.delete(key); + } + }; + } + + hasManualOperation(codexHome, platform = process.platform) { + return this.active.get(homeKey(codexHome, platform))?.actor === "manual"; + } + + isActive(codexHome, platform = process.platform) { + return this.active.has(homeKey(codexHome, platform)); + } +} + +export const sharedOperationCoordinator = new OperationCoordinator(); diff --git a/src/operation-revision.js b/src/operation-revision.js new file mode 100644 index 0000000..2af8e36 --- /dev/null +++ b/src/operation-revision.js @@ -0,0 +1,300 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { CoreError } from "./core-error.js"; + +const SESSION_SCOPES = ["sessions", "archived_sessions"]; +const LOCKED_FILE_CODES = new Set(["EACCES", "EBUSY", "EPERM", "ETXTBSY"]); + +function canonicalJsonValue(value) { + if (Array.isArray(value)) return value.map(canonicalJsonValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalJsonValue(value[key])]) + ); + } + return value; +} + +export function stableStringify(value) { + return JSON.stringify(canonicalJsonValue(value)); +} + +export function sha256Revision(value) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"); + return createHash("sha256").update(bytes).digest("base64url"); +} + +function comparablePath(value, platform) { + if (typeof value !== "string") return null; + const resolved = path.resolve(value); + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +async function statOrNull(filePath, fsImpl) { + try { + return await fsImpl.stat(filePath, { bigint: true }); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function statIdentity(stats) { + return { + size: stats.size.toString(), + mtimeNs: stats.mtimeNs.toString(), + ctimeNs: stats.ctimeNs.toString() + }; +} + +function sameStat(left, right) { + return left && right + && left.size === right.size + && left.mtimeNs === right.mtimeNs + && left.ctimeNs === right.ctimeNs; +} + +async function captureStableFile(filePath, fsImpl, { allowLocked = false } = {}) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const beforeStats = await statOrNull(filePath, fsImpl); + if (!beforeStats) return { present: false }; + if (!beforeStats.isFile()) { + throw new CoreError("STALE_STATE", "A revision target is not a regular file.", { + details: { reason: "storage" } + }); + } + const before = statIdentity(beforeStats); + try { + const bytes = await fsImpl.readFile(filePath); + const afterStats = await statOrNull(filePath, fsImpl); + const after = afterStats ? statIdentity(afterStats) : null; + if (sameStat(before, after)) { + return { present: true, ...after, sha256: sha256Revision(bytes) }; + } + } catch (error) { + if (allowLocked && LOCKED_FILE_CODES.has(error?.code)) { + return { + present: true, + ...before, + locked: true, + causeCode: error.code + }; + } + throw error; + } + } + throw new CoreError("STALE_STATE", "A revision target changed while it was being captured.", { + details: { reason: "storage" } + }); +} + +async function captureStableMetadata(filePath, fsImpl, { allowLocked = false } = {}) { + for (let attempt = 0; attempt < 2; attempt += 1) { + let beforeStats; + try { + beforeStats = await statOrNull(filePath, fsImpl); + } catch (error) { + if (allowLocked && LOCKED_FILE_CODES.has(error?.code)) { + return { present: true, locked: true, causeCode: error.code }; + } + throw error; + } + if (!beforeStats) return { present: false }; + if (!beforeStats.isFile()) { + throw new CoreError("STALE_STATE", "A revision target is not a regular file.", { + details: { reason: "storage" } + }); + } + const before = statIdentity(beforeStats); + try { + const afterStats = await statOrNull(filePath, fsImpl); + const after = afterStats ? statIdentity(afterStats) : null; + if (sameStat(before, after)) return { present: true, ...after }; + } catch (error) { + if (allowLocked && LOCKED_FILE_CODES.has(error?.code)) { + return { + present: true, + ...before, + locked: true, + causeCode: error.code + }; + } + throw error; + } + } + throw new CoreError("STALE_STATE", "A revision target changed while it was being captured.", { + details: { reason: "storage" } + }); +} + +async function listRolloutFiles(rootDir, fsImpl) { + let entries; + try { + entries = await fsImpl.readdir(rootDir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...await listRolloutFiles(fullPath, fsImpl)); + } else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) { + files.push(fullPath); + } else if (entry.isSymbolicLink()) { + throw new CoreError("STALE_STATE", "A rollout revision contains an unsupported symbolic link.", { + details: { reason: "rollout" } + }); + } + } + return files; +} + +export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "content" } = {}) { + if (mode !== "content" && mode !== "metadata") { + throw new CoreError("INVALID_INPUT", "Unsupported rollout revision mode."); + } + const manifest = []; + const lockedRolloutFiles = []; + for (const scope of SESSION_SCOPES) { + const scopeRoot = path.join(codexHome, scope); + for (const filePath of await listRolloutFiles(scopeRoot, fsImpl)) { + const relativePath = path.relative(codexHome, filePath).split(path.sep).join("/"); + const revision = mode === "metadata" + ? await captureStableMetadata(filePath, fsImpl, { allowLocked: true }) + : await captureStableFile(filePath, fsImpl, { allowLocked: true }); + manifest.push({ path: relativePath, ...revision }); + if (revision.locked) lockedRolloutFiles.push(relativePath); + } + } + manifest.sort((left, right) => left.path.localeCompare(right.path)); + return { + revision: sha256Revision(stableStringify(manifest)), + fileCount: manifest.length, + rolloutScanComplete: lockedRolloutFiles.length === 0, + lockedRolloutFiles + }; +} + +export async function captureStateDbRevision(storage, { fsImpl = fs, platform = process.platform } = {}) { + const stateDbPath = storage.stateDbLocation?.path ?? null; + if (!stateDbPath) { + return sha256Revision(stableStringify({ stateDb: null })); + } + const manifest = []; + for (const suffix of ["", "-wal", "-shm"]) { + manifest.push({ + path: suffix || "state_5.sqlite", + revision: await captureStableFile(`${stateDbPath}${suffix}`, fsImpl) + }); + } + return sha256Revision(stableStringify({ + stateDbPath: comparablePath(stateDbPath, platform), + source: storage.stateDbLocation.source, + manifest + })); +} + +async function listDirectoryFiles(rootDir, currentDir, fsImpl) { + let entries = await fsImpl.readdir(currentDir, { withFileTypes: true }); + entries = entries.sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + files.push(...await listDirectoryFiles(rootDir, fullPath, fsImpl)); + } else if (entry.isFile()) { + files.push({ + path: path.relative(rootDir, fullPath).split(path.sep).join("/"), + revision: await captureStableFile(fullPath, fsImpl) + }); + } else { + throw new CoreError("RESTORE_VALIDATION_FAILED", "A managed backup contains an unsupported linked target."); + } + } + return files; +} + +export async function captureBackupRevision(backupDir, { fsImpl = fs } = {}) { + const root = path.resolve(backupDir); + const stats = await statOrNull(root, fsImpl); + if (!stats?.isDirectory()) { + throw new CoreError("RESTORE_VALIDATION_FAILED", "The selected managed backup is unavailable."); + } + const files = await listDirectoryFiles(root, root, fsImpl); + files.sort((left, right) => left.path.localeCompare(right.path)); + return sha256Revision(stableStringify(files)); +} + +export function captureConfigRevision(configText) { + return sha256Revision(Buffer.from(configText, "utf8")); +} + +export function captureStorageRevision({ profileRevision, configRevision, storage, platform = process.platform }) { + return sha256Revision(stableStringify({ + schemaVersion: 1, + profileRevision, + configRevision, + codexHome: comparablePath(storage.codexHome, platform), + sqliteHome: comparablePath(storage.sqliteHome, platform), + sqliteHomeSource: storage.sqliteHomeSource, + sqliteAccess: { + supported: storage.sqliteAccess?.supported !== false, + reason: storage.sqliteAccess?.reason ?? null + }, + allowLegacyRootFallback: Boolean(storage.allowLegacyRootFallback), + stateDbLocation: storage.stateDbLocation + ? { + path: comparablePath(storage.stateDbLocation.path, platform), + source: storage.stateDbLocation.source + } + : null + })); +} + +export async function captureOperationRevisions({ + codexHome, + profileRevision, + configText, + storage, + backupDir = null, + rolloutRevisionMode = "content", + platform = process.platform, + fsImpl = fs +}) { + const configRevision = captureConfigRevision(configText); + const [rollout, stateDbRevision, backupRevision] = await Promise.all([ + captureRolloutRevision(codexHome, { fsImpl, mode: rolloutRevisionMode }), + captureStateDbRevision(storage, { fsImpl, platform }), + backupDir ? captureBackupRevision(backupDir, { fsImpl }) : Promise.resolve(null) + ]); + return { + profileRevision, + configRevision, + storageRevision: captureStorageRevision({ profileRevision, configRevision, storage, platform }), + rolloutRevision: rollout.revision, + stateDbRevision, + ...(backupRevision ? { backupRevision } : {}), + rolloutScanComplete: rollout.rolloutScanComplete, + lockedRolloutFiles: rollout.lockedRolloutFiles, + rolloutFileCount: rollout.fileCount + }; +} + +export function revisionMismatch(expected, actual) { + for (const [field, reason] of [ + ["profileRevision", "profile"], + ["configRevision", "config"], + ["storageRevision", "storage"], + ["rolloutRevision", "rollout"], + ["stateDbRevision", "state-db"], + ["backupRevision", "backup"] + ]) { + if ((expected[field] ?? null) !== (actual[field] ?? null)) return reason; + } + return null; +} diff --git a/src/plan-ledger.js b/src/plan-ledger.js new file mode 100644 index 0000000..b466822 --- /dev/null +++ b/src/plan-ledger.js @@ -0,0 +1,145 @@ +import { randomBytes } from "node:crypto"; + +import { CoreError } from "./core-error.js"; + +export const PLAN_SCHEMA_VERSION = 1; +export const DEFAULT_PLAN_TTL_MS = 10 * 60 * 1000; + +const OPERATIONS = new Set(["sync", "switch", "restore"]); + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const entry of Object.values(value)) deepFreeze(entry); + return Object.freeze(value); +} + +function invalidPlanInput(message) { + return new CoreError("INVALID_INPUT", message); +} + +function unavailablePlan() { + return new CoreError( + "PLAN_EXPIRED", + "The prepared operation is no longer available. Prepare and confirm it again." + ); +} + +export class PlanLedger { + constructor({ + now = () => Date.now(), + randomId = () => randomBytes(32).toString("base64url"), + ttlMs = DEFAULT_PLAN_TTL_MS, + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout + } = {}) { + if (typeof now !== "function" || typeof randomId !== "function") { + throw new TypeError("PlanLedger clock and random id source must be functions."); + } + if (!Number.isInteger(ttlMs) || ttlMs <= 0) { + throw new TypeError("PlanLedger ttlMs must be a positive integer."); + } + this.now = now; + this.randomId = randomId; + this.ttlMs = ttlMs; + this.setTimeoutImpl = setTimeoutImpl; + this.clearTimeoutImpl = clearTimeoutImpl; + this.entries = new Map(); + this.expiryTimer = null; + } + + _sweepExpired() { + const now = this.now(); + for (const [planId, entry] of this.entries) { + if (now >= entry.expiresAtMs) this.entries.delete(planId); + } + } + + _clearExpiryTimer() { + if (this.expiryTimer !== null) this.clearTimeoutImpl(this.expiryTimer); + this.expiryTimer = null; + } + + _armExpiryTimer() { + this._clearExpiryTimer(); + this._sweepExpired(); + if (this.entries.size === 0) return; + const earliestExpiry = Math.min(...[...this.entries.values()].map((entry) => entry.expiresAtMs)); + const timer = this.setTimeoutImpl(() => { + if (this.expiryTimer !== timer) return; + this.expiryTimer = null; + this._sweepExpired(); + this._armExpiryTimer(); + }, Math.max(0, earliestExpiry - this.now())); + timer?.unref?.(); + this.expiryTimer = timer; + } + + issue(operation, summary, internal) { + if (!OPERATIONS.has(operation)) { + throw new TypeError(`Unsupported plan operation: ${String(operation)}`); + } + const createdAtMs = this.now(); + this._sweepExpired(); + const planId = this.randomId(); + if (typeof planId !== "string" || !/^[A-Za-z0-9_-]{32,128}$/.test(planId)) { + throw new TypeError("Plan id source returned an invalid opaque id."); + } + if (this.entries.has(planId)) { + throw new CoreError("INTERNAL_ERROR", "The plan id source produced a duplicate id."); + } + const issuedSummary = deepFreeze({ + ...cloneJson(summary), + schemaVersion: PLAN_SCHEMA_VERSION, + planId, + operation, + createdAt: new Date(createdAtMs).toISOString(), + expiresAt: new Date(createdAtMs + this.ttlMs).toISOString(), + requiresConfirmation: true + }); + this.entries.set(planId, { + operation, + expiresAtMs: createdAtMs + this.ttlMs, + summary: issuedSummary, + internal + }); + this._armExpiryTimer(); + return issuedSummary; + } + + consume(input, expectedOperation) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw invalidPlanInput("Apply requires a schemaVersion and planId object."); + } + const keys = Object.keys(input).sort(); + if (keys.length !== 2 || keys[0] !== "planId" || keys[1] !== "schemaVersion") { + throw invalidPlanInput("Apply accepts only schemaVersion and planId."); + } + if (input.schemaVersion !== PLAN_SCHEMA_VERSION + || typeof input.planId !== "string" + || !/^[A-Za-z0-9_-]{32,128}$/.test(input.planId)) { + throw invalidPlanInput("Apply requires schemaVersion 1 and a valid opaque planId."); + } + if (!OPERATIONS.has(expectedOperation)) { + throw new TypeError(`Unsupported plan operation: ${String(expectedOperation)}`); + } + const entry = this.entries.get(input.planId); + if (!entry || entry.operation !== expectedOperation) throw unavailablePlan(); + + // Consumption is deliberately atomic and happens before waiting for any + // filesystem lock. A failed, stale, busy, or cancelled Apply cannot replay + // an old confirmation. + this.entries.delete(input.planId); + this._armExpiryTimer(); + if (this.now() >= entry.expiresAtMs) throw unavailablePlan(); + return entry; + } + + get size() { + this._sweepExpired(); + return this.entries.size; + } +} diff --git a/src/public-api.d.ts b/src/public-api.d.ts new file mode 100644 index 0000000..5848942 --- /dev/null +++ b/src/public-api.d.ts @@ -0,0 +1,65 @@ +/** + * @internal Transitional declarations for the legacy root package boundary. + * New vNext hosts use @codex-provider-sync/core and its trusted profile facade. + */ +export const CORE_ERROR_CODES: readonly string[]; + +export class CoreError extends Error { + readonly code: string; + readonly severity: string; + readonly retryable: boolean; + readonly recoveryRequired: boolean; + readonly operationId?: string; + readonly details?: Record; + readonly suggestedAction?: string; + constructor(code: string, message: string, options?: Record); + toDto(): Record; +} + +export function toCoreErrorDto(error: unknown, options?: Record): Record; +export function getStatus(options?: Record): Promise; +export function prepareSync(options?: Record): Promise; +/** @internal Trusted host control. Never expose this object to HTTP, IPC, or Renderer input. */ +export interface CoreHostOperationControl { + signal?: AbortSignal; + onOperationStarted?(value: { operationId: string; operation: "sync" | "switch" | "restore" }): void | Promise; + onProgress?(event: Record): void | Promise; +} + +export function applySync(input: Record, control?: CoreHostOperationControl): Promise; +export function prepareSwitch(options?: Record): Promise; +export function applySwitch(input: Record, control?: CoreHostOperationControl): Promise; +export function prepareRestore(options?: Record): Promise; +export function applyRestore(input: Record, control?: CoreHostOperationControl): Promise; +export function pruneBackups(options?: Record): Promise; +export function listBackups(codexHome: string): Promise; +export function listHistory(codexHome: string, options?: Record): Promise; +export function getHistorySession(codexHome: string, sessionId: string, options?: Record): Promise; +export function startWatch(options?: Record): Promise; +export function stopWatch(input: Record): Promise; +export function getWatchStatus(input?: Record | null): unknown; +export function getDiagnostics(options?: Record): Promise; + +/** @deprecated Use prepareSync/applySync through the trusted Core facade. */ +export function runSync(options?: Record): Promise; +/** @deprecated Use prepareSwitch/applySwitch through the trusted Core facade. */ +export function runSwitch(options?: Record): Promise; +/** @deprecated Use prepareRestore/applyRestore through the trusted Core facade. */ +export function runRestore(options?: Record): Promise; +/** @deprecated Use pruneBackups through the trusted Core facade. */ +export function runPruneBackups(options?: Record): Promise; +/** @deprecated Use startWatch/stopWatch through the trusted Core facade. */ +export function runWatch(options?: Record): Promise; + +/** @internal Legacy root helper; not part of the vNext Core facade. */ +export function readConfigText(filePath: string): Promise; +/** @internal Legacy root helper; not part of the vNext Core facade. */ +export function readRootModelFromConfigText(text: string): string | null; +/** @internal Legacy root helper; not part of the vNext Core facade. */ +export function detectStateDb(input: unknown): Promise; +/** @internal Legacy root helper; not part of the vNext Core facade. */ +export function ensureCodexHome(input: unknown): Promise; +/** @internal Legacy root helper; not part of the vNext Core facade. */ +export function resolveStorageLayout(input: unknown): unknown; +/** @internal Legacy root helper; not part of the vNext Core facade. */ +export function withStateDbLocation(input: unknown, location: unknown): unknown; diff --git a/src/public-api.js b/src/public-api.js new file mode 100644 index 0000000..f7e696c --- /dev/null +++ b/src/public-api.js @@ -0,0 +1,40 @@ +// The only supported Node Core import surface for product entry points. +// +// This is intentionally a thin facade during the staged vNext migration: the +// current implementation stays in its existing modules, while CLI, Web, and +// future desktop transports depend on this stable boundary rather than those +// implementation details. +// +// The runSync/runSwitch/runRestore/runWatch exports are migration adapters. +// New transports must use the prepare/apply APIs once those land in C3; the +// adapters are retained here only for CLI/Web compatibility during migration. + +export { CORE_ERROR_CODES, CoreError, toCoreErrorDto } from "./core-error.js"; + +export { + applyRestore, + applySwitch, + applySync, + getStatus, + pruneBackups, + prepareRestore, + prepareSwitch, + prepareSync, + runPruneBackups, + runRestore, + runSwitch, + runSync +} from "./service.js"; + +export { getDiagnostics } from "./diagnostics.js"; + +export { listBackups } from "./backup.js"; +export { getHistorySession, listHistory } from "./history.js"; +export { readConfigText, readRootModelFromConfigText } from "./config-file.js"; +export { detectStateDb } from "./sqlite-state.js"; +export { + ensureCodexHome, + resolveStorageLayout, + withStateDbLocation +} from "./storage-layout.js"; +export { getWatchStatus, runWatch, startWatch, stopWatch } from "./watch.js"; diff --git a/src/restore-journal.js b/src/restore-journal.js new file mode 100644 index 0000000..9d8b2d5 --- /dev/null +++ b/src/restore-journal.js @@ -0,0 +1,533 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import { defaultBackupRoot } from "./constants.js"; +import { syncDirectory } from "./atomic-file.js"; + +export const RESTORE_JOURNAL_BASENAME = "restore-journal.v2.jsonl"; +export const RESTORE_JOURNAL_SCHEMA_VERSION = 2; + +const TERMINAL_STATES = new Set(["completed", "rolled-back", "recovery-required"]); +const NON_BLOCKING_STATES = new Set(["completed", "rolled-back"]); +const VALID_STATES = new Set([ + "prepared", + "applying", + "committing", + "committed-pending-ack", + "completed", + "rollback-pending", + "rolled-back", + "recovery-required" +]); + +const VALID_TRANSITIONS = new Map([ + ["prepared", new Set(["applying", "rollback-pending", "recovery-required"])], + ["applying", new Set(["applying", "committing", "rollback-pending", "recovery-required"])], + ["committing", new Set(["committed-pending-ack", "rollback-pending", "recovery-required"])], + ["committed-pending-ack", new Set(["completed", "recovery-required"])], + ["rollback-pending", new Set(["rollback-pending", "rolled-back", "recovery-required"])], + ["completed", new Set()], + ["rolled-back", new Set()], + ["recovery-required", new Set()] +]); + +async function appendDurableJsonLine(filePath, value) { + const handle = await fs.open(filePath, "a"); + try { + await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } +} + +function validIdentity(value) { + return value + && typeof value === "object" + && !Array.isArray(value) + && typeof value.backupId === "string" + && value.backupId.length > 0 + && typeof value.backupDir === "string" + && path.isAbsolute(value.backupDir) + && typeof value.revision === "string" + && value.revision.length > 0; +} + +function validDigest(value) { + return value + && typeof value === "object" + && !Array.isArray(value) + && typeof value.present === "boolean" + && typeof value.digestKind === "string" + && value.digestKind.length > 0 + && typeof value.digest === "string" + && value.digest.length > 0; +} + +function preparedTargetKindsMatch(event) { + if (!Array.isArray(event.targets) || !Array.isArray(event.requiredTargetKinds)) return false; + const targetKinds = new Set(event.targets.map((target) => target.kind)); + const requiredKinds = new Set(event.requiredTargetKinds); + return requiredKinds.size === event.requiredTargetKinds.length + && targetKinds.size === requiredKinds.size + && [...targetKinds].every((kind) => requiredKinds.has(kind)); +} + +function validPrepared(event) { + return event.operationKind === "restore" + && validIdentity(event.sourceBackup) + && validIdentity(event.preRestoreSnapshot) + && typeof event.preRestoreSnapshot.manifestSha256 === "string" + && event.preRestoreSnapshot.manifestSha256.length > 0 + && event.storage + && typeof event.storage === "object" + && !Array.isArray(event.storage) + && typeof event.storage.codexHome === "string" + && path.isAbsolute(event.storage.codexHome) + && typeof event.storage.codexHomePhysical === "string" + && path.isAbsolute(event.storage.codexHomePhysical) + && Array.isArray(event.targets) + && event.targets.length > 0 + && event.targets.every((target) => + target + && typeof target === "object" + && typeof target.id === "string" + && target.id.length > 0 + && typeof target.kind === "string" + && typeof target.targetPath === "string" + && path.isAbsolute(target.targetPath) + && validDigest(target.pre) + && validDigest(target.expectedPost) + ) + && new Set(event.targets.map((target) => target.id)).size === event.targets.length + && Array.isArray(event.requiredTargetKinds) + && event.requiredTargetKinds.every((value) => typeof value === "string" && value.length > 0) + && preparedTargetKindsMatch(event) + && Array.isArray(event.resolvesOperationIds ?? []) + && (event.resolvesOperationIds ?? []).every((value) => typeof value === "string" && value.length > 0); +} + +function validateEvents(parsedEvents) { + const events = []; + let validationError = null; + let operationId = null; + let expectedSequence = 1; + let state = null; + let prepared = null; + const targetIds = new Set(); + const targetsById = new Map(); + const targetPhases = new Map(); + let committingHash = null; + + for (const event of parsedEvents) { + const fail = (message) => { + validationError = message; + return false; + }; + if (!event || typeof event !== "object" || Array.isArray(event)) { + fail("Restore journal event is not an object."); + break; + } + if (event.schemaVersion !== RESTORE_JOURNAL_SCHEMA_VERSION + || event.protocolVersion !== RESTORE_JOURNAL_SCHEMA_VERSION + || event.operationKind !== "restore" + || !VALID_STATES.has(event.state)) { + fail("Restore journal event has an unsupported schema, protocol, kind, or state."); + break; + } + if (typeof event.operationId !== "string" || event.operationId.length === 0) { + fail("Restore journal event is missing operationId."); + break; + } + if (event.sequence !== expectedSequence) { + fail(`Restore journal sequence mismatch: expected ${expectedSequence}, received ${event.sequence}.`); + break; + } + if (operationId === null) { + if (event.state !== "prepared" || !validPrepared(event)) { + fail("Restore journal must start with a valid prepared event."); + break; + } + operationId = event.operationId; + prepared = event; + state = "prepared"; + for (const target of event.targets) { + targetIds.add(target.id); + targetsById.set(target.id, target); + } + } else { + if (event.operationId !== operationId) { + fail("Restore journal operationId changed within one operation."); + break; + } + if (!VALID_TRANSITIONS.get(state)?.has(event.state)) { + fail(`Restore journal transition ${state} -> ${event.state} is invalid.`); + break; + } + state = event.state; + } + + if (event.targetId !== undefined + || event.targetPhase !== undefined + || event.targetDigest !== undefined) { + if (!targetIds.has(event.targetId) + || !new Set(["intent", "completed", "compensated"]).has(event.targetPhase)) { + fail("Restore journal target transition is malformed or undeclared."); + break; + } + const previous = targetPhases.get(event.targetId) ?? null; + if (event.targetPhase === "intent") { + if (event.state !== "applying" || previous !== null) { + fail("Restore target intent is duplicated or outside applying."); + break; + } + } else if (event.targetPhase === "completed") { + if (event.state !== "applying" || previous !== "intent" + || typeof event.targetDigest !== "string" + || event.targetDigest.length === 0 + || event.targetDigest !== targetsById.get(event.targetId)?.expectedPost?.digest) { + fail("Restore target completion has no matching intent or digest."); + break; + } + } else { + if (event.state !== "rollback-pending" + || previous === "compensated" + || typeof event.targetDigest !== "string" + || event.targetDigest.length === 0 + || event.targetDigest !== targetsById.get(event.targetId)?.pre?.digest) { + fail("Restore target compensation is outside rollback-pending or has the wrong digest."); + break; + } + } + targetPhases.set(event.targetId, event.targetPhase); + } + + if (event.state === "committing") { + if (targetPhases.size !== targetIds.size + || [...targetIds].some((targetId) => targetPhases.get(targetId) !== "completed") + || typeof event.postManifestSha256 !== "string" + || event.postManifestSha256.length === 0) { + fail("Restore cannot commit before every declared target is completed."); + break; + } + committingHash = event.postManifestSha256; + } else if (event.state === "committed-pending-ack") { + if (typeof event.postManifestSha256 !== "string" + || event.postManifestSha256.length === 0 + || event.postManifestSha256 !== committingHash) { + fail("Restore commit acknowledgement hash does not match committing evidence."); + break; + } + } else if (event.state === "rolled-back" + && (targetPhases.size !== targetIds.size + || [...targetIds].some((targetId) => targetPhases.get(targetId) !== "compensated"))) { + fail("Restore cannot become rolled-back before every declared target is compensated."); + break; + } + + events.push(event); + expectedSequence += 1; + } + + return { events, operationId, state, prepared, validationError, targetPhases }; +} + +export async function readRestoreJournal(filePath) { + const rawText = await fs.readFile(filePath, "utf8"); + const parsedEvents = []; + let parseError = rawText.length > 0 && !rawText.endsWith("\n") + ? "Restore journal is missing its final newline and may contain a torn append." + : null; + for (const line of rawText.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + parsedEvents.push(JSON.parse(line)); + } catch { + parseError = "Restore journal contains a truncated or malformed JSON line."; + break; + } + } + const validated = validateEvents(parsedEvents); + const rawPrepared = parsedEvents[0]; + const protectionReferences = { + sourceBackupDir: typeof rawPrepared?.sourceBackup?.backupDir === "string" + && path.isAbsolute(rawPrepared.sourceBackup.backupDir) + ? path.resolve(rawPrepared.sourceBackup.backupDir) + : null, + preRestoreSnapshotDir: typeof rawPrepared?.preRestoreSnapshot?.backupDir === "string" + && path.isAbsolute(rawPrepared.preRestoreSnapshot.backupDir) + ? path.resolve(rawPrepared.preRestoreSnapshot.backupDir) + : path.dirname(path.resolve(filePath)) + }; + const protectionReferencesUnverifiable = !( + typeof rawPrepared?.sourceBackup?.backupDir === "string" + && path.isAbsolute(rawPrepared.sourceBackup.backupDir) + && typeof rawPrepared?.preRestoreSnapshot?.backupDir === "string" + && path.isAbsolute(rawPrepared.preRestoreSnapshot.backupDir) + ); + const validationError = parseError ?? validated.validationError; + const invalidTail = validationError !== null || validated.events.length !== parsedEvents.length; + const state = invalidTail ? "recovery-required" : (validated.state ?? "recovery-required"); + return { + filePath: path.resolve(filePath), + snapshotDir: path.dirname(path.resolve(filePath)), + backupDir: path.dirname(path.resolve(filePath)), + operationKind: "restore", + events: validated.events, + operationId: validated.operationId, + prepared: validated.prepared, + state, + invalidTail, + validationError, + terminal: !invalidTail && TERMINAL_STATES.has(state), + blocking: invalidTail || !NON_BLOCKING_STATES.has(state), + targetPhases: validated.targetPhases, + protectionReferences, + protectionReferencesUnverifiable, + rawText + }; +} + +export class RestoreJournal { + constructor(filePath, operationId, sequence = 0) { + this.filePath = path.resolve(filePath); + this.operationId = operationId; + this.sequence = sequence; + } + + static async create(snapshotDir, details) { + const operationId = details.operationId ?? randomUUID(); + const filePath = path.join(snapshotDir, RESTORE_JOURNAL_BASENAME); + const event = { + ...details, + schemaVersion: RESTORE_JOURNAL_SCHEMA_VERSION, + protocolVersion: RESTORE_JOURNAL_SCHEMA_VERSION, + operationKind: "restore", + operationId, + sequence: 1, + state: "prepared", + recordedAt: new Date().toISOString() + }; + if (!validPrepared(event)) { + throw new Error("Restore journal prepared payload is invalid."); + } + const handle = await fs.open(filePath, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(event)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(path.dirname(filePath)); + return new RestoreJournal(filePath, operationId, 1); + } + + async append(state, details = {}) { + const event = { + ...details, + schemaVersion: RESTORE_JOURNAL_SCHEMA_VERSION, + protocolVersion: RESTORE_JOURNAL_SCHEMA_VERSION, + operationKind: "restore", + operationId: this.operationId, + sequence: this.sequence + 1, + state, + recordedAt: new Date().toISOString() + }; + try { + await appendDurableJsonLine(this.filePath, event); + this.sequence = event.sequence; + } catch (error) { + try { + const current = await readRestoreJournal(this.filePath); + const last = current.events.at(-1) ?? null; + if (!current.invalidTail && JSON.stringify(last) === JSON.stringify(event)) { + this.sequence = event.sequence; + } else if (!current.invalidTail) { + this.sequence = last?.sequence ?? this.sequence; + } + } catch { + // Preserve the original durability failure. + } + throw error; + } + } + + async applying() { + await this.append("applying"); + } + + async targetIntent(targetId) { + await this.append("applying", { targetId, targetPhase: "intent" }); + } + + async targetCompleted(targetId, targetDigest) { + await this.append("applying", { targetId, targetPhase: "completed", targetDigest }); + } + + async committing(postManifestSha256) { + await this.append("committing", { postManifestSha256 }); + } + + async committedPendingAck(postManifestSha256) { + await this.append("committed-pending-ack", { postManifestSha256 }); + } + + async completed() { + await this.append("completed"); + } + + async rollbackPending(reasonCode = "restore-failed") { + await this.append("rollback-pending", { reasonCode }); + } + + async targetCompensated(targetId, targetDigest) { + await this.append("rollback-pending", { + targetId, + targetPhase: "compensated", + targetDigest + }); + } + + async rolledBack() { + await this.append("rolled-back"); + } + + async recoveryRequired(reasonCode = "evidence-unverifiable") { + await this.append("recovery-required", { reasonCode }); + } +} + +export function reopenRestoreJournal(snapshot) { + return new RestoreJournal( + snapshot.filePath, + snapshot.operationId, + snapshot.events.at(-1)?.sequence ?? 0 + ); +} + +export async function findRestoreJournals(codexHome) { + const root = defaultBackupRoot(codexHome); + let entries; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + const journals = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const filePath = path.join(root, entry.name, RESTORE_JOURNAL_BASENAME); + try { + journals.push(await readRestoreJournal(filePath)); + } catch (error) { + if (error?.code !== "ENOENT") { + journals.push({ + filePath, + snapshotDir: path.dirname(filePath), + backupDir: path.dirname(filePath), + operationKind: "restore", + events: [], + operationId: null, + prepared: null, + state: "recovery-required", + invalidTail: true, + validationError: error instanceof Error ? error.message : String(error), + terminal: false, + blocking: true, + targetPhases: new Map(), + protectionReferences: null, + protectionReferencesUnverifiable: true, + rawText: "" + }); + } + } + } + return journals.sort((left, right) => left.filePath.localeCompare(right.filePath)); +} + +export async function findBlockingRestoreJournals(codexHome) { + const journals = await findRestoreJournals(codexHome); + const journalsByOperationId = new Map(); + for (const journal of journals) { + if (!journal.operationId) continue; + const matches = journalsByOperationId.get(journal.operationId) ?? []; + matches.push(journal); + journalsByOperationId.set(journal.operationId, matches); + } + const resolvedOperationIds = new Set(); + for (const resolver of journals) { + if (resolver.invalidTail || resolver.state !== "completed" || !resolver.prepared) continue; + for (const operationId of resolver.prepared.resolvesOperationIds ?? []) { + const matches = journalsByOperationId.get(operationId) ?? []; + if (matches.length !== 1) continue; + const pending = matches[0]; + const resolverSource = resolver.prepared.sourceBackup; + const pendingSource = pending.prepared?.sourceBackup; + const resolverKinds = new Set(resolver.prepared.requiredTargetKinds ?? []); + const pendingKinds = pending.prepared?.requiredTargetKinds; + const [resolverSourceKey, pendingSourceKey, resolverHomeKey, pendingHomeKey] = pendingSource + && pending.prepared + ? await Promise.all([ + physicalPathKey(resolverSource.backupDir), + physicalPathKey(pendingSource.backupDir), + physicalPathKey(resolver.prepared.storage.codexHome), + physicalPathKey(pending.prepared.storage.codexHome) + ]) + : [null, null, null, null]; + const resolverRecordedHomeKey = persistedPhysicalPathKey( + resolver.prepared.storage.codexHomePhysical + ); + const pendingRecordedHomeKey = persistedPhysicalPathKey( + pending.prepared?.storage?.codexHomePhysical + ); + const sameSource = pendingSource + && resolverSourceKey !== null + && resolverSourceKey === pendingSourceKey + && resolverSource.revision === pendingSource.revision; + const sameHome = pending.prepared + && resolverHomeKey !== null + && pendingHomeKey !== null + && resolverRecordedHomeKey !== null + && pendingRecordedHomeKey !== null + && resolverHomeKey === pendingHomeKey + && resolverHomeKey === resolverRecordedHomeKey + && pendingHomeKey === pendingRecordedHomeKey; + const completeCoverage = Array.isArray(pendingKinds) + && pendingKinds.every((kind) => resolverKinds.has(kind)); + if (pending.blocking && !pending.invalidTail && sameSource && sameHome && completeCoverage) { + resolvedOperationIds.add(operationId); + } + } + } + return journals.filter((journal) => + journal.blocking + && !(journal.operationId && resolvedOperationIds.has(journal.operationId)) + ); +} + +async function physicalPathKey(value) { + try { + const lexical = path.resolve(value); + const first = path.resolve(await fs.realpath(lexical)); + const stat = await fs.stat(first); + const second = path.resolve(await fs.realpath(lexical)); + if (!stat.isDirectory() || persistedPhysicalPathKey(first) !== persistedPhysicalPathKey(second)) { + return null; + } + return persistedPhysicalPathKey(first); + } catch { + return null; + } +} + +function persistedPhysicalPathKey(value) { + try { + if (typeof value !== "string" || !path.isAbsolute(value)) return null; + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; + } catch { + return null; + } +} diff --git a/src/restore-v2.js b/src/restore-v2.js new file mode 100644 index 0000000..2e05d25 --- /dev/null +++ b/src/restore-v2.js @@ -0,0 +1,1386 @@ +import fs from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; + +import { + BACKUP_NAMESPACE, + DB_FILE_BASENAME, + GLOBAL_STATE_BACKUP_FILE_BASENAME, + GLOBAL_STATE_FILE_BASENAME, + defaultBackupRoot +} from "./constants.js"; +import { CoreError } from "./core-error.js"; +import { writeFileAtomic } from "./atomic-file.js"; +import { + copyFileAtomic, + prepareRestoreBackup, + refreshBackupInventory, + restoreBackup +} from "./backup.js"; +import { sha256Revision, stableStringify } from "./operation-revision.js"; +import { + captureSessionRestoreEntries, + restoreSessionChanges +} from "./session-files.js"; +import { + createSqliteOnlineBackup, + restoreSqliteOnlineBackup +} from "./sqlite-state.js"; +import { + RestoreJournal, + readRestoreJournal, + reopenRestoreJournal +} from "./restore-journal.js"; +import { resolveStateDbLockResource } from "./state-db-lock.js"; +import { markBackupTransactionRolledBack } from "./transaction-journal.js"; + +export const RESTORE_SNAPSHOT_MANIFEST_BASENAME = "restore-snapshot.v2.json"; + +function compareOrdinal(left, right) { + return left < right ? -1 : (left > right ? 1 : 0); +} + +function pathKey(value, platform = process.platform) { + const resolved = path.resolve(value); + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function restoreBoundaryError(errorCode, targetKind, cause) { + return new CoreError( + errorCode, + errorCode === "RECOVERY_REQUIRED" + ? "A Restore target physical boundary can no longer be verified." + : "A Restore target physical boundary cannot be verified.", + { + cause: cause instanceof Error ? cause : undefined, + details: { + operationKind: "restore", + ...(errorCode === "LOCK_UNVERIFIABLE" ? { lockScope: "codex-home" } : {}), + ...(typeof targetKind === "string" ? { targetKind } : {}) + } + } + ); +} + +async function resolveStablePhysicalDirectory(directory, platform, errorCode, targetKind = null) { + try { + const lexical = path.resolve(directory); + const first = await fs.realpath(lexical); + const stat = await fs.stat(first); + if (!stat.isDirectory()) { + throw new Error("Restore physical directory identity is not a directory."); + } + const second = await fs.realpath(lexical); + if (pathKey(first, platform) !== pathKey(second, platform)) { + throw new Error("Restore physical directory identity changed while it was resolved."); + } + return path.resolve(first); + } catch (error) { + if (error instanceof CoreError && error.code === errorCode) throw error; + throw restoreBoundaryError(errorCode, targetKind, error); + } +} + +export async function captureStableRestoreSource( + backupDir, + { platform = process.platform, errorCode = "RESTORE_VALIDATION_FAILED" } = {} +) { + const physicalBackupDir = await resolveStablePhysicalDirectory( + backupDir, + platform, + errorCode, + "sourceBackup" + ); + return { + backupId: path.basename(physicalBackupDir), + backupDir: physicalBackupDir, + revision: await captureRestoreSourceIdentity(physicalBackupDir) + }; +} + +async function restoreTargetRelativePath(target, physicalHome, platform, errorCode) { + const targetPath = path.resolve(target?.targetPath ?? ""); + const compare = (left, right) => platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; + if (target.kind === "config") { + if (!compare(path.basename(targetPath), "config.toml")) { + throw restoreBoundaryError(errorCode, target.kind); + } + const parentPhysical = await resolveStablePhysicalDirectory( + path.dirname(targetPath), + platform, + errorCode, + target.kind + ); + if (pathKey(parentPhysical, platform) !== pathKey(physicalHome, platform)) { + throw restoreBoundaryError(errorCode, target.kind); + } + return ["config.toml"]; + } else if (target.kind === "globalState") { + const allowed = [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]; + const fileName = path.basename(targetPath); + if (!allowed.some((name) => compare(fileName, name))) { + throw restoreBoundaryError(errorCode, target.kind); + } + const parentPhysical = await resolveStablePhysicalDirectory( + path.dirname(targetPath), + platform, + errorCode, + target.kind + ); + if (pathKey(parentPhysical, platform) !== pathKey(physicalHome, platform)) { + throw restoreBoundaryError(errorCode, target.kind); + } + return [allowed.find((name) => compare(fileName, name))]; + } else if (target.kind === "rollout") { + if (!/^rollout-.*\.jsonl$/i.test(path.basename(targetPath))) { + throw restoreBoundaryError(errorCode, target.kind); + } + let rawRoot = path.dirname(targetPath); + while (true) { + if (["sessions", "archived_sessions"].some((name) => compare(path.basename(rawRoot), name))) break; + const parent = path.dirname(rawRoot); + if (parent === rawRoot) throw restoreBoundaryError(errorCode, target.kind); + rawRoot = parent; + } + const rootParentPhysical = await resolveStablePhysicalDirectory( + path.dirname(rawRoot), + platform, + errorCode, + target.kind + ); + if (pathKey(rootParentPhysical, platform) !== pathKey(physicalHome, platform)) { + throw restoreBoundaryError(errorCode, target.kind); + } + const nested = path.relative(rawRoot, targetPath); + if (!nested + || nested === ".." + || nested.startsWith(`..${path.sep}`) + || path.isAbsolute(nested)) { + throw restoreBoundaryError(errorCode, target.kind); + } + const rootName = ["sessions", "archived_sessions"] + .find((name) => compare(path.basename(rawRoot), name)); + return [rootName, ...nested.split(path.sep).filter(Boolean)]; + } else { + throw restoreBoundaryError(errorCode, target.kind); + } +} + +async function verifyNonSqliteRestoreTargetBoundary( + target, + manifestStorage, + runtimeStorage, + { platform = process.platform, errorCode = "RECOVERY_REQUIRED" } = {} +) { + if (target?.kind === "sqlite") return; + const { physicalHome } = await verifyRestoreHomePhysicalIdentity( + manifestStorage, + runtimeStorage, + { platform, errorCode, targetKind: target?.kind } + ); + const segments = await restoreTargetRelativePath(target, physicalHome, platform, errorCode); + let current = physicalHome; + for (let index = 0; index < segments.length; index += 1) { + current = path.join(current, segments[index]); + let stat; + try { + stat = await fs.lstat(current); + } catch (error) { + const isLast = index === segments.length - 1; + if (error?.code === "ENOENT" + && isLast + && (target.kind === "config" || target.kind === "globalState")) { + return; + } + throw restoreBoundaryError(errorCode, target.kind, error); + } + if (stat.isSymbolicLink()) { + throw restoreBoundaryError(errorCode, target.kind); + } + const isLast = index === segments.length - 1; + if ((!isLast && !stat.isDirectory()) || (isLast && !stat.isFile())) { + throw restoreBoundaryError(errorCode, target.kind); + } + } +} + +async function verifyRestoreHomePhysicalIdentity( + manifestStorage, + runtimeStorage, + { platform = process.platform, errorCode = "RECOVERY_REQUIRED", targetKind = null } = {} +) { + if (!manifestStorage + || typeof manifestStorage.codexHome !== "string" + || typeof manifestStorage.codexHomePhysical !== "string" + || !path.isAbsolute(manifestStorage.codexHome) + || !path.isAbsolute(manifestStorage.codexHomePhysical) + || typeof runtimeStorage?.codexHome !== "string") { + throw restoreBoundaryError(errorCode, targetKind); + } + const lexicalHome = path.resolve(manifestStorage.codexHome); + const recordedPhysicalHome = path.resolve(manifestStorage.codexHomePhysical); + const [manifestPhysicalHome, runtimePhysicalHome] = await Promise.all([ + resolveStablePhysicalDirectory(lexicalHome, platform, errorCode, targetKind), + resolveStablePhysicalDirectory(runtimeStorage.codexHome, platform, errorCode, targetKind) + ]); + if (pathKey(manifestPhysicalHome, platform) !== pathKey(recordedPhysicalHome, platform) + || pathKey(runtimePhysicalHome, platform) !== pathKey(recordedPhysicalHome, platform)) { + throw restoreBoundaryError(errorCode, targetKind); + } + return { lexicalHome, physicalHome: manifestPhysicalHome }; +} + +function emitRestoreProgress(onProgress, event) { + if (typeof onProgress !== "function") return; + try { + const result = onProgress(event); + if (result && typeof result.then === "function") result.catch(() => {}); + } catch { + // Progress is observational and cannot alter Restore transaction state. + } +} + +function targetId(kind, targetPath, platform = process.platform) { + return sha256Revision(`${kind}\0${pathKey(targetPath, platform)}`); +} + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +async function digestFile(filePath) { + const fullPath = path.resolve(filePath); + for (let attempt = 0; attempt < 2; attempt += 1) { + let before; + try { + before = await fs.stat(fullPath, { bigint: true }); + } catch (error) { + if (error?.code === "ENOENT") { + return { present: false, digestKind: "absent", digest: sha256Revision("absent") }; + } + throw error; + } + if (!before.isFile()) { + throw new CoreError("RESTORE_VALIDATION_FAILED", "A Restore target is not a regular file."); + } + const hash = createHash("sha256"); + await new Promise((resolve, reject) => { + const stream = createReadStream(fullPath); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", resolve); + stream.on("error", reject); + }); + const after = await fs.stat(fullPath, { bigint: true }).catch((error) => { + if (error?.code === "ENOENT") return null; + throw error; + }); + if (after + && before.size === after.size + && before.mtimeNs === after.mtimeNs + && before.ctimeNs === after.ctimeNs) { + return { + present: true, + digestKind: "sha256-file", + digest: hash.digest("base64url"), + sizeBytes: Number(after.size) + }; + } + } + throw new CoreError("STALE_STATE", "A Restore target changed while its digest was captured.", { + details: { reason: "restore-target" } + }); +} + +async function listIdentityFiles(rootDir, currentDir = rootDir) { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + entries.sort((left, right) => compareOrdinal(left.name, right.name)); + const files = []; + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + files.push(...await listIdentityFiles(rootDir, fullPath)); + } else if (entry.isFile()) { + const digest = await digestFile(fullPath); + files.push({ + path: path.relative(rootDir, fullPath).split(path.sep).join("/"), + sha256: digest.digest + }); + } else { + throw new CoreError( + "RESTORE_VALIDATION_FAILED", + "A managed Restore source contains an unsupported linked entry." + ); + } + } + return files; +} + +// Cross-runtime Restore identity: relative POSIX-style path ordering plus +// SHA-256 of each file's bytes. Timestamps and platform file IDs are excluded +// so Node and .NET can produce exactly the same durable source identity. +export async function captureRestoreSourceIdentity(backupDir) { + const root = path.resolve(backupDir); + const files = await listIdentityFiles(root); + files.sort((left, right) => compareOrdinal(left.path, right.path)); + return sha256Revision(stableStringify(files)); +} + +function digestSessionEntry(entry) { + return { + present: true, + digestKind: "sha256-rollout-metadata", + digest: sha256Revision(stableStringify({ + originalFirstLine: entry.originalFirstLine, + originalSeparator: entry.originalSeparator ?? "\n", + originalTurnContextModels: entry.originalTurnContextModels ?? [] + })) + }; +} + +async function digestRolloutTarget(targetPath) { + const [entry] = await captureSessionRestoreEntries([targetPath]); + return digestSessionEntry(entry); +} + +async function digestSqliteTarget(sqlitePath, scratchDir) { + if (!await exists(sqlitePath)) { + return { present: false, digestKind: "absent", digest: sha256Revision("absent") }; + } + const scratchPath = path.join(scratchDir, `.sqlite-digest-${randomUUID()}.sqlite`); + try { + const backup = await createSqliteOnlineBackup({ + stateDbLocation: { path: path.resolve(sqlitePath), source: "restore-v2-digest" } + }, scratchPath); + if (!backup.databasePresent) { + throw new CoreError("STALE_STATE", "The State DB disappeared while its Restore digest was captured.", { + details: { reason: "state-db" } + }); + } + const bytes = await fs.readFile(scratchPath); + if (bytes.length < 100 || bytes.subarray(0, 16).toString("binary") !== "SQLite format 3\0") { + throw new CoreError("RESTORE_VALIDATION_FAILED", "Restore SQLite digest source has an invalid header."); + } + // Preserve the destination's rollback/WAL mode while comparing logical + // DB content, and canonicalize the paired volatile change counters. + bytes.fill(0, 18, 20); + bytes.fill(0, 24, 28); + bytes.fill(0, 92, 96); + bytes.fill(0, 96, 100); + return { + present: true, + digestKind: "sha256-sqlite-online-backup", + digest: createHash("sha256").update(bytes).digest("base64url"), + sizeBytes: bytes.length + }; + } finally { + await Promise.all([ + scratchPath, + `${scratchPath}-wal`, + `${scratchPath}-shm` + ].map((value) => fs.rm(value, { force: true }).catch(() => {}))); + } +} + +async function digestTarget(target, scratchDir) { + if (target.kind === "rollout") return digestRolloutTarget(target.targetPath); + if (target.kind === "sqlite") return digestSqliteTarget(target.targetPath, scratchDir); + return digestFile(target.targetPath); +} + +function sameDigest(left, right) { + return Boolean(left && right) + && left.present === right.present + && left.digestKind === right.digestKind + && left.digest === right.digest; +} + +async function expectedPostDigest(target, pre, scratchDir) { + if (target.kind === "rollout") return digestSessionEntry(target.sourceEntry); + if (target.kind === "sqlite") return digestSqliteTarget(target.sourcePath, scratchDir); + if (target.kind === "globalState" && target.sourceAction === "delete") { + return { present: false, digestKind: "absent", digest: sha256Revision("absent") }; + } + if (target.kind === "globalState" && target.sourceAction === "preserve") { + return pre; + } + return digestFile(target.sourcePath); +} + +function snapshotRelativePath(target) { + if (target.kind === "config") return "config.toml"; + if (target.kind === "globalState") return path.basename(target.targetPath); + if (target.kind === "sqlite") return path.join("db", "sqlite-home", DB_FILE_BASENAME); + return null; +} + +async function captureFileSnapshot(target, snapshotDir, pre) { + const relativePath = snapshotRelativePath(target); + if (!relativePath || !pre.present) return null; + const destinationPath = path.join(snapshotDir, relativePath); + await copyFileAtomic(target.targetPath, destinationPath); + const copied = await digestFile(destinationPath); + if (!sameDigest(pre, copied)) { + throw new CoreError("BACKUP_FAILED", "A Restore pre-snapshot file did not match its source digest."); + } + return relativePath.split(path.sep).join("/"); +} + +function throwIfAborted(signal) { + if (!signal?.aborted) return; + const error = new Error("The Restore operation was cancelled."); + error.name = "AbortError"; + error.code = "ABORT_ERR"; + throw error; +} + +async function createPreRestoreSnapshot({ + operationId, + storage, + sourceBackup, + sourcePlan, + stateDbResource, + resolvesOperationIds, + faultInjector, + signal, + platform = process.platform +}) { + throwIfAborted(signal); + const codexHomePhysical = await resolveStablePhysicalDirectory( + storage.codexHome, + platform, + "LOCK_UNVERIFIABLE" + ); + const boundaryStorage = { + codexHome: path.resolve(storage.codexHome), + codexHomePhysical + }; + const backupRoot = defaultBackupRoot(storage.codexHome); + await fs.mkdir(backupRoot, { recursive: true }); + const backupId = `restore-v2-${operationId}`; + const snapshotDir = path.join(backupRoot, backupId); + await fs.mkdir(snapshotDir, { recursive: false }); + try { + const rolloutEntries = []; + const targets = []; + for (const sourceTarget of sourcePlan.targets) { + throwIfAborted(signal); + await verifyNonSqliteRestoreTargetBoundary( + sourceTarget, + boundaryStorage, + storage, + { platform, errorCode: "LOCK_UNVERIFIABLE" } + ); + const id = targetId(sourceTarget.kind, sourceTarget.targetPath, platform); + let pre; + let snapshotPath = null; + let snapshotEntryIndex = null; + if (sourceTarget.kind === "rollout") { + const [entry] = await captureSessionRestoreEntries([sourceTarget.targetPath]); + pre = digestSessionEntry(entry); + snapshotEntryIndex = rolloutEntries.length; + rolloutEntries.push(entry); + } else if (sourceTarget.kind === "sqlite") { + try { + pre = await digestSqliteTarget(sourceTarget.targetPath, snapshotDir); + } catch (error) { + throw new Error(`Restore pre-target SQLite digest failed: ${error instanceof Error ? error.message : String(error)}`, { + cause: error instanceof Error ? error : undefined + }); + } + if (pre.present) { + snapshotPath = snapshotRelativePath(sourceTarget); + const destinationPath = path.join(snapshotDir, snapshotPath); + let backup; + try { + backup = await createSqliteOnlineBackup({ + stateDbLocation: { + path: path.resolve(sourceTarget.targetPath), + source: "restore-v2-pre-snapshot" + } + }, destinationPath); + } catch (error) { + throw new Error(`Restore SQLite pre-snapshot copy failed: ${error instanceof Error ? error.message : String(error)}`, { + cause: error instanceof Error ? error : undefined + }); + } + if (!backup.databasePresent) { + throw new CoreError("BACKUP_FAILED", "The State DB disappeared during the Restore pre-snapshot."); + } + let copied; + try { + copied = await digestSqliteTarget(destinationPath, snapshotDir); + } catch (error) { + throw new Error(`Restore SQLite pre-snapshot verification failed: ${error instanceof Error ? error.message : String(error)}`, { + cause: error instanceof Error ? error : undefined + }); + } + if (!sameDigest(pre, copied)) { + throw new CoreError("BACKUP_FAILED", "The Restore pre-snapshot SQLite digest did not verify."); + } + } + } else { + pre = await digestFile(sourceTarget.targetPath); + snapshotPath = await captureFileSnapshot(sourceTarget, snapshotDir, pre); + } + await faultInjector?.({ + point: "after_restore_pre_snapshot_target_before_hash", + targetKind: sourceTarget.kind, + targetId: id + }); + let expectedPost; + try { + expectedPost = await expectedPostDigest(sourceTarget, pre, snapshotDir); + } catch (error) { + throw new Error( + `Restore expected-post digest failed for ${sourceTarget.kind}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined } + ); + } + targets.push({ + id, + kind: sourceTarget.kind, + targetPath: path.resolve(sourceTarget.targetPath), + pre, + expectedPost, + snapshotPath, + snapshotEntryIndex + }); + } + + const createdAt = new Date().toISOString(); + const sessionManifest = { + version: 2, + namespace: BACKUP_NAMESPACE, + backupKind: "restore-pre-snapshot", + codexHome: storage.codexHome, + targetProvider: sourcePlan.metadata?.targetProvider ?? null, + createdAt, + appliedPaths: rolloutEntries.map((entry) => entry.path), + files: rolloutEntries + }; + await writeFileAtomic( + path.join(snapshotDir, "session-meta-backup.json"), + JSON.stringify(sessionManifest, null, 2), + "utf8" + ); + + const globalStateFiles = {}; + for (const fileName of [GLOBAL_STATE_FILE_BASENAME, GLOBAL_STATE_BACKUP_FILE_BASENAME]) { + globalStateFiles[fileName] = await exists(path.join(snapshotDir, fileName)); + } + const sqliteTarget = targets.find((target) => target.kind === "sqlite") ?? null; + const manifest = { + schemaVersion: 2, + protocolVersion: 2, + operationKind: "restore", + operationId, + createdAt, + sourceBackup, + preRestoreSnapshot: { backupId, backupDir: path.resolve(snapshotDir) }, + storage: { + codexHome: path.resolve(storage.codexHome), + codexHomePhysical, + sqliteHome: path.resolve(storage.sqliteHome), + stateDbResourceKey: stateDbResource?.resourceKey ?? null, + targetStateDbPath: sqliteTarget?.targetPath ?? null + }, + requiredTargetKinds: [...new Set(targets.map((target) => target.kind))].sort(), + resolvesOperationIds: [...new Set(resolvesOperationIds ?? [])].sort(), + targets + }; + const manifestText = `${JSON.stringify(manifest, null, 2)}\n`; + const manifestSha256 = createHash("sha256").update(manifestText, "utf8").digest("base64url"); + await writeFileAtomic( + path.join(snapshotDir, RESTORE_SNAPSHOT_MANIFEST_BASENAME), + manifestText, + "utf8" + ); + await faultInjector?.({ point: "after_restore_pre_snapshot_manifest_before_prepared" }); + + const metadata = { + version: 2, + namespace: BACKUP_NAMESPACE, + backupKind: "restore-pre-snapshot", + restoreOperationId: operationId, + codexHome: storage.codexHome, + sqliteHome: path.dirname(sqliteTarget?.targetPath ?? storage.sqliteHome), + targetProvider: sourcePlan.metadata?.targetProvider ?? null, + createdAt, + dbFiles: [], + sqliteDbFiles: sqliteTarget?.pre.present ? [DB_FILE_BASENAME] : [], + stateDbPresent: Boolean(sqliteTarget?.pre.present), + configPresent: targets.find((target) => target.kind === "config")?.pre.present ?? false, + globalStateFiles, + changedSessionFiles: rolloutEntries.length, + restoreSnapshotManifestSha256: manifestSha256 + }; + await writeFileAtomic( + path.join(snapshotDir, "metadata.json"), + JSON.stringify(metadata, null, 2), + "utf8" + ); + const revision = manifestSha256; + return { + backupId, + backupDir: path.resolve(snapshotDir), + revision, + manifestSha256, + manifest, + metadata + }; + } catch (error) { + await fs.rm(snapshotDir, { recursive: true, force: true }).catch(() => {}); + if (error instanceof CoreError || error?.name === "AbortError") throw error; + throw new CoreError("BACKUP_FAILED", "Unable to create the Restore pre-snapshot.", { + cause: error instanceof Error ? error : undefined + }); + } +} + +function sameStructuredValue(left, right) { + return stableStringify(left) === stableStringify(right); +} + +function manifestMatchesPrepared(manifest, prepared) { + return sameStructuredValue(manifest.sourceBackup, prepared.sourceBackup) + && sameStructuredValue(manifest.storage, prepared.storage) + && sameStructuredValue(manifest.requiredTargetKinds, prepared.requiredTargetKinds) + && sameStructuredValue(manifest.resolvesOperationIds, prepared.resolvesOperationIds) + && sameStructuredValue(manifest.targets, prepared.targets); +} + +async function readVerifiedSnapshot(journalSnapshot) { + const prepared = journalSnapshot?.prepared; + if (!prepared + || typeof journalSnapshot?.snapshotDir !== "string" + || typeof prepared.preRestoreSnapshot?.backupDir !== "string") { + throw new CoreError("RECOVERY_REQUIRED", "Restore snapshot directory does not match its journal."); + } + const [journalSnapshotPhysical, preparedSnapshotPhysical] = await Promise.all([ + resolveStablePhysicalDirectory( + journalSnapshot.snapshotDir, + process.platform, + "RECOVERY_REQUIRED" + ), + resolveStablePhysicalDirectory( + prepared.preRestoreSnapshot.backupDir, + process.platform, + "RECOVERY_REQUIRED" + ) + ]); + if (pathKey(journalSnapshotPhysical) !== pathKey(preparedSnapshotPhysical)) { + throw new CoreError("RECOVERY_REQUIRED", "Restore snapshot directory does not match its journal."); + } + const manifestPath = path.join( + prepared.preRestoreSnapshot.backupDir, + RESTORE_SNAPSHOT_MANIFEST_BASENAME + ); + const text = await fs.readFile(manifestPath, "utf8"); + const digest = createHash("sha256").update(text, "utf8").digest("base64url"); + if (digest !== prepared.preRestoreSnapshot.manifestSha256) { + throw new CoreError("RECOVERY_REQUIRED", "Restore snapshot manifest verification failed."); + } + let manifest; + try { + manifest = JSON.parse(text); + } catch { + throw new CoreError("RECOVERY_REQUIRED", "Restore snapshot manifest is invalid."); + } + let manifestSnapshotPhysical = null; + if (typeof manifest?.preRestoreSnapshot?.backupDir === "string" + && path.isAbsolute(manifest.preRestoreSnapshot.backupDir)) { + manifestSnapshotPhysical = await resolveStablePhysicalDirectory( + manifest.preRestoreSnapshot.backupDir, + process.platform, + "RECOVERY_REQUIRED" + ); + } + if (manifest?.schemaVersion !== 2 + || manifest?.protocolVersion !== 2 + || manifest?.operationKind !== "restore" + || manifest?.operationId !== prepared.operationId + || manifest?.preRestoreSnapshot?.backupId !== prepared.preRestoreSnapshot.backupId + || manifestSnapshotPhysical === null + || pathKey(manifestSnapshotPhysical) !== pathKey(journalSnapshotPhysical) + || !manifestMatchesPrepared(manifest, prepared)) { + throw new CoreError("RECOVERY_REQUIRED", "Restore snapshot identity verification failed."); + } + return manifest; +} + +async function restoreTargetFromSnapshot(target, manifest, storage) { + const snapshotDir = manifest.preRestoreSnapshot.backupDir; + if (target.kind === "rollout") { + const sessionManifest = JSON.parse( + await fs.readFile(path.join(snapshotDir, "session-meta-backup.json"), "utf8") + ); + const entry = sessionManifest.files?.[target.snapshotEntryIndex]; + if (!entry || pathKey(entry.path) !== pathKey(target.targetPath)) { + throw new Error("Restore snapshot rollout entry is missing or mismatched."); + } + await restoreSessionChanges([entry]); + return; + } + if (target.kind === "sqlite") { + if (target.pre.present) { + await restoreSqliteOnlineBackup( + path.join(snapshotDir, target.snapshotPath), + target.targetPath + ); + } else { + const sidecars = [`${target.targetPath}-wal`, `${target.targetPath}-shm`]; + if ((await Promise.all(sidecars.map(exists))).some(Boolean)) { + throw new Error("Cannot remove a newly created State DB while SQLite sidecars are present."); + } + await fs.rm(target.targetPath, { force: true }); + } + return; + } + if (target.pre.present) { + await copyFileAtomic(path.join(snapshotDir, target.snapshotPath), target.targetPath); + } else { + await fs.rm(target.targetPath, { force: true }); + } +} + +async function verifyManifestTargets( + manifest, + which, + scratchDir, + { storage = manifest.storage, platform = process.platform } = {} +) { + const values = []; + for (const target of manifest.targets) { + await verifyNonSqliteRestoreTargetBoundary( + target, + manifest.storage, + storage, + { platform, errorCode: "RECOVERY_REQUIRED" } + ); + const actual = await digestTarget(target, scratchDir); + const expected = target[which]; + if (!sameDigest(actual, expected)) { + throw new CoreError("RECOVERY_REQUIRED", "A Restore target digest does not match durable evidence.", { + details: { targetKind: target.kind } + }); + } + values.push({ id: target.id, digest: actual.digest }); + } + values.sort((left, right) => compareOrdinal(left.id, right.id)); + return { + values, + manifestSha256: sha256Revision(stableStringify(values)) + }; +} + +async function compensateRestore({ + journalSnapshot, + journal, + storage, + stateDbResource, + resolveStateDbResource, + platform, + faultInjector, + mutateTargets = true, + onProgress +}) { + const manifest = await readVerifiedSnapshot(journalSnapshot); + const currentStateDbKey = journalSnapshot.prepared.storage.stateDbResourceKey ?? null; + if ((manifest.storage.stateDbResourceKey ?? null) !== currentStateDbKey) { + throw new Error("Restore snapshot State DB identity is inconsistent with its journal."); + } + await verifyRestoreHomePhysicalIdentity(manifest.storage, storage, { + platform, + errorCode: "RECOVERY_REQUIRED" + }); + emitRestoreProgress(onProgress, { + stage: "rollback_restore", + status: "start", + count: manifest.targets.length + }); + let compensatedCount = 0; + for (const target of [...manifest.targets].reverse()) { + if (target.kind === "sqlite") { + if (!stateDbResource || !currentStateDbKey) { + throw new Error("Restore compensation has no verified State DB lock identity."); + } + const currentResource = await resolveStateDbResource(target.targetPath, { platform }); + if (currentResource.resourceKey !== currentStateDbKey + || currentResource.resourceKey !== stateDbResource.resourceKey) { + throw new Error("Restore State DB physical identity changed before compensation."); + } + } + await faultInjector?.({ + point: "after_restore_rollback_pending_before_target", + targetKind: target.kind, + targetId: target.id + }); + await verifyNonSqliteRestoreTargetBoundary( + target, + manifest.storage, + storage, + { platform, errorCode: "RECOVERY_REQUIRED" } + ); + if (mutateTargets) { + await restoreTargetFromSnapshot(target, manifest, storage); + } + const actual = await digestTarget(target, manifest.preRestoreSnapshot.backupDir); + if (!sameDigest(actual, target.pre)) { + throw new Error(`Restore compensation digest failed for ${target.kind}.`); + } + await journal.targetCompensated(target.id, actual.digest); + compensatedCount += 1; + emitRestoreProgress(onProgress, { + stage: "rollback_restore", + status: "progress", + progress: compensatedCount / manifest.targets.length, + count: compensatedCount + }); + await faultInjector?.({ + point: "after_restore_compensation_verify_before_next", + targetKind: target.kind, + targetId: target.id + }); + } + await verifyManifestTargets(manifest, "pre", manifest.preRestoreSnapshot.backupDir, { + storage, + platform + }); + emitRestoreProgress(onProgress, { + stage: "rollback_restore", + status: "complete", + progress: 1, + count: compensatedCount + }); +} + +async function acknowledgeCommittedRestore(journalSnapshot, { + faultInjector, + stateDbResource, + storage = journalSnapshot?.prepared?.storage, + onProgress, + resolveStateDbResource = resolveStateDbLockResource, + platform = process.platform +} = {}) { + if (journalSnapshot.invalidTail + || journalSnapshot.state !== "committed-pending-ack" + || !journalSnapshot.prepared) { + throw new CoreError("RECOVERY_REQUIRED", "Restore commit acknowledgement evidence is incomplete."); + } + const manifest = await readVerifiedSnapshot(journalSnapshot); + await verifyRestoreHomePhysicalIdentity(manifest.storage, storage, { + platform, + errorCode: "RECOVERY_REQUIRED" + }); + emitRestoreProgress(onProgress, { + stage: "acknowledge_restore_commit", + status: "start", + count: manifest.targets.length + }); + if (manifest.requiredTargetKinds.includes("sqlite")) { + const sqliteTargets = manifest.targets.filter((target) => target.kind === "sqlite"); + if (!stateDbResource + || sqliteTargets.length !== 1 + || manifest.storage.stateDbResourceKey !== stateDbResource.resourceKey) { + throw new CoreError("RECOVERY_REQUIRED", "Restore State DB identity changed before commit acknowledgement."); + } + const currentResource = await resolveStateDbResource(sqliteTargets[0].targetPath, { platform }); + if (currentResource.resourceKey !== stateDbResource.resourceKey + || currentResource.resourceKey !== manifest.storage.stateDbResourceKey) { + throw new CoreError("RECOVERY_REQUIRED", "Restore State DB physical identity changed before commit acknowledgement."); + } + } + const verified = await verifyManifestTargets( + manifest, + "expectedPost", + manifest.preRestoreSnapshot.backupDir, + { storage, platform } + ); + const committedEvent = [...journalSnapshot.events] + .reverse() + .find((event) => event.state === "committed-pending-ack"); + if (!committedEvent || committedEvent.postManifestSha256 !== verified.manifestSha256) { + throw new CoreError("RECOVERY_REQUIRED", "Restore post-commit manifest acknowledgement failed."); + } + const physicalSourceBackupDir = await resolveStablePhysicalDirectory( + journalSnapshot.prepared.sourceBackup.backupDir, + platform, + "RECOVERY_REQUIRED", + "sourceBackup" + ); + await markBackupTransactionRolledBack(physicalSourceBackupDir); + await faultInjector?.({ point: "after_restore_source_journal_ack_before_completed" }); + const journal = reopenRestoreJournal(journalSnapshot); + await journal.completed(); + const completed = await readRestoreJournal(journal.filePath); + if (completed.invalidTail || completed.state !== "completed") { + throw new CoreError("RECOVERY_REQUIRED", "Restore completed acknowledgement did not persist."); + } + await refreshBackupInventory(manifest.preRestoreSnapshot.backupDir).catch(() => {}); + emitRestoreProgress(onProgress, { + stage: "acknowledge_restore_commit", + status: "complete", + progress: 1, + count: manifest.targets.length + }); + return { completed, manifest }; +} + +export async function restoreJournalMatchesSource(journal, sourceBackup, platform = process.platform) { + const prepared = journal?.prepared; + if (!prepared || !sourceBackup) return false; + if (prepared.sourceBackup.revision !== sourceBackup.revision) { + return false; + } + try { + const [preparedPhysical, runtimePhysical] = await Promise.all([ + resolveStablePhysicalDirectory( + prepared.sourceBackup.backupDir, + platform, + "RECOVERY_REQUIRED" + ), + resolveStablePhysicalDirectory( + sourceBackup.backupDir, + platform, + "RECOVERY_REQUIRED" + ) + ]); + return pathKey(preparedPhysical, platform) === pathKey(runtimePhysical, platform); + } catch { + return false; + } +} + +export async function restoreJournalMatchesPhysicalHome( + journal, + runtimeCodexHome, + platform = process.platform +) { + try { + await verifyRestoreHomePhysicalIdentity( + journal?.prepared?.storage, + { codexHome: runtimeCodexHome }, + { platform, errorCode: "RECOVERY_REQUIRED" } + ); + return true; + } catch { + return false; + } +} + +export function restoreJournalCoverageIsComplete(journal, requestedKinds) { + const required = journal?.prepared?.requiredTargetKinds; + if (!Array.isArray(required)) return false; + const available = new Set(requestedKinds ?? []); + return required.every((kind) => available.has(kind)); +} + +export async function acknowledgePendingRestore(journal, options = {}) { + try { + return await acknowledgeCommittedRestore(journal, options); + } catch (error) { + if (!journal.invalidTail && journal.state === "committed-pending-ack") { + try { + const writer = reopenRestoreJournal(journal); + await writer.recoveryRequired("commit-ack-unverifiable"); + } catch { + // Preserve the original verification failure and durable evidence. + } + } + throw error; + } +} + +export async function executeRestoreV2({ + storage, + sourceBackup, + restoreConfig, + restoreDatabase, + restoreSessions, + allowSqliteHomeRelocation, + stateDbResource, + resolvesOperationIds = [], + faultInjector, + signal, + onProgress, + platform = process.platform, + resolveStateDbResource = resolveStateDbLockResource +}) { + const operationId = randomUUID(); + const initialSourceRevision = await captureRestoreSourceIdentity(sourceBackup.backupDir); + if (initialSourceRevision !== sourceBackup.revision) { + throw new CoreError("STALE_STATE", "The managed Restore source changed before apply.", { + details: { reason: "backup" } + }); + } + emitRestoreProgress(onProgress, { + stage: "create_restore_pre_snapshot", + status: "start", + count: 0 + }); + throwIfAborted(signal); + const sourcePlan = await prepareRestoreBackup(sourceBackup.backupDir, storage, { + restoreConfig, + restoreDatabase, + restoreSessions, + allowSqliteHomeRelocation + }); + const snapshot = await createPreRestoreSnapshot({ + operationId, + storage, + sourceBackup, + sourcePlan, + stateDbResource, + resolvesOperationIds, + faultInjector, + signal, + platform + }); + const preApplySourceRevision = await captureRestoreSourceIdentity(sourceBackup.backupDir); + if (preApplySourceRevision !== sourceBackup.revision) { + await fs.rm(snapshot.backupDir, { recursive: true, force: true }).catch(() => {}); + throw new CoreError("STALE_STATE", "The managed Restore source changed before mutation.", { + details: { reason: "backup" } + }); + } + emitRestoreProgress(onProgress, { + stage: "create_restore_pre_snapshot", + status: "complete", + progress: 1, + count: snapshot.manifest.targets.length + }); + try { + throwIfAborted(signal); + } catch (error) { + await fs.rm(snapshot.backupDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } + const prepared = { + operationId, + sourceBackup, + preRestoreSnapshot: { + backupId: snapshot.backupId, + backupDir: snapshot.backupDir, + revision: snapshot.revision, + manifestSha256: snapshot.manifestSha256 + }, + storage: snapshot.manifest.storage, + requiredTargetKinds: snapshot.manifest.requiredTargetKinds, + resolvesOperationIds: snapshot.manifest.resolvesOperationIds, + targets: snapshot.manifest.targets + }; + let journal; + try { + emitRestoreProgress(onProgress, { + stage: "persist_restore_journal", + status: "start", + count: 0 + }); + journal = await RestoreJournal.create(snapshot.backupDir, prepared); + emitRestoreProgress(onProgress, { + stage: "persist_restore_journal", + status: "complete", + progress: 1, + count: 1 + }); + } catch (error) { + await fs.rm(snapshot.backupDir, { recursive: true, force: true }).catch(() => {}); + throw new CoreError("BACKUP_FAILED", "Unable to persist the Restore journal before mutation.", { + cause: error instanceof Error ? error : undefined + }); + } + + const targetsByKey = new Map( + snapshot.manifest.targets.map((target) => [ + `${target.kind}\0${pathKey(target.targetPath, platform)}`, + target + ]) + ); + const completed = new Map(); + let applyResult = null; + try { + await faultInjector?.({ point: "after_restore_prepared_before_applying" }); + throwIfAborted(signal); + await journal.applying(); + emitRestoreProgress(onProgress, { + stage: "apply_restore_targets", + status: "start", + count: snapshot.manifest.targets.length + }); + applyResult = await restoreBackup(sourceBackup.backupDir, storage, { + restoreConfig, + restoreDatabase, + restoreSessions, + allowSqliteHomeRelocation, + onBeforeTarget: async (sourceTarget) => { + throwIfAborted(signal); + const target = targetsByKey.get( + `${sourceTarget.kind}\0${pathKey(sourceTarget.targetPath, platform)}` + ); + if (!target) { + throw new CoreError("RECOVERY_REQUIRED", "Restore attempted an undeclared target."); + } + await verifyNonSqliteRestoreTargetBoundary( + target, + snapshot.manifest.storage, + storage, + { platform, errorCode: "RECOVERY_REQUIRED" } + ); + await journal.targetIntent(target.id); + await faultInjector?.({ + point: "after_restore_target_intent_before_write", + targetKind: target.kind, + targetId: target.id + }); + await verifyNonSqliteRestoreTargetBoundary( + target, + snapshot.manifest.storage, + storage, + { platform, errorCode: "RECOVERY_REQUIRED" } + ); + }, + onAfterTarget: async (sourceTarget) => { + const target = targetsByKey.get( + `${sourceTarget.kind}\0${pathKey(sourceTarget.targetPath, platform)}` + ); + await faultInjector?.({ + point: "after_restore_target_write_before_complete", + targetKind: target.kind, + targetId: target.id + }); + await verifyNonSqliteRestoreTargetBoundary( + target, + snapshot.manifest.storage, + storage, + { platform, errorCode: "RECOVERY_REQUIRED" } + ); + const actual = await digestTarget(target, snapshot.backupDir); + if (!sameDigest(actual, target.expectedPost)) { + throw new CoreError("RECOVERY_REQUIRED", "Restore target post-write digest verification failed.", { + details: { targetKind: target.kind } + }); + } + await journal.targetCompleted(target.id, actual.digest); + completed.set(target.id, actual.digest); + emitRestoreProgress(onProgress, { + stage: "apply_restore_targets", + status: "progress", + progress: completed.size / snapshot.manifest.targets.length, + count: completed.size + }); + await faultInjector?.({ + point: "after_restore_target_complete", + targetKind: target.kind, + targetId: target.id + }); + } + }); + throwIfAborted(signal); + const verified = await verifyManifestTargets( + snapshot.manifest, + "expectedPost", + snapshot.backupDir, + { storage, platform } + ); + if (completed.size !== snapshot.manifest.targets.length) { + throw new CoreError("RECOVERY_REQUIRED", "Restore did not durably complete every declared target."); + } + emitRestoreProgress(onProgress, { + stage: "apply_restore_targets", + status: "complete", + progress: 1, + count: completed.size + }); + await faultInjector?.({ point: "after_restore_targets_verify_before_committing" }); + emitRestoreProgress(onProgress, { + stage: "commit_restore", + status: "start", + count: snapshot.manifest.targets.length + }); + await journal.committing(verified.manifestSha256); + await faultInjector?.({ point: "after_restore_committing_before_committed_pending_ack" }); + await journal.committedPendingAck(verified.manifestSha256); + emitRestoreProgress(onProgress, { + stage: "commit_restore", + status: "complete", + progress: 1, + count: snapshot.manifest.targets.length + }); + await faultInjector?.({ point: "after_restore_committed_pending_ack_before_completed" }); + const current = await readRestoreJournal(journal.filePath); + const acknowledgement = await acknowledgeCommittedRestore(current, { + faultInjector, + stateDbResource, + storage, + onProgress, + resolveStateDbResource, + platform + }); + return { + ...applyResult, + restoreVersion: 2, + restoreOperationId: operationId, + preRestoreSnapshotId: snapshot.backupId, + restoreJournalState: acknowledgement.completed.state, + resolvedOperationIds: snapshot.manifest.resolvesOperationIds + }; + } catch (error) { + let current; + try { + current = await readRestoreJournal(journal.filePath); + } catch (journalReadError) { + throw new CoreError("RECOVERY_REQUIRED", "Restore journal cannot be read after an interrupted operation.", { + cause: journalReadError instanceof Error ? journalReadError : undefined, + details: { + operationKind: "restore", + restoreOperationId: operationId, + sourceBackupId: sourceBackup.backupId, + preRestoreSnapshotId: snapshot.backupId + } + }); + } + if (current.state === "completed") { + return { + ...applyResult, + restoreVersion: 2, + restoreOperationId: operationId, + preRestoreSnapshotId: snapshot.backupId, + restoreJournalState: "completed", + resolvedOperationIds: snapshot.manifest.resolvesOperationIds + }; + } + if (current.state === "committed-pending-ack" && !current.invalidTail) { + try { + const acknowledgement = await acknowledgeCommittedRestore(current, { + faultInjector, + stateDbResource, + storage, + onProgress, + resolveStateDbResource, + platform + }); + return { + ...applyResult, + restoreVersion: 2, + restoreOperationId: operationId, + preRestoreSnapshotId: snapshot.backupId, + restoreJournalState: acknowledgement.completed.state, + resolvedOperationIds: snapshot.manifest.resolvesOperationIds, + commitAcknowledgementRecovered: true + }; + } catch (ackError) { + try { + await reopenRestoreJournal(current).recoveryRequired("commit-ack-unverifiable"); + } catch { + // The existing committed-pending-ack evidence remains the blocker. + } + throw new CoreError("RECOVERY_REQUIRED", "Restore committed, but its final acknowledgement is unverifiable.", { + cause: ackError instanceof Error ? ackError : undefined, + details: { + operationKind: "restore", + restoreOperationId: operationId, + sourceBackupId: sourceBackup.backupId, + preRestoreSnapshotId: snapshot.backupId + } + }); + } + } + if (current.invalidTail || !current.prepared) { + throw new CoreError("RECOVERY_REQUIRED", "Restore journal evidence is incomplete; compensation was not attempted.", { + details: { + operationKind: "restore", + restoreOperationId: operationId, + sourceBackupId: sourceBackup.backupId, + preRestoreSnapshotId: snapshot.backupId + } + }); + } + const writer = reopenRestoreJournal(current); + const mutationMayHaveOccurred = [...current.targetPhases.values()] + .some((phase) => phase === "intent" || phase === "completed"); + try { + if (current.state !== "rollback-pending") { + await writer.rollbackPending(error?.code ?? "restore-failed"); + } + const rollbackSnapshot = await readRestoreJournal(writer.filePath); + await compensateRestore({ + journalSnapshot: rollbackSnapshot, + journal: writer, + storage, + stateDbResource, + resolveStateDbResource, + platform, + faultInjector, + mutateTargets: mutationMayHaveOccurred, + onProgress + }); + await writer.rolledBack(); + const terminal = await readRestoreJournal(writer.filePath); + if (terminal.invalidTail || terminal.state !== "rolled-back") { + throw new Error("Restore rollback terminal state did not persist."); + } + if (!mutationMayHaveOccurred) throw error; + throw new CoreError( + "SYNC_FAILED_ROLLED_BACK", + `Restore failed and all observed changes were rolled back. ${error instanceof Error ? error.message : String(error)}`, + { + cause: error instanceof Error ? error : undefined, + details: { + operationKind: "restore", + restoreOperationId: operationId, + sourceBackupId: sourceBackup.backupId, + preRestoreSnapshotId: snapshot.backupId, + rollbackStatus: "complete" + } + } + ); + } catch (rollbackError) { + if (rollbackError === error + || (rollbackError?.code === "SYNC_FAILED_ROLLED_BACK" + && rollbackError?.cause === error)) { + throw rollbackError; + } + try { + const latest = await readRestoreJournal(writer.filePath); + if (!latest.invalidTail && latest.state !== "recovery-required") { + await reopenRestoreJournal(latest).recoveryRequired("rollback-unverifiable"); + } + } catch { + // Keep all snapshot/journal evidence for explicit recovery. + } + throw new CoreError("RECOVERY_REQUIRED", "Restore failed and its compensation could not be verified.", { + cause: rollbackError instanceof Error ? rollbackError : undefined, + details: { + operationKind: "restore", + restoreOperationId: operationId, + sourceBackupId: sourceBackup.backupId, + preRestoreSnapshotId: snapshot.backupId + } + }); + } + } +} + +export function protectedRestoreBackupDirectories(journals) { + const protectedPaths = new Set(); + for (const journal of journals ?? []) { + if (!journal?.blocking) continue; + protectedPaths.add(pathKey(journal.snapshotDir)); + if (journal.prepared?.sourceBackup?.backupDir) { + protectedPaths.add(pathKey(journal.prepared.sourceBackup.backupDir)); + } + if (journal.prepared?.preRestoreSnapshot?.backupDir) { + protectedPaths.add(pathKey(journal.prepared.preRestoreSnapshot.backupDir)); + } + } + return protectedPaths; +} diff --git a/src/service.js b/src/service.js index d524be5..2bddf35 100644 --- a/src/service.js +++ b/src/service.js @@ -3,9 +3,11 @@ import fs from "node:fs/promises"; import { DEFAULT_BACKUP_RETENTION_COUNT, + DEFAULT_LOCK_NAME, DEFAULT_PROVIDER, defaultBackupRoot } from "./constants.js"; +import { CoreError } from "./core-error.js"; import { configDeclaresProvider, listConfiguredProviderIds, @@ -21,15 +23,29 @@ import { createBackup, getBackupRecoveryCoverage, getBackupSummary, - pruneBackups, + listBackups, + pruneBackups as pruneManagedBackups, refreshBackupInventory, + resolveRestoreStateDbTargetPath, restoreBackup, restoreGlobalStateFilesFromBackup } from "./backup.js"; -import { acquireLock } from "./locking.js"; +import { acquireLock, inspectPathLock } from "./locking.js"; +import { acquireStateDbLock, resolveStateDbLockResource } from "./state-db-lock.js"; +import { PlanLedger } from "./plan-ledger.js"; +import { sharedOperationCoordinator as operationCoordinator } from "./operation-coordinator.js"; +import { + captureBackupRevision, + captureOperationRevisions, + captureStorageRevision, + revisionMismatch, + sha256Revision, + stableStringify +} from "./operation-revision.js"; import { applySessionChanges, collectSessionChanges, + collectStatusRolloutMetadata, splitLockedSessionChanges, summarizeProviderCounts } from "./session-files.js"; @@ -60,9 +76,31 @@ import { findPendingTransactions, getAppliedJournalTargets, getStartedJournalTargets, - readTransactionJournal, - markBackupTransactionRolledBack + readTransactionJournal } from "./transaction-journal.js"; +import { + acknowledgePendingRestore, + captureStableRestoreSource, + executeRestoreV2, + restoreJournalCoverageIsComplete, + restoreJournalMatchesPhysicalHome, + restoreJournalMatchesSource +} from "./restore-v2.js"; + +const planLedger = new PlanLedger(); + +function issuePreparedPlan(operation, summary, internal) { + const plan = planLedger.issue(operation, summary, internal); + if (internal.actor === "manual") { + operationCoordinator.registerManualIntent( + internal.codexHome, + plan.planId, + plan.expiresAt, + internal.platform + ); + } + return plan; +} function pathComparisonKey(value) { const resolved = path.resolve(value); @@ -81,7 +119,7 @@ function uniqueResolvedPaths(values) { return [...pathsByKey.values()]; } -export class SyncTransactionError extends Error { +export class SyncTransactionError extends CoreError { constructor( originalError, rollbackErrors, @@ -90,12 +128,19 @@ export class SyncTransactionError extends Error { uncompletedTargets, { rollbackStatus = "incomplete", recoveryRequired = true } = {} ) { + const code = recoveryRequired ? "RECOVERY_REQUIRED" : "SYNC_FAILED_ROLLED_BACK"; const message = recoveryRequired ? `Failed to restore state after sync error. Original error: ${originalError.message}. Restore error: ${rollbackErrors.join("; ")}` : `Provider sync failed and all observed changes were rolled back. Original error: ${originalError.message}`; - super(message, { cause: originalError }); + const recoveryInstructions = recoveryRequired + ? `Restore the managed backup at ${backupDir}, inspect the pending transaction journal, then retry.` + : "No manual recovery is required. Inspect the original error, correct its cause, and retry."; + super(code, message, { + cause: originalError, + recoveryRequired, + suggestedAction: recoveryInstructions + }); this.name = "SyncTransactionError"; - this.code = recoveryRequired ? "RECOVERY_REQUIRED" : "SYNC_FAILED_ROLLED_BACK"; this.originalError = originalError; this.rollbackErrors = rollbackErrors; this.backupDir = backupDir; @@ -103,9 +148,7 @@ export class SyncTransactionError extends Error { this.uncompletedTargets = uncompletedTargets; this.rollbackStatus = rollbackStatus; this.recoveryRequired = recoveryRequired; - this.recoveryInstructions = recoveryRequired - ? `Restore the managed backup at ${backupDir}, inspect the pending transaction journal, then retry.` - : "No manual recovery is required. Inspect the original error, correct its cause, and retry."; + this.recoveryInstructions = recoveryInstructions; } } @@ -132,21 +175,37 @@ async function prepareStorage({ codexHome: explicitCodexHome, sqliteHome, config return withStateDbLocation(layout, await detectStateDb(layout)); } -function formatCounts(counts) { - return Object.entries(counts ?? {}) - .map(([provider, count]) => `${provider}: ${count}`) - .join(", ") || "(none)"; +async function physicalDirectoryComparisonKey(value) { + try { + const lexical = path.resolve(value); + const first = path.resolve(await fs.realpath(lexical)); + const stat = await fs.stat(first); + const second = path.resolve(await fs.realpath(lexical)); + if (!stat.isDirectory() || pathComparisonKey(first) !== pathComparisonKey(second)) return null; + return pathComparisonKey(first); + } catch { + return null; + } } -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; +async function releaseWriteLocks(releaseStateDbLock, releaseHomeLock) { + const failures = []; + if (releaseStateDbLock) { + try { + await releaseStateDbLock(); + } catch (error) { + failures.push(error); + } + } + try { + await releaseHomeLock(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, "Failed to release one or more write-operation locks."); } - return unitIndex === 0 ? `${bytes} B` : `${value.toFixed(value >= 10 ? 1 : 2).replace(/\.0$/, "")} ${units[unitIndex]}`; } function emitProgress(onProgress, event) { @@ -252,6 +311,68 @@ function sumCounts(counts) { return Object.values(counts ?? {}).reduce((total, value) => total + value, 0); } +function normalizeProfileId(value) { + const profileId = value ?? "default"; + if (typeof profileId !== "string" || !/^[A-Za-z0-9._-]{1,80}$/.test(profileId)) { + throw new CoreError("INVALID_INPUT", "The storage profile id is invalid."); + } + return profileId; +} + +function comparableProfilePath(value, platform) { + if (typeof value !== "string" || !value) return null; + const resolved = path.resolve(value); + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function createProfileSnapshot({ + profileId, + suppliedRevision, + codexHome, + sqliteHome, + platform = process.platform +}) { + if (suppliedRevision !== undefined && suppliedRevision !== null + && (typeof suppliedRevision !== "string" || !suppliedRevision || suppliedRevision.length > 512)) { + throw new CoreError("INVALID_INPUT", "The storage profile revision is invalid."); + } + const id = normalizeProfileId(profileId); + const revision = sha256Revision(stableStringify({ + schemaVersion: 1, + id, + suppliedRevision: suppliedRevision ?? null, + codexHome: comparableProfilePath(codexHome, platform), + sqliteHome: comparableProfilePath(sqliteHome, platform) + })); + return Object.freeze({ + id, + revision, + suppliedRevision: suppliedRevision ?? null, + codexHome: path.resolve(codexHome), + sqliteHome: typeof sqliteHome === "string" && sqliteHome ? path.resolve(sqliteHome) : null + }); +} + +function profileFromOptions(options, codexHome, sqliteHome, platform) { + return createProfileSnapshot({ + profileId: options.profile?.id ?? options.profileId, + suppliedRevision: options.profile?.revision ?? options.profileRevision, + codexHome, + sqliteHome, + platform + }); +} + +function explicitSqliteHomeFromOptions(options) { + if (typeof options.sqliteHome === "string" && options.sqliteHome.trim()) return options.sqliteHome; + if (options.storage?.sqliteHomeSource !== "default" + && typeof options.storage?.sqliteHome === "string" + && options.storage.sqliteHome.trim()) { + return options.storage.sqliteHome; + } + return undefined; +} + function buildEncryptedContentWarning(encryptedContentCounts, targetProvider) { const riskyProviders = new Set(); for (const scope of ["sessions", "archived_sessions"]) { @@ -268,11 +389,15 @@ function buildEncryptedContentWarning(encryptedContentCounts, targetProvider) { 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({ +async function scanStatus({ codexHome: explicitCodexHome, sqliteHome, storage: providedStorage, configText: providedConfigText, + profile, + profileId, + profileRevision, + rolloutScanMode = "full", platform } = {}) { const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); @@ -281,27 +406,66 @@ export async function getStatus({ const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); const current = readCurrentProviderFromConfigText(configText); const configuredProviders = listConfiguredProviderIds(configText); - const { - providerCounts, - encryptedContentCounts, - lockedPaths, - userEventThreadIds, - threadCwdById - } = await collectSessionChanges(codexHome, "__status_only__", { skipLockedReads: true }); + const metadataOnly = rolloutScanMode === "metadata"; + const rolloutScan = metadataOnly + ? await collectStatusRolloutMetadata(codexHome, { skipLockedReads: true }) + : await collectSessionChanges(codexHome, "__status_only__", { skipLockedReads: true }); + const { providerCounts, lockedPaths } = rolloutScan; + const incompletePaths = metadataOnly ? rolloutScan.incompletePaths : []; + const encryptedContentCounts = metadataOnly + ? { sessions: {}, archived_sessions: {} } + : rolloutScan.encryptedContentCounts; + const userEventThreadIds = metadataOnly ? new Set() : rolloutScan.userEventThreadIds; + const threadCwdById = metadataOnly ? new Map() : rolloutScan.threadCwdById; const stateDbLocation = storage.stateDbLocation; const sqliteCounts = storage.sqliteAccess.supported ? await readSqliteProviderCounts(storage) : null; - const sqliteRepairStats = sqliteCounts && !sqliteCounts.unreadable + const sqliteRepairStats = !metadataOnly && sqliteCounts && !sqliteCounts.unreadable ? await readSqliteRepairStats(storage, { userEventThreadIds, threadCwdById }) : null; - const projectThreadVisibility = !storage.sqliteAccess.supported || sqliteCounts?.unreadable - ? [] - : await readProjectThreadVisibility(storage); + let projectThreadVisibility = []; + let projectThreadVisibilityAvailable = !metadataOnly + && storage.sqliteAccess.supported + && !sqliteCounts?.unreadable; + if (!metadataOnly && storage.sqliteAccess.supported && !sqliteCounts?.unreadable) { + try { + projectThreadVisibility = await readProjectThreadVisibility(storage); + } catch { + // Project visibility is an optional diagnostic projection. Older/minimal + // Codex schemas may not expose every column it needs; that must not block + // Status, Plan preparation, or a safe provider sync. + projectThreadVisibility = []; + projectThreadVisibilityAvailable = false; + } + } const backupSummary = await getBackupSummary(codexHome); const pendingTransactions = await findPendingTransactions(codexHome); + const trustedProfile = createProfileSnapshot({ + profileId: profile?.id ?? profileId, + suppliedRevision: profile?.revision ?? profileRevision, + codexHome, + // Profile identity is the trusted caller/server selection. Effective + // config-derived SQLite storage belongs in storageRevision, not here. + sqliteHome, + platform + }); + const configRevision = sha256Revision(Buffer.from(configText, "utf8")); + const resolvedStorageRevision = captureStorageRevision({ + profileRevision: trustedProfile.revision, + configRevision, + storage, + platform + }); return { + schemaVersion: 1, + snapshotAt: new Date().toISOString(), + storageRevision: resolvedStorageRevision, + profile: { id: trustedProfile.id, revision: trustedProfile.revision }, + profileId: trustedProfile.id, + profileRevision: trustedProfile.suppliedRevision ?? trustedProfile.revision, + pathComparisonCaseInsensitive: (platform ?? process.platform) === "win32", codexHome, sqliteHome: storage.sqliteHome, sqliteHomeSource: storage.sqliteHomeSource, @@ -318,95 +482,337 @@ export async function getStatus({ stateDbLocation, sqliteRepairStats, projectThreadVisibility, + projectThreadVisibilityAvailable, backupRoot: defaultBackupRoot(codexHome), backupSummary, + pendingRecovery: pendingTransactions.length > 0, + operationInProgress: null, + rolloutScanComplete: lockedPaths.length === 0 && incompletePaths.length === 0, pendingTransactions: pendingTransactions.map((transaction) => ({ operationId: transaction.operationId ?? null, + operationKind: transaction.operationKind ?? "sync", state: transaction.state, + sourceBackupId: transaction.prepared?.sourceBackup?.backupId ?? path.basename(transaction.backupDir), + preRestoreSnapshotId: transaction.prepared?.preRestoreSnapshot?.backupId ?? null, backupDir: transaction.backupDir, journalPath: transaction.filePath })) }; } -export function renderStatus(status) { - const lines = [ - `Codex home: ${status.codexHome}`, - `SQLite home: ${status.sqliteHome} (source: ${status.sqliteHomeSource})`, - `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}` - ]; - - if (status.pendingTransactions?.length) { - lines.push(""); - lines.push("Recovery required:"); - for (const transaction of status.pendingTransactions) { - lines.push(` ${transaction.state}: ${transaction.backupDir}`); +function publicProfileMetadata(profile) { + return { + id: profile.id, + publicRevision: profile.suppliedRevision ?? profile.revision + }; +} + +function externalOperationFromLock({ inspection = null, error = null, scope }) { + const owner = inspection?.owner ?? null; + return { + operationId: owner?.instanceId ?? null, + operation: typeof owner?.label === "string" && owner.label ? owner.label : "unknown", + actor: "external", + runtime: typeof owner?.runtime === "string" ? owner.runtime : null, + startedAt: typeof owner?.startedAt === "string" ? owner.startedAt : null, + busyScope: scope, + lockState: inspection?.state === "active" ? "active" : "unverifiable", + ...(error?.code ? { errorCode: error.code } : {}) + }; +} + +async function inspectStatusLock(lockPath, options) { + try { + return { inspection: await inspectPathLock(lockPath, options), error: null }; + } catch (error) { + if (error?.code === "LOCK_UNVERIFIABLE" + || error?.code === "OPERATION_BUSY" + || error?.code === "PERMISSION_DENIED") { + return { inspection: null, error }; } - lines.push(" Run restore with the listed backup before the next write operation."); - } - - 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.sqliteAccess?.supported) { - lines.push(` ${status.sqliteAccess.message}`); - return lines.join("\n"); - } - 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 ${status.checkedStateDbPaths.join(", ")})`); - } - 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}`); + throw error; + } +} + +async function blockedStatus(codexHome, profile, operation, platform, details = null) { + const status = operationCoordinator.statusForBlockedWrite( + codexHome, + operation, + platform, + publicProfileMetadata(profile) + ); + if (!status.profile) { + status.profile = { id: profile.id, revision: profile.revision }; + status.profileId = profile.id; + status.profileRevision = profile.suppliedRevision ?? profile.revision; + status.pathComparisonCaseInsensitive = (platform ?? process.platform) === "win32"; + } + status.statusReadBlocked = details ?? { reason: "write-operation" }; + if (operation.lockState === "unverifiable") { + status.rolloutScanComplete = false; + try { + const pendingTransactions = await findPendingTransactions(codexHome); + status.pendingTransactions = pendingTransactions.map((transaction) => ({ + operationId: transaction.operationId ?? null, + operationKind: transaction.operationKind ?? "sync", + state: transaction.state, + sourceBackupId: transaction.prepared?.sourceBackup?.backupId ?? path.basename(transaction.backupDir), + preRestoreSnapshotId: transaction.prepared?.preRestoreSnapshot?.backupId ?? null, + backupDir: transaction.backupDir, + journalPath: transaction.filePath + })); + status.pendingRecovery = pendingTransactions.length > 0; + } catch { + status.pendingTransactions ??= []; + status.pendingRecovery = true; } - if (status.sqliteRepairStats?.cwdRowsNeedingRepair) { - lines.push(` cwd paths needing repair: ${status.sqliteRepairStats.cwdRowsNeedingRepair}`); + } + return status; +} + +export async function getStatus(options = {}) { + const codexHome = options.storage?.codexHome ?? normalizeCodexHome(options.codexHome); + const platform = options.platform ?? process.platform; + const sqliteHome = explicitSqliteHomeFromOptions(options); + const rolloutRevisionMode = options.rolloutScanMode === "metadata" ? "metadata" : "content"; + const profile = profileFromOptions(options, codexHome, sqliteHome, platform); + const activeSnapshot = operationCoordinator.statusDuringWrite( + codexHome, + platform, + publicProfileMetadata(profile) + ); + if (activeSnapshot) return activeSnapshot; + + const homeLockPath = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); + const homeBefore = await inspectStatusLock(homeLockPath, { scope: "codex-home", platform }); + if (homeBefore.error || homeBefore.inspection.state !== "absent") { + const operation = externalOperationFromLock({ + inspection: homeBefore.inspection, + error: homeBefore.error, + scope: "codex-home" + }); + return blockedStatus(codexHome, profile, operation, platform, { + reason: "codex-home-lock", + lockState: operation.lockState + }); + } + + const configPath = path.join(codexHome, "config.toml"); + const configText = options.configText ?? await readConfigText(configPath); + const storage = await prepareStorage({ + codexHome, + sqliteHome, + configText, + storage: options.storage, + platform + }); + let stateResource = null; + if (storage.stateDbLocation?.path) { + stateResource = await resolveStateDbLockResource(storage.stateDbLocation.path, { platform }); + const stateBefore = await inspectStatusLock(stateResource.lockPath, { + scope: "state-db", + resourceKey: stateResource.resourceKey, + platform + }); + if (stateBefore.error || stateBefore.inspection.state !== "absent") { + const operation = externalOperationFromLock({ + inspection: stateBefore.inspection, + error: stateBefore.error, + scope: "state-db" + }); + return blockedStatus(codexHome, profile, operation, platform, { + reason: "state-db-lock", + lockState: operation.lockState + }); } } - 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}` - ); + let beforeRevision; + try { + beforeRevision = await captureOperationRevisions({ + codexHome, + profileRevision: profile.revision, + configText, + storage, + rolloutRevisionMode, + platform + }); + } catch (error) { + return blockedStatus( + codexHome, + profile, + externalOperationFromLock({ error, scope: "codex-home" }), + platform, + { reason: "revision-unverifiable" } + ); + } + + let snapshot = await scanStatus({ + ...options, + codexHome, + sqliteHome, + storage, + configText, + profileId: profile.id, + profileRevision: profile.suppliedRevision, + platform + }); + + const homeAfter = await inspectStatusLock(homeLockPath, { scope: "codex-home", platform }); + let stateAfter = { inspection: { state: "absent" }, error: null }; + if (!homeAfter.error && homeAfter.inspection.state === "absent" && stateResource) { + stateAfter = await inspectStatusLock(stateResource.lockPath, { + scope: "state-db", + resourceKey: stateResource.resourceKey, + platform + }); + } + if (homeAfter.error || homeAfter.inspection.state !== "absent" + || stateAfter.error || stateAfter.inspection.state !== "absent") { + const source = homeAfter.error || homeAfter.inspection.state !== "absent" + ? { ...homeAfter, scope: "codex-home" } + : { ...stateAfter, scope: "state-db" }; + const operation = externalOperationFromLock(source); + return blockedStatus(codexHome, profile, operation, platform, { + reason: `${source.scope}-lock`, + lockState: operation.lockState + }); + } + + let afterRevision; + try { + const latestConfigText = await readConfigText(configPath); + afterRevision = await captureOperationRevisions({ + codexHome, + profileRevision: profile.revision, + configText: latestConfigText, + storage, + rolloutRevisionMode, + platform + }); + } catch (error) { + return blockedStatus( + codexHome, + profile, + externalOperationFromLock({ error, scope: "codex-home" }), + platform, + { reason: "revision-unverifiable" } + ); + } + let driftReason = revisionMismatch(beforeRevision, afterRevision); + if (driftReason === "state-db" || driftReason === "rollout") { + // Opening a WAL database for read-only Status can legitimately create or + // refresh its SHM sidecar. Retry once from that new complete baseline; a + // real concurrent writer will either expose its lock or drift again. + snapshot = await scanStatus({ + ...options, + codexHome, + sqliteHome, + storage, + configText, + profileId: profile.id, + profileRevision: profile.suppliedRevision, + platform + }); + const retryConfigText = await readConfigText(configPath); + const retryRevision = await captureOperationRevisions({ + codexHome, + profileRevision: profile.revision, + configText: retryConfigText, + storage, + rolloutRevisionMode, + platform + }); + driftReason = revisionMismatch(afterRevision, retryRevision); + afterRevision = retryRevision; + } + if (driftReason) { + return blockedStatus( + codexHome, + profile, + externalOperationFromLock({ scope: driftReason === "state-db" ? "state-db" : "codex-home" }), + platform, + { reason: "state-changed-during-status", revision: driftReason } + ); + } + + const homeFinal = await inspectStatusLock(homeLockPath, { scope: "codex-home", platform }); + if (homeFinal.error || homeFinal.inspection.state !== "absent") { + const operation = externalOperationFromLock({ + inspection: homeFinal.inspection, + error: homeFinal.error, + scope: "codex-home" + }); + return blockedStatus(codexHome, profile, operation, platform, { + reason: "codex-home-lock", + lockState: operation.lockState + }); + } + if (stateResource) { + const stateFinal = await inspectStatusLock(stateResource.lockPath, { + scope: "state-db", + resourceKey: stateResource.resourceKey, + platform + }); + if (stateFinal.error || stateFinal.inspection.state !== "absent") { + const operation = externalOperationFromLock({ + inspection: stateFinal.inspection, + error: stateFinal.error, + scope: "state-db" + }); + return blockedStatus(codexHome, profile, operation, platform, { + reason: "state-db-lock", + lockState: operation.lockState + }); } } - return lines.join("\n"); + operationCoordinator.cacheStatus(codexHome, snapshot, platform); + return snapshot; } -export async function runSync(options = {}) { - return runSyncCore(options); +async function verifyExpectedPlanState({ + expectedPlanState, + codexHome, + configText, + storage, + platform, + backupDir = null +}) { + if (!expectedPlanState) return null; + let currentProfile = expectedPlanState.profile; + if (typeof expectedPlanState.profileResolver === "function") { + try { + const resolved = await expectedPlanState.profileResolver(expectedPlanState.profile.id); + currentProfile = createProfileSnapshot({ + profileId: resolved?.id, + suppliedRevision: resolved?.revision, + codexHome: resolved?.codexHome, + sqliteHome: resolved?.sqliteHome, + platform + }); + } catch (error) { + throw new CoreError("STALE_STATE", "The selected storage profile changed after preparation.", { + cause: error instanceof Error ? error : undefined, + details: { reason: "profile" } + }); + } + } + const actual = await captureOperationRevisions({ + codexHome, + profileRevision: currentProfile.revision, + configText, + storage, + backupDir, + platform + }); + const reason = revisionMismatch(expectedPlanState.revisions, actual); + if (reason) { + throw new CoreError("STALE_STATE", "Protected state changed after the operation was prepared.", { + details: { reason } + }); + } + return actual; } async function runSyncCore({ @@ -422,33 +828,58 @@ async function runSyncCore({ model = null, platform, faultInjector, - signal + signal, + expectedPlanState } = {}, { afterBackup } = {}) { if (!Number.isInteger(keepCount) || keepCount < 1) { - throw new Error(`Invalid automatic keep count: ${keepCount}. Expected an integer greater than or equal to 1.`); + throw new CoreError( + "INVALID_INPUT", + `Invalid automatic keep count: ${keepCount}. Expected an integer greater than or equal to 1.` + ); } const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); const configPath = path.join(codexHome, "config.toml"); const releaseLock = await acquireLock(codexHome, "sync"); + let releaseStateDbLock = null; let backupDir = null; let journal = null; let backupDurationMs = 0; try { - await assertNoPendingTransactions(codexHome); throwIfAborted(signal); const configText = await readConfigText(configPath); - if (expectedConfigText !== undefined && configText !== expectedConfigText) { - throw new Error("config.toml changed after the operation was confirmed. Refresh and retry."); + if (!expectedPlanState && expectedConfigText !== undefined && configText !== expectedConfigText) { + throw new CoreError( + "PLAN_STALE", + "config.toml changed after the operation was confirmed. Refresh and retry." + ); } - if (configBackupText !== undefined && configText !== configBackupText) { - throw new Error("config.toml changed before the switch operation acquired its lock. Refresh and retry."); + if (!expectedPlanState && configBackupText !== undefined && configText !== configBackupText) { + throw new CoreError( + "PLAN_STALE", + "config.toml changed before the switch operation acquired its lock. Refresh and retry." + ); } const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); assertSqliteAccessSupported(storage, "sync"); if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { throw missingConfiguredStateDbError(storage); } + if (storage.stateDbLocation?.path) { + ({ release: releaseStateDbLock } = await acquireStateDbLock( + storage.stateDbLocation.path, + "sync", + { platform } + )); + } + await assertNoPendingTransactions(codexHome); + await verifyExpectedPlanState({ + expectedPlanState, + codexHome, + configText, + storage, + platform + }); const current = readCurrentProviderFromConfigText(configText); const targetProvider = provider ?? current.provider ?? DEFAULT_PROVIDER; emitProgress(onProgress, { stage: "scan_rollout_files", status: "start" }); @@ -687,7 +1118,7 @@ async function runSyncCore({ keepCount }); try { - autoPruneResult = await pruneBackups(codexHome, keepCount); + autoPruneResult = await pruneManagedBackups(codexHome, keepCount); } catch (pruneError) { autoPruneWarning = `Automatic backup cleanup failed: ${pruneError instanceof Error ? pruneError.message : String(pruneError)}`; } @@ -867,53 +1298,29 @@ async function runSyncCore({ ); } } finally { - await releaseLock(); + await releaseWriteLocks(releaseStateDbLock, releaseLock); } } -export async function runSwitch({ - codexHome: explicitCodexHome, - sqliteHome, - storage: providedStorage, - expectedConfigText, - provider, - model, - keepRootModel = false, - keepCount = DEFAULT_BACKUP_RETENTION_COUNT, - onProgress, - platform, - faultInjector, - signal -}) { - if (!provider) { - throw new Error("Missing provider id. Usage: codex-provider switch "); - } - - const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); - const configPath = path.join(codexHome, "config.toml"); - const originalConfigText = await readConfigText(configPath); - if (expectedConfigText !== undefined && originalConfigText !== expectedConfigText) { - throw new Error("config.toml changed after the operation was confirmed. Refresh and retry."); - } - const storage = await prepareStorage({ codexHome, sqliteHome, configText: originalConfigText, storage: providedStorage, platform }); - assertSqliteAccessSupported(storage, "switch"); - if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { - throw missingConfiguredStateDbError(storage); - } +function buildSwitchIntent(originalConfigText, provider, model, keepRootModel) { 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(", ")}`); + throw new CoreError( + "INVALID_INPUT", + `Provider "${provider}" is not available in config.toml. Configure it first or use one of: ${listConfiguredProviderIds(originalConfigText).join(", ")}` + ); } - if (model !== undefined && model !== null && keepRootModel) { - throw new Error("--model and --keep-root-model are mutually exclusive. Pick one."); + throw new CoreError("INVALID_INPUT", "--model and --keep-root-model are mutually exclusive. Pick one."); } let nextConfigText = setRootProviderInConfigText(originalConfigText, provider); let modelSync = { applied: false, source: "none", model: null, warning: null }; - if (model !== undefined && model !== null) { if (typeof model !== "string" || model.length === 0) { - throw new Error(`Invalid --model value: ${model}. Expected a non-empty string.`); + throw new CoreError( + "INVALID_INPUT", + `Invalid --model value: ${model}. Expected a non-empty string.` + ); } nextConfigText = setRootModelInConfigText(nextConfigText, model); modelSync = { applied: true, source: "explicit", model, warning: null }; @@ -931,26 +1338,71 @@ export async function runSwitch({ }; } } + const modelForThreads = modelSync.applied && modelSync.model + ? modelSync.model + : readRootModelFromConfigText(nextConfigText); + return { nextConfigText, modelSync, modelForThreads }; +} - // `nextConfigText` has the final root-level `model` value. Use that to - // drive the per-thread rewrite so old sessions match new sessions. - let modelForThreads = null; - if (modelSync.applied && modelSync.model) { - modelForThreads = modelSync.model; - } else { - modelForThreads = readRootModelFromConfigText(nextConfigText); +async function runSwitchCore({ + codexHome: explicitCodexHome, + sqliteHome, + storage: providedStorage, + expectedConfigText, + provider, + model, + keepRootModel = false, + keepCount = DEFAULT_BACKUP_RETENTION_COUNT, + onProgress, + platform, + faultInjector, + signal, + expectedPlanState +}) { + if (!provider) { + throw new CoreError( + "INVALID_INPUT", + "Missing provider id. Usage: codex-provider switch " + ); + } + + const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); + const configPath = path.join(codexHome, "config.toml"); + const originalConfigText = await readConfigText(configPath); + if (expectedConfigText !== undefined && originalConfigText !== expectedConfigText) { + throw new CoreError( + "PLAN_STALE", + "config.toml changed after the operation was confirmed. Refresh and retry." + ); } + const storage = await prepareStorage({ codexHome, sqliteHome, configText: originalConfigText, storage: providedStorage, platform }); + assertSqliteAccessSupported(storage, "switch"); + if (!storage.stateDbLocation && isConfiguredSqliteHome(storage)) { + throw missingConfiguredStateDbError(storage); + } + await faultInjector?.({ + point: "after_switch_storage_preflight", + stateDbPath: storage.stateDbLocation?.path ?? null + }); + const { nextConfigText, modelSync, modelForThreads } = buildSwitchIntent( + originalConfigText, + provider, + model, + keepRootModel + ); const syncResult = await runSyncCore( { codexHome, - storage, + sqliteHome, provider, configBackupText: originalConfigText, keepCount, onProgress, model: modelForThreads, faultInjector, - signal + signal, + expectedPlanState, + platform }, { afterBackup: async () => { @@ -975,7 +1427,7 @@ export async function runSwitch({ }; } -export async function runRestore({ +async function runRestoreCore({ codexHome: explicitCodexHome, sqliteHome, storage: providedStorage, @@ -986,27 +1438,145 @@ export async function runRestore({ restoreSessions = true, allowSqliteHomeRelocation = false, platform, - faultInjector + faultInjector, + signal, + onProgress, + expectedPlanState }) { if (!backupDir) { - throw new Error("Missing backup path. Usage: codex-provider restore "); + throw new CoreError( + "INVALID_INPUT", + "Missing backup path. Usage: codex-provider restore " + ); } const codexHome = providedStorage?.codexHome ?? normalizeCodexHome(explicitCodexHome); if (allowSqliteHomeRelocation && !(typeof sqliteHome === "string" && sqliteHome.trim())) { - throw new Error("--allow-sqlite-home-relocation requires an explicit --sqlite-home path."); + throw new CoreError( + "INVALID_INPUT", + "--allow-sqlite-home-relocation requires an explicit --sqlite-home path." + ); } const releaseLock = await acquireLock(codexHome, "restore"); + let releaseStateDbLock = null; + let stateDbResource = null; try { const configText = await readConfigText(path.join(codexHome, "config.toml")); - if (expectedConfigText !== undefined && configText !== expectedConfigText) { - throw new Error("config.toml changed after the operation was confirmed. Refresh and retry."); + if (!expectedPlanState && expectedConfigText !== undefined && configText !== expectedConfigText) { + throw new CoreError( + "PLAN_STALE", + "config.toml changed after the operation was confirmed. Refresh and retry." + ); } const storage = await prepareStorage({ codexHome, sqliteHome, configText, storage: providedStorage, platform }); assertSqliteAccessSupported(storage, "restore"); if (restoreDatabase && !storage.stateDbLocation && isConfiguredSqliteHome(storage)) { throw missingConfiguredStateDbError(storage); } - const normalizedBackupDir = path.resolve(backupDir); + const sourceBackup = await captureStableRestoreSource(backupDir, { platform }); + const normalizedBackupDir = sourceBackup.backupDir; + if (restoreDatabase) { + const stateDbTargetPath = await resolveRestoreStateDbTargetPath(normalizedBackupDir, storage); + ({ release: releaseStateDbLock, resource: stateDbResource } = await acquireStateDbLock( + stateDbTargetPath, + "restore", + { platform } + )); + } + await verifyExpectedPlanState({ + expectedPlanState, + codexHome, + configText, + storage, + platform, + backupDir: normalizedBackupDir + }); + const requestedKinds = [ + ...(restoreConfig ? ["config", "globalState"] : []), + ...(restoreDatabase ? ["sqlite"] : []), + ...(restoreSessions ? ["rollout"] : []) + ]; + const pending = await findPendingTransactions(codexHome); + const legacyPending = pending.filter((transaction) => transaction.operationKind !== "restore"); + const restorePending = pending.filter((transaction) => transaction.operationKind === "restore"); + const normalizedBackupKey = await physicalDirectoryComparisonKey(normalizedBackupDir); + const foreignLegacy = []; + for (const transaction of legacyPending) { + const transactionBackupKey = await physicalDirectoryComparisonKey(transaction.backupDir); + if (normalizedBackupKey === null || transactionBackupKey !== normalizedBackupKey) { + foreignLegacy.push(transaction); + } + } + const boundRestore = []; + const foreignRestore = []; + for (const transaction of restorePending) { + const preparedSource = transaction.prepared?.sourceBackup; + const physicalHomeMatches = await restoreJournalMatchesPhysicalHome( + transaction, + storage.codexHome, + platform + ); + const sourceMatches = await restoreJournalMatchesSource( + transaction, + sourceBackup, + platform + ); + const committedSourceLocationMatches = transaction.state === "committed-pending-ack" + && preparedSource + && await restoreJournalMatchesSource( + transaction, + { ...sourceBackup, revision: preparedSource.revision }, + platform + ); + if ((sourceMatches + || committedSourceLocationMatches) + && physicalHomeMatches + && restoreJournalCoverageIsComplete(transaction, requestedKinds)) { + boundRestore.push(transaction); + } else { + foreignRestore.push(transaction); + } + } + if (foreignLegacy.length > 0 || foreignRestore.length > 0) { + throw new CoreError( + "RECOVERY_REQUIRED", + "An unrelated unfinished transaction must be resolved before this restore.", + { + details: { operationKind: "restore", foreignPendingCount: foreignLegacy.length + foreignRestore.length }, + suggestedAction: "Restore the transaction-bound managed backup before starting another write." + } + ); + } + const committedPendingAck = boundRestore.filter( + (transaction) => transaction.state === "committed-pending-ack" && !transaction.invalidTail + ); + if (committedPendingAck.length > 0) { + if (committedPendingAck.length !== 1 || boundRestore.length !== 1) { + throw new CoreError( + "RECOVERY_REQUIRED", + "Multiple Restore acknowledgements cannot be reconciled automatically.", + { details: { operationKind: "restore" } } + ); + } + const acknowledgement = await acknowledgePendingRestore(committedPendingAck[0], { + faultInjector, + stateDbResource, + storage, + onProgress, + platform + }); + const metadata = JSON.parse( + await fs.readFile(path.join(normalizedBackupDir, "metadata.json"), "utf8") + ); + return { + ...metadata, + restoreVersion: 2, + restoreOperationId: acknowledgement.completed.operationId, + preRestoreSnapshotId: acknowledgement.manifest.preRestoreSnapshot.backupId, + restoreJournalState: "completed", + commitAcknowledgementRecovered: true, + resolvedOperationIds: acknowledgement.manifest.resolvesOperationIds ?? [] + }; + } let boundJournal = null; try { boundJournal = await readTransactionJournal( @@ -1024,9 +1594,16 @@ export async function runRestore({ try { conservativeCoverage = await getBackupRecoveryCoverage(normalizedBackupDir, storage); } catch (coverageError) { - coverageError.code = "RECOVERY_REQUIRED"; - coverageError.backupDir = normalizedBackupDir; - throw coverageError; + const recoveryError = new CoreError( + "RECOVERY_REQUIRED", + coverageError instanceof Error ? coverageError.message : String(coverageError), + { + cause: coverageError instanceof Error ? coverageError : undefined, + suggestedAction: "Restore the complete transaction-bound backup before retrying." + } + ); + recoveryError.backupDir = normalizedBackupDir; + throw recoveryError; } } const missingKinds = []; @@ -1047,23 +1624,33 @@ export async function runRestore({ missingKinds.push("global state"); } if (missingKinds.length > 0) { - const error = new Error( - `Partial restore would leave a pending transaction unresolved. Include: ${missingKinds.join(", ")}.` + const error = new CoreError( + "RECOVERY_REQUIRED", + `Partial restore would leave a pending transaction unresolved. Include: ${missingKinds.join(", ")}.`, + { suggestedAction: "Include every affected target kind in the explicit recovery restore." } ); - error.code = "RECOVERY_REQUIRED"; error.backupDir = normalizedBackupDir; error.missingRestoreKinds = missingKinds; throw error; } } - const result = await restoreBackup(normalizedBackupDir, storage, { + const result = await executeRestoreV2({ + storage, + sourceBackup, restoreConfig, restoreDatabase, restoreSessions, - allowSqliteHomeRelocation + allowSqliteHomeRelocation, + stateDbResource, + resolvesOperationIds: boundRestore + .map((transaction) => transaction.operationId) + .filter((value) => typeof value === "string" && value.length > 0), + faultInjector, + signal, + onProgress, + platform }); - await markBackupTransactionRolledBack(normalizedBackupDir); - // The restore and its journal marker are already durable. Refreshing the + // The Restore and its journals are already durable. Refreshing the // inventory only corrects metadata.json bookkeeping, so surface a failure as // a warning instead of reporting a completed restore as failed. try { @@ -1076,8 +1663,468 @@ export async function runRestore({ } return result; } finally { - await releaseLock(); + await releaseWriteLocks(releaseStateDbLock, releaseLock); + } +} + +function sqliteRowsToChange(sqliteCounts, targetProvider, sqliteRepairStats) { + let count = 0; + for (const scope of ["sessions", "archived_sessions"]) { + for (const [provider, providerCount] of Object.entries(sqliteCounts?.[scope] ?? {})) { + if (provider !== targetProvider && Number.isSafeInteger(providerCount)) count += providerCount; + } + } + count += sqliteRepairStats?.userEventRowsNeedingRepair ?? 0; + count += sqliteRepairStats?.cwdRowsNeedingRepair ?? 0; + return count; +} + +async function preparePlanContext(options, operation, { backupDir = null } = {}) { + const codexHome = options.storage?.codexHome ?? normalizeCodexHome(options.codexHome); + if (operationCoordinator.isActive(codexHome, options.platform)) { + throw new CoreError("OPERATION_BUSY", "Lock already exists for this Codex Home; another write operation is active.", { + details: { busyScope: "codex-home" } + }); + } + const sqliteHome = explicitSqliteHomeFromOptions(options); + const configPath = path.join(codexHome, "config.toml"); + const configText = await readConfigText(configPath); + if (options.expectedConfigText !== undefined && configText !== options.expectedConfigText) { + throw new CoreError( + "PLAN_STALE", + "config.toml changed after the operation was confirmed. Refresh and retry." + ); } + const storage = await prepareStorage({ codexHome, sqliteHome, configText, platform: options.platform }); + assertSqliteAccessSupported(storage, operation); + const profile = profileFromOptions(options, codexHome, sqliteHome, options.platform); + const status = await scanStatus({ + codexHome, + sqliteHome, + storage, + configText, + profileId: profile.id, + profileRevision: profile.suppliedRevision, + platform: options.platform + }); + // Capture the executable revision after all read-only status queries have + // closed their SQLite handles; opening a WAL database may legitimately + // update its SHM sidecar. + const revisions = await captureOperationRevisions({ + codexHome, + profileRevision: profile.revision, + configText, + storage, + backupDir, + platform: options.platform + }); + operationCoordinator.cacheStatus(codexHome, status, options.platform); + return { codexHome, sqliteHome, configText, storage, profile, revisions, status }; +} + +async function issueSyncLikePlan(operation, options, switchIntent = null) { + const keepCount = options.keepCount ?? DEFAULT_BACKUP_RETENTION_COUNT; + if (!Number.isInteger(keepCount) || keepCount < 1) { + throw new CoreError( + "INVALID_INPUT", + `Invalid automatic keep count: ${keepCount}. Expected an integer greater than or equal to 1.` + ); + } + const context = await preparePlanContext(options, operation); + if (!context.storage.stateDbLocation && isConfiguredSqliteHome(context.storage)) { + throw missingConfiguredStateDbError(context.storage); + } + const current = readCurrentProviderFromConfigText(context.configText); + const targetProvider = switchIntent?.provider + ?? options.provider + ?? current.provider + ?? DEFAULT_PROVIDER; + const targetModel = switchIntent?.modelForThreads ?? options.model ?? null; + if (targetModel !== null && targetModel !== undefined + && (typeof targetModel !== "string" || !targetModel)) { + throw new CoreError("INVALID_INPUT", "The target model must be a non-empty string or null."); + } + const scan = await collectSessionChanges(context.codexHome, targetProvider, { + skipLockedReads: true, + targetModel + }); + const { lockedChanges } = await splitLockedSessionChanges(scan.changes); + const lockedCount = new Set([ + ...scan.lockedPaths, + ...lockedChanges.map((change) => change.path) + ]).size; + const warnings = []; + if (!context.status.projectThreadVisibilityAvailable) { + warnings.push("Project visibility diagnostics are unavailable; the write operation will still validate and protect the global state with backup-first recovery."); + } + const encryptedWarning = buildEncryptedContentWarning(scan.encryptedContentCounts, targetProvider); + if (encryptedWarning) warnings.push(encryptedWarning); + if (lockedCount > 0) { + warnings.push(`${lockedCount} rollout file(s) are currently locked and may produce a partial result.`); + } + if (switchIntent?.modelSync.warning) warnings.push(switchIntent.modelSync.warning); + + const summary = { + profile: { id: context.profile.id, revision: context.profile.revision }, + storageRevision: context.revisions.storageRevision, + configRevision: context.revisions.configRevision, + rolloutRevision: context.revisions.rolloutRevision, + stateDbRevision: context.revisions.stateDbRevision, + target: { + provider: targetProvider, + model: targetModel, + ...(switchIntent ? { modelMode: switchIntent.modelMode } : {}) + }, + impact: { + rolloutFilesToChange: scan.changes.length, + sqliteRowsToChange: sqliteRowsToChange( + context.status.sqliteCounts, + targetProvider, + context.status.sqliteRepairStats + ), + workspaceRootsToChange: context.status.sqliteRepairStats?.cwdRowsNeedingRepair ?? 0, + lockedRolloutFiles: context.revisions.lockedRolloutFiles, + backupExpected: true + }, + warnings + }; + const executionOptions = operation === "switch" + ? { + codexHome: context.codexHome, + ...(context.sqliteHome ? { sqliteHome: context.sqliteHome } : {}), + provider: switchIntent.provider, + model: options.model, + keepRootModel: Boolean(options.keepRootModel), + keepCount, + onProgress: options.onProgress, + platform: options.platform, + faultInjector: options.faultInjector, + signal: options.signal + } + : { + codexHome: context.codexHome, + ...(context.sqliteHome ? { sqliteHome: context.sqliteHome } : {}), + provider: targetProvider, + keepCount, + sqliteBusyTimeoutMs: options.sqliteBusyTimeoutMs, + onProgress: options.onProgress, + model: targetModel, + platform: options.platform, + faultInjector: options.faultInjector, + signal: options.signal + }; + return issuePreparedPlan(operation, summary, { + codexHome: context.codexHome, + platform: options.platform, + actor: options.__actor === "watch" ? "watch" : "manual", + executionOptions, + expectedPlanState: { + profile: context.profile, + profileResolver: options.profileResolver, + revisions: context.revisions + }, + statusOptions: { + codexHome: context.codexHome, + ...(context.sqliteHome ? { sqliteHome: context.sqliteHome } : {}), + profileId: context.profile.id, + profileRevision: context.profile.suppliedRevision, + platform: options.platform + } + }); +} + +export async function prepareSync(options = {}) { + return issueSyncLikePlan("sync", options); +} + +export async function prepareSwitch(options = {}) { + if (!options.provider) { + throw new CoreError("INVALID_INPUT", "Missing provider id. Usage: codex-provider switch "); + } + const codexHome = options.storage?.codexHome ?? normalizeCodexHome(options.codexHome); + if (operationCoordinator.isActive(codexHome, options.platform)) { + throw new CoreError("OPERATION_BUSY", "Lock already exists for this Codex Home; another write operation is active.", { + details: { busyScope: "codex-home" } + }); + } + const configText = await readConfigText(path.join(codexHome, "config.toml")); + if (options.expectedConfigText !== undefined && configText !== options.expectedConfigText) { + throw new CoreError("PLAN_STALE", "config.toml changed after the operation was confirmed. Refresh and retry."); + } + const intent = buildSwitchIntent(configText, options.provider, options.model, Boolean(options.keepRootModel)); + return issueSyncLikePlan("switch", options, { + provider: options.provider, + modelForThreads: intent.modelForThreads, + modelSync: intent.modelSync, + modelMode: options.model !== undefined && options.model !== null + ? "explicit" + : (options.keepRootModel ? "keep-root-model" : "provider-default") + }); +} + +async function resolvePreparedBackup(options, codexHome) { + if (typeof options.backupId === "string" && options.backupId) { + const inventory = await listBackups(codexHome); + const selected = inventory.backups.find((backup) => backup.id === options.backupId); + if (!selected) { + throw new CoreError("RESTORE_VALIDATION_FAILED", "The selected managed backup is unavailable."); + } + const source = await captureStableRestoreSource(selected.path, { platform: options.platform }); + return { ...source, metadata: selected.metadata }; + } + if (typeof options.backupDir === "string" && options.backupDir) { + const source = await captureStableRestoreSource(options.backupDir, { platform: options.platform }); + return { ...source, metadata: null }; + } + throw new CoreError("INVALID_INPUT", "A managed backupId is required for Restore preparation."); +} + +export async function prepareRestore(options = {}) { + const restoreConfig = options.restoreConfig !== false; + const restoreDatabase = options.restoreDatabase !== false; + const restoreSessions = options.restoreSessions !== false; + const hasBackupId = typeof options.backupId === "string" && Boolean(options.backupId.trim()); + const hasBackupDir = typeof options.backupDir === "string" && Boolean(options.backupDir.trim()); + if (!hasBackupId && !hasBackupDir) { + throw new CoreError("INVALID_INPUT", "A managed backupId is required for Restore preparation."); + } + if (options.allowSqliteHomeRelocation + && !(typeof options.sqliteHome === "string" && options.sqliteHome.trim())) { + throw new CoreError( + "INVALID_INPUT", + "--allow-sqlite-home-relocation requires an explicit --sqlite-home path." + ); + } + const codexHome = options.storage?.codexHome ?? normalizeCodexHome(options.codexHome); + if (operationCoordinator.isActive(codexHome, options.platform)) { + throw new CoreError("OPERATION_BUSY", "Lock already exists for this Codex Home; another write operation is active.", { + details: { busyScope: "codex-home" } + }); + } + // Validate config/storage/WSL state before opening any backup path. This + // preserves the compatibility contract for stale confirmation and WSL UNC + // diagnostics while the source is canonicalized immediately afterwards. + const context = await preparePlanContext(options, "restore"); + const backup = await resolvePreparedBackup(options, codexHome); + const revisions = { + ...context.revisions, + backupRevision: await captureBackupRevision(backup.backupDir) + }; + if (restoreDatabase && !context.storage.stateDbLocation && isConfiguredSqliteHome(context.storage)) { + throw missingConfiguredStateDbError(context.storage); + } + if (restoreDatabase) { + await resolveRestoreStateDbTargetPath(backup.backupDir, context.storage); + } + const warnings = []; + if (options.allowSqliteHomeRelocation) { + warnings.push("SQLite Home relocation is explicit; config.toml will not be restored."); + } + const summary = { + profile: { id: context.profile.id, revision: context.profile.revision }, + storageRevision: revisions.storageRevision, + configRevision: revisions.configRevision, + rolloutRevision: revisions.rolloutRevision, + stateDbRevision: revisions.stateDbRevision, + backupRevision: revisions.backupRevision, + target: { + backupId: backup.backupId, + restoreConfig, + restoreDatabase, + restoreSessions, + allowSqliteHomeRelocation: Boolean(options.allowSqliteHomeRelocation) + }, + impact: { + rolloutFilesToChange: restoreSessions ? (backup.metadata?.changedSessionFiles ?? 0) : 0, + stateDbFilesToChange: restoreDatabase ? 1 : 0, + configFilesToChange: restoreConfig ? 1 : 0, + lockedRolloutFiles: context.revisions.lockedRolloutFiles, + backupExpected: true + }, + warnings + }; + return issuePreparedPlan("restore", summary, { + codexHome: context.codexHome, + platform: options.platform, + actor: "manual", + executionOptions: { + codexHome: context.codexHome, + ...(context.sqliteHome ? { sqliteHome: context.sqliteHome } : {}), + backupDir: backup.backupDir, + restoreConfig, + restoreDatabase, + restoreSessions, + allowSqliteHomeRelocation: Boolean(options.allowSqliteHomeRelocation), + platform: options.platform, + faultInjector: options.faultInjector, + signal: options.signal, + onProgress: options.onProgress + }, + expectedPlanState: { + profile: context.profile, + profileResolver: options.profileResolver, + revisions + }, + sourceBackup: { backupId: backup.backupId, backupDir: backup.backupDir }, + statusOptions: { + codexHome: context.codexHome, + ...(context.sqliteHome ? { sqliteHome: context.sqliteHome } : {}), + profileId: context.profile.id, + profileRevision: context.profile.suppliedRevision, + platform: options.platform + } + }); +} + +function operationWarnings(result) { + return [ + result?.encryptedContentWarning, + result?.autoPruneWarning, + result?.backupInventoryWarning, + result?.modelSync?.warning + ].filter((warning) => typeof warning === "string" && warning.trim()); +} + +function operationResult(operation, operationId, result, sourceBackup = null) { + const partial = Array.isArray(result?.skippedLockedRolloutFiles) + && result.skippedLockedRolloutFiles.length > 0; + const backupDir = result?.backupDir ?? sourceBackup?.backupDir ?? null; + return { + schemaVersion: 1, + operationId, + operation, + outcome: partial ? "partial" : "completed", + backup: backupDir + ? { + backupId: operation === "restore" ? sourceBackup?.backupId : path.basename(backupDir), + path: backupDir + } + : null, + warnings: operationWarnings(result), + result + }; +} + +function composeProgressObservers(primary, secondary) { + if (typeof primary !== "function") return secondary; + if (typeof secondary !== "function" || primary === secondary) return primary; + return (event) => { + emitProgress(primary, event); + emitProgress(secondary, event); + }; +} + +function notifyOperationStarted(observer, value) { + if (typeof observer !== "function") return; + try { + const result = observer(value); + if (result && typeof result.then === "function") result.catch(() => {}); + } catch { + // Runtime lifecycle observers are non-authoritative, just like progress. + } +} + +function attachOperationId(error, operationId) { + if (!error || (typeof error !== "object" && typeof error !== "function")) return; + if (typeof error.operationId === "string" && error.operationId) return; + try { + Object.defineProperty(error, "operationId", { + configurable: true, + enumerable: true, + value: operationId, + writable: false + }); + } catch { + // Error correlation is observational. Preserve the original failure when + // a frozen third-party error cannot be annotated. + } +} + +async function applyPrepared(input, operation, execute, control = {}) { + const entry = planLedger.consume(input, operation); + const internal = entry.internal; + const active = operationCoordinator.begin(internal.codexHome, operation, { + actor: internal.actor, + planId: input.planId, + platform: internal.platform + }); + notifyOperationStarted(control.onOperationStarted, { + operationId: active.operationId, + operation + }); + try { + const result = await execute({ + ...internal.executionOptions, + ...(control.signal ? { signal: control.signal } : {}), + ...(typeof control.faultInjector === "function" + ? { faultInjector: control.faultInjector } + : {}), + onProgress: composeProgressObservers( + internal.executionOptions.onProgress, + control.onProgress + ), + expectedPlanState: internal.expectedPlanState + }); + return operationResult(operation, active.operationId, result, internal.sourceBackup); + } catch (error) { + attachOperationId(error, active.operationId); + if (error?.name === "AbortError" && error?.code === "ABORT_ERR") { + throw new CoreError( + "OPERATION_CANCELLED", + "The provider-sync operation was cancelled before commit.", + { operationId: active.operationId, cause: error } + ); + } + throw error; + } finally { + operationCoordinator.end(internal.codexHome, active.operationId, internal.platform); + try { + await getStatus(internal.statusOptions); + } catch { + // Keep the last complete snapshot. A status refresh is observational and + // cannot change the transaction result or replace it with partial state. + } + } +} + +export async function applySync(input, control) { + return applyPrepared(input, "sync", (options) => runSyncCore(options), control); +} + +export async function applySwitch(input, control) { + return applyPrepared(input, "switch", (options) => runSwitchCore(options), control); +} + +export async function applyRestore(input, control) { + return applyPrepared(input, "restore", (options) => runRestoreCore(options), control); +} + +// Internal scheduler hook used by Watch. It exposes completion only for a +// same-process manual operation; external writers remain event-driven and are +// never polled or queued behind. +export function waitForManualOperationEnd({ codexHome, platform } = {}) { + return operationCoordinator.waitForManualOperation( + normalizeCodexHome(codexHome), + platform ?? process.platform + ); +} + +/** @deprecated Compatibility adapter. New transports must use prepareSync/applySync. */ +export async function runSync(options = {}) { + const plan = await prepareSync(options); + return (await applySync({ schemaVersion: 1, planId: plan.planId })).result; +} + +/** @deprecated Compatibility adapter. New transports must use prepareSwitch/applySwitch. */ +export async function runSwitch(options = {}) { + const plan = await prepareSwitch(options); + return (await applySwitch({ schemaVersion: 1, planId: plan.planId })).result; +} + +/** @deprecated Compatibility adapter. New transports must use prepareRestore/applyRestore. */ +export async function runRestore(options = {}) { + const plan = await prepareRestore(options); + return (await applyRestore({ schemaVersion: 1, planId: plan.planId })).result; } export async function runPruneBackups({ @@ -1085,15 +2132,22 @@ export async function runPruneBackups({ keepCount = DEFAULT_BACKUP_RETENTION_COUNT } = {}) { if (!Number.isInteger(keepCount) || keepCount < 0) { - throw new Error(`Invalid keep count: ${keepCount}. Expected a non-negative integer.`); + throw new CoreError( + "INVALID_INPUT", + `Invalid keep count: ${keepCount}. Expected a non-negative integer.` + ); } const codexHome = normalizeCodexHome(explicitCodexHome); await ensureCodexHome(resolveStorageLayout({ codexHome, env: {} })); const releaseLock = await acquireLock(codexHome, "prune-backups"); try { - return await pruneBackups(codexHome, keepCount); + return await pruneManagedBackups(codexHome, keepCount); } finally { await releaseLock(); } } + +export async function pruneBackups(options = {}) { + return runPruneBackups(options); +} diff --git a/src/session-files.js b/src/session-files.js index e147eb7..7f6665d 100644 --- a/src/session-files.js +++ b/src/session-files.js @@ -8,9 +8,18 @@ import { promisify } from "node:util"; import { SESSION_DIRS } from "./constants.js"; import { syncDirectory } from "./atomic-file.js"; +import { CoreError } from "./core-error.js"; const execFileAsync = promisify(execFile); const ROLLOUT_SCAN_CHUNK_BYTES = 1024 * 1024; +const STATUS_SESSION_META_MAX_BYTES = 1024 * 1024; + +class RolloutMetadataLimitError extends Error { + constructor() { + super("Rollout session metadata exceeds the read-only Status limit."); + this.name = "RolloutMetadataLimitError"; + } +} async function syncStagedFile(filePath) { const handle = await fsp.open(filePath, "r+"); @@ -224,14 +233,18 @@ async function listJsonlFiles(rootDir) { return files; } -async function readFirstLineRecord(filePath) { +async function readFirstLineRecord(filePath, { maxBytes = Number.POSITIVE_INFINITY } = {}) { 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 bounded = Number.isSafeInteger(maxBytes) && maxBytes >= 0; + const remaining = bounded ? (maxBytes + 1) - position : 64 * 1024; + const chunkLength = bounded ? Math.min(64 * 1024, remaining) : 64 * 1024; + if (chunkLength <= 0) throw new RolloutMetadataLimitError(); + const chunk = Buffer.alloc(chunkLength); const { bytesRead } = await handle.read(chunk, 0, chunk.length, position); if (bytesRead === 0) { break; @@ -240,6 +253,7 @@ async function readFirstLineRecord(filePath) { collected = Buffer.concat([collected, chunk.subarray(0, bytesRead)]); const newlineIndex = collected.indexOf(0x0a); if (newlineIndex !== -1) { + if (bounded && newlineIndex > maxBytes) throw new RolloutMetadataLimitError(); const crlf = newlineIndex > 0 && collected[newlineIndex - 1] === 0x0d; const lineBuffer = crlf ? collected.subarray(0, newlineIndex - 1) : collected.subarray(0, newlineIndex); return { @@ -248,6 +262,7 @@ async function readFirstLineRecord(filePath) { offset: newlineIndex + 1 }; } + if (bounded && collected.length > maxBytes) throw new RolloutMetadataLimitError(); } return { firstLine: collected.toString("utf8"), @@ -276,34 +291,30 @@ function parseSessionMetaRecord(firstLine) { } } -// Scan the start of a rollout file looking for the first `turn_context` -// event and return its `payload.model` field. This is the field that the -// Codex GUI bottom-right uses to label old conversations, so we have to -// rewrite it (along with `payload.collaboration_mode.settings.model`) on -// every sync in addition to the per-thread SQLite `model` column. +// Scan every `turn_context` in a rollout and return both its current models +// and a compact line-indexed model snapshot. These are the fields the Codex +// GUI uses to label old conversations; the snapshot also lets a managed +// provider-only backup restore a coherent provider/model state later. // // We stream line-by-line because individual `turn_context` lines can // easily exceed 64 KB once Codex includes the `developer_instructions` // blob — the previous code that capped the read at 64 KB silently // missed those, which made the rollout model rewrite a no-op for -// sessions whose first turn was a long planning step. We stop as -// soon as we find a `turn_context` line, so the scan is O(1) for the -// common case and we never load multi-MB rollouts into memory just -// to read a header. +// sessions whose first turn was a long planning step. The scan remains +// streaming, so it never loads a multi-MB rollout into memory. // // For each line we find, we do a regex on the raw text instead of // `JSON.parse`-ing the entire payload: Codex writes opaque multi-KB // strings (`developer_instructions`, raw tool output, …) into the // payload, and round-tripping those through `JSON.parse` -> `JSON.stringify` // would silently mangle embedded escape sequences. Anchoring on -// `"type":"turn_context"` and grabbing the first `"model":""` -// that follows is enough for the first `turn_context` of the file, -// because rollout lines are single JSON objects. +// `"type":"turn_context"` keeps message and tool payloads out of the +// backup manifest; only model strings and line indexes are retained. const ROLLOUT_TURNCONTEXT_TYPE_RE = /"type"\s*:\s*"turn_context"/; async function readTurnContextModelSnapshot( rolloutPath, - { firstLineOffset, firstLineLength, targetModel = null } = {} + { firstLineOffset, firstLineLength } = {} ) { const headerSkip = Math.max(0, firstLineOffset ?? 0); const headerLength = Math.max(0, firstLineLength ?? 0); @@ -330,26 +341,23 @@ async function readTurnContextModelSnapshot( if (!ROLLOUT_TURNCONTEXT_TYPE_RE.test(line)) { continue; } - for (const match of line.matchAll(buildTurnContextModelFieldRegex())) { - try { - const value = decodeJsonStringLiteral(match[1]); - if (typeof value === "string" && value.length > 0) { - models.push(value); - } - } catch { - // Leave malformed model literals untouched. - } + const lineModels = readTurnContextModelsInLine(line); + if (!lineModels) { + continue; } - if (typeof targetModel === "string" && targetModel.length > 0) { - const rewrite = rewriteTurnContextModelInLine(line, targetModel); - if (rewrite.replaced) { - originalTurnContextModels.push({ - lineIndex, - originalModel: rewrite.originalModel, - originalModels: rewrite.originalModels - }); - } + for (const value of lineModels) { + if (value.length > 0) models.push(value); } + // A managed backup is a snapshot of the metadata that existed before + // this operation, not only of fields this particular operation happened + // to rewrite. Recording every parseable turn_context model lets a later + // Restore of a provider-only backup return the rollout to one coherent + // provider/model state without copying any message body. + originalTurnContextModels.push({ + lineIndex, + originalModel: lineModels[0], + originalModels: lineModels + }); } return { models, originalTurnContextModels }; } catch (error) { @@ -413,29 +421,27 @@ function encodeJsonStringLiteral(value) { return JSON.stringify(value); } -function rewriteTurnContextModelInLine(line, newModel) { +function readTurnContextModelsInLine(line) { if (!line || !line.includes('"turn_context"')) { - return { line, replaced: false, originalModel: null }; + return null; } - const regex = buildTurnContextModelFieldRegex(); - // `matchAll` is non-mutating and returns a fresh iterator on - // every call, so we can safely use a stateful regex here. - const occurrences = [...line.matchAll(regex)]; + const occurrences = [...line.matchAll(buildTurnContextModelFieldRegex())]; if (occurrences.length === 0) { - return { line, replaced: false, originalModel: null }; + return null; } - const originalModels = []; try { - for (const occurrence of occurrences) { - originalModels.push(decodeJsonStringLiteral(occurrence[1])); - } + const values = occurrences.map((occurrence) => decodeJsonStringLiteral(occurrence[1])); + return values.every((value) => typeof value === "string") ? values : null; } catch { - // The line looks like a turn_context but its `model` value is - // not a clean JSON string literal. Refuse to touch it rather - // than guess — the roll-out stays byte-identical. - return { line, replaced: false, originalModel: null }; + // The line looks like a turn_context but contains a malformed JSON string + // literal. Refuse to snapshot or rewrite it rather than guessing. + return null; } - if (originalModels.some((model) => typeof model !== "string")) { +} + +function rewriteTurnContextModelInLine(line, newModel) { + const originalModels = readTurnContextModelsInLine(line); + if (!originalModels) { return { line, replaced: false, originalModel: null }; } // If every `model` field in the line already equals newModel, @@ -1085,14 +1091,16 @@ async function rewriteRolloutModelField(change, targetModel) { } lineIndex += 1; const result = rewriteTurnContextModelInLine(line, targetModel); - if (result.replaced) { - replacements += 1; + if (Array.isArray(result.originalModels) && typeof result.originalModel === "string") { originalTurnContextModels.push({ lineIndex, originalModel: result.originalModel, originalModels: result.originalModels }); } + if (result.replaced) { + replacements += 1; + } writer.write(lineSeparator); writer.write(result.line); }); @@ -1102,9 +1110,14 @@ async function rewriteRolloutModelField(change, targetModel) { writer.on("finish", resolve); }); + if (!modelSnapshotsEqual(change.originalTurnContextModels, originalTurnContextModels)) { + await fsp.rm(tmpPath, { force: true }); + throw new Error(`Rollout turn_context model snapshot changed before rewrite: ${change.path}`); + } + if (replacements === 0) { await fsp.rm(tmpPath, { force: true }); - return { replacedLines: 0, originalTurnContextModels: [] }; + return { replacedLines: 0, originalTurnContextModels }; } // Preserve the original trailing newline state. If the file @@ -1129,11 +1142,6 @@ async function rewriteRolloutModelField(change, targetModel) { // new line has no original value in the backup manifest. Throwing here // leaves the appended line untouched and lets the transaction restore the // already-mutated first line. - if (!modelSnapshotsEqual(change.originalTurnContextModels, originalTurnContextModels)) { - await fsp.rm(tmpPath, { force: true }); - throw new Error(`Rollout turn_context model snapshot changed before rewrite: ${change.path}`); - } - await fsp.chmod(tmpPath, beforeStat.mode); await syncStagedFile(tmpPath); await fsp.rename(tmpPath, filePath); @@ -1188,6 +1196,58 @@ async function findLockedFilesOnWindows(filePaths) { } } +// Public Web/Electron Status needs the complete provider distribution, but it +// must not scan message/event bodies. Reading only the first session_meta line +// keeps Status bounded by rollout count instead of total rollout byte size. +// Write preparation deliberately continues to use collectSessionChanges below. +export async function collectStatusRolloutMetadata(codexHome, options = {}) { + const { skipLockedReads = false } = options; + const lockedPaths = []; + const incompletePaths = []; + const providerCounts = { + sessions: new Map(), + archived_sessions: 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, { maxBytes: STATUS_SESSION_META_MAX_BYTES }); + } catch (error) { + if (error instanceof RolloutMetadataLimitError) { + incompletePaths.push(rolloutPath); + continue; + } + if (skipLockedReads && isRolloutFileBusyError(error)) { + lockedPaths.push(rolloutPath); + continue; + } + throw error; + } + const parsed = parseSessionMetaRecord(record.firstLine); + if (!parsed) { + incompletePaths.push(rolloutPath); + continue; + } + const currentProvider = parsed.payload.model_provider ?? "(missing)"; + providerCounts[dirName].set( + currentProvider, + (providerCounts[dirName].get(currentProvider) ?? 0) + 1 + ); + } + } + + return { incompletePaths, lockedPaths, providerCounts }; +} + export async function collectSessionChanges(codexHome, targetProvider, options = {}) { const { skipLockedReads = false, @@ -1249,15 +1309,12 @@ export async function collectSessionChanges(codexHome, targetProvider, options = throw error; } - // Peek at the first `turn_context` event to capture the - // per-turn model that the Codex GUI bottom-right reads. We - // keep this on the summary so the rewrite step knows what - // value to swap out, without making collectSessionChanges - // require a target model. + // Stream all `turn_context` events so the rewrite step can validate the + // complete model snapshot and a provider-only backup can later restore + // those same metadata fields without retaining any message content. const modelSnapshot = await readTurnContextModelSnapshot(rolloutPath, { firstLineOffset: 0, - firstLineLength: record.offset, - targetModel + firstLineLength: record.offset }); const currentModels = modelSnapshot.models; const originalModel = currentModels[0] ?? null; @@ -1303,6 +1360,59 @@ export async function collectSessionChanges(codexHome, targetProvider, options = return { changes: summaries, lockedPaths, providerCounts, encryptedContentCounts, userEventThreadIds, threadCwdById }; } +// Capture only the metadata fields that provider-sync is allowed to restore. +// The returned entries deliberately exclude message/tool payloads while using +// the same manifest shape as a managed provider-only backup. +export async function captureSessionRestoreEntries(filePaths) { + const entries = []; + const seen = new Set(); + for (const value of filePaths ?? []) { + const rolloutPath = path.resolve(value); + const identity = process.platform === "win32" ? rolloutPath.toLowerCase() : rolloutPath; + if (seen.has(identity)) { + continue; + } + seen.add(identity); + + let captured = null; + for (let attempt = 0; attempt < 2 && captured === null; attempt += 1) { + const before = await getFileSnapshot(rolloutPath); + const record = await readFirstLineRecord(rolloutPath); + const parsed = parseSessionMetaRecord(record.firstLine); + if (!parsed) { + throw new CoreError( + "RESTORE_VALIDATION_FAILED", + `Rollout does not start with a valid session_meta record: ${rolloutPath}` + ); + } + const models = await readTurnContextModelSnapshot(rolloutPath, { + firstLineOffset: 0, + firstLineLength: record.offset + }); + const after = await getFileSnapshot(rolloutPath); + if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) { + continue; + } + captured = { + path: rolloutPath, + originalFirstLine: record.firstLine, + originalSeparator: record.separator || "\n", + originalMtimeMs: after.mtimeMs, + originalTurnContextModels: models.originalTurnContextModels, + modelOnlyChange: false + }; + } + if (captured === null) { + throw new CoreError( + "ROLLOUT_CHANGED", + `Rollout changed while its recovery metadata was captured: ${rolloutPath}` + ); + } + entries.push(captured); + } + return entries; +} + export async function applySessionChanges(changes, options = {}) { const normalizedChanges = changes ?? []; const { @@ -1535,8 +1645,19 @@ export async function restoreSessionChanges(manifestEntries, options = {}) { await rewriteFirstLine(entry.path, entry.originalFirstLine, entry.originalSeparator ?? "\n"); } } + await options.onAfterFirstLineRestore?.(entry); if (entry.originalTurnContextModels?.length) { - await restoreTurnContextModelsInFile(entry.path, entry.originalTurnContextModels, entry.originalSeparator); + const modelRestore = await restoreTurnContextModelsInFile( + entry.path, + entry.originalTurnContextModels, + entry.originalSeparator + ); + if (!modelRestore.restored) { + throw new CoreError( + "ROLLOUT_CHANGED", + `Rollout turn_context model restore could not be verified: ${entry.path}` + ); + } } await restoreOriginalMtime(entry.path, entry.originalMtimeMs); restoredPaths.push(entry.path); @@ -1583,7 +1704,7 @@ export async function restoreSessionChanges(manifestEntries, options = {}) { // separator + trailing-newline state of the file. async function restoreTurnContextModelsInFile(filePath, originalTurnContextModels, originalSeparator) { if (!filePath || !Array.isArray(originalTurnContextModels) || originalTurnContextModels.length === 0) { - return; + return { restored: false, changed: false }; } // Build a quick lookup by index. const byIndex = new Map(); @@ -1593,7 +1714,7 @@ async function restoreTurnContextModelsInFile(filePath, originalTurnContextModel } } if (byIndex.size === 0) { - return; + return { restored: false, changed: false }; } const beforeStat = await fsp.stat(filePath); @@ -1606,7 +1727,10 @@ async function restoreTurnContextModelsInFile(filePath, originalTurnContextModel handle = await fsp.open(filePath, "r+"); const openedStat = await handle.stat(); if (openedStat.size !== beforeSnapshot.size || openedStat.mtimeMs !== beforeSnapshot.mtimeMs) { - return; + throw new CoreError( + "ROLLOUT_CHANGED", + `Rollout changed before turn_context model restore: ${filePath}` + ); } const tail = Buffer.alloc(Math.min(2, openedStat.size)); if (tail.length > 0) { @@ -1621,6 +1745,8 @@ async function restoreTurnContextModelsInFile(filePath, originalTurnContextModel let firstLine = true; let lineIndex = -1; let replacements = 0; + let validationError = null; + const matchedIndexes = new Set(); await new Promise((resolve, reject) => { reader.on("error", reject); @@ -1634,7 +1760,26 @@ async function restoreTurnContextModelsInFile(filePath, originalTurnContextModel } lineIndex += 1; const restoreEntry = byIndex.get(lineIndex); - if (restoreEntry !== undefined && line.includes('"turn_context"')) { + if (restoreEntry !== undefined) { + const currentModels = line.includes('"turn_context"') + && ROLLOUT_TURNCONTEXT_TYPE_RE.test(line) + ? readTurnContextModelsInLine(line) + : null; + const expectedModelCount = Array.isArray(restoreEntry.originalModels) + ? restoreEntry.originalModels.length + : null; + if (!currentModels + || currentModels.length === 0 + || (expectedModelCount !== null && currentModels.length !== expectedModelCount)) { + validationError ??= new CoreError( + "ROLLOUT_CHANGED", + `Rollout turn_context model snapshot changed before restore: ${filePath}` + ); + } else { + matchedIndexes.add(lineIndex); + } + } + if (restoreEntry !== undefined && validationError === null) { // The current line is a turn_context line whose // per-turn `model` field we need to put back. We only // touch it if it currently holds some other value @@ -1659,9 +1804,17 @@ async function restoreTurnContextModelsInFile(filePath, originalTurnContextModel writer.on("finish", resolve); }); + if (validationError || matchedIndexes.size !== byIndex.size) { + await fsp.rm(tmpPath, { force: true }); + throw validationError ?? new CoreError( + "ROLLOUT_CHANGED", + `Rollout turn_context model snapshot is incomplete during restore: ${filePath}` + ); + } + if (replacements === 0) { await fsp.rm(tmpPath, { force: true }); - return; + return { restored: true, changed: false }; } if (hasTrailingNewline) { @@ -1671,13 +1824,17 @@ async function restoreTurnContextModelsInFile(filePath, originalTurnContextModel const afterStat = await fsp.stat(filePath); if (afterStat.size !== beforeSnapshot.size || afterStat.mtimeMs !== beforeSnapshot.mtimeMs) { await fsp.rm(tmpPath, { force: true }); - return; + throw new CoreError( + "ROLLOUT_CHANGED", + `Rollout changed during turn_context model restore: ${filePath}` + ); } await fsp.chmod(tmpPath, beforeStat.mode); await syncStagedFile(tmpPath); await fsp.rename(tmpPath, filePath); await syncDirectory(path.dirname(filePath)); + return { restored: true, changed: true }; } catch (error) { throw wrapRolloutFileBusyError(error, filePath, "restore turn_context model"); } finally { diff --git a/src/sqlite-state.js b/src/sqlite-state.js index 513aaf7..68a284c 100644 --- a/src/sqlite-state.js +++ b/src/sqlite-state.js @@ -3,10 +3,11 @@ import path from "node:path"; import { syncDirectory } from "./atomic-file.js"; import { DB_FILE_BASENAME, SESSION_DIRS, SQLITE_DIR_BASENAME } from "./constants.js"; +import { CoreError } from "./core-error.js"; import { openDatabase } from "./sqlite.js"; import { resolveStorageLayout } from "./storage-layout.js"; -const DEFAULT_BUSY_TIMEOUT_MS = 5000; +const DEFAULT_BUSY_TIMEOUT_MS = 0; export function stateDbPath(codexHome) { return path.join(codexHome, SQLITE_DIR_BASENAME, DB_FILE_BASENAME); @@ -196,25 +197,43 @@ export function configureSqliteWriteDurability(db) { return { synchronous: "full", value: synchronous }; } +function sqlitePrimaryResultCode(error) { + return Number.isInteger(error?.errcode) ? error.errcode & 0xff : null; +} + function isSqliteBusyError(error) { - const message = `${error?.code ?? ""} ${error?.message ?? ""}`.toLowerCase(); - return message.includes("database is locked") || message.includes("sqlite_busy") || message.includes("busy"); + if (error instanceof CoreError) return error.code === "SQLITE_BUSY"; + const primaryCode = sqlitePrimaryResultCode(error); + return error?.code === "SQLITE_BUSY" + || error?.code === "SQLITE_LOCKED" + || (error?.code === "ERR_SQLITE_ERROR" && (primaryCode === 5 || primaryCode === 6)); } 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"); + if (error instanceof CoreError) return error.code === "SQLITE_UNREADABLE"; + const primaryCode = sqlitePrimaryResultCode(error); + return error?.code === "SQLITE_CORRUPT" + || error?.code === "SQLITE_NOTADB" + || (error?.code === "ERR_SQLITE_ERROR" && (primaryCode === 11 || primaryCode === 26)); +} + +function sqliteErrorDetails(error) { + const primaryCode = sqlitePrimaryResultCode(error); + return { + ...(typeof error?.code === "string" ? { causeCode: error.code } : {}), + ...(primaryCode !== null ? { sqlitePrimaryCode: primaryCode } : {}) + }; } 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}` + if (error instanceof CoreError) return error; + return new CoreError( + "SQLITE_BUSY", + `Unable to ${action} because state_5.sqlite is currently in use. Close Codex and the Codex app, then retry. Original error: ${error.message}`, + { cause: error, details: sqliteErrorDetails(error) } ); } @@ -222,8 +241,11 @@ 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}` + if (error instanceof CoreError) return error; + return new CoreError( + "SQLITE_UNREADABLE", + `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}`, + { cause: error, details: sqliteErrorDetails(error) } ); } @@ -548,15 +570,19 @@ export async function createSqliteOnlineBackup(storageOrLocation, destinationPat } let db; + let backupPhase = "source-open"; try { // Read-only is deliberate: a database that disappears after discovery // must fail here instead of being silently recreated as an empty file. db = await openDatabase(fullSourcePath, { readOnly: true }); + backupPhase = "source-metadata"; setBusyTimeout(db, options.busyTimeoutMs); const sourceMetadata = readSqliteConnectionMetadata(db); const driver = db.driver ?? "unknown"; + backupPhase = "destination-backup"; await db.backup(fullDestinationPath, options.backupOptions ?? {}); + backupPhase = "destination-sync"; const handle = await fs.open(fullDestinationPath, "r+"); try { await handle.sync(); @@ -591,8 +617,13 @@ export async function createSqliteOnlineBackup(storageOrLocation, destinationPat `${fullDestinationPath}-wal`, `${fullDestinationPath}-shm` ].map((filePath) => fs.rm(filePath, { force: true }).catch(() => {}))); + const phasedError = new Error( + `SQLite online backup failed during ${backupPhase}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined } + ); + if (typeof error?.code === "string") phasedError.code = error.code; throw wrapSqliteMalformedError( - wrapSqliteBusyError(error, "create a consistent SQLite online backup"), + wrapSqliteBusyError(phasedError, "create a consistent SQLite online backup"), "create a consistent SQLite online backup" ); } finally { diff --git a/src/sqlite.js b/src/sqlite.js index ae9be21..feb310f 100644 --- a/src/sqlite.js +++ b/src/sqlite.js @@ -1,5 +1,28 @@ +import path from "node:path"; + let databaseFactoryPromise = null; +// electron-vite replaces this identifier only in the Desktop bundle. Regular +// Node/CLI execution sees it as undefined, while the hidden test bundle can +// exercise the real Core through the packaged native fallback without adding +// a production environment-variable switch. +// @ts-ignore -- compile-time Desktop test-bundle define guarded by typeof. +const forceBetterSqlite3ForDesktopTestBuild = typeof __CPS_DESKTOP_FORCE_BETTER_SQLITE3__ !== "undefined" + // @ts-ignore -- compile-time Desktop test-bundle define guarded above. + && __CPS_DESKTOP_FORCE_BETTER_SQLITE3__ === true; + +function nativeSqlitePath(value) { + if (process.platform !== "win32" + || typeof value !== "string" + || value === ":memory:" + || value.startsWith("file:")) { + return value; + } + // SQLite's Windows VFS needs the extended-length form once deeply nested + // managed backup/snapshot paths cross MAX_PATH. + return path.toNamespacedPath(path.resolve(value)); +} + function normalizeImportDefault(moduleNamespace) { return moduleNamespace.default ?? moduleNamespace; } @@ -7,7 +30,7 @@ function normalizeImportDefault(moduleNamespace) { class BetterSqliteDatabase { constructor(Database, dbPath, options = {}) { this.driver = "better-sqlite3"; - this.db = new Database(dbPath, { + this.db = new Database(nativeSqlitePath(dbPath), { readonly: Boolean(options.readOnly) }); } @@ -21,7 +44,7 @@ class BetterSqliteDatabase { } async backup(destinationPath, options = {}) { - return this.db.backup(destinationPath, options); + return this.db.backup(nativeSqlitePath(destinationPath), options); } close() { @@ -33,7 +56,7 @@ class NodeSqliteDatabase { constructor(sqlite, dbPath, options = {}) { this.driver = "node:sqlite"; this.sqlite = sqlite; - this.db = new sqlite.DatabaseSync(dbPath, options); + this.db = new sqlite.DatabaseSync(nativeSqlitePath(dbPath), options); } prepare(sql) { @@ -45,7 +68,7 @@ class NodeSqliteDatabase { } async backup(destinationPath, options = {}) { - return this.sqlite.backup(this.db, destinationPath, options); + return this.sqlite.backup(this.db, nativeSqlitePath(destinationPath), options); } close() { @@ -54,13 +77,15 @@ class NodeSqliteDatabase { } async function loadDatabaseFactory() { - try { - const sqlite = await import("node:sqlite"); - if (sqlite.DatabaseSync && typeof sqlite.backup === "function") { - return (dbPath, options) => new NodeSqliteDatabase(sqlite, dbPath, options); + if (!forceBetterSqlite3ForDesktopTestBuild) { + try { + const sqlite = await import("node:sqlite"); + if (sqlite.DatabaseSync && typeof sqlite.backup === "function") { + return (dbPath, options) => new NodeSqliteDatabase(sqlite, dbPath, options); + } + } catch { + // Older Node.js releases do not include node:sqlite. } - } catch { - // Older Node.js releases do not include node:sqlite. } try { diff --git a/src/state-db-lock.js b/src/state-db-lock.js new file mode 100644 index 0000000..bdaa34d --- /dev/null +++ b/src/state-db-lock.js @@ -0,0 +1,114 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { DB_FILE_BASENAME } from "./constants.js"; +import { CoreError } from "./core-error.js"; +import { acquirePathLock } from "./locking.js"; + +function unverifiable(message, cause) { + return new CoreError("LOCK_UNVERIFIABLE", message, { + cause, + details: { + lockScope: "state-db", + ...(typeof cause?.code === "string" ? { causeCode: cause.code } : {}) + } + }); +} + +function permissionDenied(message, cause) { + return new CoreError("PERMISSION_DENIED", message, { + cause, + details: { + lockScope: "state-db", + ...(typeof cause?.code === "string" ? { causeCode: cause.code } : {}) + } + }); +} + +function normalizeIdentityPart(value, platform) { + return platform === "win32" ? value.toLowerCase() : value; +} + +export async function resolveStateDbLockResource( + stateDbPath, + { fsImpl = fs, platform = process.platform } = {} +) { + if (typeof stateDbPath !== "string" || !stateDbPath.trim()) { + throw unverifiable("The State DB resource path is missing or invalid."); + } + const lexicalPath = path.resolve(stateDbPath); + if (path.basename(lexicalPath).toLowerCase() !== DB_FILE_BASENAME.toLowerCase()) { + throw unverifiable("The State DB resource filename is not canonical."); + } + const lexicalParent = path.dirname(lexicalPath); + let realParent; + try { + realParent = await fsImpl.realpath(lexicalParent); + const parentStats = await fsImpl.stat(realParent); + if (!parentStats.isDirectory()) { + throw unverifiable("The State DB physical parent is not a directory."); + } + const verifiedParent = await fsImpl.realpath(lexicalParent); + if (normalizeIdentityPart(realParent, platform) !== normalizeIdentityPart(verifiedParent, platform)) { + throw unverifiable("The State DB physical parent changed while its identity was resolved."); + } + } catch (error) { + if (error instanceof CoreError) throw error; + if (error?.code === "EACCES" || error?.code === "EPERM") { + throw permissionDenied("Permission denied while resolving the State DB resource identity.", error); + } + throw unverifiable("The State DB physical parent identity cannot be verified.", error); + } + + let physicalFileName = DB_FILE_BASENAME; + try { + const realFile = await fsImpl.realpath(lexicalPath); + physicalFileName = path.basename(realFile); + realParent = path.dirname(realFile); + const fileStats = await fsImpl.stat(realFile); + if (!fileStats.isFile()) throw unverifiable("The State DB target is not a regular file."); + if (physicalFileName.toLowerCase() !== DB_FILE_BASENAME.toLowerCase()) { + throw unverifiable("The State DB physical filename is not canonical."); + } + } catch (error) { + if (error instanceof CoreError) throw error; + if (error?.code !== "ENOENT") { + if (error?.code === "EACCES" || error?.code === "EPERM") { + throw permissionDenied("Permission denied while resolving the State DB physical target.", error); + } + throw unverifiable("The State DB physical target identity cannot be verified.", error); + } + } + + const normalizedParent = normalizeIdentityPart(path.resolve(realParent), platform); + const normalizedFileName = normalizeIdentityPart(physicalFileName, platform); + // NUL cannot occur in a filesystem path and therefore gives an unambiguous, + // cross-runtime identity serialization. + const identity = `${normalizedParent}\0${normalizedFileName}`; + const resourceKey = createHash("sha256").update(identity, "utf8").digest("hex"); + const lockPath = path.join( + path.resolve(realParent), + ".codex-provider-sync", + "locks", + `${resourceKey}.lock` + ); + return Object.freeze({ + identity, + resourceKey, + realDbParent: path.resolve(realParent), + stateDbPath: path.join(path.resolve(realParent), physicalFileName), + lockPath + }); +} + +export async function acquireStateDbLock(stateDbPath, label = "codex-provider-sync", options = {}) { + const resource = await resolveStateDbLockResource(stateDbPath, options); + const release = await acquirePathLock(resource.lockPath, label, { + ...options, + scope: "state-db", + resourceKey: resource.resourceKey + }); + return { resource, release }; +} + diff --git a/src/storage-layout.js b/src/storage-layout.js index c1eba31..2845840 100644 --- a/src/storage-layout.js +++ b/src/storage-layout.js @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { DB_FILE_BASENAME, defaultCodexHome } from "./constants.js"; +import { CoreError } from "./core-error.js"; import { readSqliteHomeFromConfigText } from "./config-file.js"; function resolvePath(value, cwd) { @@ -82,8 +83,18 @@ export function resolveStorageLayout({ } export async function ensureCodexHome(storage) { - await fs.access(storage.codexHome).catch(() => { - throw new Error(`Codex home not found at ${storage.codexHome}`); + await fs.access(storage.codexHome).catch((error) => { + const permissionDenied = error?.code === "EACCES" || error?.code === "EPERM"; + throw new CoreError( + permissionDenied ? "PERMISSION_DENIED" : "CODEX_HOME_NOT_FOUND", + permissionDenied + ? `Permission denied while accessing Codex home at ${storage.codexHome}` + : `Codex home not found at ${storage.codexHome}`, + { + cause: error, + details: typeof error?.code === "string" ? { causeCode: error.code } : undefined + } + ); }); } @@ -97,12 +108,16 @@ export function isConfiguredSqliteHome(storage) { export function assertSqliteAccessSupported(storage, operation) { if (storage.sqliteAccess?.supported === false) { - throw new Error(`Cannot ${operation}: ${storage.sqliteAccess.message}`); + throw new CoreError("SQLITE_UNSUPPORTED_PATH", `Cannot ${operation}: ${storage.sqliteAccess.message}`, { + details: storage.sqliteAccess.reason ? { reason: storage.sqliteAccess.reason } : undefined + }); } } export function missingConfiguredStateDbError(storage) { - return new Error( - `state_5.sqlite not found in configured SQLite home ${storage.sqliteHome} (source: ${storage.sqliteHomeSource}).` + return new CoreError( + "STATE_DB_NOT_FOUND", + `state_5.sqlite not found in configured SQLite home ${storage.sqliteHome} (source: ${storage.sqliteHomeSource}).`, + { details: { sqliteHomeSource: storage.sqliteHomeSource } } ); } diff --git a/src/transaction-journal.js b/src/transaction-journal.js index 65ee655..f1b027a 100644 --- a/src/transaction-journal.js +++ b/src/transaction-journal.js @@ -4,6 +4,8 @@ import { randomUUID } from "node:crypto"; import { defaultBackupRoot } from "./constants.js"; import { writeFileAtomic, syncDirectory } from "./atomic-file.js"; +import { CoreError } from "./core-error.js"; +import { findBlockingRestoreJournals } from "./restore-journal.js"; export const TRANSACTION_JOURNAL_BASENAME = "transaction-journal.jsonl"; const TERMINAL_STATES = new Set(["committed", "rolledBack"]); @@ -307,6 +309,7 @@ export async function readTransactionJournal(filePath) { const lastEvent = events.at(-1) ?? null; return { filePath, + operationKind: "sync", events, invalidTail, validationError, @@ -348,7 +351,7 @@ export function getAppliedJournalTargets(journal) { .map((target) => target.targetPath); } -export async function findPendingTransactions(codexHome) { +export async function findLegacyPendingTransactions(codexHome) { const root = defaultBackupRoot(codexHome); let entries; try { @@ -376,6 +379,7 @@ export async function findPendingTransactions(codexHome) { pending.push({ filePath: journalPath, backupDir: path.dirname(journalPath), + operationKind: "sync", state: "recoveryRequired", terminal: false, readError: error.message @@ -386,12 +390,24 @@ export async function findPendingTransactions(codexHome) { return pending.sort((left, right) => left.filePath.localeCompare(right.filePath)); } -export class RecoveryRequiredError extends Error { +export async function findPendingTransactions(codexHome) { + const [legacy, restore] = await Promise.all([ + findLegacyPendingTransactions(codexHome), + findBlockingRestoreJournals(codexHome) + ]); + return [...legacy, ...restore] + .sort((left, right) => left.filePath.localeCompare(right.filePath)); +} + +export class RecoveryRequiredError extends CoreError { constructor(pendingTransactions) { const backups = pendingTransactions.map((item) => item.backupDir).join(", "); - super(`An unfinished provider-sync transaction requires recovery before another write. Restore the bound backup, then retry. Backup(s): ${backups}`); + super( + "RECOVERY_REQUIRED", + `An unfinished provider-sync transaction requires recovery before another write. Restore the bound backup, then retry. Backup(s): ${backups}`, + { suggestedAction: "Restore the transaction-bound managed backup before starting another write." } + ); this.name = "RecoveryRequiredError"; - this.code = "RECOVERY_REQUIRED"; this.pendingTransactions = pendingTransactions; } } diff --git a/src/watch.js b/src/watch.js index 5265b5a..a86b847 100644 --- a/src/watch.js +++ b/src/watch.js @@ -13,7 +13,10 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { CoreError } from "./core-error.js"; +import { sharedOperationCoordinator } from "./operation-coordinator.js"; import { detectStateDb } from "./sqlite-state.js"; import { readConfigText, readRootModelFromConfigText } from "./config-file.js"; import { @@ -25,6 +28,50 @@ import { withStateDbLocation } from "./storage-layout.js"; +const watchRegistry = new Map(); +const activeWatchByScope = new Map(); +const pendingWatchStartByScope = new Map(); +const MAX_WATCH_HISTORY = 64; + +async function physicalWatchScope(options) { + const codexHome = normalizeCodexHome(options.codexHome); + let physical; + try { + physical = await fsp.realpath(codexHome); + const info = await fsp.stat(physical); + if (!info.isDirectory()) throw Object.assign(new Error("Codex Home is not a directory."), { code: "ENOTDIR" }); + physical = await fsp.realpath(physical); + } catch (error) { + if (error?.code === "EACCES" || error?.code === "EPERM") { + throw new CoreError("PERMISSION_DENIED", "Permission denied while resolving the Watch scope.", { + cause: error, + details: { causeCode: error.code } + }); + } + throw new CoreError("CODEX_HOME_NOT_FOUND", "Codex Home could not be resolved for Watch.", { + cause: error + }); + } + const resolved = path.resolve(physical); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function pruneWatchHistory() { + let stoppedCount = 0; + for (const entry of watchRegistry.values()) { + if (entry.status === "stopped") stoppedCount += 1; + } + let removeCount = stoppedCount - MAX_WATCH_HISTORY; + if (removeCount <= 0) return; + for (const [watchId, entry] of watchRegistry) { + if (removeCount <= 0) break; + if (entry.status === "stopped") { + watchRegistry.delete(watchId); + removeCount -= 1; + } + } +} + function defaultDebounceMs() { return 750; } @@ -33,26 +80,7 @@ function describeEvent(eventType, filename) { return `${eventType ?? "change"}${filename ? `:${filename}` : ""}`; } -function makeDebouncer(delayMs, run) { - let timer = null; - let pending = null; - - const fire = () => { - timer = null; - const args = pending; - pending = null; - run(...args); - }; - - return function schedule(...args) { - pending = args; - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(fire, delayMs); - }; -} - +/** @deprecated Compatibility adapter. New transports must use startWatch/stopWatch/getWatchStatus once available. */ export async function runWatch({ codexHome: explicitCodexHome, sqliteHome: explicitSqliteHome, @@ -65,19 +93,41 @@ export async function runWatch({ runSyncImpl, signal, sleepImpl, - platform + platform, + manualOperationWaiter, + accessImpl = fsp.access } = {}) { if (!Number.isInteger(debounceMs) || debounceMs < 0) { - throw new Error(`Invalid --debounce-ms value: ${debounceMs}. Expected a non-negative integer.`); + throw new CoreError( + "INVALID_INPUT", + `Invalid --debounce-ms value: ${debounceMs}. Expected a non-negative integer.` + ); } const codexHome = normalizeCodexHome(explicitCodexHome); const configPath = path.join(codexHome, "config.toml"); - await fsp.access(codexHome).catch(() => { - throw new Error(`Codex home not found at ${codexHome}`); + await accessImpl(codexHome).catch((error) => { + if (error?.code === "EACCES" || error?.code === "EPERM") { + throw new CoreError("PERMISSION_DENIED", `Permission denied while accessing Codex home at ${codexHome}.`, { + cause: error, + details: { causeCode: error.code } + }); + } + throw new CoreError("CODEX_HOME_NOT_FOUND", `Codex home not found at ${codexHome}`, { + cause: error + }); }); - await fsp.access(configPath).catch(() => { - throw new Error(`config.toml not found at ${configPath}`); + await accessImpl(configPath).catch((error) => { + if (error?.code === "EACCES" || error?.code === "EPERM") { + throw new CoreError("PERMISSION_DENIED", `Permission denied while accessing ${configPath}.`, { + cause: error, + details: { causeCode: error.code } + }); + } + throw new CoreError("CODEX_HOME_NOT_FOUND", `config.toml not found at ${configPath}`, { + cause: error, + details: { missing: "config.toml" } + }); }); const log = (message) => { @@ -100,7 +150,7 @@ export async function runWatch({ return withStateDbLocation(layout, await detectStateDb(layout)); }; - const invokeSync = async (reason, storage) => { + const invokeSync = async (reason, reasons, storage) => { // Read the current root-level model on every fire so the per-thread // model rewrite picks up the latest value the user has in config.toml. // We only consider the top-level (root) `model = "..."` line — anything @@ -115,23 +165,38 @@ export async function runWatch({ // Missing/unreadable config; carry on with a null model. } if (typeof onSync === "function") { - return onSync({ reason, codexHome, sqliteHome: storage.sqliteHome, storage, model: rootModel }); + return onSync({ reason, reasons, codexHome, sqliteHome: storage.sqliteHome, storage, model: rootModel }); } if (typeof runSyncImpl === "function") { - return runSyncImpl({ codexHome, sqliteHome: storage.sqliteHome, storage, reason, model: rootModel }); + return runSyncImpl({ codexHome, sqliteHome: storage.sqliteHome, storage, reason, reasons, model: rootModel }); } - // Lazy import to avoid pulling in the full service module until needed. - const { runSync } = await import("./service.js"); - return runSync({ + // Lazy import to avoid pulling in the public Core boundary until needed. + const { prepareSync, applySync } = await import("./public-api.js"); + const plan = await prepareSync({ codexHome, storage, model: rootModel, + __actor: "watch", onProgress: (event) => { if (event?.stage && event.status === "start") { log(` · ${event.stage}`); } } }); + // Yield one event-loop turn after the read-only plan so an already queued + // user confirmation can declare the manual Apply first. + await new Promise((resolve) => setImmediate(resolve)); + return (await applySync({ schemaVersion: 1, planId: plan.planId })).result; + }; + + const getManualOperationWait = async () => { + if (typeof manualOperationWaiter === "function") { + return manualOperationWaiter({ codexHome, platform }); + } + return sharedOperationCoordinator.waitForManualOperation( + codexHome, + platform ?? process.platform + ); }; let stopped = false; @@ -140,6 +205,11 @@ export async function runWatch({ let stateWatchGeneration = 0; let stateDbInfo = null; let activeStorage = null; + let debounceTimer = null; + const pendingReasons = new Set(); + let rerunRequested = false; + let busyWaitTicket = null; + let waitingForExternalChange = false; // Track the currently-running sync (if any) so that stop()/SIGINT can // wait for it to drain instead of yanking the watcher out from under // a half-written SQLite transaction. @@ -166,15 +236,35 @@ export async function runWatch({ resolveDone = resolve; }); - const debouncedSync = makeDebouncer(debounceMs, (reason) => { + const scheduleSync = (reason) => { if (stopped) { return; } + pendingReasons.add(reason); + if (busyWaitTicket) return; + waitingForExternalChange = false; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + void launchPendingSync(); + }, debounceMs); + }; + + const launchPendingSync = () => { + if (stopped || pendingReasons.size === 0) return; + if (inFlight) { + rerunRequested = true; + return; + } + const reasons = [...pendingReasons].sort(); + pendingReasons.clear(); + rerunRequested = false; + const reason = reasons.includes("config.toml") ? "config.toml" : reasons[0]; log(`[${new Date().toISOString()}] Detected change (${reason}); running sync...`); const task = (async () => { try { const nextStorage = await resolveCurrentStorage(); - if (includeStateDb && reason === "config.toml") { + if (includeStateDb && reasons.includes("config.toml")) { await rebindStateWatchers(nextStorage); } else { activeStorage = nextStorage; @@ -184,7 +274,7 @@ export async function runWatch({ consecutiveNonBusyFailures = 0; return; } - const result = await invokeSync(reason, nextStorage); + const result = await invokeSync(reason, reasons, nextStorage); log(`[${new Date().toISOString()}] Sync complete: provider=${result.targetProvider}, rollout_files=${result.changedSessionFiles}, sqlite_rows=${result.sqliteRowsUpdated}${result.skippedLockedRolloutFiles?.length ? `, skipped_locked=${result.skippedLockedRolloutFiles.length}` : ""}`); // A successful sync resets the consecutive-failure counter // so a transient error followed by recovery does not @@ -195,10 +285,49 @@ export async function runWatch({ } } catch (error) { const message = error instanceof Error ? error.message : String(error); + const isRecoveryBlocked = error?.code === "RECOVERY_REQUIRED" + || error?.code === "PENDING_TRANSACTION"; + if (isRecoveryBlocked) { + log(`[${new Date().toISOString()}] Watcher stopped: explicit recovery is required.`); + await shutdown("recovery-required", task); + return; + } // SQLite being in use is a normal transient condition while Codex // is actively writing. Don't crash; just retry on the next event. - if (/state_5\.sqlite is currently in use/i.test(message)) { - log(`[${new Date().toISOString()}] Sync skipped: ${message} (will retry on next change)`); + const isTypedSqliteBusy = error?.code === "SQLITE_BUSY"; + const isOperationBusy = error?.code === "OPERATION_BUSY"; + const isLockUnverifiable = error?.code === "LOCK_UNVERIFIABLE"; + const isLegacySqliteBusy = !error?.code && /state_5\.sqlite is currently in use/i.test(message); + if (isTypedSqliteBusy || isLegacySqliteBusy || isOperationBusy || isLockUnverifiable) { + const disposition = isOperationBusy + ? "yielded to an active manual operation" + : (isLockUnverifiable ? "lock ownership could not be verified" : "SQLite is busy"); + log(`[${new Date().toISOString()}] Sync skipped: ${message} (${disposition}; will retry on the next change)`); + for (const retainedReason of reasons) pendingReasons.add(retainedReason); + if (isOperationBusy) { + const ticket = await getManualOperationWait(); + if (ticket?.promise && typeof ticket.promise.then === "function") { + busyWaitTicket = ticket; + void ticket.promise.then(() => { + if (busyWaitTicket !== ticket) return; + busyWaitTicket = null; + if (stopped || pendingReasons.size === 0 || debounceTimer) return; + debounceTimer = setTimeout(() => { + debounceTimer = null; + void launchPendingSync(); + }, debounceMs); + }).catch(() => { + if (busyWaitTicket === ticket) { + busyWaitTicket = null; + waitingForExternalChange = true; + } + }); + } else { + waitingForExternalChange = true; + } + } else { + waitingForExternalChange = true; + } // Busy is normal — reset the consecutive-failure counter // so a long-running Codex session that keeps the DB open // for many seconds does not push us toward the auto-shutdown @@ -225,10 +354,21 @@ export async function runWatch({ if (inFlight === task) { inFlight = null; } + if (!stopped + && !busyWaitTicket + && !waitingForExternalChange + && (rerunRequested || pendingReasons.size > 0) + && !debounceTimer) { + rerunRequested = false; + debounceTimer = setTimeout(() => { + debounceTimer = null; + void launchPendingSync(); + }, debounceMs); + } } })(); inFlight = task; - }); + }; const initialStorage = await resolveCurrentStorage(); @@ -237,7 +377,7 @@ export async function runWatch({ return; } log(`[${new Date().toISOString()}] config.toml ${describeEvent(eventType, filename)}`); - debouncedSync("config.toml"); + scheduleSync("config.toml"); }); watchers.push(configWatcher); @@ -258,6 +398,15 @@ export async function runWatch({ return; } stopped = true; + pendingReasons.clear(); + rerunRequested = false; + waitingForExternalChange = false; + busyWaitTicket?.cancel?.(); + busyWaitTicket = null; + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } stateWatchGeneration += 1; for (const watcher of watchers) { try { @@ -388,7 +537,7 @@ export async function runWatch({ // re-opened. Either way a sync should run. void filename; log(`[${new Date().toISOString()}] ${reasonLabel} change${filename ? `:${filename}` : ""}`); - debouncedSync(reasonLabel); + scheduleSync(reasonLabel); }); } catch (error) { // Path may have gone away. Wait a moment and try again; @@ -419,3 +568,98 @@ export async function runWatch({ done: donePromise }; } + +function watchSnapshot(entry) { + return { + schemaVersion: 1, + watchId: entry.watchId, + status: entry.status, + startedAt: entry.startedAt, + stoppedAt: entry.stoppedAt, + stopReason: entry.stopReason, + includeStateDb: entry.includeStateDb, + once: entry.once + }; +} + +export async function startWatch(options = {}) { + const scopeKey = await physicalWatchScope(options); + const active = activeWatchByScope.get(scopeKey); + if (active && active.status !== "stopped") return watchSnapshot(active); + const pending = pendingWatchStartByScope.get(scopeKey); + if (pending) return pending; + const start = (async () => { + const current = activeWatchByScope.get(scopeKey); + if (current && current.status !== "stopped") return watchSnapshot(current); + const handle = await runWatch(options); + const entry = { + watchId: randomUUID(), + status: "running", + startedAt: new Date().toISOString(), + stoppedAt: null, + stopReason: null, + includeStateDb: options.includeStateDb !== false, + once: Boolean(options.once), + scopeKey, + handle + }; + watchRegistry.set(entry.watchId, entry); + activeWatchByScope.set(scopeKey, entry); + void handle.done.then((reason) => finalizeWatch(entry, reason)); + return watchSnapshot(entry); + })(); + pendingWatchStartByScope.set(scopeKey, start); + try { + return await start; + } finally { + if (pendingWatchStartByScope.get(scopeKey) === start) { + pendingWatchStartByScope.delete(scopeKey); + } + } +} + +function finalizeWatch(entry, reason) { + if (entry.status !== "stopped") { + entry.status = "stopped"; + entry.stoppedAt = new Date().toISOString(); + entry.stopReason = typeof reason === "string" ? reason : "unknown"; + } + if (activeWatchByScope.get(entry.scopeKey) === entry) { + activeWatchByScope.delete(entry.scopeKey); + } + pruneWatchHistory(); +} + +function requireWatchEntry(input) { + if (!input || typeof input !== "object" || Array.isArray(input) + || Object.keys(input).length !== 1 || typeof input.watchId !== "string") { + throw new CoreError("INVALID_INPUT", "Expected exactly { watchId } for this Watch operation."); + } + const entry = watchRegistry.get(input.watchId); + if (!entry) { + throw new CoreError("INVALID_INPUT", "The requested Watch operation is unavailable."); + } + return entry; +} + +export async function stopWatch(input) { + const entry = requireWatchEntry(input); + if (entry.status === "running") { + entry.status = "stopping"; + await entry.handle.stop(); + finalizeWatch(entry, await entry.handle.done); + } else if (entry.status === "stopping") { + finalizeWatch(entry, await entry.handle.done); + } + return watchSnapshot(entry); +} + +export function getWatchStatus(input = null) { + if (input === null || input === undefined) { + return { + schemaVersion: 1, + watches: [...watchRegistry.values()].map(watchSnapshot) + }; + } + return watchSnapshot(requireWatchEntry(input)); +} diff --git a/src/web-core-adapter.js b/src/web-core-adapter.js new file mode 100644 index 0000000..e9c40ba --- /dev/null +++ b/src/web-core-adapter.js @@ -0,0 +1,181 @@ +import { + CORE_PROTOCOL_VERSION, + ContractValidationError, + assertCoreMethodOutput, + assertCoreRequestEnvelope, + createCoreOperationStartedEnvelope, + createCoreProgressEnvelope, + createCoreFailureEnvelope, + createCoreSuccessEnvelope, + createPublicCoreErrorDto, + isCoreErrorCode +} from "../packages/contracts/dist/index.js"; +import { createCoreFacade } from "../packages/core/src/index.js"; + +const CONFLICT_CODES = new Set([ + "PROFILE_CHANGED", + "STORAGE_CHANGED", + "PLAN_STALE", + "PLAN_EXPIRED", + "STALE_STATE", + "ROLLOUT_CHANGED", + "PENDING_TRANSACTION", + "RECOVERY_REQUIRED", + "OPERATION_BUSY" +]); + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function safeString(value) { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function httpStatusForCode(code) { + if (code === "INVALID_INPUT" || code === "PROTOCOL_VERSION_MISMATCH") return 400; + if (code === "CODEX_HOME_NOT_FOUND" || code === "STATE_DB_NOT_FOUND") return 404; + if (code === "PERMISSION_DENIED") return 403; + if (code === "SQLITE_BUSY" || code === "LOCK_UNVERIFIABLE") return 423; + if (CONFLICT_CODES.has(code)) return 409; + if (code === "OPERATION_CANCELLED") return 499; + return 500; +} + +function publicErrorFromException(error, fallbackCode = "INTERNAL_ERROR") { + const source = isRecord(error) ? error : {}; + const candidate = safeString(source.code); + const code = isCoreErrorCode(candidate) ? candidate : fallbackCode; + return createPublicCoreErrorDto(code, { + operationId: source.operationId, + details: source.details + }); +} + +function correlationFromUnknown(value) { + const source = isRecord(value) ? value : {}; + return { + requestId: safeString(source.requestId) ?? "invalid-request", + operationId: safeString(source.operationId) + }; +} + +export function createWebCoreFacade(stateStore) { + if (!stateStore || typeof stateStore.getProfile !== "function") { + throw new TypeError("A trusted Web UI state store is required."); + } + return createCoreFacade({ + async resolveProfile(selector) { + const profile = stateStore.getProfile(selector.profileId); + return { + id: profile.id, + revision: profile.revision, + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}) + }; + } + }); +} + +export async function dispatchWebCoreRequest(coreFacade, value, control = {}) { + let request; + try { + assertCoreRequestEnvelope(value); + request = value; + } catch (error) { + const correlation = correlationFromUnknown(value); + const dto = publicErrorFromException( + error, + error instanceof ContractValidationError && error.code === "PROTOCOL_VERSION_MISMATCH" + ? "PROTOCOL_VERSION_MISMATCH" + : "INVALID_INPUT" + ); + return { + statusCode: httpStatusForCode(dto.code), + envelope: { + protocolVersion: CORE_PROTOCOL_VERSION, + requestId: correlation.requestId, + ...(correlation.operationId ? { operationId: correlation.operationId } : {}), + ok: false, + error: dto + }, + activity: { method: null, ok: false, code: dto.code } + }; + } + + let registered = false; + let observedOperationId; + const operation = request.method === "applySync" + ? "sync" + : request.method === "applySwitch" + ? "switch" + : request.method === "applyRestore" + ? "restore" + : null; + const notify = (observer, event) => { + if (typeof observer !== "function") return; + try { observer(event); } catch {} + }; + try { + if (typeof control.onRequestValidated === "function") { + control.onRequestValidated(request); + registered = true; + } + const handler = coreFacade[request.method]; + if (typeof handler !== "function") { + throw Object.assign(new Error("Unknown Core method."), { code: "INVALID_INPUT" }); + } + const result = await handler.call(coreFacade, request.payload, { + ...(control.signal ? { signal: control.signal } : {}), + ...(operation ? { + onOperationStarted(event) { + const operationId = safeString(event?.operationId); + if (!operationId) return; + observedOperationId = operationId; + notify( + control.onOperationStarted, + createCoreOperationStartedEnvelope(request.requestId, operationId, operation) + ); + }, + onProgress(event) { + if (!observedOperationId) return; + notify( + control.onProgress, + createCoreProgressEnvelope(request.requestId, observedOperationId, event) + ); + } + } : {}) + }); + try { + assertCoreMethodOutput(request.method, result); + } catch { + throw Object.assign(new Error("Core returned an invalid public result."), { + code: "INTERNAL_ERROR" + }); + } + const operationId = isRecord(result) ? safeString(result.operationId) : undefined; + return { + statusCode: 200, + envelope: createCoreSuccessEnvelope(request, result, operationId), + activity: { + method: request.method, + ok: true, + operationId: operationId ?? request.operationId ?? null + } + }; + } catch (error) { + const dto = publicErrorFromException(error); + return { + statusCode: httpStatusForCode(dto.code), + envelope: createCoreFailureEnvelope(request, dto), + activity: { + method: request.method, + ok: false, + code: dto.code, + operationId: dto.operationId ?? request.operationId ?? null + } + }; + } finally { + if (registered) notify(control.onRequestSettled, request); + } +} diff --git a/src/web-server.js b/src/web-server.js index 9bf5d55..b6880e3 100644 --- a/src/web-server.js +++ b/src/web-server.js @@ -5,14 +5,23 @@ import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { listBackups } from "./backup.js"; -import { readConfigText, readRootModelFromConfigText } from "./config-file.js"; import { defaultCodexHome } from "./constants.js"; -import { getHistorySession, listHistory } from "./history.js"; -import { getStatus, runPruneBackups, runRestore, runSwitch, runSync } from "./service.js"; -import { detectStateDb } from "./sqlite-state.js"; -import { ensureCodexHome, resolveStorageLayout, withStateDbLocation } from "./storage-layout.js"; +import { + applyRestore, + applySwitch, + applySync, + CoreError, + prepareRestore, + prepareSwitch, + prepareSync, + readConfigText, + readRootModelFromConfigText, + resolveStorageLayout, + runPruneBackups, + toCoreErrorDto, +} from "./public-api.js"; import { createMemoryWebUiState, ProfileRevisionConflictError, WebUiStateStore } from "./web-state.js"; +import { createWebCoreFacade, dispatchWebCoreRequest } from "./web-core-adapter.js"; const DEFAULT_PORT = 8791; const MAX_REQUEST_BYTES = 64 * 1024; @@ -27,6 +36,8 @@ const INTERNAL_RESPONSE_DOMAIN = "codex-provider-sync:web-ui:internal-pairing:v2 const DEVICE_SECRET_BYTES = 32; const STATE_FILENAME = "provider-sync-web.json"; const RUNTIME_FILENAME = "provider-sync-web.runtime.json"; +const CORE_STREAM_CONTENT_TYPE = "application/x-ndjson"; +const CORE_APPLY_METHODS = new Set(["applySync", "applySwitch", "applyRestore"]); const WEB_ROOT = fileURLToPath(new URL("../web/dist/", import.meta.url)); const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], @@ -40,6 +51,15 @@ const MIME_TYPES = new Map([ [".webp", "image/webp"] ]); +class WebRequestError extends Error { + constructor(message, statusCode, code) { + super(message); + this.name = "WebRequestError"; + this.statusCode = statusCode; + this.code = code; + } +} + function sendJson(response, statusCode, value) { const body = JSON.stringify(value); response.writeHead(statusCode, { @@ -53,26 +73,99 @@ function sendJson(response, statusCode, value) { function sendError(response, statusCode, error, code) { const message = error instanceof Error ? error.message : String(error); - sendJson(response, statusCode, { error: message, ...(code ? { code } : {}) }); + sendJson(response, statusCode, { + error: message, + ...(code ? { code } : {}), + ...(error instanceof CoreError ? { coreError: toCoreErrorDto(error) } : {}) + }); +} + +function startCoreStream(response) { + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": `${CORE_STREAM_CONTENT_TYPE}; charset=utf-8`, + "X-Content-Type-Options": "nosniff" + }); + response.flushHeaders?.(); +} + +function writeCoreStream(response, value) { + if (response.destroyed || response.writableEnded) return; + try { response.write(`${JSON.stringify(value)}\n`); } catch {} +} + +function coreErrorHttpStatus(error, fallback) { + if (!(error instanceof CoreError)) return fallback; + if (error.code === "INVALID_INPUT" + || error.code === "RESTORE_VALIDATION_FAILED" + || error.code === "SQLITE_UNSUPPORTED_PATH") return 400; + if (error.code === "PLAN_EXPIRED" || error.code === "STALE_STATE" + || error.code === "OPERATION_BUSY" || error.code === "LOCK_UNVERIFIABLE" + || error.code === "RECOVERY_REQUIRED") return 409; + return fallback; } async function readJsonBody(request) { - const chunks = []; - let received = 0; - for await (const chunk of request) { - received += chunk.length; - if (received > MAX_REQUEST_BYTES) { - throw new Error(`Request body exceeds ${MAX_REQUEST_BYTES} bytes.`); - } - chunks.push(chunk); - } + const chunks = await new Promise((resolve, reject) => { + const receivedChunks = []; + let received = 0; + let tooLarge = false; + let settled = false; + const cleanup = ({ keepErrorListener = false } = {}) => { + request.removeListener("data", onData); + request.removeListener("end", onEnd); + if (!keepErrorListener) request.removeListener("error", onError); + request.removeListener("aborted", onAborted); + }; + const finish = (callback, value, cleanupOptions) => { + if (settled) return; + settled = true; + cleanup(cleanupOptions); + callback(value); + }; + const onData = (chunk) => { + if (tooLarge) return; + received += chunk.length; + if (received > MAX_REQUEST_BYTES) { + tooLarge = true; + receivedChunks.length = 0; + return; + } + receivedChunks.push(chunk); + }; + const onEnd = () => { + if (tooLarge) { + finish(reject, new WebRequestError( + `Request body exceeds ${MAX_REQUEST_BYTES} bytes.`, + 413, + "REQUEST_TOO_LARGE" + )); + return; + } + finish(resolve, receivedChunks); + }; + const onError = (error) => finish(reject, error); + const onAborted = () => finish( + reject, + new WebRequestError("Request was aborted.", 400, "INVALID_REQUEST"), + // Some supported Node releases emit ECONNRESET after `aborted`. + // Leave the once-only error listener in place to consume that terminal + // stream event; its settle guard preserves the original error. + { keepErrorListener: true } + ); + request.on("data", onData); + request.once("end", onEnd); + request.once("error", onError); + request.once("aborted", onAborted); + if (request.aborted) onAborted(); + }); if (chunks.length === 0) { return {}; } try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { - throw new Error("Request body must be valid JSON."); + throw new WebRequestError("Request body must be valid JSON.", 400, "INVALID_JSON"); } } @@ -123,6 +216,23 @@ function resolveStorageProfile(input, stateStore) { }; } +function legacyCoreReadInput(input, allowedKeys) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new CoreError("INVALID_INPUT", "The legacy Core read input is invalid."); + } + const allowed = new Set(["profileId", ...allowedKeys]); + if (Object.keys(input).some((key) => !allowed.has(key))) { + throw new CoreError("INVALID_INPUT", "The legacy Core read input is invalid."); + } + const profileId = requireString(input.profileId ?? "default", "profileId", { maxLength: 80 }); + return { + profile: { profileId }, + ...Object.fromEntries(allowedKeys + .filter((key) => input[key] !== undefined) + .map((key) => [key, input[key]])) + }; +} + function captureProfileRevision(profileId, suppliedRevision, stateStore, response) { const profile = stateStore.getProfile(profileId); if (typeof suppliedRevision !== "string" || !suppliedRevision) { @@ -159,60 +269,6 @@ function captureStorageProfile(input, stateStore, response) { return Object.freeze(snapshot); } -function comparableStoragePath(value, platform) { - if (typeof value !== "string") return null; - return platform === "win32" ? value.toLowerCase() : value; -} - -function storageRevision(profile, storage, configText, platform) { - const canonical = JSON.stringify({ - version: 2, - profileId: profile.profileId ?? profile.id, - profileRevision: profile.profileRevision ?? profile.revision, - configRevision: crypto.createHash("sha256").update(configText, "utf8").digest("base64url"), - codexHome: comparableStoragePath(storage.codexHome, platform), - sqliteHome: comparableStoragePath(storage.sqliteHome, platform), - sqliteHomeSource: storage.sqliteHomeSource, - sqliteAccess: { - supported: storage.sqliteAccess?.supported !== false, - reason: storage.sqliteAccess?.reason ?? null - }, - allowLegacyRootFallback: Boolean(storage.allowLegacyRootFallback), - stateDbLocation: storage.stateDbLocation - ? { - path: comparableStoragePath(storage.stateDbLocation.path, platform), - source: storage.stateDbLocation.source - } - : null - }); - return crypto.createHash("sha256").update(canonical, "utf8").digest("base64url"); -} - -function serializeStatus(status) { - const rollout = status.rolloutCounts ?? { sessions: {}, archived_sessions: {} }; - const sqlite = status.sqliteCounts; - const targetProvider = status.currentProvider; - const matchesTargetProvider = (distribution) => ["sessions", "archived_sessions"].every((scope) => ( - Object.entries(distribution?.[scope] ?? {}).every(([provider, count]) => count === 0 || provider === targetProvider) - )); - const sqliteReadable = Boolean(sqlite && !sqlite.unreadable); - const rolloutScanComplete = !status.lockedRolloutFiles?.length; - return { - ...status, - alignment: { - aligned: Boolean( - targetProvider - && sqliteReadable - && rolloutScanComplete - && matchesTargetProvider(rollout) - && matchesTargetProvider(sqlite) - ), - sqliteReadable, - targetProvider - } - }; -} - function stageMessage(event) { const messages = { scan_rollout_files: "Scanning rollout files", @@ -292,9 +348,14 @@ async function serveStatic(response, pathname, webRoot) { } const extension = path.extname(filePath).toLowerCase(); + let cspNonce = null; if (extension === ".html") { - file = Buffer.from(file.toString("utf8").replace("__CODEX_PROVIDER_SYNC_BOOTSTRAP__", "{}"), "utf8"); + cspNonce = crypto.randomBytes(18).toString("base64url"); + file = Buffer.from(file.toString("utf8") + .replace("__CODEX_PROVIDER_SYNC_BOOTSTRAP__", "{}") + .replaceAll("__CPS_CSP_NONCE__", cspNonce), "utf8"); } + const nonceSource = cspNonce ? ` 'nonce-${cspNonce}'` : ""; response.writeHead(200, { "Cache-Control": extension === ".html" ? "no-store" : "public, max-age=3600", "Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream", @@ -302,7 +363,7 @@ async function serveStatic(response, pathname, webRoot) { "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", - "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + "Content-Security-Policy": `default-src 'self'; script-src 'self'${nonceSource}; style-src 'self'${nonceSource}; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'` }); response.end(file); } @@ -416,23 +477,42 @@ export function createWebUiServer({ environment = process.env } = {}) { const api = { - getStatus: services.getStatus ?? getStatus, - listBackups: services.listBackups ?? listBackups, - runSync: services.runSync ?? runSync, - runSwitch: services.runSwitch ?? runSwitch, - runRestore: services.runRestore ?? runRestore, + applyRestore: services.applyRestore ?? applyRestore, + applySwitch: services.applySwitch ?? applySwitch, + applySync: services.applySync ?? applySync, + prepareRestore: services.prepareRestore ?? prepareRestore, + prepareSwitch: services.prepareSwitch ?? prepareSwitch, + prepareSync: services.prepareSync ?? prepareSync, runPruneBackups: services.runPruneBackups ?? runPruneBackups, readConfigText: services.readConfigText ?? readConfigText, readRootModelFromConfigText: services.readRootModelFromConfigText ?? readRootModelFromConfigText, - listHistory: services.listHistory ?? listHistory, - getHistorySession: services.getHistorySession ?? getHistorySession }; + const coreFacade = services.coreFacade ?? createWebCoreFacade(stateStore); const activity = []; let activityId = 0; let activeOperation = null; let baseUrl = null; let pairing = null; const internalChallenges = new Map(); + const activeCoreOperations = new Map(); + + const callLegacyCoreRead = async (method, payload, response) => { + const dispatched = await dispatchWebCoreRequest(coreFacade, { + protocolVersion: 1, + requestId: crypto.randomUUID(), + method, + payload + }); + if (!dispatched.envelope.ok) { + sendJson(response, dispatched.statusCode, { + error: dispatched.envelope.error.message, + code: dispatched.envelope.error.code, + coreError: dispatched.envelope.error + }); + return null; + } + return dispatched.envelope.result; + }; const record = (level, message, detail = null, operation = activeOperation?.kind ?? null) => { activityId += 1; @@ -476,79 +556,55 @@ export function createWebUiServer({ record("info", `${kind} started`); try { const result = await operation(); - const outcome = Array.isArray(result?.skippedLockedRolloutFiles) && result.skippedLockedRolloutFiles.length > 0 - ? "partial" - : "success"; + const outcome = typeof result?.outcome === "string" + ? result.outcome + : (Array.isArray(result?.skippedLockedRolloutFiles) && result.skippedLockedRolloutFiles.length > 0 + ? "partial" + : "success"); record(outcome === "partial" ? "warning" : "success", `${kind} completed`); sendJson(response, 200, { result: { ...result, outcome } }); } catch (error) { - record("error", `${kind} failed`, error instanceof Error ? error.message : String(error)); - sendError(response, 400, error); + record("error", `${kind} failed`, typeof error?.code === "string" ? error.code : "INTERNAL_ERROR"); + sendError(response, coreErrorHttpStatus(error, 400), error); } finally { activeOperation = null; } }; - const resolveOperationStorage = async (profile) => { - let configText = ""; - try { - configText = await api.readConfigText(path.join(profile.codexHome, "config.toml")); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - const layout = resolveStorageLayout({ - codexHome: profile.codexHome, - sqliteHome: profile.sqliteHome, - configText, - env: environment, - platform - }); - await ensureCodexHome(layout); - const storage = layout.sqliteAccess.supported === false - ? withStateDbLocation(layout, null) - : withStateDbLocation(layout, await detectStateDb(layout)); - return { configText, storage }; - }; - - const captureOperationStorage = async (input, response) => { + const capturePrepareProfile = (input, response) => { if (Object.hasOwn(input ?? {}, "codexHome") || Object.hasOwn(input ?? {}, "sqliteHome")) { throw new Error("Storage paths must be selected through a server-managed profileId."); } const profileId = requireString(input?.profileId ?? "default", "profileId", { maxLength: 80 }); const profile = captureProfileRevision(profileId, input?.profileRevision, stateStore, response); if (!profile) return null; - if (typeof input?.storageRevision !== "string" || !input.storageRevision) { - sendJson(response, 409, { - error: "This operation requires the confirmed SQLite storage revision. Refresh and confirm again.", - code: "STORAGE_REVISION_REQUIRED", - profile - }); - return null; - } - const prepared = await resolveOperationStorage(profile); - if (input.storageRevision !== storageRevision(profile, prepared.storage, prepared.configText, platform)) { - sendJson(response, 409, { - error: "The configuration or effective SQLite storage changed after this operation was prepared. Refresh and confirm again.", - code: "STORAGE_CHANGED", - profile - }); - return null; - } + return Object.freeze({ + id: profile.id, + revision: profile.revision, + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}) + }); + }; + + const resolveCurrentProfile = async (profileId) => { + const profile = stateStore.getProfile(profileId); return { - profile: Object.freeze({ - profileId: profile.id, - codexHome: profile.codexHome, - ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}) - }), - ...prepared + id: profile.id, + revision: profile.revision, + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}) }; }; - const assertWebOperationStorage = (storage, operation) => { - if (storage.sqliteAccess.supported === false) { - throw new Error(`Cannot ${operation}: ${storage.sqliteAccess.message}`); + const requirePlanApply = (input) => { + if (!input || typeof input !== "object" || Array.isArray(input) + || Object.keys(input).sort().join(",") !== "planId,schemaVersion" + || input.schemaVersion !== 1 + || typeof input.planId !== "string" + || !input.planId) { + throw new CoreError("INVALID_INPUT", "Apply accepts exactly { schemaVersion: 1, planId }."); } - return storage; + return { schemaVersion: 1, planId: input.planId }; }; const server = http.createServer(async (request, response) => { @@ -665,6 +721,111 @@ export function createWebUiServer({ } const body = await readJsonBody(request); + if (pathname === "/api/core/cancel") { + const allowedKeys = body?.operationId === undefined + ? ["protocolVersion", "requestId"] + : ["operationId", "protocolVersion", "requestId"]; + const valid = body + && typeof body === "object" + && !Array.isArray(body) + && Object.keys(body).sort().join(",") === allowedKeys.sort().join(",") + && body.protocolVersion === 1 + && typeof body.requestId === "string" + && body.requestId.length > 0 + && body.requestId.length <= 512 + && (body.operationId === undefined + || (typeof body.operationId === "string" + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.operationId))); + if (!valid) { + sendJson(response, 400, { accepted: false }); + return; + } + const active = activeCoreOperations.get(body.requestId); + const accepted = Boolean( + active + && (!body.operationId || body.operationId === active.operationId) + ); + if (accepted) active.controller.abort(); + sendJson(response, 200, { accepted }); + return; + } + if (pathname === "/api/core") { + const contentType = String(request.headers["content-type"] ?? "").toLowerCase(); + if (!contentType.startsWith("application/json")) { + const dispatched = await dispatchWebCoreRequest(coreFacade, { + protocolVersion: body?.protocolVersion, + requestId: body?.requestId, + operationId: body?.operationId, + method: body?.method, + payload: { invalidContentType: true } + }); + record("warning", "Core request rejected", dispatched.activity); + sendJson(response, 415, dispatched.envelope); + return; + } + const wantsStream = String(request.headers.accept ?? "") + .toLowerCase() + .split(",") + .some((entry) => entry.trim().startsWith(CORE_STREAM_CONTENT_TYPE)); + if (wantsStream) { + const controller = new AbortController(); + let completed = false; + let registeredRequestId = null; + startCoreStream(response); + response.once("close", () => { + if (!completed) controller.abort(); + }); + const dispatched = await dispatchWebCoreRequest(coreFacade, body, { + signal: controller.signal, + onRequestValidated(validatedRequest) { + if (!CORE_APPLY_METHODS.has(validatedRequest.method)) return; + if (activeCoreOperations.has(validatedRequest.requestId)) { + throw Object.assign(new Error("A Core request with this requestId is already active."), { + code: "OPERATION_BUSY", + details: { busyScope: "web-request" } + }); + } + registeredRequestId = validatedRequest.requestId; + activeCoreOperations.set(registeredRequestId, { + controller, + operationId: null + }); + }, + onOperationStarted(event) { + if (registeredRequestId) { + const active = activeCoreOperations.get(registeredRequestId); + if (active?.controller === controller) active.operationId = event.operationId; + } + writeCoreStream(response, event); + }, + onProgress(event) { + writeCoreStream(response, event); + }, + onRequestSettled() { + if (!registeredRequestId) return; + const active = activeCoreOperations.get(registeredRequestId); + if (active?.controller === controller) activeCoreOperations.delete(registeredRequestId); + } + }); + record( + dispatched.activity.ok ? "info" : "warning", + dispatched.activity.ok ? "Core request completed" : "Core request rejected", + dispatched.activity + ); + completed = true; + writeCoreStream(response, dispatched.envelope); + response.end(); + return; + } + const dispatched = await dispatchWebCoreRequest(coreFacade, body); + record( + dispatched.activity.ok ? "info" : "warning", + dispatched.activity.ok ? "Core request completed" : "Core request rejected", + dispatched.activity + ); + sendJson(response, dispatched.statusCode, dispatched.envelope); + return; + } if (pathname === "/api/profiles/save") { const profileId = requireString(body.profileId, "profileId", { maxLength: 80 }); try { @@ -701,117 +862,134 @@ export function createWebUiServer({ } if (pathname === "/api/status") { - const profile = resolveStorageProfile(body, stateStore); - const prepared = await resolveOperationStorage(profile); - const status = serializeStatus(await api.getStatus({ - ...profile, - storage: prepared.storage, - configText: prepared.configText - })); - status.pathComparisonCaseInsensitive = platform === "win32"; - status.profileId = profile.profileId; - status.profileRevision = profile.profileRevision; - status.storageRevision = storageRevision(profile, prepared.storage, prepared.configText, platform); - record("info", "Status refreshed", status.codexHome, null); + const input = legacyCoreReadInput(body, []); + const status = await callLegacyCoreRead("getStatus", input, response); + if (!status) return; + record("info", "Status refreshed", { profileId: input.profile.profileId }, null); sendJson(response, 200, { status }); return; } if (pathname === "/api/backups") { - const { codexHome } = resolveStorageProfile(body, stateStore); - sendJson(response, 200, await api.listBackups(codexHome)); + const input = legacyCoreReadInput(body, []); + const result = await callLegacyCoreRead("listBackups", input, response); + if (!result) return; + sendJson(response, 200, result); return; } if (pathname === "/api/history") { - const storage = resolveStorageProfile(body, stateStore); - const history = await api.listHistory(storage.codexHome, body); + const input = legacyCoreReadInput(body, ["page", "pageSize", "query", "project", "provider", "archived"]); + const history = await callLegacyCoreRead("listHistory", input, response); + if (!history) return; sendJson(response, 200, { history }); return; } if (pathname === "/api/history/session") { - const storage = resolveStorageProfile(body, stateStore); - const sessionId = requireString(body.sessionId, "sessionId", { maxLength: 300 }); - const history = await api.getHistorySession(storage.codexHome, sessionId); + const input = legacyCoreReadInput(body, ["sessionId", "messageLimit"]); + const history = await callLegacyCoreRead("getHistorySession", input, response); + if (!history) return; sendJson(response, 200, { history }); return; } - if (pathname === "/api/sync") { - const operationStorage = await captureOperationStorage(body, response); - if (!operationStorage) return; - await withOperation("sync", response, async () => { - assertWebOperationStorage(operationStorage.storage, "sync"); - const provider = requireProvider(body.provider); - const keepCount = requireKeepCount(body.keepCount); - const model = api.readRootModelFromConfigText(operationStorage.configText); - return api.runSync({ - ...operationStorage.profile, - storage: operationStorage.storage, - expectedConfigText: operationStorage.configText, - provider, - keepCount, - model, - onProgress: (event) => record("progress", stageMessage(event), event) - }); + if (pathname === "/api/sync/prepare") { + const profile = capturePrepareProfile(body, response); + if (!profile) return; + const configText = await api.readConfigText(path.join(profile.codexHome, "config.toml")); + const plan = await api.prepareSync({ + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}), + profile: { id: profile.id, revision: profile.revision }, + profileResolver: resolveCurrentProfile, + provider: requireProvider(body.provider), + model: api.readRootModelFromConfigText(configText), + keepCount: requireKeepCount(body.keepCount), + platform }); + sendJson(response, 200, { plan }); return; } - if (pathname === "/api/switch") { - const operationStorage = await captureOperationStorage(body, response); - if (!operationStorage) return; - await withOperation("switch", response, async () => { - assertWebOperationStorage(operationStorage.storage, "switch"); - const provider = requireProvider(body.provider); - const keepCount = requireKeepCount(body.keepCount); - const model = requireString(body.model, "model", { optional: true, maxLength: 500 }); - return api.runSwitch({ - ...operationStorage.profile, - storage: operationStorage.storage, - expectedConfigText: operationStorage.configText, - provider, - keepCount, - model, - keepRootModel: Boolean(body.keepRootModel), - onProgress: (event) => record("progress", stageMessage(event), event) - }); + if (pathname === "/api/sync/apply") { + await withOperation("sync", response, () => api.applySync(requirePlanApply(body))); + return; + } + + if (pathname === "/api/switch/prepare") { + const profile = capturePrepareProfile(body, response); + if (!profile) return; + const modelMode = body.modelMode ?? (body.keepRootModel ? "keep-root-model" : (body.model ? "explicit" : "provider-default")); + if (!["provider-default", "keep-root-model", "explicit"].includes(modelMode)) { + throw new CoreError("INVALID_INPUT", "modelMode must be provider-default, keep-root-model, or explicit."); + } + const model = modelMode === "explicit" + ? requireString(body.model, "model", { maxLength: 500 }) + : undefined; + if (modelMode !== "explicit" && body.model !== undefined && body.model !== null && body.model !== "") { + throw new CoreError("INVALID_INPUT", "model is only accepted when modelMode is explicit."); + } + const plan = await api.prepareSwitch({ + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}), + profile: { id: profile.id, revision: profile.revision }, + profileResolver: resolveCurrentProfile, + provider: requireProvider(body.provider), + model, + keepRootModel: modelMode === "keep-root-model", + keepCount: requireKeepCount(body.keepCount), + platform }); + sendJson(response, 200, { plan }); return; } - if (pathname === "/api/restore") { - const operationStorage = await captureOperationStorage(body, response); - if (!operationStorage) return; - await withOperation("restore", response, async () => { - assertWebOperationStorage(operationStorage.storage, "restore"); - const backupId = requireString(body.backupId, "backupId", { maxLength: 300 }); - const listed = await api.listBackups(operationStorage.profile.codexHome); - const backup = listed.backups.find((entry) => entry.id === backupId); - if (!backup) { - throw new Error("The selected backup is not a managed backup for this Codex Home."); - } - const restoreConfig = Boolean(body.restoreConfig); - const restoreDatabase = Boolean(body.restoreDatabase); - const restoreSessions = Boolean(body.restoreSessions); - if (!restoreConfig && !restoreDatabase && !restoreSessions) { - throw new Error("Select at least one backup content type to restore."); - } - if (body.allowSqliteHomeRelocation && !operationStorage.profile.sqliteHome) { - throw new Error("SQLite Home relocation requires a storage profile with an explicit SQLite Home target."); - } - return api.runRestore({ - ...operationStorage.profile, - storage: operationStorage.storage, - expectedConfigText: operationStorage.configText, - backupDir: backup.path, - restoreConfig, - restoreDatabase, - restoreSessions, - allowSqliteHomeRelocation: Boolean(body.allowSqliteHomeRelocation) - }); + if (pathname === "/api/switch/apply") { + await withOperation("switch", response, () => api.applySwitch(requirePlanApply(body))); + return; + } + + if (pathname === "/api/restore/prepare") { + const profile = capturePrepareProfile(body, response); + if (!profile) return; + const restoreConfig = Boolean(body.restoreConfig); + const restoreDatabase = Boolean(body.restoreDatabase); + const restoreSessions = Boolean(body.restoreSessions); + if (!restoreConfig && !restoreDatabase && !restoreSessions) { + throw new CoreError("INVALID_INPUT", "Select at least one backup content type to restore."); + } + if (body.allowSqliteHomeRelocation && !profile.sqliteHome) { + throw new CoreError("INVALID_INPUT", "SQLite Home relocation requires a storage profile with an explicit SQLite Home target."); + } + const plan = await api.prepareRestore({ + codexHome: profile.codexHome, + ...(profile.sqliteHome ? { sqliteHome: profile.sqliteHome } : {}), + profile: { id: profile.id, revision: profile.revision }, + profileResolver: resolveCurrentProfile, + backupId: requireString(body.backupId, "backupId", { maxLength: 300 }), + restoreConfig, + restoreDatabase, + restoreSessions, + allowSqliteHomeRelocation: Boolean(body.allowSqliteHomeRelocation), + platform }); + sendJson(response, 200, { plan }); + return; + } + + if (pathname === "/api/restore/apply") { + await withOperation("restore", response, () => api.applyRestore(requirePlanApply(body))); + return; + } + + if (pathname === "/api/sync" || pathname === "/api/switch" || pathname === "/api/restore") { + sendError( + response, + 410, + `Direct write endpoint ${pathname} is retired. Use ${pathname}/prepare, show the returned plan, then submit only { schemaVersion, planId } to ${pathname}/apply.`, + "PLAN_REQUIRED" + ); return; } @@ -834,7 +1012,12 @@ export function createWebUiServer({ } await serveStatic(response, pathname, webRoot); } catch (error) { - sendError(response, 500, error); + if (request.aborted || response.destroyed || response.writableEnded) return; + if (error instanceof WebRequestError) { + sendError(response, error.statusCode, error, error.code); + } else { + sendError(response, coreErrorHttpStatus(error, 500), error); + } } }); diff --git a/test-support/HistoricalBackupProducer/HistoricalBackupProducer.csproj b/test-support/HistoricalBackupProducer/HistoricalBackupProducer.csproj new file mode 100644 index 0000000..238f157 --- /dev/null +++ b/test-support/HistoricalBackupProducer/HistoricalBackupProducer.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0 + enable + enable + + diff --git a/test-support/HistoricalBackupProducer/Program.cs b/test-support/HistoricalBackupProducer/Program.cs new file mode 100644 index 0000000..dc3b6da --- /dev/null +++ b/test-support/HistoricalBackupProducer/Program.cs @@ -0,0 +1,88 @@ +using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; + +if (args.Length != 2) +{ + Console.Error.WriteLine("usage: HistoricalBackupProducer "); + return 2; +} + +string assemblyPath = Path.GetFullPath(args[0]); +string codexHome = Path.GetFullPath(args[1]); +if (!File.Exists(assemblyPath) + || !string.Equals(Path.GetExtension(assemblyPath), ".dll", StringComparison.OrdinalIgnoreCase)) +{ + throw new InvalidOperationException("The historical Core assembly must be an existing DLL."); +} +if (!Directory.Exists(codexHome)) +{ + throw new InvalidOperationException("The synthetic Codex Home does not exist."); +} +string assemblyDirectory = Path.GetDirectoryName(assemblyPath) + ?? throw new InvalidOperationException("The Core assembly has no parent directory."); + +AssemblyLoadContext.Default.Resolving += (_, name) => +{ + string candidate = Path.Combine(assemblyDirectory, $"{name.Name}.dll"); + return File.Exists(candidate) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(candidate) : null; +}; + +Assembly core = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); +Type serviceType = core.GetType("CodexProviderSync.Core.CodexSyncService", throwOnError: true)!; +object service = Activator.CreateInstance(serviceType) + ?? throw new InvalidOperationException("Could not construct the historical CodexSyncService."); +MethodInfo method = serviceType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(candidate => string.Equals(candidate.Name, "RunSyncAsync", StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.GetParameters().Length) + .First(); + +HashSet supportedParameters = new(StringComparer.Ordinal) +{ + "explicitCodexHome", + "provider", + "configBackupText", + "keepCount", + "sqliteBusyTimeoutMs", + "model", + "explicitSqliteHome", + "cancellationToken" +}; +string[] unknownParameters = method.GetParameters() + .Select(parameter => parameter.Name ?? string.Empty) + .Where(name => !supportedParameters.Contains(name)) + .ToArray(); +if (unknownParameters.Length > 0) +{ + throw new InvalidOperationException( + $"Unsupported historical RunSyncAsync parameters: {string.Join(", ", unknownParameters)}"); +} + +object?[] values = method.GetParameters().Select(parameter => parameter.Name switch +{ + "explicitCodexHome" => codexHome, + "provider" => "openai", + "configBackupText" => null, + "keepCount" => 5, + "sqliteBusyTimeoutMs" => null, + "model" => null, + "explicitSqliteHome" => null, + "cancellationToken" => CancellationToken.None, + _ => throw new InvalidOperationException($"Unsupported historical parameter: {parameter.Name}") +}).ToArray(); + +Task task = (Task)(method.Invoke(service, values) + ?? throw new InvalidOperationException("Historical RunSyncAsync returned null.")); +await task.ConfigureAwait(false); +object result = task.GetType().GetProperty("Result")?.GetValue(task) + ?? throw new InvalidOperationException("Historical RunSyncAsync produced no result."); +string backupDir = (string?)(result.GetType().GetProperty("BackupDir")?.GetValue(result)) + ?? throw new InvalidOperationException("Historical SyncResult has no BackupDir."); + +Console.WriteLine(JsonSerializer.Serialize(new +{ + schemaVersion = 1, + backupDir, + coreAssemblyVersion = core.GetName().Version?.ToString() +})); +return 0; diff --git a/test-support/cli-json-driver.js b/test-support/cli-json-driver.js new file mode 100644 index 0000000..a9455a9 --- /dev/null +++ b/test-support/cli-json-driver.js @@ -0,0 +1,95 @@ +import { CoreError, toCoreErrorDto } from "../src/public-api.js"; +import { runCli } from "../src/cli.js"; + +const scenario = process.env.CODEX_PROVIDER_SYNC_CLI_SCENARIO ?? "completed"; + +function errorForScenario() { + if (scenario === "error-secret-details") { + return new CoreError("OPERATION_BUSY", "The operation is busy.", { + details: { + busyScope: "codex-home", + authToken: "fixture-secret-token", + messageBody: "fixture secret body" + } + }); + } + const code = scenario.startsWith("error:") ? scenario.slice("error:".length) : null; + if (!code) return null; + const details = code === "OPERATION_BUSY" + ? { busyScope: "codex-home" } + : code === "LOCK_UNVERIFIABLE" + ? { lockScope: "state-db" } + : undefined; + return new CoreError(code, `${code} fixture`, { details }); +} + +function completedSyncResult() { + return { + targetProvider: "openai", + codexHome: "C:\\fixture\\.codex", + sqliteHome: "C:\\fixture\\.codex\\sqlite", + sqliteHomeSource: "default", + backupDir: "C:\\fixture\\.codex\\backups_state\\provider-sync\\fixture", + backupDurationMs: 25, + changedSessionFiles: 1, + sqliteRowsUpdated: 1, + sqlitePresent: true, + skippedLockedRolloutFiles: scenario === "partial" ? ["locked-rollout.jsonl"] : [], + autoPruneWarning: scenario === "warning" ? "cleanup warning" : null + }; +} + +const core = { + toCoreErrorDto, + readConfigText: async () => 'model_provider = "openai"\n', + readRootModelFromConfigText: () => null, + getStatus: async () => ({ + schemaVersion: 1, + currentProvider: "openai", + codexHome: "C:\\fixture\\.codex" + }), + runSync: async ({ onProgress }) => { + onProgress?.({ stage: "scan_rollout_files", status: "start" }); + onProgress?.({ + stage: "create_backup", + status: "complete", + durationMs: 25, + backupDir: "C:\\fixture\\secret-backup-path" + }); + const error = errorForScenario(); + if (error) throw error; + if (scenario === "cyclic-result") { + const result = {}; + result.self = result; + return result; + } + return completedSyncResult(); + }, + runSwitch: async () => ({ + ...completedSyncResult(), + modelSync: { applied: true, source: "explicit", model: "fixture-model", warning: null } + }), + runPruneBackups: async () => ({ + backupRoot: "C:\\fixture\\backups", + deletedCount: 0, + remainingCount: 1, + freedBytes: 0 + }), + runRestore: async () => ({ + codexHome: "C:\\fixture\\.codex", + targetProvider: "openai", + backupInventoryWarning: scenario === "warning" ? "inventory warning" : null + }) +}; + +const exitCode = await runCli(process.argv.slice(2), { + loadCoreImpl: async () => core, + installWindowsLauncherImpl: async () => ({ + vbsPath: "C:\\fixture\\Codex Provider Sync.vbs", + cmdPath: "C:\\fixture\\Codex Provider Sync.cmd", + targetDir: "C:\\fixture", + codexHome: null, + sqliteHome: null + }) +}); +process.exitCode = exitCode; diff --git a/test-support/cross-runtime-fixtures.mjs b/test-support/cross-runtime-fixtures.mjs new file mode 100644 index 0000000..73ac86b --- /dev/null +++ b/test-support/cross-runtime-fixtures.mjs @@ -0,0 +1,1466 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { createRuntimeDifference, runFixtureInTemp } from "../packages/test-fixtures/src/index.js"; +import { runRestore, runSync } from "../src/public-api.js"; +import { + captureRestoreSourceIdentity, + RESTORE_SNAPSHOT_MANIFEST_BASENAME +} from "../src/restore-v2.js"; +import { + readRestoreJournal, + RESTORE_JOURNAL_BASENAME +} from "../src/restore-journal.js"; +import { openDatabase } from "../src/sqlite.js"; +import { + findPendingTransactions, + readTransactionJournal, + TRANSACTION_JOURNAL_BASENAME +} from "../src/transaction-journal.js"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const staticRoot = path.join(repositoryRoot, "packages", "test-fixtures", "static"); +const fixtureHostDll = process.env.CPS_DOTNET_FIXTURE_HOST + ?? path.join(repositoryRoot, "desktop", "CodexProviderSync.Core.Tests", "FixtureHost", "bin", "Release", "net10.0", "CodexProviderSync.FixtureHost.dll"); +const crashHostDll = process.env.CPS_DOTNET_CRASH_HOST + ?? path.join(repositoryRoot, "desktop", "CodexProviderSync.Core.Tests", "CrashHost", "bin", "Release", "net10.0", "CodexProviderSync.CrashHost.dll"); +const nodeCrashHost = path.join(repositoryRoot, "test-support", "cross-runtime-node-crash-host.mjs"); +const nodeWriterHost = path.join(repositoryRoot, "test-support", "cross-runtime-writer-host.mjs"); +const nodeRestoreCrashHost = path.join(repositoryRoot, "test-support", "restore-v2-crash-host.mjs"); +const ordinalCompare = (left, right) => left < right ? -1 : left > right ? 1 : 0; +const physicalPathKey = (value) => process.platform === "win32" + ? path.resolve(value).toLowerCase() + : path.resolve(value); + +function windowsShortDirectoryPath(directory) { + assert.equal(process.platform, "win32"); + const executable = path.join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe" + ); + const command = [ + "$fso = New-Object -ComObject Scripting.FileSystemObject", + "$folder = $fso.GetFolder($env:CPS_SHORT_PATH_TARGET)", + "$folder.ShortPath" + ].join("; "); + const result = spawnSync(executable, [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command + ], { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + env: { ...process.env, CPS_SHORT_PATH_TARGET: directory } + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); + const shortPath = result.stdout.trim(); + assert.equal(path.isAbsolute(shortPath), true, "PowerShell must return an absolute 8.3 alias"); + assert.notEqual( + physicalPathKey(shortPath), + physicalPathKey(directory), + "The Windows volume must expose an actual short-path alias for this fixture" + ); + return shortPath; +} + +function digest(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function canonicalSqliteValue(value) { + if (Buffer.isBuffer(value)) return ["blob", value.toString("base64")]; + if (typeof value === "bigint") return ["integer", value.toString()]; + return value; +} + +async function rolloutFiles(root) { + const result = []; + async function visit(current) { + let entries = []; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) await visit(fullPath); + else if (entry.isFile() && entry.name.endsWith(".jsonl")) result.push(fullPath); + } + } + await visit(root); + return result; +} + +async function canonicalState( + codexHome, + databasePath = path.join(codexHome, "sqlite", "state_5.sqlite") +) { + const files = []; + const configPath = path.join(codexHome, "config.toml"); + files.push(["config.toml", digest(await fs.readFile(configPath))]); + for (const fileName of [".codex-global-state.json", ".codex-global-state.json.bak"]) { + const filePath = path.join(codexHome, fileName); + try { + files.push([fileName, digest(await fs.readFile(filePath))]); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + files.push([fileName, null]); + } + } + for (const scope of ["sessions", "archived_sessions"]) { + for (const filePath of await rolloutFiles(path.join(codexHome, scope))) { + files.push([ + path.relative(codexHome, filePath).replaceAll("\\", "/"), + digest(await fs.readFile(filePath)) + ]); + } + } + const sidecars = []; + for (const suffix of ["-wal", "-shm"]) { + try { + sidecars.push([suffix, digest(await fs.readFile(`${databasePath}${suffix}`))]); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + sidecars.push([suffix, null]); + } + } + const database = await openDatabase(databasePath, { readOnly: true }); + try { + const integrity = database.prepare("PRAGMA integrity_check").get(); + assert.equal(integrity.integrity_check, "ok"); + const columns = database.prepare("PRAGMA table_info(threads)").all() + .sort((left, right) => Number(left.cid) - Number(right.cid)) + .map((column) => String(column.name)); + const quotedColumns = columns + .map((column) => `"${column.replaceAll('"', '""')}"`) + .join(", "); + const rows = database.prepare(`SELECT ${quotedColumns} FROM threads ORDER BY id`).all() + .map((row) => columns.map((column) => canonicalSqliteValue(row[column]))); + const schema = database.prepare(` + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE sql IS NOT NULL + ORDER BY type, name + `).all().map((row) => [row.type, row.name, row.tbl_name, row.sql]); + const canonical = { + files, + sqlite: { + schema, + columns, + rows, + userVersion: Number(database.prepare("PRAGMA user_version").get().user_version), + sidecars + } + }; + const providerIndex = columns.indexOf("model_provider"); + return { + hash: digest(JSON.stringify(canonical)), + provider: providerIndex >= 0 ? rows[0]?.[providerIndex] ?? null : null, + canonical + }; + } finally { + database.close(); + } +} + +async function managedBackupTree(codexHome) { + const root = path.join(codexHome, "backups_state", "provider-sync"); + const entries = []; + async function visit(current) { + let children = []; + try { + children = await fs.readdir(current, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + for (const child of children.sort((left, right) => ordinalCompare(left.name, right.name))) { + const fullPath = path.join(current, child.name); + const relativePath = path.relative(root, fullPath).replaceAll("\\", "/"); + if (child.isDirectory()) { + entries.push([relativePath, "directory"]); + await visit(fullPath); + } else if (child.isFile()) { + entries.push([relativePath, "file", digest(await fs.readFile(fullPath))]); + } else { + entries.push([relativePath, "unsupported"]); + } + } + } + await visit(root); + return entries; +} + +async function createCase(fixture, name) { + const caseRoot = path.join(fixture.root, "work", name); + const codexHome = path.join(caseRoot, ".codex"); + await fs.mkdir(caseRoot, { recursive: true }); + await fs.cp(fixture.codexHome, codexHome, { recursive: true, force: false, errorOnExist: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + const seed = await fs.readFile(path.join(fixture.root, "sqlite-seed.sql"), "utf8"); + const database = await openDatabase(path.join(codexHome, "sqlite", "state_5.sqlite")); + try { + database.exec(seed); + } finally { + database.close(); + } + return { codexHome, initial: await canonicalState(codexHome) }; +} + +function runProcess(command, args, { expectCrash = false } = {}) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + timeout: 60_000, + maxBuffer: 1024 * 1024, + env: { ...process.env, NO_COLOR: "1" } + }); + assert.equal( + result.error, + undefined, + [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n") + ); + if (expectCrash) { + if (command === process.execPath) { + assert.equal( + result.status, + 86, + ["The Node crash host did not reach its exact fault point.", result.stdout, result.stderr] + .filter(Boolean) + .join("\n") + ); + } else { + assert.notEqual(result.status, 0, "The crash host unexpectedly completed successfully."); + } + return result; + } + assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); + const line = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + const output = JSON.parse(line); + assert.equal(output.schemaVersion, 1); + assert.equal(output.ok, true); + return output; +} + +function runProcessFailure(command, args, expectedCode = "RECOVERY_REQUIRED") { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + timeout: 60_000, + maxBuffer: 1024 * 1024, + env: { ...process.env, NO_COLOR: "1" } + }); + assert.equal( + result.error, + undefined, + [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n") + ); + assert.notEqual(result.status, 0, "The fixture operation unexpectedly completed successfully."); + const line = result.stderr.trim().split(/\r?\n/).filter(Boolean).at(-1); + assert.ok(line, result.stdout); + const output = JSON.parse(line); + assert.equal(output.schemaVersion, 1); + assert.equal(output.ok, false); + assert.equal(output.errorCode, expectedCode); + return output; +} + +function startProcess(command, args) { + const child = spawn(command, args, { + cwd: repositoryRoot, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" } + }); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const completed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (status, signal) => resolve({ status, signal, stdout, stderr })); + }); + return { child, completed }; +} + +async function finishProcess(runner) { + const result = await runner.completed; + assert.equal( + result.status, + 0, + [ + `The gated writer exited with status ${result.status ?? "null"} and signal ${result.signal ?? "none"}.`, + result.stdout, + result.stderr + ].filter(Boolean).join("\n") + ); + const line = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + const output = JSON.parse(line); + assert.equal(output.schemaVersion, 1); + assert.equal(output.ok, true); + return output; +} + +async function waitForPath(filePath, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fs.access(filePath); + return; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + await delay(25); + } + assert.fail(`Timed out waiting for gated writer marker: ${filePath}`); +} + +async function releaseAndFinish(runner, releasePath) { + await fs.writeFile(releasePath, "release\n", { flag: "wx" }).catch((error) => { + if (error?.code !== "EEXIST") throw error; + }); + return finishProcess(runner); +} + +function runDotnet(operation, codexHome, argument) { + return runProcess("dotnet", [fixtureHostDll, operation, codexHome, argument]); +} + +function runDotnetFailure(operation, codexHome, argument, expectedCode = "RECOVERY_REQUIRED") { + return runProcessFailure( + "dotnet", + [fixtureHostDll, operation, codexHome, argument], + expectedCode + ); +} + +async function createSharedStateDbCase(fixture, name) { + const nodeHome = await createCase(fixture, `${name}-node-home`); + const dotnetHome = await createCase(fixture, `${name}-dotnet-home`); + const sharedSqliteHome = path.join(fixture.root, "work", name, "shared-sqlite"); + const sharedStateDb = path.join(sharedSqliteHome, "state_5.sqlite"); + await fs.mkdir(sharedSqliteHome, { recursive: true }); + await fs.copyFile( + path.join(nodeHome.codexHome, "sqlite", "state_5.sqlite"), + sharedStateDb + ); + return { nodeHome, dotnetHome, sharedSqliteHome, sharedStateDb }; +} + +async function writeUnknownRestoreJournal(codexHome, sourceBackupDir, suffix) { + const snapshotDir = path.join( + codexHome, + "backups_state", + "provider-sync", + `restore-v2-unknown-${suffix}` + ); + await fs.mkdir(snapshotDir, { recursive: true }); + const journalPath = path.join(snapshotDir, RESTORE_JOURNAL_BASENAME); + const event = { + schemaVersion: 99, + protocolVersion: 99, + operationKind: "restore", + operationId: `unknown-${suffix}`, + sequence: 1, + state: "prepared", + recordedAt: "2026-08-27T00:00:00.000Z", + sourceBackup: { + backupId: path.basename(sourceBackupDir), + backupDir: path.resolve(sourceBackupDir), + revision: "unknown-schema-source" + }, + preRestoreSnapshot: { + backupId: path.basename(snapshotDir), + backupDir: path.resolve(snapshotDir), + revision: "unknown-schema-snapshot", + manifestSha256: "unknown-schema-manifest" + } + }; + const raw = `${JSON.stringify(event)}\n`; + await fs.writeFile(journalPath, raw, "utf8"); + return { journalPath, raw }; +} + +async function mismatchManifestPreparedBinding(journal) { + const manifestPath = path.join(journal.snapshotDir, RESTORE_SNAPSHOT_MANIFEST_BASENAME); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + manifest.sourceBackup.revision = `${manifest.sourceBackup.revision}-mismatched`; + const manifestText = `${JSON.stringify(manifest, null, 2)}\n`; + const manifestSha256 = crypto.createHash("sha256") + .update(manifestText, "utf8") + .digest("base64url"); + await fs.writeFile(manifestPath, manifestText, "utf8"); + const lines = (await fs.readFile(journal.filePath, "utf8")).trimEnd().split(/\r?\n/); + const prepared = JSON.parse(lines[0]); + prepared.preRestoreSnapshot.manifestSha256 = manifestSha256; + lines[0] = JSON.stringify(prepared); + await fs.writeFile(journal.filePath, `${lines.join("\n")}\n`, "utf8"); +} + +async function mismatchPreparedPhysicalHome(journal, physicalHome) { + const lines = (await fs.readFile(journal.filePath, "utf8")).trimEnd().split(/\r?\n/); + const prepared = JSON.parse(lines[0]); + prepared.storage.codexHomePhysical = path.resolve(await fs.realpath(physicalHome)); + lines[0] = JSON.stringify(prepared); + await fs.writeFile(journal.filePath, `${lines.join("\n")}\n`, "utf8"); +} + +async function managedBackupDirectories(codexHome) { + const root = path.join(codexHome, "backups_state", "provider-sync"); + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(root, entry.name)) + .sort(ordinalCompare); +} + +async function restoreJournals(codexHome) { + const directories = await managedBackupDirectories(codexHome); + const journals = []; + for (const directory of directories) { + try { + journals.push(await readRestoreJournal(path.join(directory, RESTORE_JOURNAL_BASENAME))); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + return journals.sort((left, right) => ordinalCompare(left.filePath, right.filePath)); +} + +async function assertJournal(backupDir, expectedState) { + const journal = await readTransactionJournal(path.join(backupDir, TRANSACTION_JOURNAL_BASENAME)); + assert.equal(journal.invalidTail, false); + assert.equal(journal.terminal, true); + assert.equal(journal.state, expectedState); +} + +async function assertRestored(caseState, backupDir, expectedJournal) { + const restored = await canonicalState(caseState.codexHome); + assert.deepEqual(restored.canonical, caseState.initial.canonical); + assert.equal(restored.provider, "relay"); + const pending = await findPendingTransactions(caseState.codexHome); + const restoreEvidence = pending.length > 0 + ? (await restoreJournals(caseState.codexHome)).map((journal) => ({ + operationId: journal.operationId, + state: journal.state, + invalidTail: journal.invalidTail, + validationError: journal.validationError, + resolvesOperationIds: journal.prepared?.resolvesOperationIds ?? [], + sourceBackup: journal.prepared?.sourceBackup ?? null, + codexHome: journal.prepared?.storage?.codexHome ?? null, + requiredTargetKinds: journal.prepared?.requiredTargetKinds ?? [] + })) + : []; + assert.equal( + pending.length, + 0, + JSON.stringify({ + pending: pending.map((journal) => ({ + operationId: journal.operationId ?? null, + operationKind: journal.operationKind, + state: journal.state, + invalidTail: journal.invalidTail ?? false, + validationError: journal.validationError ?? null + })), + restoreEvidence + }) + ); + await assertJournal(backupDir, expectedJournal); +} + +async function assertCompletedRestoreEvidence({ + result, + codexHome, + sourceBackupDir, + expectedSourceRevision, + journals, + expectedResolvedOperationIds = [] +}) { + const operationId = result.restoreOperationId ?? result.RestoreOperationId; + const snapshotId = result.preRestoreSnapshotId ?? result.PreRestoreSnapshotId; + const journalState = result.restoreJournalState ?? result.RestoreJournalState; + assert.equal(typeof operationId, "string"); + assert.equal(typeof snapshotId, "string"); + assert.equal(journalState, "completed"); + const matching = journals.filter((journal) => journal.operationId === operationId); + assert.equal(matching.length, 1, "Restore result must bind to exactly one completed journal."); + const [journal] = matching; + assert.equal(journal.invalidTail, false); + assert.equal(journal.state, "completed"); + assert.equal(journal.prepared.preRestoreSnapshot.backupId, snapshotId); + assert.equal( + physicalPathKey(await fs.realpath(journal.prepared.sourceBackup.backupDir)), + physicalPathKey(await fs.realpath(sourceBackupDir)) + ); + assert.equal( + journal.prepared.sourceBackup.revision, + expectedSourceRevision + ); + assert.equal( + physicalPathKey(await fs.realpath(journal.prepared.storage.codexHome)), + physicalPathKey(await fs.realpath(codexHome)) + ); + assert.equal( + physicalPathKey(journal.prepared.storage.codexHomePhysical), + physicalPathKey(await fs.realpath(codexHome)) + ); + assert.equal( + physicalPathKey(await fs.realpath(journal.prepared.preRestoreSnapshot.backupDir)), + physicalPathKey(await fs.realpath(journal.snapshotDir)) + ); + await Promise.all([ + fs.access(journal.snapshotDir), + fs.access(path.join(journal.snapshotDir, RESTORE_SNAPSHOT_MANIFEST_BASENAME)), + fs.access(path.join(journal.snapshotDir, RESTORE_JOURNAL_BASENAME)) + ]); + assert.deepEqual( + journal.prepared.resolvesOperationIds, + expectedResolvedOperationIds + ); +} + +test("bidirectional-backup-roundtrip uses one synthetic corpus across Node and .NET", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-to-dotnet"); + const nodeResult = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + const nodeIdentity = await captureRestoreSourceIdentity(nodeResult.backupDir); + const dotnetIdentity = runDotnet("source-identity", nodeToDotnet.codexHome, nodeResult.backupDir); + assert.equal(dotnetIdentity.Revision, nodeIdentity); + assert.notEqual((await canonicalState(nodeToDotnet.codexHome)).hash, nodeToDotnet.initial.hash); + const dotnetRestore = runDotnet("restore-v2", nodeToDotnet.codexHome, nodeResult.backupDir); + assert.equal(dotnetRestore.RestoreVersion, 2); + assert.equal(dotnetRestore.RestoreJournalState, "completed"); + await assertRestored(nodeToDotnet, nodeResult.backupDir, fixture.manifest.expected.journalTerminal); + const nodeToDotnetJournals = await restoreJournals(nodeToDotnet.codexHome); + await assertCompletedRestoreEvidence({ + result: dotnetRestore, + codexHome: nodeToDotnet.codexHome, + sourceBackupDir: nodeResult.backupDir, + expectedSourceRevision: nodeIdentity, + journals: nodeToDotnetJournals + }); + + const dotnetToNode = await createCase(fixture, "dotnet-to-node"); + const dotnetResult = runDotnet("sync", dotnetToNode.codexHome, "openai"); + const dotnetSourceIdentity = runDotnet( + "source-identity", + dotnetToNode.codexHome, + dotnetResult.BackupDir + ); + assert.equal( + dotnetSourceIdentity.Revision, + await captureRestoreSourceIdentity(dotnetResult.BackupDir) + ); + assert.notEqual((await canonicalState(dotnetToNode.codexHome)).hash, dotnetToNode.initial.hash); + const nodeRestore = await runRestore({ + codexHome: dotnetToNode.codexHome, + backupDir: dotnetResult.BackupDir + }); + assert.equal(nodeRestore.restoreVersion, 2); + assert.equal(nodeRestore.restoreJournalState, "completed"); + await assertRestored(dotnetToNode, dotnetResult.BackupDir, fixture.manifest.expected.journalTerminal); + const dotnetToNodeJournals = await restoreJournals(dotnetToNode.codexHome); + await assertCompletedRestoreEvidence({ + result: nodeRestore, + codexHome: dotnetToNode.codexHome, + sourceBackupDir: dotnetResult.BackupDir, + expectedSourceRevision: dotnetSourceIdentity.Revision, + journals: dotnetToNodeJournals + }); + + return createRuntimeDifference({ + fixtureId: fixture.manifest.id, + status: "matched", + node: { restored: true }, + dotnet: { restored: true }, + decision: "Both directed backup/restore paths match the synthetic canonical state." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("actual Node and .NET writers contend across different Homes sharing one State DB", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(nodeWriterHost)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeWinnerCase = await createSharedStateDbCase(fixture, "node-winner"); + const nodeReadyPath = path.join(fixture.root, "work", "node-winner.ready"); + const nodeReleasePath = path.join(fixture.root, "work", "node-winner.release"); + const nodeWinner = startProcess(process.execPath, [ + nodeWriterHost, + nodeWinnerCase.nodeHome.codexHome, + "openai", + nodeWinnerCase.sharedSqliteHome, + nodeReadyPath, + nodeReleasePath + ]); + try { + await waitForPath(nodeReadyPath); + const loserBefore = await canonicalState( + nodeWinnerCase.dotnetHome.codexHome, + nodeWinnerCase.sharedStateDb + ); + const loserBackupsBefore = await managedBackupTree(nodeWinnerCase.dotnetHome.codexHome); + const failure = runProcessFailure( + "dotnet", + [ + fixtureHostDll, + "sync-explicit", + nodeWinnerCase.dotnetHome.codexHome, + "openai", + nodeWinnerCase.sharedSqliteHome + ], + "OPERATION_BUSY" + ); + assert.equal(failure.busyScope, "state-db"); + assert.deepEqual( + (await canonicalState( + nodeWinnerCase.dotnetHome.codexHome, + nodeWinnerCase.sharedStateDb + )).canonical, + loserBefore.canonical + ); + assert.deepEqual( + await managedBackupTree(nodeWinnerCase.dotnetHome.codexHome), + loserBackupsBefore + ); + assert.equal((await findPendingTransactions(nodeWinnerCase.dotnetHome.codexHome)).length, 0); + } finally { + await releaseAndFinish(nodeWinner, nodeReleasePath); + } + assert.equal( + (await canonicalState(nodeWinnerCase.nodeHome.codexHome, nodeWinnerCase.sharedStateDb)).provider, + "openai" + ); + + const dotnetWinnerCase = await createSharedStateDbCase(fixture, "dotnet-winner"); + const dotnetReadyPath = path.join(fixture.root, "work", "dotnet-winner.ready"); + const dotnetReleasePath = path.join(fixture.root, "work", "dotnet-winner.release"); + const dotnetWinner = startProcess("dotnet", [ + fixtureHostDll, + "sync-gated", + dotnetWinnerCase.dotnetHome.codexHome, + "openai", + dotnetWinnerCase.sharedSqliteHome, + dotnetReadyPath, + dotnetReleasePath + ]); + try { + await waitForPath(dotnetReadyPath); + const loserBefore = await canonicalState( + dotnetWinnerCase.nodeHome.codexHome, + dotnetWinnerCase.sharedStateDb + ); + const loserBackupsBefore = await managedBackupTree(dotnetWinnerCase.nodeHome.codexHome); + await assert.rejects( + () => runSync({ + codexHome: dotnetWinnerCase.nodeHome.codexHome, + provider: "openai", + sqliteHome: dotnetWinnerCase.sharedSqliteHome + }), + (error) => error?.code === "OPERATION_BUSY" + && error?.details?.busyScope === "state-db" + ); + assert.deepEqual( + (await canonicalState( + dotnetWinnerCase.nodeHome.codexHome, + dotnetWinnerCase.sharedStateDb + )).canonical, + loserBefore.canonical + ); + assert.deepEqual( + await managedBackupTree(dotnetWinnerCase.nodeHome.codexHome), + loserBackupsBefore + ); + assert.equal((await findPendingTransactions(dotnetWinnerCase.nodeHome.codexHome)).length, 0); + } finally { + await releaseAndFinish(dotnetWinner, dotnetReleasePath); + } + assert.equal( + (await canonicalState(dotnetWinnerCase.dotnetHome.codexHome, dotnetWinnerCase.sharedStateDb)).provider, + "openai" + ); + + return createRuntimeDifference({ + fixtureId: "shared-sqlite-home-actual-writer-contention", + status: "matched", + node: { + heldStateDbLockAgainstDotnetWriter: true, + rejectedByDotnetWriter: true + }, + dotnet: { + heldStateDbLockAgainstNodeWriter: true, + rejectedByNodeWriter: true + }, + decision: "Actual Node and .NET Sync writers both fail the losing Home before Backup, Journal, or business mutation." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("foreign-pending-restore converges both crash directions to rolledBack", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "foreign-pending-restore"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-pending-to-dotnet"); + runProcess(process.execPath, [nodeCrashHost, nodeToDotnet.codexHome], { expectCrash: true }); + const nodePending = await findPendingTransactions(nodeToDotnet.codexHome); + assert.equal(nodePending.length, 1); + assert.notEqual((await canonicalState(nodeToDotnet.codexHome)).hash, nodeToDotnet.initial.hash); + runDotnet("restore", nodeToDotnet.codexHome, nodePending[0].backupDir); + await assertRestored(nodeToDotnet, nodePending[0].backupDir, fixture.manifest.expected.journalTerminal); + + const dotnetToNode = await createCase(fixture, "dotnet-pending-to-node"); + runProcess("dotnet", [crashHostDll, dotnetToNode.codexHome], { expectCrash: true }); + const dotnetPending = await findPendingTransactions(dotnetToNode.codexHome); + assert.equal(dotnetPending.length, 1); + assert.notEqual((await canonicalState(dotnetToNode.codexHome)).hash, dotnetToNode.initial.hash); + await runRestore({ codexHome: dotnetToNode.codexHome, backupDir: dotnetPending[0].backupDir }); + await assertRestored(dotnetToNode, dotnetPending[0].backupDir, fixture.manifest.expected.journalTerminal); + + assert.equal((await managedBackupDirectories(nodeToDotnet.codexHome)).length, 2); + assert.equal((await managedBackupDirectories(dotnetToNode.codexHome)).length, 2); + return createRuntimeDifference({ + fixtureId: fixture.manifest.id, + status: "matched", + node: { pendingRecovered: true }, + dotnet: { pendingRecovered: true }, + decision: "Both foreign pending journals reached a valid rolledBack terminal." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("Restore v2 crash recovery is bidirectional across Node and .NET", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-restore-crash-to-dotnet"); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + const nodeSourceRevision = await captureRestoreSourceIdentity(nodeSource.backupDir); + runProcess( + process.execPath, + [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSource.backupDir, + "after_restore_target_write_before_complete", + "--with-database" + ], + { expectCrash: true } + ); + assert.equal((await findPendingTransactions(nodeToDotnet.codexHome)).length, 1); + const nodeCrashJournals = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodeCrashJournals.length, 1); + assert.equal(nodeCrashJournals[0].state, "applying"); + assert.equal([...nodeCrashJournals[0].targetPhases.values()].includes("intent"), true); + const recoveredByDotnet = runDotnet( + "restore-v2", + nodeToDotnet.codexHome, + nodeSource.backupDir + ); + assert.equal(recoveredByDotnet.RestoreJournalState, "completed"); + assert.equal(recoveredByDotnet.ResolvedOperationIds.length, 1); + await assertRestored(nodeToDotnet, nodeSource.backupDir, fixture.manifest.expected.journalTerminal); + const nodeOriginJournals = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodeOriginJournals.length, 2); + assert.equal(nodeOriginJournals.filter((journal) => journal.state === "applying").length, 1); + assert.equal(nodeOriginJournals.filter((journal) => journal.state === "completed").length, 1); + await assertCompletedRestoreEvidence({ + result: recoveredByDotnet, + codexHome: nodeToDotnet.codexHome, + sourceBackupDir: nodeSource.backupDir, + expectedSourceRevision: nodeSourceRevision, + journals: nodeOriginJournals, + expectedResolvedOperationIds: [nodeCrashJournals[0].operationId] + }); + + const dotnetToNode = await createCase(fixture, "dotnet-restore-crash-to-node"); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + const dotnetSourceRevision = await captureRestoreSourceIdentity(dotnetSource.BackupDir); + runProcess( + "dotnet", + [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSource.BackupDir, + "after_restore_target_write_before_complete" + ], + { expectCrash: true } + ); + assert.equal((await findPendingTransactions(dotnetToNode.codexHome)).length, 1); + const dotnetCrashJournals = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetCrashJournals.length, 1); + assert.equal(dotnetCrashJournals[0].state, "applying"); + assert.equal([...dotnetCrashJournals[0].targetPhases.values()].includes("intent"), true); + const recoveredByNode = await runRestore({ + codexHome: dotnetToNode.codexHome, + backupDir: dotnetSource.BackupDir + }); + assert.equal(recoveredByNode.restoreJournalState, "completed"); + assert.equal(recoveredByNode.resolvedOperationIds.length, 1); + await assertRestored(dotnetToNode, dotnetSource.BackupDir, fixture.manifest.expected.journalTerminal); + const dotnetOriginJournals = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetOriginJournals.length, 2); + assert.equal(dotnetOriginJournals.filter((journal) => journal.state === "applying").length, 1); + assert.equal(dotnetOriginJournals.filter((journal) => journal.state === "completed").length, 1); + await assertCompletedRestoreEvidence({ + result: recoveredByNode, + codexHome: dotnetToNode.codexHome, + sourceBackupDir: dotnetSource.BackupDir, + expectedSourceRevision: dotnetSourceRevision, + journals: dotnetOriginJournals, + expectedResolvedOperationIds: [dotnetCrashJournals[0].operationId] + }); + + return createRuntimeDifference({ + fixtureId: "restore-v2-bidirectional-crash-recovery", + status: "matched", + node: { recoveredDotnetCrash: true }, + dotnet: { recoveredNodeCrash: true }, + decision: "Each runtime safely resolved the other runtime's applying Restore journal." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("Restore v2 recovery accepts real Windows 8.3 aliases across Node and .NET", { + skip: process.platform !== "win32" +}, async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-short-alias-to-dotnet"); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + runProcess(process.execPath, [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSource.backupDir, + "after_restore_prepared_before_applying", + "--with-database" + ], { expectCrash: true }); + const [nodePending] = await restoreJournals(nodeToDotnet.codexHome); + const nodePendingBytes = await fs.readFile(nodePending.filePath); + const recoveredByDotnet = runDotnet( + "restore-v2", + windowsShortDirectoryPath(nodeToDotnet.codexHome), + windowsShortDirectoryPath(nodeSource.backupDir) + ); + assert.equal(recoveredByDotnet.RestoreJournalState, "completed"); + assert.deepEqual(recoveredByDotnet.ResolvedOperationIds, [nodePending.operationId]); + assert.deepEqual(await fs.readFile(nodePending.filePath), nodePendingBytes); + await assertRestored(nodeToDotnet, nodeSource.backupDir, fixture.manifest.expected.journalTerminal); + + const dotnetToNode = await createCase(fixture, "dotnet-short-alias-to-node"); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + runProcess("dotnet", [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSource.BackupDir, + "after_restore_prepared_before_applying" + ], { expectCrash: true }); + const [dotnetPending] = await restoreJournals(dotnetToNode.codexHome); + const dotnetPendingBytes = await fs.readFile(dotnetPending.filePath); + const recoveredByNode = await runRestore({ + codexHome: windowsShortDirectoryPath(dotnetToNode.codexHome), + backupDir: windowsShortDirectoryPath(dotnetSource.BackupDir) + }); + assert.equal(recoveredByNode.restoreJournalState, "completed"); + assert.deepEqual(recoveredByNode.resolvedOperationIds, [dotnetPending.operationId]); + assert.deepEqual(await fs.readFile(dotnetPending.filePath), dotnetPendingBytes); + await assertRestored(dotnetToNode, dotnetSource.BackupDir, fixture.manifest.expected.journalTerminal); + + const nodeAliasCreated = await createCase(fixture, "node-created-via-short-alias"); + const nodeAliasSource = await runSync({ codexHome: nodeAliasCreated.codexHome, provider: "openai" }); + runProcess(process.execPath, [ + nodeRestoreCrashHost, + windowsShortDirectoryPath(nodeAliasCreated.codexHome), + windowsShortDirectoryPath(nodeAliasSource.backupDir), + "after_restore_prepared_before_applying", + "--with-database" + ], { expectCrash: true }); + const [nodeAliasPending] = await restoreJournals(nodeAliasCreated.codexHome); + const nodeAliasPhysical = await fs.realpath(nodeAliasSource.backupDir); + assert.equal( + physicalPathKey(await fs.realpath(nodeAliasPending.prepared.sourceBackup.backupDir)), + physicalPathKey(nodeAliasPhysical) + ); + assert.equal(nodeAliasPending.prepared.sourceBackup.backupId, path.basename(nodeAliasPhysical)); + const nodeAliasRecoveredByDotnet = runDotnet( + "restore-v2", + nodeAliasCreated.codexHome, + nodeAliasSource.backupDir + ); + assert.equal(nodeAliasRecoveredByDotnet.RestoreJournalState, "completed"); + assert.deepEqual(nodeAliasRecoveredByDotnet.ResolvedOperationIds, [nodeAliasPending.operationId]); + + const dotnetAliasCreated = await createCase(fixture, "dotnet-created-via-short-alias"); + const dotnetAliasSource = runDotnet("sync", dotnetAliasCreated.codexHome, "openai"); + runProcess("dotnet", [ + crashHostDll, + "restore-v2", + windowsShortDirectoryPath(dotnetAliasCreated.codexHome), + windowsShortDirectoryPath(dotnetAliasSource.BackupDir), + "after_restore_prepared_before_applying" + ], { expectCrash: true }); + const [dotnetAliasPending] = await restoreJournals(dotnetAliasCreated.codexHome); + const dotnetAliasPhysical = await fs.realpath(dotnetAliasSource.BackupDir); + assert.equal( + physicalPathKey(await fs.realpath(dotnetAliasPending.prepared.sourceBackup.backupDir)), + physicalPathKey(dotnetAliasPhysical) + ); + assert.equal(dotnetAliasPending.prepared.sourceBackup.backupId, path.basename(dotnetAliasPhysical)); + const dotnetAliasRecoveredByNode = await runRestore({ + codexHome: dotnetAliasCreated.codexHome, + backupDir: dotnetAliasSource.BackupDir + }); + assert.equal(dotnetAliasRecoveredByNode.restoreJournalState, "completed"); + assert.deepEqual(dotnetAliasRecoveredByNode.resolvedOperationIds, [dotnetAliasPending.operationId]); + + return createRuntimeDifference({ + fixtureId: "restore-v2-windows-path-alias", + status: "matched", + node: { + recoveredDotnetPendingViaShortAlias: true, + createdPendingViaShortAliasRecoveredByDotnet: true + }, + dotnet: { + recoveredNodePendingViaShortAlias: true, + createdPendingViaShortAliasRecoveredByNode: true + }, + decision: "Both runtimes created and recovered the other runtime's pending Restore through actual Windows 8.3 aliases." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("Restore v2 recovery binds Windows junction aliases at creation across Node and .NET", { + skip: process.platform !== "win32" +}, async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-junction-create-to-dotnet"); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + const nodeSourceAlias = path.join(path.dirname(nodeToDotnet.codexHome), "source-backup-junction"); + await fs.symlink(nodeSource.backupDir, nodeSourceAlias, "junction"); + runProcess(process.execPath, [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSourceAlias, + "after_restore_prepared_before_applying", + "--with-database" + ], { expectCrash: true }); + const [nodePending] = await restoreJournals(nodeToDotnet.codexHome); + assert.equal( + physicalPathKey(await fs.realpath(nodePending.prepared.sourceBackup.backupDir)), + physicalPathKey(await fs.realpath(nodeSource.backupDir)) + ); + const nodeRecoveredByDotnet = runDotnet( + "restore-v2", + nodeToDotnet.codexHome, + nodeSource.backupDir + ); + assert.equal(nodeRecoveredByDotnet.RestoreJournalState, "completed"); + assert.deepEqual(nodeRecoveredByDotnet.ResolvedOperationIds, [nodePending.operationId]); + await assertRestored(nodeToDotnet, nodeSource.backupDir, fixture.manifest.expected.journalTerminal); + + const dotnetToNode = await createCase(fixture, "dotnet-junction-create-to-node"); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + const dotnetSourceAlias = path.join(path.dirname(dotnetToNode.codexHome), "source-backup-junction"); + await fs.symlink(dotnetSource.BackupDir, dotnetSourceAlias, "junction"); + runProcess("dotnet", [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSourceAlias, + "after_restore_prepared_before_applying" + ], { expectCrash: true }); + const [dotnetPending] = await restoreJournals(dotnetToNode.codexHome); + assert.equal( + physicalPathKey(await fs.realpath(dotnetPending.prepared.sourceBackup.backupDir)), + physicalPathKey(await fs.realpath(dotnetSource.BackupDir)) + ); + const dotnetRecoveredByNode = await runRestore({ + codexHome: dotnetToNode.codexHome, + backupDir: dotnetSource.BackupDir + }); + assert.equal(dotnetRecoveredByNode.restoreJournalState, "completed"); + assert.deepEqual(dotnetRecoveredByNode.resolvedOperationIds, [dotnetPending.operationId]); + await assertRestored(dotnetToNode, dotnetSource.BackupDir, fixture.manifest.expected.journalTerminal); + + return createRuntimeDifference({ + fixtureId: "restore-v2-windows-junction-alias", + status: "matched", + node: { createdThroughJunctionRecoveredByDotnet: true }, + dotnet: { createdThroughJunctionRecoveredByNode: true }, + decision: "Both runtimes persisted physical Restore source identity when the pending journal was created through a Windows junction." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("Restore v2 prepared, committing and rollback-pending crashes recover across runtimes", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const scenarios = [ + { + name: "prepared", + point: "after_restore_prepared_before_applying", + state: "prepared" + }, + { + name: "committing", + point: "after_restore_committing_before_committed_pending_ack", + state: "committing" + }, + { + name: "rollback-pending", + point: "after_restore_rollback_pending_before_target", + failurePoint: "after_restore_target_write_before_complete", + state: "rollback-pending" + } + ]; + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + for (const scenario of scenarios) { + const nodeToDotnet = await createCase(fixture, `node-${scenario.name}-to-dotnet`); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + const nodeSourceRevision = await captureRestoreSourceIdentity(nodeSource.backupDir); + const nodeCrashArgs = [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSource.backupDir, + scenario.point, + "--with-database" + ]; + if (scenario.failurePoint) nodeCrashArgs.push("--fail-at", scenario.failurePoint); + runProcess(process.execPath, nodeCrashArgs, { expectCrash: true }); + const nodePendingJournals = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodePendingJournals.length, 1); + const [nodePending] = nodePendingJournals; + assert.equal(nodePending.state, scenario.state); + const recoveredByDotnet = runDotnet( + "restore-v2", + nodeToDotnet.codexHome, + nodeSource.backupDir + ); + assert.equal(recoveredByDotnet.RestoreJournalState, "completed"); + assert.deepEqual(recoveredByDotnet.ResolvedOperationIds, [nodePending.operationId]); + await assertRestored(nodeToDotnet, nodeSource.backupDir, fixture.manifest.expected.journalTerminal); + const nodeJournals = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodeJournals.length, 2); + assert.equal(nodeJournals.filter((journal) => journal.state === scenario.state).length, 1); + assert.equal(nodeJournals.filter((journal) => journal.state === "completed").length, 1); + await assertCompletedRestoreEvidence({ + result: recoveredByDotnet, + codexHome: nodeToDotnet.codexHome, + sourceBackupDir: nodeSource.backupDir, + expectedSourceRevision: nodeSourceRevision, + journals: nodeJournals, + expectedResolvedOperationIds: [nodePending.operationId] + }); + + const dotnetToNode = await createCase(fixture, `dotnet-${scenario.name}-to-node`); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + const dotnetSourceRevision = await captureRestoreSourceIdentity(dotnetSource.BackupDir); + const dotnetCrashArgs = [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSource.BackupDir, + scenario.point + ]; + if (scenario.failurePoint) dotnetCrashArgs.push("--fail-at", scenario.failurePoint); + runProcess("dotnet", dotnetCrashArgs, { expectCrash: true }); + const dotnetPendingJournals = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetPendingJournals.length, 1); + const [dotnetPending] = dotnetPendingJournals; + assert.equal(dotnetPending.state, scenario.state); + const recoveredByNode = await runRestore({ + codexHome: dotnetToNode.codexHome, + backupDir: dotnetSource.BackupDir + }); + assert.equal(recoveredByNode.restoreJournalState, "completed"); + assert.deepEqual(recoveredByNode.resolvedOperationIds, [dotnetPending.operationId]); + await assertRestored(dotnetToNode, dotnetSource.BackupDir, fixture.manifest.expected.journalTerminal); + const dotnetJournals = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetJournals.length, 2); + assert.equal(dotnetJournals.filter((journal) => journal.state === scenario.state).length, 1); + assert.equal(dotnetJournals.filter((journal) => journal.state === "completed").length, 1); + await assertCompletedRestoreEvidence({ + result: recoveredByNode, + codexHome: dotnetToNode.codexHome, + sourceBackupDir: dotnetSource.BackupDir, + expectedSourceRevision: dotnetSourceRevision, + journals: dotnetJournals, + expectedResolvedOperationIds: [dotnetPending.operationId] + }); + } + return createRuntimeDifference({ + fixtureId: "restore-v2-cross-runtime-crash-matrix", + status: "matched", + node: { recoveredStates: scenarios.map((scenario) => scenario.state) }, + dotnet: { recoveredStates: scenarios.map((scenario) => scenario.state) }, + decision: "Both runtimes resolved the other runtime's durable pre-commit Restore states." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("foreign Restore v2 pending is rejected without mutation in both runtime directions", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-foreign-v2-to-dotnet"); + const nodeSourceA = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + const nodeSourceB = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "relay" }); + assert.notEqual(path.resolve(nodeSourceA.backupDir), path.resolve(nodeSourceB.backupDir)); + runProcess(process.execPath, [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSourceA.backupDir, + "after_restore_target_write_before_complete", + "--with-database" + ], { expectCrash: true }); + const nodePendingJournals = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodePendingJournals.length, 1); + const [nodePending] = nodePendingJournals; + assert.equal(nodePending.state, "applying"); + assert.equal([...nodePending.targetPhases.values()].includes("intent"), true); + const nodeCanonicalBefore = await canonicalState(nodeToDotnet.codexHome); + const nodeTreeBefore = await managedBackupTree(nodeToDotnet.codexHome); + const nodeJournalBefore = await fs.readFile(nodePending.filePath); + runDotnetFailure("restore-v2", nodeToDotnet.codexHome, nodeSourceB.backupDir); + assert.deepEqual((await canonicalState(nodeToDotnet.codexHome)).canonical, nodeCanonicalBefore.canonical); + assert.deepEqual(await managedBackupTree(nodeToDotnet.codexHome), nodeTreeBefore); + assert.deepEqual(await fs.readFile(nodePending.filePath), nodeJournalBefore); + assert.equal((await restoreJournals(nodeToDotnet.codexHome)).length, 1); + + const dotnetToNode = await createCase(fixture, "dotnet-foreign-v2-to-node"); + const dotnetSourceA = runDotnet("sync", dotnetToNode.codexHome, "openai"); + const dotnetSourceB = runDotnet("sync", dotnetToNode.codexHome, "relay"); + assert.notEqual(path.resolve(dotnetSourceA.BackupDir), path.resolve(dotnetSourceB.BackupDir)); + runProcess("dotnet", [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSourceA.BackupDir, + "after_restore_prepared_before_applying" + ], { expectCrash: true }); + const dotnetPendingJournals = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetPendingJournals.length, 1); + const [dotnetPending] = dotnetPendingJournals; + assert.equal(dotnetPending.state, "prepared"); + const dotnetCanonicalBefore = await canonicalState(dotnetToNode.codexHome); + const dotnetTreeBefore = await managedBackupTree(dotnetToNode.codexHome); + const dotnetJournalBefore = await fs.readFile(dotnetPending.filePath); + await assert.rejects( + () => runRestore({ codexHome: dotnetToNode.codexHome, backupDir: dotnetSourceB.BackupDir }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + assert.deepEqual((await canonicalState(dotnetToNode.codexHome)).canonical, dotnetCanonicalBefore.canonical); + assert.deepEqual(await managedBackupTree(dotnetToNode.codexHome), dotnetTreeBefore); + assert.deepEqual(await fs.readFile(dotnetPending.filePath), dotnetJournalBefore); + assert.equal((await restoreJournals(dotnetToNode.codexHome)).length, 1); + + return createRuntimeDifference({ + fixtureId: "restore-v2-cross-runtime-foreign-pending", + status: "matched", + node: { rejectedDotnetForeignPending: true }, + dotnet: { rejectedNodeForeignPending: true }, + decision: "Foreign Restore v2 journals failed closed without mutating data or evidence." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("unknown Restore journal schema blocks Node and .NET writes without rewriting evidence", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "unknown-schema-to-dotnet"); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + const nodeUnknown = await writeUnknownRestoreJournal( + nodeToDotnet.codexHome, + nodeSource.backupDir, + "node-to-dotnet" + ); + const nodeCanonicalBefore = await canonicalState(nodeToDotnet.codexHome); + const nodeTreeBefore = await managedBackupTree(nodeToDotnet.codexHome); + runDotnetFailure("sync", nodeToDotnet.codexHome, "relay"); + runDotnetFailure("restore-v2", nodeToDotnet.codexHome, nodeSource.backupDir); + assert.deepEqual((await canonicalState(nodeToDotnet.codexHome)).canonical, nodeCanonicalBefore.canonical); + assert.deepEqual(await managedBackupTree(nodeToDotnet.codexHome), nodeTreeBefore); + assert.equal(await fs.readFile(nodeUnknown.journalPath, "utf8"), nodeUnknown.raw); + + const dotnetToNode = await createCase(fixture, "unknown-schema-to-node"); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + const dotnetUnknown = await writeUnknownRestoreJournal( + dotnetToNode.codexHome, + dotnetSource.BackupDir, + "dotnet-to-node" + ); + const dotnetCanonicalBefore = await canonicalState(dotnetToNode.codexHome); + const dotnetTreeBefore = await managedBackupTree(dotnetToNode.codexHome); + await assert.rejects( + () => runSync({ codexHome: dotnetToNode.codexHome, provider: "relay" }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + await assert.rejects( + () => runRestore({ codexHome: dotnetToNode.codexHome, backupDir: dotnetSource.BackupDir }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + assert.deepEqual((await canonicalState(dotnetToNode.codexHome)).canonical, dotnetCanonicalBefore.canonical); + assert.deepEqual(await managedBackupTree(dotnetToNode.codexHome), dotnetTreeBefore); + assert.equal(await fs.readFile(dotnetUnknown.journalPath, "utf8"), dotnetUnknown.raw); + + return createRuntimeDifference({ + fixtureId: "restore-v2-cross-runtime-unknown-schema", + status: "matched", + node: { failedClosed: true }, + dotnet: { failedClosed: true }, + decision: "Both readers preserve unknown Restore journal bytes and block Sync and Restore writes." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("Restore v2 commit acknowledgement is forward-only across runtimes", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-ack-to-dotnet"); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + const nodeSourceRevision = await captureRestoreSourceIdentity(nodeSource.backupDir); + runProcess( + process.execPath, + [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSource.backupDir, + "after_restore_committed_pending_ack_before_completed", + "--with-database" + ], + { expectCrash: true } + ); + const nodePending = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodePending.length, 1); + assert.equal(nodePending[0].state, "committed-pending-ack"); + const dotnetAck = runDotnet("restore-v2", nodeToDotnet.codexHome, nodeSource.backupDir); + assert.equal(dotnetAck.CommitAcknowledgementRecovered, true); + const nodeAcknowledged = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodeAcknowledged.length, 1); + assert.equal(nodeAcknowledged[0].state, "completed"); + assert.equal(nodeAcknowledged[0].events.some((event) => event.state === "rollback-pending"), false); + await assertCompletedRestoreEvidence({ + result: dotnetAck, + codexHome: nodeToDotnet.codexHome, + sourceBackupDir: nodeSource.backupDir, + expectedSourceRevision: nodeSourceRevision, + journals: nodeAcknowledged + }); + await assertRestored(nodeToDotnet, nodeSource.backupDir, fixture.manifest.expected.journalTerminal); + + const dotnetToNode = await createCase(fixture, "dotnet-ack-to-node"); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + const dotnetSourceRevision = await captureRestoreSourceIdentity(dotnetSource.BackupDir); + runProcess( + "dotnet", + [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSource.BackupDir, + "after_restore_committed_pending_ack_before_completed" + ], + { expectCrash: true } + ); + const dotnetPending = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetPending.length, 1); + assert.equal(dotnetPending[0].state, "committed-pending-ack"); + const nodeAck = await runRestore({ + codexHome: dotnetToNode.codexHome, + backupDir: dotnetSource.BackupDir + }); + assert.equal(nodeAck.commitAcknowledgementRecovered, true); + const dotnetAcknowledged = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetAcknowledged.length, 1); + assert.equal(dotnetAcknowledged[0].state, "completed"); + assert.equal(dotnetAcknowledged[0].events.some((event) => event.state === "rollback-pending"), false); + await assertCompletedRestoreEvidence({ + result: nodeAck, + codexHome: dotnetToNode.codexHome, + sourceBackupDir: dotnetSource.BackupDir, + expectedSourceRevision: dotnetSourceRevision, + journals: dotnetAcknowledged + }); + await assertRestored(dotnetToNode, dotnetSource.BackupDir, fixture.manifest.expected.journalTerminal); + + return createRuntimeDifference({ + fixtureId: "restore-v2-cross-runtime-commit-ack", + status: "matched", + node: { acknowledgedDotnetCommit: true }, + dotnet: { acknowledgedNodeCommit: true }, + decision: "Both runtimes completed the other's committed Restore without compensation." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("Restore v2 manifest and prepared journal binding fails closed across runtimes", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const nodeToDotnet = await createCase(fixture, "node-binding-to-dotnet"); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + runProcess( + process.execPath, + [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSource.backupDir, + "after_restore_committed_pending_ack_before_completed", + "--with-database" + ], + { expectCrash: true } + ); + const [nodePending] = await restoreJournals(nodeToDotnet.codexHome); + await mismatchManifestPreparedBinding(nodePending); + const nodeBusinessBefore = await canonicalState(nodeToDotnet.codexHome); + runDotnetFailure("restore-v2", nodeToDotnet.codexHome, nodeSource.backupDir); + assert.deepEqual((await canonicalState(nodeToDotnet.codexHome)).canonical, nodeBusinessBefore.canonical); + const [nodeFailed] = await restoreJournals(nodeToDotnet.codexHome); + assert.equal(nodeFailed.state, "recovery-required"); + assert.equal(nodeFailed.events.some((event) => event.state === "rollback-pending"), false); + + const dotnetToNode = await createCase(fixture, "dotnet-binding-to-node"); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + runProcess( + "dotnet", + [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSource.BackupDir, + "after_restore_committed_pending_ack_before_completed" + ], + { expectCrash: true } + ); + const [dotnetPending] = await restoreJournals(dotnetToNode.codexHome); + await mismatchManifestPreparedBinding(dotnetPending); + const dotnetBusinessBefore = await canonicalState(dotnetToNode.codexHome); + await assert.rejects( + () => runRestore({ + codexHome: dotnetToNode.codexHome, + backupDir: dotnetSource.BackupDir + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + assert.deepEqual((await canonicalState(dotnetToNode.codexHome)).canonical, dotnetBusinessBefore.canonical); + const [dotnetFailed] = await restoreJournals(dotnetToNode.codexHome); + assert.equal(dotnetFailed.state, "recovery-required"); + assert.equal(dotnetFailed.events.some((event) => event.state === "rollback-pending"), false); + + return createRuntimeDifference({ + fixtureId: "restore-v2-manifest-prepared-binding", + status: "matched", + node: { rejectedDotnetMismatch: true }, + dotnet: { rejectedNodeMismatch: true }, + decision: "Both runtimes reject a rehashed manifest that disagrees with durable prepared evidence." + }); + }); + assert.equal(evidence.status, "matched"); +}); + +test("Restore v2 persisted physical Home binding fails closed across runtimes", async () => { + await Promise.all([fs.access(fixtureHostDll), fs.access(crashHostDll)]); + const fixtureRoot = path.join(staticRoot, "bidirectional-backup-roundtrip"); + const evidence = await runFixtureInTemp(fixtureRoot, async (fixture) => { + const foreignPhysicalHome = path.join(fixture.root, "work", "foreign-physical-home"); + await fs.mkdir(foreignPhysicalHome, { recursive: true }); + + const nodeToDotnet = await createCase(fixture, "node-home-binding-to-dotnet"); + const nodeSource = await runSync({ codexHome: nodeToDotnet.codexHome, provider: "openai" }); + runProcess( + process.execPath, + [ + nodeRestoreCrashHost, + nodeToDotnet.codexHome, + nodeSource.backupDir, + "after_restore_prepared_before_applying", + "--with-database" + ], + { expectCrash: true } + ); + const [nodePending] = await restoreJournals(nodeToDotnet.codexHome); + await mismatchPreparedPhysicalHome(nodePending, foreignPhysicalHome); + const nodeJournalBefore = await fs.readFile(nodePending.filePath); + const nodeBusinessBefore = await canonicalState(nodeToDotnet.codexHome); + const nodeDirectoriesBefore = await managedBackupDirectories(nodeToDotnet.codexHome); + runDotnetFailure("restore-v2", nodeToDotnet.codexHome, nodeSource.backupDir); + assert.deepEqual((await canonicalState(nodeToDotnet.codexHome)).canonical, nodeBusinessBefore.canonical); + assert.deepEqual(await managedBackupDirectories(nodeToDotnet.codexHome), nodeDirectoriesBefore); + assert.deepEqual(await fs.readFile(nodePending.filePath), nodeJournalBefore); + + const dotnetToNode = await createCase(fixture, "dotnet-home-binding-to-node"); + const dotnetSource = runDotnet("sync", dotnetToNode.codexHome, "openai"); + runProcess( + "dotnet", + [ + crashHostDll, + "restore-v2", + dotnetToNode.codexHome, + dotnetSource.BackupDir, + "after_restore_prepared_before_applying" + ], + { expectCrash: true } + ); + const [dotnetPending] = await restoreJournals(dotnetToNode.codexHome); + await mismatchPreparedPhysicalHome(dotnetPending, foreignPhysicalHome); + const dotnetJournalBefore = await fs.readFile(dotnetPending.filePath); + const dotnetBusinessBefore = await canonicalState(dotnetToNode.codexHome); + const dotnetDirectoriesBefore = await managedBackupDirectories(dotnetToNode.codexHome); + await assert.rejects( + () => runRestore({ + codexHome: dotnetToNode.codexHome, + backupDir: dotnetSource.BackupDir + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + assert.deepEqual((await canonicalState(dotnetToNode.codexHome)).canonical, dotnetBusinessBefore.canonical); + assert.deepEqual(await managedBackupDirectories(dotnetToNode.codexHome), dotnetDirectoriesBefore); + assert.deepEqual(await fs.readFile(dotnetPending.filePath), dotnetJournalBefore); + + return createRuntimeDifference({ + fixtureId: "restore-v2-persisted-physical-home-binding", + status: "matched", + node: { rejectedDotnetPhysicalMismatch: true }, + dotnet: { rejectedNodePhysicalMismatch: true }, + decision: "Both runtimes reject a pending Restore whose persisted physical Home differs from the current locked Home." + }); + }); + assert.equal(evidence.status, "matched"); +}); diff --git a/test-support/cross-runtime-node-crash-host.mjs b/test-support/cross-runtime-node-crash-host.mjs new file mode 100644 index 0000000..fcbb685 --- /dev/null +++ b/test-support/cross-runtime-node-crash-host.mjs @@ -0,0 +1,13 @@ +import { runSync } from "../src/public-api.js"; + +if (process.argv.length !== 3) process.exit(64); + +await runSync({ + codexHome: process.argv[2], + provider: "openai", + faultInjector: async ({ point }) => { + if (point === "after_rollout_mutation_before_applied") process.exit(86); + } +}); + +process.exit(65); diff --git a/test-support/cross-runtime-writer-host.mjs b/test-support/cross-runtime-writer-host.mjs new file mode 100644 index 0000000..656b4f5 --- /dev/null +++ b/test-support/cross-runtime-writer-host.mjs @@ -0,0 +1,37 @@ +import fs from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; + +import { runSync } from "../src/public-api.js"; + +if (process.argv.length !== 7) process.exit(64); + +const [, , codexHome, provider, sqliteHome, readyPath, releasePath] = process.argv; + +const result = await runSync({ + codexHome, + provider, + sqliteHome, + faultInjector: async ({ point }) => { + if (point !== "before_backup") return; + await fs.writeFile(readyPath, "ready\n", { flag: "wx" }); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + await fs.access(releasePath); + return; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + await delay(25); + } + throw new Error("Timed out waiting for the cross-runtime writer release marker."); + } +}); + +console.log(JSON.stringify({ + schemaVersion: 1, + ok: true, + operation: "sync", + backupDir: result.backupDir, + targetProvider: result.targetProvider +})); diff --git a/test-support/desktop-readonly-fixture.mjs b/test-support/desktop-readonly-fixture.mjs new file mode 100644 index 0000000..a40c805 --- /dev/null +++ b/test-support/desktop-readonly-fixture.mjs @@ -0,0 +1,138 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { defaultBackupRoot } from "../src/constants.js"; +import { TransactionJournal } from "../src/transaction-journal.js"; + +async function hashTree(root) { + const result = {}; + async function visit(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const absolute = path.join(directory, entry.name); + const relative = path.relative(root, absolute).replaceAll("\\", "/"); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) { + result[relative] = createHash("sha256").update(await fs.readFile(absolute)).digest("hex"); + } else { + throw new Error(`Desktop fixture contains an unsupported entry: ${relative}`); + } + } + } + await visit(root); + return result; +} + +export async function createDesktopReadOnlyFixture() { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-c6-desktop-")); + const codexHome = path.join(fixtureRoot, "codex-home"); + const userData = path.join(fixtureRoot, "user-data"); + const rolloutPath = path.join( + codexHome, + "sessions", + "2026", + "08", + "26", + "rollout-c6-desktop.jsonl" + ); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); + await fs.mkdir(userData, { recursive: true }); + await fs.writeFile( + path.join(codexHome, "config.toml"), + 'model_provider = "openai"\nmodel = "gpt-5"\n', + "utf8" + ); + await fs.writeFile(rolloutPath, `${[ + { + type: "session_meta", + timestamp: "2026-08-26T00:00:00.000Z", + payload: { + id: "c6-desktop-session", + cwd: "C:\\synthetic\\desktop-project", + model_provider: "openai", + model: "gpt-5" + } + }, + { + type: "event_msg", + timestamp: "2026-08-26T00:01:00.000Z", + payload: { type: "user_message", message: "C6_DESKTOP_BODY_ONLY_MARKER" } + }, + { + type: "event_msg", + timestamp: "2026-08-26T00:02:00.000Z", + payload: { type: "assistant_message", message: "Synthetic desktop response." } + } + ].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); + const database = new DatabaseSync(path.join(codexHome, "sqlite", "state_5.sqlite")); + try { + database.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 '', + model TEXT, + has_user_event INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL DEFAULT 0 + ); + `); + database.prepare(` + INSERT INTO threads ( + id, model_provider, cwd, archived, first_user_message, model, + has_user_event, updated_at, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "c6-desktop-session", + "openai", + "C:\\synthetic\\desktop-project", + 0, + "", + "gpt-5", + 1, + 1787702400, + 1787702400000 + ); + } finally { + database.close(); + } + const pendingBackupDir = path.join(defaultBackupRoot(codexHome), "c6-pending-journal"); + await fs.mkdir(pendingBackupDir, { recursive: true }); + await TransactionJournal.create(pendingBackupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [] + }); + const before = await hashTree(codexHome); + let closed = false; + return { + fixtureRoot, + codexHome, + userData, + before, + async assertUnchanged() { + const after = await hashTree(codexHome); + if (JSON.stringify(after) !== JSON.stringify(before)) { + throw new Error("Desktop read-only fixture was modified."); + } + }, + async close() { + if (closed) return; + closed = true; + await fs.rm(fixtureRoot, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100 + }); + } + }; +} diff --git a/test-support/desktop-sync-switch-fixture.mjs b/test-support/desktop-sync-switch-fixture.mjs new file mode 100644 index 0000000..b5ba85d --- /dev/null +++ b/test-support/desktop-sync-switch-fixture.mjs @@ -0,0 +1,361 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { createCoreFacade } from "@codex-provider-sync/core"; + +import { defaultBackupRoot } from "../src/constants.js"; +import { + readTransactionJournal, + TRANSACTION_JOURNAL_BASENAME +} from "../src/transaction-journal.js"; + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +async function fileDigest(filePath) { + try { + return digest(await fs.readFile(filePath)); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +async function treeDigest(root) { + const entries = []; + async function visit(directory) { + const children = await fs.readdir(directory, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name)); + if (children.length === 0) { + entries.push([`${path.relative(root, directory).replaceAll("\\", "/")}/`, "directory"]); + } + for (const child of children) { + const absolute = path.join(directory, child.name); + const relative = path.relative(root, absolute).replaceAll("\\", "/"); + if (child.isDirectory()) await visit(absolute); + else if (child.isFile()) entries.push([relative, digest(await fs.readFile(absolute))]); + else throw new Error(`Unsupported desktop fixture entry: ${relative}`); + } + } + try { + await visit(root); + return entries; + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function plainRows(rows) { + return rows.map((row) => Object.fromEntries(Object.entries(row))); +} + +function sqliteCanonicalState(stateDbPath) { + const database = new DatabaseSync(stateDbPath, { readOnly: true }); + try { + return { + userVersion: Number(database.prepare("PRAGMA user_version").get().user_version), + schema: plainRows(database.prepare(` + SELECT type, name, tbl_name AS tableName, sql + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name + `).all()), + threads: plainRows(database.prepare(` + SELECT id, model_provider, cwd, archived, first_user_message, model, + has_user_event, updated_at, updated_at_ms + FROM threads + ORDER BY id + `).all()) + }; + } finally { + database.close(); + } +} + +async function sqliteFileDigests(stateDbPath) { + const basename = path.basename(stateDbPath); + return Promise.all(["", "-wal", "-shm", "-journal"].map(async (suffix) => [ + `${basename}${suffix}`, + await fileDigest(`${stateDbPath}${suffix}`) + ])); +} + +function createSyntheticStateDatabase(stateDbPath, provider, model) { + const database = new DatabaseSync(stateDbPath); + try { + database.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 '', + model TEXT, + has_user_event INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL DEFAULT 0 + ); + `); + database.prepare(` + INSERT INTO threads ( + id, model_provider, cwd, archived, first_user_message, model, + has_user_event, updated_at, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "c7-desktop-session", + provider, + "C:\\synthetic\\desktop-project", + 0, + "", + model, + 1, + 1787702400, + 1787702400000 + ); + } finally { + database.close(); + } +} + +function readSessionMeta(text) { + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + const entry = JSON.parse(line); + if (entry?.type === "session_meta") return entry.payload; + } + throw new Error("Writable desktop fixture has no session_meta entry."); +} + +function readTurnContext(text) { + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + const entry = JSON.parse(line); + if (entry?.type === "turn_context") return entry.payload; + } + throw new Error("Writable desktop fixture has no turn_context entry."); +} + +export async function createDesktopSyncSwitchFixture() { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-c7-desktop-")); + const codexHome = path.join(fixtureRoot, "codex-home"); + const userData = path.join(fixtureRoot, "user-data"); + const rolloutPath = path.join( + codexHome, + "sessions", + "2026", + "08", + "26", + "rollout-c7-desktop.jsonl" + ); + const stateDbPath = path.join(codexHome, "sqlite", "state_5.sqlite"); + const targetSqliteHome = path.join(fixtureRoot, "relocation-target-sqlite"); + const targetStateDbPath = path.join(targetSqliteHome, "state_5.sqlite"); + const configPath = path.join(codexHome, "config.toml"); + const globalStatePath = path.join(codexHome, ".codex-global-state.json"); + const globalStateBackupPath = `${globalStatePath}.bak`; + const gateMarkerPath = path.join(fixtureRoot, "runtime-gate.json"); + const coreProfile = { profileId: "c7-desktop-fixture", profileRevision: "fixture-r1" }; + const core = createCoreFacade({ + resolveProfile: async (selector) => { + if (selector.profileId !== coreProfile.profileId + || (selector.profileRevision !== undefined + && selector.profileRevision !== coreProfile.profileRevision)) { + throw new Error("Unexpected C7 desktop fixture profile selector."); + } + return { + id: coreProfile.profileId, + revision: coreProfile.profileRevision, + codexHome + }; + } + }); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.dirname(stateDbPath), { recursive: true }); + await fs.mkdir(targetSqliteHome, { recursive: true }); + await fs.mkdir(userData, { recursive: true }); + await fs.writeFile(path.join(userData, "profiles.v1.json"), `${JSON.stringify({ + schemaVersion: 1, + profiles: [ + { + id: "relocation-target", + name: "Relocation target", + codexHome, + sqliteHome: targetSqliteHome + }, + { + id: "no-sqlite-target", + name: "No SQLite target", + codexHome + } + ] + }, null, 2)}\n`, "utf8"); + await fs.writeFile( + configPath, + [ + 'model_provider = "openai"', + 'model = "gpt-5"', + "", + "[model_providers.relay]", + 'model = "relay-model"', + 'base_url = "https://relay.invalid"', + "" + ].join("\n"), + "utf8" + ); + const globalState = `${JSON.stringify({ + "electron-saved-workspace-roots": ["C:\\synthetic\\previous-project"], + "project-order": ["C:\\synthetic\\previous-project"] + }, null, 2)}\n`; + await fs.writeFile(globalStatePath, globalState, "utf8"); + await fs.writeFile(globalStateBackupPath, globalState, "utf8"); + await fs.writeFile(rolloutPath, `${[ + { + type: "session_meta", + timestamp: "2026-08-26T00:00:00.000Z", + payload: { + id: "c7-desktop-session", + cwd: "C:\\synthetic\\desktop-project", + model_provider: "legacy-provider", + model: "legacy-model" + } + }, + { + type: "turn_context", + timestamp: "2026-08-26T00:00:30.000Z", + payload: { + model: "legacy-model", + collaboration_mode: { settings: { model: "legacy-model" } } + } + }, + { + type: "event_msg", + timestamp: "2026-08-26T00:01:00.000Z", + payload: { type: "user_message", message: "C7_DESKTOP_BODY_ONLY_MARKER" } + } + ].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); + createSyntheticStateDatabase(stateDbPath, "legacy-provider", "legacy-model"); + createSyntheticStateDatabase(targetStateDbPath, "target-before", "target-model"); + let closed = false; + return { + fixtureRoot, + codexHome, + userData, + rolloutPath, + stateDbPath, + targetSqliteHome, + targetStateDbPath, + configPath, + gateMarkerPath, + async snapshotSqlite(databasePath = stateDbPath) { + const value = sqliteCanonicalState(databasePath); + return { ...value, hash: digest(JSON.stringify(value)) }; + }, + async snapshotTargets() { + const value = { + config: await fileDigest(configPath), + globalState: await fileDigest(globalStatePath), + globalStateBackup: await fileDigest(globalStateBackupPath), + sessions: await treeDigest(path.join(codexHome, "sessions")), + archivedSessions: await treeDigest(path.join(codexHome, "archived_sessions")), + // SQLite online backup/restore may produce a byte-different but fully + // equivalent database. Compare the complete synthetic schema and rows + // here; byte-preservation checks below still hash the actual DB files. + sqlite: sqliteCanonicalState(stateDbPath) + }; + return { ...value, hash: digest(JSON.stringify(value)) }; + }, + async snapshotProtected() { + const value = { + config: await fileDigest(configPath), + globalState: await fileDigest(globalStatePath), + globalStateBackup: await fileDigest(globalStateBackupPath), + sessions: await treeDigest(path.join(codexHome, "sessions")), + archivedSessions: await treeDigest(path.join(codexHome, "archived_sessions")), + // Resource-lock directories are coordination artifacts, not protected + // business state. Hash the real SQLite files and exclude those locks. + sqlite: await sqliteFileDigests(stateDbPath), + backups: await treeDigest(defaultBackupRoot(codexHome)) + }; + return { ...value, hash: digest(JSON.stringify(value)) }; + }, + async appendConfigDrift() { + await fs.appendFile(configPath, "# C7 deterministic plan drift\n", "utf8"); + }, + holdSqliteWriteLock() { + const database = new DatabaseSync(stateDbPath); + database.exec("BEGIN IMMEDIATE"); + let released = false; + return { + release() { + if (released) return; + released = true; + try { database.exec("ROLLBACK"); } finally { database.close(); } + } + }; + }, + async readJournals() { + const result = []; + for (const backupId of (await this.inspect()).backupIds) { + const journalPath = path.join(defaultBackupRoot(codexHome), backupId, TRANSACTION_JOURNAL_BASENAME); + try { + const journal = await readTransactionJournal(journalPath); + result.push({ backupId, state: journal.state, terminal: journal.terminal, invalidTail: journal.invalidTail }); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + return result; + }, + async restoreManagedBackup(backupId) { + const plan = await core.prepareRestore({ + profile: coreProfile, + backupId, + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true + }); + return core.applyRestore({ schemaVersion: 1, planId: plan.planId }); + }, + async inspect() { + const configText = await fs.readFile(configPath, "utf8"); + const rolloutText = await fs.readFile(rolloutPath, "utf8"); + const rollout = readSessionMeta(rolloutText); + const turnContext = readTurnContext(rolloutText); + const db = new DatabaseSync(stateDbPath, { readOnly: true }); + let sqlite; + try { + sqlite = db.prepare( + "SELECT model_provider AS provider, model, updated_at AS updatedAt, updated_at_ms AS updatedAtMs FROM threads WHERE id = ?" + ).get("c7-desktop-session"); + } finally { + db.close(); + } + let backupIds = []; + try { + backupIds = (await fs.readdir(defaultBackupRoot(codexHome), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + return { configText, rollout, turnContext, sqlite, backupIds }; + }, + async close() { + if (closed) return; + closed = true; + await fs.rm(fixtureRoot, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100 + }); + } + }; +} diff --git a/test-support/formal-release-assets.v1.json b/test-support/formal-release-assets.v1.json new file mode 100644 index 0000000..fc260e5 --- /dev/null +++ b/test-support/formal-release-assets.v1.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "repository": "Dailin521/codex-provider-sync", + "release": { + "id": 366935386, + "tag": "v0.4.1", + "tagObjectSha": "1971fd52c8b9f9e24835dd2b4719137e73b36d77", + "commit": "75f45756cf732333e7c52f45c8cd1b183291a029", + "name": "v0.4.1 - 修复 v0.4.0 同步性能回退", + "publishedAt": "2026-08-07T18:21:23Z", + "uploader": "github-actions[bot]", + "draft": false, + "prerelease": false, + "tagSigned": false + }, + "assets": { + "automationZip": { + "id": 505502580, + "name": "codex-provider-sync-v0.4.1-automation-win-x64.zip", + "size": 33692001, + "sha256": "6a8266a38567c56f9c8bb2662a84ac5a7b739c837a92cc4be0f4fdef76058616", + "url": "https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.1/codex-provider-sync-v0.4.1-automation-win-x64.zip" + }, + "automationChecksum": { + "id": 505502582, + "name": "codex-provider-sync-v0.4.1-automation-win-x64.zip.sha256", + "size": 117, + "sha256": "8203a06a475e2d859717cd2072b82a5671fe557eca504aaf38b0080b4d98c2f3", + "url": "https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.1/codex-provider-sync-v0.4.1-automation-win-x64.zip.sha256" + }, + "releaseChecksums": { + "id": 505502577, + "name": "checksums.txt", + "size": 312, + "sha256": "2a1b0426667024f235a49a4dd3a2bd27b3ffa0e3995b6f03468e05f57608cc98", + "url": "https://github.com/Dailin521/codex-provider-sync/releases/download/v0.4.1/checksums.txt" + } + }, + "archiveEntries": [ + { + "name": "automation-protocol-v0.4.schema.json", + "size": 9688, + "sha256": "005f1552f089a42a9950d1cf302bb75069e2c6657ac0e0bfdba32dc0451aba5c" + }, + { + "name": "CodexProviderSync.Automation.exe", + "size": 39029116, + "sha256": "e1ed5a75018833ecc80cd2da90b185c9573efcf4d07e13baef2c206fb6b70c64" + }, + { + "name": "README-AUTOMATION.zh-CN.md", + "size": 2581, + "sha256": "b8c8c799894b74032f3f8c371b26dafe94ddfa6e013efb7e3091392c448ecc56" + } + ] +} diff --git a/test-support/formal-release-backup-fixtures.mjs b/test-support/formal-release-backup-fixtures.mjs new file mode 100644 index 0000000..da0a2b0 --- /dev/null +++ b/test-support/formal-release-backup-fixtures.mjs @@ -0,0 +1,508 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { getStatus, runRestore } from "../src/public-api.js"; +import { defaultBackupRoot } from "../src/constants.js"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const manifestPath = path.join(repositoryRoot, "test-support", "formal-release-assets.v1.json"); +const reportPath = path.join( + repositoryRoot, + "artifacts", + "test-fixtures", + "historical-formal-release-backup-evidence.json" +); + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function redact(value, redactions = []) { + let output = String(value ?? ""); + for (const item of redactions) { + if (typeof item === "string" && item) output = output.replaceAll(item, ""); + } + return output.replace(/FORMAL_RELEASE_AUTH_CANARY_[a-f0-9]+/gi, ""); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repositoryRoot, + env: options.env ?? process.env, + encoding: "utf8", + windowsHide: true, + maxBuffer: 16 * 1024 * 1024, + timeout: options.timeoutMs ?? 10 * 60_000 + }); + assert.equal(result.error, undefined, result.error?.message); + const failureOutput = redact([result.stdout, result.stderr].filter(Boolean).join("\n"), options.redactions); + assert.equal(result.status, 0, failureOutput); + return result.stdout.trim(); +} + +function githubApiHeaders() { + return { + accept: "application/vnd.github+json", + "user-agent": "codex-provider-sync-formal-release-fixture", + "x-github-api-version": "2022-11-28" + }; +} + +function allowlistedWindowsEnvironment(overrides = {}) { + const environment = {}; + for (const name of [ + "ComSpec", + "NUMBER_OF_PROCESSORS", + "OS", + "Path", + "PATH", + "PATHEXT", + "PROCESSOR_ARCHITECTURE", + "PROCESSOR_IDENTIFIER", + "PROCESSOR_LEVEL", + "PROCESSOR_REVISION", + "SystemDrive", + "SystemRoot", + "WINDIR" + ]) { + if (typeof process.env[name] === "string" && process.env[name]) environment[name] = process.env[name]; + } + return { ...environment, ...overrides }; +} + +async function fetchJson(url) { + const response = await fetch(url, { + headers: githubApiHeaders(), + redirect: "error", + signal: AbortSignal.timeout(60_000) + }); + assert.equal(response.ok, true, `GitHub API returned HTTP ${response.status}.`); + return response.json(); +} + +async function verifyDownloadedAsset(asset, destination) { + const info = await fs.lstat(destination); + assert.equal(info.isFile(), true, `${asset.name} is not a regular file.`); + assert.equal(info.isSymbolicLink(), false, `${asset.name} is a link.`); + assert.equal(info.size, asset.size, `${asset.name} size changed.`); + const bytes = await fs.readFile(destination); + assert.equal(bytes.length, asset.size, `${asset.name} size changed.`); + assert.equal(sha256(bytes), asset.sha256, `${asset.name} digest changed.`); + return bytes; +} + +function verifyReleaseAsset(actual, expected) { + assert.equal(actual.id, expected.id, `${expected.name} asset id changed.`); + assert.equal(actual.name, expected.name, `${expected.name} asset name changed.`); + assert.equal(actual.size, expected.size, `${expected.name} asset size changed.`); + assert.equal(actual.digest, `sha256:${expected.sha256}`, `${expected.name} API digest changed.`); + assert.equal(actual.browser_download_url, expected.url, `${expected.name} download URL changed.`); + assert.equal(actual.state, "uploaded", `${expected.name} is not uploaded.`); +} + +async function verifyFormalRelease(manifest) { + const repositoryApi = `https://api.github.com/repos/${manifest.repository}`; + const release = await fetchJson(`${repositoryApi}/releases/tags/${manifest.release.tag}`); + assert.equal(release.id, manifest.release.id); + assert.equal(release.tag_name, manifest.release.tag); + assert.equal(release.name, manifest.release.name); + assert.equal(release.published_at, manifest.release.publishedAt); + assert.equal(release.draft, manifest.release.draft); + assert.equal(release.prerelease, manifest.release.prerelease); + assert.equal(release.author?.login, manifest.release.uploader); + for (const asset of Object.values(manifest.assets)) { + const actual = release.assets.find((candidate) => candidate.id === asset.id); + assert.ok(actual, `Release asset ${asset.name} is missing.`); + verifyReleaseAsset(actual, asset); + } + + const ref = await fetchJson(`${repositoryApi}/git/ref/tags/${manifest.release.tag}`); + assert.equal(ref.object?.type, "tag"); + assert.equal(ref.object?.sha, manifest.release.tagObjectSha); + const tag = await fetchJson(`${repositoryApi}/git/tags/${manifest.release.tagObjectSha}`); + assert.equal(tag.object?.type, "commit"); + assert.equal(tag.object?.sha, manifest.release.commit); + assert.equal(tag.verification?.verified, manifest.release.tagSigned); +} + +async function verifyArchive(extractRoot, entries) { + const actualNames = (await fs.readdir(extractRoot)).sort((left, right) => left.localeCompare(right)); + const expectedNames = entries.map((entry) => entry.name).sort((left, right) => left.localeCompare(right)); + assert.deepEqual(actualNames, expectedNames, "The formal Automation archive entry set changed."); + for (const entry of entries) { + const filePath = path.join(extractRoot, entry.name); + const info = await fs.lstat(filePath); + assert.equal(info.isFile(), true, `${entry.name} is not a regular file.`); + assert.equal(info.isSymbolicLink(), false, `${entry.name} is a link.`); + assert.equal(info.size, entry.size, `${entry.name} size changed.`); + assert.equal(sha256(await fs.readFile(filePath)), entry.sha256, `${entry.name} digest changed.`); + } +} + +function verifyArchiveListing(listing, entries) { + const actualNames = listing.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean); + const expectedNames = entries.map((entry) => entry.name); + assert.equal(new Set(actualNames).size, actualNames.length, "The formal Automation archive has duplicate entries."); + for (const entry of actualNames) { + const normalized = entry.replaceAll("\\", "/"); + assert.equal(path.posix.isAbsolute(normalized), false, `The formal Automation archive has an absolute entry: ${entry}`); + assert.equal(normalized.split("/").includes(".."), false, `The formal Automation archive escapes its root: ${entry}`); + assert.equal(normalized.includes(":"), false, `The formal Automation archive has a drive or ADS entry: ${entry}`); + } + assert.deepEqual( + [...actualNames].sort((left, right) => left.localeCompare(right)), + [...expectedNames].sort((left, right) => left.localeCompare(right)), + "The formal Automation archive entry set changed before extraction." + ); +} + +function isolatedAutomationEnvironment(root, fixture) { + return allowlistedWindowsEnvironment({ + HOME: path.join(root, "process-home"), + USERPROFILE: path.join(root, "process-home"), + APPDATA: path.join(root, "process-appdata"), + LOCALAPPDATA: path.join(root, "process-localappdata"), + TEMP: path.join(root, "process-temp"), + TMP: path.join(root, "process-temp"), + CODEX_HOME: fixture.codexHome, + CODEX_SQLITE_HOME: fixture.sqliteHome + }); +} + +function runAutomation(executable, args, environment, redactions) { + const stdout = run(executable, args, { env: environment, redactions }); + assert.equal(stdout.startsWith("{"), true, "Automation stdout is not one JSON document."); + assert.equal(stdout.endsWith("}"), true, "Automation stdout is not one JSON document."); + let response; + try { + response = JSON.parse(stdout); + } catch (error) { + assert.fail(`Automation stdout is not valid JSON: ${error?.message ?? "parse failed"}; output=${redact(stdout, redactions)}`); + } + const safeStdout = redact(stdout, redactions); + assert.equal(response.protocolVersion, "0.4"); + assert.equal(response.result, "success", safeStdout); + assert.equal(response.exitCode, 0, safeStdout); + return response; +} + +async function treeSha256(root, forbiddenMarker) { + const records = []; + async function visit(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const absolute = path.join(directory, entry.name); + const relative = path.relative(root, absolute).replaceAll("\\", "/"); + assert.equal(entry.isSymbolicLink(), false, `formal Release backup contains a link: ${relative}`); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) { + const bytes = await fs.readFile(absolute); + assert.equal( + bytes.includes(Buffer.from(forbiddenMarker, "utf8")), + false, + `formal Release backup copied the synthetic credential marker: ${relative}` + ); + records.push([relative, sha256(bytes)]); + } else { + assert.fail(`formal Release backup contains an unsupported entry: ${relative}`); + } + } + } + await visit(root); + return sha256(JSON.stringify(records)); +} + +function sqliteProvider(databasePath, nextProvider) { + const database = new DatabaseSync(databasePath); + try { + if (nextProvider) { + database.prepare("UPDATE threads SET model_provider = ? WHERE id = ?") + .run(nextProvider, "formal-release-synthetic-thread"); + } + return database.prepare("SELECT model_provider AS provider FROM threads WHERE id = ?") + .get("formal-release-synthetic-thread").provider; + } finally { + database.close(); + } +} + +async function createSyntheticHome(root) { + const codexHome = path.join(root, "codex-home"); + const sqliteHome = path.join(codexHome, "sqlite"); + const rolloutPath = path.join( + codexHome, + "sessions", + "2026", + "08", + "28", + "rollout-formal-release-synthetic.jsonl" + ); + const databasePath = path.join(sqliteHome, "state_5.sqlite"); + const configPath = path.join(codexHome, "config.toml"); + const authPath = path.join(codexHome, "auth.json"); + const config = 'model_provider = "openai"\n'; + const authCanary = `FORMAL_RELEASE_AUTH_CANARY_${crypto.randomBytes(16).toString("hex")}`; + const rollout = `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-28T00:00:00.000Z", + payload: { + id: "formal-release-synthetic-thread", + cwd: "C:\\synthetic\\formal-release", + model_provider: "apigather" + } + })}\n`; + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(sqliteHome, { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.writeFile(configPath, config, "utf8"); + await fs.writeFile(rolloutPath, rollout, "utf8"); + await fs.writeFile(authPath, `${JSON.stringify({ token: authCanary })}\n`, "utf8"); + const database = new DatabaseSync(databasePath); + try { + database.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 '', + model TEXT, + has_user_event INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL DEFAULT 0 + ); + `); + database.prepare(` + INSERT INTO threads ( + id, model_provider, cwd, archived, first_user_message, model, + has_user_event, updated_at, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "formal-release-synthetic-thread", + "apigather", + "C:\\synthetic\\formal-release", + 0, + "", + null, + 0, + 1787875200, + 1787875200000 + ); + } finally { + database.close(); + } + return { codexHome, sqliteHome, rolloutPath, databasePath, configPath, authPath, config, rollout, authCanary }; +} + +test("current Node restores a checksum-bound backup produced by the hosted v0.4.1 formal Release", async (t) => { + assert.equal(process.platform, "win32", "Formal Release binary evidence is a Windows CI fixture."); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.repository, "Dailin521/codex-provider-sync"); + await verifyFormalRelease(manifest); + + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-formal-release-backup-")); + t.after(() => fs.rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })); + const downloadRoot = path.join(root, "download"); + const extractRoot = path.join(root, "extract"); + await fs.mkdir(downloadRoot, { recursive: true }); + await fs.mkdir(extractRoot, { recursive: true }); + const zipPath = path.join(downloadRoot, manifest.assets.automationZip.name); + const checksumPath = path.join(downloadRoot, manifest.assets.automationChecksum.name); + const releaseChecksumsPath = path.join(downloadRoot, manifest.assets.releaseChecksums.name); + const toolEnvironment = allowlistedWindowsEnvironment({ + TEMP: path.join(root, "process-temp"), + TMP: path.join(root, "process-temp") + }); + await fs.mkdir(toolEnvironment.TEMP, { recursive: true }); + const downloadEnvironment = process.env.GITHUB_ACTIONS === "true" + ? { ...toolEnvironment } + : { ...process.env }; + if (process.env.GITHUB_ACTIONS === "true") { + downloadEnvironment.GH_CONFIG_DIR = path.join(root, "gh-config"); + await fs.mkdir(downloadEnvironment.GH_CONFIG_DIR, { recursive: true }); + } + if (typeof process.env.GITHUB_TOKEN === "string" && process.env.GITHUB_TOKEN) { + downloadEnvironment.GH_TOKEN = process.env.GITHUB_TOKEN; + } + run("gh", [ + "release", "download", manifest.release.tag, + "--repo", manifest.repository, + "--pattern", manifest.assets.automationZip.name, + "--pattern", manifest.assets.automationChecksum.name, + "--pattern", manifest.assets.releaseChecksums.name, + "--dir", downloadRoot + ], { env: downloadEnvironment, redactions: [root] }); + await Promise.all([ + verifyDownloadedAsset(manifest.assets.automationZip, zipPath), + verifyDownloadedAsset(manifest.assets.automationChecksum, checksumPath), + verifyDownloadedAsset(manifest.assets.releaseChecksums, releaseChecksumsPath) + ]); + const expectedChecksumLine = `${manifest.assets.automationZip.sha256} ${manifest.assets.automationZip.name}`; + assert.equal((await fs.readFile(checksumPath, "utf8")).trim(), expectedChecksumLine); + assert.equal( + (await fs.readFile(releaseChecksumsPath, "utf8")).split(/\r?\n/).includes(expectedChecksumLine), + true, + "checksums.txt does not bind the Automation ZIP." + ); + const archiveListing = run("tar", ["-tf", zipPath], { env: toolEnvironment, redactions: [root] }); + verifyArchiveListing(archiveListing, manifest.archiveEntries); + run("tar", ["-xf", zipPath, "-C", extractRoot], { env: toolEnvironment, redactions: [root] }); + await verifyArchive(extractRoot, manifest.archiveEntries); + + const executable = path.join(extractRoot, "CodexProviderSync.Automation.exe"); + const escapedExecutable = executable.replaceAll("'", "''"); + const signatureStatus = run("pwsh", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-AuthenticodeSignature -LiteralPath '${escapedExecutable}').Status.ToString()` + ], { env: toolEnvironment, redactions: [root] }); + assert.equal(signatureStatus, "NotSigned", "The frozen formal Release signature expectation changed."); + + const fixture = await createSyntheticHome(path.join(root, "fixture")); + const ledgerRoot = path.join(root, "plan-ledger"); + const planPath = path.join(root, "sync-plan.json"); + for (const directory of [ + path.join(root, "process-home"), + path.join(root, "process-appdata"), + path.join(root, "process-localappdata"), + path.join(root, "process-temp") + ]) await fs.mkdir(directory, { recursive: true }); + const environment = isolatedAutomationEnvironment(root, fixture); + const executableEntry = manifest.archiveEntries.find((entry) => entry.name === path.basename(executable)); + assert.ok(executableEntry, "The pinned archive manifest has no Automation executable."); + const redactions = [root, fixture.codexHome, fixture.sqliteHome, fixture.authCanary]; + await verifyDownloadedAsset(executableEntry, executable); + const describe = runAutomation(executable, ["describe"], environment, redactions); + assert.equal(describe.data?.explicitApplyRequired, true); + assert.equal(describe.data?.exactPlanDigestRequired, true); + + const commonArgs = [ + "--codex-home", fixture.codexHome, + "--sqlite-home", fixture.sqliteHome, + "--ledger-root", ledgerRoot, + "--provider", "openai" + ]; + const planned = runAutomation(executable, ["plan", "--operation", "sync", ...commonArgs], environment, redactions); + assert.match(planned.data?.digest ?? "", /^[a-f0-9]{64}$/); + await fs.writeFile(planPath, JSON.stringify(planned.data), { encoding: "utf8", flag: "wx" }); + await verifyDownloadedAsset(executableEntry, executable); + const applied = runAutomation(executable, [ + "sync", + ...commonArgs, + "--apply", + "--plan", planPath, + "--plan-digest", planned.data.digest + ], environment, redactions); + assert.equal(applied.data?.applied, true); + const producedBackupDir = applied.data?.result?.backupDir; + assert.equal(typeof producedBackupDir, "string"); + assert.equal(path.isAbsolute(producedBackupDir), true); + const managedRoot = await fs.realpath(defaultBackupRoot(fixture.codexHome)); + const backupDir = await fs.realpath(producedBackupDir); + assert.equal(path.dirname(backupDir).toLowerCase(), managedRoot.toLowerCase()); + const metadataBytes = await fs.readFile(path.join(backupDir, "metadata.json")); + const metadata = JSON.parse(metadataBytes); + assert.equal(metadata.version, 2); + assert.equal(metadata.namespace, "provider-sync"); + assert.deepEqual(metadata.sqliteDbFiles, ["state_5.sqlite"]); + assert.equal(metadata.changedSessionFiles, 1); + for (const relativePath of [ + "config.toml", + "session-meta-backup.json", + path.join("db", "sqlite-home", "state_5.sqlite") + ]) { + const info = await fs.lstat(path.join(backupDir, relativePath)); + assert.equal(info.isFile(), true, `Formal Release backup is missing ${relativePath}.`); + assert.equal(info.isSymbolicLink(), false, `Formal Release backup linked ${relativePath}.`); + } + assert.equal(sqliteProvider(fixture.databasePath), "openai"); + assert.equal((await fs.readFile(fixture.rolloutPath, "utf8")).includes('"model_provider":"openai"'), true); + assert.equal(await fs.readFile(fixture.authPath, "utf8"), `${JSON.stringify({ token: fixture.authCanary })}\n`); + const producedBackupTreeSha256 = await treeSha256(backupDir, fixture.authCanary); + + await fs.writeFile(fixture.configPath, 'model_provider = "relay"\n', "utf8"); + await fs.writeFile(fixture.rolloutPath, fixture.rollout.replace("apigather", "relay"), "utf8"); + assert.equal(sqliteProvider(fixture.databasePath, "relay"), "relay"); + await runRestore({ + codexHome: fixture.codexHome, + sqliteHome: fixture.sqliteHome, + backupDir, + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true + }); + assert.equal(await fs.readFile(fixture.configPath, "utf8"), fixture.config); + assert.equal(await fs.readFile(fixture.rolloutPath, "utf8"), fixture.rollout); + assert.equal(sqliteProvider(fixture.databasePath), "apigather"); + assert.equal(await fs.readFile(fixture.authPath, "utf8"), `${JSON.stringify({ token: fixture.authCanary })}\n`); + const status = await getStatus({ codexHome: fixture.codexHome, sqliteHome: fixture.sqliteHome }); + assert.equal(status.pendingTransactions.length, 0); + + const workflow = process.env.GITHUB_ACTIONS === "true" + ? { + repository: process.env.GITHUB_REPOSITORY, + runId: process.env.GITHUB_RUN_ID, + runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), + testedCommit: process.env.GITHUB_SHA + } + : null; + const report = `${JSON.stringify({ + schemaVersion: 1, + scope: "historical-formal-release-backup-evidence", + containsRealUserData: false, + syntheticOnly: true, + generatedAt: new Date().toISOString(), + workflow, + release: { + repository: manifest.repository, + releaseId: manifest.release.id, + tag: manifest.release.tag, + tagObjectSha: manifest.release.tagObjectSha, + commit: manifest.release.commit, + publishedAt: manifest.release.publishedAt, + tagSigned: manifest.release.tagSigned + }, + asset: { + id: manifest.assets.automationZip.id, + name: manifest.assets.automationZip.name, + size: manifest.assets.automationZip.size, + sha256: manifest.assets.automationZip.sha256, + checksumAssetSha256: manifest.assets.automationChecksum.sha256, + releaseChecksumsSha256: manifest.assets.releaseChecksums.sha256 + }, + binary: { + name: path.basename(executable), + size: manifest.archiveEntries.find((entry) => entry.name === path.basename(executable)).size, + sha256: manifest.archiveEntries.find((entry) => entry.name === path.basename(executable)).sha256, + authenticodeStatus: signatureStatus + }, + backup: { + metadataVersion: metadata.version, + metadataSha256: sha256(metadataBytes), + producedTreeSha256: producedBackupTreeSha256 + }, + verification: { + releaseApiPinned: true, + releaseChecksumPinned: true, + isolatedEnvironment: true, + authCanaryExcluded: true, + currentNodeRestoreVerified: true, + pendingRecoveryCount: status.pendingTransactions.length + }, + limitation: "The formal v0.4.1 Automation binary is unsigned; provenance is bound to the hosted GitHub Release, fixed repository manifest, and SHA-256. Synthetic input is not real-user Beta evidence." + }, null, 2)}\n`; + assert.equal(report.includes(root), false); + assert.equal(report.includes(fixture.authCanary), false); + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.writeFile(reportPath, report, "utf8"); +}); diff --git a/test-support/historical-tag-backup-fixtures.mjs b/test-support/historical-tag-backup-fixtures.mjs new file mode 100644 index 0000000..eaf6087 --- /dev/null +++ b/test-support/historical-tag-backup-fixtures.mjs @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { runRestore } from "../src/public-api.js"; +import { defaultBackupRoot } from "../src/constants.js"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const producerProject = path.join( + repositoryRoot, + "test-support", + "HistoricalBackupProducer", + "HistoricalBackupProducer.csproj" +); +const historicalTags = Object.freeze([ + { + tag: "v0.2.9", + expectedCommit: "1a2b290791a35d9cd29dba7c2fbacd324f1b9c72", + metadataVersion: 1 + }, + { + tag: "v0.4.1", + expectedCommit: "75f45756cf732333e7c52f45c8cd1b183291a029", + metadataVersion: 2 + } +]); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repositoryRoot, + env: { ...process.env, ...(options.env ?? {}) }, + encoding: "utf8", + windowsHide: true, + maxBuffer: 16 * 1024 * 1024, + timeout: options.timeoutMs ?? 10 * 60_000 + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); + return result.stdout.trim(); +} + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +async function treeSha256(root, forbiddenMarker) { + const records = []; + async function visit(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const absolute = path.join(directory, entry.name); + const relative = path.relative(root, absolute).replaceAll("\\", "/"); + assert.equal(entry.isSymbolicLink(), false, `historical backup contains a link: ${relative}`); + if (entry.isDirectory()) await visit(absolute); + else if (entry.isFile()) { + const bytes = await fs.readFile(absolute); + if (forbiddenMarker) { + assert.equal(bytes.includes(Buffer.from(forbiddenMarker, "utf8")), false, + `historical backup copied a forbidden synthetic credential marker: ${relative}`); + } + records.push([relative, sha256(bytes)]); + } + else assert.fail(`historical backup contains an unsupported entry: ${relative}`); + } + } + await visit(root); + return sha256(JSON.stringify(records)); +} + +function sqliteProvider(databasePath, nextProvider) { + const database = new DatabaseSync(databasePath); + try { + if (nextProvider) { + database.prepare("UPDATE threads SET model_provider = ? WHERE id = ?") + .run(nextProvider, "historical-synthetic-thread"); + } + return database.prepare("SELECT model_provider AS provider FROM threads WHERE id = ?") + .get("historical-synthetic-thread").provider; + } finally { + database.close(); + } +} + +async function createSyntheticHome(root) { + const codexHome = path.join(root, "codex-home"); + const rolloutPath = path.join( + codexHome, + "sessions", + "2026", + "08", + "28", + "rollout-historical-synthetic.jsonl" + ); + const databasePath = path.join(codexHome, "sqlite", "state_5.sqlite"); + const configPath = path.join(codexHome, "config.toml"); + const config = 'model_provider = "openai"\n'; + const authCanary = `HISTORICAL_AUTH_CANARY_${crypto.randomBytes(16).toString("hex")}`; + const rollout = `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-28T00:00:00.000Z", + payload: { + id: "historical-synthetic-thread", + cwd: "C:\\synthetic\\historical-tag", + model_provider: "apigather" + } + })}\n`; + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.dirname(databasePath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.writeFile(configPath, config, "utf8"); + await fs.writeFile(rolloutPath, rollout, "utf8"); + await fs.writeFile(path.join(codexHome, "auth.json"), `${JSON.stringify({ token: authCanary })}\n`, "utf8"); + const database = new DatabaseSync(databasePath); + try { + database.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 '', + model TEXT, + has_user_event INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL DEFAULT 0 + ); + `); + database.prepare(` + INSERT INTO threads ( + id, model_provider, cwd, archived, first_user_message, model, + has_user_event, updated_at, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "historical-synthetic-thread", + "apigather", + "C:\\synthetic\\historical-tag", + 0, + "", + null, + 0, + 1787875200, + 1787875200000 + ); + } finally { + database.close(); + } + return { codexHome, rolloutPath, databasePath, configPath, config, rollout, authCanary }; +} + +async function buildHistoricalCore(commit, tag, root) { + const archive = path.join(root, `${tag}.tar`); + const source = path.join(root, "source"); + const output = path.join(root, "core-output"); + await fs.mkdir(source, { recursive: true }); + run("git", ["archive", "--format=tar", `--output=${archive}`, commit]); + run("tar", ["-xf", archive, "-C", source]); + const project = path.join(source, "desktop", "CodexProviderSync.Core", "CodexProviderSync.Core.csproj"); + run("dotnet", [ + "publish", + project, + "--configuration", + "Release", + "--runtime", + "win-x64", + "--self-contained", + "false", + "--output", + output, + "--nologo", + "-p:CopyLocalLockFileAssemblies=true" + ]); + return path.join(output, "CodexProviderSync.Core.dll"); +} + +test("current Node restores backups produced by historical repository tags", async (t) => { + assert.equal(process.platform, "win32", "Historical tag producer evidence is a Windows CI fixture."); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cps-historical-tag-backups-")); + t.after(() => fs.rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })); + const producerOutput = path.join(root, "producer-output"); + run("dotnet", [ + "build", + producerProject, + "--configuration", + "Release", + "--output", + producerOutput, + "--nologo" + ]); + const producerDll = path.join(producerOutput, "HistoricalBackupProducer.dll"); + const evidence = []; + + for (const scenario of historicalTags) { + const tagCommit = run("git", ["rev-parse", "--verify", `${scenario.tag}^{commit}`]); + assert.equal(tagCommit, scenario.expectedCommit, `${scenario.tag} no longer resolves to its frozen commit`); + const scenarioRoot = path.join(root, scenario.tag); + const coreAssembly = await buildHistoricalCore( + scenario.expectedCommit, + scenario.tag, + path.join(scenarioRoot, "tag") + ); + const fixture = await createSyntheticHome(path.join(scenarioRoot, "fixture")); + const produced = JSON.parse(run("dotnet", [producerDll, coreAssembly, fixture.codexHome])); + assert.equal(produced.schemaVersion, 1); + assert.equal(path.isAbsolute(produced.backupDir), true); + const managedRoot = await fs.realpath(defaultBackupRoot(fixture.codexHome)); + const backupDir = await fs.realpath(produced.backupDir); + assert.equal( + process.platform === "win32" ? path.dirname(backupDir).toLowerCase() : path.dirname(backupDir), + process.platform === "win32" ? managedRoot.toLowerCase() : managedRoot, + `${scenario.tag} returned a backup outside the synthetic managed root` + ); + const backupInfo = await fs.lstat(produced.backupDir); + assert.equal(backupInfo.isDirectory(), true); + assert.equal(backupInfo.isSymbolicLink(), false); + const metadataBytes = await fs.readFile(path.join(produced.backupDir, "metadata.json")); + const metadata = JSON.parse(metadataBytes); + assert.equal(metadata.version, scenario.metadataVersion, scenario.tag); + assert.equal(sqliteProvider(fixture.databasePath), "openai", scenario.tag); + + await fs.writeFile(fixture.configPath, 'model_provider = "relay"\n', "utf8"); + await fs.writeFile(fixture.rolloutPath, fixture.rollout.replace("apigather", "relay"), "utf8"); + assert.equal(sqliteProvider(fixture.databasePath, "relay"), "relay", scenario.tag); + await runRestore({ + codexHome: fixture.codexHome, + backupDir: produced.backupDir, + restoreConfig: true, + restoreDatabase: true, + restoreSessions: true + }); + assert.equal(await fs.readFile(fixture.configPath, "utf8"), fixture.config, scenario.tag); + assert.equal(await fs.readFile(fixture.rolloutPath, "utf8"), fixture.rollout, scenario.tag); + assert.equal(sqliteProvider(fixture.databasePath), "apigather", scenario.tag); + + evidence.push({ + tag: scenario.tag, + tagCommit, + metadataVersion: scenario.metadataVersion, + coreAssemblyVersion: produced.coreAssemblyVersion, + metadataSha256: sha256(metadataBytes), + backupTreeSha256: await treeSha256(produced.backupDir, fixture.authCanary), + syntheticOnly: true, + currentNodeRestoreVerified: true, + provenance: "repository-tag-source", + limitation: "This is tag-produced evidence, not execution of a hosted formal Release binary." + }); + } + + const reportPath = path.join( + repositoryRoot, + "artifacts", + "test-fixtures", + "historical-tag-backup-evidence.json" + ); + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + const report = `${JSON.stringify({ + schemaVersion: 1, + containsRealUserData: false, + generatedAt: new Date().toISOString(), + runtime: { + node: process.version, + platform: process.platform, + arch: process.arch, + dotnet: run("dotnet", ["--version"]) + }, + evidence + }, null, 2)}\n`; + for (const scenario of historicalTags) { + const canaryPath = path.join(root, scenario.tag, "fixture", "codex-home", "auth.json"); + const canary = JSON.parse(await fs.readFile(canaryPath, "utf8")).token; + assert.equal(report.includes(canary), false); + } + await fs.writeFile(reportPath, report, "utf8"); +}); diff --git a/test-support/restore-v2-crash-host.mjs b/test-support/restore-v2-crash-host.mjs new file mode 100644 index 0000000..0ad6f8e --- /dev/null +++ b/test-support/restore-v2-crash-host.mjs @@ -0,0 +1,34 @@ +import { runRestore } from "../src/service.js"; + +const [codexHome, backupDir, crashPoint, ...flags] = process.argv.slice(2); +if (!codexHome || !backupDir || !crashPoint) { + process.stderr.write( + "usage: restore-v2-crash-host " + + "[--with-database] [--fail-at ]\n" + ); + process.exit(2); +} +const failureIndex = flags.indexOf("--fail-at"); +const failurePoint = failureIndex >= 0 ? flags[failureIndex + 1] : null; +if (failureIndex >= 0 && !failurePoint) { + process.stderr.write("--fail-at requires a fault point\n"); + process.exit(2); +} +let failureInjected = false; + +await runRestore({ + codexHome, + backupDir, + restoreDatabase: flags.includes("--with-database"), + faultInjector: ({ point }) => { + if (point === failurePoint && !failureInjected) { + failureInjected = true; + throw new Error(`forced Restore failure at ${point}`); + } + if (point === crashPoint) { + process.exit(86); + } + } +}); + +process.exit(0); diff --git a/test/c10-evidence-bundle.test.js b/test/c10-evidence-bundle.test.js new file mode 100644 index 0000000..ba7de61 --- /dev/null +++ b/test/c10-evidence-bundle.test.js @@ -0,0 +1,332 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { + assertEventBaseContained, + assertEvidenceSchema, + assertRedacted, + normalizeCandidateIndex, + normalizeFormalReleaseEvidence, + normalizeRequiredJobs, + REQUIRED_JOBS, + REQUIRED_TARGETS +} from "../scripts/write-c10-evidence-bundle.mjs"; + +const execFileAsync = promisify(execFile); + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(testDirectory, ".."); +const sha = "a".repeat(40); +const hash = "b".repeat(64); +const formalReleaseManifest = JSON.parse(fs.readFileSync( + path.join(rootDir, "test-support", "formal-release-assets.v1.json"), + "utf8" +)); +const expectedAssets = { + "windows-x64": ["CodexProviderSync-1.0.0-rc.42-windows-x64-portable.zip", "CodexProviderSync-1.0.0-rc.42-windows-x64-setup.exe"], + "macos-x64": ["CodexProviderSync-1.0.0-rc.42-macos-x64.dmg", "CodexProviderSync-1.0.0-rc.42-macos-x64.zip"], + "macos-arm64": ["CodexProviderSync-1.0.0-rc.42-macos-arm64.dmg", "CodexProviderSync-1.0.0-rc.42-macos-arm64.zip"], + "linux-x64": ["CodexProviderSync-1.0.0-rc.42-linux-x64.AppImage", "CodexProviderSync-1.0.0-rc.42-linux-x64.deb"] +}; + +function candidateIndex() { + return { + schemaVersion: 1, + scope: "ci-candidate-index", + releaseAuthorized: false, + version: "1.0.0-rc.42", + commit: sha, + targets: REQUIRED_TARGETS.map((target) => ({ + target, + version: "1.0.0-rc.42", + commit: sha, + buildId: `1.0.0-rc.42-${target}`, + lockfileSha256: hash, + toolVersions: { electron: "44.0.0" }, + fusePolicy: "c9-v1", + artifactAuditPolicy: { schemaVersion: 1, sha256: hash }, + manifestSha256: hash, + assets: expectedAssets[target].map((name, index) => ({ name, sizeBytes: 10 + index, sha256: hash })) + })) + }; +} + +function formalReleaseEvidence() { + const executable = formalReleaseManifest.archiveEntries.find( + (entry) => entry.name === "CodexProviderSync.Automation.exe" + ); + return { + schemaVersion: 1, + scope: "historical-formal-release-backup-evidence", + containsRealUserData: false, + syntheticOnly: true, + generatedAt: "2026-08-28T00:00:00.000Z", + workflow: { + repository: formalReleaseManifest.repository, + runId: "42", + runAttempt: 3, + testedCommit: sha + }, + release: { + repository: formalReleaseManifest.repository, + releaseId: formalReleaseManifest.release.id, + tag: formalReleaseManifest.release.tag, + tagObjectSha: formalReleaseManifest.release.tagObjectSha, + commit: formalReleaseManifest.release.commit, + publishedAt: formalReleaseManifest.release.publishedAt, + tagSigned: formalReleaseManifest.release.tagSigned + }, + asset: { + id: formalReleaseManifest.assets.automationZip.id, + name: formalReleaseManifest.assets.automationZip.name, + size: formalReleaseManifest.assets.automationZip.size, + sha256: formalReleaseManifest.assets.automationZip.sha256, + checksumAssetSha256: formalReleaseManifest.assets.automationChecksum.sha256, + releaseChecksumsSha256: formalReleaseManifest.assets.releaseChecksums.sha256 + }, + binary: { + name: executable.name, + size: executable.size, + sha256: executable.sha256, + authenticodeStatus: "NotSigned" + }, + backup: { + metadataVersion: 2, + metadataSha256: hash, + producedTreeSha256: hash + }, + verification: { + releaseApiPinned: true, + releaseChecksumPinned: true, + isolatedEnvironment: true, + authCanaryExcluded: true, + currentNodeRestoreVerified: true, + pendingRecoveryCount: 0 + }, + limitation: "The formal v0.4.1 Automation binary is unsigned; synthetic fixture only." + }; +} + +async function git(repository, ...args) { + const result = await execFileAsync("git", args, { cwd: repository, encoding: "utf8" }); + return String(result.stdout).trim().toLowerCase(); +} + +test("C10 required jobs must be exact and all successful", () => { + const jobs = Object.fromEntries(REQUIRED_JOBS.map((id) => [id, { result: "success" }])); + assert.deepEqual(normalizeRequiredJobs(jobs).map((entry) => entry.id), REQUIRED_JOBS); + jobs.test.result = "skipped"; + assert.throws(() => normalizeRequiredJobs(jobs), /did not succeed: test/); + jobs.test.result = "success"; + jobs.unreviewed = { result: "success" }; + assert.throws(() => normalizeRequiredJobs(jobs), /inventory changed/); +}); + +test("C10 candidate set binds four native targets to the tested commit", () => { + const normalized = normalizeCandidateIndex(candidateIndex(), sha); + assert.equal(normalized.commit, sha); + assert.deepEqual(normalized.targets.map((target) => target.target), REQUIRED_TARGETS); + + const wrongCommit = candidateIndex(); + wrongCommit.commit = "c".repeat(40); + assert.throws(() => normalizeCandidateIndex(wrongCommit, sha), /workflow-tested commit/); + + const incomplete = candidateIndex(); + incomplete.targets.pop(); + assert.throws(() => normalizeCandidateIndex(incomplete, sha)); + + const wrongAsset = candidateIndex(); + wrongAsset.targets[0].assets[0].name = "unexpected.zip"; + assert.throws(() => normalizeCandidateIndex(wrongAsset, sha), /asset names are invalid/); + + const toolDrift = candidateIndex(); + toolDrift.targets[0].toolVersions.electron = "44.0.1"; + assert.throws(() => normalizeCandidateIndex(toolDrift, sha), /tool-version set/); +}); + +test("C10 formal Release evidence binds the hosted binary and current workflow", () => { + const normalized = normalizeFormalReleaseEvidence(formalReleaseEvidence(), { + manifest: formalReleaseManifest, + evidenceForCommit: sha, + repository: formalReleaseManifest.repository, + runId: "42", + runAttempt: 3 + }); + assert.equal(normalized.release.tag, "v0.4.1"); + assert.equal(normalized.asset.sha256, formalReleaseManifest.assets.automationZip.sha256); + assert.equal(normalized.currentNodeRestoreVerified, true); + + const wrongRun = formalReleaseEvidence(); + wrongRun.workflow.runId = "41"; + assert.throws(() => normalizeFormalReleaseEvidence(wrongRun, { + manifest: formalReleaseManifest, + evidenceForCommit: sha, + repository: formalReleaseManifest.repository, + runId: "42", + runAttempt: 3 + })); + + const changedAsset = formalReleaseEvidence(); + changedAsset.asset.sha256 = hash; + assert.throws(() => normalizeFormalReleaseEvidence(changedAsset, { + manifest: formalReleaseManifest, + evidenceForCommit: sha, + repository: formalReleaseManifest.repository, + runId: "42", + runAttempt: 3 + })); + + for (const mutate of [ + (evidence) => { evidence.workflow.testedCommit = "c".repeat(40); }, + (evidence) => { evidence.binary.sha256 = hash; }, + (evidence) => { evidence.backup.metadataVersion = 1; }, + (evidence) => { evidence.verification.currentNodeRestoreVerified = false; }, + (evidence) => { evidence.verification.pendingRecoveryCount = 1; } + ]) { + const changed = formalReleaseEvidence(); + mutate(changed); + assert.throws(() => normalizeFormalReleaseEvidence(changed, { + manifest: formalReleaseManifest, + evidenceForCommit: sha, + repository: formalReleaseManifest.repository, + runId: "42", + runAttempt: 3 + })); + } +}); + +test("C10 redaction rejects protected keys, absolute paths, and credential markers", () => { + assert.doesNotThrow(() => assertRedacted({ evidencePath: "docs/migration/evidence/C9.md", result: "success" })); + assert.throws(() => assertRedacted({ token: "redacted" }), /forbidden key class/); + assert.throws(() => assertRedacted({ apiKey: "redacted" }), /forbidden key class/); + assert.throws(() => assertRedacted({ accessToken: "redacted" }), /forbidden key class/); + assert.throws(() => assertRedacted({ value: "C:\\Users\\person\\data" }), /absolute Windows path/); + assert.throws(() => assertRedacted({ value: "//server/share/private" }), /absolute network path/); + assert.throws(() => assertRedacted({ value: "prefix //server/share/private" }), /absolute network path/); + assert.doesNotThrow(() => assertRedacted({ value: "https://example.invalid/path" })); + assert.throws(() => assertRedacted({ value: "/home/person/data" }), /absolute POSIX path/); + assert.throws(() => assertRedacted({ value: "/srv/private/key.pem" }), /absolute POSIX path/); + assert.throws(() => assertRedacted({ value: "file:/etc/passwd" }), /absolute POSIX path/); + assert.throws(() => assertRedacted({ value: "prefix,/srv/private/key.pem" }), /absolute POSIX path/); + assert.throws(() => assertRedacted({ value: "note:'/var/lib/private'" }), /absolute POSIX path/); + assert.throws(() => assertRedacted({ value: "Bearer example" }), /credential marker/); + assert.throws(() => assertRedacted({ value: "https://example.invalid/?access_token=example" }), /credential marker/); + assert.throws(() => assertRedacted({ value: "sk-proj-1234567890abcdef" }), /credential marker/); + assert.throws(() => assertRedacted({ value: "AKIA1234567890ABCDEF" }), /credential marker/); +}); + +test("C10 JSON schema remains strict and release-false-only", () => { + const schema = JSON.parse(fs.readFileSync(path.join(rootDir, "docs", "migration", "evidence", "C10_EVIDENCE_BUNDLE.v1.schema.json"), "utf8")); + assert.equal(schema.additionalProperties, false); + assert.equal(schema.properties.scope.const, "vnext-c10-evidence"); + assert.equal(schema.properties.outcome.const, "ci-verified-not-release"); + assert.deepEqual( + schema.properties.checkpoints.prefixItems.map((entry) => entry.allOf[1].properties.id.const), + ["C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9"] + ); + assert.deepEqual( + schema.properties.ci.properties.requiredJobs.prefixItems.map((entry) => entry.allOf[1].properties.id.const), + REQUIRED_JOBS + ); + assert.deepEqual( + schema.properties.candidateSet.properties.targets.prefixItems.map((entry) => entry.allOf[1].properties.target.const), + REQUIRED_TARGETS + ); + assert.equal( + schema.properties.historicalFormalRelease.properties.artifactName.const, + "historical-formal-release-backup-evidence" + ); + assert.deepEqual( + Object.fromEntries(["tagObjectSha", "commit", "publishedAt"].map((name) => [ + name, + schema.properties.historicalFormalRelease.properties.release.properties[name].const + ])), + { + tagObjectSha: formalReleaseManifest.release.tagObjectSha, + commit: formalReleaseManifest.release.commit, + publishedAt: formalReleaseManifest.release.publishedAt + } + ); + assert.equal( + schema.properties.assertions.properties.historicalFormalReleaseBackupVerified.const, + true + ); + for (const property of Object.values(schema.properties.release.properties)) assert.equal(property.const, false); +}); + +test("C10 JSON schema compiles in strict mode and rejects an incomplete bundle", { + skip: Number(process.versions.node.split(".")[0]) < 24 +}, async () => { + await assert.rejects( + assertEvidenceSchema({}, rootDir), + /does not match its JSON Schema/ + ); +}); + +test("C10 event-base evidence follows the actual Git graph for push and pull-request commits", async () => { + const repository = await fsPromises.mkdtemp(path.join(os.tmpdir(), "c10-git-graph-")); + try { + await git(repository, "init"); + await git(repository, "config", "user.name", "C10 Test"); + await git(repository, "config", "user.email", "c10@example.invalid"); + await fsPromises.writeFile(path.join(repository, "evidence.txt"), "base\n", "utf8"); + await git(repository, "add", "evidence.txt"); + await git(repository, "commit", "-m", "base"); + const baseBranch = await git(repository, "branch", "--show-current"); + const eventBaseCommit = await git(repository, "rev-parse", "HEAD"); + + await git(repository, "switch", "-c", "source"); + await fsPromises.appendFile(path.join(repository, "evidence.txt"), "source\n", "utf8"); + await git(repository, "commit", "-am", "source"); + const sourceHeadCommit = await git(repository, "rev-parse", "HEAD"); + + await assertEventBaseContained(repository, { + event: "push", + evidenceForCommit: sourceHeadCommit, + sourceHeadCommit, + eventBaseCommit + }); + + await git(repository, "switch", baseBranch); + await git(repository, "merge", "--no-ff", "source", "-m", "merge source"); + const testedMergeCommit = await git(repository, "rev-parse", "HEAD"); + await assertEventBaseContained(repository, { + event: "pull_request", + evidenceForCommit: testedMergeCommit, + sourceHeadCommit, + eventBaseCommit + }); + await assert.rejects( + assertEventBaseContained(repository, { + event: "push", + evidenceForCommit: testedMergeCommit, + sourceHeadCommit, + eventBaseCommit + }), + /source head to the tested commit/ + ); + + await git(repository, "switch", "--orphan", "unrelated"); + await fsPromises.writeFile(path.join(repository, "unrelated.txt"), "unrelated\n", "utf8"); + await git(repository, "add", "-A"); + await git(repository, "commit", "-m", "unrelated root"); + const unrelatedHead = await git(repository, "rev-parse", "HEAD"); + await assert.rejects( + assertEventBaseContained(repository, { + event: "push", + evidenceForCommit: unrelatedHead, + sourceHeadCommit: unrelatedHead, + eventBaseCommit + }), + /does not contain the workflow event base commit/ + ); + } finally { + await fsPromises.rm(repository, { recursive: true, force: true }); + } +}); diff --git a/test/cli-json-contract.test.js b/test/cli-json-contract.test.js new file mode 100644 index 0000000..80c9e71 --- /dev/null +++ b/test/cli-json-contract.test.js @@ -0,0 +1,364 @@ +import { spawn } from "node:child_process"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { runCli } from "../src/cli.js"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cliPath = path.join(repositoryRoot, "src", "cli.js"); +const driverPath = path.join(repositoryRoot, "test-support", "cli-json-driver.js"); +const ENVELOPE_KEYS = [ + "schemaVersion", + "command", + "ok", + "outcome", + "result", + "warnings", + "error" +]; + +async function runNode(scriptPath, args, { scenario, env = {} } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [scriptPath, ...args], { + cwd: repositoryRoot, + env: { + ...process.env, + ...env, + ...(scenario ? { CODEX_PROVIDER_SYNC_CLI_SCENARIO: scenario } : {}) + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal, stdout, stderr })); + }); +} + +function parseSingleEnvelope(stdout) { + assert.ok(stdout.endsWith("\n"), "stdout must end with exactly one JSON line terminator"); + assert.equal((stdout.match(/\n/g) ?? []).length, 1, stdout); + const envelope = JSON.parse(stdout); + assert.deepEqual(Object.keys(envelope), ENVELOPE_KEYS); + assert.equal(envelope.schemaVersion, 1); + assert.equal(/\x1b\[/.test(stdout), false, "stdout must not contain ANSI sequences"); + return envelope; +} + +test("real CLI emits schema help JSON and keeps Human help unchanged", async () => { + const json = await runNode(cliPath, ["--json"]); + assert.equal(json.code, 0); + assert.equal(json.signal, null); + assert.equal(json.stderr, ""); + const envelope = parseSingleEnvelope(json.stdout); + assert.equal(envelope.command, "help"); + assert.equal(envelope.ok, true); + assert.match(envelope.result.text, /^codex-provider\n\nUsage:/); + + const human = await runNode(cliPath, ["--help"]); + assert.equal(human.code, 0); + assert.equal(human.stderr, ""); + assert.match(human.stdout, /^codex-provider\r?\n\r?\nUsage:/); + assert.doesNotMatch(human.stdout, /"schemaVersion"/); +}); + +test("real CLI JSON input failures use one stdout document and exit 2", async () => { + const cases = [ + ["unknown", "--json"], + ["sync", "--json", "--keep", "1.5"], + ["status", "--json", "--unknown"], + ["status", "--json", "--json"], + ["status", "--json=false"] + ]; + for (const args of cases) { + const result = await runNode(cliPath, args); + assert.equal(result.code, 2, args.join(" ")); + assert.equal(result.stderr, "", args.join(" ")); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, "INVALID_INPUT"); + if (args[0] === "unknown") assert.equal(envelope.command, "unknown"); + } + + const human = await runNode(cliPath, ["unknown"]); + assert.equal(human.code, 1); + assert.equal(human.stdout, ""); + assert.match(human.stderr, /^Unknown command: unknown\r?\n$/); +}); + +test("JSON input and storage errors never echo untrusted values", async () => { + const secretCommand = "fixtureSecretCommandValue"; + const unknownCommand = await runNode(cliPath, [secretCommand, "--json"]); + assert.equal(unknownCommand.code, 2); + assert.equal(parseSingleEnvelope(unknownCommand.stdout).command, "unknown"); + assert.doesNotMatch(`${unknownCommand.stdout}${unknownCommand.stderr}`, new RegExp(secretCommand)); + + const secretProto = "fixtureSecretProto"; + const prototypeFlag = await runNode(cliPath, ["status", "--json", `--__proto__=${secretProto}`]); + assert.equal(prototypeFlag.code, 2); + assert.equal(parseSingleEnvelope(prototypeFlag.stdout).error.code, "INVALID_INPUT"); + assert.doesNotMatch(`${prototypeFlag.stdout}${prototypeFlag.stderr}`, new RegExp(secretProto)); + + const secretKeep = "fixtureSecretToken123"; + const invalidKeep = await runNode(cliPath, ["sync", "--json", "--keep", secretKeep]); + assert.equal(invalidKeep.code, 2); + assert.equal(parseSingleEnvelope(invalidKeep.stdout).error.message, "The command input is invalid."); + assert.doesNotMatch(`${invalidKeep.stdout}${invalidKeep.stderr}`, new RegExp(secretKeep)); + + const missingHome = path.join(os.tmpdir(), "fixture-secret-home-value", "missing"); + const missing = await runNode(cliPath, ["status", "--json", "--codex-home", missingHome]); + assert.equal(missing.code, 1); + const envelope = parseSingleEnvelope(missing.stdout); + assert.equal(envelope.error.code, "INTERNAL_ERROR"); + assert.equal(envelope.error.message, "An internal error occurred."); + assert.doesNotMatch(`${missing.stdout}${missing.stderr}`, /fixture-secret-home-value/); +}); + +test("real CLI status JSON returns Core status without writing Human text", async () => { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-cli-status-")); + const root = path.join(parent, "codex=tail"); + try { + await fs.mkdir(path.join(root, "sessions"), { recursive: true }); + await fs.writeFile(path.join(root, "config.toml"), 'model_provider = "openai"\n', "utf8"); + const result = await runNode(cliPath, ["status", "--json", `--codex-home=${root}`]); + assert.equal(result.code, 0); + assert.equal(result.stderr, ""); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.command, "status"); + assert.equal(envelope.ok, true); + assert.equal(envelope.result.codexHome, path.resolve(root)); + assert.equal(envelope.result.currentProvider, "openai"); + assert.equal(envelope.error, null); + assert.doesNotMatch(result.stdout, /^Codex home:/); + } finally { + await fs.rm(parent, { recursive: true, force: true }); + } +}); + +test("every finite JSON command returns the same top-level envelope", async () => { + const cases = [ + ["status", ["status", "--json"]], + ["sync", ["sync", "--json"]], + ["switch", ["switch", "openai", "--model", "fixture-model", "--json"]], + ["prune-backups", ["prune-backups", "--keep", "0", "--json"]], + ["restore", ["restore", "C:\\fixture\\backup", "--no-config", "--json"]], + ["install-windows-launcher", ["install-windows-launcher", "--json"]] + ]; + + for (const [command, args] of cases) { + const result = await runNode(driverPath, args); + assert.equal(result.code, 0, command); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.command, command); + assert.equal(envelope.ok, true); + assert.equal(envelope.outcome, "completed"); + assert.equal(envelope.error, null); + } +}); + +test("JSON sync progress goes only to stderr and redacts the backup path", async () => { + const result = await runNode(driverPath, ["sync", "--json"], { scenario: "warning" }); + assert.equal(result.code, 0); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.command, "sync"); + assert.deepEqual(envelope.warnings, ["Automatic backup cleanup did not complete."]); + assert.match(result.stderr, /\[1\/6\] Scanning rollout files/); + assert.match(result.stderr, /Backup created in 25 ms/); + assert.doesNotMatch(result.stderr, /secret-backup-path/); + assert.doesNotMatch(result.stdout, /\[1\/6\]|secret-backup-path/); +}); + +test("JSON subprocess exit matrix covers partial and canonical failures", async () => { + const cases = [ + ["partial", 3, true, "partial", null], + ["error:SYNC_FAILED_ROLLED_BACK", 1, false, "failed_rolled_back", "SYNC_FAILED_ROLLED_BACK"], + ["error:INVALID_INPUT", 2, false, "failed", "INVALID_INPUT"], + ["error:PLAN_EXPIRED", 2, false, "stale", "PLAN_EXPIRED"], + ["error:STALE_STATE", 2, false, "stale", "STALE_STATE"], + ["error:RECOVERY_REQUIRED", 4, false, "recovery_required", "RECOVERY_REQUIRED"], + ["error:PENDING_TRANSACTION", 4, false, "recovery_required", "PENDING_TRANSACTION"], + ["error:OPERATION_BUSY", 5, false, "failed", "OPERATION_BUSY"], + ["error:LOCK_UNVERIFIABLE", 5, false, "failed", "LOCK_UNVERIFIABLE"], + ["error:SQLITE_BUSY", 5, false, "failed", "SQLITE_BUSY"], + ["error:OPERATION_CANCELLED", 130, false, "cancelled", "OPERATION_CANCELLED"] + ]; + + for (const [scenario, code, ok, outcome, errorCode] of cases) { + const result = await runNode(driverPath, ["sync", "--json"], { scenario }); + assert.equal(result.code, code, scenario); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.ok, ok, scenario); + assert.equal(envelope.outcome, outcome, scenario); + assert.equal(envelope.error?.code ?? null, errorCode, scenario); + } +}); + +test("serialization failure still emits one redacted INTERNAL_ERROR envelope", async () => { + const result = await runNode(driverPath, ["sync", "--json"], { scenario: "cyclic-result" }); + assert.equal(result.code, 1); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, "INTERNAL_ERROR"); + assert.equal(envelope.error.message, "An internal error occurred."); + assert.doesNotMatch(result.stdout, /circular|stack|cause/i); +}); + +test("typed Core errors cannot inject unapproved details into JSON", async () => { + const result = await runNode(driverPath, ["sync", "--json"], { + scenario: "error-secret-details" + }); + assert.equal(result.code, 5); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.error.code, "OPERATION_BUSY"); + assert.deepEqual(envelope.error.details, { busyScope: "codex-home" }); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /fixture-secret-token|fixture secret body/); +}); + +test("watch and web reject JSON before creating long-running state", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-cli-json-")); + try { + for (const command of ["watch", "web"]) { + const result = await runNode(cliPath, [command, "--json", "--codex-home", root]); + assert.equal(result.code, 2, command); + assert.equal(result.stderr, "", command); + const envelope = parseSingleEnvelope(result.stdout); + assert.equal(envelope.error.code, "INVALID_INPUT"); + assert.equal(envelope.error.message, "The command input is invalid."); + } + assert.deepEqual(await fs.readdir(root), []); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("watch and web JSON validation does not load Core or start Web", async () => { + for (const args of [ + ["watch", "--once", "--json"], + ["web", "--no-open", "--json"] + ]) { + let coreLoads = 0; + let webStarts = 0; + const stdoutChunks = []; + const exitCode = await runCli(args, { + stdout: { + write(document) { + stdoutChunks.push(document); + } + }, + stderr: { write() {} }, + loadCoreImpl: async () => { + coreLoads += 1; + return {}; + }, + startWebUiImpl: async () => { + webStarts += 1; + } + }); + + assert.equal(exitCode, 2, args[0]); + assert.equal(coreLoads, 0, args[0]); + assert.equal(webStarts, 0, args[0]); + assert.equal(parseSingleEnvelope(stdoutChunks.join("")).error.code, "INVALID_INPUT"); + } +}); + +test("JSON terminal writer attempts stdout at most once when the pipe closes", async () => { + let stdoutWrites = 0; + let stderrWrites = 0; + + const exitCode = await runCli(["--json"], { + stdout: { + write() { + stdoutWrites += 1; + const error = new Error("pipe closed"); + error.code = "EPIPE"; + throw error; + } + }, + stderr: { + write() { + stderrWrites += 1; + } + } + }); + + assert.equal(exitCode, 1); + assert.equal(stdoutWrites, 1); + assert.equal(stderrWrites, 0); +}); + +test("JSON terminal writer handles asynchronous EPIPE without a second write", async () => { + const stdout = new EventEmitter(); + let stdoutWrites = 0; + let stderrWrites = 0; + stdout.write = (_document, callback) => { + stdoutWrites += 1; + process.nextTick(() => { + const error = new Error("pipe closed asynchronously"); + error.code = "EPIPE"; + stdout.emit("error", error); + callback(error); + }); + return true; + }; + + const exitCode = await runCli(["--json"], { + stdout, + stderr: { + write() { + stderrWrites += 1; + } + } + }); + + assert.equal(exitCode, 1); + assert.equal(stdoutWrites, 1); + assert.equal(stderrWrites, 0); +}); + +test("JSON progress stream failures cannot change the operation result", async () => { + const stdoutChunks = []; + const stderr = new EventEmitter(); + let stderrWrites = 0; + stderr.write = (_document, callback) => { + stderrWrites += 1; + const error = new Error("diagnostic pipe closed"); + error.code = "EPIPE"; + stderr.emit("error", error); + callback?.(error); + return false; + }; + + const exitCode = await runCli(["sync", "--json"], { + stdout: { + write(document) { + stdoutChunks.push(document); + } + }, + stderr, + loadCoreImpl: async () => ({ + readConfigText: async () => "", + readRootModelFromConfigText: () => null, + runSync: async ({ onProgress }) => { + onProgress({ stage: "scan_rollout_files", status: "start" }); + return { targetProvider: "openai", skippedLockedRolloutFiles: [] }; + } + }) + }); + + assert.equal(exitCode, 0); + assert.equal(stderrWrites, 1); + const envelope = parseSingleEnvelope(stdoutChunks.join("")); + assert.equal(envelope.ok, true); + assert.equal(envelope.outcome, "completed"); +}); diff --git a/test/cli-json.test.js b/test/cli-json.test.js new file mode 100644 index 0000000..57fc539 --- /dev/null +++ b/test/cli-json.test.js @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CLI_JSON_SCHEMA_VERSION, + cliJsonExitCode, + collectCliWarnings, + createCliFailureEnvelope, + createCliSuccessEnvelope, + inferCliSuccessOutcome, + normalizeCliErrorDto +} from "../src/cli-json.js"; + +const ENVELOPE_KEYS = [ + "schemaVersion", + "command", + "ok", + "outcome", + "result", + "warnings", + "error" +]; + +function dto(code, overrides = {}) { + return { + code, + message: `${code} fixture`, + severity: "error", + retryable: true, + recoveryRequired: false, + ...overrides + }; +} + +test("CLI JSON success envelope has the exact schema v1 top-level shape", () => { + const result = { + targetProvider: "openai", + skippedLockedRolloutFiles: ["locked.jsonl"], + encryptedContentWarning: "encrypted warning", + autoPruneWarning: "prune warning" + }; + const envelope = createCliSuccessEnvelope("sync", result); + + assert.equal(CLI_JSON_SCHEMA_VERSION, 1); + assert.deepEqual(Object.keys(envelope), ENVELOPE_KEYS); + assert.equal(envelope.schemaVersion, 1); + assert.equal(envelope.ok, true); + assert.equal(envelope.outcome, "partial"); + assert.notEqual(envelope.result, result); + assert.equal( + envelope.result.encryptedContentWarning, + "Existing encrypted content may not be usable with the target provider." + ); + assert.equal(envelope.result.autoPruneWarning, "Automatic backup cleanup did not complete."); + assert.deepEqual(envelope.warnings, [ + "Existing encrypted content may not be usable with the target provider.", + "Automatic backup cleanup did not complete." + ]); + assert.equal(envelope.error, null); + assert.equal(cliJsonExitCode(envelope), 3); +}); + +test("CLI JSON success outcome and warning inference is deterministic", () => { + assert.equal(inferCliSuccessOutcome({ noop: true }), "noop"); + assert.equal(inferCliSuccessOutcome({}), "completed"); + assert.deepEqual(collectCliWarnings({ + warnings: ["one", "one", null], + backupInventoryWarning: "two", + modelSync: { warning: "three" } + }), [ + "The operation completed with a warning.", + "Backup inventory refresh did not complete.", + "The selected provider has no default model; the root model was not changed." + ]); + assert.equal(cliJsonExitCode(createCliSuccessEnvelope("status", {})), 0); +}); + +test("CLI JSON failure exit codes follow the frozen matrix", () => { + const cases = [ + ["INVALID_INPUT", 2, "failed"], + ["PLAN_EXPIRED", 2, "stale"], + ["PLAN_STALE", 2, "stale"], + ["STALE_STATE", 2, "stale"], + ["PROFILE_CHANGED", 2, "stale"], + ["STORAGE_CHANGED", 2, "stale"], + ["SYNC_FAILED_ROLLED_BACK", 1, "failed_rolled_back"], + ["RECOVERY_REQUIRED", 4, "recovery_required"], + ["PENDING_TRANSACTION", 4, "recovery_required"], + ["OPERATION_BUSY", 5, "failed"], + ["LOCK_UNVERIFIABLE", 5, "failed"], + ["SQLITE_BUSY", 5, "failed"], + ["OPERATION_CANCELLED", 130, "cancelled"], + ["PERMISSION_DENIED", 1, "failed"], + ["INTERNAL_ERROR", 1, "failed"] + ]; + + for (const [code, exitCode, outcome] of cases) { + const overrides = code === "RECOVERY_REQUIRED" || code === "PENDING_TRANSACTION" + ? { recoveryRequired: true } + : {}; + const envelope = createCliFailureEnvelope("sync", dto(code, overrides)); + assert.deepEqual(Object.keys(envelope), ENVELOPE_KEYS, code); + assert.equal(envelope.ok, false, code); + assert.equal(envelope.outcome, outcome, code); + assert.equal(envelope.result, null, code); + assert.deepEqual(envelope.warnings, [], code); + assert.equal(cliJsonExitCode(envelope), exitCode, code); + } +}); + +test("CLI JSON hides arbitrary internal error text and optional properties", () => { + const normalized = normalizeCliErrorDto({ + code: "INTERNAL_ERROR", + message: "authToken=secret and message body", + severity: "fatal", + retryable: false, + recoveryRequired: false, + details: { authToken: "secret" }, + operationId: "secret-operation" + }); + assert.deepEqual(normalized, { + code: "INTERNAL_ERROR", + message: "An internal error occurred.", + severity: "fatal", + retryable: false, + recoveryRequired: false + }); + assert.doesNotMatch(JSON.stringify(normalized), /secret|message body/); +}); + +test("CLI JSON rejects inherited error-code property names", () => { + for (const code of ["__proto__", "constructor", "toString"]) { + const normalized = normalizeCliErrorDto(dto(code)); + assert.deepEqual(normalized, { + code: "INTERNAL_ERROR", + message: "An internal error occurred.", + severity: "fatal", + retryable: false, + recoveryRequired: false + }, code); + } +}); + +test("CLI JSON allowlists typed error details and result fields", () => { + const envelope = createCliFailureEnvelope("sync", dto("OPERATION_BUSY", { + message: "token=secret-token", + operationId: "secret-operation", + suggestedAction: "Use token=secret-token", + details: { + busyScope: "codex-home", + causeCode: "SQLITE_BUSY", + authToken: "secret-token", + messageBody: "secret body", + reason: "secret-reason", + revision: "secret-revision" + } + })); + assert.equal(envelope.error.message, "Another write operation is using the protected resource."); + assert.deepEqual(envelope.error.details, { + busyScope: "codex-home", + causeCode: "SQLITE_BUSY" + }); + assert.equal(Object.hasOwn(envelope.error, "operationId"), false); + assert.equal(Object.hasOwn(envelope.error, "suggestedAction"), false); + + const success = createCliSuccessEnvelope("sync", { + changedSessionFiles: 1, + authToken: "secret-token", + apiKey: "secret-api-key", + accessKey: "secret-access-key", + cookie: "secret-cookie", + prompt: "secret-prompt", + message: "secret-message", + text: "secret-text", + encrypted_content: "secret-content", + nested: { + messageBody: "secret body", + cause: { stack: "secret stack" } + }, + warnings: ["C:\\private\\path\\token.txt"] + }); + assert.deepEqual(success.result, { + changedSessionFiles: 1, + warnings: ["The operation completed with a warning."] + }); + assert.doesNotMatch( + JSON.stringify(success), + /secret-token|secret-api|secret-access|secret-cookie|secret-prompt|secret-message|secret-text|secret-content|secret body|secret stack|private/ + ); +}); + +test("CLI JSON malformed DTOs fail closed without invoking hostile getters", () => { + const cyclic = {}; + cyclic.self = cyclic; + assert.equal(createCliFailureEnvelope("sync", dto("OPERATION_BUSY", { + details: { busyScope: "codex-home", reason: cyclic } + })).error.code, "INTERNAL_ERROR"); + + const hostile = new Proxy({}, { + get() { + throw new Error("secret getter text"); + }, + ownKeys() { + throw new Error("secret getter text"); + } + }); + const normalized = normalizeCliErrorDto(hostile); + assert.equal(normalized.code, "INTERNAL_ERROR"); + assert.doesNotMatch(JSON.stringify(normalized), /secret getter/); +}); + +test("CLI JSON command result schemas expose only audited fields", () => { + const status = createCliSuccessEnvelope("status", { + currentProvider: "openai", + sqliteAccess: { + supported: false, + reason: "windows-wsl-unc", + message: "secret diagnostic text" + }, + rolloutCounts: { + sessions: { openai: 2 }, + archived_sessions: { openai: 1 } + }, + prompt: "secret prompt", + message: "secret message" + }); + assert.deepEqual(status.result, { + sqliteAccess: { supported: false, reason: "windows-wsl-unc" }, + currentProvider: "openai", + rolloutCounts: { + sessions: { openai: 2 }, + archived_sessions: { openai: 1 } + } + }); + + const restore = createCliSuccessEnvelope("restore", { + version: 2, + namespace: "provider-sync", + codexHome: "C:\\fixture\\.codex", + targetProvider: "openai", + sqliteDbFiles: ["state_5.sqlite"], + changedSessionFiles: 2, + apiKey: "secret api key", + metadata: { prompt: "secret prompt" } + }); + assert.deepEqual(restore.result, { + version: 2, + namespace: "provider-sync", + codexHome: "C:\\fixture\\.codex", + targetProvider: "openai", + sqliteDbFiles: ["state_5.sqlite"], + changedSessionFiles: 2 + }); + assert.doesNotMatch(JSON.stringify({ status, restore }), /secret/); + assert.throws(() => createCliSuccessEnvelope("unknown-command", {}), /Unsupported CLI JSON/); +}); diff --git a/test/core-error.test.js b/test/core-error.test.js new file mode 100644 index 0000000..7e74dd3 --- /dev/null +++ b/test/core-error.test.js @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CORE_ERROR_CODES, CoreError, toCoreErrorDto } from "../src/core-error.js"; + +test("CoreError exposes the canonical serializable DTO shape", () => { + const error = new CoreError("STALE_STATE", "The prepared state changed.", { + operationId: "operation-1", + details: { reason: "rollout", revision: 4 }, + suggestedAction: "Prepare the operation again." + }); + + assert.deepEqual(error.toDto(), { + code: "STALE_STATE", + message: "The prepared state changed.", + severity: "warning", + retryable: true, + recoveryRequired: false, + operationId: "operation-1", + details: { reason: "rollout", revision: 4 }, + suggestedAction: "Prepare the operation again." + }); + assert.doesNotThrow(() => JSON.stringify(error.toDto())); + assert.equal(Object.hasOwn(error.toDto(), "stack"), false); + assert.equal(Object.hasOwn(error.toDto(), "cause"), false); +}); + +test("the C1 canonical code set includes expiry, stale state, and unverifiable locks", () => { + assert.ok(CORE_ERROR_CODES.includes("PLAN_EXPIRED")); + assert.ok(CORE_ERROR_CODES.includes("STALE_STATE")); + assert.ok(CORE_ERROR_CODES.includes("LOCK_UNVERIFIABLE")); + assert.ok(Object.isFrozen(CORE_ERROR_CODES)); +}); + +test("scoped lock errors require an explicit trusted resource scope", () => { + assert.throws( + () => new CoreError("OPERATION_BUSY", "busy"), + /details\.busyScope/ + ); + assert.throws( + () => new CoreError("OPERATION_BUSY", "busy", { details: { busyScope: "unknown" } }), + /details\.busyScope/ + ); + assert.throws( + () => new CoreError("LOCK_UNVERIFIABLE", "uncertain"), + /details\.lockScope/ + ); + + assert.equal( + new CoreError("OPERATION_BUSY", "busy", { details: { busyScope: "state-db" } }).toDto().details.busyScope, + "state-db" + ); + assert.equal( + new CoreError("LOCK_UNVERIFIABLE", "uncertain", { details: { lockScope: "codex-home" } }).toDto().details.lockScope, + "codex-home" + ); +}); + +test("unknown exceptions become INTERNAL_ERROR without copying arbitrary properties", () => { + const error = new Error("unexpected failure"); + error.code = "EUNEXPECTED"; + error.authToken = "must-not-be-copied"; + error.details = { messageBody: "must-not-be-copied" }; + + const dto = toCoreErrorDto(error); + assert.deepEqual(dto, { + code: "INTERNAL_ERROR", + message: "unexpected failure", + severity: "fatal", + retryable: false, + recoveryRequired: false, + details: { causeCode: "EUNEXPECTED" } + }); + assert.doesNotMatch(JSON.stringify(dto), /must-not-be-copied/); +}); + +test("canonical-looking plain errors cannot inject transport details", () => { + const error = new Error("recovery failed"); + error.code = "RECOVERY_REQUIRED"; + error.operationId = "untrusted-operation"; + error.recoveryRequired = false; + error.details = { + authToken: "must-not-be-copied", + messageBody: "must-not-be-copied" + }; + + const dto = toCoreErrorDto(error); + assert.deepEqual(dto, { + code: "RECOVERY_REQUIRED", + message: "recovery failed", + severity: "error", + retryable: true, + recoveryRequired: true + }); + assert.doesNotMatch(JSON.stringify(dto), /must-not-be-copied|untrusted-operation/); +}); + +test("CoreError recursively freezes its normalized details", () => { + const input = { nested: { values: ["safe"] } }; + const error = new CoreError("STALE_STATE", "state changed", { details: input }); + + assert.notEqual(error.details, input); + assert.ok(Object.isFrozen(error.details)); + assert.ok(Object.isFrozen(error.details.nested)); + assert.ok(Object.isFrozen(error.details.nested.values)); + assert.throws(() => error.details.nested.values.push("changed"), TypeError); + assert.deepEqual(error.toDto().details, { nested: { values: ["safe"] } }); +}); + +test("typed legacy cancellation and permission errors map without parsing messages", () => { + const cancelled = new Error("localized cancellation text"); + cancelled.name = "AbortError"; + cancelled.code = "ABORT_ERR"; + assert.equal(toCoreErrorDto(cancelled).code, "OPERATION_CANCELLED"); + + const permission = new Error("localized permission text"); + permission.code = "EACCES"; + const dto = toCoreErrorDto(permission); + assert.equal(dto.code, "PERMISSION_DENIED"); + assert.deepEqual(dto.details, { causeCode: "EACCES" }); +}); + +test("malformed structured errors fail closed as INTERNAL_ERROR during DTO conversion", () => { + const malformed = new Error("busy without a trusted scope"); + malformed.code = "OPERATION_BUSY"; + + const dto = toCoreErrorDto(malformed); + assert.equal(dto.code, "INTERNAL_ERROR"); + assert.deepEqual(dto.details, { causeCode: "OPERATION_BUSY" }); +}); diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js new file mode 100644 index 0000000..6f7274a --- /dev/null +++ b/test/diagnostics.test.js @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { getDiagnostics } from "../src/diagnostics.js"; +import { openDatabase } from "../src/sqlite.js"; + +test("getDiagnostics reports bounded safety metadata without credentials or message bodies", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-diagnostics-")); + const codexHome = path.join(root, ".codex"); + const rolloutPath = path.join(codexHome, "sessions", "2026", "08", "25", "rollout.jsonl"); + const secret = "credential-and-message-secret-fixture"; + try { + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.join(codexHome, "sqlite"), { recursive: true }); + await fs.writeFile(path.join(codexHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + await fs.writeFile(path.join(codexHome, "auth.json"), JSON.stringify({ token: secret }), "utf8"); + const database = await openDatabase(path.join(codexHome, "sqlite", "state_5.sqlite")); + try { + database.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 '', + model TEXT + ) + `); + database.prepare("INSERT INTO threads (id, model_provider, cwd, archived, first_user_message) VALUES (?, ?, ?, ?, ?)") + .run("thread-a", "openai", "C:\\AITemp", 0, "redacted"); + } finally { + database.close(); + } + await fs.writeFile(rolloutPath, [ + JSON.stringify({ + timestamp: "2026-08-25T00:00:00.000Z", + type: "session_meta", + payload: { id: "thread-a", model_provider: "openai", cwd: "C:\\AITemp" } + }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: secret } }) + ].join("\n") + "\n", "utf8"); + + const diagnostics = await getDiagnostics({ codexHome }); + const serialized = JSON.stringify(diagnostics); + assert.equal(diagnostics.schemaVersion, 1); + assert.equal(diagnostics.safety.lockedRolloutCount, 0); + assert.doesNotMatch(serialized, new RegExp(secret)); + assert.doesNotMatch(serialized, /auth\.json|token/i); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/entrypoint-import-contract.test.js b/test/entrypoint-import-contract.test.js new file mode 100644 index 0000000..fd1fd41 --- /dev/null +++ b/test/entrypoint-import-contract.test.js @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const coreInternalModules = [ + "backup.js", + "config-file.js", + "core-error.js", + "history.js", + "service.js", + "sqlite-state.js", + "storage-layout.js" +]; + +async function readRepositoryFile(relativePath) { + return fs.readFile(path.join(repositoryRoot, relativePath), "utf8"); +} + +function assertNoDeepCoreImports(source, filePath) { + for (const internalModule of coreInternalModules) { + assert.doesNotMatch( + source, + new RegExp("(?:from|import)\\s*\\(?[\\\"'][^\\\"']*" + internalModule.replace(".", "\\.") + "[\\\"']"), + filePath + " must use src/public-api.js instead of " + internalModule + ); + } +} + +async function collectDesktopEntryPoints(relativeDirectory) { + const absoluteDirectory = path.join(repositoryRoot, relativeDirectory); + let entries; + try { + entries = await fs.readdir(absoluteDirectory, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + + const files = []; + for (const entry of entries) { + const relativePath = path.join(relativeDirectory, entry.name); + if (entry.isDirectory()) { + files.push(...await collectDesktopEntryPoints(relativePath)); + } else if (/\.(?:[cm]?js|tsx?)$/.test(entry.name)) { + files.push(relativePath); + } + } + return files; +} + +test("CLI and Web entry points import Core behavior only through the public API", async () => { + for (const entryPoint of ["src/cli.js", "src/web-server.js"]) { + const source = await readRepositoryFile(entryPoint); + assert.match(source, /["']\.\/public-api\.js["']/); + assertNoDeepCoreImports(source, entryPoint); + } +}); + +test("watch lazy-loads sync through the public API", async () => { + const source = await readRepositoryFile("src/watch.js"); + assert.match(source, /import\(["']\.\/public-api\.js["']\)/); + assert.doesNotMatch(source, /import\(["']\.\/service\.js["']\)/); +}); + +test("present and future desktop entry points do not deep-import Core internals", async () => { + const desktopFiles = await collectDesktopEntryPoints("apps/desktop"); + for (const entryPoint of desktopFiles) { + assertNoDeepCoreImports(await readRepositoryFile(entryPoint), entryPoint); + } +}); diff --git a/test/history-requests.test.js b/test/history-requests.test.js deleted file mode 100644 index dcf1713..0000000 --- a/test/history-requests.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createLatestRequestGate, scheduleDebounced } from "../web/src/history-requests.js"; - -test("starting a newer History request aborts the older request and rejects its response", () => { - const gate = createLatestRequestGate(); - const first = gate.begin(); - const second = gate.begin(); - - assert.equal(first.signal.aborted, true); - assert.equal(second.signal.aborted, false); - assert.equal(gate.isLatest(first.sequence), false); - assert.equal(gate.isLatest(second.sequence), true); -}); - -test("History query debounce uses a 300ms delay and can be cancelled", () => { - const calls = []; - const timers = { - setTimeout(callback, delay) { calls.push(["set", delay, callback]); return 17; }, - clearTimeout(id) { calls.push(["clear", id]); } - }; - const cancel = scheduleDebounced(() => {}, 300, timers); - cancel(); - assert.deepEqual(calls.slice(0, 2).map((call) => call.slice(0, 2)), [["set", 300], ["clear", 17]]); -}); diff --git a/test/history.test.js b/test/history.test.js index 85ae837..7ef7b24 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -6,6 +6,38 @@ import test from "node:test"; import { getHistorySession, listHistory } from "../src/history.js"; +test("history public inputs fail with typed invalid-input errors", async () => { + await assert.rejects( + () => listHistory("unused", { page: 0 }), + (error) => error?.code === "INVALID_INPUT" && /page must/.test(error.message) + ); + await assert.rejects( + () => listHistory("unused", { archived: "unknown" }), + (error) => error?.code === "INVALID_INPUT" && /archived must/.test(error.message) + ); + await assert.rejects( + () => getHistorySession("unused", ""), + (error) => error?.code === "INVALID_INPUT" && /sessionId is required/.test(error.message) + ); +}); + +test("history treats a missing Codex Home as an empty page", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-missing-")); + const missing = path.join(root, "not-created"); + try { + const result = await listHistory(missing, { page: 1, pageSize: 50 }); + assert.deepEqual(result, { + page: 1, + pageSize: 50, + total: 0, + hasNextPage: false, + sessions: [] + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + async function fixture() { const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-")); const file = path.join(home, "sessions", "2026", "08", "04", "rollout-one.jsonl"); @@ -22,6 +54,28 @@ async function fixture() { return { home, file }; } +function trackHistoryReadBytes() { + const originalOpen = fs.open; + const bytesByPath = new Map(); + fs.open = async (...args) => { + const handle = await originalOpen(...args); + const filePath = path.resolve(String(args[0])); + const originalRead = handle.read.bind(handle); + handle.read = async (...readArgs) => { + const result = await originalRead(...readArgs); + bytesByPath.set(filePath, (bytesByPath.get(filePath) ?? 0) + result.bytesRead); + return result; + }; + return handle; + }; + return { + bytesByPath, + restore() { + fs.open = originalOpen; + } + }; +} + test("history lists readable sessions and filters message text", async () => { const { home } = await fixture(); try { @@ -29,6 +83,88 @@ test("history lists readable sessions and filters message text", async () => { assert.equal(result.total, 1); assert.equal(result.sessions[0].id, "thread-one"); assert.equal(result.sessions[0].messageCount, 3); + assert.equal(result.sessions[0].messageCountKnown, true); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history list without a query reads only bounded rollout metadata", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-metadata-")); + const file = path.join(home, "sessions", "rollout-large-body.jsonl"); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, [ + JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-04T08:00:00.000Z", + payload: { id: "metadata-only", title: "Metadata only", cwd: "/work/metadata", model_provider: "openai" } + }), + JSON.stringify({ + type: "event_msg", + payload: { type: "assistant_message", message: `private-body-${"x".repeat(2 * 1024 * 1024)}` } + }) + ].join("\n") + "\n", "utf8"); + const tracker = trackHistoryReadBytes(); + try { + const result = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(result.total, 1); + assert.equal(result.sessions[0].id, "metadata-only"); + assert.equal(result.sessions[0].messageCount, 0); + assert.equal(result.sessions[0].messageCountKnown, false); + assert.ok((tracker.bytesByPath.get(path.resolve(file)) ?? 0) <= 64 * 1024); + assert.doesNotMatch(JSON.stringify(result), /private-body/); + } finally { + tracker.restore(); + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history skips an oversized first metadata line without scanning later content", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-oversized-metadata-")); + const file = path.join(home, "sessions", "rollout-oversized-metadata.jsonl"); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, [ + JSON.stringify({ + type: "session_meta", + payload: { id: "oversized", title: "x".repeat(64 * 1024), cwd: "/work/oversized", model_provider: "openai" } + }), + JSON.stringify({ type: "session_meta", payload: { id: "must-not-be-used", cwd: "/work/later", model_provider: "openai" } }) + ].join("\n") + "\n", "utf8"); + const tracker = trackHistoryReadBytes(); + try { + const result = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(result.total, 0); + assert.ok((tracker.bytesByPath.get(path.resolve(file)) ?? 0) <= (64 * 1024) + 1); + } finally { + tracker.restore(); + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history bounds retained metadata fields even when the first line is within the byte limit", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-bounded-fields-")); + const file = path.join(home, "sessions", "rollout-bounded-fields.jsonl"); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, `${JSON.stringify({ + type: "session_meta", + timestamp: "t".repeat(129), + payload: { + id: "i".repeat(513), + title: "t".repeat(1025), + cwd: "c".repeat((32 * 1024) + 1), + model_provider: "p".repeat(513), + model: "m".repeat(513) + } + })}\n`, "utf8"); + try { + const result = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(result.total, 1); + assert.match(result.sessions[0].id, /^rollout:/); + assert.equal(result.sessions[0].title, ""); + assert.equal(result.sessions[0].cwd, ""); + assert.equal(result.sessions[0].provider, "(missing)"); + assert.equal(result.sessions[0].model, ""); + assert.equal(result.sessions[0].createdAt, null); } finally { await fs.rm(home, { recursive: true, force: true }); } @@ -61,9 +197,11 @@ test("history prefers canonical user events over response-item bootstrap and dup await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); const list = await listHistory(home, { page: 1, pageSize: 50 }); - assert.equal(list.sessions[0].title, "请检查真实标题"); - assert.equal(list.sessions[0].firstUserMessage, "请检查真实标题"); - assert.equal(list.sessions[0].messageCount, 2); + assert.equal(list.sessions[0].title, ""); + assert.equal("firstUserMessage" in list.sessions[0], false); + assert.doesNotMatch(JSON.stringify(list), /请检查真实标题|标题已检查/); + assert.equal(list.sessions[0].messageCount, 0); + assert.equal(list.sessions[0].messageCountKnown, false); const detail = await getHistorySession(home, "thread-one"); assert.deepEqual(detail.messages.map(({ role, text }) => ({ role, text })), [ @@ -78,8 +216,10 @@ test("history prefers canonical user events over response-item bootstrap and dup ]; await fs.writeFile(file, `${legacyLines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); const legacy = await listHistory(home, { page: 1, pageSize: 50 }); - assert.equal(legacy.sessions[0].firstUserMessage, "旧格式用户消息"); - assert.equal(legacy.sessions[0].messageCount, 2); + assert.equal(legacy.sessions[0].title, ""); + assert.equal("firstUserMessage" in legacy.sessions[0], false); + assert.equal(legacy.sessions[0].messageCount, 0); + assert.equal(legacy.sessions[0].messageCountKnown, false); } finally { await fs.rm(home, { recursive: true, force: true }); } @@ -127,3 +267,138 @@ test("history exposes a stable bounded id when a session has no thread id", asyn await fs.rm(home, { recursive: true, force: true }); } }); + +test("history list aggregates a large rollout while detail retains only its bounded tail", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-large-")); + const file = path.join(home, "sessions", "rollout-large.jsonl"); + await fs.mkdir(path.dirname(file), { recursive: true }); + const records = [{ + type: "session_meta", + timestamp: "2026-08-04T08:00:00.000Z", + payload: { id: "thread-large", cwd: "/work/large", model_provider: "openai" } + }]; + for (let index = 0; index < 5_000; index += 1) { + records.push({ + type: "event_msg", + timestamp: `2026-08-04T08:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: index % 2 === 0 ? "user_message" : "assistant_message", + message: `bounded-message-${index}` + } + }); + } + await fs.writeFile(file, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, "utf8"); + try { + const page = await listHistory(home, { page: 1, pageSize: 50, query: "bounded-message-4999" }); + assert.equal(page.total, 1); + assert.equal(page.sessions[0].messageCount, 5_000); + assert.equal(page.sessions[0].messageCountKnown, true); + const detail = await getHistorySession(home, "thread-large", { messageLimit: 10 }); + assert.equal(detail.returnedMessageCount, 10); + assert.equal(detail.truncated, true); + assert.equal(detail.messages[0].sequence, 4_991); + assert.equal(detail.messages.at(-1).text, "bounded-message-4999"); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history detail reads decoy rollouts as metadata and deep-reads only the selected rollout", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-detail-target-")); + const sessions = path.join(home, "sessions"); + await fs.mkdir(sessions, { recursive: true }); + const decoys = []; + for (let index = 0; index < 3; index += 1) { + const decoy = path.join(sessions, `rollout-decoy-${index}.jsonl`); + decoys.push(decoy); + await fs.writeFile(decoy, [ + JSON.stringify({ type: "session_meta", payload: { id: `decoy-${index}`, cwd: "/work/decoy", model_provider: "openai" } }), + JSON.stringify({ type: "event_msg", payload: { type: "assistant_message", message: `decoy-body-${"x".repeat(256 * 1024)}` } }) + ].join("\n") + "\n", "utf8"); + } + const target = path.join(sessions, "rollout-target.jsonl"); + await fs.writeFile(target, [ + JSON.stringify({ type: "session_meta", payload: { id: "selected-target", cwd: "/work/target", model_provider: "openai" } }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "selected body" } }) + ].join("\n") + "\n", "utf8"); + const tracker = trackHistoryReadBytes(); + try { + const result = await getHistorySession(home, "selected-target"); + assert.equal(result.returnedMessageCount, 1); + assert.equal(result.messages[0].text, "selected body"); + assert.equal(result.session.messageCountKnown, true); + for (const decoy of decoys) { + assert.ok((tracker.bytesByPath.get(path.resolve(decoy)) ?? 0) <= 64 * 1024); + } + } finally { + tracker.restore(); + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history detail rejects a same-mtime file replacement selected after listing", async () => { + const { home, file } = await fixture(); + const replacement = `${file}.replacement`; + const displaced = `${file}.displaced`; + const originalStat = await fs.stat(file); + await fs.writeFile(replacement, [ + JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-04T08:00:00.000Z", + payload: { id: "thread-one", cwd: "/work/demo", model_provider: "openai" } + }), + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-04T08:01:00.000Z", + payload: { type: "assistant_message", message: "replacement marker must never be returned" } + }) + ].join("\n") + "\n", "utf8"); + await fs.utimes(replacement, originalStat.atime, originalStat.mtime); + const originalOpen = fs.open; + let openCount = 0; + fs.open = async (...args) => { + openCount += 1; + if (openCount === 2) { + await fs.rename(file, displaced); + await fs.rename(replacement, file); + } + return originalOpen(...args); + }; + try { + await assert.rejects( + () => getHistorySession(home, "thread-one"), + (error) => error?.code === "STALE_STATE" + && !String(error?.message).includes("replacement marker") + ); + } finally { + fs.open = originalOpen; + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("history ignores a linked sessions root outside the selected Codex Home", async (t) => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-root-")); + const external = await fs.mkdtemp(path.join(os.tmpdir(), "codex-history-external-")); + const rollout = path.join(external, "rollout-external.jsonl"); + await fs.writeFile(rollout, `${JSON.stringify({ + type: "session_meta", + payload: { id: "external-thread", cwd: "/external", model_provider: "openai" } + })}\n`, "utf8"); + try { + try { + await fs.symlink(external, path.join(home, "sessions"), process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + if (["EPERM", "EACCES", "ENOTSUP"].includes(error?.code)) { + t.skip(`directory link unavailable: ${error.code}`); + return; + } + throw error; + } + const page = await listHistory(home, { page: 1, pageSize: 50 }); + assert.equal(page.total, 0); + assert.doesNotMatch(JSON.stringify(page), /external-thread|\/external/); + } finally { + await fs.rm(home, { recursive: true, force: true }); + await fs.rm(external, { recursive: true, force: true }); + } +}); diff --git a/test/locking.test.js b/test/locking.test.js index 16e3e55..eab3b92 100644 --- a/test/locking.test.js +++ b/test/locking.test.js @@ -66,7 +66,12 @@ test("acquireLock publishes a complete versioned owner and holds a unique claim" assert.equal(typeof owner.instanceId, "string"); assert.deepEqual(await fs.readdir(claimsDir), [`${owner.instanceId}.json`]); - await assert.rejects(acquireLock(codexHome, "other", lockOptions()), /live claim|Lock already exists/); + await assert.rejects( + acquireLock(codexHome, "other", lockOptions()), + (error) => error?.code === "OPERATION_BUSY" + && error.details?.busyScope === "codex-home" + && /live claim|Lock already exists/.test(error.message) + ); await release(); await assert.rejects(fs.access(lockDir), { code: "ENOENT" }); assert.deepEqual(await fs.readdir(claimsDir), []); @@ -106,6 +111,30 @@ test("acquireLock retries transient candidate creation failures", async (t) => { await release(); }); +test("lock storage permission failures are typed before ownership is published", async (t) => { + const codexHome = await makeLockHome(t); + const fsImpl = new Proxy(fs, { + get(target, property) { + if (property === "mkdir") { + return async () => { + const error = new Error("permission denied fixture"); + error.code = "EACCES"; + throw error; + }; + } + const value = target[property]; + return typeof value === "function" ? value.bind(target) : value; + } + }); + + await assert.rejects( + acquireLock(codexHome, "sync", lockOptions({ fsImpl })), + (error) => error?.code === "PERMISSION_DENIED" + && error.details?.lockScope === "codex-home" + && error.details?.causeCode === "EACCES" + ); +}); + test("owner publication failure removes its empty canonical reservation and claim", async (t) => { const codexHome = await makeLockHome(t); const lockDir = path.join(codexHome, "tmp", DEFAULT_LOCK_NAME); @@ -215,7 +244,9 @@ test("link failure does not remove a swapped empty reservation and still release await assert.rejects( acquireLock(codexHome, "sync", lockOptions({ fsImpl })), - /cleanup was incomplete/ + (error) => error?.code === "LOCK_UNVERIFIABLE" + && error.details?.lockScope === "codex-home" + && /cleanup was incomplete/.test(error.message) ); assert.equal((await fs.lstat(lockDir)).isDirectory(), true); assert.equal((await fs.lstat(displacedReservation)).isDirectory(), true); @@ -225,10 +256,18 @@ test("link failure does not remove a swapped empty reservation and still release test("acquirePathLock supports an arbitrary future SQLite resource path", async (t) => { const root = await makeLockHome(t); const lockPath = path.join(root, "resource-locks", "state-db.lock"); - const release = await acquirePathLock(lockPath, "sqlite-resource", lockOptions()); + const resourceOptions = lockOptions({ scope: "state-db", resourceKey: "a".repeat(64) }); + const release = await acquirePathLock(lockPath, "sqlite-resource", resourceOptions); const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8")); assert.equal(owner.label, "sqlite-resource"); + assert.equal(owner.scope, "state-db"); + assert.equal(owner.resourceKey, "a".repeat(64)); assert.deepEqual(await fs.readdir(`${lockPath}.claims`), [`${owner.instanceId}.json`]); + await assert.rejects( + acquirePathLock(lockPath, "sqlite-resource-2", resourceOptions), + (error) => error?.code === "OPERATION_BUSY" + && error.details?.busyScope === "state-db" + ); await release(); await assert.rejects(fs.access(lockPath), { code: "ENOENT" }); }); @@ -240,8 +279,13 @@ test("acquirePathLock rejects a canonical file with an explicit diagnostic", asy await fs.writeFile(lockPath, "foreign", "utf8"); await assert.rejects( - acquirePathLock(lockPath, "sqlite-resource", lockOptions()), - /canonical lock path is not a directory/ + acquirePathLock(lockPath, "sqlite-resource", lockOptions({ + scope: "state-db", + resourceKey: "b".repeat(64) + })), + (error) => error?.code === "LOCK_UNVERIFIABLE" + && error.details?.lockScope === "state-db" + && /canonical lock path is not a directory/.test(error.message) ); assert.equal(await fs.readFile(lockPath, "utf8"), "foreign"); assert.deepEqual(await fs.readdir(`${lockPath}.claims`), []); @@ -312,6 +356,38 @@ test("acquireLock recognizes a live version 2 .NET owner from UTC-second identit assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); }); +test("cross-runtime start-time uncertainty is LOCK_UNVERIFIABLE, not OPERATION_BUSY", async (t) => { + const codexHome = await makeLockHome(t); + let startTimeProbeCount = 0; + const lockDir = await writeCanonicalOwner(codexHome, { + protocolVersion: 2, + runtime: "dotnet", + pid: process.pid, + processId: process.pid, + processStartedAt: TEST_STARTED_AT, + instanceId: "dotnet-v2-unverifiable", + startedAt: TEST_STARTED_AT, + label: "dotnet", + cwd: codexHome + }); + + await assert.rejects( + acquireLock(codexHome, "node", lockOptions({ + getProcessStartedAtIdentity: async () => { + startTimeProbeCount += 1; + if (startTimeProbeCount === 1) { + return TEST_STARTED_AT; + } + throw new Error("injected cross-runtime identity failure"); + } + })), + (error) => error?.code === "LOCK_UNVERIFIABLE" + && error.details?.lockScope === "codex-home" + && /could not be verified/.test(error.message) + ); + assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); +}); + test("same-second PID reuse is rejected by the exact Node start marker", async (t) => { const codexHome = await makeLockHome(t); const lockDir = await writeCanonicalOwner(codexHome, { @@ -571,7 +647,9 @@ test("acquireLock fails closed while a legacy canonical owner is missing", async await fs.mkdir(lockDir, { recursive: true }); await assert.rejects( acquireLock(codexHome, "sync", lockOptions()), - /owner\.json is not visible yet/ + (error) => error?.code === "LOCK_UNVERIFIABLE" + && error.details?.lockScope === "codex-home" + && /owner\.json is not visible yet/.test(error.message) ); assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); }); @@ -604,7 +682,12 @@ test("acquireLock fails closed for future protocols and conflicting cross-runtim await t.test(fixture.name, async (subtest) => { const codexHome = await makeLockHome(subtest); const lockDir = await writeCanonicalOwner(codexHome, fixture.owner); - await assert.rejects(acquireLock(codexHome, "sync", lockOptions()), fixture.expected); + await assert.rejects( + acquireLock(codexHome, "sync", lockOptions()), + (error) => error?.code === "LOCK_UNVERIFIABLE" + && error.details?.lockScope === "codex-home" + && fixture.expected.test(error.message) + ); assert.deepEqual(await fs.readdir(`${lockDir}.claims`), []); assert.deepEqual( JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf8")), diff --git a/test/operation-coordinator.test.js b/test/operation-coordinator.test.js new file mode 100644 index 0000000..01c4eab --- /dev/null +++ b/test/operation-coordinator.test.js @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { OperationCoordinator } from "../src/operation-coordinator.js"; + +function createFakeScheduler(start = 0) { + let now = start; + const timers = new Set(); + return { + now: () => now, + setTimeoutImpl(callback, delay) { + const timer = { + callback, + dueAt: now + delay, + cleared: false, + unrefCalled: false, + unref() { + this.unrefCalled = true; + } + }; + timers.add(timer); + return timer; + }, + clearTimeoutImpl(timer) { + timer.cleared = true; + timers.delete(timer); + }, + advanceTo(value) { + now = value; + while (true) { + const due = [...timers] + .filter((timer) => !timer.cleared && timer.dueAt <= now) + .sort((left, right) => left.dueAt - right.dueAt)[0]; + if (!due) break; + timers.delete(due); + due.callback(); + } + }, + activeTimers: () => [...timers].filter((timer) => !timer.cleared) + }; +} + +test("Watch yields to a prepared manual intent until the manual Apply ends", async () => { + let operationSequence = 0; + const coordinator = new OperationCoordinator({ + randomOperationId: () => `operation-${++operationSequence}` + }); + const codexHome = "C:\\fixtures\\manual-priority"; + const planId = "manual-plan"; + coordinator.registerManualIntent(codexHome, planId, Date.now() + 5_000, "win32"); + + assert.throws( + () => coordinator.begin(codexHome, "sync", { actor: "watch", platform: "win32" }), + (error) => error?.code === "OPERATION_BUSY" + && error?.details?.busyScope === "codex-home" + && error?.details?.reason === "manual-intent" + ); + + const ticket = coordinator.waitForManualOperation(codexHome, "win32"); + assert.ok(ticket); + let priorityEnded = false; + void ticket.promise.then(() => { priorityEnded = true; }); + + const manual = coordinator.begin(codexHome, "sync", { + actor: "manual", + planId, + platform: "win32" + }); + await Promise.resolve(); + assert.equal(priorityEnded, false, "consuming the plan must not wake Watch during manual Apply"); + + coordinator.end(codexHome, manual.operationId, "win32"); + await ticket.promise; + assert.equal(priorityEnded, true); + + const watch = coordinator.begin(codexHome, "sync", { actor: "watch", platform: "win32" }); + coordinator.end(codexHome, watch.operationId, "win32"); +}); + +test("an abandoned manual intent wakes Watch when its plan TTL expires", async () => { + const coordinator = new OperationCoordinator({ randomOperationId: () => "watch-operation" }); + const codexHome = "/tmp/manual-intent-expiry"; + coordinator.registerManualIntent(codexHome, "expiring-plan", Date.now() + 40, "linux"); + const ticket = coordinator.waitForManualOperation(codexHome, "linux"); + assert.ok(ticket); + + await Promise.race([ + ticket.promise, + new Promise((_resolve, reject) => setTimeout( + () => reject(new Error("manual intent expiry did not wake Watch")), + 1_000 + )) + ]); + + const watch = coordinator.begin(codexHome, "sync", { actor: "watch", platform: "linux" }); + coordinator.end(codexHome, watch.operationId, "linux"); +}); + +test("manual intent expiry is cleaned autonomously without a Watch waiter", () => { + const scheduler = createFakeScheduler(10_000); + const coordinator = new OperationCoordinator({ + randomOperationId: () => "watch-operation", + now: scheduler.now, + setTimeoutImpl: scheduler.setTimeoutImpl, + clearTimeoutImpl: scheduler.clearTimeoutImpl + }); + const codexHome = "/tmp/manual-intent-autonomous-expiry"; + coordinator.registerManualIntent(codexHome, "abandoned-plan", 10_050, "linux"); + assert.equal(scheduler.activeTimers().length, 1); + assert.equal(scheduler.activeTimers()[0].unrefCalled, true); + + scheduler.advanceTo(10_050); + + assert.equal(scheduler.activeTimers().length, 0); + assert.equal(coordinator.manualIntents.size, 0); + const watch = coordinator.begin(codexHome, "sync", { actor: "watch", platform: "linux" }); + coordinator.end(codexHome, watch.operationId, "linux"); +}); + +test("one Home expiry timer advances across multiple manual intents and rearms on release", () => { + const scheduler = createFakeScheduler(20_000); + let operationSequence = 0; + const coordinator = new OperationCoordinator({ + randomOperationId: () => `operation-${++operationSequence}`, + now: scheduler.now, + setTimeoutImpl: scheduler.setTimeoutImpl, + clearTimeoutImpl: scheduler.clearTimeoutImpl + }); + const codexHome = "/tmp/manual-intent-multiple-expiry"; + coordinator.registerManualIntent(codexHome, "first", 20_010, "linux"); + coordinator.registerManualIntent(codexHome, "second", 20_020, "linux"); + assert.equal(scheduler.activeTimers().length, 1); + assert.equal(scheduler.activeTimers()[0].dueAt, 20_010); + + scheduler.advanceTo(20_010); + assert.equal(scheduler.activeTimers().length, 1); + assert.equal(scheduler.activeTimers()[0].dueAt, 20_020); + assert.throws( + () => coordinator.begin(codexHome, "sync", { actor: "watch", platform: "linux" }), + (error) => error?.details?.reason === "manual-intent" + ); + + coordinator.releaseManualIntent(codexHome, "second", "linux"); + assert.equal(scheduler.activeTimers().length, 0); + assert.equal(coordinator.manualIntents.size, 0); + const watch = coordinator.begin(codexHome, "sync", { actor: "watch", platform: "linux" }); + coordinator.end(codexHome, watch.operationId, "linux"); +}); + +test("a consumed manual plan releases its intent even when another operation is active", async () => { + let operationSequence = 0; + const coordinator = new OperationCoordinator({ + randomOperationId: () => `operation-${++operationSequence}` + }); + const codexHome = "/tmp/manual-consumed-busy"; + const active = coordinator.begin(codexHome, "sync", { actor: "watch", platform: "linux" }); + coordinator.registerManualIntent(codexHome, "manual-plan", Date.now() + 5_000, "linux"); + + assert.throws( + () => coordinator.begin(codexHome, "restore", { + actor: "manual", + planId: "manual-plan", + platform: "linux" + }), + (error) => error?.code === "OPERATION_BUSY" + ); + coordinator.end(codexHome, active.operationId, "linux"); + + const next = coordinator.begin(codexHome, "sync", { actor: "watch", platform: "linux" }); + coordinator.end(codexHome, next.operationId, "linux"); +}); diff --git a/test/operation-revision.test.js b/test/operation-revision.test.js new file mode 100644 index 0000000..7d17d38 --- /dev/null +++ b/test/operation-revision.test.js @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + captureBackupRevision, + captureConfigRevision, + captureOperationRevisions, + captureRolloutRevision, + captureStateDbRevision, + revisionMismatch +} from "../src/operation-revision.js"; + +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-revision-")); + const codexHome = path.join(root, ".codex"); + const sqliteHome = path.join(codexHome, "sqlite"); + const stateDbPath = path.join(sqliteHome, "state_5.sqlite"); + const rolloutPath = path.join(codexHome, "sessions", "2026", "08", "rollout-a.jsonl"); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(sqliteHome, { recursive: true }); + await fs.writeFile(rolloutPath, '{"type":"session_meta"}\n', "utf8"); + await fs.writeFile(stateDbPath, "db-one", "utf8"); + const storage = { + codexHome, + sqliteHome, + sqliteHomeSource: "default", + sqliteAccess: { supported: true, reason: null }, + allowLegacyRootFallback: true, + stateDbLocation: { path: stateDbPath, source: "sqlite-dir" } + }; + return { root, codexHome, rolloutPath, stateDbPath, storage }; +} + +test("operation revisions detect exact config, rollout, and State DB drift", async () => { + const value = await fixture(); + try { + const first = await captureOperationRevisions({ + codexHome: value.codexHome, + profileRevision: "profile-r1", + configText: 'model_provider = "openai"\n', + storage: value.storage + }); + assert.equal(first.rolloutScanComplete, true); + assert.equal(first.rolloutFileCount, 1); + + await fs.writeFile(value.rolloutPath, '{"type":"session_meta","changed":true}\n', "utf8"); + const rolloutChanged = await captureOperationRevisions({ + codexHome: value.codexHome, + profileRevision: "profile-r1", + configText: 'model_provider = "openai"\n', + storage: value.storage + }); + assert.equal(revisionMismatch(first, rolloutChanged), "rollout"); + + const configChanged = { ...first, configRevision: captureConfigRevision("changed") }; + assert.equal(revisionMismatch(first, configChanged), "config"); + + const dbBefore = await captureStateDbRevision(value.storage); + await fs.writeFile(value.stateDbPath, "db-two", "utf8"); + const dbAfter = await captureStateDbRevision(value.storage); + assert.notEqual(dbAfter, dbBefore); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("rollout and managed backup revisions are deterministic and content-bound", async () => { + const value = await fixture(); + const backupDir = path.join(value.codexHome, "backups_state", "provider-sync", "fixture"); + try { + const rolloutOne = await captureRolloutRevision(value.codexHome); + const rolloutTwo = await captureRolloutRevision(value.codexHome); + assert.deepEqual(rolloutTwo, rolloutOne); + + await fs.mkdir(backupDir, { recursive: true }); + await fs.writeFile(path.join(backupDir, "metadata.json"), '{"namespace":"provider-sync"}', "utf8"); + const backupOne = await captureBackupRevision(backupDir); + await fs.writeFile(path.join(backupDir, "metadata.json"), '{"namespace":"provider-sync","changed":true}', "utf8"); + const backupTwo = await captureBackupRevision(backupDir); + assert.notEqual(backupTwo, backupOne); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("metadata rollout revisions avoid body reads while detecting file metadata drift", async () => { + const value = await fixture(); + let rolloutBodyReads = 0; + const fsImpl = { + ...fs, + async readFile(filePath, ...args) { + if (path.resolve(String(filePath)) === path.resolve(value.rolloutPath)) { + rolloutBodyReads += 1; + throw new Error("rollout body read sentinel"); + } + return fs.readFile(filePath, ...args); + } + }; + try { + const first = await captureRolloutRevision(value.codexHome, { fsImpl, mode: "metadata" }); + assert.equal(first.rolloutScanComplete, true); + assert.equal(rolloutBodyReads, 0); + + await fs.appendFile(value.rolloutPath, '{"type":"event_msg"}\n', "utf8"); + const second = await captureRolloutRevision(value.codexHome, { fsImpl, mode: "metadata" }); + assert.notEqual(second.revision, first.revision); + assert.equal(rolloutBodyReads, 0); + + await assert.rejects( + captureRolloutRevision(value.codexHome, { fsImpl }), + /rollout body read sentinel/ + ); + assert.equal(rolloutBodyReads, 1); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); diff --git a/test/plan-apply.test.js b/test/plan-apply.test.js new file mode 100644 index 0000000..8da077d --- /dev/null +++ b/test/plan-apply.test.js @@ -0,0 +1,515 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + applyRestore, + applySwitch, + applySync, + getStatus, + prepareRestore, + prepareSwitch, + prepareSync +} from "../src/service.js"; +import { listBackups } from "../src/backup.js"; +import { openDatabase } from "../src/sqlite.js"; + +async function makeFixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-plan-apply-")); + const codexHome = path.join(root, ".codex"); + const rolloutPath = path.join(codexHome, "sessions", "2026", "08", "25", "rollout-a.jsonl"); + const stateDbPath = path.join(codexHome, "sqlite", "state_5.sqlite"); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(path.dirname(stateDbPath), { recursive: true }); + await fs.writeFile( + path.join(codexHome, "config.toml"), + 'model_provider = "openai"\nmodel = "gpt-5"\n', + "utf8" + ); + const meta = { + id: "thread-a", + timestamp: "2026-08-25T00:00:00.000Z", + cwd: "C:\\AITemp", + source: "cli", + cli_version: "0.115.0", + model_provider: "custom" + }; + await fs.writeFile( + rolloutPath, + `${JSON.stringify({ timestamp: meta.timestamp, type: "session_meta", payload: meta })}\n`, + "utf8" + ); + const db = await openDatabase(stateDbPath); + 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 '', + model TEXT + ) + `); + db.prepare("INSERT INTO threads (id, model_provider, cwd, archived, first_user_message, model) VALUES (?, ?, ?, ?, ?, ?)") + .run("thread-a", "custom", "C:\\AITemp", 0, "hello", "old-model"); + } finally { + db.close(); + } + return { root, codexHome, rolloutPath, stateDbPath }; +} + +function backupRoot(codexHome) { + return path.join(codexHome, "backups_state", "provider-sync"); +} + +async function backupCount(codexHome) { + try { + return (await fs.readdir(backupRoot(codexHome))).length; + } catch (error) { + if (error?.code === "ENOENT") return 0; + throw error; + } +} + +test("prepareSync returns schema v1 summary and applySync consumes it exactly once", async () => { + const value = await makeFixture(); + try { + const plan = await prepareSync({ codexHome: value.codexHome, provider: "openai", model: "gpt-5" }); + assert.equal(plan.schemaVersion, 1); + assert.equal(plan.operation, "sync"); + assert.equal(plan.requiresConfirmation, true); + assert.equal(plan.target.provider, "openai"); + assert.equal(plan.impact.rolloutFilesToChange, 1); + assert.match(plan.planId, /^[A-Za-z0-9_-]{32,128}$/); + + const applied = await applySync({ schemaVersion: 1, planId: plan.planId }); + assert.equal(applied.schemaVersion, 1); + assert.equal(applied.operation, "sync"); + assert.equal(applied.outcome, "completed"); + assert.match(applied.operationId, /^[0-9a-f-]{36}$/); + assert.equal(applied.result.targetProvider, "openai"); + assert.equal(await backupCount(value.codexHome), 1); + + await assert.rejects( + applySync({ schemaVersion: 1, planId: plan.planId }), + (error) => error?.code === "PLAN_EXPIRED" + ); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("a prepared manual plan has priority over a Watch Apply without weakening single consumption", async () => { + const value = await makeFixture(); + try { + const manualPlan = await prepareSync({ codexHome: value.codexHome }); + const watchPlan = await prepareSync({ codexHome: value.codexHome, __actor: "watch" }); + + await assert.rejects( + applySync({ schemaVersion: 1, planId: watchPlan.planId }), + (error) => error?.code === "OPERATION_BUSY" + && error?.details?.busyScope === "codex-home" + && error?.details?.reason === "manual-intent" + ); + assert.equal(await backupCount(value.codexHome), 0); + + const applied = await applySync({ schemaVersion: 1, planId: manualPlan.planId }); + assert.equal(applied.outcome, "completed"); + assert.equal(await backupCount(value.codexHome), 1); + + await assert.rejects( + applySync({ schemaVersion: 1, planId: watchPlan.planId }), + (error) => error?.code === "PLAN_EXPIRED" + ); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("Apply publishes one operation id, projects progress, and cancels before backup", async () => { + const value = await makeFixture(); + const controller = new AbortController(); + const lifecycle = []; + try { + const plan = await prepareSync({ codexHome: value.codexHome }); + await assert.rejects( + applySync( + { schemaVersion: 1, planId: plan.planId }, + { + onOperationStarted(started) { + lifecycle.push({ kind: "started", ...started }); + }, + onProgress(progress) { + lifecycle.push({ kind: "progress", ...progress }); + if (progress.stage === "create_backup" && progress.status === "start") { + controller.abort(); + } + }, + signal: controller.signal + } + ), + (error) => error?.code === "OPERATION_CANCELLED" + && error?.operationId === lifecycle[0]?.operationId + ); + assert.equal(lifecycle[0]?.kind, "started"); + assert.equal(lifecycle[0]?.operation, "sync"); + assert.ok(lifecycle.some((event) => event.kind === "progress")); + assert.equal(await backupCount(value.codexHome), 0); + assert.equal((await getStatus({ codexHome: value.codexHome })).operationInProgress, null); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("Apply cancellation after rollout mutation preserves rolled-back failure semantics", async () => { + const value = await makeFixture(); + const controller = new AbortController(); + const rolloutBefore = await fs.readFile(value.rolloutPath); + let startedOperationId; + try { + const plan = await prepareSync({ + codexHome: value.codexHome, + faultInjector({ point }) { + if (point === "after_rollout_mutation_before_applied") controller.abort(); + } + }); + await assert.rejects( + applySync( + { schemaVersion: 1, planId: plan.planId }, + { + signal: controller.signal, + onOperationStarted(value) { startedOperationId = value.operationId; } + } + ), + (error) => error?.code === "SYNC_FAILED_ROLLED_BACK" + && error?.operationId === startedOperationId + ); + assert.deepEqual(await fs.readFile(value.rolloutPath), rolloutBefore); + const database = await openDatabase(value.stateDbPath, { readOnly: true }); + try { + assert.equal( + database.prepare("SELECT model_provider FROM threads WHERE id = ?").get("thread-a").model_provider, + "custom" + ); + } finally { + database.close(); + } + const status = await getStatus({ codexHome: value.codexHome }); + assert.equal(status.pendingRecovery, false); + assert.deepEqual(status.pendingTransactions, []); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("applySync rejects config drift under the write locks before backup", async () => { + const value = await makeFixture(); + try { + const plan = await prepareSync({ codexHome: value.codexHome }); + await fs.appendFile(path.join(value.codexHome, "config.toml"), "# changed\n", "utf8"); + await assert.rejects( + applySync({ schemaVersion: 1, planId: plan.planId }), + (error) => error?.code === "STALE_STATE" && error?.details?.reason === "config" + ); + assert.equal(await backupCount(value.codexHome), 0); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("applySync rejects rollout and State DB drift before backup", async () => { + for (const drift of ["rollout", "state-db"]) { + const value = await makeFixture(); + try { + const plan = await prepareSync({ codexHome: value.codexHome }); + if (drift === "rollout") { + await fs.appendFile(value.rolloutPath, '{"type":"event_msg"}\n', "utf8"); + } else { + const db = await openDatabase(value.stateDbPath); + try { + db.prepare("UPDATE threads SET first_user_message = ? WHERE id = ?").run("changed", "thread-a"); + } finally { + db.close(); + } + } + await assert.rejects( + applySync({ schemaVersion: 1, planId: plan.planId }), + (error) => error?.code === "STALE_STATE" && error?.details?.reason === drift, + drift + ); + assert.equal(await backupCount(value.codexHome), 0, drift); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } + } +}); + +test("profile revision drift is checked after preparation and consumes the plan", async () => { + const value = await makeFixture(); + try { + let revision = "profile-r1"; + const plan = await prepareSync({ + codexHome: value.codexHome, + profileId: "work", + profileRevision: revision, + profileResolver: async () => ({ + id: "work", + revision, + codexHome: value.codexHome + }) + }); + revision = "profile-r2"; + await assert.rejects( + applySync({ schemaVersion: 1, planId: plan.planId }), + (error) => error?.code === "STALE_STATE" && error?.details?.reason === "profile" + ); + await assert.rejects( + applySync({ schemaVersion: 1, planId: plan.planId }), + (error) => error?.code === "PLAN_EXPIRED" + ); + assert.equal(await backupCount(value.codexHome), 0); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("status returns the last complete snapshot with operation metadata while Apply is paused", async () => { + const value = await makeFixture(); + let entered; + let release; + const enteredPromise = new Promise((resolve) => { entered = resolve; }); + const releasePromise = new Promise((resolve) => { release = resolve; }); + try { + const plan = await prepareSync({ + codexHome: value.codexHome, + faultInjector: async ({ point }) => { + if (point === "before_backup") { + entered(); + await releasePromise; + } + } + }); + const applyPromise = applySync({ schemaVersion: 1, planId: plan.planId }); + await enteredPromise; + const status = await getStatus({ codexHome: value.codexHome }); + assert.equal(status.rolloutScanComplete, true); + assert.equal(status.operationInProgress.operation, "sync"); + assert.equal(status.operationInProgress.actor, "manual"); + assert.match(status.operationInProgress.operationId, /^[0-9a-f-]{36}$/); + release(); + await applyPromise; + assert.equal((await getStatus({ codexHome: value.codexHome })).operationInProgress, null); + } finally { + release?.(); + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("two concurrent Apply calls for one plan start exactly one operation", async () => { + const value = await makeFixture(); + try { + const plan = await prepareSync({ codexHome: value.codexHome }); + const settled = await Promise.allSettled([ + applySync({ schemaVersion: 1, planId: plan.planId }), + applySync({ schemaVersion: 1, planId: plan.planId }) + ]); + assert.equal(settled.filter((entry) => entry.status === "fulfilled").length, 1); + const rejected = settled.find((entry) => entry.status === "rejected"); + assert.equal(rejected.reason.code, "PLAN_EXPIRED"); + assert.equal(await backupCount(value.codexHome), 1); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("different Codex Homes sharing one State DB contend before the losing backup", async () => { + const value = await makeFixture(); + const secondHome = path.join(value.root, "second-codex-home"); + const secondRollout = path.join(secondHome, "sessions", "2026", "08", "25", "rollout-b.jsonl"); + let entered; + let release; + const enteredPromise = new Promise((resolve) => { entered = resolve; }); + const releasePromise = new Promise((resolve) => { release = resolve; }); + try { + await fs.mkdir(path.dirname(secondRollout), { recursive: true }); + await fs.mkdir(path.join(secondHome, "archived_sessions"), { recursive: true }); + await fs.writeFile( + path.join(secondHome, "config.toml"), + `model_provider = "openai"\nsqlite_home = ${JSON.stringify(path.dirname(value.stateDbPath))}\n`, + "utf8" + ); + await fs.writeFile(secondRollout, `${JSON.stringify({ + timestamp: "2026-08-25T00:00:00.000Z", + type: "session_meta", + payload: { + id: "thread-b", + timestamp: "2026-08-25T00:00:00.000Z", + cwd: "C:\\AITemp", + source: "cli", + cli_version: "0.115.0", + model_provider: "custom" + } + })}\n`, "utf8"); + const secondConfigBefore = await fs.readFile(path.join(secondHome, "config.toml")); + const secondRolloutBefore = await fs.readFile(secondRollout); + + const firstPlan = await prepareSync({ + codexHome: value.codexHome, + faultInjector: async ({ point }) => { + if (point === "before_backup") { + entered(); + await releasePromise; + } + } + }); + const firstApply = applySync({ schemaVersion: 1, planId: firstPlan.planId }); + await enteredPromise; + + const secondPlan = await prepareSync({ codexHome: secondHome }); + await assert.rejects( + applySync({ schemaVersion: 1, planId: secondPlan.planId }), + (error) => error?.code === "OPERATION_BUSY" && error?.details?.busyScope === "state-db" + ); + assert.equal(await backupCount(secondHome), 0); + assert.deepEqual(await fs.readFile(path.join(secondHome, "config.toml")), secondConfigBefore); + assert.deepEqual(await fs.readFile(secondRollout), secondRolloutBefore); + + release(); + await firstApply; + } finally { + release?.(); + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("prepareSwitch/applySwitch preserves all three model-mode intents in consumed plans", async () => { + for (const fixture of [ + { expectedMode: "provider-default", options: {}, expectedModel: "relay-model" }, + { expectedMode: "keep-root-model", options: { keepRootModel: true }, expectedModel: "gpt-5" }, + { expectedMode: "explicit", options: { model: "explicit-model" }, expectedModel: "explicit-model" } + ]) { + const value = await makeFixture(); + try { + await fs.appendFile( + path.join(value.codexHome, "config.toml"), + '\n[model_providers.relay]\nmodel = "relay-model"\nbase_url = "https://example.invalid"\n', + "utf8" + ); + const plan = await prepareSwitch({ + codexHome: value.codexHome, + provider: "relay", + ...fixture.options + }); + assert.equal(plan.operation, "switch"); + assert.equal(plan.target.modelMode, fixture.expectedMode); + const applied = await applySwitch({ schemaVersion: 1, planId: plan.planId }); + assert.equal(applied.outcome, "completed"); + const configText = await fs.readFile(path.join(value.codexHome, "config.toml"), "utf8"); + assert.match(configText, /^model_provider = "relay"/m); + assert.match(configText, new RegExp(`^model = "${fixture.expectedModel}"`, "m")); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } + } +}); + +test("Switch re-resolves the authoritative State DB under the Home lock", async () => { + const value = await makeFixture(); + const legacyStateDbPath = path.join(value.codexHome, "state_5.sqlite"); + let newDefaultBytes = null; + try { + await fs.appendFile( + path.join(value.codexHome, "config.toml"), + '\n[model_providers.relay]\nmodel = "relay-model"\nbase_url = "https://example.invalid"\n', + "utf8" + ); + await fs.rename(value.stateDbPath, legacyStateDbPath); + await fs.rm(path.dirname(value.stateDbPath), { recursive: true, force: true }); + assert.equal( + (await getStatus({ codexHome: value.codexHome })).stateDbLocation.path, + legacyStateDbPath + ); + const configBefore = await fs.readFile(path.join(value.codexHome, "config.toml")); + const rolloutBefore = await fs.readFile(value.rolloutPath); + const legacyBefore = await fs.readFile(legacyStateDbPath); + + const plan = await prepareSwitch({ + codexHome: value.codexHome, + provider: "relay", + faultInjector: async ({ point }) => { + if (point !== "after_switch_storage_preflight") return; + await fs.mkdir(path.dirname(value.stateDbPath), { recursive: true }); + await fs.copyFile(legacyStateDbPath, value.stateDbPath); + newDefaultBytes = await fs.readFile(value.stateDbPath); + } + }); + + await assert.rejects( + applySwitch({ schemaVersion: 1, planId: plan.planId }), + (error) => error?.code === "STALE_STATE" && error?.details?.reason === "storage" + ); + assert.equal(await backupCount(value.codexHome), 0); + assert.deepEqual(await fs.readFile(path.join(value.codexHome, "config.toml")), configBefore); + assert.deepEqual(await fs.readFile(value.rolloutPath), rolloutBefore); + assert.deepEqual(await fs.readFile(legacyStateDbPath), legacyBefore); + assert.deepEqual(await fs.readFile(value.stateDbPath), newDefaultBytes); + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); + +test("prepareRestore/applyRestore binds a managed backup and rejects backup drift before mutation", async () => { + const value = await makeFixture(); + try { + const syncPlan = await prepareSync({ codexHome: value.codexHome, provider: "openai" }); + await applySync({ schemaVersion: 1, planId: syncPlan.planId }); + const inventory = await listBackups(value.codexHome); + const backup = inventory.backups[0]; + assert.ok(backup?.id); + + const stalePlan = await prepareRestore({ + codexHome: value.codexHome, + backupId: backup.id, + restoreConfig: false, + restoreDatabase: true, + restoreSessions: true + }); + await fs.appendFile(path.join(backup.path, "metadata.json"), "\n", "utf8"); + await assert.rejects( + applyRestore({ schemaVersion: 1, planId: stalePlan.planId }), + (error) => error?.code === "STALE_STATE" && error?.details?.reason === "backup" + ); + const dbBefore = await fs.readFile(value.stateDbPath); + + // Restore the exact metadata bytes from the immutable source plan fixture, + // then prepare a fresh plan and apply it. + const metadataText = await fs.readFile(path.join(backup.path, "metadata.json"), "utf8"); + await fs.writeFile(path.join(backup.path, "metadata.json"), metadataText.trimEnd(), "utf8"); + const freshPlan = await prepareRestore({ + codexHome: value.codexHome, + backupId: backup.id, + restoreConfig: false, + restoreDatabase: true, + restoreSessions: true + }); + const restored = await applyRestore({ schemaVersion: 1, planId: freshPlan.planId }); + assert.equal(restored.operation, "restore"); + assert.equal(restored.outcome, "completed"); + assert.notDeepEqual(await fs.readFile(value.stateDbPath), dbBefore); + const db = await openDatabase(value.stateDbPath); + try { + assert.equal( + db.prepare("SELECT model_provider FROM threads WHERE id = ?").get("thread-a").model_provider, + "custom" + ); + } finally { + db.close(); + } + } finally { + await fs.rm(value.root, { recursive: true, force: true }); + } +}); diff --git a/test/plan-ledger.test.js b/test/plan-ledger.test.js new file mode 100644 index 0000000..38e7ac6 --- /dev/null +++ b/test/plan-ledger.test.js @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_PLAN_TTL_MS, + PLAN_SCHEMA_VERSION, + PlanLedger +} from "../src/plan-ledger.js"; + +function fixtureLedger() { + let now = Date.parse("2026-08-25T00:00:00.000Z"); + let sequence = 0; + const ledger = new PlanLedger({ + now: () => now, + randomId: () => `fixture_${String(sequence += 1).padStart(40, "0")}` + }); + return { ledger, advance: (milliseconds) => { now += milliseconds; } }; +} + +function scheduledFixtureLedger() { + let now = Date.parse("2026-08-25T00:00:00.000Z"); + let sequence = 0; + const timers = new Set(); + const ledger = new PlanLedger({ + now: () => now, + ttlMs: 100, + randomId: () => `scheduled_${String(sequence += 1).padStart(40, "0")}`, + setTimeoutImpl(callback, delay) { + const timer = { + callback, + dueAt: now + delay, + unrefCalled: false, + unref() { this.unrefCalled = true; } + }; + timers.add(timer); + return timer; + }, + clearTimeoutImpl(timer) { + timers.delete(timer); + } + }); + return { + ledger, + timers, + advance(milliseconds) { + now += milliseconds; + while (true) { + const timer = [...timers] + .filter((entry) => entry.dueAt <= now) + .sort((left, right) => left.dueAt - right.dueAt)[0]; + if (!timer) break; + timers.delete(timer); + timer.callback(); + } + } + }; +} + +test("PlanLedger issues immutable schema v1 summaries with a ten-minute TTL", () => { + const { ledger } = fixtureLedger(); + const summary = ledger.issue("sync", { + schemaVersion: 999, + planId: "attacker", + operation: "restore", + profile: { id: "default", revision: "profile-r1" }, + target: { provider: "openai" }, + impact: {}, + warnings: [] + }, { trusted: true }); + + assert.equal(summary.schemaVersion, PLAN_SCHEMA_VERSION); + assert.equal(summary.operation, "sync"); + assert.match(summary.planId, /^fixture_/); + assert.equal( + Date.parse(summary.expiresAt) - Date.parse(summary.createdAt), + DEFAULT_PLAN_TTL_MS + ); + assert.equal(summary.requiresConfirmation, true); + assert.equal(Object.isFrozen(summary), true); + assert.equal(Object.isFrozen(summary.profile), true); +}); + +test("PlanLedger consumes a plan once and fails closed for replay or cross-operation use", () => { + const { ledger } = fixtureLedger(); + const plan = ledger.issue("switch", { profile: {}, target: {}, impact: {}, warnings: [] }, { marker: 1 }); + + assert.equal(ledger.consume({ schemaVersion: 1, planId: plan.planId }, "switch").internal.marker, 1); + assert.throws( + () => ledger.consume({ schemaVersion: 1, planId: plan.planId }, "switch"), + (error) => error?.code === "PLAN_EXPIRED" + ); + + const restore = ledger.issue("restore", { profile: {}, target: {}, impact: {}, warnings: [] }, {}); + assert.throws( + () => ledger.consume({ schemaVersion: 1, planId: restore.planId }, "sync"), + (error) => error?.code === "PLAN_EXPIRED" + ); +}); + +test("PlanLedger expires plans and rejects tampered Apply payloads without consuming valid input", () => { + const { ledger, advance } = fixtureLedger(); + const plan = ledger.issue("sync", { profile: {}, target: {}, impact: {}, warnings: [] }, {}); + assert.throws( + () => ledger.consume({ schemaVersion: 1, planId: plan.planId, provider: "attacker" }, "sync"), + (error) => error?.code === "INVALID_INPUT" + ); + assert.equal(ledger.size, 1); + + advance(DEFAULT_PLAN_TTL_MS); + assert.throws( + () => ledger.consume({ schemaVersion: 1, planId: plan.planId }, "sync"), + (error) => error?.code === "PLAN_EXPIRED" + ); + assert.equal(ledger.size, 0); +}); + +test("PlanLedger autonomously discards abandoned plans without keeping the process alive", () => { + const { ledger, timers, advance } = scheduledFixtureLedger(); + ledger.issue("sync", { profile: {}, target: {}, impact: {}, warnings: [] }, {}); + assert.equal(ledger.size, 1); + assert.equal(timers.size, 1); + assert.equal([...timers][0].unrefCalled, true); + + advance(100); + + assert.equal(ledger.size, 0); + assert.equal(timers.size, 0); +}); diff --git a/test/profile-refresh.test.js b/test/profile-refresh.test.js deleted file mode 100644 index 2aef3ae..0000000 --- a/test/profile-refresh.test.js +++ /dev/null @@ -1,134 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createProfileRefresh, storagePayload } from "../web/src/profile-refresh.js"; - -function createDeferred() { - let resolve; - let reject; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -function createRecordingFetches() { - const calls = []; - const pending = new Map(); - const fetchFor = (kind) => (storage, options = {}) => { - const deferred = createDeferred(); - const key = `${kind}:${storage.profileId}`; - calls.push({ kind, profileId: storage.profileId, signal: options.signal }); - pending.set(key, deferred); - return deferred.promise; - }; - return { - calls, - pending, - fetchStatus: fetchFor("status"), - fetchBackups: fetchFor("backups"), - resolveFor(profileId, { status = {}, backups = {} } = {}) { - pending.get(`status:${profileId}`)?.resolve({ status }); - pending.get(`backups:${profileId}`)?.resolve(backups); - }, - rejectFor(profileId, error) { - pending.get(`status:${profileId}`)?.reject(error); - pending.get(`backups:${profileId}`)?.reject(error); - } - }; -} - -function createUiRecorder() { - const recorder = { - applied: [], - errors: [], - loading: [] - }; - recorder.onResult = (result) => recorder.applied.push(result); - recorder.onError = (error) => recorder.errors.push(error.message); - recorder.onLoading = (value) => recorder.loading.push(value); - return recorder; -} - -test("storagePayload defaults to the default profile", () => { - assert.deepEqual(storagePayload(), { profileId: "default" }); - assert.deepEqual(storagePayload("work"), { profileId: "work" }); -}); - -test("profile refresh race: A starts first, B finishes first, A finishes last — UI stays on B", async () => { - const fetches = createRecordingFetches(); - const ui = createUiRecorder(); - const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); - - // The user is on profile A and a refresh is in flight. - const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); - // The user switches to profile B before A completes. - const refreshB = refresh({ profileId: "b", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); - - // Starting B aborts A's underlying requests. - const requestA = fetches.calls.find((call) => call.profileId === "a"); - assert.equal(requestA.signal.aborted, true); - - // B finishes first and is applied. - fetches.resolveFor("b", { status: { currentProvider: "provider-b" }, backups: { backups: ["b-backup"] } }); - assert.equal(await refreshB, true); - assert.deepEqual(ui.applied.map((entry) => entry.profileId), ["b"]); - assert.deepEqual(ui.loading, [true, true, false]); - - // A finishes last; its results, loading transitions, and errors must be discarded. - fetches.resolveFor("a", { status: { currentProvider: "provider-a" }, backups: { backups: ["a-backup"] } }); - assert.equal(await refreshA, false); - assert.deepEqual(ui.applied.map((entry) => entry.profileId), ["b"]); - assert.deepEqual(ui.applied[0].status, { currentProvider: "provider-b" }); - assert.deepEqual(ui.errors, []); - assert.deepEqual(ui.loading, [true, true, false]); -}); - -test("profile refresh race: a stale request failing late never surfaces an error", async () => { - const fetches = createRecordingFetches(); - const ui = createUiRecorder(); - const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); - - const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); - const refreshB = refresh({ profileId: "b", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); - - fetches.resolveFor("b", { status: { currentProvider: "provider-b" } }); - assert.equal(await refreshB, true); - - fetches.rejectFor("a", new Error("profile A backend exploded")); - assert.equal(await refreshA, false); - assert.deepEqual(ui.errors, []); - assert.deepEqual(ui.applied.map((entry) => entry.profileId), ["b"]); -}); - -test("profile refresh surfaces errors from the latest request only", async () => { - const fetches = createRecordingFetches(); - const ui = createUiRecorder(); - const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); - - const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); - fetches.rejectFor("a", new Error("profile A is unreachable")); - assert.equal(await refreshA, false); - assert.deepEqual(ui.errors, ["profile A is unreachable"]); - assert.deepEqual(ui.applied, []); - assert.deepEqual(ui.loading, [true, false]); -}); - -test("profile refresh quiet mode leaves the loading indicator untouched unless it is the latest", async () => { - const fetches = createRecordingFetches(); - const ui = createUiRecorder(); - const refresh = createProfileRefresh({ fetchStatus: fetches.fetchStatus, fetchBackups: fetches.fetchBackups }); - - const refreshA = refresh({ profileId: "a", onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); - const quietB = refresh({ profileId: "b", showLoading: false, onLoading: ui.onLoading, onResult: ui.onResult, onError: ui.onError }); - - fetches.resolveFor("b", { status: { currentProvider: "provider-b" } }); - assert.equal(await quietB, true); - // The quiet refresh never raised the indicator but still settles it as the latest request. - assert.deepEqual(ui.loading, [true, false]); - - fetches.resolveFor("a", { status: { currentProvider: "provider-a" } }); - assert.equal(await refreshA, false); - assert.deepEqual(ui.loading, [true, false]); -}); diff --git a/test/public-api-contract.test.js b/test/public-api-contract.test.js new file mode 100644 index 0000000..b4c19b1 --- /dev/null +++ b/test/public-api-contract.test.js @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import * as publicApi from "../src/public-api.js"; + +const EXPECTED_EXPORTS = [ + "CORE_ERROR_CODES", + "CoreError", + "applyRestore", + "applySwitch", + "applySync", + "detectStateDb", + "ensureCodexHome", + "getDiagnostics", + "getHistorySession", + "getStatus", + "getWatchStatus", + "listBackups", + "listHistory", + "prepareRestore", + "prepareSwitch", + "prepareSync", + "pruneBackups", + "readConfigText", + "readRootModelFromConfigText", + "resolveStorageLayout", + "runPruneBackups", + "runRestore", + "runSwitch", + "runSync", + "runWatch", + "startWatch", + "stopWatch", + "toCoreErrorDto", + "withStateDbLocation" +]; + +test("public Core API has the exact C3 export surface", () => { + assert.deepEqual(Object.keys(publicApi).sort(), EXPECTED_EXPORTS); + for (const name of EXPECTED_EXPORTS) { + if (name !== "CORE_ERROR_CODES") { + assert.equal(typeof publicApi[name], "function", name + " must be callable"); + } + } + assert.ok(Object.isFrozen(publicApi.CORE_ERROR_CODES)); + assert.ok(publicApi.CORE_ERROR_CODES.includes("STALE_STATE")); +}); + +test("public Core helper adapters retain their current callable behavior", () => { + assert.equal( + publicApi.readRootModelFromConfigText( + 'model = "gpt-5"\n[model_providers.example]\nmodel = "ignored"\n' + ), + "gpt-5" + ); + + const layout = publicApi.resolveStorageLayout({ + codexHome: "/tmp/codex-provider-sync-public-api", + configText: "" + }); + assert.equal(layout.codexHome, path.resolve("/tmp/codex-provider-sync-public-api")); + assert.equal(typeof publicApi.withStateDbLocation(layout, null), "object"); +}); diff --git a/test/release-packaging-contract.test.js b/test/release-packaging-contract.test.js index 82d0038..9a4e99f 100644 --- a/test/release-packaging-contract.test.js +++ b/test/release-packaging-contract.test.js @@ -36,8 +36,39 @@ test("release packaging creates a focused Automation ZIP with its protocol and g test("publish workflow resolves a tag-bound Chinese announcement instead of hardcoding a body", () => { const workflow = read(".github/workflows/publish.yml"); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /release_tag:/); + assert.match(workflow, /ref: refs\/tags\/\$\{\{ inputs\.release_tag \}\}/); + assert.match(workflow, /tag_name: \$\{\{ inputs\.release_tag \}\}/); + assert.doesNotMatch(workflow, /\bpush:\s*\n\s+tags:/); + assert.doesNotMatch(workflow, /github\.ref_name|GITHUB_REF_NAME/); assert.match(workflow, /read-release-metadata\.js --tag/); assert.match(workflow, /body_path: \$\{\{ steps\.release_metadata\.outputs\.release_body_path \}\}/); assert.match(workflow, /name: \$\{\{ steps\.release_metadata\.outputs\.release_title \}\}/); assert.doesNotMatch(workflow, /^\s+body:\s*\|/m); }); + +test("CI requires all four native Electron candidates and their aggregate index", () => { + const workflow = read(".github/workflows/ci.yml"); + + for (const value of [ + "windows-x64", + "macos-x64", + "macos-arm64", + "linux-x64", + "macos-15-intel", + "macos-15", + "desktop:pack:candidate", + "desktop:stage:candidate", + "desktop:smoke:candidate:artifacts", + "desktop:verify:candidate:set" + ]) assert.match(workflow, new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(workflow, /ELECTRON_RELEASE_CANDIDATE_RESULT/); + assert.match(workflow, /ELECTRON_CANDIDATE_SET_RESULT/); + assert.match(workflow, /c10-evidence-bundle:/); + assert.match(workflow, /CPS_REQUIRED_JOB_RESULTS_JSON: \$\{\{ toJSON\(needs\) \}\}/); + assert.match(workflow, /C10_EVIDENCE_RESULT/); + assert.match(workflow, /vnext-c10-evidence-\$\{\{ github\.sha \}\}/); + assert.ok((workflow.match(/retention-days: 30/g) || []).length >= 3); + assert.match(workflow, /if-no-files-found: error/); +}); diff --git a/test/restore-v2-state-machine.test.js b/test/restore-v2-state-machine.test.js new file mode 100644 index 0000000..3f32d99 --- /dev/null +++ b/test/restore-v2-state-machine.test.js @@ -0,0 +1,890 @@ +import { execFile, spawn } from "node:child_process"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { defaultBackupRoot } from "../src/constants.js"; +import { pruneBackups } from "../src/backup.js"; +import { + readRestoreJournal, + RESTORE_JOURNAL_BASENAME, + RestoreJournal +} from "../src/restore-journal.js"; +import { + acknowledgePendingRestore, + RESTORE_SNAPSHOT_MANIFEST_BASENAME +} from "../src/restore-v2.js"; +import { getStatus, runRestore, runSwitch } from "../src/service.js"; +import { openDatabase } from "../src/sqlite.js"; +import { resolveStateDbLockResource } from "../src/state-db-lock.js"; +import { findPendingTransactions } from "../src/transaction-journal.js"; + +const execFileAsync = promisify(execFile); + +async function windowsShortDirectoryPath(directory) { + assert.equal(process.platform, "win32"); + const executable = path.join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe" + ); + const command = [ + "$fso = New-Object -ComObject Scripting.FileSystemObject", + "$folder = $fso.GetFolder($env:CPS_SHORT_PATH_TARGET)", + "$folder.ShortPath" + ].join("; "); + const { stdout } = await execFileAsync(executable, [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command + ], { + env: { ...process.env, CPS_SHORT_PATH_TARGET: directory }, + windowsHide: true + }); + const shortPath = stdout.trim(); + assert.equal(path.isAbsolute(shortPath), true, "PowerShell must return an absolute 8.3 alias"); + assert.notEqual( + path.resolve(shortPath).toLowerCase(), + path.resolve(directory).toLowerCase(), + "The Windows volume must expose an actual short-path alias for this fixture" + ); + return shortPath; +} + +async function makeFixture({ withDatabase = false } = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-restore-v2-")); + const codexHome = path.join(root, "home"); + await fs.mkdir(codexHome, { recursive: true }); + await fs.writeFile( + path.join(codexHome, "config.toml"), + 'model_provider = "apigather"\n\n[model_providers.apigather]\nname = "API Gather"\n', + "utf8" + ); + if (withDatabase) { + const dbPath = path.join(codexHome, "sqlite", "state_5.sqlite"); + 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, + archived INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO threads (id, model_provider, archived) + VALUES ('restore-v2', 'apigather', 0); + `); + } finally { + db.close(); + } + } + const rolloutPath = path.join( + codexHome, + "sessions", + "2026", + "08", + "26", + "rollout-restore-v2.jsonl" + ); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.writeFile( + rolloutPath, + [ + JSON.stringify({ + type: "session_meta", + payload: { id: "restore-v2", model_provider: "apigather", cwd: root } + }), + JSON.stringify({ type: "turn_context", payload: { model: "source-model" } }), + "" + ].join("\n"), + "utf8" + ); + const switched = await runSwitch({ + codexHome, + provider: "openai", + model: "target-model" + }); + return { + root, + codexHome, + rolloutPath, + sourceBackup: switched.backupDir + }; +} + +async function readDbProvider(codexHome) { + const db = await openDatabase(path.join(codexHome, "sqlite", "state_5.sqlite"), { readOnly: true }); + try { + return db.prepare("SELECT model_provider FROM threads WHERE id = 'restore-v2'").get().model_provider; + } finally { + db.close(); + } +} + +async function listRestoreJournals(codexHome) { + const root = defaultBackupRoot(codexHome); + const entries = await fs.readdir(root, { withFileTypes: true }); + const journals = []; + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith("restore-v2-")) continue; + journals.push(await readRestoreJournal(path.join(root, entry.name, RESTORE_JOURNAL_BASENAME))); + } + return journals.sort((left, right) => left.filePath.localeCompare(right.filePath)); +} + +function spawnCrash( + codexHome, + backupDir, + point, + { withDatabase = false, failurePoint = null } = {} +) { + const host = fileURLToPath(new URL("../test-support/restore-v2-crash-host.mjs", import.meta.url)); + return new Promise((resolve, reject) => { + const args = [host, codexHome, backupDir, point]; + if (withDatabase) args.push("--with-database"); + if (failurePoint) args.push("--fail-at", failurePoint); + const child = spawn(process.execPath, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("exit", (code, signal) => resolve({ code, signal, stderr })); + }); +} + +async function completeResolverJournal(journal, prepared, manifestSha256) { + await journal.applying(); + for (const target of prepared.targets) { + await journal.targetIntent(target.id); + await journal.targetCompleted(target.id, target.expectedPost.digest); + } + await journal.committing(manifestSha256); + await journal.committedPendingAck(manifestSha256); + await journal.completed(); +} + +for (const scenario of [ + { + name: "prepared", + crashPoint: "after_restore_prepared_before_applying", + expectedState: "prepared", + withDatabase: false + }, + { + name: "committing with SQLite", + crashPoint: "after_restore_committing_before_committed_pending_ack", + expectedState: "committing", + withDatabase: true + }, + { + name: "rollback-pending with SQLite", + crashPoint: "after_restore_rollback_pending_before_target", + failurePoint: "after_restore_target_write_before_complete", + expectedState: "rollback-pending", + withDatabase: true + } +]) { + test(`an explicit same-source Restore resolves a real ${scenario.name} process crash`, async () => { + const fixture = await makeFixture({ withDatabase: scenario.withDatabase }); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + scenario.crashPoint, + { + withDatabase: scenario.withDatabase, + failurePoint: scenario.failurePoint ?? null + } + ); + assert.equal(crashed.signal, null, crashed.stderr); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + assert.equal(pending.state, scenario.expectedState); + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, true); + + const recovered = await runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: scenario.withDatabase + }); + assert.equal(recovered.restoreJournalState, "completed"); + assert.deepEqual(recovered.resolvedOperationIds, [pending.operationId]); + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, false); + assert.match(await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"), /apigather/); + if (scenario.withDatabase) { + assert.equal(await readDbProvider(fixture.codexHome), "apigather"); + } + }); +} + +test("Restore v2 snapshot failure is pre-mutation and leaves no journal", async () => { + const fixture = await makeFixture(); + const configBefore = await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"); + const rolloutBefore = await fs.readFile(fixture.rolloutPath, "utf8"); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false, + faultInjector: ({ point }) => { + if (point === "after_restore_pre_snapshot_target_before_hash") { + throw new Error("snapshot fault"); + } + } + }), + (error) => error?.code === "BACKUP_FAILED" + ); + + assert.equal(await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"), configBefore); + assert.equal(await fs.readFile(fixture.rolloutPath, "utf8"), rolloutBefore); + assert.deepEqual(await listRestoreJournals(fixture.codexHome), []); +}); + +test("legacy runRestore preserves cancellation and does not mutate after a prepared-only abort", async () => { + const fixture = await makeFixture(); + const controller = new AbortController(); + const configPath = path.join(fixture.codexHome, "config.toml"); + const configBefore = await fs.readFile(configPath); + const rolloutBefore = await fs.readFile(fixture.rolloutPath); + const configStatBefore = await fs.stat(configPath); + const rolloutStatBefore = await fs.stat(fixture.rolloutPath); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false, + signal: controller.signal, + faultInjector: ({ point }) => { + if (point === "after_restore_prepared_before_applying") controller.abort(); + } + }), + (error) => error?.code === "OPERATION_CANCELLED" + ); + + assert.deepEqual(await fs.readFile(configPath), configBefore); + assert.deepEqual(await fs.readFile(fixture.rolloutPath), rolloutBefore); + assert.equal((await fs.stat(configPath)).mtimeMs, configStatBefore.mtimeMs); + assert.equal((await fs.stat(fixture.rolloutPath)).mtimeMs, rolloutStatBefore.mtimeMs); + const [journal] = await listRestoreJournals(fixture.codexHome); + assert.equal(journal.state, "rolled-back"); + assert.equal([...journal.targetPhases.values()].every((phase) => phase === "compensated"), true); +}); + +test("Restore progress observer failures are non-authoritative", async () => { + const fixture = await makeFixture(); + let calls = 0; + const result = await runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false, + onProgress: () => { + calls += 1; + if (calls % 2 === 0) return Promise.reject(new Error("async observer failure")); + throw new Error("sync observer failure"); + } + }); + + assert.ok(calls > 0); + assert.equal(result.restoreJournalState, "completed"); + assert.equal((await listRestoreJournals(fixture.codexHome))[0].state, "completed"); +}); + +test("Restore compensation rejects a swapped rollout junction before touching the external target", async () => { + const fixture = await makeFixture(); + const sessionsPath = path.join(fixture.codexHome, "sessions"); + const preservedSessionsPath = path.join(fixture.codexHome, "sessions-preserved"); + const externalSessionsPath = path.join(fixture.root, "external-sessions"); + const externalRolloutPath = path.join( + externalSessionsPath, + "2026", + "08", + "26", + path.basename(fixture.rolloutPath) + ); + await fs.mkdir(path.dirname(externalRolloutPath), { recursive: true }); + await fs.writeFile(externalRolloutPath, "external-sentinel\n", "utf8"); + const externalBefore = await fs.readFile(externalRolloutPath); + let swapped = false; + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreConfig: false, + restoreDatabase: false, + faultInjector: async ({ point, targetKind }) => { + if (!swapped + && point === "after_restore_rollback_pending_before_target" + && targetKind === "rollout") { + swapped = true; + await fs.rename(sessionsPath, preservedSessionsPath); + await fs.symlink( + externalSessionsPath, + sessionsPath, + process.platform === "win32" ? "junction" : "dir" + ); + } + if (point === "after_restore_target_write_before_complete" && targetKind === "rollout") { + throw new Error("force compensation"); + } + } + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + + assert.equal(swapped, true); + assert.deepEqual(await fs.readFile(externalRolloutPath), externalBefore); + const [journal] = await listRestoreJournals(fixture.codexHome); + assert.equal(journal.state, "recovery-required"); +}); + +test("Restore v2 compensates a mid-target failure to rolled-back", async () => { + const fixture = await makeFixture(); + const configBefore = await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"); + const rolloutBefore = await fs.readFile(fixture.rolloutPath, "utf8"); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false, + faultInjector: ({ point, targetKind }) => { + if (point === "after_restore_target_write_before_complete" && targetKind === "config") { + throw new Error("mid-restore fault"); + } + } + }), + /mid-restore fault/ + ); + + assert.equal(await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"), configBefore); + assert.equal(await fs.readFile(fixture.rolloutPath, "utf8"), rolloutBefore); + const journals = await listRestoreJournals(fixture.codexHome); + assert.equal(journals.length, 1); + assert.equal(journals[0].state, "rolled-back"); + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, false); +}); + +test("Restore v2 reconciles committed-pending-ack without rollback", async () => { + const fixture = await makeFixture(); + const result = await runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false, + faultInjector: ({ point }) => { + if (point === "after_restore_committed_pending_ack_before_completed") { + throw new Error("lost final acknowledgement"); + } + } + }); + + assert.equal(result.restoreJournalState, "completed"); + assert.equal(result.commitAcknowledgementRecovered, true); + assert.match(await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"), /apigather/); + assert.match(await fs.readFile(fixture.rolloutPath, "utf8"), /"model_provider":"apigather"/); + const journals = await listRestoreJournals(fixture.codexHome); + assert.equal(journals.length, 1); + assert.equal(journals[0].state, "completed"); +}); + +test("an explicit same-source Restore resolves an applying crash journal", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_target_write_before_complete" + ); + assert.equal(crashed.signal, null, crashed.stderr); + assert.equal(crashed.code, 86, crashed.stderr); + + const afterCrash = await getStatus({ codexHome: fixture.codexHome }); + assert.equal(afterCrash.pendingRecovery, true); + assert.equal(afterCrash.pendingTransactions.some((item) => item.operationKind === "restore"), true); + + const recovered = await runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false + }); + assert.equal(recovered.restoreJournalState, "completed"); + assert.equal(recovered.resolvedOperationIds.length, 1); + assert.match(await fs.readFile(path.join(fixture.codexHome, "config.toml"), "utf8"), /apigather/); + assert.match(await fs.readFile(fixture.rolloutPath, "utf8"), /"model_provider":"apigather"/); + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, false); + + const journals = await listRestoreJournals(fixture.codexHome); + const [oldJournal] = journals.filter((journal) => journal.state === "applying"); + assert.ok(oldJournal); + assert.equal(journals.filter((journal) => journal.state === "completed").length, 1); + + // Resolution admits the new explicit Restore, but does not authorize Prune + // to delete evidence referenced by the still-nonterminal older journal. + await pruneBackups(fixture.codexHome, 0); + await fs.access(fixture.sourceBackup); + await fs.access(oldJournal.snapshotDir); +}); + +test("foreign or incomplete Restore leaves a crash journal and its backups protected", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_target_write_before_complete" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + assert.equal(pending.state, "applying"); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false, + restoreSessions: false + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + assert.equal((await listRestoreJournals(fixture.codexHome)).length, 1); + + const pruned = await pruneBackups(fixture.codexHome, 0); + assert.equal(pruned.deletedCount, 0); + await fs.access(fixture.sourceBackup); + await fs.access(pending.snapshotDir); +}); + +test("a completed resolver with a different source cannot hide a pending Restore", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_target_write_before_complete" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + const pendingBefore = await fs.readFile(pending.filePath); + + const resolverDir = path.join(defaultBackupRoot(fixture.codexHome), "restore-v2-mismatched-resolver"); + await fs.mkdir(resolverDir, { recursive: true }); + const resolver = await RestoreJournal.create(resolverDir, { + ...pending.prepared, + operationId: "mismatched-resolver", + sourceBackup: { + ...pending.prepared.sourceBackup, + revision: `${pending.prepared.sourceBackup.revision}-different` + }, + preRestoreSnapshot: { + ...pending.prepared.preRestoreSnapshot, + backupId: "mismatched-resolver-snapshot", + backupDir: resolverDir + }, + resolvesOperationIds: [pending.operationId] + }); + await completeResolverJournal(resolver, pending.prepared, "resolver-post-manifest"); + + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, true); + await assert.rejects( + () => runSwitch({ codexHome: fixture.codexHome, provider: "openai" }), + (error) => error?.code === "RECOVERY_REQUIRED" + || error?.code === "PENDING_TRANSACTION" + ); + assert.deepEqual(await fs.readFile(pending.filePath), pendingBefore); +}); + +test("a completed resolver binds physical source and revision instead of display backupId", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_prepared_before_applying" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + const pendingBefore = await fs.readFile(pending.filePath); + + const resolverDir = path.join(defaultBackupRoot(fixture.codexHome), "restore-v2-display-id-resolver"); + await fs.mkdir(resolverDir, { recursive: true }); + const resolver = await RestoreJournal.create(resolverDir, { + ...pending.prepared, + operationId: "display-id-resolver", + sourceBackup: { + ...pending.prepared.sourceBackup, + backupId: "different-lexical-alias-name" + }, + preRestoreSnapshot: { + ...pending.prepared.preRestoreSnapshot, + backupId: "display-id-resolver-snapshot", + backupDir: resolverDir + }, + resolvesOperationIds: [pending.operationId] + }); + await completeResolverJournal(resolver, pending.prepared, "display-id-resolver-post-manifest"); + + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, false); + assert.equal( + (await findPendingTransactions(fixture.codexHome)) + .some((transaction) => transaction.operationId === pending.operationId), + false + ); + assert.deepEqual(await fs.readFile(pending.filePath), pendingBefore); + await pruneBackups(fixture.codexHome, 0); + await fs.access(fixture.sourceBackup); + await fs.access(pending.snapshotDir); +}); + +test("a completed resolver matches real Windows 8.3 and long physical path aliases", { + skip: process.platform !== "win32" +}, async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_prepared_before_applying" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + assert.equal(pending.state, "prepared"); + const pendingBefore = await fs.readFile(pending.filePath); + const shortCodexHome = await windowsShortDirectoryPath(pending.prepared.storage.codexHome); + const shortSourceBackup = await windowsShortDirectoryPath( + pending.prepared.sourceBackup.backupDir + ); + + const resolverDir = path.join(defaultBackupRoot(fixture.codexHome), "restore-v2-physical-alias-resolver"); + await fs.mkdir(resolverDir, { recursive: true }); + const resolver = await RestoreJournal.create(resolverDir, { + ...pending.prepared, + operationId: "physical-alias-resolver", + sourceBackup: { + ...pending.prepared.sourceBackup, + backupDir: shortSourceBackup + }, + storage: { + ...pending.prepared.storage, + codexHome: shortCodexHome + }, + preRestoreSnapshot: { + ...pending.prepared.preRestoreSnapshot, + backupId: "physical-alias-resolver-snapshot", + backupDir: await fs.realpath(resolverDir), + revision: "physical-alias-resolver-revision", + manifestSha256: "physical-alias-resolver-manifest" + }, + resolvesOperationIds: [pending.operationId] + }); + await completeResolverJournal(resolver, pending.prepared, "physical-alias-post-manifest"); + + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, false); + assert.deepEqual(await fs.readFile(pending.filePath), pendingBefore); +}); + +test("a completed resolver with a different persisted physical Home cannot hide pending Restore", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_prepared_before_applying" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + const otherPhysicalHome = path.join(fixture.root, "other-physical-home"); + await fs.mkdir(otherPhysicalHome, { recursive: true }); + const resolverDir = path.join(defaultBackupRoot(fixture.codexHome), "restore-v2-other-home-resolver"); + await fs.mkdir(resolverDir, { recursive: true }); + const resolverPrepared = { + ...pending.prepared, + operationId: "other-home-resolver", + storage: { + ...pending.prepared.storage, + codexHomePhysical: await fs.realpath(otherPhysicalHome) + }, + preRestoreSnapshot: { + ...pending.prepared.preRestoreSnapshot, + backupId: "other-home-resolver-snapshot", + backupDir: resolverDir, + revision: "other-home-resolver-revision", + manifestSha256: "other-home-resolver-manifest" + }, + resolvesOperationIds: [pending.operationId] + }; + const resolver = await RestoreJournal.create( + resolverDir, + resolverPrepared + ); + await completeResolverJournal(resolver, resolverPrepared, "other-home-post-manifest"); + + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, true); + await assert.rejects( + () => runSwitch({ codexHome: fixture.codexHome, provider: "openai" }), + (error) => error?.code === "RECOVERY_REQUIRED" + || error?.code === "PENDING_TRANSACTION" + ); +}); + +test("Restore preflight rejects a same-source pending journal bound to another physical Home", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_prepared_before_applying" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + const otherPhysicalHome = path.join(fixture.root, "foreign-physical-home"); + await fs.mkdir(otherPhysicalHome, { recursive: true }); + const lines = (await fs.readFile(pending.filePath, "utf8")).trimEnd().split(/\r?\n/); + const prepared = JSON.parse(lines[0]); + prepared.storage.codexHomePhysical = await fs.realpath(otherPhysicalHome); + lines[0] = JSON.stringify(prepared); + await fs.writeFile(pending.filePath, `${lines.join("\n")}\n`, "utf8"); + const journalBefore = await fs.readFile(pending.filePath); + const backupDirectoriesBefore = (await fs.readdir(defaultBackupRoot(fixture.codexHome))).sort(); + const configBefore = await fs.readFile(path.join(fixture.codexHome, "config.toml")); + const rolloutBefore = await fs.readFile(fixture.rolloutPath); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + + assert.deepEqual(await fs.readFile(pending.filePath), journalBefore); + assert.deepEqual((await fs.readdir(defaultBackupRoot(fixture.codexHome))).sort(), backupDirectoriesBefore); + assert.deepEqual(await fs.readFile(path.join(fixture.codexHome, "config.toml")), configBefore); + assert.deepEqual(await fs.readFile(fixture.rolloutPath), rolloutBefore); +}); + +test("a completed resolver without per-target evidence cannot hide a pending Restore", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_prepared_before_applying" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + const resolverDir = path.join(defaultBackupRoot(fixture.codexHome), "restore-v2-incomplete-resolver"); + await fs.mkdir(resolverDir, { recursive: true }); + const resolver = await RestoreJournal.create(resolverDir, { + ...pending.prepared, + operationId: "incomplete-resolver", + preRestoreSnapshot: { + ...pending.prepared.preRestoreSnapshot, + backupId: "incomplete-resolver-snapshot", + backupDir: resolverDir, + revision: "incomplete-resolver-revision", + manifestSha256: "incomplete-resolver-manifest" + }, + resolvesOperationIds: [pending.operationId] + }); + await resolver.applying(); + await resolver.committing("incomplete-resolver-post-manifest"); + await resolver.committedPendingAck("incomplete-resolver-post-manifest"); + await resolver.completed(); + + const journals = await listRestoreJournals(fixture.codexHome); + const incomplete = journals.find((journal) => journal.snapshotDir === resolverDir); + assert.equal(incomplete.invalidTail, true); + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, true); + await assert.rejects( + () => runSwitch({ codexHome: fixture.codexHome, provider: "openai" }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); +}); + +test("committed-pending-ack hash drift becomes recovery-required without compensation", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_committed_pending_ack_before_completed" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const configPath = path.join(fixture.codexHome, "config.toml"); + await fs.writeFile(configPath, 'model_provider = "external-drift"\n', "utf8"); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + + assert.match(await fs.readFile(configPath, "utf8"), /external-drift/); + const [journal] = await listRestoreJournals(fixture.codexHome); + assert.equal(journal.state, "recovery-required"); + assert.equal((await getStatus({ codexHome: fixture.codexHome })).pendingRecovery, true); +}); + +test("committed-pending-ack rejects a rehashed manifest that disagrees with prepared evidence", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_committed_pending_ack_before_completed" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + const manifestPath = path.join(pending.snapshotDir, RESTORE_SNAPSHOT_MANIFEST_BASENAME); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + manifest.sourceBackup.revision = `${manifest.sourceBackup.revision}-mismatched`; + const manifestText = `${JSON.stringify(manifest, null, 2)}\n`; + const manifestSha256 = createHash("sha256") + .update(manifestText, "utf8") + .digest("base64url"); + await fs.writeFile(manifestPath, manifestText, "utf8"); + const lines = (await fs.readFile(pending.filePath, "utf8")).trimEnd().split(/\r?\n/); + const prepared = JSON.parse(lines[0]); + prepared.preRestoreSnapshot.manifestSha256 = manifestSha256; + lines[0] = JSON.stringify(prepared); + await fs.writeFile(pending.filePath, `${lines.join("\n")}\n`, "utf8"); + const configBefore = await fs.readFile(path.join(fixture.codexHome, "config.toml")); + const rolloutBefore = await fs.readFile(fixture.rolloutPath); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + + assert.deepEqual(await fs.readFile(path.join(fixture.codexHome, "config.toml")), configBefore); + assert.deepEqual(await fs.readFile(fixture.rolloutPath), rolloutBefore); + const [failed] = await listRestoreJournals(fixture.codexHome); + assert.equal(failed.state, "recovery-required"); + assert.equal(failed.events.some((event) => event.state === "rollback-pending"), false); +}); + +test("commit acknowledgement revalidates the current physical State DB identity", async () => { + const fixture = await makeFixture({ withDatabase: true }); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_committed_pending_ack_before_completed", + { withDatabase: true } + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [pending] = await listRestoreJournals(fixture.codexHome); + assert.equal(pending.state, "committed-pending-ack"); + const dbPath = path.join(fixture.codexHome, "sqlite", "state_5.sqlite"); + const stateDbResource = await resolveStateDbLockResource(dbPath); + + await assert.rejects( + () => acknowledgePendingRestore(pending, { + stateDbResource, + resolveStateDbResource: async () => ({ + ...stateDbResource, + resourceKey: `${stateDbResource.resourceKey}-changed` + }) + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + + assert.equal(await readDbProvider(fixture.codexHome), "apigather"); + assert.equal((await listRestoreJournals(fixture.codexHome))[0].state, "recovery-required"); +}); + +test("Restore v2 uses online SQLite snapshot for compensation and commit", async () => { + const fixture = await makeFixture({ withDatabase: true }); + assert.equal(await readDbProvider(fixture.codexHome), "openai"); + + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + faultInjector: ({ point, targetKind }) => { + if (point === "after_restore_target_write_before_complete" && targetKind === "sqlite") { + throw new Error("sqlite restore fault"); + } + } + }), + /sqlite restore fault/ + ); + assert.equal(await readDbProvider(fixture.codexHome), "openai"); + assert.equal((await listRestoreJournals(fixture.codexHome))[0].state, "rolled-back"); + + const restored = await runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup + }); + assert.equal(restored.restoreJournalState, "completed"); + assert.equal(await readDbProvider(fixture.codexHome), "apigather"); +}); + +test("unknown Restore journal schema fails closed and protects referenced evidence", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_target_write_before_complete" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [journal] = await listRestoreJournals(fixture.codexHome); + const originalText = await fs.readFile(journal.filePath, "utf8"); + const lines = originalText.trimEnd().split("\n"); + const first = JSON.parse(lines[0]); + first.schemaVersion = 99; + lines[0] = JSON.stringify(first); + const unknownText = `${lines.join("\n")}\n`; + await fs.writeFile(journal.filePath, unknownText, "utf8"); + + const status = await getStatus({ codexHome: fixture.codexHome }); + assert.equal(status.pendingRecovery, true); + await assert.rejects( + () => runRestore({ + codexHome: fixture.codexHome, + backupDir: fixture.sourceBackup, + restoreDatabase: false + }), + (error) => error?.code === "RECOVERY_REQUIRED" + ); + assert.equal(await fs.readFile(journal.filePath, "utf8"), unknownText); + + const pruned = await pruneBackups(fixture.codexHome, 0); + assert.equal(pruned.deletedCount, 0); + await fs.access(fixture.sourceBackup); + await fs.access(journal.snapshotDir); +}); + +test("truncated Restore prepared record makes Prune a global no-op", async () => { + const fixture = await makeFixture(); + const crashed = await spawnCrash( + fixture.codexHome, + fixture.sourceBackup, + "after_restore_target_write_before_complete" + ); + assert.equal(crashed.code, 86, crashed.stderr); + const [journal] = await listRestoreJournals(fixture.codexHome); + const truncated = '{"schemaVersion":2,"sourceBackup":'; + await fs.writeFile(journal.filePath, truncated, "utf8"); + + const parsed = await readRestoreJournal(journal.filePath); + assert.equal(parsed.invalidTail, true); + assert.equal(parsed.protectionReferencesUnverifiable, true); + + const before = await fs.readdir(defaultBackupRoot(fixture.codexHome)); + const pruned = await pruneBackups(fixture.codexHome, 0); + const after = await fs.readdir(defaultBackupRoot(fixture.codexHome)); + assert.equal(pruned.deletedCount, 0); + assert.deepEqual(after.sort(), before.sort()); + await fs.access(fixture.sourceBackup); + await fs.access(journal.snapshotDir); +}); diff --git a/test/sqlite-error-contract.test.js b/test/sqlite-error-contract.test.js new file mode 100644 index 0000000..cdb395f --- /dev/null +++ b/test/sqlite-error-contract.test.js @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CoreError } from "../src/core-error.js"; +import { wrapSqliteBusyError, wrapSqliteMalformedError } from "../src/sqlite-state.js"; + +function sqliteError({ code = "ERR_SQLITE_ERROR", errcode, message = "driver failure" } = {}) { + const error = new Error(message); + error.code = code; + if (errcode !== undefined) error.errcode = errcode; + return error; +} + +test("SQLite driver primary result codes 5 and 6 map to SQLITE_BUSY", () => { + for (const errcode of [5, 6, 261, 262]) { + const error = wrapSqliteBusyError(sqliteError({ errcode }), "write test data"); + assert.ok(error instanceof CoreError); + assert.equal(error.code, "SQLITE_BUSY"); + assert.equal(error.details.sqlitePrimaryCode, errcode & 0xff); + assert.equal(error.cause.errcode, errcode); + } +}); + +test("SQLite driver primary result codes 11 and 26 map to SQLITE_UNREADABLE", () => { + for (const errcode of [11, 26, 267]) { + const error = wrapSqliteMalformedError(sqliteError({ errcode }), "read test data"); + assert.ok(error instanceof CoreError); + assert.equal(error.code, "SQLITE_UNREADABLE"); + assert.equal(error.details.sqlitePrimaryCode, errcode & 0xff); + } +}); + +test("explicit SQLite symbolic codes map without depending on English text", () => { + assert.equal( + wrapSqliteBusyError(sqliteError({ code: "SQLITE_LOCKED", message: "localized" }), "write").code, + "SQLITE_BUSY" + ); + assert.equal( + wrapSqliteMalformedError(sqliteError({ code: "SQLITE_NOTADB", message: "localized" }), "read").code, + "SQLITE_UNREADABLE" + ); +}); + +test("message-only lookalikes are not treated as stable SQLite classifications", () => { + const busyLookalike = new Error("database is locked"); + const malformedLookalike = new Error("file is not a database"); + + assert.equal(wrapSqliteBusyError(busyLookalike, "write"), busyLookalike); + assert.equal(wrapSqliteMalformedError(malformedLookalike, "read"), malformedLookalike); +}); diff --git a/test/state-db-lock.test.js b/test/state-db-lock.test.js new file mode 100644 index 0000000..34287c9 --- /dev/null +++ b/test/state-db-lock.test.js @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; + +import { + acquireStateDbLock, + resolveStateDbLockResource +} from "../src/state-db-lock.js"; + +test("State DB resource identity uses the real parent, NUL delimiter, and SHA-256 lock path", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-state-lock-")); + const sqliteHome = path.join(root, "sqlite"); + const stateDbPath = path.join(sqliteHome, "state_5.sqlite"); + try { + await fs.mkdir(sqliteHome, { recursive: true }); + await fs.writeFile(stateDbPath, "fixture", "utf8"); + const resource = await resolveStateDbLockResource(stateDbPath); + const realParent = await fs.realpath(sqliteHome); + const expectedIdentity = `${process.platform === "win32" ? realParent.toLowerCase() : realParent}\0state_5.sqlite`; + const expectedKey = createHash("sha256").update(expectedIdentity, "utf8").digest("hex"); + assert.equal(resource.identity, expectedIdentity); + assert.equal(resource.resourceKey, expectedKey); + assert.equal( + resource.lockPath, + path.join(await fs.realpath(sqliteHome), ".codex-provider-sync", "locks", `${expectedKey}.lock`) + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("State DB locks publish scope/resourceKey and report state-db contention", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-state-lock-")); + const sqliteHome = path.join(root, "sqlite"); + const stateDbPath = path.join(sqliteHome, "state_5.sqlite"); + try { + await fs.mkdir(sqliteHome, { recursive: true }); + await fs.writeFile(stateDbPath, "fixture", "utf8"); + const first = await acquireStateDbLock(stateDbPath, "fixture-one"); + try { + const owner = JSON.parse(await fs.readFile(path.join(first.resource.lockPath, "owner.json"), "utf8")); + assert.equal(owner.scope, "state-db"); + assert.equal(owner.resourceKey, first.resource.resourceKey); + await assert.rejects( + acquireStateDbLock(stateDbPath, "fixture-two"), + (error) => error?.code === "OPERATION_BUSY" && error?.details?.busyScope === "state-db" + ); + } finally { + await first.release(); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("State DB resource identity supports a missing database only when its parent is verifiable", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-state-lock-")); + try { + const sqliteHome = path.join(root, "sqlite"); + await fs.mkdir(sqliteHome); + const resource = await resolveStateDbLockResource(path.join(sqliteHome, "state_5.sqlite")); + assert.match(resource.resourceKey, /^[a-f0-9]{64}$/); + + await assert.rejects( + resolveStateDbLockResource(path.join(root, "missing", "state_5.sqlite")), + (error) => error?.code === "LOCK_UNVERIFIABLE" + && error?.details?.lockScope === "state-db" + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("a real Node process publishes a State DB lock that blocks another process", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-state-lock-process-")); + const sqliteHome = path.join(root, "sqlite"); + const stateDbPath = path.join(sqliteHome, "state_5.sqlite"); + let child; + let childExit; + try { + await fs.mkdir(sqliteHome, { recursive: true }); + await fs.writeFile(stateDbPath, "fixture", "utf8"); + const moduleUrl = pathToFileURL(path.resolve("src/state-db-lock.js")).href; + const script = ` + import { acquireStateDbLock } from ${JSON.stringify(moduleUrl)}; + const held = await acquireStateDbLock(${JSON.stringify(stateDbPath)}, "child-winner"); + console.log(JSON.stringify({ ready: true, resourceKey: held.resource.resourceKey })); + await new Promise((resolve) => process.stdin.once("data", resolve)); + await held.release(); + `; + child = spawn(process.execPath, ["--input-type=module", "-e", script], { + cwd: path.resolve("."), + stdio: ["pipe", "pipe", "pipe"] + }); + childExit = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + const lines = createInterface({ input: child.stdout }); + const readyLine = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("child State DB lock did not become ready")), + 15000 + ); + lines.once("line", (line) => { + clearTimeout(timeout); + resolve(line); + }); + }); + const ready = JSON.parse(readyLine); + const resource = await resolveStateDbLockResource(stateDbPath); + assert.equal(ready.resourceKey, resource.resourceKey); + await assert.rejects( + acquireStateDbLock(stateDbPath, "parent-contender"), + (error) => error?.code === "OPERATION_BUSY" && error?.details?.busyScope === "state-db" + ); + lines.close(); + child.stdin.end("release\n"); + const exitCode = await childExit; + assert.equal(exitCode, 0); + child = null; + } finally { + if (child) { + child.stdin.end("release\n"); + await childExit; + } + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/status-coordination.test.js b/test/status-coordination.test.js new file mode 100644 index 0000000..51b3a75 --- /dev/null +++ b/test/status-coordination.test.js @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { openDatabase } from "../src/sqlite.js"; +import { createWebCoreFacade } from "../src/web-core-adapter.js"; +import { createWebUiServer } from "../src/web-server.js"; +import { createMemoryWebUiState } from "../src/web-state.js"; + +async function request(origin, pathname, body, headers = {}) { + return new Promise((resolve, reject) => { + const target = new URL(origin); + const serialized = body === undefined ? null : JSON.stringify(body); + const client = http.request({ + hostname: target.hostname, + port: target.port, + path: pathname, + method: "POST", + headers: { + ...(serialized ? { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(serialized) + } : {}), + ...headers + } + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + resolve({ status: response.statusCode, payload: JSON.parse(text) }); + }); + }); + client.once("error", reject); + if (serialized) client.write(serialized); + client.end(); + }); +} + +async function makeFixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-status-lock-")); + const codexHome = path.join(root, ".codex"); + const sqliteHome = path.join(root, "shared-sqlite"); + const stateDbPath = path.join(sqliteHome, "state_5.sqlite"); + const rolloutPath = path.join(codexHome, "sessions", "2026", "08", "25", "rollout-status.jsonl"); + await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); + await fs.mkdir(sqliteHome, { recursive: true }); + const configText = (provider) => [ + `model_provider = "${provider}"`, + `sqlite_home = ${JSON.stringify(sqliteHome)}`, + "" + ].join("\n"); + await fs.writeFile(path.join(codexHome, "config.toml"), configText("openai"), "utf8"); + await fs.writeFile(rolloutPath, `${JSON.stringify({ + timestamp: "2026-08-25T00:00:00.000Z", + type: "session_meta", + payload: { + id: "status-thread", + model_provider: "openai", + cwd: "C:\\AITemp" + } + })}\n`, "utf8"); + const database = await openDatabase(stateDbPath); + try { + database.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 '', + model TEXT + ) + `); + database.prepare("INSERT INTO threads (id, model_provider, cwd, archived, first_user_message) VALUES (?, ?, ?, ?, ?)") + .run("status-thread", "openai", "C:\\AITemp", 0, "redacted"); + } finally { + database.close(); + } + return { root, codexHome, sqliteHome, stateDbPath, configText }; +} + +async function startRealWeb(codexHome, webRoot) { + await fs.mkdir(webRoot, { recursive: true }); + await fs.writeFile(path.join(webRoot, "index.html"), "status", "utf8"); + const stateStore = createMemoryWebUiState({ codexHome }); + const coreFacade = createWebCoreFacade(stateStore); + const handle = createWebUiServer({ webRoot, stateStore, services: { coreFacade } }); + await new Promise((resolve, reject) => { + handle.server.once("error", reject); + handle.server.listen(0, "127.0.0.1", resolve); + }); + const address = handle.server.address(); + const origin = `http://127.0.0.1:${address.port}`; + handle.setBaseUrl(origin); + const paired = await request(origin, "/api/pair", undefined, { + Origin: origin, + "X-Codex-Provider-Pairing": handle.issuePairing() + }); + assert.equal(paired.status, 200); + const credential = paired.payload.deviceCredential; + return { + handle, + coreFacade, + stateStore, + origin, + async status() { + return request(origin, "/api/status", { profileId: "default" }, { + Origin: origin, + "X-Codex-Provider-Device": credential + }); + }, + async close() { + await new Promise((resolve, reject) => handle.server.close((error) => error ? reject(error) : resolve())); + } + }; +} + +async function startHolder({ mode, codexHome, stateDbPath, configText = "" }) { + const lockingUrl = new URL("../src/locking.js", import.meta.url).href; + const stateLockUrl = new URL("../src/state-db-lock.js", import.meta.url).href; + const sqliteUrl = new URL("../src/sqlite.js", import.meta.url).href; + const script = ` + import fs from "node:fs/promises"; + import path from "node:path"; + import { once } from "node:events"; + import { acquireLock } from ${JSON.stringify(lockingUrl)}; + import { acquireStateDbLock } from ${JSON.stringify(stateLockUrl)}; + import { openDatabase } from ${JSON.stringify(sqliteUrl)}; + const mode = process.env.STATUS_HOLDER_MODE; + const codexHome = process.env.STATUS_CODEX_HOME; + const stateDbPath = process.env.STATUS_STATE_DB; + const releaseHome = mode === "home" ? await acquireLock(codexHome, "external-status-home") : null; + const heldState = await acquireStateDbLock(stateDbPath, mode === "home" ? "external-status-home" : "external-status-db"); + try { + if (mode === "home") { + await fs.writeFile(path.join(codexHome, "config.toml"), process.env.STATUS_CONFIG_TEXT, "utf8"); + } else { + const database = await openDatabase(stateDbPath); + try { + database.prepare("UPDATE threads SET model_provider = ? WHERE id = ?").run("external", "status-thread"); + } finally { + database.close(); + } + } + process.stdout.write(JSON.stringify({ ready: true }) + "\\n"); + await once(process.stdin, "data"); + } finally { + await heldState.release(); + if (releaseHome) await releaseHome(); + } + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { + cwd: process.cwd(), + env: { + ...process.env, + STATUS_HOLDER_MODE: mode, + STATUS_CODEX_HOME: codexHome, + STATUS_STATE_DB: stateDbPath, + STATUS_CONFIG_TEXT: configText + }, + stdio: ["pipe", "pipe", "pipe"] + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const ready = await new Promise((resolve, reject) => { + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + const lineEnd = stdout.indexOf("\n"); + if (lineEnd < 0) return; + try { + resolve(JSON.parse(stdout.slice(0, lineEnd))); + } catch (error) { + reject(error); + } + }); + child.once("error", reject); + child.once("exit", (code) => { + if (code !== null) reject(new Error(`Status lock holder exited ${code} before ready: ${stderr}`)); + }); + }); + assert.equal(ready.ready, true); + return async () => { + child.stdin.end("release\n"); + const code = await new Promise((resolve) => child.once("exit", resolve)); + assert.equal(code, 0, stderr); + }; +} + +function asLastComplete(status) { + const value = JSON.parse(JSON.stringify(status)); + value.operationInProgress = null; + delete value.statusReadBlocked; + delete value.alignment; + return value; +} + +test("Core and Web Status preserve the last complete snapshot under external Home and shared State DB locks", async () => { + const fixture = await makeFixture(); + const web = await startRealWeb(fixture.codexHome, path.join(fixture.root, "web")); + let releaseHolder = null; + try { + const profileRevision = web.stateStore.getProfile("default").revision; + const statusOptions = { + profile: { profileId: "default", profileRevision } + }; + const readCoreStatus = () => web.coreFacade.getStatus(statusOptions); + const baselineWebResponse = await web.status(); + assert.equal(baselineWebResponse.status, 200); + const baselineCore = await readCoreStatus(); + + releaseHolder = await startHolder({ + mode: "home", + codexHome: fixture.codexHome, + stateDbPath: fixture.stateDbPath, + configText: fixture.configText("external") + }); + const blockedCore = await readCoreStatus(); + const blockedWebResponse = await web.status(); + assert.equal(blockedCore.operationInProgress.actor, "external"); + assert.equal(blockedCore.operationInProgress.busyScope, "codex-home"); + assert.equal(blockedCore.statusReadBlocked.reason, "codex-home-lock"); + assert.deepEqual(asLastComplete(blockedCore), asLastComplete(baselineCore)); + assert.equal(blockedWebResponse.status, 200); + assert.equal(blockedWebResponse.payload.status.alignment.aligned, false); + assert.equal(blockedWebResponse.payload.status.operationInProgress.busyScope, "codex-home"); + assert.deepEqual( + asLastComplete(blockedWebResponse.payload.status), + asLastComplete(baselineCore) + ); + await releaseHolder(); + releaseHolder = null; + assert.equal((await readCoreStatus()).currentProvider, "external"); + + await fs.writeFile(path.join(fixture.codexHome, "config.toml"), fixture.configText("openai"), "utf8"); + assert.equal((await web.status()).status, 200); + const stateBaselineCore = await readCoreStatus(); + releaseHolder = await startHolder({ + mode: "state-db", + codexHome: fixture.codexHome, + stateDbPath: fixture.stateDbPath + }); + const stateBlockedCore = await readCoreStatus(); + const stateBlockedWeb = (await web.status()).payload.status; + assert.equal(stateBlockedCore.operationInProgress.busyScope, "state-db"); + assert.equal(stateBlockedCore.statusReadBlocked.reason, "state-db-lock"); + assert.deepEqual(asLastComplete(stateBlockedCore), asLastComplete(stateBaselineCore)); + assert.equal(stateBlockedWeb.alignment.aligned, false); + assert.equal(stateBlockedWeb.operationInProgress.busyScope, "state-db"); + assert.deepEqual(asLastComplete(stateBlockedWeb), asLastComplete(stateBaselineCore)); + await releaseHolder(); + releaseHolder = null; + const refreshed = await readCoreStatus(); + assert.equal(refreshed.sqliteCounts.sessions.external, 1); + + const lockDir = path.join(fixture.codexHome, "tmp", "provider-sync.lock"); + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile(path.join(lockDir, "owner.json"), "{malformed", "utf8"); + const unverifiable = await readCoreStatus(); + assert.equal(unverifiable.operationInProgress.lockState, "unverifiable"); + assert.equal(unverifiable.rolloutScanComplete, false); + assert.ok(unverifiable.statusReadBlocked); + await fs.rm(lockDir, { recursive: true, force: true }); + } finally { + await releaseHolder?.().catch(() => {}); + await web.close(); + await fs.rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/test/sync-service.test.js b/test/sync-service.test.js index b64794a..8ed18ac 100644 --- a/test/sync-service.test.js +++ b/test/sync-service.test.js @@ -13,15 +13,18 @@ import { restoreBackup, updateSessionBackupManifest } from "../src/backup.js"; -import { getStatus, renderStatus, runRestore, runSwitch, runSync } from "../src/service.js"; +import { getStatus, runPruneBackups, runRestore, runSwitch, runSync } from "../src/service.js"; +import { renderStatus } from "../src/cli-presenter.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, - createWindowsExclusiveRewriteWorker + createWindowsExclusiveRewriteWorker, + restoreSessionChanges } from "../src/session-files.js"; import { openDatabase } from "../src/sqlite.js"; +import { assertSqliteWritable } from "../src/sqlite-state.js"; import { TransactionJournal, findPendingTransactions, @@ -31,6 +34,58 @@ import { syncDirectory, writeFileAtomic } from "../src/atomic-file.js"; delete process.env.CODEX_SQLITE_HOME; +test("public write adapters expose typed invalid-input errors", async () => { + const cases = [ + () => runSync({ keepCount: 0 }), + () => runSwitch({}), + () => runRestore({}), + () => runPruneBackups({ keepCount: -1 }) + ]; + + for (const invoke of cases) { + await assert.rejects( + invoke, + (error) => error?.code === "INVALID_INPUT" + && typeof error.toDto === "function" + ); + } +}); + +test("public write adapters expose typed validation and stale-confirmation errors", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome); + + await assert.rejects( + () => runSwitch({ codexHome, provider: "apigather", model: "" }), + (error) => error?.code === "INVALID_INPUT" && /Invalid --model value/.test(error.message) + ); + await assert.rejects( + () => runRestore({ + codexHome, + backupDir: path.join(codexHome, "unused-backup"), + allowSqliteHomeRelocation: true + }), + (error) => error?.code === "INVALID_INPUT" + && /requires an explicit --sqlite-home/.test(error.message) + ); + + const staleCalls = [ + () => runSync({ codexHome, expectedConfigText: "stale" }), + () => runSwitch({ codexHome, provider: "apigather", expectedConfigText: "stale" }), + () => runRestore({ + codexHome, + backupDir: path.join(codexHome, "unused-backup"), + expectedConfigText: "stale" + }) + ]; + for (const invoke of staleCalls) { + await assert.rejects( + invoke, + (error) => error?.code === "PLAN_STALE" && typeof error.toDto === "function" + ); + } +}); + test("runSync rolls back the first rollout when a later target fails (#69)", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); @@ -1153,6 +1208,7 @@ test("partial restore cannot clear a pending SQLite transaction", async () => { restoreSessions: false }), (error) => error?.code === "RECOVERY_REQUIRED" + && typeof error.toDto === "function" && error.missingRestoreKinds.includes("SQLite database") ); assert.equal(await readProvider(codexHome, "thread-partial-restore"), "openai"); @@ -2448,7 +2504,8 @@ test("runSwitch rejects --model and --keep-root-model together", async () => { await assert.rejects( () => runSwitch({ codexHome, provider: "apigather", model: "X", keepRootModel: true }), - /--model and --keep-root-model are mutually exclusive/ + (error) => error?.code === "INVALID_INPUT" + && /--model and --keep-root-model are mutually exclusive/.test(error.message) ); // Confirm the file on disk was not mutated by the failed call. @@ -2903,6 +2960,106 @@ test("runSync leaves turn_context model field alone when no model is provided", } }); +test("a provider-only managed backup restores its original turn_context models after a later switch", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"\n'); + const configPath = path.join(codexHome, "config.toml"); + const sessionPath = path.join( + codexHome, + "sessions", + "2026", + "06", + "09", + "rollout-provider-only-backup.jsonl" + ); + await writeRolloutWithTurnContext(sessionPath, { + id: "thread-provider-only-backup", + provider: "legacy-provider", + model: "legacy-model" + }); + const original = await fs.readFile(sessionPath); + + const firstScan = await collectSessionChanges(codexHome, "openai"); + assert.equal(firstScan.changes.length, 1); + assert.equal(firstScan.changes[0].modelRewriteRequired, false); + assert.equal(firstScan.changes[0].originalTurnContextModels.length, 2); + const firstBackup = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: firstScan.changes, + configPath + }); + await applySessionChanges(firstScan.changes); + await updateSessionBackupManifest(firstBackup, firstScan.changes); + + const laterScan = await collectSessionChanges(codexHome, "relay", { + targetModel: "later-model" + }); + await applySessionChanges(laterScan.changes, { targetModel: "later-model" }); + assert.match(await fs.readFile(sessionPath, "utf8"), /"model":"later-model"/); + + await restoreBackup(firstBackup, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }); + + assert.deepEqual(await fs.readFile(sessionPath), original); +}); + +test("rollout restore fails closed when an expected turn_context changes after provider restore", async () => { + const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-restore-race-")); + const sessionPath = path.join(testRoot, "rollout.jsonl"); + const originalFirstLine = JSON.stringify({ + type: "session_meta", + payload: { id: "restore-race", model_provider: "openai" } + }); + const currentFirstLine = JSON.stringify({ + type: "session_meta", + payload: { id: "restore-race", model_provider: "relay" } + }); + const currentTurnContext = JSON.stringify({ + type: "turn_context", + payload: { model: "relay-model" } + }); + try { + await fs.writeFile(sessionPath, `${currentFirstLine}\n${currentTurnContext}\n`, "utf8"); + const originalMtimeMs = (await fs.stat(sessionPath)).mtimeMs; + await assert.rejects( + () => restoreSessionChanges([{ + path: sessionPath, + originalFirstLine, + originalSeparator: "\n", + originalMtimeMs, + modelOnlyChange: false, + originalTurnContextModels: [{ + lineIndex: 1, + originalModel: "legacy-model", + originalModels: ["legacy-model"] + }] + }], { + async onAfterFirstLineRestore() { + const externalEvent = JSON.stringify({ + type: "event_msg", + payload: { type: "agent_reasoning", marker: "external-change" } + }); + await fs.writeFile(sessionPath, `${originalFirstLine}\n${externalEvent}\n`, "utf8"); + } + }), + (error) => error instanceof AggregateError + && error.errors.some((failure) => failure.cause?.code === "ROLLOUT_CHANGED") + ); + const [restoredMeta, externalLine] = (await fs.readFile(sessionPath, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + assert.equal(restoredMeta.payload.model_provider, "openai"); + assert.equal(externalLine.payload.marker, "external-change"); + } finally { + await fs.rm(testRoot, { recursive: true, force: true }); + } +}); + test("status reports implicit default provider and rollout/sqlite counts", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome); @@ -3085,7 +3242,8 @@ test("runSwitch rejects unknown custom providers", async () => { await writeConfig(codexHome); await assert.rejects( () => runSwitch({ codexHome, provider: "missing" }), - /Provider "missing" is not available/ + (error) => error?.code === "INVALID_INPUT" + && /Provider "missing" is not available/.test(error.message) ); }); @@ -3144,7 +3302,7 @@ test("runSync leaves rollout files and sqlite untouched when sqlite is locked", try { lockDb.exec("BEGIN IMMEDIATE"); await assert.rejects( - () => runSync({ codexHome, sqliteBusyTimeoutMs: 0 }), + () => runSync({ codexHome }), /state_5\.sqlite is currently in use/ ); } finally { @@ -3170,6 +3328,33 @@ test("runSync leaves rollout files and sqlite untouched when sqlite is locked", } }); +test("assertSqliteWritable defaults to a fail-fast SQLite busy policy", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeStateDb(codexHome, [ + { id: "thread-default-busy", model_provider: "openai", archived: false } + ]); + const lockDb = await openDatabase(stateDbPath(codexHome)); + try { + lockDb.exec("BEGIN IMMEDIATE"); + const startedAt = Date.now(); + await assert.rejects( + () => assertSqliteWritable(codexHome), + (error) => error?.code === "SQLITE_BUSY" + ); + assert.ok( + Date.now() - startedAt < 1500, + "The default SQLite busy timeout must not wait before reporting contention." + ); + } finally { + try { + lockDb.exec("ROLLBACK"); + } catch { + // Ignore cleanup failures in tests. + } + lockDb.close(); + } +}); + test("runSync skips locked rollout files and still updates sqlite", async () => { if (process.platform !== "win32") { return; @@ -3524,6 +3709,91 @@ test("restoreBackup rejects escaped and non-rollout session manifest targets", a ); }); +test("restoreBackup rejects rollout targets that traverse a directory link", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const configPath = path.join(codexHome, "config.toml"); + const sessionsRoot = path.join(codexHome, "sessions"); + const realDirectory = path.join(sessionsRoot, "2026", "linked-target"); + const linkedDirectory = path.join(sessionsRoot, "linked-alias"); + const realPath = path.join(realDirectory, "rollout-linked.jsonl"); + await fs.mkdir(realDirectory, { recursive: true }); + await writeRollout(realPath, "thread-linked", "apigather"); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath + }); + await fs.symlink(realDirectory, linkedDirectory, process.platform === "win32" ? "junction" : "dir"); + const manifestPath = path.join(backupDir, "session-meta-backup.json"); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + manifest.files[0].path = path.join(linkedDirectory, "rollout-linked.jsonl"); + await fs.writeFile(manifestPath, JSON.stringify(manifest), "utf8"); + + await assert.rejects( + restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true + }), + /symbolic link or reparse point/ + ); +}); + +test("restoreBackup revalidates a rollout target immediately before restore", async () => { + const { root, codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionDirectory = path.join(codexHome, "sessions", "2026", "swap-target"); + const originalDirectory = path.join(codexHome, "sessions", "2026", "swap-original"); + const sessionPath = path.join(sessionDirectory, "rollout-swap.jsonl"); + await fs.mkdir(sessionDirectory, { recursive: true }); + await writeRollout(sessionPath, "thread-swap", "apigather"); + const { changes } = await collectSessionChanges(codexHome, "openai"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: changes, + configPath: path.join(codexHome, "config.toml") + }); + await writeRollout(sessionPath, "thread-swap", "openai"); + + const outsideDirectory = path.join(root, "outside-swap-target"); + const outsidePath = path.join(outsideDirectory, "rollout-swap.jsonl"); + await fs.mkdir(outsideDirectory, { recursive: true }); + await writeRollout(outsidePath, "outside-thread", "outside-provider"); + const outsideBefore = await fs.readFile(outsidePath, "utf8"); + let swapped = false; + try { + await assert.rejects( + restoreBackup(backupDir, codexHome, { + restoreConfig: false, + restoreDatabase: false, + restoreSessions: true, + onBeforeSessionRestore: async () => { + if (swapped) return; + swapped = true; + await fs.rename(sessionDirectory, originalDirectory); + await fs.symlink( + outsideDirectory, + sessionDirectory, + process.platform === "win32" ? "junction" : "dir" + ); + } + }), + (error) => error instanceof AggregateError + && error.failures.some((failure) => /symbolic link or reparse point/.test(failure.message)) + ); + assert.equal(await fs.readFile(outsidePath, "utf8"), outsideBefore); + } finally { + if (swapped) { + await fs.unlink(sessionDirectory); + await fs.rename(originalDirectory, sessionDirectory); + } + } +}); + test("Windows restore attempts every rollout even when one target is locked", async () => { if (process.platform !== "win32") { return; @@ -3883,6 +4153,134 @@ test("cli sync prints stage progress and backup timing", async () => { assert.match(result.stdout, /Backup creation time: /); }); +test("real cli sync JSON keeps the terminal envelope on stdout and progress on stderr", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-json.jsonl"); + await writeRollout(sessionPath, "thread-json", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-json", model_provider: "apigather", archived: false } + ]); + + const result = await runCli(["sync", "--json", "--codex-home", codexHome]); + assert.equal(result.code, 0); + assert.equal((result.stdout.match(/\n/g) ?? []).length, 1); + const envelope = JSON.parse(result.stdout); + assert.deepEqual(Object.keys(envelope), [ + "schemaVersion", + "command", + "ok", + "outcome", + "result", + "warnings", + "error" + ]); + assert.equal(envelope.command, "sync"); + assert.equal(envelope.ok, true); + assert.equal(envelope.outcome, "completed"); + assert.equal(envelope.result.changedSessionFiles, 1); + assert.equal(envelope.result.sqliteRowsUpdated, 1); + assert.equal(envelope.error, null); + assert.doesNotMatch(result.stdout, /\[1\/6\]/); + assert.match(result.stderr, /\[1\/6\] Scanning rollout files/); + assert.match(result.stderr, /\[6\/6\] Cleaning backups/); +}); + +test("real cli sync JSON returns exit 4 for an unfinished transaction without a new backup", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + await writeStateDb(codexHome, [{ id: "thread-json-pending", model_provider: "openai" }]); + const configPath = path.join(codexHome, "config.toml"); + const backupDir = await createBackup({ + codexHome, + targetProvider: "openai", + sessionChanges: [], + configPath + }); + await TransactionJournal.create(backupDir, { + codexHome, + targetProvider: "openai", + potentialTargets: [configPath] + }); + + const beforeBackups = await fs.readdir(backupRoot(codexHome)); + const result = await runCli(["sync", "--json", "--codex-home", codexHome]); + assert.equal(result.code, 4); + assert.equal((result.stdout.match(/\n/g) ?? []).length, 1); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.outcome, "recovery_required"); + assert.equal(envelope.error.code, "RECOVERY_REQUIRED"); + assert.equal(envelope.error.recoveryRequired, true); + assert.deepEqual(await fs.readdir(backupRoot(codexHome)), beforeBackups); +}); + +test("real cli sync JSON returns exit 5 before backup when SQLite is busy", async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-json-busy.jsonl"); + await writeRollout(sessionPath, "thread-json-busy", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-json-busy", model_provider: "apigather", archived: false } + ]); + const rolloutBefore = await fs.readFile(sessionPath, "utf8"); + + const lockDb = await openDatabase(stateDbPath(codexHome)); + let result; + try { + lockDb.exec("BEGIN IMMEDIATE"); + result = await runCli(["sync", "--json", "--codex-home", codexHome]); + } finally { + try { + lockDb.exec("ROLLBACK"); + } catch { + // Ignore cleanup failures in tests. + } + lockDb.close(); + } + + assert.equal(result.code, 5); + assert.equal((result.stdout.match(/\n/g) ?? []).length, 1); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, "SQLITE_BUSY"); + assert.equal(await fs.readFile(sessionPath, "utf8"), rolloutBefore); + await assert.rejects(() => fs.access(backupRoot(codexHome))); +}); + +test("real cli sync JSON reports a locked rollout as partial", { + skip: process.platform !== "win32" +}, async () => { + const { codexHome } = await makeTempCodexHome(); + await writeConfig(codexHome, 'model_provider = "openai"'); + const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-json-locked.jsonl"); + await writeRollout(sessionPath, "thread-json-locked", "apigather"); + await writeStateDb(codexHome, [ + { id: "thread-json-locked", model_provider: "apigather", archived: false } + ]); + + const lockProcess = await lockRolloutFile(sessionPath); + let result; + try { + result = await runCli(["sync", "--json", "--codex-home", codexHome]); + } finally { + lockProcess.kill(); + if (lockProcess.exitCode === null) { + await new Promise((resolve) => lockProcess.once("exit", resolve)); + } + } + + assert.equal(result.code, 3); + assert.equal((result.stdout.match(/\n/g) ?? []).length, 1); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.ok, true); + assert.equal(envelope.outcome, "partial"); + assert.deepEqual(envelope.result.skippedLockedRolloutFiles, [sessionPath]); + assert.equal(envelope.result.sqliteRowsUpdated, 1); + assert.match(result.stderr, /\[1\/6\] Scanning rollout files/); + assert.equal(await readProvider(codexHome, "thread-json-locked"), "openai"); +}); + test("syncDirectory only downgrades known unsupported flush errors on Windows", async () => { const permissionError = Object.assign(new Error("directory flush denied"), { code: "EPERM" }); const unsupportedFs = { @@ -3937,6 +4335,7 @@ test("runRestore rejects all-disabled recovery when the first journal record is }), (error) => { assert.equal(error.code, "RECOVERY_REQUIRED"); + assert.equal(typeof error.toDto, "function"); assert.deepEqual( new Set(error.missingRestoreKinds), new Set(["rollout sessions", "SQLite database", "config.toml", "global state"]) diff --git a/test/watch.test.js b/test/watch.test.js index 0ec9ec5..713ae6b 100644 --- a/test/watch.test.js +++ b/test/watch.test.js @@ -4,7 +4,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { runWatch } from "../src/watch.js"; +import { getWatchStatus, runWatch, startWatch, stopWatch } from "../src/watch.js"; +import { OperationCoordinator } from "../src/operation-coordinator.js"; delete process.env.CODEX_SQLITE_HOME; @@ -49,7 +50,8 @@ test("runWatch rejects invalid debounce-ms values", async () => { const { codexHome } = await makeTempCodexHome(); await assert.rejects( () => runWatch({ codexHome, debounceMs: -1 }), - /Invalid --debounce-ms value/ + (error) => error?.code === "INVALID_INPUT" + && /Invalid --debounce-ms value/.test(error.message) ); await fs.rm(codexHome, { recursive: true, force: true }); }); @@ -58,17 +60,32 @@ test("runWatch rejects when codex home or config.toml is missing", async () => { const root = await fs.mkdtemp(path.join(testTempDir, "codex-provider-sync-watch-")); await assert.rejects( () => runWatch({ codexHome: path.join(root, "does-not-exist") }), - /Codex home not found/ + (error) => error?.code === "CODEX_HOME_NOT_FOUND" + && /Codex home not found/.test(error.message) ); const codexHome = path.join(root, ".codex"); await fs.mkdir(codexHome, { recursive: true }); await assert.rejects( () => runWatch({ codexHome }), - /config\.toml not found/ + (error) => error?.code === "CODEX_HOME_NOT_FOUND" + && /config\.toml not found/.test(error.message) ); await fs.rm(root, { recursive: true, force: true }); }); +test("runWatch distinguishes access denial from a missing Codex home", async () => { + const denied = new Error("permission denied fixture"); + denied.code = "EACCES"; + await assert.rejects( + () => runWatch({ + codexHome: path.join(testTempDir, "permission-denied-codex-home"), + accessImpl: async () => { throw denied; } + }), + (error) => error?.code === "PERMISSION_DENIED" + && error.details?.causeCode === "EACCES" + ); +}); + test("runWatch blocks Windows WSL UNC SQLite homes before creating watchers", async () => { const { root, codexHome } = await makeTempCodexHome(); let handle; @@ -133,7 +150,7 @@ test("runWatch invokes the injected sync handler when config.toml changes and st await fs.rm(codexHome, { recursive: true, force: true }); }); -test("runWatch swallows 'sqlite in use' errors and keeps watching", async () => { +test("runWatch uses the typed SQLITE_BUSY code and keeps watching", async () => { const { codexHome } = await makeTempCodexHome(); const configPath = path.join(codexHome, "config.toml"); @@ -145,7 +162,9 @@ test("runWatch swallows 'sqlite in use' errors and keeps watching", async () => includeStateDb: false, onSync: async () => { firstSyncCalls += 1; - throw new Error("state_5.sqlite is currently in use by another process"); + const error = new Error("localized busy diagnostic"); + error.code = "SQLITE_BUSY"; + throw error; }, onLog: (line) => logs.push(line) }); @@ -155,7 +174,7 @@ test("runWatch swallows 'sqlite in use' errors and keeps watching", async () => assert.ok(firstSyncCalls >= 1, "first sync should have been attempted"); assert.ok( - logs.some((line) => /state_5\.sqlite is currently in use/.test(line)), + logs.some((line) => /Sync skipped: localized busy diagnostic/.test(line)), "the locked-sqlite error should have been logged as a soft skip" ); @@ -170,6 +189,326 @@ test("runWatch swallows 'sqlite in use' errors and keeps watching", async () => await fs.rm(codexHome, { recursive: true, force: true }); }); +test("runWatch coalesces in-flight events and never overlaps applies", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const configPath = path.join(codexHome, "config.toml"); + const firstStarted = deferred(); + const releaseFirst = deferred(); + let calls = 0; + let concurrent = 0; + let maxConcurrent = 0; + const observedReasons = []; + const handle = await runWatch({ + codexHome, + debounceMs: 20, + includeStateDb: false, + onLog: () => {}, + onSync: async ({ reasons }) => { + calls += 1; + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + observedReasons.push(reasons); + if (calls === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + } + concurrent -= 1; + return { targetProvider: "openai", changedSessionFiles: 0, sqliteRowsUpdated: 0 }; + } + }); + + try { + await fs.writeFile(configPath, 'model_provider = "first"\n', "utf8"); + await firstStarted.promise; + await fs.writeFile(configPath, 'model_provider = "second"\n', "utf8"); + await fs.writeFile(configPath, 'model_provider = "third"\n', "utf8"); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(calls, 1, "an in-flight Apply must not be overlapped"); + releaseFirst.resolve(); + await waitUntil(() => calls === 2, "coalesced follow-up Apply did not run"); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(calls, 2, "all in-flight events must merge into exactly one follow-up Apply"); + assert.equal(maxConcurrent, 1); + assert.ok(observedReasons.every((reasons) => Array.isArray(reasons) && reasons.includes("config.toml"))); + } finally { + releaseFirst.resolve(); + await handle.stop(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("runWatch yields on OPERATION_BUSY without counting consecutive failures", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const configPath = path.join(codexHome, "config.toml"); + const logs = []; + let attempts = 0; + let shutdownReason = null; + const handle = await runWatch({ + codexHome, + debounceMs: 10, + includeStateDb: false, + onLog: (line) => logs.push(line), + onShutdown: (reason) => { shutdownReason = reason; }, + onSync: async () => { + attempts += 1; + const error = new Error("manual operation owns the lock"); + error.code = "OPERATION_BUSY"; + error.details = { busyScope: "codex-home" }; + throw error; + } + }); + + try { + for (let index = 0; index < 6; index += 1) { + await fs.writeFile(configPath, `model_provider = "busy-${index}"\n`, "utf8"); + await new Promise((resolve) => setTimeout(resolve, 60)); + } + assert.ok(attempts >= 5, `expected repeated event-driven attempts, got ${attempts}`); + assert.equal(shutdownReason, null); + assert.ok(logs.some((line) => /yielded to an active manual operation/.test(line))); + assert.ok(!logs.some((line) => /giving up after/.test(line))); + } finally { + await handle.stop(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("runWatch retains a busy batch and applies it exactly once after a local manual operation ends", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const configPath = path.join(codexHome, "config.toml"); + const manualCompleted = deferred(); + let attempts = 0; + let cancelled = 0; + const handle = await runWatch({ + codexHome, + debounceMs: 15, + includeStateDb: false, + onLog: () => {}, + manualOperationWaiter: () => ({ + promise: manualCompleted.promise, + cancel: () => { cancelled += 1; } + }), + onSync: async () => { + attempts += 1; + if (attempts === 1) { + const error = new Error("manual operation owns the lock"); + error.code = "OPERATION_BUSY"; + error.details = { busyScope: "codex-home" }; + throw error; + } + return { targetProvider: "openai", changedSessionFiles: 0, sqliteRowsUpdated: 0 }; + } + }); + + try { + await fs.writeFile(configPath, 'model_provider = "manual-busy"\n', "utf8"); + await waitUntil(() => attempts === 1, "Watch did not yield to the manual operation"); + await fs.writeFile(configPath, 'model_provider = "coalesced-a"\n', "utf8"); + await fs.writeFile(configPath, 'model_provider = "coalesced-b"\n', "utf8"); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(attempts, 1, "events must remain queued while the manual operation is active"); + + manualCompleted.resolve(); + await waitUntil(() => attempts === 2, "Watch did not retry after manual completion"); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(attempts, 2, "the retained busy batch must merge into one follow-up Apply"); + assert.equal(cancelled, 0); + } finally { + manualCompleted.resolve(); + await handle.stop(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("runWatch cancels its manual-completion subscription when stopped", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const configPath = path.join(codexHome, "config.toml"); + const manualCompleted = deferred(); + let attempts = 0; + let cancelled = 0; + const handle = await runWatch({ + codexHome, + debounceMs: 10, + includeStateDb: false, + onLog: () => {}, + manualOperationWaiter: () => ({ + promise: manualCompleted.promise, + cancel: () => { cancelled += 1; } + }), + onSync: async () => { + attempts += 1; + const error = new Error("manual operation owns the lock"); + error.code = "OPERATION_BUSY"; + error.details = { busyScope: "codex-home" }; + throw error; + } + }); + + await fs.writeFile(configPath, 'model_provider = "manual-stop"\n', "utf8"); + await waitUntil(() => attempts === 1, "Watch did not enter the busy wait"); + await handle.stop(); + assert.equal(cancelled, 1); + manualCompleted.resolve(); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(attempts, 1, "a stopped watcher must not apply after completion notification"); + await fs.rm(root, { recursive: true, force: true }); +}); + +test("runWatch retries one retained batch when a prepared manual intent expires", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const configPath = path.join(codexHome, "config.toml"); + let operationSequence = 0; + const coordinator = new OperationCoordinator({ + randomOperationId: () => `watch-${++operationSequence}` + }); + coordinator.registerManualIntent(codexHome, "abandoned-plan", Date.now() + 80); + let attempts = 0; + const handle = await runWatch({ + codexHome, + debounceMs: 10, + includeStateDb: false, + onLog: () => {}, + manualOperationWaiter: () => coordinator.waitForManualOperation(codexHome), + onSync: async () => { + attempts += 1; + const active = coordinator.begin(codexHome, "sync", { actor: "watch" }); + coordinator.end(codexHome, active.operationId); + return { targetProvider: "openai", changedSessionFiles: 0, sqliteRowsUpdated: 0 }; + } + }); + + try { + await fs.writeFile(configPath, 'model_provider = "manual-intent"\n', "utf8"); + await waitUntil(() => attempts === 1, "Watch did not observe the prepared manual intent"); + await waitUntil(() => attempts === 2, "Watch did not resume after manual intent expiry"); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(attempts, 2, "the retained batch must be applied exactly once after expiry"); + } finally { + await handle.stop(); + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("runWatch stops immediately when explicit recovery is required", async () => { + for (const code of ["RECOVERY_REQUIRED", "PENDING_TRANSACTION"]) { + const { root, codexHome } = await makeTempCodexHome(); + const configPath = path.join(codexHome, "config.toml"); + let attempts = 0; + const handle = await runWatch({ + codexHome, + debounceMs: 10, + includeStateDb: false, + onLog: () => {}, + onSync: async () => { + attempts += 1; + const error = new Error("fixture recovery blocker"); + error.code = code; + throw error; + } + }); + try { + await fs.writeFile(configPath, `model_provider = "${code}"\n`, "utf8"); + assert.equal(await handle.done, "recovery-required", code); + assert.equal(attempts, 1, code); + await fs.writeFile(configPath, 'model_provider = "after-stop"\n', "utf8"); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(attempts, 1, `${code} must stop all later Watch applies`); + } finally { + await handle.stop(); + await fs.rm(root, { recursive: true, force: true }); + } + } +}); + +test("startWatch exposes registry status and stopWatch is idempotent", async () => { + const { root, codexHome } = await makeTempCodexHome(); + try { + const started = await startWatch({ + codexHome, + includeStateDb: false, + onLog: () => {}, + onSync: async () => ({ targetProvider: "openai", changedSessionFiles: 0, sqliteRowsUpdated: 0 }) + }); + assert.equal(started.schemaVersion, 1); + assert.equal(started.status, "running"); + assert.equal(getWatchStatus({ watchId: started.watchId }).status, "running"); + const stopped = await stopWatch({ watchId: started.watchId }); + assert.equal(stopped.status, "stopped"); + assert.equal((await stopWatch({ watchId: started.watchId })).status, "stopped"); + assert.ok(getWatchStatus().watches.some((watch) => watch.watchId === started.watchId)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("startWatch deduplicates one physical Codex Home and releases the scope after stop", async () => { + const { root, codexHome } = await makeTempCodexHome(); + const aliasHome = path.join(root, "codex-home-alias"); + try { + await fs.symlink(codexHome, aliasHome, process.platform === "win32" ? "junction" : "dir"); + const options = { + codexHome, + includeStateDb: false, + onLog: () => {}, + onSync: async () => ({ targetProvider: "openai", changedSessionFiles: 0, sqliteRowsUpdated: 0 }) + }; + const left = await startWatch(options); + const right = await startWatch({ + ...options, + codexHome: aliasHome, + includeStateDb: true, + once: true + }); + assert.equal(left.watchId, right.watchId); + assert.equal(right.includeStateDb, false); + assert.equal(right.once, false); + assert.equal(getWatchStatus().watches.filter((watch) => watch.status !== "stopped" + && watch.watchId === left.watchId).length, 1); + + await stopWatch({ watchId: left.watchId }); + const [restarted, concurrent] = await Promise.all([ + startWatch(options), + startWatch({ ...options, codexHome: aliasHome }) + ]); + assert.equal(restarted.watchId, concurrent.watchId); + assert.notEqual(restarted.watchId, left.watchId); + assert.equal(getWatchStatus({ watchId: left.watchId }).status, "stopped"); + assert.equal((await stopWatch({ watchId: left.watchId })).status, "stopped"); + await stopWatch({ watchId: restarted.watchId }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("stopped Watch remains immediately idempotent when active watches exceed retained history", async () => { + const fixtures = []; + const started = []; + try { + for (let index = 0; index < 65; index += 1) { + const fixture = await makeTempCodexHome(); + fixtures.push(fixture); + started.push(await startWatch({ + codexHome: fixture.codexHome, + includeStateDb: false, + onLog: () => {}, + onSync: async () => ({ + targetProvider: "openai", + changedSessionFiles: 0, + sqliteRowsUpdated: 0 + }) + })); + } + + const first = started[0]; + assert.equal((await stopWatch({ watchId: first.watchId })).status, "stopped"); + assert.equal((await stopWatch({ watchId: first.watchId })).status, "stopped"); + assert.equal(getWatchStatus({ watchId: first.watchId }).status, "stopped"); + } finally { + await Promise.allSettled(started.map(({ watchId }) => stopWatch({ watchId }))); + await Promise.all(fixtures.map(({ root }) => fs.rm(root, { recursive: true, force: true }))); + } +}); + test("runWatch stops itself after consecutive non-busy sync failures and resolves done", async () => { // Regression guard for B11: when the sync handler keeps // throwing something other than `state_5.sqlite is currently diff --git a/test/web-server.test.js b/test/web-server.test.js index 922bdf3..bbbdebd 100644 --- a/test/web-server.test.js +++ b/test/web-server.test.js @@ -6,10 +6,11 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { CoreError } from "../src/public-api.js"; import { createWebUiServer, startWebUi } from "../src/web-server.js"; import { createMemoryWebUiState, WebUiStateStore } from "../src/web-state.js"; -function request({ origin, pathname = "/", method = "GET", body, headers = {}, hostHeader }) { +function request({ origin, pathname = "/", method = "GET", body, headers = {}, hostHeader, agent }) { return new Promise((resolve, reject) => { const target = new URL(origin); const serialized = body === undefined ? null : JSON.stringify(body); @@ -18,19 +19,21 @@ function request({ origin, pathname = "/", method = "GET", body, headers = {}, h port: target.port, path: pathname, method, + agent, headers: { ...(hostHeader ? { Host: hostHeader } : {}), ...(serialized ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(serialized) } : {}), ...headers } }, (response) => { + const socket = response.socket; const chunks = []; response.on("data", (chunk) => chunks.push(chunk)); response.on("end", () => { const text = Buffer.concat(chunks).toString("utf8"); let payload = null; try { payload = JSON.parse(text); } catch {} - resolve({ status: response.statusCode, text, payload, headers: response.headers }); + resolve({ status: response.statusCode, text, payload, headers: response.headers, socket }); }); }); client.once("error", reject); @@ -43,7 +46,7 @@ async function startFixture(services = {}, options = {}) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-web-")); await fs.writeFile( path.join(root, "index.html"), - 'fixture', + 'fixture', "utf8" ); const stateStore = options.stateStore ?? createMemoryWebUiState({ @@ -89,28 +92,14 @@ async function startFixture(services = {}, options = {}) { } async function api(handle, pathname, body = {}, credential, { originHeader = handle.origin, hostHeader } = {}) { - const profileRevisionEndpoints = new Set(["/api/sync", "/api/switch", "/api/restore", "/api/prune"]); - const storageRevisionEndpoints = new Set(["/api/sync", "/api/switch", "/api/restore"]); + const profileRevisionEndpoints = new Set([ + "/api/prune", + "/api/sync/prepare", "/api/switch/prepare", "/api/restore/prepare" + ]); const profile = profileRevisionEndpoints.has(pathname) && body.profileId && !Object.hasOwn(body, "profileRevision") ? handle.stateStore.getProfile(body.profileId) : null; - let preparedBody = profile ? { ...body, profileRevision: profile.revision } : body; - if (storageRevisionEndpoints.has(pathname) && !Object.hasOwn(preparedBody, "storageRevision")) { - const status = await request({ - origin: handle.origin, - pathname: "/api/status", - method: "POST", - body: { profileId: preparedBody.profileId ?? "default" }, - hostHeader, - headers: { - Origin: originHeader, - "X-Codex-Provider-Device": credential ?? "" - } - }); - if (status.payload?.status?.storageRevision) { - preparedBody = { ...preparedBody, storageRevision: status.payload.status.storageRevision }; - } - } + const preparedBody = profile ? { ...body, profileRevision: profile.revision } : body; return request({ origin: handle.origin, pathname, @@ -144,18 +133,350 @@ function statusFixture(overrides = {}) { projectThreadVisibility: [], backupRoot: "/tmp/.codex/backups_state/provider-sync", backupSummary: { count: 0, totalBytes: 0 }, + pathComparisonCaseInsensitive: process.platform === "win32", + ...overrides + }; +} + +function streamRequest({ origin, pathname, method = "POST", body, headers = {} }) { + return new Promise((resolve, reject) => { + const target = new URL(origin); + const serialized = body === undefined ? null : JSON.stringify(body); + const client = http.request({ + hostname: target.hostname, + port: target.port, + path: pathname, + method, + headers: { + ...(serialized ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(serialized) } : {}), + ...headers + } + }, (response) => resolve({ + status: response.statusCode, + headers: response.headers, + body: response + })); + client.once("error", reject); + if (serialized) client.write(serialized); + client.end(); + }); +} + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function coreStatusFixture(profile, overrides = {}) { + const status = { + schemaVersion: 1, + snapshotAt: "2026-08-25T00:00:00.000Z", + storageRevision: "storage-r1", + profile: { id: profile.id, revision: profile.revision }, + currentProvider: "openai", + rolloutCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + sqliteCounts: { sessions: { openai: 1 }, archived_sessions: {} }, + codexHomeSource: "profile", + sqliteHomeSource: "default", + backupSummary: { count: 0, totalBytes: 0 }, + pendingRecovery: false, + pendingTransactions: [], + operationInProgress: null, + rolloutScanComplete: true, + lockedRolloutFiles: [], ...overrides }; + const matchesTarget = (distribution) => ["sessions", "archived_sessions"].every((scope) => ( + Object.entries(distribution?.[scope] ?? {}).every(([provider, count]) => ( + Number(count) === 0 || provider === status.currentProvider + )) + )); + return { + ...status, + alignment: status.alignment ?? { + aligned: Boolean( + !status.operationInProgress + && !status.statusReadBlocked + && status.rolloutScanComplete + && status.lockedRolloutFiles.length === 0 + && status.sqliteCounts?.unreadable !== true + && matchesTarget(status.rolloutCounts) + && matchesTarget(status.sqliteCounts) + ), + sqliteReadable: status.sqliteCounts?.unreadable !== true, + targetProvider: status.currentProvider + } + }; } +test("versioned Core HTTP endpoint preserves pairing, correlation and canonical errors", async () => { + let statusError = null; + const handle = await startFixture({ + coreFacade: { + async getStatus(input) { + if (statusError) throw statusError; + const profile = handle.stateStore.getProfile(input.profile.profileId); + return coreStatusFixture(profile); + } + } + }); + try { + const profile = handle.stateStore.getProfile("default"); + const envelope = { + protocolVersion: 1, + requestId: "core-status-request", + method: "getStatus", + payload: { profile: { profileId: profile.id, profileRevision: profile.revision } } + }; + const anonymous = await api(handle, "/api/core", envelope); + assert.equal(anonymous.status, 403); + + const { credential } = await handle.pair(); + const response = await api(handle, "/api/core", envelope, credential); + assert.equal(response.status, 200); + assert.equal(response.payload.protocolVersion, 1); + assert.equal(response.payload.requestId, envelope.requestId); + assert.equal(response.payload.ok, true); + assert.equal(response.payload.result.profile.id, "default"); + assert.equal("codexHome" in response.payload.result, false); + assert.equal("sqliteHome" in response.payload.result, false); + + const pathInjection = await api(handle, "/api/core", { + ...envelope, + requestId: "core-path-injection", + payload: { ...envelope.payload, codexHome: "C:\\private" } + }, credential); + assert.equal(pathInjection.status, 400); + assert.equal(pathInjection.payload.ok, false); + assert.equal(pathInjection.payload.error.code, "INVALID_INPUT"); + assert.doesNotMatch(JSON.stringify(pathInjection.payload), /C:\\\\private/); + + statusError = Object.assign(new Error("token=secret C:\\private\\journal.json"), { + code: "INTERNAL_ERROR", + details: { path: "C:\\private\\journal.json", token: "secret" } + }); + const failed = await api(handle, "/api/core", { + ...envelope, + requestId: "core-safe-error" + }, credential); + assert.equal(failed.status, 500); + assert.equal(failed.payload.ok, false); + assert.equal(failed.payload.requestId, "core-safe-error"); + assert.deepEqual(failed.payload.error, { + code: "INTERNAL_ERROR", + message: "An internal error occurred.", + severity: "fatal", + retryable: false, + recoveryRequired: false + }); + assert.doesNotMatch(JSON.stringify(failed.payload), /secret|private|journal/i); + assert.doesNotMatch(JSON.stringify(handle.getActivity()), /secret|private|journal/i); + + const applyInjection = await api(handle, "/api/core", { + protocolVersion: 1, + requestId: "core-apply-injection", + method: "applySync", + payload: { schemaVersion: 1, planId: "opaque", provider: "openai" } + }, credential); + assert.equal(applyInjection.status, 400); + assert.equal(applyInjection.payload.error.code, "INVALID_INPUT"); + + const wrongContentType = await request({ + origin: handle.origin, + pathname: "/api/core", + method: "POST", + body: envelope, + headers: { + Origin: handle.origin, + "X-Codex-Provider-Device": credential, + "Content-Type": "text/plain" + } + }); + assert.equal(wrongContentType.status, 415); + assert.equal(wrongContentType.payload.ok, false); + assert.equal(wrongContentType.payload.error.code, "INVALID_INPUT"); + + const incompatible = await api(handle, "/api/core", { + ...envelope, + protocolVersion: 99, + requestId: "core-protocol-mismatch" + }, credential); + assert.equal(incompatible.status, 400); + assert.equal(incompatible.payload.requestId, "core-protocol-mismatch"); + assert.equal(incompatible.payload.error.code, "PROTOCOL_VERSION_MISMATCH"); + + const keepAliveAgent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + try { + const oversized = await request({ + origin: handle.origin, + pathname: "/api/core", + method: "POST", + agent: keepAliveAgent, + headers: { + Origin: handle.origin, + "X-Codex-Provider-Device": credential + }, + body: { + ...envelope, + requestId: "core-oversized", + padding: "x".repeat(70 * 1024) + } + }); + assert.equal(oversized.status, 413); + assert.equal(oversized.payload.code, "REQUEST_TOO_LARGE"); + const health = await request({ origin: handle.origin, pathname: "/api/health", agent: keepAliveAgent }); + assert.equal(health.status, 200); + assert.equal(health.socket, oversized.socket, "The drained oversized request should leave its connection reusable."); + } finally { + keepAliveAgent.destroy(); + } + } finally { + await handle.close(); + } +}); + +test("aborted JSON requests settle without dispatching Core work or wedging the server", async () => { + let facadeCalls = 0; + const handle = await startFixture({ + coreFacade: { + async getStatus() { + facadeCalls += 1; + throw new Error("An aborted body must not be dispatched."); + } + } + }); + try { + const { credential } = await handle.pair(); + const target = new URL(handle.origin); + const requestSeen = new Promise((resolve) => { + handle.server.once("request", (incoming) => { + assert.equal(incoming.url, "/api/core"); + resolve(); + }); + }); + const client = http.request({ + hostname: target.hostname, + port: target.port, + path: "/api/core", + method: "POST", + headers: { + Origin: handle.origin, + "X-Codex-Provider-Device": credential, + "Content-Type": "application/json", + "Content-Length": "1024" + } + }); + const clientClosed = new Promise((resolve) => { + client.once("error", () => {}); + client.once("close", resolve); + }); + client.write('{"protocolVersion":1'); + await requestSeen; + client.destroy(); + await clientClosed; + + const health = await request({ origin: handle.origin, pathname: "/api/health" }); + assert.equal(health.status, 200); + assert.equal(facadeCalls, 0); + } finally { + await handle.close(); + } +}); + +test("versioned Core HTTP stream forwards progress and cancels only its correlated apply", async () => { + const operationId = "11111111-1111-4111-8111-111111111111"; + const handle = await startFixture({ + coreFacade: { + async applySync(_input, control) { + control.onOperationStarted({ operationId }); + control.onProgress({ stage: "create_backup", status: "start" }); + await new Promise((resolve, reject) => { + if (control.signal.aborted) { + reject(Object.assign(new Error("cancelled"), { code: "OPERATION_CANCELLED", operationId })); + return; + } + control.signal.addEventListener("abort", () => { + reject(Object.assign(new Error("cancelled"), { code: "OPERATION_CANCELLED", operationId })); + }, { once: true }); + }); + } + } + }); + try { + const { credential } = await handle.pair(); + const requestId = "core-stream-cancel"; + const response = await streamRequest({ + origin: handle.origin, + pathname: "/api/core", + method: "POST", + headers: { + Accept: "application/x-ndjson", + Origin: handle.origin, + "X-Codex-Provider-Device": credential + }, + body: { + protocolVersion: 1, + requestId, + method: "applySync", + payload: { schemaVersion: 1, planId: "a".repeat(32) } + } + }); + assert.equal(response.status, 200); + assert.match(response.headers["content-type"], /^application\/x-ndjson/); + let buffered = ""; + const frames = []; + let cancelSent = false; + for await (const chunk of response.body) { + buffered += chunk.toString("utf8"); + let newline; + while ((newline = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (!line) continue; + const frame = JSON.parse(line); + frames.push(frame); + if (frame.event === "operation-started" && !cancelSent) { + cancelSent = true; + const wrong = await api(handle, "/api/core/cancel", { + protocolVersion: 1, + requestId, + operationId: "22222222-2222-4222-8222-222222222222" + }, credential); + assert.deepEqual(wrong.payload, { accepted: false }); + const cancelled = await api(handle, "/api/core/cancel", { + protocolVersion: 1, + requestId, + operationId + }, credential); + assert.deepEqual(cancelled.payload, { accepted: true }); + } + } + } + assert.deepEqual(frames.map((frame) => frame.event ?? (frame.ok ? "success" : frame.error.code)), [ + "operation-started", + "progress", + "OPERATION_CANCELLED" + ]); + assert.equal(frames.every((frame) => frame.requestId === requestId), true); + } finally { + await handle.close(); + } +}); + test("status alignment follows the current provider without requiring equal inventory counts", async () => { - let currentStatus = statusFixture({ + let currentStatus = { currentProvider: "dal", configuredProviders: ["dal", "openai"], rolloutCounts: { sessions: { dal: 949 }, archived_sessions: {} }, sqliteCounts: { sessions: { dal: 948 }, archived_sessions: {} } + }; + const handle = await startFixture({ + coreFacade: { + async getStatus(input) { + const profile = handle.stateStore.getProfile(input.profile.profileId); + return coreStatusFixture(profile, currentStatus); + } + } }); - const handle = await startFixture({ getStatus: async () => currentStatus }); try { const paired = await handle.pair(); const readAlignment = async () => { @@ -183,7 +504,7 @@ test("status alignment follows the current provider without requiring equal inve ...currentStatus, rolloutCounts: { sessions: { dal: 949 }, archived_sessions: {} }, sqliteCounts: { sessions: { dal: 948 }, archived_sessions: {} }, - lockedRolloutFiles: ["C:\\locked-rollout.jsonl"] + lockedRolloutFiles: ["locked-rollout.jsonl"] }; assert.equal((await readAlignment()).aligned, false); } finally { @@ -192,15 +513,29 @@ test("status alignment follows the current provider without requiring equal inve }); test("anonymous HTML contains no API or pairing credential and write APIs require pairing", async () => { - const handle = await startFixture({ getStatus: async () => statusFixture() }); + const handle = await startFixture({ + coreFacade: { + async getStatus(input) { + return coreStatusFixture({ id: input.profile.profileId, revision: "fixture-revision" }); + } + } + }); try { const pairingToken = handle.issuePairing(); const page = await request({ origin: handle.origin }); assert.equal(page.status, 200); + const csp = page.headers["content-security-policy"]; + const scriptNonce = csp.match(/script-src 'self' 'nonce-([^']+)'/); + const styleNonce = csp.match(/style-src 'self' 'nonce-([^']+)'/); + assert.ok(scriptNonce); + assert.ok(styleNonce); + assert.equal(scriptNonce[1], styleNonce[1]); + assert.match(page.text, new RegExp(`nonce="${scriptNonce[1]}"`)); + assert.doesNotMatch(page.text, /__CPS_CSP_NONCE__/); + assert.doesNotMatch(csp, /unsafe-inline/); assert.doesNotMatch(page.text, new RegExp(pairingToken)); assert.doesNotMatch(page.text, /apiToken|X-Codex-Provider-Token/); assert.doesNotMatch(page.text, /__CODEX_PROVIDER_SYNC_BOOTSTRAP__/); - assert.match(page.headers["content-security-policy"], /script-src 'self';/); const denied = await api(handle, "/api/status", { profileId: "default" }, "wrong-token"); assert.equal(denied.status, 403); @@ -280,7 +615,13 @@ test("internal pairing requires and consumes a server-issued authenticated chall }); test("Origin validation uses the actual loopback Host and supports forwarded ports", async () => { - const handle = await startFixture({ getStatus: async () => statusFixture() }); + const handle = await startFixture({ + coreFacade: { + async getStatus(input) { + return coreStatusFixture({ id: input.profile.profileId, revision: "fixture-revision" }); + } + } + }); try { const { credential } = await handle.pair(); const invalid = await api(handle, "/api/status", { profileId: "default" }, credential, { originHeader: "http://evil.example" }); @@ -298,14 +639,23 @@ test("Origin validation uses the actual loopback Host and supports forwarded por } }); -test("server-managed profiles reject per-operation paths and resolve profileId", async () => { +test("legacy read endpoints reject per-operation paths and forward only a profile selector", async () => { const calls = []; - const handle = await startFixture({ getStatus: async (storage) => { calls.push(storage); return statusFixture({ codexHome: storage.codexHome }); } }); + const handle = await startFixture({ + coreFacade: { + async getStatus(input) { + calls.push(cloneJson(input)); + const profile = handle.stateStore.getProfile(input.profile.profileId); + return coreStatusFixture(profile); + } + } + }); try { const { credential } = await handle.pair(); const rawPath = await api(handle, "/api/status", { codexHome: "/tmp/other" }, credential); - assert.equal(rawPath.status, 500); - assert.match(rawPath.payload.error, /server-managed profileId/); + assert.equal(rawPath.status, 400); + assert.equal(rawPath.payload.coreError.code, "INVALID_INPUT"); + assert.doesNotMatch(JSON.stringify(rawPath.payload), /\/tmp\/other/); const workCodexHome = path.join(handle.root, "work-codex"); const workSqliteHome = path.join(handle.root, "work-sqlite"); @@ -321,189 +671,275 @@ test("server-managed profiles reject per-operation paths and resolve profileId", const response = await api(handle, "/api/status", { profileId: "work" }, credential); assert.equal(response.status, 200); - assert.equal(response.payload.status.pathComparisonCaseInsensitive, process.platform === "win32"); - assert.equal(calls.at(-1).codexHome, path.resolve(workCodexHome)); - assert.equal(calls.at(-1).sqliteHome, path.resolve(workSqliteHome)); + assert.deepEqual(calls.at(-1), { profile: { profileId: "work" } }); + assert.equal(response.payload.status.profile.id, "work"); + assert.equal("codexHome" in response.payload.status, false); + assert.equal("sqliteHome" in response.payload.status, false); } finally { await handle.close(); } }); -test("Web UI sync delegates only server-resolved storage to the shared service", async () => { - const calls = []; +test("legacy Web direct-write routes require Plan/Apply and never invoke a writer", async () => { + let calls = 0; + const handle = await startFixture({ + prepareSync: async () => { calls += 1; }, + applySync: async () => { calls += 1; }, + prepareSwitch: async () => { calls += 1; }, + applySwitch: async () => { calls += 1; }, + prepareRestore: async () => { calls += 1; }, + applyRestore: async () => { calls += 1; } + }); + try { + const configPath = path.join(handle.root, "config.toml"); + await fs.writeFile(configPath, 'model_provider = "openai"\n', "utf8"); + const before = await fs.readFile(configPath); + const { credential } = await handle.pair(); + for (const [endpoint, body] of [ + ["/api/sync", { profileId: "default", provider: "openai", keepCount: 5 }], + ["/api/switch", { profileId: "default", provider: "openai", keepCount: 5 }], + ["/api/restore", { profileId: "default", backupId: "managed", restoreSessions: true }] + ]) { + const response = await api(handle, endpoint, body, credential); + assert.equal(response.status, 410); + assert.equal(response.payload.code, "PLAN_REQUIRED"); + } + assert.equal(calls, 0); + assert.deepEqual(await fs.readFile(configPath), before); + await assert.rejects(() => fs.access(path.join(handle.root, "backups_state")), { code: "ENOENT" }); + } finally { + await handle.close(); + } +}); + +test("Web Prepare/Apply keeps trusted profile paths server-side and Apply accepts only planId", async () => { + const prepareCalls = []; + const applyCalls = []; const handle = await startFixture({ readConfigText: async () => 'model_provider = "openai"\nmodel = "gpt-5"\n', readRootModelFromConfigText: () => "gpt-5", - runSync: async (options) => { calls.push(options); options.onProgress({ stage: "create_backup", status: "start" }); return { targetProvider: options.provider, backupDir: "/tmp/backup" }; } + prepareSync: async (options) => { + prepareCalls.push(options); + return { + schemaVersion: 1, + planId: "opaque-plan", + operation: "sync", + requiresConfirmation: true + }; + }, + applySync: async (input) => { + applyCalls.push(input); + return { + schemaVersion: 1, + operationId: "11111111-1111-4111-8111-111111111111", + operation: "sync", + outcome: "completed", + backup: null, + warnings: [], + result: { targetProvider: "openai" } + }; + } }); try { - await fs.writeFile(path.join(handle.root, "state_5.sqlite"), "not-a-real-db"); const { credential } = await handle.pair(); - const invalid = await api(handle, "/api/sync", { profileId: "default", provider: "bad provider", keepCount: 5 }, credential); - assert.equal(invalid.status, 400); - const response = await api(handle, "/api/sync", { profileId: "default", provider: "openai", keepCount: 5 }, credential); - assert.equal(response.status, 200); - assert.equal(calls.length, 1); - assert.equal(calls[0].codexHome, path.resolve(handle.root)); - assert.equal(calls[0].model, "gpt-5"); - assert.equal(calls[0].storage.stateDbLocation.source, "legacy-root"); - assert.equal(calls[0].storage.stateDbLocation.path, path.join(handle.root, "state_5.sqlite")); - assert.ok(handle.getActivity().some((entry) => entry.message === "Creating backup")); + const prepared = await api( + handle, + "/api/sync/prepare", + { profileId: "default", provider: "openai", keepCount: 5 }, + credential + ); + assert.equal(prepared.status, 200); + assert.equal(prepared.payload.plan.planId, "opaque-plan"); + assert.equal(prepareCalls.length, 1); + assert.equal(prepareCalls[0].codexHome, path.resolve(handle.root)); + assert.equal(prepareCalls[0].profile.id, "default"); + assert.equal(prepareCalls[0].model, "gpt-5"); + assert.equal(typeof prepareCalls[0].profileResolver, "function"); + + const rejected = await api( + handle, + "/api/sync/apply", + { schemaVersion: 1, planId: "opaque-plan", provider: "attacker" }, + credential + ); + assert.equal(rejected.status, 400); + assert.equal(rejected.payload.coreError.code, "INVALID_INPUT"); + assert.equal(applyCalls.length, 0); + + const applied = await api( + handle, + "/api/sync/apply", + { schemaVersion: 1, planId: "opaque-plan" }, + credential + ); + assert.equal(applied.status, 200); + assert.deepEqual(applyCalls, [{ schemaVersion: 1, planId: "opaque-plan" }]); + assert.equal(applied.payload.result.outcome, "completed"); } finally { await handle.close(); } }); -test("Web UI rejects an operation when config changes the effective SQLite target after confirmation", async () => { - let configText = 'model_provider = "openai"\nsqlite_home = "sqlite-a"\n'; - const syncCalls = []; +test("Web Prepare validates Switch model modes and passes Restore only a managed backupId", async () => { + const switchCalls = []; + const restoreCalls = []; const handle = await startFixture({ - readConfigText: async () => configText, - readRootModelFromConfigText: () => null, - getStatus: async ({ storage }) => statusFixture({ - codexHome: storage.codexHome, - sqliteHome: storage.sqliteHome, - sqliteHomeSource: storage.sqliteHomeSource, - sqliteAccess: storage.sqliteAccess, - stateDbLocation: storage.stateDbLocation, - checkedStateDbPaths: storage.stateDbCandidates.map((candidate) => candidate.path) - }), - runSync: async (options) => { syncCalls.push(options); return {}; } + prepareSwitch: async (options) => { + switchCalls.push(options); + return { schemaVersion: 1, planId: `switch-${switchCalls.length}`, operation: "switch" }; + }, + prepareRestore: async (options) => { + restoreCalls.push(options); + return { schemaVersion: 1, planId: "restore-1", operation: "restore" }; + } }); try { const { credential } = await handle.pair(); - const profile = handle.stateStore.getProfile("default"); - const confirmed = await api(handle, "/api/status", { profileId: "default" }, credential); - assert.equal(confirmed.status, 200); - - const missing = await request({ - origin: handle.origin, - pathname: "/api/sync", - method: "POST", - body: { profileId: "default", profileRevision: profile.revision, provider: "openai", keepCount: 5 }, - headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } - }); - assert.equal(missing.status, 409); - assert.equal(missing.payload.code, "STORAGE_REVISION_REQUIRED"); + const explicit = await api(handle, "/api/switch/prepare", { + profileId: "default", + provider: "relay", + keepCount: 5, + modelMode: "explicit", + model: "model-x" + }, credential); + assert.equal(explicit.status, 200); + assert.equal(switchCalls[0].model, "model-x"); + assert.equal(switchCalls[0].keepRootModel, false); - configText = 'model_provider = "openai"\nsqlite_home = "sqlite-b"\n'; - const changed = await request({ - origin: handle.origin, - pathname: "/api/sync", - method: "POST", - body: { - profileId: "default", - profileRevision: profile.revision, - storageRevision: confirmed.payload.status.storageRevision, - provider: "openai", - keepCount: 5 - }, - headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } - }); - assert.equal(changed.status, 409); - assert.equal(changed.payload.code, "STORAGE_CHANGED"); - assert.equal(syncCalls.length, 0); + const invalid = await api(handle, "/api/switch/prepare", { + profileId: "default", + provider: "relay", + keepCount: 5, + modelMode: "keep-root-model", + model: "must-not-pass" + }, credential); + assert.equal(invalid.status, 400); + assert.equal(invalid.payload.coreError.code, "INVALID_INPUT"); - const refreshed = await api(handle, "/api/status", { profileId: "default" }, credential); - const accepted = await request({ - origin: handle.origin, - pathname: "/api/sync", - method: "POST", - body: { - profileId: "default", - profileRevision: profile.revision, - storageRevision: refreshed.payload.status.storageRevision, - provider: "openai", - keepCount: 5 - }, - headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } - }); - assert.equal(accepted.status, 200); - assert.equal(syncCalls.length, 1); - assert.equal(syncCalls[0].storage.sqliteHome, path.resolve("sqlite-b")); - assert.equal(syncCalls[0].storage.sqliteHomeSource, "config"); - assert.equal(syncCalls[0].expectedConfigText, configText); + const restore = await api(handle, "/api/restore/prepare", { + profileId: "default", + backupId: "managed-backup-1", + restoreConfig: true, + restoreDatabase: false, + restoreSessions: true + }, credential); + assert.equal(restore.status, 200); + assert.equal(restoreCalls.length, 1); + assert.equal(restoreCalls[0].backupId, "managed-backup-1"); + assert.equal(Object.hasOwn(restoreCalls[0], "backupDir"), false); } finally { await handle.close(); } }); -test("Web UI binds confirmed operations to config contents even when storage is unchanged", async () => { - let configText = 'model_provider = "openai"\nmodel = "gpt-5"\n'; - const syncCalls = []; +test("Web Apply appends a safe CoreError DTO without leaking transport internals", async () => { const handle = await startFixture({ - readConfigText: async () => configText, - readRootModelFromConfigText: (text) => /^model = "([^"]+)"$/m.exec(text)?.[1] ?? null, - getStatus: async ({ storage }) => statusFixture({ - codexHome: storage.codexHome, - sqliteHome: storage.sqliteHome, - sqliteHomeSource: storage.sqliteHomeSource, - sqliteAccess: storage.sqliteAccess, - stateDbLocation: storage.stateDbLocation, - checkedStateDbPaths: storage.stateDbCandidates.map((candidate) => candidate.path) - }), - runSync: async (options) => { syncCalls.push(options); return {}; } + applySync: async () => { + throw new CoreError("SQLITE_BUSY", "The state database is busy.", { + details: { causeCode: "SQLITE_BUSY" } + }); + } }); try { const { credential } = await handle.pair(); - const profile = handle.stateStore.getProfile("default"); - const confirmed = await api(handle, "/api/status", { profileId: "default" }, credential); - assert.equal(confirmed.status, 200); + const response = await api( + handle, + "/api/sync/apply", + { schemaVersion: 1, planId: "opaque-plan" }, + credential + ); - configText = 'model_provider = "openai"\nmodel = "gpt-5.2"\n'; - const changed = await request({ - origin: handle.origin, - pathname: "/api/sync", - method: "POST", - body: { - profileId: "default", - profileRevision: profile.revision, - storageRevision: confirmed.payload.status.storageRevision, - provider: "openai", - keepCount: 5 - }, - headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } + assert.equal(response.status, 400); + assert.equal(response.payload.error, "The state database is busy."); + assert.equal(response.payload.code, undefined); + assert.deepEqual(response.payload.coreError, { + code: "SQLITE_BUSY", + message: "The state database is busy.", + severity: "warning", + retryable: true, + recoveryRequired: false, + details: { causeCode: "SQLITE_BUSY" } }); - assert.equal(changed.status, 409); - assert.equal(changed.payload.code, "STORAGE_CHANGED"); - assert.match(changed.payload.error, /configuration or effective SQLite storage changed/); - assert.equal(syncCalls.length, 0); + } finally { + await handle.close(); + } +}); - const refreshed = await api(handle, "/api/status", { profileId: "default" }, credential); - assert.notEqual(refreshed.payload.status.storageRevision, confirmed.payload.status.storageRevision); - const accepted = await request({ - origin: handle.origin, - pathname: "/api/sync", - method: "POST", - body: { - profileId: "default", - profileRevision: profile.revision, - storageRevision: refreshed.payload.status.storageRevision, - provider: "openai", - keepCount: 5 - }, - headers: { Origin: handle.origin, "X-Codex-Provider-Device": credential } - }); - assert.equal(accepted.status, 200); - assert.equal(syncCalls.length, 1); - assert.equal(syncCalls[0].expectedConfigText, configText); - assert.equal(syncCalls[0].model, "gpt-5.2"); +test("Web status forwards the Core last-complete snapshot without reading or mixing live storage", async () => { + let configReads = 0; + const coreStatus = coreStatusFixture({ id: "default", revision: "trusted-profile-revision" }, { + snapshotAt: "2026-08-25T00:00:00.000Z", + storageRevision: "cached-storage-revision", + currentProvider: "openai", + operationInProgress: { + operationId: "external-operation", + operation: "sync", + actor: "external", + busyScope: "codex-home" + } + }); + const handle = await startFixture({ + readConfigText: async () => { configReads += 1; throw new Error("status must not read config in Web"); }, + coreFacade: { getStatus: async () => cloneJson(coreStatus) } + }); + try { + const { credential } = await handle.pair(); + const response = await api(handle, "/api/status", { profileId: "default" }, credential); + assert.equal(response.status, 200); + assert.equal(configReads, 0); + assert.equal(response.payload.status.storageRevision, coreStatus.storageRevision); + assert.deepEqual(response.payload.status.operationInProgress, coreStatus.operationInProgress); + assert.equal(response.payload.status.currentProvider, "openai"); } finally { await handle.close(); } }); -test("Web UI restore only accepts managed backups for the selected profile", async () => { - let restored = false; +test("Web Switch and Restore Apply reject every field beyond schemaVersion and planId", async () => { + let switchCalls = 0; + let restoreCalls = 0; const handle = await startFixture({ - listBackups: async () => ({ backupRoot: "/tmp/.codex/backups_state/provider-sync", backups: [{ id: "known", path: "/tmp/.codex/backups_state/provider-sync/known", metadata: {} }] }), - runRestore: async () => { restored = true; return { targetProvider: "openai" }; } + applySwitch: async () => { switchCalls += 1; }, + applyRestore: async () => { restoreCalls += 1; } }); try { const { credential } = await handle.pair(); - const response = await api(handle, "/api/restore", { profileId: "default", backupId: "../../outside", restoreDatabase: true, restoreSessions: true }, credential); + const switchResponse = await api(handle, "/api/switch/apply", { + schemaVersion: 1, + planId: "switch-plan", + provider: "attacker" + }, credential); + const restoreResponse = await api(handle, "/api/restore/apply", { + schemaVersion: 1, + planId: "restore-plan", + backupId: "attacker" + }, credential); + assert.equal(switchResponse.status, 400); + assert.equal(restoreResponse.status, 400); + assert.equal(switchResponse.payload.coreError.code, "INVALID_INPUT"); + assert.equal(restoreResponse.payload.coreError.code, "INVALID_INPUT"); + assert.equal(switchCalls, 0); + assert.equal(restoreCalls, 0); + } finally { + await handle.close(); + } +}); + +test("Web Restore preparation delegates managed backup membership validation to Core", async () => { + let applies = 0; + const handle = await startFixture({ + prepareRestore: async ({ backupId }) => { + assert.equal(backupId, "../../outside"); + throw new CoreError("RESTORE_VALIDATION_FAILED", "The selected backup is not managed by this Codex Home."); + }, + applyRestore: async () => { applies += 1; } + }); + try { + const { credential } = await handle.pair(); + const response = await api(handle, "/api/restore/prepare", { profileId: "default", backupId: "../../outside", restoreDatabase: true }, credential); assert.equal(response.status, 400); - assert.equal(restored, false); + assert.equal(response.payload.coreError.code, "RESTORE_VALIDATION_FAILED"); + assert.equal(applies, 0); } finally { await handle.close(); } @@ -512,14 +948,50 @@ test("Web UI restore only accepts managed backups for the selected profile", asy test("Web UI history endpoints delegate through the selected profile", async () => { const calls = []; const handle = await startFixture({ - listHistory: async (codexHome, options) => { calls.push(["list", codexHome, options]); return { page: 1, pageSize: 50, total: 1, hasNextPage: false, sessions: [{ id: "thread", title: "safe" }] }; }, - getHistorySession: async (codexHome, sessionId) => { calls.push(["detail", codexHome, sessionId]); return { session: { id: sessionId }, messages: [], truncated: false, returnedMessageCount: 0 }; } + coreFacade: { + async listHistory(input) { + calls.push(["list", cloneJson(input)]); + return { + page: 1, + pageSize: 50, + total: 1, + hasNextPage: false, + sessions: [{ + id: "thread", + title: "safe", + provider: "openai", + archived: false, + updatedAt: "2026-08-25T00:00:00.000Z", + messageCount: 1 + }] + }; + }, + async getHistorySession(input) { + calls.push(["detail", cloneJson(input)]); + return { + session: { + id: input.sessionId, + title: "safe", + provider: "openai", + archived: false, + updatedAt: "2026-08-25T00:00:00.000Z", + messageCount: 0 + }, + messages: [], + truncated: false, + returnedMessageCount: 0 + }; + } + } }); try { const { credential } = await handle.pair(); assert.equal((await api(handle, "/api/history", { profileId: "default", query: "safe" }, credential)).status, 200); assert.equal((await api(handle, "/api/history/session", { profileId: "default", sessionId: "thread" }, credential)).status, 200); - assert.deepEqual(calls.map((call) => call[0]), ["list", "detail"]); + assert.deepEqual(calls, [ + ["list", { profile: { profileId: "default" }, query: "safe" }], + ["detail", { profile: { profileId: "default" }, sessionId: "thread" }] + ]); } finally { await handle.close(); } @@ -548,10 +1020,39 @@ test("Web UI opens a no-thread-id history session from a rollout path longer tha const { credential } = await handle.pair(); const listed = await api(handle, "/api/history", { profileId: "default" }, credential); assert.equal(listed.status, 200); + if (process.platform === "win32" + && process.versions.node.startsWith("16.") + && listed.payload.history.total === 0) { + const namespacedText = await fs.readFile(path.toNamespacedPath(rolloutPath), "utf8"); + assert.match(namespacedText, /Open this session/); + const legacyFailures = []; + for (const [operation, probe] of [ + ["readdir", () => fs.readdir(path.dirname(rolloutPath))], + ["lstat", () => fs.lstat(rolloutPath)], + ["realpath", () => fs.realpath(rolloutPath)] + ]) { + try { + await probe(); + } catch (error) { + if (["ENAMETOOLONG", "ENOENT"].includes(error?.code)) { + legacyFailures.push(`${operation}:${error.code}`); + continue; + } + throw error; + } + } + assert.ok( + legacyFailures.length > 0, + "History omitted a namespaced-readable fixture without a known Node 16 long-path primitive failure." + ); + t.skip(`Node 16 on Windows cannot enumerate this greater-than-MAX_PATH fixture (${legacyFailures.join(", ")}); Node 24 retains full coverage.`); + return; + } assert.equal(listed.payload.history.total, 1); const [session] = listed.payload.history.sessions; assert.match(session.id, /^rollout:[A-Za-z0-9_-]{43}$/); - assert.equal(session.rolloutPath, path.resolve(rolloutPath)); + assert.equal("rolloutPath" in session, false); + assert.equal("cwd" in session, false); const detail = await api(handle, "/api/history/session", { profileId: "default", @@ -811,8 +1312,8 @@ test("startWebUi reports occupied ports clearly and handles unavailable or headl }); test("Web UI operations require a current profile revision and preserve a captured profile snapshot", async () => { - let syncCalls = 0; - const handle = await startFixture({ runSync: async () => { syncCalls += 1; return {}; } }); + let prepareCalls = 0; + const handle = await startFixture({ prepareSync: async () => { prepareCalls += 1; return {}; } }); try { const first = await handle.pair(); const second = await handle.pair(); @@ -831,7 +1332,7 @@ test("Web UI operations require a current profile revision and preserve a captur const changed = await request({ origin: handle.origin, - pathname: "/api/sync", + pathname: "/api/sync/prepare", method: "POST", body: { profileId: "work", profileRevision: stale.revision, provider: "openai", keepCount: 5 }, headers: { Origin: handle.origin, "X-Codex-Provider-Device": first.credential } @@ -839,18 +1340,18 @@ test("Web UI operations require a current profile revision and preserve a captur assert.equal(changed.status, 409); assert.equal(changed.payload.code, "PROFILE_CHANGED"); assert.equal(changed.payload.profile.codexHome, path.resolve("/tmp/work-after")); - assert.equal(syncCalls, 0); + assert.equal(prepareCalls, 0); const required = await request({ origin: handle.origin, - pathname: "/api/sync", + pathname: "/api/sync/prepare", method: "POST", body: { profileId: "work", provider: "openai", keepCount: 5 }, headers: { Origin: handle.origin, "X-Codex-Provider-Device": first.credential } }); assert.equal(required.status, 409); assert.equal(required.payload.code, "PROFILE_REVISION_REQUIRED"); - assert.equal(syncCalls, 0); + assert.equal(prepareCalls, 0); } finally { await handle.close(); } @@ -962,13 +1463,19 @@ test("Web UI state profile saves apply revision checks after asynchronous valida test("Web UI marks skipped locked rollout files as a partial operation outcome", async () => { const handle = await startFixture({ - readConfigText: async () => 'model = "gpt-5"\n', - readRootModelFromConfigText: () => "gpt-5", - runSync: async () => ({ skippedLockedRolloutFiles: ["rollout-active.jsonl"] }) + applySync: async () => ({ + schemaVersion: 1, + operationId: "partial-operation", + operation: "sync", + outcome: "partial", + backup: null, + warnings: [], + result: { skippedLockedRolloutFiles: ["rollout-active.jsonl"] } + }) }); try { const { credential } = await handle.pair(); - const response = await api(handle, "/api/sync", { profileId: "default", provider: "openai", keepCount: 5 }, credential); + const response = await api(handle, "/api/sync/apply", { schemaVersion: 1, planId: "partial-plan" }, credential); assert.equal(response.status, 200); assert.equal(response.payload.result.outcome, "partial"); } finally { @@ -977,12 +1484,11 @@ test("Web UI marks skipped locked rollout files as a partial operation outcome", }); test("Web UI restore requires an explicit SQLite Home for relocation and rejects WSL UNC storage", async () => { - let restoreCalls = 0; - const backups = { backupRoot: "/tmp/.codex/backups_state/provider-sync", backups: [{ id: "known", path: "/tmp/.codex/backups_state/provider-sync/known", metadata: {} }] }; - const handle = await startFixture({ listBackups: async () => backups, runRestore: async () => { restoreCalls += 1; return {}; } }); + let restorePrepareCalls = 0; + const handle = await startFixture({ prepareRestore: async () => { restorePrepareCalls += 1; return {}; } }); try { const { credential } = await handle.pair(); - const relocation = await api(handle, "/api/restore", { + const relocation = await api(handle, "/api/restore/prepare", { profileId: "default", backupId: "known", restoreDatabase: true, @@ -990,7 +1496,7 @@ test("Web UI restore requires an explicit SQLite Home for relocation and rejects }, credential); assert.equal(relocation.status, 400); assert.match(relocation.payload.error, /explicit SQLite Home target/); - assert.equal(restoreCalls, 0); + assert.equal(restorePrepareCalls, 0); } finally { await handle.close(); } @@ -1006,16 +1512,28 @@ test("Web UI restore requires an explicit SQLite Home for relocation and rejects }; assert.equal(wslStore.getProfile("default").sqliteHome, rawWslUnc); + let wslPrepareCalls = 0; const wslHandle = await startFixture( - { runSync: async () => { restoreCalls += 1; return {}; } }, + { + readConfigText: async () => 'model_provider = "openai"\n', + prepareSync: async (options) => { + wslPrepareCalls += 1; + assert.equal(options.sqliteHome, rawWslUnc); + throw new CoreError("SQLITE_UNSUPPORTED_PATH", "Windows cannot safely access SQLite through the WSL UNC path.", { + details: { reason: "windows-wsl-unc" } + }); + } + }, { platform: "win32", stateStore: wslStore } ); try { const { credential } = await wslHandle.pair(); - const rejected = await api(wslHandle, "/api/sync", { profileId: "default", provider: "openai", keepCount: 5 }, credential); + const rejected = await api(wslHandle, "/api/sync/prepare", { profileId: "default", provider: "openai", keepCount: 5 }, credential); assert.equal(rejected.status, 400); assert.match(rejected.payload.error, /Windows cannot safely access SQLite through the WSL UNC path/); - assert.equal(restoreCalls, 0); + assert.equal(rejected.payload.coreError.code, "SQLITE_UNSUPPORTED_PATH"); + assert.deepEqual(rejected.payload.coreError.details, { reason: "windows-wsl-unc" }); + assert.equal(wslPrepareCalls, 1); } finally { await wslHandle.close(); await fs.rm(wslCodexHome, { recursive: true, force: true }); diff --git a/test/workspace-boundaries.test.js b/test/workspace-boundaries.test.js new file mode 100644 index 0000000..a7fb6da --- /dev/null +++ b/test/workspace-boundaries.test.js @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; + +const repositoryRoot = path.resolve(new URL("..", import.meta.url).pathname.replace(/^\/(?:([A-Za-z]:))/, "$1")); +const workspacePaths = [ + "apps/cli", + "apps/web", + "apps/desktop", + "packages/core", + "packages/contracts", + "packages/core-client", + "packages/app-ui", + "packages/design-system", + "packages/test-fixtures" +]; +const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; + +async function manifest(relativePath) { + return JSON.parse(await fs.readFile(path.join(repositoryRoot, relativePath, "package.json"), "utf8")); +} + +test("root npm package remains a Node 16 surface with only the audited shared Web runtime", async () => { + const root = await manifest(""); + assert.equal(root.name, "@dailin521/codex-provider-sync"); + assert.equal(root.engines.node, ">=16.20.2"); + assert.equal(root.bin["codex-provider"], "src/cli.js"); + assert.deepEqual(root.dependencies ?? {}, {}); + assert.ok(root.files.includes("src")); + assert.ok(root.files.includes("web/dist")); + assert.equal(root.files.some((entry) => entry.startsWith("apps")), false); + assert.deepEqual( + root.files.filter((entry) => entry.startsWith("packages")).sort(), + ["packages/contracts/dist", "packages/core/src"] + ); + const webConfig = await fs.readFile(path.join(repositoryRoot, "apps/web/vite.config.ts"), "utf8"); + assert.match(webConfig, /sourcemap:\s*false/); +}); + +test("all C4 workspaces are private and direct dependency versions are exact", async () => { + for (const workspacePath of workspacePaths) { + const workspace = await manifest(workspacePath); + assert.equal(workspace.private, true, workspacePath); + for (const field of dependencyFields) { + for (const [name, specification] of Object.entries(workspace[field] ?? {})) { + assert.match(specification, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/, `${workspacePath} ${field}.${name}`); + } + } + } +}); + +test("Core bridge has one explicit transition exception and no deep implementation import", async () => { + const source = await fs.readFile(path.join(repositoryRoot, "packages/core/src/index.js"), "utf8"); + const imports = [...source.matchAll(/from\s+["'](\.\.\/\.\.\/\.\.\/src\/[^"']+)["']/g)] + .map((match) => match[1]); + assert.deepEqual(imports, ["../../../src/public-api.js"]); + assert.doesNotMatch(source, /src\/(service|locking|backup|history|watch)\.js/); +}); + +test("transitional root declarations match runtime exports and mark legacy adapters", async () => { + const declarations = await fs.readFile(path.join(repositoryRoot, "src/public-api.d.ts"), "utf8"); + const declaredNames = [...declarations.matchAll(/export\s+(?:declare\s+)?(?:const|class|function)\s+([A-Za-z0-9_]+)/g)] + .map((match) => match[1]) + .sort(); + const runtime = await import("../src/public-api.js"); + assert.deepEqual(declaredNames, Object.keys(runtime).sort()); + for (const adapter of ["runSync", "runSwitch", "runRestore", "runPruneBackups", "runWatch"]) { + assert.match( + declarations, + new RegExp(`@deprecated[^]*?export function ${adapter}\\(`), + `${adapter} must remain explicitly deprecated during migration` + ); + } +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..2d411dc --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/tsconfig.workspaces.json b/tsconfig.workspaces.json new file mode 100644 index 0000000..e2cef36 --- /dev/null +++ b/tsconfig.workspaces.json @@ -0,0 +1,10 @@ +{ + "files": [], + "references": [ + { "path": "packages/contracts" }, + { "path": "packages/core-client" }, + { "path": "packages/design-system" }, + { "path": "packages/app-ui" }, + { "path": "apps/desktop" } + ] +} diff --git a/web/dist/assets/index-776d2b19.css b/web/dist/assets/index-776d2b19.css deleted file mode 100644 index dbec9df..0000000 --- a/web/dist/assets/index-776d2b19.css +++ /dev/null @@ -1 +0,0 @@ -:root{color:#172033;background:#f5f7fa;font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Microsoft YaHei,sans-serif;font-synthesis:none;text-rendering:optimizeLegibility;--accent: #2463eb;--accent-strong: #174fc8;--accent-soft: #edf3ff;--success: #11865b;--success-soft: #eaf8f2;--warning: #b36309;--warning-soft: #fff7e6;--danger: #c43c45;--danger-soft: #fff0f0;--text: #172033;--muted: #687388;--subtle: #8a94a7;--border: #dfe4ec;--border-strong: #cdd4df;--surface: #ffffff;--sidebar: #fbfcfe;--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;background:#f5f7fa}button,input,select{font:inherit}button{color:inherit}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid rgba(36,99,235,.18);outline-offset:1px}.app-shell{min-height:100vh;display:grid;grid-template-columns:198px minmax(0,1fr);grid-template-rows:68px minmax(0,1fr)}.app-header{grid-column:1 / -1;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:30}.brand,.header-actions,.service-state,.brand-mark,.nav-item,.sidebar-provider-value,.path-input-wrap,.button,.alignment-state,.column-label,.warning-row,.execution-head,.backup-assurance,.text-button,.backup-row,.backup-root-line,.live-operation,.modal-callout,.restore-summary,.toast>div{display:flex;align-items:center}.brand{gap:11px}.brand-mark{width:36px;height:36px;justify-content:center;color:#fff;background:#202a3d;border-radius:9px;box-shadow:0 5px 13px #17203329}.brand-name{font-size:15px;font-weight:720;letter-spacing:-.01em}.brand-subtitle{margin-top:2px;color:var(--muted);font-size:11px}.header-actions{gap:16px}.service-state{gap:8px;color:#4f5b70;font-size:12px}.status-dot{width:7px;height:7px;border-radius:50%;background:#9aa4b5;box-shadow:0 0 0 3px #9aa4b51f;flex:0 0 auto}.status-dot--success{background:var(--success);box-shadow:0 0 0 3px #11865b1f}.status-dot--warning{background:#db820d;box-shadow:0 0 0 3px #db820d1f}.status-dot--danger{background:var(--danger);box-shadow:0 0 0 3px #c43c451f}.sidebar{grid-column:1;grid-row:2;position:sticky;top:68px;height:calc(100vh - 68px);display:flex;flex-direction:column;padding:19px 12px 16px;background:var(--sidebar);border-right:1px solid var(--border)}.navigation{display:grid;gap:4px}.nav-item{width:100%;gap:10px;border:0;border-radius:7px;background:transparent;padding:10px 12px;color:#657087;font-size:13px;text-align:left;cursor:pointer}.nav-item:hover{background:#f0f3f8;color:#354157}.nav-item--active{background:var(--accent-soft);color:#174fc8;font-weight:650;box-shadow:inset 2px 0 0 var(--accent)}.sidebar-foot{margin-top:auto;padding:14px 10px 0;border-top:1px solid var(--border)}.sidebar-provider-label{color:var(--subtle);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.sidebar-provider-value{gap:8px;margin-top:8px;font-family:var(--mono);font-size:12px;font-weight:700}.sidebar-version{margin-top:12px;color:#a0a8b6;font-size:10px}.main-area{grid-column:2;grid-row:2;min-width:0}.storage-bar{min-height:88px;display:grid;grid-template-columns:minmax(220px,.8fr) minmax(260px,1.2fr) auto auto auto;gap:10px;align-items:end;padding:15px 26px 16px;background:rgba(255,255,255,.92);border-bottom:1px solid var(--border);position:sticky;top:68px;z-index:20;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}.path-field{display:grid;gap:6px;min-width:0;color:#455168;font-size:11px;font-weight:650}.path-field span small{color:var(--subtle);font-weight:450}.path-input-wrap{height:36px;gap:8px;padding:0 10px;border:1px solid var(--border-strong);background:#fff;border-radius:7px;color:#8791a3}.path-input-wrap:focus-within{border-color:#8eaff6;box-shadow:0 0 0 3px #2463eb17}.path-input-wrap input,.path-input-wrap select{min-width:0;width:100%;border:0;outline:0;color:#273247;font-family:var(--mono);font-size:11px;background:transparent}.storage-refresh{height:36px}.view-content{max-width:1480px;margin:0 auto;padding:24px 26px 42px}.history-view{height:calc(100dvh - 156px);min-height:0;display:flex;flex-direction:column;overflow:hidden}h1,h2,p{margin-top:0}h1{margin-bottom:6px;font-size:25px;letter-spacing:-.025em}h2{margin-bottom:5px;font-size:16px;letter-spacing:-.014em}.section-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.section-title-row p,.execution-head p,.page-intro p{margin-bottom:0;color:var(--muted);font-size:12px;line-height:1.55}.section-title-row--compact{align-items:center}.button{min-height:36px;justify-content:center;gap:7px;padding:0 13px;border:1px solid transparent;border-radius:7px;font-size:12px;font-weight:650;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .15s ease}.button:hover:not(:disabled){transform:translateY(-1px)}.button:disabled{opacity:.48;cursor:not-allowed}.button--primary{color:#fff;background:var(--accent);border-color:var(--accent);box-shadow:0 5px 12px #2463eb2e}.button--primary:hover:not(:disabled){background:var(--accent-strong)}.button--secondary{color:#3d4960;background:#fff;border-color:var(--border-strong);box-shadow:0 1px 2px #1720330a}.button--secondary:hover:not(:disabled){background:#f8faff;border-color:#b7c2d2}.button--quiet{background:#f5f7fa;border-color:#edf0f5;color:#3e4b62}.button--danger{color:#fff;background:var(--danger);border-color:var(--danger)}.button--compact{min-height:31px;padding:0 10px;font-size:11px}.text-button{gap:3px;border:0;background:transparent;color:var(--accent);font-size:11px;font-weight:650;cursor:pointer}.icon-button{width:32px;height:32px;display:grid;place-items:center;border:0;border-radius:6px;background:transparent;cursor:pointer;color:var(--muted)}.icon-button:hover{background:#f1f3f7}.status-panel,.data-section,.recent-backups,.backup-list-section,.activity-console{background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 1px 2px #17203306}.status-panel{padding:20px 22px 21px}.alignment-state{gap:7px;min-height:30px;padding:0 10px;border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:650}.alignment-state--success{color:var(--success);border-color:#bfe4d4;background:var(--success-soft)}.alignment-state--warning{color:var(--warning);border-color:#ead2a4;background:var(--warning-soft)}.summary-strip{display:grid;grid-template-columns:repeat(4,1fr);margin-top:20px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.summary-item{min-width:0;padding:15px 18px;border-right:1px solid var(--border)}.summary-item:first-child{padding-left:0}.summary-item:last-child{border-right:0}.summary-item>span{display:block;color:var(--muted);font-size:10px}.summary-item strong{display:block;margin-top:5px;font-size:19px;letter-spacing:-.025em;overflow:hidden;text-overflow:ellipsis}.summary-item small{display:block;margin-top:3px;color:var(--subtle);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.distribution-grid{display:grid;grid-template-columns:minmax(0,1fr) 1px minmax(0,1fr);gap:22px;padding-top:19px}.distribution-divider{background:var(--border)}.column-label{gap:7px;margin-bottom:14px;color:#3f4a5f;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.distribution-block+.distribution-block{margin-top:16px}.distribution-heading,.distribution-meta{display:flex;justify-content:space-between;gap:12px}.distribution-heading{margin-bottom:8px;color:var(--muted);font-size:11px}.distribution-heading strong{color:#3c485e}.distribution-list{display:grid;gap:8px}.distribution-meta{margin-bottom:5px;color:var(--muted);font-family:var(--mono);font-size:10px}.provider-name{color:#303b50}.bar-track{height:5px;overflow:hidden;background:#edf0f5;border-radius:99px}.bar-fill{display:block;height:100%;background:#9eabc0;border-radius:inherit}.bar-fill--current{background:var(--accent)}.empty-inline{color:var(--subtle);font-size:11px}.warning-stack{margin-top:12px;display:grid;gap:7px}.warning-row{align-items:flex-start;gap:10px;padding:10px 13px;border:1px solid;border-radius:7px;font-size:11px}.warning-row svg{margin-top:1px;flex:0 0 auto}.warning-row div{display:grid;gap:2px}.warning-row span{color:#6e5d47;line-height:1.5;word-break:break-word}.warning-row--warning{background:var(--warning-soft);border-color:#ebd7ae;color:var(--warning)}.warning-row--danger{background:var(--danger-soft);border-color:#efc2c4;color:var(--danger)}.warning-row--info{background:#eef6ff;border-color:#c9def8;color:#2463a7}.warning-row--info span{color:#4f6a89}.overview-lower-grid{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:18px;align-items:start;margin-top:18px}.overview-main-column{min-width:0;width:100%;display:grid;grid-template-columns:minmax(0,1fr);gap:18px}.data-section,.recent-backups{min-width:0;padding:18px 20px}.table-scroll{overflow-x:auto;margin-top:15px}.data-table{width:100%;border-collapse:collapse;font-size:11px}.data-table th{padding:9px 10px;color:var(--muted);background:#f8fafc;border-top:1px solid var(--border);border-bottom:1px solid var(--border);font-weight:650;text-align:left;white-space:nowrap}.data-table td{padding:11px 10px;border-bottom:1px solid #edf0f4;color:#465268;vertical-align:top}.data-table tbody tr:last-child td{border-bottom:0}.path-cell{max-width:260px;font-family:var(--mono);color:#263249!important;word-break:break-all}.table-empty{color:var(--subtle)!important;text-align:center;padding:25px!important}.execution-panel{position:sticky;top:174px;padding:19px;background:#fff;border:1px solid #cdd8e9;border-radius:10px;box-shadow:0 9px 24px #26375312}.execution-head{align-items:flex-start;justify-content:space-between;gap:14px}.execution-head>svg{color:var(--accent);margin-top:2px}.segmented{display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-top:17px;padding:4px;background:#f1f4f8;border-radius:8px}.segmented-option{min-width:0;display:grid;gap:3px;padding:9px 8px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;text-align:left}.segmented-option span{font-size:11px;font-weight:700}.segmented-option small{color:var(--subtle);font-size:9px}.segmented-option--active{background:#fff;color:#263249;border-color:#dce3ee;box-shadow:0 2px 5px #17203312}.form-grid{display:grid;grid-template-columns:minmax(0,1fr) 100px;gap:12px;margin-top:15px}.form-field{display:grid;align-content:start;gap:6px;color:#4c586d;font-size:10px;font-weight:650}.form-field select,.form-field input,.custom-model-option input[type=text]{width:100%;height:35px;padding:0 9px;color:#273247;background:#fff;border:1px solid var(--border-strong);border-radius:6px;font-size:11px}.form-field small{color:var(--subtle);font-weight:450;line-height:1.35}.form-field .field-error{color:var(--danger)}.manual-provider-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center;margin-top:9px}.manual-provider-row input{min-width:0;height:32px;padding:0 9px;color:#273247;background:#fff;border:1px solid var(--border-strong);border-radius:6px;font-family:var(--mono);font-size:10px}.manual-remove{grid-column:1 / -1;justify-self:start;padding:0;border:0;background:transparent;color:var(--danger);font-size:9px;cursor:pointer}.model-options,.restore-options{margin:16px 0 0;padding:13px;border:1px solid var(--border);border-radius:7px}.model-options legend,.restore-options legend{padding:0 5px;color:#566176;font-size:10px;font-weight:700}.model-options>label,.restore-options>label{display:flex;align-items:flex-start;gap:8px;padding:7px 0;color:#364258;font-size:11px;cursor:pointer}.model-options input[type=radio],.restore-options input[type=checkbox]{margin:2px 0 0;accent-color:var(--accent)}.model-options label>span,.restore-options label>span{min-width:0;display:grid;gap:2px;flex:1}.model-options small,.restore-options small{color:var(--muted);font-size:9px;font-weight:450;line-height:1.4}.custom-model-option input[type=text]{margin-top:5px}.backup-assurance{gap:7px;margin-top:14px;color:var(--success);font-size:10px}.execute-button{width:100%;height:40px;margin-top:14px}.backup-rows{margin-top:13px;border-top:1px solid var(--border)}.backup-row{min-width:0;gap:11px;padding:11px 0;border-bottom:1px solid #edf0f4}.backup-row:last-child{border-bottom:0}.backup-icon{width:30px;height:30px;display:grid;place-items:center;color:#5e6a80;background:#f1f4f8;border-radius:7px;flex:0 0 auto}.backup-main{min-width:0;display:grid;gap:2px;flex:1}.backup-main strong{font-size:11px}.backup-main span,.backup-size{color:var(--muted);font-size:10px}.empty-state{padding:25px 0 12px;color:var(--subtle);font-size:11px;text-align:center}.page-intro{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:4px 2px 20px}.prune-control{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:11px}.prune-control input{width:64px;height:34px;border:1px solid var(--border-strong);border-radius:6px;text-align:center}.backup-list-section{overflow:hidden}.backup-root-line{gap:8px;padding:13px 17px;background:#f8fafc;border-bottom:1px solid var(--border);color:#59657a;font-size:10px}.backup-root-line span{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;font-family:var(--mono);white-space:nowrap}.backup-root-line strong{color:#465168;font-size:10px}.full-backup-row{display:grid;grid-template-columns:190px 230px minmax(220px,1fr) auto;gap:22px;align-items:center;padding:16px 18px;border-bottom:1px solid var(--border)}.full-backup-row:last-child{border-bottom:0}.backup-date,.backup-source{min-width:0;display:grid;gap:5px}.backup-date strong{font-size:12px}.backup-date span,.backup-source span{color:var(--subtle);font-size:9px}.backup-facts{display:flex;gap:15px;color:var(--muted);font-size:10px}.backup-facts span{display:grid;gap:3px}.backup-facts strong{color:#344057}.backup-source code{overflow:hidden;color:#445169;font-family:var(--mono);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.backup-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:12px;color:var(--muted);font-size:10px}.large-empty{display:grid;justify-items:center;gap:7px;padding:70px 20px;color:var(--subtle)}.large-empty strong{color:#59657a;font-size:13px}.large-empty span{font-size:11px}.live-operation{gap:7px;color:var(--warning);font-size:11px}.activity-console{overflow:hidden}.console-head{display:flex;justify-content:space-between;padding:12px 15px;color:#b6c2d4;background:#202a3b;border-bottom:1px solid #354156;font-family:var(--mono);font-size:10px}.console-body{min-height:440px;max-height:calc(100vh - 250px);overflow:auto;padding:7px 0;background:#17202f;content-visibility:auto}.console-row{display:grid;grid-template-columns:150px 70px minmax(220px,1fr) minmax(0,1.2fr);gap:12px;padding:8px 15px;border-bottom:1px solid rgba(255,255,255,.035);color:#d7deea;font-family:var(--mono);font-size:10px;line-height:1.45}.console-row time,.console-detail{color:#7e8ca3}.console-level{color:#8fa3c1;text-transform:uppercase}.console-row--success .console-level{color:#54d19f}.console-row--error .console-level{color:#ff8790}.console-row--progress .console-level{color:#79a8ff}.console-empty{padding:30px 15px;color:#708098;font-family:var(--mono);font-size:11px}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:100;display:grid;place-items:center;padding:20px;background:rgba(20,28,43,.46);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.modal{width:min(600px,100%);max-height:calc(100vh - 40px);overflow:auto;background:#fff;border:1px solid #cfd6e1;border-radius:12px;box-shadow:0 24px 70px #0e172840;animation:modal-in .16s ease-out}.modal-head{display:flex;align-items:center;justify-content:space-between;padding:17px 19px;border-bottom:1px solid var(--border)}.modal-head h2{margin:0;font-size:16px}.modal-body{padding:18px 19px}.modal-actions{display:flex;justify-content:flex-end;gap:9px;padding:13px 19px;background:#f8fafc;border-top:1px solid var(--border)}.modal-callout{align-items:flex-start;gap:10px;padding:11px 12px;color:#9a5909;background:var(--warning-soft);border:1px solid #ead4aa;border-radius:7px}.modal-callout>svg{margin-top:1px;flex:0 0 auto}.modal-callout div{display:grid;gap:3px}.modal-callout span{color:#735f43;font-size:10px;line-height:1.55;word-break:break-word}.modal-callout--danger{color:var(--danger);background:var(--danger-soft);border-color:#efc1c4}.modal-callout--warning{margin-top:14px}.operation-scope{margin:16px 0 0;border-top:1px solid var(--border)}.operation-scope>div{display:grid;grid-template-columns:130px minmax(0,1fr);gap:14px;padding:9px 0;border-bottom:1px solid #edf0f4;font-size:11px}.operation-scope dt{color:var(--muted)}.operation-scope dd{margin:0;color:#2d394f;font-family:var(--mono);word-break:break-word}.operation-scope small{color:var(--subtle)}.restore-summary{align-items:flex-start;gap:10px;padding-bottom:13px;border-bottom:1px solid var(--border)}.restore-summary div{min-width:0;display:grid;gap:4px}.restore-summary strong{font-size:12px}.restore-summary code{color:var(--muted);font-family:var(--mono);font-size:9px;word-break:break-all}.restore-options{margin-bottom:14px}.profile-form{display:grid;gap:12px}.profile-form label{display:grid;gap:6px;color:#4c586d;font-size:10px;font-weight:650}.profile-form input{width:100%;height:36px;padding:0 10px;color:#273247;border:1px solid var(--border-strong);border-radius:6px;font:11px var(--mono)}.access-gate{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:12px;padding:24px;color:#344057;background:#f5f7fb;text-align:center}.access-gate svg{color:var(--accent)}.access-gate h1{margin:0;font-size:22px}.access-gate p{max-width:520px;margin:0;color:var(--muted);line-height:1.6}.access-gate code{padding:8px 12px;color:#273247;background:#fff;border:1px solid var(--border);border-radius:6px}.toast{position:fixed;right:22px;bottom:22px;z-index:120;width:min(390px,calc(100vw - 44px));display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:13px 14px;background:#fff;border:1px solid var(--border-strong);border-left:3px solid var(--accent);border-radius:8px;box-shadow:0 15px 40px #161f302e;animation:toast-in .2s ease-out}.toast>div{align-items:flex-start;gap:9px}.toast span{display:grid;gap:3px}.toast strong{font-size:12px}.toast small{color:var(--muted);font-size:10px;line-height:1.4;word-break:break-word}.toast button{border:0;background:transparent;color:var(--muted);cursor:pointer}.toast--success{border-left-color:var(--success)}.toast--success>div>svg{color:var(--success)}.toast--error{border-left-color:var(--danger)}.toast--error>div>svg{color:var(--danger)}.toast--warning{border-left-color:var(--warning)}.toast--warning>div>svg{color:var(--warning)}.spin{animation:spin 1s linear infinite}.history-toolbar{display:grid;grid-template-columns:minmax(220px,1.7fr) repeat(3,minmax(130px,1fr));gap:9px;margin-bottom:14px}.history-toolbar input,.history-toolbar select{width:100%;min-width:0;padding:10px 11px;border:1px solid var(--border-strong);border-radius:7px;background:#fff;color:var(--text);font:inherit}.history-error{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:10px 12px;color:var(--danger);background:#fff5f3;border:1px solid #f2c9c2;border-radius:7px;font-size:12px}.history-layout{display:grid;grid-template-columns:minmax(300px,.8fr) minmax(0,1.4fr);flex:1 1 auto;min-height:0;height:100%;border:1px solid var(--border);border-radius:9px;overflow:hidden;background:#fff}.history-list-panel{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden;border-right:1px solid var(--border);background:#fbfcfe}.history-list-head{display:flex;justify-content:space-between;padding:13px 15px;color:var(--muted);border-bottom:1px solid var(--border);font-size:11px}.history-list{flex:1 1 auto;min-height:0;height:0;overflow-y:auto;overscroll-behavior:contain}.history-session-row{display:block;width:100%;padding:13px 15px;text-align:left;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer}.history-session-row:hover,.history-session-row--selected{background:#eef5ff}.history-session-top{display:flex;justify-content:space-between;gap:9px}.history-session-top strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.history-session-top time{flex:none;color:var(--subtle);font-size:10px}.history-session-row p{display:-webkit-box;margin:7px 0;overflow:hidden;color:var(--muted);font-size:11px;line-height:1.45;-webkit-box-orient:vertical;-webkit-line-clamp:2}.history-session-meta{display:flex;flex-wrap:wrap;gap:7px;color:var(--subtle);font-size:10px}.history-session-meta span:first-child{color:var(--accent)}.history-pagination{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-top:1px solid var(--border)}.history-pagination span{color:var(--muted);font-size:11px}.history-detail-panel{min-width:0;min-height:0;overflow-y:auto;overscroll-behavior:contain;background:#fff}.history-detail-head{display:flex;justify-content:space-between;gap:12px;padding:20px 22px 15px;border-bottom:1px solid var(--border)}.history-detail-head h2{margin:0 0 6px;font-size:17px}.history-detail-head p{margin:0;color:var(--muted);font:10px/1.4 var(--mono);word-break:break-word}.history-detail-head>span{flex:none;height:fit-content;padding:4px 7px;color:var(--accent);background:#edf4ff;border-radius:4px;font-size:10px}.history-truncated{display:flex;align-items:center;gap:7px;margin:14px 22px 0;padding:9px 10px;color:#8b631c;background:#fff8e7;border:1px solid #f0dfae;border-radius:6px;font-size:11px}.message-stream{display:grid;gap:14px;padding:20px 22px 28px}.chat-message{max-width:88%}.chat-message--user{justify-self:end}.chat-message--assistant{justify-self:start}.chat-message-label{display:flex;justify-content:space-between;gap:12px;margin-bottom:5px;color:var(--muted);font-size:10px}.chat-message--user .chat-message-label{color:var(--accent)}.chat-message-label time{color:var(--subtle);font:9px var(--mono)}.chat-message-body{padding:11px 13px;white-space:pre-wrap;word-break:break-word;color:var(--text);background:#f5f7fb;border:1px solid var(--border);border-radius:8px;font-size:12px;line-height:1.6}.chat-message--user .chat-message-body{background:#edf4ff;border-color:#d5e4fb}.chat-message-body code{padding:1px 4px;background:rgba(45,57,79,.08);border-radius:3px;font:11px var(--mono)}.chat-message-body pre{margin:8px 0 0;padding:10px;overflow:auto;background:#1f2937;color:#f7f9fc;border-radius:5px;white-space:pre}.chat-message-body pre code{padding:0;background:transparent;color:inherit}@keyframes spin{to{transform:rotate(360deg)}}@keyframes modal-in{0%{opacity:0;transform:translateY(8px) scale(.99)}}@keyframes toast-in{0%{opacity:0;transform:translateY(8px)}}@media (max-width: 1120px){.app-shell{grid-template-columns:168px minmax(0,1fr)}.overview-lower-grid{grid-template-columns:1fr}.execution-panel{position:static}.full-backup-row{grid-template-columns:170px 1fr auto}.backup-source{grid-column:1 / 3;grid-row:2}.backup-row-actions{grid-column:3;grid-row:1 / 3}}@media (max-width: 820px){.app-shell{display:block}.app-header{height:62px;padding:0 14px}.brand-subtitle,.service-state{display:none}.sidebar{position:sticky;top:62px;z-index:25;width:100%;height:auto;display:block;padding:7px 10px;border-right:0;border-bottom:1px solid var(--border)}.navigation{display:grid;grid-template-columns:repeat(4,1fr)}.nav-item{justify-content:center;padding:8px}.nav-item--active{box-shadow:inset 0 -2px 0 var(--accent)}.sidebar-foot{display:none}.storage-bar{position:static;grid-template-columns:1fr;padding:14px}.view-content{padding:18px 14px 32px}.summary-strip{grid-template-columns:1fr 1fr}.summary-item{border-bottom:1px solid var(--border)}.summary-item:nth-child(2){border-right:0}.summary-item:nth-child(3),.summary-item:nth-child(4){border-bottom:0}.summary-item:nth-child(3){padding-left:0}.distribution-grid{grid-template-columns:1fr}.distribution-divider{height:1px}.page-intro{align-items:flex-start;flex-direction:column}.full-backup-row{grid-template-columns:1fr auto;gap:12px}.backup-facts,.backup-source{grid-column:1 / -1}.backup-row-actions{grid-column:2;grid-row:1}.console-row{grid-template-columns:115px 62px minmax(180px,1fr)}.console-detail{grid-column:3}.history-toolbar{grid-template-columns:1fr 1fr}.history-toolbar input{grid-column:1 / -1}.history-view{height:calc(100dvh - 270px);min-height:520px}.history-layout{grid-template-columns:1fr;min-height:0}.history-list-panel{min-height:0;max-height:40%;border-right:0;border-bottom:1px solid var(--border)}.history-detail-panel{min-height:0}}@media (max-width: 560px){.header-actions .button span{display:none}.brand-name{font-size:13px}.status-panel,.data-section,.recent-backups{padding:16px}.section-title-row{align-items:flex-start;flex-direction:column}.summary-strip{grid-template-columns:1fr}.summary-item{padding-left:0;border-right:0;border-bottom:1px solid var(--border)!important}.summary-item:last-child{border-bottom:0!important}.form-grid,.segmented{grid-template-columns:1fr}.backup-size{display:none}.prune-control{width:100%;justify-content:space-between}.operation-scope>div{grid-template-columns:1fr;gap:4px}.modal{max-height:calc(100vh - 20px)}.modal-backdrop{padding:10px}.history-toolbar{grid-template-columns:1fr}.history-toolbar input{grid-column:auto}.history-view{height:calc(100dvh - 300px);min-height:500px}.history-detail-head,.message-stream{padding-left:15px;padding-right:15px}.chat-message{max-width:96%}}@media (prefers-reduced-motion: reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} diff --git a/web/dist/assets/index-BEjqXoxc.js b/web/dist/assets/index-BEjqXoxc.js new file mode 100644 index 0000000..42cc405 --- /dev/null +++ b/web/dist/assets/index-BEjqXoxc.js @@ -0,0 +1,113 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,o)=>(o=n==null?{}:e(i(n)),s(r||!n||!n.__esModule||!a.call(n,`default`)?t(o,`default`,{value:n,enumerable:!0}):o,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),u=o(((e,t)=>{t.exports=l()})),d=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function D(e,t){return E(e.type,t,e.props)}function O(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ee(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var te=/\/+/g;function k(e,t){return typeof e==`object`&&e&&e.key!=null?ee(``+e.key):t.toString(36)}function A(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ne(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ne(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+k(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(te,`$&/`)+`/`),ne(o,r,i,``,function(e){return e})):o!=null&&(O(o)&&(o=D(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(te,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=d()})),p=c(f(),1),m=u(),h=p.createContext(void 0),g=e=>{let t=p.useContext(h);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},_=({client:e,children:t})=>(p.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,m.jsx)(h.Provider,{value:e,children:t})),v={setTimeout:(e,t)=>setTimeout(e,t),clearTimeout:e=>clearTimeout(e),setInterval:(e,t)=>setInterval(e,t),clearInterval:e=>clearInterval(e)},y=new class{#e=v;setTimeoutProvider(e){this.#e=e}setTimeout(e,t){return this.#e.setTimeout(e,t)}clearTimeout(e){this.#e.clearTimeout(e)}setInterval(e,t){return this.#e.setInterval(e,t)}clearInterval(e){this.#e.clearInterval(e)}};function b(e){setTimeout(e,0)}var x=typeof window>`u`||`Deno`in globalThis;function S(){}function C(e,t){return typeof e==`function`?e(t):e}function w(e){return typeof e==`number`&&e>=0&&e!==1/0}function T(e,t){return Math.max(e+(t||0)-Date.now(),0)}function E(e,t){return typeof e==`function`?e(t):e}function D(e,t){return typeof e==`function`?e(t):e}function O(e,t){let{type:n=`all`,exact:r,fetchStatus:i,predicate:a,queryKey:o,stale:s}=e;if(o){if(r){if(t.queryHash!==te(o,t.options))return!1}else if(!A(t.queryKey,o))return!1}if(n!==`all`){let e=t.isActive();if(n===`active`&&!e||n===`inactive`&&e)return!1}return!(typeof s==`boolean`&&t.isStale()!==s||i&&i!==t.state.fetchStatus||a&&!a(t))}function ee(e,t){let{exact:n,status:r,predicate:i,mutationKey:a}=e;if(a){if(!t.options.mutationKey)return!1;if(n){if(k(t.options.mutationKey)!==k(a))return!1}else if(!A(t.options.mutationKey,a))return!1}return!(r&&t.state.status!==r||i&&!i(t))}function te(e,t){return(t?.queryKeyHashFn||k)(e)}function k(e){return JSON.stringify(e,(e,t)=>re(t)?Object.keys(t).sort().reduce((e,n)=>(e[n]=t[n],e),{}):t)}function A(e,t){if(e===t)return!0;if(typeof e!=typeof t)return!1;if(e&&t&&typeof e==`object`&&typeof t==`object`){if(Array.isArray(e)&&Array.isArray(t)){for(let n=0;n500)return t;let r=N(e)&&N(t);if(!r&&!(re(e)&&re(t)))return t;let i=(r?e:Object.keys(e)).length,a=r?t:Object.keys(t),o=a.length,s=r?Array(o):{},c=0;for(let l=0;l{y.setTimeout(t,e)})}function P(e,t,n){return typeof n.structuralSharing==`function`?n.structuralSharing(e,t):n.structuralSharing===!1?t:j(e,t)}function F(e,t,n=0){let r=[...e,t];return n&&r.length>n?r.slice(1):r}function oe(e,t,n=0){let r=[t,...e];return n&&r.length>n?r.slice(0,-1):r}var se=Symbol();function ce(e,t){return!e.queryFn&&t?.initialPromise?()=>t.initialPromise:!e.queryFn||e.queryFn===se?()=>Promise.reject(Error(`Missing queryFn: '${e.queryHash}'`)):e.queryFn}function le(e,t){return typeof e==`function`?e(...t):!!e}function ue(e,t,n){let r=!1,i;return Object.defineProperty(e,"signal",{enumerable:!0,get:()=>(i??=t(),r?i:(r=!0,i.aborted?n():i.addEventListener(`abort`,n,{once:!0}),i))}),e}var de=(()=>{let e=()=>x;return{isServer(){return e()},setIsServer(t){e=t}}})(),fe=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(e){return this.listeners.add(e),this.onSubscribe(),()=>{this.listeners.delete(e),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},pe=new class extends fe{#e;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e();return window.addEventListener(`visibilitychange`,t,!1),()=>{window.removeEventListener(`visibilitychange`,t)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(e=>{typeof e==`boolean`?this.setFocused(e):this.onFocus()})}setFocused(e){this.#e!==e&&(this.#e=e,this.onFocus())}onFocus(){let e=this.isFocused();this.listeners.forEach(t=>{t(e)})}isFocused(){return typeof this.#e==`boolean`?this.#e:globalThis.document?.visibilityState!==`hidden`}},me=b;function he(){let e=[],t=0,n=e=>{e()},r=e=>{e()},i=me,a=r=>{t?e.push(r):i(()=>{n(r)})},o=()=>{let t=e;e=[],t.length&&i(()=>{r(()=>{t.forEach(e=>{n(e)})})})};return{batch:e=>{let n;t++;try{n=e()}finally{t--,t||o()}return n},batchCalls:e=>(...t)=>{a(()=>{e(...t)})},schedule:a,setNotifyFunction:e=>{n=e},setBatchNotifyFunction:e=>{r=e},setScheduler:e=>{i=e}}}var ge=he(),_e=new class extends fe{#e=!0;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e(!0),n=()=>e(!1);return window.addEventListener(`online`,t,!1),window.addEventListener(`offline`,n,!1),()=>{window.removeEventListener(`online`,t),window.removeEventListener(`offline`,n)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(this.setOnline.bind(this))}setOnline(e){this.#e!==e&&(this.#e=e,this.listeners.forEach(t=>{t(e)}))}isOnline(){return this.#e}};function ve(e){return Math.min(1e3*2**e,3e4)}function ye(e){return(e??`online`)!==`online`||_e.isOnline()}var be=class extends Error{constructor(e){super(`CancelledError`),this.revert=e?.revert,this.silent=e?.silent}};function xe(e){let t=!1,n=0,r,i=`pending`,a,o,s=new Promise((e,t)=>{a=e,o=t});s.catch(S);let c=()=>i!==`pending`,l=t=>{if(!c()){let n=new be(t);h(n),e.onCancel?.(n)}},u=()=>{t=!0},d=()=>{t=!1},f=()=>pe.isFocused()&&(e.networkMode===`always`||_e.isOnline())&&e.canRun(),p=()=>ye(e.networkMode)&&e.canRun(),m=e=>{c()||(r?.(),i=`resolved`,a(e))},h=e=>{c()||(r?.(),i=`rejected`,o(e))},g=()=>new Promise(t=>{r=e=>{(c()||f())&&t(e)},e.onPause?.()}).then(()=>{r=void 0,c()||e.onContinue?.()}),_=()=>{if(c())return;let r,i=n===0?e.initialPromise:void 0;try{r=i??e.fn()}catch(e){r=Promise.reject(e)}Promise.resolve(r).then(m).catch(r=>{if(c())return;let i=e.retry??(de.isServer()?0:3),a=e.retryDelay??ve,o=typeof a==`function`?a(n,r):a,s=i===!0||typeof i==`number`&&nf()?void 0:g()).then(()=>{t?h(r):_()})})};return{promise:s,status:()=>i,cancel:l,continue:()=>(r?.(),s),cancelRetry:u,continueRetry:d,canStart:p,start:()=>(p()?_():g().then(_),s)}}var Se=class{#e;destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),w(this.gcTime)&&(this.#e=y.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(e){this.gcTime=Math.max(this.gcTime||0,e??(de.isServer()?1/0:3e5))}clearGcTimeout(){this.#e!==void 0&&(y.clearTimeout(this.#e),this.#e=void 0)}};function Ce(e){return{onFetch:(t,n)=>{let r=t.options,i=t.fetchOptions?.meta?.fetchMore?.direction,a=t.state.data?.pages||[],o=t.state.data?.pageParams||[],s={pages:[],pageParams:[]},c=0,l=async()=>{let n=!1,l=e=>{ue(e,()=>t.signal,()=>n=!0)},u=ce(t.options,t.fetchOptions),d=async(e,r,i)=>{if(n)return Promise.reject(t.signal.reason);if(r==null&&e.pages.length)return Promise.resolve(e);let a=(()=>{let e={client:t.client,queryKey:t.queryKey,pageParam:r,direction:i?`backward`:`forward`,meta:t.options.meta};return l(e),e})(),o=await u(a),{maxPages:s}=t.options,c=i?oe:F;return{pages:c(e.pages,o,s),pageParams:c(e.pageParams,r,s)}};if(i&&a.length){let e=i===`backward`,t=e?Te:we,n={pages:a,pageParams:o};s=await d(n,t(r,n),e)}else{let t=e??a.length;do{let e=c===0?o[0]??r.initialPageParam:we(r,s);if(c>0&&e==null)break;s=await d(s,e),c++}while(ct.options.persister?.(l,{client:t.client,queryKey:t.queryKey,meta:t.options.meta,signal:t.signal},n):l}}}function we(e,{pages:t,pageParams:n}){let r=t.length-1;return t.length>0?e.getNextPageParam(t[r],t,n[r],n):void 0}function Te(e,{pages:t,pageParams:n}){return t.length>0?e.getPreviousPageParam?.(t[0],t,n[0],n):void 0}var Ee=class extends Se{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e){super(),this.#s=!1,this.#o=e.defaultOptions,this.setOptions(e.options),this.observers=[],this.#i=e.client,this.#r=this.#i.getQueryCache(),this.queryKey=e.queryKey,this.queryHash=e.queryHash,this.#t=ke(this.options),this.state=e.state??this.#t,this.scheduleGc()}get meta(){return this.options.meta}get queryType(){return this.#e}get promise(){return this.#a?.promise}setOptions(e){if(this.options={...this.#o,...e},e?._type&&(this.#e=e._type),this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){let e=ke(this.options);e.data!==void 0&&(this.setState(Oe(e.data,e.dataUpdatedAt)),this.#t=e)}}optionalRemove(){!this.observers.length&&this.state.fetchStatus===`idle`&&this.#r.remove(this)}setData(e,t){let n=P(this.state.data,e,this.options);return this.#l({data:n,type:`success`,dataUpdatedAt:t?.updatedAt,manual:t?.manual}),n}setState(e){this.#l({type:`setState`,state:e})}cancel(e){let t=this.#a?.promise;return this.#a?.cancel(e),t?t.then(S).catch(S):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}get resetState(){return this.#t}reset(){this.destroy(),this.setState(this.resetState)}isActive(){return this.observers.some(e=>D(e.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===se||!this.isFetched()}isFetched(){return this.state.dataUpdateCount+this.state.errorUpdateCount>0}isStatic(){return this.getObserversCount()>0&&this.observers.some(e=>E(e.options.staleTime,this)===`static`)}isStale(){return this.getObserversCount()>0?this.observers.some(e=>e.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(e=0){return this.state.data===void 0?!0:e===`static`?!1:this.state.isInvalidated?!0:!T(this.state.dataUpdatedAt,e)}onFocus(){this.observers.find(e=>e.shouldFetchOnWindowFocus())?.refetch({cancelRefetch:!1}),this.#a?.continue()}onOnline(){this.observers.find(e=>e.shouldFetchOnReconnect())?.refetch({cancelRefetch:!1}),this.#a?.continue()}addObserver(e){this.observers.includes(e)||(this.observers.push(e),this.clearGcTimeout(),this.#r.notify({type:`observerAdded`,query:this,observer:e}))}removeObserver(e){let t=this.observers.indexOf(e);t!==-1&&(this.observers.splice(t,1),this.observers.length||(this.#a&&(this.#s||this.#c()?this.#a.cancel({revert:!0}):this.#a.cancelRetry()),this.scheduleGc()),this.#r.notify({type:`observerRemoved`,query:this,observer:e}))}getObserversCount(){return this.observers.length}#c(){return this.state.fetchStatus===`paused`&&this.state.status===`pending`}invalidate(){this.state.isInvalidated||this.#l({type:`invalidate`})}async fetch(e,t){if(this.state.fetchStatus!==`idle`&&this.#a?.status()!==`rejected`){if(this.state.data!==void 0&&t?.cancelRefetch)this.cancel({silent:!0});else if(this.#a)return this.#a.continueRetry(),this.#a.promise}if(e&&this.setOptions(e),!this.options.queryFn){let e=this.observers.find(e=>e.options.queryFn);e&&this.setOptions(e.options)}let n=new AbortController,r=e=>{Object.defineProperty(e,"signal",{enumerable:!0,get:()=>(this.#s=!0,n.signal)})},i=()=>{let e=ce(this.options,t),n=(()=>{let e={client:this.#i,queryKey:this.queryKey,meta:this.meta};return r(e),e})();return this.#s=!1,this.options.persister?this.options.persister(e,n,this):e(n)},a=(()=>{let e={fetchOptions:t,options:this.options,queryKey:this.queryKey,client:this.#i,state:this.state,fetchFn:i};return r(e),e})();(this.#e===`infinite`?Ce(this.options.pages):this.options.behavior)?.onFetch(a,this),this.#n=this.state,(this.state.fetchStatus===`idle`||this.state.fetchMeta!==a.fetchOptions?.meta)&&this.#l({type:`fetch`,meta:a.fetchOptions?.meta});let o=this.#a=xe({initialPromise:t?.initialPromise,fn:a.fetchFn,onCancel:e=>{e instanceof be&&e.revert&&this.setState({...this.#n,fetchStatus:`idle`}),n.abort()},onFail:(e,t)=>{this.#l({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#l({type:`pause`})},onContinue:()=>{this.#l({type:`continue`})},retry:a.options.retry,retryDelay:a.options.retryDelay,networkMode:a.options.networkMode,canRun:()=>!0});try{let e=await o.start();if(e===void 0)throw Error(`${this.queryHash} data is undefined`);return this.setData(e),this.#r.config.onSuccess?.(e,this),this.#r.config.onSettled?.(e,this.state.error,this),e}catch(e){if(e instanceof be){if(e.silent)return this.#a.promise;if(e.revert){if(this.state.data===void 0)throw e;return this.state.data}}throw this.#l({type:`error`,error:e}),this.#r.config.onError?.(e,this),this.#r.config.onSettled?.(this.state.data,e,this),e}finally{this.#a===o&&(this.#a=void 0),this.scheduleGc()}}#l(e){let t=t=>{switch(e.type){case`failed`:return{...t,fetchFailureCount:e.failureCount,fetchFailureReason:e.error};case`pause`:return{...t,fetchStatus:`paused`};case`continue`:return{...t,fetchStatus:`fetching`};case`fetch`:return{...t,...De(t.data,this.options),fetchMeta:e.meta??null};case`success`:let n={...t,...Oe(e.data,e.dataUpdatedAt),dataUpdateCount:t.dataUpdateCount+1,...!e.manual&&{fetchStatus:`idle`,fetchFailureCount:0,fetchFailureReason:null}};return this.#n=e.manual?n:void 0,n;case`error`:let r=e.error;return{...t,error:r,errorUpdateCount:t.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:t.fetchFailureCount+1,fetchFailureReason:r,fetchStatus:`idle`,status:`error`,isInvalidated:!0};case`invalidate`:return{...t,isInvalidated:!0};case`setState`:return{...t,...e.state}}};this.state=t(this.state),ge.batch(()=>{this.observers.slice().forEach(e=>{e.onQueryUpdate()}),this.#r.notify({query:this,type:`updated`,action:e})})}};function De(e,t){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:ye(t.networkMode)?`fetching`:`paused`,...e===void 0&&{error:null,status:`pending`}}}function Oe(e,t){return{data:e,dataUpdatedAt:t??Date.now(),error:null,isInvalidated:!1,status:`success`}}function ke(e){let t=typeof e.initialData==`function`?e.initialData():e.initialData,n=t!==void 0,r=n?typeof e.initialDataUpdatedAt==`function`?e.initialDataUpdatedAt():e.initialDataUpdatedAt:0;return{data:t,dataUpdateCount:0,dataUpdatedAt:n?r??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:n?`success`:`pending`,fetchStatus:`idle`}}var Ae=class extends fe{#e;#t=void 0;#n=void 0;#r=void 0;#i;#a;#o;#s;#c;#l;#u;#d;#f;#p=new Set;constructor(e,t){super(),this.options=t,this.#e=e,this.#o=null,this.bindMethods(),this.setOptions(t)}bindMethods(){this.refetch=this.refetch.bind(this)}onSubscribe(){this.listeners.size===1&&(this.#t.addObserver(this),Me(this.#t,this.options)?this.#m():this.updateResult(),this.#v())}onUnsubscribe(){this.hasListeners()||this.destroy()}shouldFetchOnReconnect(){return Ne(this.#t,this.options,this.options.refetchOnReconnect)}shouldFetchOnWindowFocus(){return Ne(this.#t,this.options,this.options.refetchOnWindowFocus)}destroy(){this.listeners=new Set,this.#y(),this.#b(),this.#t.removeObserver(this)}setOptions(e){let t=this.options,n=this.#t;if(this.options=this.#e.defaultQueryOptions(e),this.options.enabled!==void 0&&typeof this.options.enabled!=`boolean`&&typeof this.options.enabled!=`function`&&typeof D(this.options.enabled,this.#t)!=`boolean`)throw Error(`Expected enabled to be a boolean or a callback that returns a boolean`);this.#x(),this.#t.setOptions(this.options),t._defaulted&&!M(this.options,t)&&this.#e.getQueryCache().notify({type:`observerOptionsUpdated`,query:this.#t,observer:this});let r=this.hasListeners();r&&Pe(this.#t,n,this.options,t)&&this.#m(),this.updateResult(),r&&(this.#t!==n||D(this.options.enabled,this.#t)!==D(t.enabled,this.#t)||E(this.options.staleTime,this.#t)!==E(t.staleTime,this.#t))&&this.#h();let i=this.#g();r&&(this.#t!==n||D(this.options.enabled,this.#t)!==D(t.enabled,this.#t)||i!==this.#f)&&this.#_(i)}getOptimisticResult(e){let t=this.#e.getQueryCache().build(this.#e,e),n=this.createResult(t,e);return Ie(this,n)&&(this.#r=n,this.#a=this.options,this.#i=this.#t.state),n}getCurrentResult(){return this.#r}trackResult(e,t){return new Proxy(e,{get:(e,n)=>(this.trackProp(n),t?.(n),Reflect.get(e,n))})}trackProp(e){this.#p.add(e)}getCurrentQuery(){return this.#t}refetch({...e}={}){return this.fetch({...e})}fetchOptimistic(e){let t=this.#e.defaultQueryOptions(e),n=this.#e.getQueryCache().build(this.#e,t),r=()=>{},i,a=new Promise(e=>{i=e,r=this.#e.getQueryCache().subscribe(i=>{i.type===`updated`&&i.query.queryHash===n.queryHash&&n.state.data!==void 0&&(r(),e(this.createResult(n,t)))})});return Promise.race([n.fetch().then(()=>{let e=this.createResult(n,t);return i?.(e),e}).finally(()=>{r()}),a])}fetch(e){return this.#m({...e,cancelRefetch:e.cancelRefetch??!0}).then(()=>(this.updateResult(),this.#r))}#m(e){this.#x();let t=this.#t.fetch(this.options,e);return e?.throwOnError||(t=t.catch(S)),t}#h(){this.#y();let e=E(this.options.staleTime,this.#t);if(de.isServer()||this.#r.isStale||!w(e))return;let t=T(this.#r.dataUpdatedAt,e)+1;this.#u=y.setTimeout(()=>{this.#r.isStale||this.updateResult()},t)}#g(){return(typeof this.options.refetchInterval==`function`?this.options.refetchInterval(this.#t):this.options.refetchInterval)??!1}#_(e){this.#b(),this.#f=e,!(de.isServer()||D(this.options.enabled,this.#t)===!1||!w(this.#f)||this.#f===0)&&(this.#d=y.setInterval(()=>{(this.options.refetchIntervalInBackground||pe.isFocused())&&this.#m()},this.#f))}#v(){this.#h(),this.#_(this.#g())}#y(){this.#u!==void 0&&(y.clearTimeout(this.#u),this.#u=void 0)}#b(){this.#d!==void 0&&(y.clearInterval(this.#d),this.#d=void 0)}createResult(e,t){let n=this.#t,r=this.options,i=this.#r,a=this.#i,o=this.#a,s=e===n?this.#n:e.state,{state:c}=e,l={...c},u=!1,d;if(t._optimisticResults){let i=this.hasListeners(),a=!i&&Me(e,t),o=i&&Pe(e,n,t,r);(a||o)&&(l={...l,...De(c.data,e.options)}),t._optimisticResults===`isRestoring`&&(l.fetchStatus=`idle`)}let{error:f,errorUpdatedAt:p,status:m}=l;d=l.data;let h=!1;if(t.placeholderData!==void 0&&d===void 0&&m===`pending`){let e;i?.isPlaceholderData&&t.placeholderData===o?.placeholderData?(e=i.data,h=!0):e=typeof t.placeholderData==`function`?t.placeholderData(this.#l?.state.data,this.#l):t.placeholderData,e!==void 0&&(m=`success`,d=P(i?.data,e,t),u=!0)}if(t.select&&d!==void 0&&!h){if(i&&d===a?.data&&t.select===this.#s)d=this.#c;else try{this.#s=t.select,d=t.select(d),d=P(i?.data,d,t),this.#c=d,this.#o=null}catch(e){this.#o=e}}else d===void 0&&(this.#o=null);this.#o&&(f=this.#o,d=this.#c,p=Date.now(),m=`error`,u=!1);let g=l.fetchStatus===`fetching`,_=m===`pending`,v=m===`error`,y=_&&g,b=d!==void 0;return{status:m,fetchStatus:l.fetchStatus,isPending:_,isSuccess:m===`success`,isError:v,isInitialLoading:y,isLoading:y,data:d,dataUpdatedAt:l.dataUpdatedAt,error:f,errorUpdatedAt:p,failureCount:l.fetchFailureCount,failureReason:l.fetchFailureReason,errorUpdateCount:l.errorUpdateCount,isFetched:e.isFetched(),isFetchedAfterMount:l.dataUpdateCount>s.dataUpdateCount||l.errorUpdateCount>s.errorUpdateCount,isFetching:g,isRefetching:g&&!_,isLoadingError:v&&!b,isPaused:l.fetchStatus===`paused`,isPlaceholderData:u,isRefetchError:v&&b,isStale:Fe(e,t),refetch:this.refetch,isEnabled:D(t.enabled,e)!==!1}}updateResult(){let e=this.#r,t=this.createResult(this.#t,this.options);this.#i=this.#t.state,this.#a=this.options,this.#i.data!==void 0&&(this.#l=this.#t),!M(t,e)&&(this.#r=t,this.#S({listeners:(()=>{if(!e)return!0;let{notifyOnChangeProps:t}=this.options,n=typeof t==`function`?t():t;if(n===`all`||!n&&!this.#p.size)return!0;let r=new Set(n??this.#p);return this.options.throwOnError&&r.add(`error`),Object.keys(this.#r).some(t=>{let n=t;return this.#r[n]!==e[n]&&r.has(n)})})()}))}#x(){let e=this.#e.getQueryCache().build(this.#e,this.options);if(e===this.#t)return;let t=this.#t;this.#t=e,this.#n=e.state,this.hasListeners()&&(t?.removeObserver(this),e.addObserver(this))}onQueryUpdate(){this.updateResult(),this.hasListeners()&&this.#v()}#S(e){ge.batch(()=>{e.listeners&&this.listeners.forEach(e=>{e(this.#r)}),this.#e.getQueryCache().notify({query:this.#t,type:`observerResultsUpdated`})})}};function je(e,t){return D(t.enabled,e)!==!1&&e.state.data===void 0&&(e.state.status!==`error`||D(t.retryOnMount,e)!==!1)}function Me(e,t){return je(e,t)||e.state.data!==void 0&&Ne(e,t,t.refetchOnMount)}function Ne(e,t,n){if(D(t.enabled,e)!==!1&&E(t.staleTime,e)!==`static`){let r=typeof n==`function`?n(e):n;return r===`always`||r!==!1&&Fe(e,t)}return!1}function Pe(e,t,n,r){return(e!==t||D(r.enabled,e)===!1)&&(!n.suspense||e.state.status!==`error`)&&Fe(e,n)}function Fe(e,t){return D(t.enabled,e)!==!1&&e.isStaleByTime(E(t.staleTime,e))}function Ie(e,t){return!M(e.getCurrentResult(),t)}var Le=class extends Se{#e;#t;#n;#r;constructor(e){super(),this.#e=e.client,this.mutationId=e.mutationId,this.#n=e.mutationCache,this.#t=[],this.state=e.state||Re(),this.setOptions(e.options),this.scheduleGc()}setOptions(e){this.options=e,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(e){this.#t.includes(e)||(this.#t.push(e),this.clearGcTimeout(),this.#n.notify({type:`observerAdded`,mutation:this,observer:e}))}removeObserver(e){this.#t=this.#t.filter(t=>t!==e),this.scheduleGc(),this.#n.notify({type:`observerRemoved`,mutation:this,observer:e})}optionalRemove(){this.#t.length||(this.state.status===`pending`?this.scheduleGc():this.#n.remove(this))}continue(){return this.#r?.continue()??(this.state.status===`pending`?this.execute(this.state.variables):Promise.resolve())}async execute(e){let t=()=>{this.#i({type:`continue`})},n={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey},r=this.#r=xe({fn:()=>this.options.mutationFn?this.options.mutationFn(e,n):Promise.reject(Error(`No mutationFn found`)),onFail:(e,t)=>{this.#i({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#i({type:`pause`})},onContinue:t,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>this.#n.canRun(this)}),i=this.state.status===`pending`,a=!r.canStart();try{if(i)t();else{this.#i({type:`pending`,variables:e,isPaused:a}),this.#n.config.onMutate&&await this.#n.config.onMutate(e,this,n);let t=await this.options.onMutate?.(e,n);t!==this.state.context&&this.#i({type:`pending`,context:t,variables:e,isPaused:a})}let o=await r.start();return await this.#n.config.onSuccess?.(o,e,this.state.context,this,n),await this.options.onSuccess?.(o,e,this.state.context,n),await this.#n.config.onSettled?.(o,null,this.state.variables,this.state.context,this,n),await this.options.onSettled?.(o,null,e,this.state.context,n),this.#i({type:`success`,data:o}),o}catch(t){try{await this.#n.config.onError?.(t,e,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onError?.(t,e,this.state.context,n)}catch(e){Promise.reject(e)}try{await this.#n.config.onSettled?.(void 0,t,this.state.variables,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onSettled?.(void 0,t,e,this.state.context,n)}catch(e){Promise.reject(e)}throw this.#i({type:`error`,error:t}),t}finally{this.#r===r&&(this.#r=void 0),this.#n.runNext(this)}}#i(e){let t=t=>{switch(e.type){case`failed`:return{...t,failureCount:e.failureCount,failureReason:e.error};case`pause`:return{...t,isPaused:!0};case`continue`:return{...t,isPaused:!1};case`pending`:return{...t,context:e.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:e.isPaused,status:`pending`,variables:e.variables,submittedAt:Date.now()};case`success`:return{...t,data:e.data,failureCount:0,failureReason:null,error:null,status:`success`,isPaused:!1};case`error`:return{...t,data:void 0,error:e.error,failureCount:t.failureCount+1,failureReason:e.error,isPaused:!1,status:`error`}}};this.state=t(this.state),ge.batch(()=>{this.#t.forEach(t=>{t.onMutationUpdate(e)}),this.#n.notify({mutation:this,type:`updated`,action:e})})}};function Re(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:`idle`,variables:void 0,submittedAt:0}}var ze=class extends fe{#e;#t;#n;constructor(e={}){super(),this.config=e,this.#e=new Set,this.#t=new Map,this.#n=0}build(e,t,n){let r=new Le({client:e,mutationCache:this,mutationId:++this.#n,options:e.defaultMutationOptions(t),state:n});return this.add(r),r}add(e){this.#e.add(e);let t=Be(e);if(typeof t==`string`){let n=this.#t.get(t);n?n.push(e):this.#t.set(t,[e])}this.notify({type:`added`,mutation:e})}remove(e){if(this.#e.delete(e)){let t=Be(e);if(typeof t==`string`){let n=this.#t.get(t);if(n){if(n.length>1){let t=n.indexOf(e);t!==-1&&n.splice(t,1)}else n[0]===e&&this.#t.delete(t)}}}this.notify({type:`removed`,mutation:e})}canRun(e){let t=Be(e);if(typeof t==`string`){let n=this.#t.get(t)?.find(e=>e.state.status===`pending`);return!n||n===e}return!0}runNext(e){let t=Be(e);return typeof t==`string`?(this.#t.get(t)?.find(t=>t!==e&&t.state.isPaused))?.continue()??Promise.resolve():Promise.resolve()}clear(){ge.batch(()=>{this.#e.forEach(e=>{this.notify({type:`removed`,mutation:e})}),this.#e.clear(),this.#t.clear()})}getAll(){return Array.from(this.#e)}find(e){let t={exact:!0,...e};return this.getAll().find(e=>ee(t,e))}findAll(e={}){return this.getAll().filter(t=>ee(e,t))}notify(e){ge.batch(()=>{this.listeners.forEach(t=>{t(e)})})}resumePausedMutations(){let e=this.getAll().filter(e=>e.state.isPaused);return ge.batch(()=>Promise.all(e.map(e=>e.continue().catch(S))))}};function Be(e){return e.options.scope?.id}var Ve=class extends fe{#e;#t=void 0;#n;#r;constructor(e,t){super(),this.#e=e,this.setOptions(t),this.bindMethods(),this.#i()}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){let t=this.options;this.options=this.#e.defaultMutationOptions(e),M(this.options,t)||this.#e.getMutationCache().notify({type:`observerOptionsUpdated`,mutation:this.#n,observer:this}),t?.mutationKey&&this.options.mutationKey&&k(t.mutationKey)!==k(this.options.mutationKey)?this.reset():this.#n?.state.status===`pending`&&this.#n.setOptions(this.options)}onSubscribe(){this.listeners.size===1&&this.#n&&(this.#n.addObserver(this),this.#i())}onUnsubscribe(){this.hasListeners()||this.#n?.removeObserver(this)}onMutationUpdate(e){this.#i(),this.#a(e)}getCurrentResult(){return this.#t}reset(){this.#n?.removeObserver(this),this.#n=void 0,this.#i(),this.#a()}mutate(e,t){return this.#r=t,this.#n?.removeObserver(this),this.#n=this.#e.getMutationCache().build(this.#e,this.options),this.#n.addObserver(this),this.#n.execute(e)}#i(){let e=this.#n?.state??Re();this.#t={...e,isPending:e.status===`pending`,isSuccess:e.status===`success`,isError:e.status===`error`,isIdle:e.status===`idle`,mutate:this.mutate,reset:this.reset}}#a(e){ge.batch(()=>{if(this.#r&&this.hasListeners()){let t=this.#t.variables,n=this.#t.context,r={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey};if(e?.type===`success`){try{this.#r.onSuccess?.(e.data,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(e.data,null,t,n,r)}catch(e){Promise.reject(e)}}else if(e?.type===`error`){try{this.#r.onError?.(e.error,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(void 0,e.error,t,n,r)}catch(e){Promise.reject(e)}}}this.listeners.forEach(e=>{e(this.#t)})})}},He=class extends fe{#e;constructor(e={}){super(),this.config=e,this.#e=new Map}build(e,t,n){let r=t.queryKey,i=t.queryHash??te(r,t),a=this.get(i);return a||(a=new Ee({client:e,queryKey:r,queryHash:i,options:e.defaultQueryOptions(t),state:n,defaultOptions:e.getQueryDefaults(r)}),this.add(a)),a}add(e){this.#e.has(e.queryHash)||(this.#e.set(e.queryHash,e),this.notify({type:`added`,query:e}))}remove(e){let t=this.#e.get(e.queryHash);t&&(e.destroy(),t===e&&this.#e.delete(e.queryHash),this.notify({type:`removed`,query:e}))}clear(){ge.batch(()=>{this.getAll().forEach(e=>{this.remove(e)})})}get(e){return this.#e.get(e)}getAll(){return[...this.#e.values()]}find(e){let t={exact:!0,...e};return this.getAll().find(e=>O(t,e))}findAll(e={}){let t=this.getAll();return Object.keys(e).length>0?t.filter(t=>O(e,t)):t}notify(e){ge.batch(()=>{this.listeners.forEach(t=>{t(e)})})}onFocus(){ge.batch(()=>{this.getAll().forEach(e=>{e.onFocus()})})}onOnline(){ge.batch(()=>{this.getAll().forEach(e=>{e.onOnline()})})}},Ue=class{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e={}){this.#e=e.queryCache||new He,this.#t=e.mutationCache||new ze,this.#n=e.defaultOptions||{},this.#r=new Map,this.#i=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=pe.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onFocus())}),this.#s=_e.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#s?.(),this.#s=void 0)}isFetching(e){return this.#e.findAll({...e,fetchStatus:`fetching`}).length}isMutating(e){return this.#t.findAll({...e,status:`pending`}).length}getQueryData(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state.data}ensureQueryData(e){let t=this.defaultQueryOptions(e),n=this.#e.build(this,t),r=n.state.data;return r===void 0?this.fetchQuery(e):(e.revalidateIfStale&&n.isStaleByTime(E(t.staleTime,n))&&this.prefetchQuery(t),Promise.resolve(r))}getQueriesData(e){return this.#e.findAll(e).map(({queryKey:e,state:t})=>[e,t.data])}setQueryData(e,t,n){let r=this.defaultQueryOptions({queryKey:e}),i=this.#e.get(r.queryHash)?.state.data,a=C(t,i);if(a!==void 0)return this.#e.build(this,r).setData(a,{...n,manual:!0})}setQueriesData(e,t,n){return ge.batch(()=>this.#e.findAll(e).map(({queryKey:e})=>[e,this.setQueryData(e,t,n)]))}getQueryState(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state}removeQueries(e){let t=this.#e;ge.batch(()=>{t.findAll(e).forEach(e=>{t.remove(e)})})}resetQueries(e,t){let n=this.#e;return ge.batch(()=>{let r=n.findAll(e),i=new Set(r);return r.forEach(e=>{e.reset()}),this.refetchQueries({type:`active`,predicate:e=>i.has(e)},t)})}cancelQueries(e,t={}){let n={revert:!0,...t},r=ge.batch(()=>this.#e.findAll(e).map(e=>e.cancel(n)));return Promise.all(r).then(S).catch(S)}invalidateQueries(e,t={}){return ge.batch(()=>(this.#e.findAll(e).forEach(e=>{e.invalidate()}),e?.refetchType===`none`?Promise.resolve():this.refetchQueries({...e,type:e?.refetchType??e?.type??`active`},t)))}refetchQueries(e,t={}){let n={...t,cancelRefetch:t.cancelRefetch??!0},r=ge.batch(()=>this.#e.findAll(e).filter(e=>!e.isDisabled()&&!e.isStatic()).map(e=>{let t=e.fetch(void 0,n);return n.throwOnError||(t=t.catch(S)),e.state.fetchStatus===`paused`?Promise.resolve():t}));return Promise.all(r).then(S)}async query(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t),r=n.isStaleByTime(E(t.staleTime,n))?await n.fetch(t):n.state.data,i=t.select;return i?i(r):r}fetchQuery(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t);return n.isStaleByTime(E(t.staleTime,n))?n.fetch(t):Promise.resolve(n.state.data)}prefetchQuery(e){return this.fetchQuery(e).then(S).catch(S)}infiniteQuery(e){return e._type=`infinite`,this.query(e)}fetchInfiniteQuery(e){return e._type=`infinite`,this.fetchQuery(e)}prefetchInfiniteQuery(e){return this.fetchInfiniteQuery(e).then(S).catch(S)}ensureInfiniteQueryData(e){return e._type=`infinite`,this.ensureQueryData(e)}resumePausedMutations(){return _e.isOnline()?this.#t.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#e}getMutationCache(){return this.#t}getDefaultOptions(){return this.#n}setDefaultOptions(e){this.#n=e}setQueryDefaults(e,t){this.#r.set(k(e),{queryKey:e,defaultOptions:t})}getQueryDefaults(e){let t=[...this.#r.values()],n={};return t.forEach(t=>{A(e,t.queryKey)&&Object.assign(n,t.defaultOptions)}),n}setMutationDefaults(e,t){this.#i.set(k(e),{mutationKey:e,defaultOptions:t})}getMutationDefaults(e){let t=[...this.#i.values()],n={};return t.forEach(t=>{A(e,t.mutationKey)&&Object.assign(n,t.defaultOptions)}),n}defaultQueryOptions(e){if(e._defaulted)return e;let t={...this.#n.queries,...this.getQueryDefaults(e.queryKey),...e,_defaulted:!0};return t.queryHash||=te(t.queryKey,t),t.refetchOnReconnect===void 0&&(t.refetchOnReconnect=t.networkMode!==`always`),t.throwOnError===void 0&&(t.throwOnError=!!t.suspense),!t.networkMode&&t.persister&&(t.networkMode=`offlineFirst`),t.queryFn===se&&(t.enabled=!1),t}defaultMutationOptions(e){return e?._defaulted?e:{...this.#n.mutations,...e?.mutationKey&&this.getMutationDefaults(e.mutationKey),...e,_defaulted:!0}}clear(){this.#e.clear(),this.#t.clear()}},We=p.createContext(!1),Ge=()=>p.useContext(We);We.Provider;function Ke(){let e=!1;return{clearReset:()=>{e=!1},reset:()=>{e=!0},isReset:()=>e}}var qe=p.createContext(Ke()),Je=()=>p.useContext(qe),Ye=(e,t,n)=>{let r=n?.state.error&&typeof e.throwOnError==`function`?le(e.throwOnError,[n.state.error,n]):e.throwOnError;(e.suspense||r)&&(t.isReset()||(e.retryOnMount=!1))},Xe=e=>{p.useEffect(()=>{e.clearReset()},[e])},Ze=({result:e,errorResetBoundary:t,throwOnError:n,query:r,suspense:i})=>e.isError&&!t.isReset()&&!e.isFetching&&r&&(i&&e.data===void 0||le(n,[e.error,r])),Qe=e=>{if(e.suspense){let t=1e3,n=e=>e===`static`?e:Math.max(e??t,t),r=e.staleTime;e.staleTime=typeof r==`function`?(...e)=>n(r(...e)):n(r),typeof e.gcTime==`number`&&(e.gcTime=Math.max(e.gcTime,t))}},$e=(e,t)=>e?.suspense&&t.isPending,et=(e,t,n)=>t.fetchOptimistic(e).catch(()=>{n.clearReset()});function tt(e,t,n){let r=Ge(),i=Je(),a=g(n),o=a.defaultQueryOptions(e),s=a.getQueryCache().get(o.queryHash),c=e.subscribed!==!1;o._optimisticResults=r?`isRestoring`:c?`optimistic`:void 0,Qe(o),Ye(o,i,s),Xe(i);let[l]=p.useState(()=>new t(a,o)),u=l.getOptimisticResult(o),d=!r&&c;if(p.useSyncExternalStore(p.useCallback(e=>{let t=d?l.subscribe(ge.batchCalls(e)):S;return l.updateResult(),t},[l,d]),()=>l.getCurrentResult(),()=>l.getCurrentResult()),p.useEffect(()=>{l.setOptions(o)},[o,l]),$e(o,u))throw et(o,l,i);if(Ze({result:u,errorResetBoundary:i,throwOnError:o.throwOnError,query:s,suspense:o.suspense}))throw u.error;return o.notifyOnChangeProps?u:l.trackResult(u)}function nt(e,t){return tt(e,Ae,t)}function rt(e,t){let n=g(t);return at({filters:{...e,status:`pending`}},n).length}function it(e,t){return e.findAll(t.filters).map(e=>t.select?t.select(e):e.state)}function at(e={},t){let n=g(t).getMutationCache(),r=p.useRef(e),i=p.useRef(null);return i.current===null&&(i.current=it(n,e)),p.useEffect(()=>{r.current=e}),p.useSyncExternalStore(p.useCallback(e=>n.subscribe(()=>{let t=j(i.current,it(n,r.current));i.current!==t&&(i.current=t,ge.schedule(e))}),[n]),()=>i.current,()=>i.current)}function ot(e,t){let n=g(t),[r]=p.useState(()=>new Ve(n,e));p.useEffect(()=>{r.setOptions(e)},[r,e]);let i=p.useSyncExternalStore(p.useCallback(e=>r.subscribe(ge.batchCalls(e)),[r]),()=>r.getCurrentResult(),()=>r.getCurrentResult()),a=p.useCallback((...e)=>{r.mutate(e[0],e[1]).catch(S)},[r]);if(i.error&&le(r.options.throwOnError,[i.error]))throw i.error;return{...i,mutate:a,mutateAsync:i.mutate}}var I=e=>typeof e==`string`,st=()=>{let e,t,n=new Promise((n,r)=>{e=n,t=r});return n.resolve=e,n.reject=t,n},ct=e=>e==null?``:String(e),lt=(e,t,n)=>{e.forEach(e=>{t[e]&&(n[e]=t[e])})},ut=/###/g,dt=e=>e&&e.includes(`###`)?e.replace(ut,`.`):e,ft=e=>!e||I(e),pt=(e,t,n)=>{let r=I(t)?t.split(`.`):t,i=0;for(;i{let{obj:r,k:i}=pt(e,t,Object);if(r!==void 0||t.length===1){r[i]=n;return}let a=t[t.length-1],o=t.slice(0,t.length-1),s=pt(e,o,Object);for(;s.obj===void 0&&o.length;)a=`${o[o.length-1]}.${a}`,o=o.slice(0,o.length-1),s=pt(e,o,Object),s?.obj&&s.obj[`${s.k}.${a}`]!==void 0&&(s.obj=void 0);s.obj[`${s.k}.${a}`]=n},ht=(e,t,n,r)=>{let{obj:i,k:a}=pt(e,t,Object);i[a]=i[a]||[],i[a].push(n)},gt=(e,t)=>{let{obj:n,k:r}=pt(e,t);if(n&&Object.prototype.hasOwnProperty.call(n,r))return n[r]},_t=(e,t,n)=>{let r=gt(e,n);return r===void 0?gt(t,n):r},vt=(e,t,n)=>{for(let r in t)r!==`__proto__`&&r!==`constructor`&&(Object.prototype.hasOwnProperty.call(e,r)?I(e[r])||e[r]instanceof String||I(t[r])||t[r]instanceof String?n&&(e[r]=t[r]):vt(e[r],t[r],n):e[r]=t[r]);return e},yt=e=>e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,`\\$&`),bt={"&":`&`,"<":`<`,">":`>`,'"':`"`,"'":`'`,"/":`/`},xt=e=>I(e)?e.replace(/[&<>"'\/]/g,e=>bt[e]):e,St=class{constructor(e){this.capacity=e,this.regExpMap=new Map,this.regExpQueue=[]}getRegExp(e){let t=this.regExpMap.get(e);if(t!==void 0)return t;let n=new RegExp(e);return this.regExpQueue.length===this.capacity&&this.regExpMap.delete(this.regExpQueue.shift()),this.regExpMap.set(e,n),this.regExpQueue.push(e),n}},Ct=[` `,`,`,`?`,`!`,`;`],wt=new St(20),Tt=(e,t,n)=>{t||=``,n||=``;let r=Ct.filter(e=>!t.includes(e)&&!n.includes(e));if(r.length===0)return!0;let i=wt.getRegExp(`(${r.map(e=>e===`?`?`\\?`:e).join(`|`)})`),a=!i.test(e);if(!a){let t=e.indexOf(n);t>0&&!i.test(e.substring(0,t))&&(a=!0)}return a},Et=(e,t,n=`.`)=>{if(!e)return;if(e[t])return Object.prototype.hasOwnProperty.call(e,t)?e[t]:void 0;let r=t.split(n),i=e;for(let e=0;ee?.replace(/_/g,`-`),Ot={type:`logger`,log(e){this.output(`log`,e)},warn(e){this.output(`warn`,e)},error(e){this.output(`error`,e)},output(e,t){console?.[e]?.apply?.(console,t)}},kt=new class e{constructor(e,t={}){this.init(e,t)}init(e,t={}){this.prefix=t.prefix||`i18next:`,this.logger=e||Ot,this.options=t,this.debug=t.debug}log(...e){return this.forward(e,`log`,``,!0)}warn(...e){return this.forward(e,`warn`,``,!0)}error(...e){return this.forward(e,`error`,``)}deprecate(...e){return this.forward(e,`warn`,`WARNING DEPRECATED: `,!0)}forward(e,t,n,r){return r&&!this.debug?null:(e=e.map(e=>I(e)?e.replace(/[\r\n\x00-\x1F\x7F]/g,` `):e),I(e[0])&&(e[0]=`${n}${this.prefix} ${e[0]}`),this.logger[t](e))}create(t){return new e(this.logger,{prefix:`${this.prefix}:${t}:`,...this.options})}clone(t){return t||=this.options,t.prefix=t.prefix||this.prefix,new e(this.logger,t)}},At=class{constructor(){this.observers={}}on(e,t){return e.split(` `).forEach(e=>{this.observers[e]||(this.observers[e]=new Map);let n=this.observers[e].get(t)||0;this.observers[e].set(t,n+1)}),this}off(e,t){if(this.observers[e]){if(!t){delete this.observers[e];return}this.observers[e].delete(t)}}once(e,t){let n=(...r)=>{t(...r),this.off(e,n)};return this.on(e,n),this}emit(e,...t){this.observers[e]&&Array.from(this.observers[e].entries()).forEach(([e,n])=>{for(let r=0;r{for(let i=0;i-1&&this.options.ns.splice(t,1)}getResource(e,t,n,r={}){let i=r.keySeparator===void 0?this.options.keySeparator:r.keySeparator,a=r.ignoreJSONStructure===void 0?this.options.ignoreJSONStructure:r.ignoreJSONStructure,o;e.includes(`.`)?o=e.split(`.`):(o=[e,t],n&&(Array.isArray(n)?o.push(...n):I(n)&&i?o.push(...n.split(i)):o.push(n)));let s=gt(this.data,o);return!s&&!t&&!n&&e.includes(`.`)&&(e=o[0],t=o[1],n=o.slice(2).join(`.`)),s||!a||!I(n)?s:Et(this.data?.[e]?.[t],n,i)}addResource(e,t,n,r,i={silent:!1}){let a=i.keySeparator===void 0?this.options.keySeparator:i.keySeparator,o=[e,t];n&&(o=o.concat(a?n.split(a):n)),e.includes(`.`)&&(o=e.split(`.`),r=t,t=o[1]),this.addNamespaces(t),mt(this.data,o,r),i.silent||this.emit(`added`,e,t,n,r)}addResources(e,t,n,r={silent:!1}){for(let r in n)(I(n[r])||Array.isArray(n[r]))&&this.addResource(e,t,r,n[r],{silent:!0});r.silent||this.emit(`added`,e,t,n)}addResourceBundle(e,t,n,r,i,a={silent:!1,skipCopy:!1}){let o=[e,t];e.includes(`.`)&&(o=e.split(`.`),r=n,n=t,t=o[1]),this.addNamespaces(t);let s=gt(this.data,o)||{};a.skipCopy||(n=JSON.parse(JSON.stringify(n))),r?vt(s,n,i):s={...s,...n},mt(this.data,o,s),a.silent||this.emit(`added`,e,t,n)}removeResourceBundle(e,t){this.hasResourceBundle(e,t)&&delete this.data[e][t],this.removeNamespaces(t),this.emit(`removed`,e,t)}hasResourceBundle(e,t){return this.getResource(e,t)!==void 0}getResourceBundle(e,t){return t||=this.options.defaultNS,this.getResource(e,t)}getDataByLanguage(e){return this.data[e]}hasLanguageSomeTranslations(e){let t=this.getDataByLanguage(e);return!!(t&&Object.keys(t)||[]).find(e=>t[e]&&Object.keys(t[e]).length>0)}toJSON(){return this.data}},Mt={processors:{},addPostProcessor(e){this.processors[e.name]=e},handle(e,t,n,r,i){return e.forEach(e=>{t=this.processors[e]?.process(t,n,r,i)??t}),t}},Nt=Symbol(`i18next/PATH_KEY`);function Pt(){let e=[],t=Object.create(null),n;return t.get=(r,i)=>(n?.revoke?.(),i===Nt?e:(e.push(i),n=Proxy.revocable(r,t),n.proxy)),Proxy.revocable(Object.create(null),t).proxy}function Ft(e,t){let{[Nt]:n}=e(Pt()),r=t?.keySeparator??`.`,i=t?.nsSeparator??`:`,a=t?.enableSelector===`strict`;if(n.length>1&&i){let e=t?.ns,o=a?Array.isArray(e)?e:e?[e]:null:Array.isArray(e)?e:null;if(o&&(a?o:o.length>1?o.slice(1):[]).includes(n[0]))return`${n[0]}${i}${n.slice(1).join(r)}`}return n.join(r)}var It=e=>!I(e)&&typeof e!=`boolean`&&typeof e!=`number`,Lt=class e extends At{constructor(e,t={}){super(),lt([`resourceStore`,`languageUtils`,`pluralResolver`,`interpolator`,`backendConnector`,`i18nFormat`,`utils`],e,this),this.options=t,this.options.keySeparator===void 0&&(this.options.keySeparator=`.`),this.logger=kt.create(`translator`),this.checkedLoadedFor={}}changeLanguage(e){e&&(this.language=e)}exists(e,t={interpolation:{}}){let n={...t};if(e==null)return!1;let r=this.resolve(e,n);if(r?.res===void 0)return!1;let i=It(r.res);return!(n.returnObjects===!1&&i)}extractFromKey(e,t){let n=t.nsSeparator===void 0?this.options.nsSeparator:t.nsSeparator;n===void 0&&(n=`:`);let r=t.keySeparator===void 0?this.options.keySeparator:t.keySeparator,i=t.ns||this.options.defaultNS||[],a=n&&e.includes(n),o=!this.options.userDefinedKeySeparator&&!t.keySeparator&&!this.options.userDefinedNsSeparator&&!t.nsSeparator&&!Tt(e,n,r);if(a&&!o){let t=e.match(this.interpolator.nestingRegexp);if(t&&t.length>0)return{key:e,namespaces:I(i)?[i]:i};let a=e.split(n);(n!==r||n===r&&this.options.ns.includes(a[0]))&&(i=a.shift()),e=a.join(r)}return{key:e,namespaces:I(i)?[i]:i}}translate(t,n,r){let i=typeof n==`object`?{...n}:n;if(typeof i!=`object`&&this.options.overloadTranslationOptionHandler&&(i=this.options.overloadTranslationOptionHandler(arguments)),typeof i==`object`&&(i={...i}),i||={},t==null)return``;typeof t==`function`&&(t=Ft(t,{...this.options,...i})),Array.isArray(t)||(t=[String(t)]),t=t.map(e=>typeof e==`function`?Ft(e,{...this.options,...i}):String(e));let a=i.returnDetails===void 0?this.options.returnDetails:i.returnDetails,o=i.keySeparator===void 0?this.options.keySeparator:i.keySeparator,{key:s,namespaces:c}=this.extractFromKey(t[t.length-1],i),l=c[c.length-1],u=i.nsSeparator===void 0?this.options.nsSeparator:i.nsSeparator;u===void 0&&(u=`:`);let d=i.lng||this.language,f=i.appendNamespaceToCIMode||this.options.appendNamespaceToCIMode;if(d?.toLowerCase()===`cimode`)return f?a?{res:`${l}${u}${s}`,usedKey:s,exactUsedKey:s,usedLng:d,usedNS:l,usedParams:this.getUsedParamsDetails(i)}:`${l}${u}${s}`:a?{res:s,usedKey:s,exactUsedKey:s,usedLng:d,usedNS:l,usedParams:this.getUsedParamsDetails(i)}:s;let p=this.resolve(t,i),m=p?.res,h=p?.usedKey||s,g=p?.exactUsedKey||s,_=[`[object Number]`,`[object Function]`,`[object RegExp]`],v=i.joinArrays===void 0?this.options.joinArrays:i.joinArrays,y=!this.i18nFormat||this.i18nFormat.handleAsObject,b=i.count!==void 0&&!I(i.count),x=e.hasDefaultValue(i),S=b?this.pluralResolver.getSuffix(d,i.count,i):``,C=i.ordinal&&b?this.pluralResolver.getSuffix(d,i.count,{ordinal:!1}):``,w=b&&!i.ordinal&&i.count===0,T=w&&i[`defaultValue${this.options.pluralSeparator}zero`]||i[`defaultValue${S}`]||i[`defaultValue${C}`]||i.defaultValue,E=m;y&&!m&&x&&(E=T);let D=It(E),O=Object.prototype.toString.apply(E);if(y&&E&&D&&!_.includes(O)&&!(I(v)&&Array.isArray(E))){if(!i.returnObjects&&!this.options.returnObjects){this.options.returnedObjectHandler||this.logger.warn(`accessing an object - but returnObjects options is not enabled!`);let e=this.options.returnedObjectHandler?this.options.returnedObjectHandler(h,E,{...i,ns:c}):`key '${s} (${this.language})' returned an object instead of string.`;return a?(p.res=e,p.usedParams=this.getUsedParamsDetails(i),p):e}if(o){let e=Array.isArray(E),t=e?[]:{},n=e?g:h;for(let e in E)if(Object.prototype.hasOwnProperty.call(E,e)){let r=`${n}${o}${e}`;t[e]=x&&!m?this.translate(r,{...i,defaultValue:It(T)?T[e]:void 0,joinArrays:!1,ns:c}):this.translate(r,{...i,joinArrays:!1,ns:c}),t[e]===r&&(t[e]=E[e])}m=t}}else if(y&&I(v)&&Array.isArray(m))m=m.join(v),m&&=this.extendTranslation(m,t,i,r);else{let e=!1,n=!1;!this.isValidLookup(m)&&x&&(e=!0,m=T),this.isValidLookup(m)||(n=!0,m=s);let a=(i.missingKeyNoValueFallbackToKey||this.options.missingKeyNoValueFallbackToKey)&&n?void 0:m,c=x&&T!==m&&this.options.updateMissing;if(n||e||c){if(this.logger.log(c?`updateKey`:`missingKey`,d,l,b&&!c?`${s}${this.pluralResolver.getSuffix(d,i.count,i)}`:s,c?T:m),o){let e=this.resolve(s,{...i,keySeparator:!1});e&&e.res&&this.logger.warn(`Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.`)}let e=[],t=this.languageUtils.getFallbackCodes(this.options.fallbackLng,i.lng||this.language);if(this.options.saveMissingTo===`fallback`&&t&&t[0])for(let n=0;n{let r=x&&n!==m?n:a;this.options.missingKeyHandler?this.options.missingKeyHandler(e,l,t,r,c,i):this.backendConnector?.saveMissing&&this.backendConnector.saveMissing(e,l,t,r,c,i),this.emit(`missingKey`,e,l,t,m)};this.options.saveMissing&&(this.options.saveMissingPlurals&&b?e.forEach(e=>{let t=this.pluralResolver.getSuffixes(e,i);w&&i[`defaultValue${this.options.pluralSeparator}zero`]&&!t.includes(`${this.options.pluralSeparator}zero`)&&t.push(`${this.options.pluralSeparator}zero`),t.forEach(t=>{n([e],s+t,i[`defaultValue${t}`]||T)})}):n(e,s,T))}m=this.extendTranslation(m,t,i,p,r),n&&m===s&&this.options.appendNamespaceToMissingKey&&(m=`${l}${u}${s}`),(n||e)&&this.options.parseMissingKeyHandler&&(m=this.options.parseMissingKeyHandler(this.options.appendNamespaceToMissingKey?`${l}${u}${s}`:s,e?m:void 0,i))}return a?(p.res=m,p.usedParams=this.getUsedParamsDetails(i),p):m}extendTranslation(e,t,n,r,i){if(this.i18nFormat?.parse)e=this.i18nFormat.parse(e,{...this.options.interpolation.defaultVariables,...n},n.lng||this.language||r.usedLng,r.usedNS,r.usedKey,{resolved:r});else if(!n.skipInterpolation){n.interpolation&&this.interpolator.init({...n,interpolation:{...this.options.interpolation,...n.interpolation}});let a=I(e)&&(n?.interpolation?.skipOnVariables===void 0?this.options.interpolation.skipOnVariables:n.interpolation.skipOnVariables),o;if(a){let t=e.match(this.interpolator.nestingRegexp);o=t&&t.length}let s=n.replace&&!I(n.replace)?n.replace:n;if(this.options.interpolation.defaultVariables&&(s={...this.options.interpolation.defaultVariables,...s}),e=this.interpolator.interpolate(e,s,n.lng||this.language||r.usedLng,n),a){let t=e.match(this.interpolator.nestingRegexp),r=t&&t.length;oi?.[0]===e[0]&&!n.context?(this.logger.warn(`It seems you are nesting recursively key: ${e[0]} in key: ${t[0]}`),null):this.translate(...e,t),n)),n.interpolation&&this.interpolator.reset()}let a=n.postProcess||this.options.postProcess,o=I(a)?[a]:a;return e!=null&&o?.length&&n.applyPostProcessor!==!1&&(e=Mt.handle(o,e,t,this.options&&this.options.postProcessPassResolved?{i18nResolved:{...r,usedParams:this.getUsedParamsDetails(n)},...n}:n,this)),e}resolve(e,t={}){let n,r,i,a,o;return I(e)&&(e=[e]),Array.isArray(e)&&(e=e.map(e=>typeof e==`function`?Ft(e,{...this.options,...t}):e)),e.forEach(e=>{if(this.isValidLookup(n))return;let s=this.extractFromKey(e,t),c=s.key;r=c;let l=s.namespaces;this.options.fallbackNS&&(l=l.concat(this.options.fallbackNS));let u=t.count!==void 0&&!I(t.count),d=u&&!t.ordinal&&t.count===0,f=t.context!==void 0&&(I(t.context)||typeof t.context==`number`)&&t.context!==``,p=t.lngs?t.lngs:this.languageUtils.toResolveHierarchy(t.lng||this.language,t.fallbackLng);l.forEach(e=>{this.isValidLookup(n)||(o=e,!this.checkedLoadedFor[`${p[0]}-${e}`]&&this.utils?.hasLoadedNamespace&&!this.utils?.hasLoadedNamespace(o)&&(this.checkedLoadedFor[`${p[0]}-${e}`]=!0,this.logger.warn(`key "${r}" for languages "${p.join(`, `)}" won't get resolved as namespace "${o}" was not yet loaded`,`This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!`)),p.forEach(r=>{if(this.isValidLookup(n))return;a=r;let o=[c];if(this.i18nFormat?.addLookupKeys)this.i18nFormat.addLookupKeys(o,c,r,e,t);else{let e;u&&(e=this.pluralResolver.getSuffix(r,t.count,t));let n=`${this.options.pluralSeparator}zero`,i=`${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;if(u&&(t.ordinal&&e.startsWith(i)&&o.push(c+e.replace(i,this.options.pluralSeparator)),o.push(c+e),d&&o.push(c+n)),f){let r=`${c}${this.options.contextSeparator||`_`}${t.context}`;o.push(r),u&&(t.ordinal&&e.startsWith(i)&&o.push(r+e.replace(i,this.options.pluralSeparator)),o.push(r+e),d&&o.push(r+n))}}let s;for(;s=o.pop();)this.isValidLookup(n)||(i=s,n=this.getResource(r,e,s,t))}))})}),{res:n,usedKey:r,exactUsedKey:i,usedLng:a,usedNS:o}}isValidLookup(e){return e!==void 0&&!(!this.options.returnNull&&e===null)&&!(!this.options.returnEmptyString&&e===``)}getResource(e,t,n,r={}){return this.i18nFormat?.getResource?this.i18nFormat.getResource(e,t,n,r):this.resourceStore.getResource(e,t,n,r)}getUsedParamsDetails(e={}){let t=[`defaultValue`,`ordinal`,`context`,`replace`,`lng`,`lngs`,`fallbackLng`,`ns`,`keySeparator`,`nsSeparator`,`returnObjects`,`returnDetails`,`joinArrays`,`postProcess`,`interpolation`],n=e.replace&&!I(e.replace),r=n?e.replace:e;if(n&&e.count!==void 0&&(r={...r,count:e.count}),this.options.interpolation.defaultVariables&&(r={...this.options.interpolation.defaultVariables,...r}),!n){r={...r};for(let e of t)delete r[e]}return r}static hasDefaultValue(e){for(let t in e)if(Object.prototype.hasOwnProperty.call(e,t)&&t.startsWith(`defaultValue`)&&e[t]!==void 0)return!0;return!1}},Rt=class{constructor(e){this.options=e,this.supportedLngs=this.options.supportedLngs||!1,this.logger=kt.create(`languageUtils`),this.resolveHierarchyCache={}}clearCache(){this.resolveHierarchyCache={}}getScriptPartFromCode(e){if(e=Dt(e),!e||!e.includes(`-`))return null;let t=e.split(`-`);return t.length===2||(t.pop(),t[t.length-1].toLowerCase()===`x`)?null:this.formatLanguageCode(t.join(`-`))}getLanguagePartFromCode(e){if(e=Dt(e),!e||!e.includes(`-`))return e;let t=e.split(`-`);return this.formatLanguageCode(t[0])}formatLanguageCode(e){if(I(e)&&e.includes(`-`)){let t;try{t=Intl.getCanonicalLocales(e)[0]}catch{}return t&&this.options.lowerCaseLng&&(t=t.toLowerCase()),t||(this.options.lowerCaseLng?e.toLowerCase():e)}return this.options.cleanCode||this.options.lowerCaseLng?e.toLowerCase():e}isSupportedCode(e){return(this.options.load===`languageOnly`||this.options.nonExplicitSupportedLngs)&&(e=this.getLanguagePartFromCode(e)),!this.supportedLngs||!this.supportedLngs.length||this.supportedLngs.includes(e)}getBestMatchFromCodes(e){if(!e)return null;let t;return e.forEach(e=>{if(t)return;let n=this.formatLanguageCode(e);(!this.options.supportedLngs||this.isSupportedCode(n))&&(t=n)}),!t&&this.options.supportedLngs&&e.forEach(e=>{if(t)return;let n=this.getScriptPartFromCode(e);if(this.isSupportedCode(n))return t=n;let r=this.getLanguagePartFromCode(e);if(this.isSupportedCode(r))return t=r;t=this.options.supportedLngs.find(e=>e===r?!0:!e.includes(`-`)&&!r.includes(`-`)?!1:!!(e.includes(`-`)&&!r.includes(`-`)&&e.slice(0,e.indexOf(`-`))===r||e.startsWith(r)&&r.length>1))}),t||=this.getFallbackCodes(this.options.fallbackLng)[0],t}getFallbackCodes(e,t){if(!e)return[];if(typeof e==`function`&&(e=e(t)),I(e)&&(e=[e]),Array.isArray(e))return e;if(!t)return e.default||[];let n=e[t];return n||=e[this.getScriptPartFromCode(t)],n||=e[this.formatLanguageCode(t)],n||=e[this.getLanguagePartFromCode(t)],n||=e.default,n||[]}toResolveHierarchy(e,t){let n=this.options.fallbackLng,r=Array.isArray(n)?n.join(`|`):n;r!==this._cachedFallbackLng&&(this.resolveHierarchyCache={},this._cachedFallbackLng=r);let i=t===void 0||t===!1||I(t),a=t===void 0&&typeof this.options.fallbackLng==`function`,o=I(e)&&i&&!a,s=null;if(o){let n;n=t===void 0?`undefined`:t===!1?`boolean:false`:`string:${t}`,s=`${e.length}:${e}|${n}`}if(s!==null){let e=this.resolveHierarchyCache[s];if(e!==void 0)return e.slice()}let c=this.getFallbackCodes((t===!1?[]:t)||this.options.fallbackLng||[],e),l=[],u=e=>{e&&(this.isSupportedCode(e)?l.push(e):this.logger.warn(`rejecting language code not found in supportedLngs: ${e}`))};return I(e)&&(e.includes(`-`)||e.includes(`_`))?(this.options.load!==`languageOnly`&&u(this.formatLanguageCode(e)),this.options.load!==`languageOnly`&&this.options.load!==`currentOnly`&&u(this.getScriptPartFromCode(e)),this.options.load!==`currentOnly`&&u(this.getLanguagePartFromCode(e))):I(e)&&u(this.formatLanguageCode(e)),c.forEach(e=>{l.includes(e)||u(this.formatLanguageCode(e))}),s===null?l:(this.resolveHierarchyCache[s]=l,l.slice())}},zt={zero:0,one:1,two:2,few:3,many:4,other:5},Bt={select:e=>e===1?`one`:`other`,resolvedOptions:()=>({pluralCategories:[`one`,`other`]})},Vt=class{constructor(e,t={}){this.languageUtils=e,this.options=t,this.logger=kt.create(`pluralResolver`),this.pluralRulesCache={}}clearCache(){this.pluralRulesCache={}}getRule(e,t={}){let n=Dt(e===`dev`?`en`:e),r=t.ordinal?`ordinal`:`cardinal`,i=JSON.stringify({cleanedCode:n,type:r});if(i in this.pluralRulesCache)return this.pluralRulesCache[i];let a;try{a=new Intl.PluralRules(n,{type:r})}catch{if(typeof Intl>`u`)return this.logger.error(`No Intl support, please use an Intl polyfill!`),Bt;if(!e.match(/-|_/))return Bt;let n=this.languageUtils.getLanguagePartFromCode(e);a=this.getRule(n,t)}return this.pluralRulesCache[i]=a,a}needsPlural(e,t={}){let n=this.getRule(e,t);return n||=this.getRule(`dev`,t),n?.resolvedOptions().pluralCategories.length>1}getPluralFormsOfKey(e,t,n={}){return this.getSuffixes(e,n).map(e=>`${t}${e}`)}getSuffixes(e,t={}){let n=this.getRule(e,t);return n||=this.getRule(`dev`,t),n?n.resolvedOptions().pluralCategories.sort((e,t)=>zt[e]-zt[t]).map(e=>`${this.options.prepend}${t.ordinal?`ordinal${this.options.prepend}`:``}${e}`):[]}getSuffix(e,t,n={}){let r=this.getRule(e,n);return r?`${this.options.prepend}${n.ordinal?`ordinal${this.options.prepend}`:``}${r.select(t)}`:(this.logger.warn(`no plural rule found for: ${e}`),this.getSuffix(`dev`,t,n))}},Ht=(e,t,n,r=`.`,i=!0)=>{let a=_t(e,t,n);return!a&&i&&I(n)&&(a=Et(e,n,r),a===void 0&&(a=Et(t,n,r))),a},Ut=e=>e.replace(/\$/g,`$$$$`),Wt=class{constructor(e={}){this.logger=kt.create(`interpolator`),this.options=e,this.format=e?.interpolation?.format||(e=>e),this.init(e)}init(e={}){e.interpolation||={escapeValue:!0};let{escape:t,escapeValue:n,useRawValueToEscape:r,prefix:i,prefixEscaped:a,suffix:o,suffixEscaped:s,formatSeparator:c,unescapeSuffix:l,unescapePrefix:u,nestingPrefix:d,nestingPrefixEscaped:f,nestingSuffix:p,nestingSuffixEscaped:m,nestingOptionsSeparator:h,maxReplaces:g,alwaysFormat:_}=e.interpolation;this.escape=t===void 0?xt:t,this.escapeValue=n===void 0||n,this.useRawValueToEscape=r!==void 0&&r,this.prefix=i?yt(i):a||`{{`,this.suffix=o?yt(o):s||`}}`,this.formatSeparator=c||`,`,this.unescapePrefix=l?``:u?yt(u):`-`,this.unescapeSuffix=this.unescapePrefix?``:l?yt(l):``,this.nestingPrefix=d?yt(d):f||yt(`$t(`),this.nestingSuffix=p?yt(p):m||yt(`)`),this.nestingOptionsSeparator=h||`,`,this.maxReplaces=g||1e3,this.alwaysFormat=_!==void 0&&_,this.resetRegExp()}reset(){this.options&&this.init(this.options)}resetRegExp(){let e=(e,t)=>e?.source===t?(e.lastIndex=0,e):new RegExp(t,`g`);this.regexp=e(this.regexp,`${this.prefix}(.+?)${this.suffix}`),this.regexpUnescape=e(this.regexpUnescape,`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`),this.nestingRegexp=e(this.nestingRegexp,`${this.nestingPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${this.nestingSuffix}`)}interpolate(e,t,n,r){let i,a,o,s=this.options&&this.options.interpolation&&this.options.interpolation.defaultVariables||{},c=e=>{if(!e.includes(this.formatSeparator)){let i=Ht(t,s,e,this.options.keySeparator,this.options.ignoreJSONStructure);return this.alwaysFormat?this.format(i,void 0,n,{...r,...t,interpolationkey:e}):i}let i=e.split(this.formatSeparator),a=i.shift().trim(),o=i.join(this.formatSeparator).trim();return this.format(Ht(t,s,a,this.options.keySeparator,this.options.ignoreJSONStructure),o,n,{...r,...t,interpolationkey:a})};this.resetRegExp(),!this.escapeValue&&typeof e==`string`&&/\$t\([^)]*\{[^}]*\{\{/.test(e)&&this.logger.warn(`nesting options string contains interpolated variables with escapeValue: false — if any of those values are attacker-controlled they can inject additional nesting options (e.g. redirect lng/ns). Sanitise untrusted input before passing it to t(), or keep escapeValue: true.`);let l=r?.missingInterpolationHandler||this.options.missingInterpolationHandler,u=r?.interpolation?.skipOnVariables===void 0?this.options.interpolation.skipOnVariables:r.interpolation.skipOnVariables;return[{regex:this.regexpUnescape,safeValue:e=>e},{regex:this.regexp,safeValue:e=>this.escapeValue?this.escape(e):e}].forEach(t=>{for(o=0;i=t.regex.exec(e);){let n=i[1].trim();if(a=c(n),a===void 0){if(typeof l==`function`){let t=l(e,i,r);a=I(t)?t:``}else if(r&&Object.prototype.hasOwnProperty.call(r,n))a=``;else if(u){a=i[0];continue}else this.logger.warn(`missed to pass in variable ${n} for interpolating ${e}`),a=``}else!I(a)&&!this.useRawValueToEscape&&(a=ct(a));let s=t.safeValue(a);if(e=e.replace(i[0],Ut(s)),u?(t.regex.lastIndex+=s.length,t.regex.lastIndex-=i[0].length):t.regex.lastIndex=0,o++,o>=this.maxReplaces)break}}),e}nest(e,t,n={}){let r,i,a,o=(e,t)=>{let n=this.nestingOptionsSeparator;if(!e.includes(n))return e;let r=e.split(RegExp(`${yt(n)}[ ]*{`)),i=`{${r[1]}`;e=r[0],i=this.interpolate(i,a);let o=i.match(/'/g),s=i.match(/"/g);((o?.length??0)%2==0&&!s||(s?.length??0)%2!=0)&&(i=i.replace(/'/g,`"`));try{a=JSON.parse(i),t&&(a={...t,...a})}catch(t){return this.logger.warn(`failed parsing options string in nesting for key ${e}`,t),`${e}${n}${i}`}return a.defaultValue&&a.defaultValue.includes(this.prefix)&&delete a.defaultValue,e};for(;r=this.nestingRegexp.exec(e);){let s=[];a={...n},a=a.replace&&!I(a.replace)?a.replace:a,a.applyPostProcessor=!1,delete a.defaultValue;let c=/{.*}/s.test(r[1])?r[1].lastIndexOf(`}`)+1:r[1].indexOf(this.formatSeparator);if(c!==-1&&(s=r[1].slice(c).split(this.formatSeparator).map(e=>e.trim()).filter(Boolean),r[1]=r[1].slice(0,c)),i=t(o.call(this,r[1].trim(),a),a),i&&r[0]===e&&!I(i))return i;I(i)||(i=ct(i)),i||=(this.logger.warn(`missed to resolve ${r[1]} for nesting ${e}`),``),s.length&&(i=s.reduce((e,t)=>this.format(e,t,n.lng,{...n,interpolationkey:r[1].trim()}),i.trim())),e=e.replace(r[0],i),this.regexp.lastIndex=0}return e}},Gt=e=>{let t=e.toLowerCase().trim(),n={};if(e.includes(`(`)){let r=e.split(`(`);t=r[0].toLowerCase().trim();let i=r[1].slice(0,-1);t===`currency`&&!i.includes(`:`)?n.currency||=i.trim():t===`relativetime`&&!i.includes(`:`)?n.range||=i.trim():i.split(`;`).forEach(e=>{if(e){let[t,...r]=e.split(`:`),i=r.join(`:`).trim().replace(/^'+|'+$/g,``),a=t.trim();n[a]||(n[a]=i),i===`false`&&(n[a]=!1),i===`true`&&(n[a]=!0),isNaN(i)||(n[a]=parseInt(i,10))}})}return{formatName:t,formatOptions:n}},Kt=e=>{let t={};return(n,r,i)=>{let a=i;i&&i.interpolationkey&&i.formatParams&&i.formatParams[i.interpolationkey]&&i[i.interpolationkey]&&(a={...a,[i.interpolationkey]:void 0});let o=r+JSON.stringify(a),s=t[o];return s||(s=e(Dt(r),i),t[o]=s),s(n)}},qt=e=>(t,n,r)=>e(Dt(n),r)(t),Jt=class{constructor(e={}){this.logger=kt.create(`formatter`),this.options=e,this.init(e)}init(e,t={interpolation:{}}){this.formatSeparator=t.interpolation.formatSeparator||`,`;let n=t.cacheInBuiltFormats?Kt:qt;this.formats={number:n((e,t)=>{let n=new Intl.NumberFormat(e,{...t});return e=>n.format(e)}),currency:n((e,t)=>{let n=new Intl.NumberFormat(e,{...t,style:`currency`});return e=>n.format(e)}),datetime:n((e,t)=>{let n=new Intl.DateTimeFormat(e,{...t});return e=>n.format(e)}),relativetime:n((e,t)=>{let n=new Intl.RelativeTimeFormat(e,{...t});return e=>n.format(e,t.range||`day`)}),list:n((e,t)=>{let n=new Intl.ListFormat(e,{...t});return e=>n.format(e)})}}add(e,t){this.formats[e.toLowerCase().trim()]=t}addCached(e,t){this.formats[e.toLowerCase().trim()]=Kt(t)}format(e,t,n,r={}){if(!t||e==null)return e;let i=t.split(this.formatSeparator),a=[];for(let e=0;e-1&&!t.includes(`)`)&&e+1{let{formatName:i,formatOptions:a}=Gt(t);if(this.formats[i]){let t=e;try{let o=r?.formatParams?.[r.interpolationkey]||{},s=o.locale||o.lng||r.locale||r.lng||n;t=this.formats[i](e,s,{...a,...r,...o})}catch(e){this.logger.warn(e)}return t}return this.logger.warn(`there was no format function for ${i}`),e},e)}},Yt=(e,t)=>{e.pending[t]!==void 0&&(delete e.pending[t],e.pendingCount--)},Xt=class extends At{constructor(e,t,n,r={}){super(),this.backend=e,this.store=t,this.services=n,this.languageUtils=n.languageUtils,this.options=r,this.logger=kt.create(`backendConnector`),this.waitingReads=[],this.maxParallelReads=r.maxParallelReads||10,this.readingCalls=0,this.maxRetries=r.maxRetries>=0?r.maxRetries:5,this.retryTimeout=r.retryTimeout>=1?r.retryTimeout:350,this.state={},this.queue=[],this.backend?.init?.(n,r.backend,r)}queueLoad(e,t,n,r){let i={},a={},o={},s={};return e.forEach(e=>{let r=!0;t.forEach(t=>{let o=`${e}|${t}`;!n.reload&&this.store.hasResourceBundle(e,t)?this.state[o]=2:this.state[o]<0||(this.state[o]===1?a[o]===void 0&&(a[o]=!0):(this.state[o]=1,r=!1,a[o]===void 0&&(a[o]=!0),i[o]===void 0&&(i[o]=!0),s[t]===void 0&&(s[t]=!0)))}),r||(o[e]=!0)}),(Object.keys(i).length||Object.keys(a).length)&&this.queue.push({pending:a,pendingCount:Object.keys(a).length,loaded:{},errors:[],callback:r}),{toLoad:Object.keys(i),pending:Object.keys(a),toLoadLanguages:Object.keys(o),toLoadNamespaces:Object.keys(s)}}loaded(e,t,n){let r=e.split(`|`),i=r[0],a=r[1];t&&this.emit(`failedLoading`,i,a,t),!t&&n&&this.store.addResourceBundle(i,a,n,void 0,void 0,{skipCopy:!0}),this.state[e]=t?-1:2,t&&n&&(this.state[e]=0);let o={};this.queue.forEach(n=>{ht(n.loaded,[i],a),Yt(n,e),t&&n.errors.push(t),n.pendingCount===0&&!n.done&&(Object.keys(n.loaded).forEach(e=>{o[e]||(o[e]={});let t=n.loaded[e];t.length&&t.forEach(t=>{o[e][t]===void 0&&(o[e][t]=!0)})}),n.done=!0,n.errors.length?n.callback(n.errors):n.callback())}),this.emit(`loaded`,o),this.queue=this.queue.filter(e=>!e.done)}read(e,t,n,r=0,i=this.retryTimeout,a){if(!e.length)return a(null,{});if(this.readingCalls>=this.maxParallelReads){this.waitingReads.push({lng:e,ns:t,fcName:n,tried:r,wait:i,callback:a});return}this.readingCalls++;let o=(o,s)=>{if(this.readingCalls--,this.waitingReads.length>0){let e=this.waitingReads.shift();this.read(e.lng,e.ns,e.fcName,e.tried,e.wait,e.callback)}if(o&&s&&r{this.read(e,t,n,r+1,i*2,a)},i);return}a(o,s)},s=this.backend[n].bind(this.backend);if(s.length===2){try{let n=s(e,t);n&&typeof n.then==`function`?n.then(e=>o(null,e)).catch(o):o(null,n)}catch(e){o(e)}return}return s(e,t,o)}prepareLoading(e,t,n={},r){if(!this.backend)return this.logger.warn(`No backend was added via i18next.use. Will not load resources.`),r&&r();I(e)&&(e=this.languageUtils.toResolveHierarchy(e)),I(t)&&(t=[t]);let i=this.queueLoad(e,t,n,r);if(!i.toLoad.length)return i.pending.length||r(),null;i.toLoad.forEach(e=>{this.loadOne(e)})}load(e,t,n){this.prepareLoading(e,t,{},n)}reload(e,t,n){this.prepareLoading(e,t,{reload:!0},n)}loadOne(e,t=``){let n=e.split(`|`),r=n[0],i=n[1];this.read(r,i,`read`,void 0,void 0,(n,a)=>{n&&this.logger.warn(`${t}loading namespace ${i} for language ${r} failed`,n),!n&&a&&this.logger.log(`${t}loaded namespace ${i} for language ${r}`,a),this.loaded(e,n,a)})}saveMissing(e,t,n,r,i,a={},o=()=>{}){if(this.services?.utils?.hasLoadedNamespace&&!this.services?.utils?.hasLoadedNamespace(t)){this.logger.warn(`did not save key "${n}" as the namespace "${t}" was not yet loaded`,`This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!`);return}if(n!=null&&n!==``){if(this.backend?.create){let s={...a,isUpdate:i},c=this.backend.create.bind(this.backend);if(c.length<6)try{let i;i=c.length===5?c(e,t,n,r,s):c(e,t,n,r),i&&typeof i.then==`function`?i.then(e=>o(null,e)).catch(o):o(null,i)}catch(e){o(e)}else c(e,t,n,r,o,s)}!e||!e[0]||this.store.addResource(e[0],t,n,r)}}},Zt=()=>({debug:!1,initAsync:!0,ns:[`translation`],defaultNS:[`translation`],fallbackLng:[`dev`],fallbackNS:!1,supportedLngs:!1,nonExplicitSupportedLngs:!1,load:`all`,preload:!1,keySeparator:`.`,nsSeparator:`:`,pluralSeparator:`_`,contextSeparator:`_`,enableSelector:!1,partialBundledLanguages:!1,saveMissing:!1,updateMissing:!1,saveMissingTo:`fallback`,saveMissingPlurals:!0,missingKeyHandler:!1,missingInterpolationHandler:!1,postProcess:!1,postProcessPassResolved:!1,returnNull:!1,returnEmptyString:!0,returnObjects:!1,joinArrays:!1,returnedObjectHandler:!1,parseMissingKeyHandler:!1,appendNamespaceToMissingKey:!1,appendNamespaceToCIMode:!1,overloadTranslationOptionHandler:e=>{let t={};if(typeof e[1]==`object`&&(t=e[1]),I(e[1])&&(t.defaultValue=e[1]),I(e[2])&&(t.tDescription=e[2]),typeof e[2]==`object`||typeof e[3]==`object`){let n=e[3]||e[2];Object.keys(n).forEach(e=>{t[e]=n[e]})}return t},interpolation:{escapeValue:!0,prefix:`{{`,suffix:`}}`,formatSeparator:`,`,unescapePrefix:`-`,nestingPrefix:`$t(`,nestingSuffix:`)`,nestingOptionsSeparator:`,`,maxReplaces:1e3,skipOnVariables:!0},cacheInBuiltFormats:!0}),Qt=e=>(I(e.ns)&&(e.ns=[e.ns]),I(e.fallbackLng)&&(e.fallbackLng=[e.fallbackLng]),I(e.fallbackNS)&&(e.fallbackNS=[e.fallbackNS]),e.supportedLngs&&!e.supportedLngs.includes(`cimode`)&&(e.supportedLngs=e.supportedLngs.concat([`cimode`])),e),$t=()=>{},en=e=>{Object.getOwnPropertyNames(Object.getPrototypeOf(e)).forEach(t=>{typeof e[t]==`function`&&(e[t]=e[t].bind(e))})},tn=class e extends At{constructor(e={},t){if(super(),this.options=Qt(e),this.services={},this.logger=kt,this.modules={external:[]},en(this),t&&!this.isInitialized&&!e.isClone){if(!this.options.initAsync)return this.init(e,t),this;setTimeout(()=>{this.init(e,t)},0)}}init(e={},t){this.isInitializing=!0,typeof e==`function`&&(t=e,e={}),e.defaultNS==null&&e.ns&&(I(e.ns)?e.defaultNS=e.ns:e.ns.includes(`translation`)||(e.defaultNS=e.ns[0]));let n=Zt();this.options={...n,...this.options,...Qt(e)},this.options.interpolation={...n.interpolation,...this.options.interpolation},e.keySeparator!==void 0&&(this.options.userDefinedKeySeparator=e.keySeparator),e.nsSeparator!==void 0&&(this.options.userDefinedNsSeparator=e.nsSeparator),typeof this.options.overloadTranslationOptionHandler!=`function`&&(this.options.overloadTranslationOptionHandler=n.overloadTranslationOptionHandler);let r=e=>e?typeof e==`function`?new e:e:null;if(!this.options.isClone){this.modules.logger?kt.init(r(this.modules.logger),this.options):kt.init(null,this.options);let e;e=this.modules.formatter?this.modules.formatter:Jt;let t=new Rt(this.options);this.store=new jt(this.options.resources,this.options);let n=this.services;n.logger=kt,n.resourceStore=this.store,n.languageUtils=t,n.pluralResolver=new Vt(t,{prepend:this.options.pluralSeparator}),e&&(n.formatter=r(e),n.formatter.init&&n.formatter.init(n,this.options),this.options.interpolation.format=n.formatter.format.bind(n.formatter)),n.interpolator=new Wt(this.options),n.utils={hasLoadedNamespace:this.hasLoadedNamespace.bind(this)},n.backendConnector=new Xt(r(this.modules.backend),n.resourceStore,n,this.options),n.backendConnector.on(`*`,(e,...t)=>{this.emit(e,...t)}),this.modules.languageDetector&&(n.languageDetector=r(this.modules.languageDetector),n.languageDetector.init&&n.languageDetector.init(n,this.options.detection,this.options)),this.modules.i18nFormat&&(n.i18nFormat=r(this.modules.i18nFormat),n.i18nFormat.init&&n.i18nFormat.init(this)),this.translator=new Lt(this.services,this.options),this.translator.on(`*`,(e,...t)=>{this.emit(e,...t)}),this.modules.external.forEach(e=>{e.init&&e.init(this)})}if(this.format=this.options.interpolation.format,t||=$t,this.options.fallbackLng&&!this.services.languageDetector&&!this.options.lng){let e=this.services.languageUtils.getFallbackCodes(this.options.fallbackLng);e.length>0&&e[0]!==`dev`&&(this.options.lng=e[0])}!this.services.languageDetector&&!this.options.lng&&this.logger.warn(`init: no languageDetector is used and no lng is defined`),[`getResource`,`hasResourceBundle`,`getResourceBundle`,`getDataByLanguage`].forEach(e=>{this[e]=(...t)=>this.store[e](...t)}),[`addResource`,`addResources`,`addResourceBundle`,`removeResourceBundle`].forEach(e=>{this[e]=(...t)=>(this.store[e](...t),this)});let i=st(),a=()=>{let e=(e,n)=>{this.isInitializing=!1,this.isInitialized&&!this.initializedStoreOnce&&this.logger.warn(`init: i18next is already initialized. You should call init just once!`),this.isInitialized=!0,this.options.isClone||this.logger.log(`initialized`,this.options),this.emit(`initialized`,this.options),i.resolve(n),t(e,n)};if((this.languages||this.isLanguageChangingTo)&&!this.isInitialized)return e(null,this.t.bind(this));this.changeLanguage(this.options.lng,e)};return this.options.resources||!this.options.initAsync?a():setTimeout(a,0),i}loadResources(e,t=$t){let n=t,r=I(e)?e:this.language;if(typeof e==`function`&&(n=e),!this.options.resources||this.options.partialBundledLanguages){if(r?.toLowerCase()===`cimode`&&(!this.options.preload||this.options.preload.length===0))return n();let e=[],t=t=>{t&&t!==`cimode`&&this.services.languageUtils.toResolveHierarchy(t).forEach(t=>{t!==`cimode`&&(e.includes(t)||e.push(t))})};r?t(r):this.services.languageUtils.getFallbackCodes(this.options.fallbackLng).forEach(e=>t(e)),this.options.preload?.forEach?.(e=>t(e)),this.services.backendConnector.load(e,this.options.ns,e=>{!e&&!this.resolvedLanguage&&this.language&&this.setResolvedLanguage(this.language),n(e)})}else n(null)}reloadResources(e,t,n){let r=st();return typeof e==`function`&&(n=e,e=void 0),typeof t==`function`&&(n=t,t=void 0),e||=this.languages,t||=this.options.ns,n||=$t,this.services.backendConnector.reload(e,t,e=>{r.resolve(),n(e)}),r}use(e){if(!e)throw Error(`You are passing an undefined module! Please check the object you are passing to i18next.use()`);if(!e.type)throw Error(`You are passing a wrong module! Please check the object you are passing to i18next.use()`);return e.type===`backend`&&(this.modules.backend=e),(e.type===`logger`||e.log&&e.warn&&e.error)&&(this.modules.logger=e),e.type===`languageDetector`&&(this.modules.languageDetector=e),e.type===`i18nFormat`&&(this.modules.i18nFormat=e),e.type===`postProcessor`&&Mt.addPostProcessor(e),e.type===`formatter`&&(this.modules.formatter=e),e.type===`3rdParty`&&this.modules.external.push(e),this}setResolvedLanguage(e){if(!(!e||!this.languages)&&![`cimode`,`dev`].includes(e)){for(let e=0;e{this.language=e,this.languages=this.services.languageUtils.toResolveHierarchy(e),this.resolvedLanguage=void 0,this.setResolvedLanguage(e)},i=(i,a)=>{a?this.isLanguageChangingTo===e&&(r(a),this.translator.changeLanguage(a),this.isLanguageChangingTo=void 0,this.emit(`languageChanged`,a),this.logger.log(`languageChanged`,a)):this.isLanguageChangingTo=void 0,n.resolve((...e)=>this.t(...e)),t&&t(i,(...e)=>this.t(...e))},a=t=>{!e&&!t&&this.services.languageDetector&&(t=[]);let n=I(t)?t:t&&t[0],a=this.store.hasLanguageSomeTranslations(n)?n:this.services.languageUtils.getBestMatchFromCodes(I(t)?[t]:t);a&&(this.language||r(a),this.translator.language||this.translator.changeLanguage(a),this.services.languageDetector?.cacheUserLanguage?.(a)),this.loadResources(a,e=>{i(e,a)})};return!e&&this.services.languageDetector&&!this.services.languageDetector.async?a(this.services.languageDetector.detect()):!e&&this.services.languageDetector&&this.services.languageDetector.async?this.services.languageDetector.detect.length===0?this.services.languageDetector.detect().then(a):this.services.languageDetector.detect(a):a(e),n}getFixedT(e,t,n,r){let i=r?.scopeNs,a=(e,t,...r)=>{let o;o=typeof t==`object`?{...t}:this.options.overloadTranslationOptionHandler([e,t].concat(r)),o.lng=o.lng||a.lng,o.lngs=o.lngs||a.lngs;let s=o.ns!==void 0&&o.ns!==null;o.ns=o.ns||a.ns,o.keyPrefix!==``&&(o.keyPrefix=o.keyPrefix||n||a.keyPrefix);let c={...this.options,...o};Array.isArray(i)&&!s&&(c.ns=i),typeof o.keyPrefix==`function`&&(o.keyPrefix=Ft(o.keyPrefix,c));let l=this.options.keySeparator||`.`,u;return o.keyPrefix&&Array.isArray(e)?u=e.map(e=>(typeof e==`function`&&(e=Ft(e,c)),`${o.keyPrefix}${l}${e}`)):(typeof e==`function`&&(e=Ft(e,c)),u=o.keyPrefix?`${o.keyPrefix}${l}${e}`:e),this.t(u,o)};return I(e)?a.lng=e:a.lngs=e,a.ns=t,a.keyPrefix=n,a}t(...e){return this.translator?.translate(...e)}exists(...e){return this.translator?.exists(...e)}setDefaultNamespace(e){this.options.defaultNS=e}hasLoadedNamespace(e,t={}){if(!this.isInitialized)return this.logger.warn(`hasLoadedNamespace: i18next was not initialized`,this.languages),!1;if(!this.languages||!this.languages.length)return this.logger.warn(`hasLoadedNamespace: i18n.languages were undefined or empty`,this.languages),!1;let n=t.lng||this.resolvedLanguage||this.languages[0],r=this.options?this.options.fallbackLng:!1,i=this.languages[this.languages.length-1];if(n.toLowerCase()===`cimode`)return!0;let a=(e,t)=>{let n=this.services.backendConnector.state[`${e}|${t}`];return n===-1||n===0||n===2};if(t.precheck){let e=t.precheck(this,a);if(e!==void 0)return e}return!!(this.hasResourceBundle(n,e)||!this.services.backendConnector.backend||this.options.resources&&!this.options.partialBundledLanguages||a(n,e)&&(!r||a(i,e)))}loadNamespaces(e,t){let n=st();return this.options.ns?(I(e)&&(e=[e]),e.forEach(e=>{this.options.ns.includes(e)||this.options.ns.push(e)}),this.loadResources(e=>{n.resolve(),t&&t(e)}),n):(t&&t(),Promise.resolve())}loadLanguages(e,t){let n=st();I(e)&&(e=[e]);let r=this.options.preload||[],i=e.filter(e=>!r.includes(e)&&this.services.languageUtils.isSupportedCode(e));return i.length?(this.options.preload=r.concat(i),this.loadResources(e=>{n.resolve(),t&&t(e)}),n):(t&&t(),Promise.resolve())}dir(e){if(e||=this.resolvedLanguage||(this.languages?.length>0?this.languages[0]:this.language),!e)return`rtl`;try{let t=new Intl.Locale(e);if(t&&t.getTextInfo){let e=t.getTextInfo();if(e&&e.direction)return e.direction}}catch{}let t=`ar.shu.sqr.ssh.xaa.yhd.yud.aao.abh.abv.acm.acq.acw.acx.acy.adf.ads.aeb.aec.afb.ajp.apc.apd.arb.arq.ars.ary.arz.auz.avl.ayh.ayl.ayn.ayp.bbz.pga.he.iw.ps.pbt.pbu.pst.prp.prd.ug.ur.ydd.yds.yih.ji.yi.hbo.men.xmn.fa.jpr.peo.pes.prs.dv.sam.ckb`.split(`.`),n=this.services?.languageUtils||new Rt(Zt());return e.toLowerCase().indexOf(`-latn`)>1?`ltr`:t.includes(n.getLanguagePartFromCode(e))||e.toLowerCase().indexOf(`-arab`)>1?`rtl`:`ltr`}static createInstance(t={},n){let r=new e(t,n);return r.createInstance=e.createInstance,r}cloneInstance(t={},n=$t){let r=t.forkResourceStore;r&&delete t.forkResourceStore;let i={...this.options,...t,isClone:!0},a=new e(i);if((t.debug!==void 0||t.prefix!==void 0)&&(a.logger=a.logger.clone(t)),[`store`,`services`,`language`].forEach(e=>{a[e]=this[e]}),a.services={...this.services},a.services.utils={hasLoadedNamespace:a.hasLoadedNamespace.bind(a)},r&&(a.store=new jt(Object.keys(this.store.data).reduce((e,t)=>(e[t]={...this.store.data[t]},e[t]=Object.keys(e[t]).reduce((n,r)=>(n[r]={...e[t][r]},n),e[t]),e),{}),i),a.services.resourceStore=a.store),t.interpolation){let e={...Zt().interpolation,...this.options.interpolation,...t.interpolation},n={...i,interpolation:e};a.services.interpolator=new Wt(n)}return a.translator=new Lt(a.services,i),a.translator.on(`*`,(e,...t)=>{a.emit(e,...t)}),a.init(i,n),a.translator.options=i,a.translator.backendConnector.services.utils={hasLoadedNamespace:a.hasLoadedNamespace.bind(a)},a}toJSON(){return{options:this.options,store:this.store,language:this.language,languages:this.languages,resolvedLanguage:this.resolvedLanguage}}}.createInstance();tn.createInstance,tn.dir,tn.init,tn.loadResources,tn.reloadResources,tn.use,tn.changeLanguage,tn.getFixedT,tn.t,tn.exists,tn.setDefaultNamespace,tn.hasLoadedNamespace,tn.loadNamespaces,tn.loadLanguages;var nn=(e,t,n,r)=>{let i=[n,{code:t,...r||{}}];if(e?.services?.logger?.forward)return e.services.logger.forward(i,`warn`,`react-i18next::`,!0);un(i[0])&&(i[0]=`react-i18next:: ${i[0]}`),e?.services?.logger?.warn?e.services.logger.warn(...i):console?.warn&&console.warn(...i)},rn={},an=(e,t,n,r)=>{un(n)&&rn[n]||(un(n)&&(rn[n]=new Date),nn(e,t,n,r))},on=(e,t)=>()=>{if(e.isInitialized)t();else{let n=()=>{setTimeout(()=>{e.off(`initialized`,n)},0),t()};e.on(`initialized`,n)}},sn=(e,t,n)=>{e.loadNamespaces(t,on(e,n))},cn=(e,t,n,r)=>{if(un(n)&&(n=[n]),e.options.preload&&e.options.preload.indexOf(t)>-1)return sn(e,n,r);n.forEach(t=>{e.options.ns.indexOf(t)<0&&e.options.ns.push(t)}),e.loadLanguages(t,on(e,r))},ln=(e,t,n={})=>!t.languages||!t.languages.length?(an(t,`NO_LANGUAGES`,`i18n.languages were undefined or empty`,{languages:t.languages}),!0):t.hasLoadedNamespace(e,{lng:n.lng,precheck:(t,r)=>{if(n.bindI18n&&n.bindI18n.indexOf(`languageChanging`)>-1&&t.services.backendConnector.backend&&t.isLanguageChangingTo&&!r(t.isLanguageChangingTo,e))return!1}}),un=e=>typeof e==`string`,dn=e=>typeof e==`object`&&!!e,fn=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,pn={"&":`&`,"&":`&`,"<":`<`,"<":`<`,">":`>`,">":`>`,"'":`'`,"'":`'`,""":`"`,""":`"`," ":` `," ":` `,"©":`©`,"©":`©`,"®":`®`,"®":`®`,"…":`…`,"…":`…`,"/":`/`,"/":`/`},mn=e=>pn[e],hn={bindI18n:`languageChanged`,bindI18nStore:``,transEmptyNodeValue:``,transSupportBasicHtmlNodes:!0,transWrapTextNodes:``,transKeepBasicHtmlNodesFor:[`br`,`strong`,`i`,`p`],useSuspense:!0,unescape:e=>e.replace(fn,mn),transDefaultProps:void 0},gn=()=>hn,_n,vn=()=>_n,yn=(0,p.createContext)(),bn=class{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach(e=>{this.usedNamespaces[e]||(this.usedNamespaces[e]=!0)})}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}},xn=o((e=>{var t=f();function n(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var r=typeof Object.is==`function`?Object.is:n,i=t.useState,a=t.useEffect,o=t.useLayoutEffect,s=t.useDebugValue;function c(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),c=r[0].inst,u=r[1];return o(function(){c.value=n,c.getSnapshot=t,l(c)&&u({inst:c})},[e,n,t]),a(function(){return l(c)&&u({inst:c}),e(function(){l(c)&&u({inst:c})})},[e]),s(n),n}function l(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!r(e,n)}catch{return!0}}function u(e,t){return t()}var d=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?u:c;e.useSyncExternalStore=t.useSyncExternalStore===void 0?d:t.useSyncExternalStore})),Sn=o(((e,t)=>{t.exports=xn()}))(),Cn={t:(e,t)=>{if(un(t))return t;if(dn(t)&&un(t.defaultValue))return t.defaultValue;if(typeof e==`function`)return``;if(Array.isArray(e)){let t=e[e.length-1];return typeof t==`function`?``:t}return e},ready:!1},wn=()=>()=>{},Tn=(e,t={})=>{let{i18n:n}=t,{i18n:r,defaultNS:i}=(0,p.useContext)(yn)||{},a=n||r||vn();a&&!a.reportNamespaces&&(a.reportNamespaces=new bn),a||an(a,`NO_I18NEXT_INSTANCE`,`useTranslation: You will need to pass in an i18next instance by using initReactI18next or by passing it via props or context. In monorepo setups, make sure there is only one instance of react-i18next.`);let o=(0,p.useMemo)(()=>({...gn(),...a?.options?.react,...t}),[a,t]),{useSuspense:s,keyPrefix:c}=o,l=e||i||a?.options?.defaultNS,u=un(l)?[l]:l||[`translation`],d=(0,p.useMemo)(()=>u,u);a?.reportNamespaces?.addUsedNamespaces?.(d);let f=(0,p.useRef)(0),m=(0,p.useCallback)(e=>{if(!a)return wn;let{bindI18n:t,bindI18nStore:n}=o,r=()=>{f.current+=1,e()};return t&&a.on(t,r),n&&a.store.on(n,r),()=>{t&&t.split(` `).forEach(e=>a.off(e,r)),n&&n.split(` `).forEach(e=>a.store.off(e,r))}},[a,o]),h=(0,p.useRef)(),g=(0,p.useCallback)(()=>{if(!a)return Cn;let e=!!(a.isInitialized||a.initializedStoreOnce)&&d.every(e=>ln(e,a,o)),n=t.lng||a.language,r=f.current,i=h.current;if(i&&i.ready===e&&i.lng===n&&i.keyPrefix===c&&i.revision===r)return i;let s={t:a.getFixedT(n,o.nsMode===`fallback`?d:d[0],c,{scopeNs:d}),ready:e,lng:n,keyPrefix:c,revision:r};return h.current=s,s},[a,d,c,o,t.lng]),[_,v]=(0,p.useState)(0),{t:y,ready:b}=(0,Sn.useSyncExternalStore)(m,g,g);(0,p.useEffect)(()=>{if(a&&!b&&!s){let e=()=>v(e=>e+1);t.lng?cn(a,t.lng,d,e):sn(a,d,e)}},[a,t.lng,d,b,s,_]);let x=a||{},S=(0,p.useRef)(null),C=(0,p.useRef)(),w=e=>{let t=Object.getOwnPropertyDescriptors(e);t.__original&&delete t.__original;let n=Object.create(Object.getPrototypeOf(e),t);if(!Object.prototype.hasOwnProperty.call(n,`__original`))try{Object.defineProperty(n,"__original",{value:e,writable:!1,enumerable:!1,configurable:!1})}catch{}return n},T=(0,p.useMemo)(()=>{let e=x,t=e?.language,n=e;e&&(S.current&&S.current.__original===e&&C.current===t?n=S.current:(n=w(e),S.current=n,C.current=t));let r=!b&&!s?(...e)=>(an(a,`USE_T_BEFORE_READY`,`useTranslation: t was called before ready. When using useSuspense: false, make sure to check the ready flag before using t.`),y(...e)):y,i=[r,n,b];return i.t=r,i.i18n=n,i.ready=b,i},[y,x,b,x.resolvedLanguage,x.language,x.languages]);if(a&&s&&!b){let e=!1;try{e=!1}catch{}throw e&&an(a,`SUSPENDED_WHILE_LOADING`,`useTranslation: suspended while translations are loading (useSuspense is true by default). Add a boundary above this component, or set react.useSuspense: false in the i18next init options. https://react.i18next.com/latest/usetranslation-hook`),new Promise(e=>{let n=()=>e();t.lng?cn(a,t.lng,d,n):sn(a,d,n)})}return T};function En({i18n:e,defaultNS:t,children:n}){let r=(0,p.useMemo)(()=>({i18n:e,defaultNS:t}),[e,t]);return(0,p.createElement)(yn.Provider,{value:r},n)}var Dn=[`getStatus`,`prepareSync`,`applySync`,`prepareSwitch`,`applySwitch`,`listBackups`,`prepareRestore`,`applyRestore`,`pruneBackups`,`listHistory`,`getHistorySession`,`startWatch`,`stopWatch`,`getWatchStatus`,`getDiagnostics`],On=[`INVALID_INPUT`,`PROFILE_CHANGED`,`STORAGE_CHANGED`,`PLAN_STALE`,`PLAN_EXPIRED`,`STALE_STATE`,`CODEX_HOME_NOT_FOUND`,`STATE_DB_NOT_FOUND`,`SQLITE_UNSUPPORTED_PATH`,`SQLITE_BUSY`,`SQLITE_UNREADABLE`,`ROLLOUT_LOCKED`,`ROLLOUT_CHANGED`,`PENDING_TRANSACTION`,`BACKUP_FAILED`,`SYNC_FAILED_ROLLED_BACK`,`RECOVERY_REQUIRED`,`RESTORE_VALIDATION_FAILED`,`PERMISSION_DENIED`,`OPERATION_BUSY`,`LOCK_UNVERIFIABLE`,`OPERATION_CANCELLED`,`CORE_RUNTIME_CRASHED`,`PROTOCOL_VERSION_MISMATCH`,`INTERNAL_ERROR`],kn=Object.freeze({INVALID_INPUT:`The command input is invalid.`,PROFILE_CHANGED:`The selected profile changed. Prepare the operation again.`,STORAGE_CHANGED:`The resolved storage changed. Prepare the operation again.`,PLAN_STALE:`The prepared operation is stale. Prepare it again.`,PLAN_EXPIRED:`The prepared operation expired. Prepare it again.`,STALE_STATE:`The protected state changed. Prepare the operation again.`,CODEX_HOME_NOT_FOUND:`The selected Codex Home was not found.`,STATE_DB_NOT_FOUND:`The selected state database was not found.`,SQLITE_UNSUPPORTED_PATH:`The selected SQLite path is not supported by this runtime.`,SQLITE_BUSY:`The state database is busy. Close Codex processes and retry.`,SQLITE_UNREADABLE:`The state database is unreadable or malformed.`,ROLLOUT_LOCKED:`One or more rollout files are locked.`,ROLLOUT_CHANGED:`One or more rollout files changed during the operation.`,PENDING_TRANSACTION:`An unfinished transaction must be resolved before another write.`,BACKUP_FAILED:`The required backup could not be completed.`,SYNC_FAILED_ROLLED_BACK:`The operation failed and its changes were rolled back.`,RECOVERY_REQUIRED:`The operation requires explicit recovery.`,RESTORE_VALIDATION_FAILED:`The selected backup or restore target failed validation.`,PERMISSION_DENIED:`The operation does not have permission to access a required resource.`,OPERATION_BUSY:`Another write operation is using the protected resource.`,LOCK_UNVERIFIABLE:`The lock owner or protected resource identity cannot be verified.`,OPERATION_CANCELLED:`The operation was cancelled.`,CORE_RUNTIME_CRASHED:`The Core runtime stopped unexpectedly.`,PROTOCOL_VERSION_MISMATCH:`The client and Core protocol versions are incompatible.`,INTERNAL_ERROR:`An internal error occurred.`}),An=new Set([`PROFILE_CHANGED`,`STORAGE_CHANGED`,`PLAN_STALE`,`PLAN_EXPIRED`,`STALE_STATE`,`SQLITE_BUSY`,`ROLLOUT_LOCKED`,`ROLLOUT_CHANGED`,`OPERATION_BUSY`]),jn=new Set([`PENDING_TRANSACTION`,`RECOVERY_REQUIRED`]),Mn=new Set([`codex-home`,`state-db`]),Nn=new Set([`profile`,`config`,`storage`,`rollout`,`state-db`,`windows-wsl-unc`]),Pn=new Set([`ENOENT`,`EACCES`,`EPERM`,`EIO`,`EBUSY`,`SQLITE_BUSY`,`SQLITE_LOCKED`,`SQLITE_CORRUPT`,`SQLITE_NOTADB`,`ERR_SQLITE_ERROR`]),Fn=new Set([`cli`,`config`,`env`,`default`]),In=new Set([`sync`,`switch`,`restore`,`prune-backups`,`watch`]),Ln=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,Rn=new Set(On);function zn(e){return typeof e==`object`&&e&&!Array.isArray(e)?e:null}function Bn(e){if(typeof e!=`object`||!e||Array.isArray(e))return null;let t=Object.getPrototypeOf(e);return t===Object.prototype||t===null?e:null}function Vn(e,t){if(!e)return;let n=Object.getOwnPropertyDescriptor(e,t);return n&&`value`in n?n.value:void 0}function Hn(e){return e===`OPERATION_CANCELLED`?`info`:e===`CORE_RUNTIME_CRASHED`||e===`INTERNAL_ERROR`?`fatal`:An.has(e)?`warning`:`error`}function Un(e){let t=Bn(e);if(!t)return;let n={},r=Vn(t,`busyScope`),i=Vn(t,`lockScope`),a=Vn(t,`causeCode`),o=Vn(t,`reason`),s=Vn(t,`missing`),c=Vn(t,`sqliteHomeSource`),l=Vn(t,`operationKind`);Mn.has(String(r))&&(n.busyScope=String(r)),Mn.has(String(i))&&(n.lockScope=String(i)),Pn.has(String(a))&&(n.causeCode=String(a)),Nn.has(String(o))&&(n.reason=String(o)),(s===`config.toml`||s===`state_5.sqlite`)&&(n.missing=s),Fn.has(String(c))&&(n.sqliteHomeSource=String(c));for(let e of[`sqlitePrimaryCode`,`sqliteExtendedCode`]){let r=Vn(t,e);Number.isInteger(r)&&Number(r)>=0&&Number(r)<=65535&&(n[e]=Number(r))}return In.has(String(l))&&(n.operationKind=String(l)),Object.keys(n).length>0?n:void 0}function Wn(e,t={}){if(Rn.has(e)||(e=`INTERNAL_ERROR`),e===`INTERNAL_ERROR`)return{code:e,message:kn[e],severity:`fatal`,retryable:!1,recoveryRequired:!1};let n=Un(t.details);if(e===`OPERATION_BUSY`&&n?.busyScope===void 0||e===`LOCK_UNVERIFIABLE`&&n?.lockScope===void 0)return Wn(`INTERNAL_ERROR`);let r=typeof t.operationId==`string`&&Ln.test(t.operationId)?t.operationId:void 0;return{code:e,message:kn[e],severity:Hn(e),retryable:!0,recoveryRequired:jn.has(e),...r?{operationId:r}:{},...n?{details:n}:{}}}function Gn(e){let t=zn(e),n=Vn(t,`code`);return Wn(typeof n==`string`&&Rn.has(n)?n:`INTERNAL_ERROR`,{operationId:Vn(t,`operationId`),details:Vn(t,`details`)})}function Kn(e){let t=Bn(e);if(!t)return!1;let n=Vn(t,`code`);if(typeof n!=`string`||!Rn.has(n))return!1;let r=Gn(t),i=Object.keys(t).sort(),a=Object.keys(r).sort();if(i.length!==a.length||i.some((e,t)=>e!==a[t]))return!1;for(let e of a)if(e!==`details`&&Vn(t,e)!==r[e])return!1;let o=Bn(Vn(t,`details`)),s=r.details;if(s===void 0)return o===null;if(!o)return!1;let c=Object.keys(o).sort(),l=Object.keys(s).sort();return c.length===l.length&&c.every((e,t)=>e===l[t]&&Vn(o,e)===s[e])}var qn=new Set(Dn);new Set(On);function L(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function R(e){return typeof e==`string`&&e.length>0}function Jn(e,t){let n=new Set(t);return Object.keys(e).every(e=>n.has(e))}function Yn(e){if(!L(e)||!Jn(e,[`profileId`,`profileRevision`])||typeof e.profileId!=`string`||!/^[A-Za-z0-9._-]{1,80}$/.test(e.profileId)||e.profileRevision!==void 0&&(!R(e.profileRevision)||e.profileRevision.length>512))throw new z(`INVALID_INPUT`,`Invalid profile selector.`)}function Xn(e,t){if(!L(e)||!Jn(e,[`profile`,...t]))throw new z(`INVALID_INPUT`,`Invalid Core method input.`);Yn(e.profile)}var z=class extends Error{code;constructor(e,t){super(t),this.name=`ContractValidationError`,this.code=e}};function Zn(e){if(e!==1)throw new z(`PROTOCOL_VERSION_MISMATCH`,`Unsupported Core protocol version: ${String(e)}.`)}function Qn(e){if(!L(e)||Object.keys(e).sort().join(`,`)!==`planId,schemaVersion`||e.schemaVersion!==1||!R(e.planId))throw new z(`INVALID_INPUT`,`Apply accepts exactly { schemaVersion: 1, planId }.`)}function $n(e,t){switch(e){case`applySync`:case`applySwitch`:case`applyRestore`:Qn(t);return;case`getStatus`:case`listBackups`:case`getDiagnostics`:Xn(t,[]);return;case`prepareSync`:if(Xn(t,[`keepCount`]),t.keepCount!==void 0&&(!Number.isSafeInteger(t.keepCount)||Number(t.keepCount)<1))throw new z(`INVALID_INPUT`,`Invalid Sync retention count.`);return;case`prepareSwitch`:if(Xn(t,[`provider`,`modelMode`,`model`,`keepCount`]),!R(t.provider)||![`provider-default`,`keep-root-model`,`explicit`].includes(String(t.modelMode))||t.modelMode===`explicit`&&!R(t.model)||t.modelMode!==`explicit`&&t.model!==void 0||t.keepCount!==void 0&&(!Number.isSafeInteger(t.keepCount)||Number(t.keepCount)<1))throw new z(`INVALID_INPUT`,`Invalid Switch Provider input.`);return;case`prepareRestore`:if(Xn(t,[`backupId`,`restoreConfig`,`restoreDatabase`,`restoreSessions`,`allowSqliteHomeRelocation`,`relocationTargetProfileId`]),!R(t.backupId)||typeof t.restoreConfig!=`boolean`||typeof t.restoreDatabase!=`boolean`||typeof t.restoreSessions!=`boolean`||t.allowSqliteHomeRelocation!==void 0&&typeof t.allowSqliteHomeRelocation!=`boolean`||t.relocationTargetProfileId!==void 0&&(typeof t.relocationTargetProfileId!=`string`||!/^[A-Za-z0-9._-]{1,80}$/.test(t.relocationTargetProfileId))||t.allowSqliteHomeRelocation===!0&&(t.restoreConfig!==!1||t.relocationTargetProfileId===void 0)||t.relocationTargetProfileId!==void 0&&t.allowSqliteHomeRelocation!==!0)throw new z(`INVALID_INPUT`,`Invalid Restore input.`);return;case`pruneBackups`:if(Xn(t,[`keepCount`]),!Number.isSafeInteger(t.keepCount)||Number(t.keepCount)<0)throw new z(`INVALID_INPUT`,`Invalid Prune retention count.`);return;case`listHistory`:if(Xn(t,[`page`,`pageSize`,`query`,`project`,`provider`,`archived`]),t.page!==void 0&&(!Number.isSafeInteger(t.page)||Number(t.page)<1)||t.pageSize!==void 0&&(!Number.isSafeInteger(t.pageSize)||Number(t.pageSize)<10||Number(t.pageSize)>100)||[`query`,`project`,`provider`].some(e=>t[e]!==void 0&&typeof t[e]!=`string`)||t.archived!==void 0&&![`all`,`active`,`archived`].includes(String(t.archived)))throw new z(`INVALID_INPUT`,`Invalid History list input.`);return;case`getHistorySession`:if(Xn(t,[`sessionId`,`messageLimit`]),!R(t.sessionId)||t.messageLimit!==void 0&&(!Number.isSafeInteger(t.messageLimit)||Number(t.messageLimit)<1||Number(t.messageLimit)>200))throw new z(`INVALID_INPUT`,`Invalid History detail input.`);return;case`startWatch`:if(Xn(t,[`includeStateDb`,`debounceMs`,`once`]),t.includeStateDb!==void 0&&typeof t.includeStateDb!=`boolean`||t.once!==void 0&&typeof t.once!=`boolean`||t.debounceMs!==void 0&&(!Number.isSafeInteger(t.debounceMs)||Number(t.debounceMs)<0))throw new z(`INVALID_INPUT`,`Invalid Watch input.`);return;case`stopWatch`:if(!L(t)||!Jn(t,[`watchId`])||!R(t.watchId))throw new z(`INVALID_INPUT`,`Invalid Watch reference.`);return;case`getWatchStatus`:if(!L(t)||!Jn(t,[`watchId`])||t.watchId!==void 0&&!R(t.watchId))throw new z(`INVALID_INPUT`,`Invalid Watch status input.`);return;default:throw new z(`INVALID_INPUT`,`Unknown Core method input.`)}}function er(e){if(!Kn(e))throw new z(`INVALID_INPUT`,`Invalid public CoreErrorDto.`)}function tr(e){if(!L(e))throw new z(`INVALID_INPUT`,`Core request envelope must be an object.`);let t=new Set([`protocolVersion`,`requestId`,`operationId`,`method`,`payload`]);if(Object.keys(e).some(e=>!t.has(e)))throw new z(`INVALID_INPUT`,`Core request envelope has unknown fields.`);if(Zn(e.protocolVersion),!R(e.requestId)||!R(e.method)||!qn.has(e.method)||!L(e.payload))throw new z(`INVALID_INPUT`,`Invalid Core request envelope.`);if(e.operationId!==void 0&&!R(e.operationId))throw new z(`INVALID_INPUT`,`Invalid Core request operationId.`);$n(e.method,e.payload)}function nr(e,t){if(!L(e))throw new z(`INVALID_INPUT`,`Core response envelope must be an object.`);let n=e.ok===!0?new Set([`protocolVersion`,`requestId`,`operationId`,`ok`,`result`]):new Set([`protocolVersion`,`requestId`,`operationId`,`ok`,`error`]);if(Object.keys(e).some(e=>!n.has(e)))throw new z(`INVALID_INPUT`,`Core response envelope has unknown fields.`);if(Zn(e.protocolVersion),!R(e.requestId)||t!==void 0&&e.requestId!==t||typeof e.ok!=`boolean`)throw new z(`INVALID_INPUT`,`Invalid Core response envelope.`);if(e.operationId!==void 0&&!R(e.operationId))throw new z(`INVALID_INPUT`,`Invalid Core response operationId.`);if(e.ok){if(!(`result`in e)||`error`in e)throw new z(`INVALID_INPUT`,`Invalid successful Core response.`)}else{if(!(`error`in e)||`result`in e)throw new z(`INVALID_INPUT`,`Invalid failed Core response.`);er(e.error)}}function rr(e,t){if(!L(e)||e.schemaVersion!==1)throw new z(`INVALID_INPUT`,`Invalid ${t}.`);return e}function ir(e,t){if(!Array.isArray(e)||e.some(e=>typeof e!=`string`))throw new z(`INVALID_INPUT`,`Invalid ${t}.`)}function ar(e){return Number.isSafeInteger(e)&&Number(e)>=0}function or(e){return e===null||typeof e==`string`}function sr(e,t=0){return t>16?!1:e===null||typeof e==`string`||typeof e==`boolean`?!0:typeof e==`number`?Number.isFinite(e):Array.isArray(e)?e.every(e=>sr(e,t+1)):L(e)?Object.values(e).every(e=>sr(e,t+1)):!1}function cr(e){return L(e)?Object.values(e).every(e=>L(e)&&Object.values(e).every(ar)):!1}var lr=new Set([`prepared`,`applying`,`applied`,`skipped`,`committing`,`committed-pending-ack`,`rollback-pending`,`rollingBack`,`recovery-required`,`recoveryRequired`,`unknown`]);function ur(e){return typeof e==`string`&&/^[A-Za-z0-9._()-]{1,200}$/.test(e)}function dr(e){return typeof e==`string`&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(e)}function fr(e){return!L(e)||Object.keys(e).length>512?!1:Object.entries(e).every(([e,t])=>ur(e)&&ar(t))}function pr(e,t=!1){return!L(e)||!Jn(e,t?[`sessions`,`archived_sessions`,`unreadable`]:[`sessions`,`archived_sessions`])||!(`sessions`in e)||!(`archived_sessions`in e)||!fr(e.sessions)||!fr(e.archived_sessions)?!1:!t||e.unreadable===void 0||e.unreadable===!0}function mr(e){return L(e)&&Object.keys(e).sort().join(`,`)===`operationId,operationKind,preRestoreSnapshotId,sourceBackupId,state`&&(e.operationId===null||dr(e.operationId))&&[`sync`,`switch`,`restore`].includes(String(e.operationKind))&&lr.has(String(e.state))&&(e.sourceBackupId===null||ur(e.sourceBackupId))&&(e.preRestoreSnapshotId===null||ur(e.preRestoreSnapshotId))}function hr(e){return e===null?!0:!L(e)||!Jn(e,[`operationId`,`operation`,`actor`,`startedAt`,`busyScope`,`lockState`,`errorCode`])?!1:(e.operationId===void 0||dr(e.operationId))&&(e.operation===void 0||[`sync`,`switch`,`restore`,`prune`,`watch`,`unknown`].includes(String(e.operation)))&&(e.actor===void 0||[`manual`,`watch`,`external`].includes(String(e.actor)))&&(e.startedAt===void 0||R(e.startedAt)&&e.startedAt.length<=64)&&(e.busyScope===void 0||[`codex-home`,`state-db`].includes(String(e.busyScope)))&&(e.lockState===void 0||ur(e.lockState)&&e.lockState.length<=80)&&(e.errorCode===void 0||typeof e.errorCode==`string`&&/^[A-Z0-9_]{1,80}$/.test(e.errorCode))}function gr(e){let t=rr(e,`DiagnosticsSnapshot`),n=L(t.runtime)?t.runtime:null,r=L(t.storage)?t.storage:null,i=L(t.provider)?t.provider:null,a=L(t.safety)?t.safety:null;if(!(Jn(t,[`schemaVersion`,`generatedAt`,`runtime`,`storage`,`provider`,`safety`])&&R(t.generatedAt)&&t.generatedAt.length<=64&&n!==null&&Object.keys(n).sort().join(`,`)===`arch,node,platform`&&[n.node,n.platform,n.arch].every(e=>typeof e==`string`&&/^[A-Za-z0-9._-]{1,80}$/.test(e))&&r!==null&&Object.keys(r).sort().join(`,`)===`sqliteHomeSource,sqliteSupported,stateDbFound`&&[`cli`,`config`,`env`,`default`,`unknown`].includes(String(r.sqliteHomeSource))&&typeof r.stateDbFound==`boolean`&&typeof r.sqliteSupported==`boolean`&&i!==null&&Object.keys(i).sort().join(`,`)===`configured,current,implicit,rolloutCounts,sqliteCounts`&&ur(i.current)&&typeof i.implicit==`boolean`&&Array.isArray(i.configured)&&i.configured.length<=256&&i.configured.every(ur)&&pr(i.rolloutCounts)&&(i.sqliteCounts===null||pr(i.sqliteCounts,!0))&&a!==null&&Jn(a,[`storageRevision`,`pendingRecovery`,`pendingTransactions`,`operationInProgress`,`rolloutScanComplete`,`lockedRolloutCount`,`projectThreadVisibilityAvailable`])&&(a.storageRevision===void 0||typeof a.storageRevision==`string`&&/^[A-Za-z0-9_-]{1,256}$/.test(a.storageRevision))&&typeof a.pendingRecovery==`boolean`&&Array.isArray(a.pendingTransactions)&&a.pendingTransactions.length<=256&&a.pendingTransactions.every(mr)&&hr(a.operationInProgress)&&typeof a.rolloutScanComplete==`boolean`&&ar(a.lockedRolloutCount)&&typeof a.projectThreadVisibilityAvailable==`boolean`))throw new z(`INVALID_INPUT`,`Invalid DiagnosticsSnapshot.`)}function _r(e){return L(e)?R(e.id)&&typeof e.title==`string`&&!(`cwd`in e)&&R(e.provider)&&typeof e.archived==`boolean`&&R(e.updatedAt)&&ar(e.messageCount)&&(e.model===void 0||or(e.model))&&(e.createdAt===void 0||R(e.createdAt)):!1}function vr(e){let t=rr(e,`WatchSnapshot`);if(!R(t.watchId)||![`running`,`stopping`,`stopped`].includes(String(t.status))||!R(t.startedAt)||!or(t.stoppedAt)||!or(t.stopReason)||typeof t.includeStateDb!=`boolean`||typeof t.once!=`boolean`)throw new z(`INVALID_INPUT`,`Invalid WatchSnapshot.`)}function yr(e,t){switch(e){case`getStatus`:{let e=rr(t,`StatusSnapshot`),n=L(e.profile)?e.profile:null;if(!R(e.snapshotAt)||!R(e.storageRevision)||!n||!R(n.id)||!R(n.revision)||!R(e.currentProvider)||!cr(e.rolloutCounts)||e.modelCounts!==void 0&&!cr(e.modelCounts)||!(`sqliteCounts`in e)||!sr(e.sqliteCounts)||`codexHome`in e||`sqliteHome`in e||!R(e.codexHomeSource)||!R(e.sqliteHomeSource)||!L(e.backupSummary)||!ar(e.backupSummary.count)||!ar(e.backupSummary.totalBytes)||typeof e.pendingRecovery!=`boolean`||!Array.isArray(e.pendingTransactions)||e.pendingTransactions.some(e=>!L(e)||!sr(e))||!(e.operationInProgress===null||L(e.operationInProgress)&&sr(e.operationInProgress))||typeof e.rolloutScanComplete!=`boolean`||!Array.isArray(e.lockedRolloutFiles)||e.lockedRolloutFiles.some(e=>typeof e!=`string`)||e.currentModel!==void 0&&!or(e.currentModel))throw new z(`INVALID_INPUT`,`Invalid StatusSnapshot.`);return}case`prepareSync`:case`prepareSwitch`:case`prepareRestore`:{let e=rr(t,`PlanSummary`);if(!R(e.planId)||![`sync`,`switch`,`restore`].includes(String(e.operation))||!R(e.createdAt)||!R(e.expiresAt)||!L(e.profile)||!R(e.profile.id)||!R(e.profile.revision)||!R(e.storageRevision)||!R(e.configRevision)||!R(e.rolloutRevision)||!R(e.stateDbRevision)||e.backupRevision!==void 0&&!R(e.backupRevision)||!L(e.target)||!sr(e.target)||!L(e.impact)||!sr(e.impact)||!Array.isArray(e.warnings)||e.warnings.some(e=>typeof e!=`string`)||typeof e.requiresConfirmation!=`boolean`)throw new z(`INVALID_INPUT`,`Invalid PlanSummary.`);return}case`applySync`:case`applySwitch`:case`applyRestore`:{let e=rr(t,`OperationResult`);if(!R(e.operationId)||![`sync`,`switch`,`restore`].includes(String(e.operation))||![`completed`,`partial`,`failed_rolled_back`,`recovery_required`,`cancelled`,`stale`].includes(String(e.outcome))||!(e.backup===null||L(e.backup)&&R(e.backup.backupId)))throw new z(`INVALID_INPUT`,`Invalid OperationResult.`);if(ir(e.warnings,`OperationResult warnings`),!(`result`in e)||!sr(e.result))throw new z(`INVALID_INPUT`,`OperationResult result is required.`);return}case`listBackups`:if(!L(t)||!Array.isArray(t.backups)||t.backups.some(e=>{let t=L(e)?e:null;return!t||!R(t.backupId)||!ar(t.sizeBytes)||!L(t.metadata)||!sr(t.metadata)||t.createdAt!==void 0&&!R(t.createdAt)}))throw new z(`INVALID_INPUT`,`Invalid BackupList.`);return;case`pruneBackups`:if(!L(t)||!ar(t.deletedCount)||!ar(t.remainingCount)||!ar(t.freedBytes))throw new z(`INVALID_INPUT`,`Invalid PruneBackupsResult.`);return;case`listHistory`:if(!L(t)||!Number.isSafeInteger(t.page)||Number(t.page)<1||!Number.isSafeInteger(t.pageSize)||Number(t.pageSize)<1||!ar(t.total)||typeof t.hasNextPage!=`boolean`||!Array.isArray(t.sessions)||t.sessions.some(e=>!_r(e)))throw new z(`INVALID_INPUT`,`Invalid HistoryPage.`);return;case`getHistorySession`:if(!L(t)||!_r(t.session)||!Array.isArray(t.messages)||t.messages.some(e=>{let t=L(e)?e:null;return!t||!R(t.role)||typeof t.text!=`string`||!ar(t.sequence)||t.timestamp!==void 0&&!R(t.timestamp)})||typeof t.truncated!=`boolean`||!ar(t.returnedMessageCount)||Number(t.returnedMessageCount)!==t.messages.length)throw new z(`INVALID_INPUT`,`Invalid HistorySessionDetail.`);return;case`startWatch`:case`stopWatch`:vr(t);return;case`getWatchStatus`:if(L(t)&&Array.isArray(t.watches)){rr(t,`WatchStatusList`),t.watches.forEach(vr);return}vr(t);return;case`getDiagnostics`:gr(t);return;default:throw new z(`INVALID_INPUT`,`Unknown Core method output.`)}}function br(e){if(!L(e))throw new z(`INVALID_INPUT`,`Progress event must be an object.`);let t=new Set([`stage`,`status`,`progress`,`count`]);if(Object.keys(e).some(e=>!t.has(e))||!R(e.stage)||!R(e.status)||e.stage.length>80||e.status.length>40||e.progress!==void 0&&(typeof e.progress!=`number`||!Number.isFinite(e.progress)||e.progress<0||e.progress>1)||e.count!==void 0&&(!Number.isSafeInteger(e.count)||Number(e.count)<0))throw new z(`INVALID_INPUT`,`Invalid ProgressEvent.`)}function xr(e,t,n){if(!L(e)||!Jn(e,[`protocolVersion`,`requestId`,`operationId`,`event`,`operation`])||(Zn(e.protocolVersion),!R(e.requestId)||e.requestId.length>512||t!==void 0&&e.requestId!==t||!R(e.operationId)||!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(e.operationId)||n!==void 0&&e.operationId!==n||e.event!==`operation-started`||![`sync`,`switch`,`restore`].includes(String(e.operation))))throw new z(`INVALID_INPUT`,`Invalid operation-started envelope.`)}function Sr(e,t,n){if(!L(e)||!Jn(e,[`protocolVersion`,`requestId`,`operationId`,`event`,`progress`])||(Zn(e.protocolVersion),!R(e.requestId)||e.requestId.length>512||t!==void 0&&e.requestId!==t||!R(e.operationId)||!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(e.operationId)||n!==void 0&&e.operationId!==n||e.event!==`progress`))throw new z(`INVALID_INPUT`,`Invalid Core progress envelope.`);br(e.progress)}function Cr(e,t,n){if(L(e)&&e.event===`operation-started`){xr(e,t,n);return}Sr(e,t,n)}function wr(e,t,n,r){let i={protocolVersion:1,requestId:n,...r?{operationId:r}:{},method:e,payload:t};return tr(i),i}function Tr(e,t,n){return er(t),{protocolVersion:1,requestId:e.requestId,...n??t.operationId??e.operationId?{operationId:n??t.operationId??e.operationId}:{},ok:!1,error:t}}var Er=class extends Error{dto;code;constructor(e){er(e),super(e.message),this.name=`CoreClientError`,this.dto=e,this.code=e.code}};function Dr(){return globalThis.crypto?.randomUUID?.()??`request-${Date.now()}-${Math.random().toString(16).slice(2)}`}var Or=class{#e;#t;constructor(e,{requestIdFactory:t=Dr}={}){this.#e=e,this.#t=t}async#n(e,t,n={}){let r=n.requestId??this.#t(),i=wr(e,t,r,n.operationId),a=await this.#e.request(i,{signal:n.signal,onOperationStarted:n.onOperationStarted,onProgress:n.onProgress});try{nr(a,r),a.ok&&yr(e,a.result)}catch(e){throw e instanceof z?new Er(Wn(e.code===`PROTOCOL_VERSION_MISMATCH`?`PROTOCOL_VERSION_MISMATCH`:`INTERNAL_ERROR`)):e}if(!a.ok)throw new Er(a.error);return a.result}getStatus(e,t){return this.#n(`getStatus`,e,t)}prepareSync(e,t){return this.#n(`prepareSync`,e,t)}applySync(e,t){return this.#n(`applySync`,e,t)}prepareSwitch(e,t){return this.#n(`prepareSwitch`,e,t)}applySwitch(e,t){return this.#n(`applySwitch`,e,t)}listBackups(e,t){return this.#n(`listBackups`,e,t)}prepareRestore(e,t){return this.#n(`prepareRestore`,e,t)}applyRestore(e,t){return this.#n(`applyRestore`,e,t)}pruneBackups(e,t){return this.#n(`pruneBackups`,e,t)}listHistory(e,t){return this.#n(`listHistory`,e,t)}getHistorySession(e,t){return this.#n(`getHistorySession`,e,t)}startWatch(e,t){return this.#n(`startWatch`,e,t)}stopWatch(e,t){return this.#n(`stopWatch`,e,t)}getWatchStatus(e,t){return this.#n(`getWatchStatus`,e,t)}getDiagnostics(e,t){return this.#n(`getDiagnostics`,e,t)}},kr=Object.freeze([`getStatus`,`listBackups`,`listHistory`,`getHistorySession`,`getDiagnostics`]),Ar=Object.freeze([`prepareSync`,`applySync`,`prepareSwitch`,`applySwitch`]),jr=Object.freeze([`prepareRestore`,`applyRestore`]),Mr=Object.freeze([`pruneBackups`,`startWatch`,`stopWatch`,`getWatchStatus`]);Object.freeze([...kr,...Ar,...jr,...Mr]),new Set(kr),new Set(Ar),new Set(jr),new Set(Mr);var Nr=`application/x-ndjson`;function Pr(e,t){if(e)try{e(t)}catch{}}var Fr=class extends Error{status;constructor(e,t=null){super(e),this.name=`CoreTransportError`,this.status=t}},Ir=class{#e;#t;#n;#r;constructor({baseUrl:e,endpoint:t=`/api/core`,fetch:n=globalThis.fetch,headers:r={}}){if(typeof n!=`function`)throw TypeError(`HttpCoreTransport requires a Fetch implementation.`);this.#e=new URL(t,e),this.#t=new URL(`${t.replace(/\/$/,``)}/cancel`,e),this.#n=n,this.#r=Object.freeze({...r})}async request(e,t={}){let n=JSON.stringify(e);if(new TextEncoder().encode(n).byteLength>65536)throw new Fr(`Core request exceeds the 64 KiB transport limit.`);let r=e.method===`applySync`||e.method===`applySwitch`||e.method===`applyRestore`;if(t.signal?.aborted){if(r)return Tr(e,Wn(`OPERATION_CANCELLED`));throw new DOMException(`The Core HTTP request was cancelled.`,`AbortError`)}let i,a=!1,o=()=>{a=!0,this.#n(this.#t,{method:`POST`,credentials:`same-origin`,redirect:`error`,headers:{"Content-Type":`application/json`,...this.#r},body:JSON.stringify({protocolVersion:e.protocolVersion,requestId:e.requestId,...i?{operationId:i}:{}})}).catch(()=>void 0)},s=r?o:void 0;s&&t.signal?.addEventListener(`abort`,s,{once:!0});let c;try{c=await this.#n(this.#e,{method:`POST`,credentials:`same-origin`,redirect:`error`,headers:{"Content-Type":`application/json`,Accept:Nr,...this.#r},body:n,signal:r?void 0:t.signal})}catch{throw s&&t.signal?.removeEventListener(`abort`,s),new Fr(`Core HTTP request failed.`)}if((c.headers.get(`content-type`)?.toLowerCase()??``).startsWith(Nr))try{let n=await this.#i(c,e,t,{get operationId(){return i},set operationId(e){i=e},get cancellationRequested(){return a},requestCancellation:o});if(!c.ok&&typeof n==`object`&&n&&!Array.isArray(n)&&`ok`in n&&n.ok===!0)throw new Fr(`Core HTTP request failed.`,c.status);return n}finally{s&&t.signal?.removeEventListener(`abort`,s)}let l;try{l=await c.json()}catch{throw s&&t.signal?.removeEventListener(`abort`,s),new Fr(`Core HTTP response was not valid JSON.`,c.status)}if(s&&t.signal?.removeEventListener(`abort`,s),!c.ok&&(typeof l!=`object`||!l||Array.isArray(l)||!(`ok`in l)||l.ok!==!1))throw new Fr(`Core HTTP request failed.`,c.status);return l}async#i(e,t,n,r){if(!e.body)throw new Fr(`Core HTTP stream has no body.`,e.status);let i=t.method===`applySync`||t.method===`applySwitch`||t.method===`applyRestore`,a=t.method===`applySync`?`sync`:t.method===`applySwitch`?`switch`:t.method===`applyRestore`?`restore`:null,o=e.body.getReader(),s=new TextDecoder,c=``,l=0,u,d=o=>{if(!o.trim())return;if(u!==void 0)throw new Fr(`Core HTTP stream contained data after its terminal envelope.`,e.status);let s;try{s=JSON.parse(o)}catch{throw new Fr(`Core HTTP stream contained invalid JSON.`,e.status)}if(typeof s==`object`&&s&&!Array.isArray(s)&&`event`in s){if(!i)throw new Fr(`Core HTTP read stream contained an operation event.`,e.status);let o=`event`in s?s.event:void 0;if(r.operationId===void 0&&o!==`operation-started`)throw new Fr(`Core HTTP stream emitted progress before operation-started.`,e.status);if(r.operationId!==void 0&&o===`operation-started`)throw new Fr(`Core HTTP stream emitted multiple operation-started events.`,e.status);try{Cr(s,t.requestId,r.operationId)}catch{throw new Fr(`Core HTTP stream contained an invalid operation event.`,e.status)}let c=s;if(c.event===`operation-started`&&c.operation!==a)throw new Fr(`Core HTTP stream started the wrong operation.`,e.status);r.operationId=c.operationId,c.event===`operation-started`?Pr(n.onOperationStarted,c):Pr(n.onProgress,c),r.cancellationRequested&&r.requestCancellation();return}try{nr(s,t.requestId)}catch{throw new Fr(`Core HTTP stream contained an invalid terminal envelope.`,e.status)}let c=s,l=r.operationId;if(l!==void 0){if(c.operationId!==l||!c.ok&&c.error.operationId!==void 0&&c.error.operationId!==l)throw new Fr(`Core HTTP stream terminal operationId did not match its lifecycle.`,e.status)}else if(i&&c.operationId!==void 0)throw new Fr(`Core HTTP stream ended an unannounced operation.`,e.status);if(c.ok){try{yr(t.method,c.result)}catch{throw new Fr(`Core HTTP stream contained an invalid terminal result.`,e.status)}if(i){if(l===void 0)throw new Fr(`Core HTTP apply stream ended without operation-started.`,e.status);if(c.result.operationId!==l)throw new Fr(`Core HTTP stream result operationId did not match its lifecycle.`,e.status)}}u=c};for(;;){let{value:t,done:n}=await o.read();if(n)break;if(l+=t.byteLength,l>16777216)throw await o.cancel(),new Fr(`Core HTTP stream exceeded its response limit.`,e.status);c+=s.decode(t,{stream:!0});let r;for(;(r=c.indexOf(` +`))>=0;)d(c.slice(0,r)),c=c.slice(r+1)}if(c+=s.decode(),d(c),u===void 0)throw new Fr(`Core HTTP stream ended without a terminal envelope.`,e.status);return u}},Lr=class extends Or{constructor(e){super(new Ir(e),{requestIdFactory:e.requestIdFactory})}},Rr=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),zr=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),Br=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),Vr=e=>{let t=Br(e);return t.charAt(0).toUpperCase()+t.slice(1)},Hr={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},Ur=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},Wr=(0,p.createContext)({}),Gr=()=>(0,p.useContext)(Wr),Kr=(0,p.forwardRef)(({color:e,size:t,strokeWidth:n,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>{let{size:l=24,strokeWidth:u=2,absoluteStrokeWidth:d=!1,color:f=`currentColor`,className:m=``}=Gr()??{},h=r??d?Number(n??u)*24/Number(t??l):n??u;return(0,p.createElement)(`svg`,{ref:c,...Hr,width:t??l??Hr.width,height:t??l??Hr.height,stroke:e??f,strokeWidth:h,className:Rr(`lucide`,m,i),...!a&&!Ur(s)&&{"aria-hidden":`true`},...s},[...o.map(([e,t])=>(0,p.createElement)(e,t)),...Array.isArray(a)?a:[a]])}),qr=(e,t)=>{let n=(0,p.forwardRef)(({className:n,...r},i)=>(0,p.createElement)(Kr,{ref:i,iconNode:t,className:Rr(`lucide-${zr(Vr(e))}`,`lucide-${e}`,n),...r}));return n.displayName=Vr(e),n},Jr=qr(`activity`,[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]),Yr=qr(`archive-restore`,[[`rect`,{width:`20`,height:`5`,x:`2`,y:`3`,rx:`1`,key:`1wp1u1`}],[`path`,{d:`M4 8v11a2 2 0 0 0 2 2h2`,key:`tvwodi`}],[`path`,{d:`M20 8v11a2 2 0 0 1-2 2h-2`,key:`1gkqxj`}],[`path`,{d:`m9 15 3-3 3 3`,key:`1pd0qc`}],[`path`,{d:`M12 12v9`,key:`192myk`}]]),Xr=qr(`circle-check`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),Zr=qr(`database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),Qr=qr(`earth`,[[`path`,{d:`M21.54 15H17a2 2 0 0 0-2 2v4.54`,key:`1djwo0`}],[`path`,{d:`M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17`,key:`1tzkfa`}],[`path`,{d:`M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05`,key:`14pb5j`}],[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}]]),$r=qr(`file-clock`,[[`path`,{d:`M16 22h2a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v2.85`,key:`ryk6xj`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 14v2.2l1.6 1`,key:`6m4bie`}],[`circle`,{cx:`8`,cy:`16`,r:`6`,key:`10v15b`}]]),ei=qr(`folder-cog`,[[`path`,{d:`M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.98a2 2 0 0 1 1.69.9l.66 1.2A2 2 0 0 0 12 6h8a2 2 0 0 1 2 2v3.3`,key:`128dxu`}],[`path`,{d:`m14.305 19.53.923-.382`,key:`3m78fa`}],[`path`,{d:`m15.228 16.852-.923-.383`,key:`npixar`}],[`path`,{d:`m16.852 15.228-.383-.923`,key:`5xggr7`}],[`path`,{d:`m16.852 20.772-.383.924`,key:`dpfhf9`}],[`path`,{d:`m19.148 15.228.383-.923`,key:`1reyyz`}],[`path`,{d:`m19.53 21.696-.382-.924`,key:`1goivc`}],[`path`,{d:`m20.772 16.852.924-.383`,key:`htqkph`}],[`path`,{d:`m20.772 19.148.924.383`,key:`9w9pjp`}],[`circle`,{cx:`18`,cy:`18`,r:`3`,key:`1xkwt0`}]]),ti=qr(`gauge`,[[`path`,{d:`m12 14 4-4`,key:`9kzdfg`}],[`path`,{d:`M3.34 19a10 10 0 1 1 17.32 0`,key:`19p75a`}]]),ni=qr(`languages`,[[`path`,{d:`m5 8 6 6`,key:`1wu5hv`}],[`path`,{d:`m4 14 6-6 2-3`,key:`1k1g8d`}],[`path`,{d:`M2 5h12`,key:`or177f`}],[`path`,{d:`M7 2h1`,key:`1t2jsx`}],[`path`,{d:`m22 22-5-10-5 10`,key:`don7ne`}],[`path`,{d:`M14 18h6`,key:`1m8k6r`}]]),ri=qr(`moon`,[[`path`,{d:`M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401`,key:`kfwtm`}]]),ii=qr(`play`,[[`path`,{d:`M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z`,key:`10ikf1`}]]),ai=qr(`refresh-cw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),oi=qr(`rotate-ccw-clock`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}],[`path`,{d:`M12 7v5l4 2`,key:`1fdv2h`}]]),si=qr(`rotate-ccw`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}]]),ci=qr(`settings`,[[`path`,{d:`M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915`,key:`1i5ecw`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),li=qr(`shield-alert`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]]),ui=qr(`sun`,[[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`m4.93 4.93 1.41 1.41`,key:`149t6j`}],[`path`,{d:`m17.66 17.66 1.41 1.41`,key:`ptbguv`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`m6.34 17.66-1.41 1.41`,key:`1m8zz5`}],[`path`,{d:`m19.07 4.93-1.41 1.41`,key:`1shlcs`}]]),di=qr(`triangle-alert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),fi=qr(`workflow`,[[`rect`,{width:`8`,height:`8`,x:`3`,y:`3`,rx:`2`,key:`by2w9f`}],[`path`,{d:`M7 11v4a2 2 0 0 0 2 2h4`,key:`xkn7yn`}],[`rect`,{width:`8`,height:`8`,x:`13`,y:`13`,rx:`2`,key:`1cgmvn`}]]),pi=qr(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),mi=e=>e.type===`checkbox`,hi=e=>e.type===`file`,gi=e=>e instanceof Date,_i=e=>e==null,vi=e=>typeof e==`object`,yi=e=>!_i(e)&&!Array.isArray(e)&&vi(e)&&!gi(e),bi=e=>yi(e)&&e.target?mi(e.target)?e.target.checked:hi(e.target)?e.target.files:e.target.value:e,xi=(e,t)=>t.split(`.`).some((t,n,r)=>!isNaN(Number(t))&&e.has(r.slice(0,n).join(`.`))),Si=typeof window<`u`&&window.HTMLElement!==void 0&&typeof document<`u`;function B(e){if(typeof e!=`object`||!e)return e;if(e instanceof Date)return new Date(e);let t=typeof FileList<`u`&&e instanceof FileList;if(Si&&(e instanceof Blob||t))return e;let n=Array.isArray(e);if(!n&&e.constructor!==Object)return e;let r=n?[]:Object.create(Object.getPrototypeOf(e));for(let t in e)Object.prototype.hasOwnProperty.call(e,t)&&(r[t]=B(e[t]));return r}var Ci={BLUR:`blur`,FOCUS_OUT:`focusout`,CHANGE:`change`,SUBMIT:`submit`,TRIGGER:`trigger`,VALID:`valid`},wi={onBlur:`onBlur`,onChange:`onChange`,onSubmit:`onSubmit`,onTouched:`onTouched`,all:`all`},Ti={max:`max`,min:`min`,maxLength:`maxLength`,minLength:`minLength`,pattern:`pattern`,required:`required`,validate:`validate`},Ei=`root`,Di=[`__proto__`,`constructor`,`prototype`],Oi=/^\w*$/,ki=e=>Oi.test(e),V=e=>e===void 0,H=/[.[\]'"]/,Ai=e=>e.split(H).filter(Boolean),U=(e,t,n)=>{if(!t||!yi(e))return n;let r=ki(t)?[t]:Ai(t);if(r.some(e=>Di.includes(e)))return n;let i=r.reduce((e,t)=>_i(e)?void 0:e[t],e);return V(i)||i===e?V(e[t])?n:e[t]:i},ji=e=>typeof e==`boolean`,Mi=e=>typeof e==`function`,Ni=(e,t,n)=>{let r=-1,i=ki(t)?[t]:Ai(t),a=i.length,o=a-1;for(;++r{let i={};for(let a in e)Object.defineProperty(i,a,{get:()=>{let i=a;return t._proxyFormState[i]!==wi.all&&(t._proxyFormState[i]=!r||wi.all),n&&(n[i]=!0),e[i]}});return i},Ii=Si?p.useLayoutEffect:p.useEffect,Li=e=>{let t=e.constructor&&e.constructor.prototype;return yi(t)&&t.hasOwnProperty(`isPrototypeOf`)},Ri=e=>_i(e)||!vi(e),zi=(e,t)=>t.length===0&&!Array.isArray(e)&&!Li(e);function Bi(e,t,n=new WeakMap){if(e===t)return!0;if(Ri(e)||Ri(t))return Object.is(e,t);if(gi(e)&&gi(t))return Object.is(e.getTime(),t.getTime());let r=Object.keys(e),i=Object.keys(t);if(r.length!==i.length)return!1;if(zi(e,r)||zi(t,i))return Object.is(e,t);if(!r.length&&Array.isArray(e)!==Array.isArray(t))return!1;let a=n.get(e);if(a&&a.has(t))return!0;if(a)a.add(t);else{let r=new WeakSet;r.add(t),n.set(e,r)}for(let i of r){let r=e[i];if(!(i in t))return!1;if(i!==`ref`){let e=t[i];if(gi(r)&&gi(e)||(yi(r)||Array.isArray(r))&&(yi(e)||Array.isArray(e))?!Bi(r,e,n):!Object.is(r,e))return!1}}return!0}function Vi(){let e=p.useRef(!1),t=p.useRef(void 0);return{resyncIfNeeded:p.useCallback((n,r,i)=>{if(n&&e.current){let e=r();Bi(t.current,e)||i(e)}e.current=!0},[]),snapshot:p.useCallback((e,n)=>{e&&(t.current=B(n()))},[])}}var Hi=e=>typeof e==`string`,Ui=(e,t,n,r,i)=>Hi(e)?(r&&t.watch.add(e),U(n,e,i)):Array.isArray(e)?e.map(e=>(r&&t.watch.add(e),U(n,e))):(r&&(t.watchAll=!0),n),Wi=e=>({isOnSubmit:!e||e===wi.onSubmit,isOnBlur:e===wi.onBlur,isOnChange:e===wi.onChange,isOnAll:e===wi.all,isOnTouch:e===wi.onTouched}),Gi=(e,t,n)=>{if(n)return!1;if(t.watchAll||t.watch.has(e))return!0;for(let n of t.watch)if(e.startsWith(n)&&e.charAt(n.length)===`.`)return!0;return!1},Ki=(e,t,n,r)=>{for(let i of n||Object.keys(e)){if(i===`_f`)continue;let a=n?U(e,i):e[i];if(a){let{_f:e}=a;if(e){if(e.refs&&e.refs[0]&&t(e.refs[0],i)&&!r||e.ref&&t(e.ref,e.name)&&!r)return!0;if(Ki(a,t))break}else if((yi(a)||Array.isArray(a))&&Ki(a,t))break}}},qi=(e,t,n)=>{let r=U(e,n),i=Array.isArray(r)?r:[];return Ni(i,Ei,t[n]),Ni(e,n,i),e},Ji=e=>yi(e)&&!Object.keys(e).length,Yi=e=>{if(!Si)return!1;let t=e?e.ownerDocument:0;return e instanceof(t&&t.defaultView?t.defaultView.HTMLElement:HTMLElement)},Xi=e=>e.type===`radio`,Zi=e=>e instanceof RegExp,Qi=(e,t,n,r,i)=>t?{...n[e],types:{...n[e]&&n[e].types?n[e].types:{},[r]:i||!0}}:{},$i={value:!1,isValid:!1},ea={value:!0,isValid:!0},ta=e=>{if(!Array.isArray(e))return $i;if(e.length>1){let t=e.filter(e=>e&&e.checked&&!e.disabled).map(e=>e.value);return{value:t,isValid:!!t.length}}let t=e[0];return!t||!t.checked||t.disabled?$i:!t.attributes||!(`value`in t.attributes)||V(t.value)||t.value===``?ea:{value:t.value,isValid:!0}},na={isValid:!1,value:null},ra=e=>Array.isArray(e)?e.reduce((e,t)=>t&&t.checked&&!t.disabled?{isValid:!0,value:t.value}:e,na):na;function ia(e,t,n=`validate`){if(Hi(e)||Array.isArray(e)&&e.every(Hi)||ji(e)&&!e)return{type:n,message:Hi(e)?e:``,ref:t}}var aa=e=>yi(e)&&!Zi(e)?e:{value:e,message:``},oa=async(e,t,n,r,i,a)=>{let{ref:o,refs:s,required:c,maxLength:l,minLength:u,min:d,max:f,pattern:p,validate:m,name:h,valueAsNumber:g,mount:_}=e._f,v=U(n,h);if(!_||t.has(h))return{};let y=s?s[0]:o,b=e=>{if(i&&y.reportValidity){let t=ji(e)?``:e||``;s?s.forEach(e=>e.setCustomValidity(t)):y.setCustomValidity(t),y.reportValidity()}},x={},S=Xi(o),C=mi(o),w=S||C,T=(g||hi(o))&&V(o.value)&&V(v)||Yi(o)&&o.value===``||v===``||Array.isArray(v)&&!v.length,E=Qi.bind(null,h,r,x),D=(e,t,n,r=Ti.maxLength,i=Ti.minLength)=>{let a=e?t:n;x[h]={type:e?r:i,message:a,ref:o,...E(e?r:i,a)}};if(a?!Array.isArray(v)||!v.length:c&&(!w&&(T||_i(v))||ji(v)&&!v||C&&!ta(s).isValid||S&&!ra(s).isValid)){let{value:e,message:t}=Hi(c)?{value:!!c,message:c}:aa(c);if(e&&(x[h]={type:Ti.required,message:t,ref:y,...E(Ti.required,t)},!r))return b(t),x}if(!T&&(!_i(d)||!_i(f))){let e,t,n=aa(f),i=aa(d);if(!_i(v)&&!gi(v)&&!isNaN(v)){let r=o.valueAsNumber||v&&+v;_i(n.value)||(e=r>n.value),_i(i.value)||(t=rnew Date(new Date().toDateString()+` `+e),s=o.type==`time`,c=o.type==`week`;Hi(n.value)&&v&&(e=s?a(v)>a(n.value):c?v>n.value:r>new Date(n.value)),Hi(i.value)&&v&&(t=s?a(v)+e.value,i=!_i(t.value)&&v.length<+t.value;if((n||i)&&(D(n,e.message,t.message),!r))return b(x[h].message),x}if(p&&!T&&Hi(v)){let{value:e,message:t}=aa(p);if(Zi(e)&&!v.match(e)&&(x[h]={type:Ti.pattern,message:t,ref:o,...E(Ti.pattern,t)},!r))return b(t),x}if(m){if(Mi(m)){let e=ia(await m(v,n),y);if(e&&(x[h]={...e,...E(Ti.validate,e.message)},!r))return b(e.message),x}else if(yi(m)){let e={};for(let t in m){if(!Ji(e)&&!r)break;let i=ia(await m[t](v,n),y,t);i&&(e={...i,...E(t,i.message)},b(i.message),r&&(x[h]=e))}if(!Ji(e)&&(x[h]={ref:y,...e},!r))return x}}let O=x[h];return b(!O||O.message),x},sa=e=>Array.isArray(e)?e:[e],ca=e=>Array.isArray(e)?e.filter(Boolean):[];function la(e,t){let n=t.length-1,r=0;for(;rDi.includes(String(e))))return e;let r=n.length===1?e:la(e,n),i=n.length-1,a=n[i];return r&&delete r[a],i!==0&&(yi(r)&&Ji(r)||Array.isArray(r)&&ua(r))&&da(e,n.slice(0,-1)),e}var fa=p.createContext(null);fa.displayName=`HookFormContext`;var pa=()=>{let e=[];return{get observers(){return e},next:t=>{for(let n of e)n.next&&n.next(t)},subscribe:t=>(e.push(t),{unsubscribe:()=>{e=e.filter(e=>e!==t)}}),unsubscribe:()=>{e=[]}}};function ma(e,t){let n={};for(let r in e)if(e.hasOwnProperty(r)){let i=e[r],a=t[r];if(i&&yi(i)&&a){let e=ma(i,a);yi(e)&&(n[r]=e)}else e[r]&&(n[r]=a)}return n}var ha=(e,t)=>e!==null&&vi(e)&&Object.prototype.hasOwnProperty.call(e,t),ga=(e,t)=>{if(!t)return!1;let n=e;for(let r of ki(t)?[t]:Ai(t)){if(!ha(n,r))return ha(e,t);n=n[r]}return!0},_a=e=>e.type===`select-multiple`,va=e=>Xi(e)||mi(e),ya=e=>Yi(e)&&e.isConnected;function ba(e){return Array.isArray(e)||yi(e)}function xa(e,t,n=``,r=[]){for(let i in e){let a=n?`${n}.${i}`:i,o=e[i];ba(o)&&ba(U(t,a))?xa(o,t,a,r):r.push(a)}return r}var Sa=e=>{for(let t in e)if(Mi(e[t]))return!0;return!1};function Ca(e){return Array.isArray(e)||yi(e)&&!Sa(e)}function wa(e){return!!(e&&`_f`in e)}function Ta(e){return Array.isArray(e)?!e.some(e=>!V(e)):!Object.keys(e).length}function Ea(e,t){Array.isArray(e)?e[t]=void 0:delete e[t]}function Da(e,t={},n){for(let r in e){let i=e[r],a=n&&n[r];Ca(i)&&(!Array.isArray(i)||!wa(a))?(t[r]=Array.isArray(i)?[]:{},Da(i,t[r],a),Ta(t[r])&&Ea(t,r)):V(i)||(t[r]=!0)}return t}function Oa(e,t,n,r){n||=Da(t,{},r);for(let i in e){let a=e[i],o=r&&r[i];Ca(a)&&(!Array.isArray(a)||!wa(o))?(V(t)||Ri(n[i])?n[i]=Da(a,Array.isArray(a)?[]:{},o):Oa(a,_i(t)?{}:t[i],n[i],o),Ta(n[i])&&Ea(n,i)):Bi(a,t[i])?Ea(n,i):n[i]=!0}return n}var ka=(e,t)=>{let n=t.split(`.`),r=[],i=n[0];for(let t=1;tV(e)?e:t?e===``?NaN:e&&+e:n&&Hi(e)?new Date(e):r?r(e):e;function ja(e){let t=e.ref;return hi(t)?t.files:Xi(t)?ra(e.refs).value:_a(t)?[...t.selectedOptions].map(({value:e})=>e):mi(t)?ta(e.refs).value:Aa(t.value,e)}var Ma=(e,t,n,r)=>{let i={};for(let n of e){let e=U(t,n);e&&Ni(i,n,e._f)}return{criteriaMode:n,names:[...e],fields:i,shouldUseNativeValidation:r}},Na=e=>V(e)?e:Zi(e)?e.source:yi(e)?Zi(e.value)?e.value.source:e.value:e,Pa=`AsyncFunction`,Fa=e=>{if(!e||!e.validate)return!1;if(Mi(e.validate))return e.validate.constructor.name===Pa;if(yi(e.validate)){for(let t in e.validate)if(e.validate[t].constructor.name===Pa)return!0}return!1},Ia=e=>e.mount&&(e.required||!V(e.required)&&e.required!==!1||!V(e.min)||!V(e.max)||!V(e.maxLength)||!V(e.minLength)||e.pattern||e.validate);function La(e,t,n){let r=U(e,n);if(r||ki(n))return{error:r,name:n};let i=n.split(`.`);for(;i.length;){let r=i.join(`.`),a=U(t,r),o=U(e,r);if(a&&!Array.isArray(a)&&n!==r)return{name:n};if(o&&o.type)return{name:r,error:o};if(o&&o.root&&o.root.type)return{name:`${r}.root`,error:o.root};i.pop()}return{name:n}}var Ra=(e,t,n,r)=>{n(e);let i=Object.keys(e).filter(e=>e!==`name`);return!i.length||r&&i.length>=Object.keys(t).length||i.find(e=>t[e]===(!r||wi.all))},za=(e,t,n)=>!e||!t||e===t||sa(e).some(e=>e&&(n?e===t||e.startsWith(t+`.`):e.startsWith(t)||t.startsWith(e))),Ba=(e,t,n,r,i)=>i.isOnAll?!1:!n&&i.isOnTouch?!(t||e):(n?r.isOnBlur:i.isOnBlur)?!e:!(n?r.isOnChange:i.isOnChange)||e,Va=(e,t)=>{let n=U(e,t);!ca(n).length&&!(n!=null&&n.root)&&da(e,t)},Ha={mode:wi.onSubmit,reValidateMode:wi.onChange,shouldFocusError:!0},Ua=`form`,Wa=(e,t)=>{for(let n in e)n in t||delete e[n];Object.assign(e,t)},Ga={submitCount:0,isDirty:!1,isReady:!1,isValidating:!1,isSubmitted:!1,isSubmitting:!1,isSubmitSuccessful:!1,isValid:!1,touchedFields:{},dirtyFields:{},validatingFields:{}};function Ka(e={}){let t={...Ha,...e},n={...B(Ga),isLoading:Mi(t.defaultValues),errors:t.errors||{},disabled:t.disabled||!1},r={},i=(yi(t.defaultValues)||yi(t.values))&&B(t.defaultValues||t.values)||{},a=t.shouldUnregister?{}:B(i),o={action:!1,actionArrayLengths:new Map,mount:!1,watch:!1,keepIsValid:!1},s={mount:new Set,disabled:new Set,unMount:new Set,array:new Set,watch:new Set,registerName:new Set},c={},l={},u=0,d=Wi(t.mode),f=Wi(t.reValidateMode),p={isDirty:!1,dirtyFields:!1,validatingFields:!1,touchedFields:!1,isValidating:!1,isValid:!1,errors:!1},m={...p},h={...m},g={array:pa(),state:pa()},_=0,v=t.criteriaMode===wi.all,y=(e,t)=>n=>{clearTimeout(l[e]),l[e]=setTimeout(t,n)},b=async e=>{if(!o.keepIsValid&&!t.disabled&&(m.isValid||h.isValid||e)){let e=++_,i;t.resolver?(i=Ji((await k()).errors),e===_&&x()):i=await j({fields:r,onlyCheckValid:!0,eventType:Ci.VALID}),e===_&&i!==n.isValid&&g.state.next({isValid:i})}},x=(e,r)=>{!t.disabled&&(m.isValidating||m.validatingFields||h.isValidating||h.validatingFields)&&((e||s.mount).forEach(e=>{e&&(r?Ni(n.validatingFields,e,r):da(n.validatingFields,e))}),g.state.next({validatingFields:n.validatingFields,isValidating:!Ji(n.validatingFields)}))},S=()=>{n.dirtyFields=Oa(i,a,void 0,r)},C=(e,i=[],s,c,l=!0,u=!0)=>{if(c&&s&&!t.disabled){o.action=!0;let t=U(r,e);if(o.actionArrayLengths.has(e)||o.actionArrayLengths.set(e,Array.isArray(t)?t.length:0),u&&Array.isArray(t)){let n=s(t,c.argA,c.argB);l&&Ni(r,e,n)}let a=U(n.errors,e);if(u&&Array.isArray(a)){let t=a.root,r=s(a,c.argA,c.argB)||a;t&&(r.root=t),l&&Ni(n.errors,e,r),Va(n.errors,e)}let d=U(n.touchedFields,e);if((m.touchedFields||h.touchedFields)&&u&&Array.isArray(d)){let t=s(d,c.argA,c.argB);l&&Ni(n.touchedFields,e,t)}(m.dirtyFields||h.dirtyFields)&&S(),g.state.next({name:e,isDirty:N(e,i),dirtyFields:n.dirtyFields,errors:n.errors,isValid:n.isValid})}else Ni(a,e,i)},w=(e,t)=>{Ni(n.errors,e,t),n.errors={...n.errors},g.state.next({errors:n.errors})},T=e=>{n.errors=e,g.state.next({errors:n.errors,isValid:!1})},E=e=>{let t=ki(e)?[e]:Ai(e),n=a,r=i;for(let e=0;e{if(!o.actionArrayLengths.size)return!1;let t=ki(e)?[e]:Ai(e),n=a,r=``,i=-1,s=0;for(let e=0;e=n.length)return i===-1?!1:e!==i||+a{let d=U(r,t);if(d){if(E(t)||D(t))return;let r=V(U(a,t)),f=U(a,t,V(l)?U(i,t):l);V(f)||u&&u.defaultChecked||c?Ni(a,t,c?f:ja(d._f)):ae(t,f),o.mount&&!o.action&&(b(),r&&n.isDirty&&(m.isDirty||h.isDirty)&&(N()||(n.isDirty=!1,g.state.next({...n}))),e.shouldUnregister&&r&&!V(U(a,t))&&Gi(t,s)&&(o.watch=!0))}},ee=(e,o,s,c,l)=>{let u=!1,d=!1,f={name:e};if(!t.disabled||c===!0){if(!s||c){let t=Bi(U(i,e),o);(m.isDirty||h.isDirty)&&(d=n.isDirty,n.isDirty=f.isDirty=!t||N(),u=d!==f.isDirty),d=!!U(n.dirtyFields,e),t===n.isDirty?t?da(n.dirtyFields,e):Ni(n.dirtyFields,e,!0):Wa(n.dirtyFields,Oa(i,a,void 0,r)),f.dirtyFields=n.dirtyFields,u||=(m.dirtyFields||h.dirtyFields)&&d!==!t}if(s){let t=U(n.touchedFields,e);t||(Ni(n.touchedFields,e,s),f.touchedFields=n.touchedFields,u||=(m.touchedFields||h.touchedFields)&&t!==s)}u&&l&&g.state.next(f)}return u?f:{}},te=(e,r,i,a)=>{let o=U(n.errors,e),s=(m.isValid||h.isValid)&&ji(r)&&n.isValid!==r;if(t.delayError&&i?(c[e]=y(e,()=>w(e,i)),c[e](t.delayError)):(clearTimeout(l[e]),delete c[e],i?Ni(n.errors,e,i):da(n.errors,e),n.errors={...n.errors}),(i?!Bi(o,i):o)||!Ji(a)||s){let t={...a,...s&&ji(r)?{isValid:r}:{},errors:n.errors,name:e};g.state.next(t)}},k=async e=>(x(e,!0),await t.resolver(a,t.context,Ma(e||s.mount,r,t.criteriaMode,t.shouldUseNativeValidation))),A=async e=>{let{errors:t}=await k(e);if(x(e),e){for(let r of e){let e=U(t,r);e?s.array.has(r)&&yi(e)&&!Object.keys(e).some(e=>!Number.isNaN(Number(e)))?qi(n.errors,{[r]:e},r):Ni(n.errors,r,e):da(n.errors,r)}n.errors={...n.errors}}else n.errors=t;return t},ne=async({name:t,eventType:r})=>{if(e.validate){let i=await e.validate({formValues:a,formState:n,name:t,eventType:r});if(yi(i))for(let e in i){let t=i[e];t&&he(`${Ua}.${e}`,{message:Hi(t.message)?t.message:``,type:t.type||Ti.validate})}else Hi(i)||!i?he(Ua,{message:i||``,type:Ti.validate}):me(Ua);return i}return!0},j=async({fields:r,onlyCheckValid:i,name:o,eventType:c,context:l={valid:!0,runRootValidation:!1}})=>{if(e.validate&&(l.runRootValidation=!0,!await ne({name:o,eventType:c})&&(l.valid=!1,i)))return l.valid;for(let o in r){let u=r[o];if(u){let{_f:r,...d}=u;if(r){let o=s.array.has(r.name),c=u._f&&Fa(u._f),d=m.validatingFields||m.isValidating||h.validatingFields||h.isValidating;c&&d&&x([r.name],!0);let f=await oa(u,s.disabled,a,v,t.shouldUseNativeValidation&&!i,o);if(c&&d&&x([r.name]),f[r.name]&&(l.valid=!1,i)||(!i&&(U(f,r.name)?o?qi(n.errors,f,r.name):Ni(n.errors,r.name,f[r.name]):da(n.errors,r.name)),e.shouldUseNativeValidation&&f[r.name]))break}!Ji(d)&&await j({context:l,onlyCheckValid:i,fields:d,name:o,eventType:c})}}return l.valid},M=()=>{for(let e of s.unMount){let t=U(r,e);t&&(t._f.refs?t._f.refs.every(e=>!ya(e)):!ya(t._f.ref))&&ye(e)}s.unMount=new Set},N=(e,t)=>(e&&t&&Ni(a,e,t),!Bi(o.mount?a:i,i)),re=(e,t,n)=>Ui(e,s,{...o.mount?a:V(t)||Hi(e)?i:t},n,t),ie=e=>ca(U(o.mount?a:i,e,t.shouldUnregister?U(i,e,[]):[])),ae=(e,t,n={},i=!1,o=!1,s=!1)=>{let c=U(r,e),l=t;if(c){let n=c._f;n&&(!n.disabled&&Ni(a,e,Aa(t,n)),l=Yi(n.ref)&&_i(t)?``:t,_a(n.ref)?[...n.ref.options].forEach(e=>e.selected=l.includes(e.value)):n.refs?mi(n.ref)?n.refs.forEach(e=>{(!e.defaultChecked||!e.disabled)&&(e.checked=Array.isArray(l)?!!l.find(t=>t===e.value):l===e.value||!!l)}):n.refs.forEach(e=>e.checked=e.value===l):hi(n.ref)?n.ref.value=``:(n.ref.value=l,!n.ref.type&&!o&&!s&&g.state.next({name:e,values:i?a:B(a)})))}(n.shouldDirty||n.shouldTouch)&&ee(e,l,n.shouldTouch,n.shouldDirty,!o),n.shouldValidate&&ue(e,{delayError:n.delayError})},P=(e,t,n,i=!1,o=!1,c=!1)=>{s.array.has(e)&&g.array.next({name:e,values:i?a:B(a)});for(let a in t){if(!t.hasOwnProperty(a))return;let l=t[a],u=e+`.`+a,d=U(r,u);(s.array.has(e)||yi(l)||d&&!d._f)&&!gi(l)?P(u,l,n,i,o,c):ae(u,l,n,i,o,c)}},F=(e,t,i,c,l=!1)=>{let u=U(r,e),d=s.array.has(e),f=c?t:B(t),p=Bi(U(a,e),f);if(p||Ni(a,e,f),d)g.array.next({name:e,values:c?a:B(a)}),(m.isDirty||m.dirtyFields||h.isDirty||h.dirtyFields)&&i.shouldDirty&&(S(),l||g.state.next({name:e,dirtyFields:n.dirtyFields,isDirty:N(e,f)}));else{let t=Array.isArray(f)&&!f.length||Ji(f),n=!p&&!l;!u||u._f||_i(f)||t?ae(e,f,i,c,l,n):P(e,f,i,c,l,n)}if(!p&&!l){let t=Gi(e,s),r=c?a:B(a);if(g.state.next({...t&&n,name:o.mount||t?e:void 0,values:r}),!d)for(let t of ka(s.array,e))g.state.next({name:t,values:r})}},oe=(e,t,n={})=>F(e,t,n,!1),se=(e,t={})=>{let r=Mi(e)?e(a):e;if(!Bi(a,r)){a={...a,...r};for(let e of s.mount)ga(r,e)&&F(e,U(r,e),t,!0,!0);g.state.next({...n,name:void 0,type:void 0,...u?{values:a}:{}}),t.shouldValidate&&b()}},ce=async i=>{o.mount=!0;let l=i.target,p=l.name,_=!0,y=U(r,p),S=e=>{_=Number.isNaN(e)||gi(e)&&isNaN(e.getTime())||Bi(e,U(a,p,e))};if(y){let o,C,w=l.type?ja(y._f):bi(i),T=i.type===Ci.BLUR||i.type===Ci.FOCUS_OUT,E=!Ia(y._f)&&!e.validate&&!t.resolver&&!U(n.errors,p)&&!y._f.deps,D=E||Ba(T,U(n.touchedFields,p),n.isSubmitted,f,d),O=Gi(p,s,T);if(Ni(a,p,w),T){if(!l||!l.readOnly){y._f.onBlur&&y._f.onBlur(i);let e=c[p];e&&e(0)}}else y._f.onChange&&y._f.onChange(i);let A=ee(p,w,T),M=!Ji(A)||O;if(!T&&g.state.next({name:p,type:i.type,...u?{values:B(a)}:{}}),D)return(!E||!n.isValid)&&(m.isValid||h.isValid)&&(t.mode===`onBlur`?T&&b():T||b()),M&&g.state.next({name:p,...O?{}:A});if(!t.resolver&&e.validate&&await ne({name:p,eventType:i.type}),!T&&O&&g.state.next({...n}),t.resolver){let{errors:e}=await k([p]);if(x([p]),S(w),!_){!Ji(A)&&g.state.next(A);return}let t=La(n.errors,r,p),i=La(e,r,t.name||p);o=i.error,p=i.name,C=Ji(e)}else x([p],!0),o=(await oa(y,s.disabled,a,v,t.shouldUseNativeValidation))[p],x([p]),S(w),_&&(o?C=!1:(m.isValid||h.isValid)&&(C=await j({fields:r,onlyCheckValid:!0,name:p,eventType:i.type})));_&&(y._f.deps&&(!Array.isArray(y._f.deps)||y._f.deps.length>0)&&ue(y._f.deps),te(p,C,o,A))}},le=(e,t)=>{if(U(n.errors,t)&&e.focus)return e.focus(),1},ue=async(e,i={})=>{let a,o,u=sa(e);if(t.resolver){let t=await A(V(e)?e:u);a=Ji(t),o=e?!u.some(e=>U(t,e)):a}else e?(o=(await Promise.all(u.map(async e=>{let t=U(r,e);return await j({fields:t&&t._f?{[e]:t}:t,eventType:Ci.TRIGGER})}))).every(Boolean),!(!o&&!n.isValid)&&b()):o=a=await j({fields:r,name:e,eventType:Ci.TRIGGER});if(i.delayError&&t.delayError&&Hi(e)){let r=U(n.errors,e);r?(da(n.errors,e),c[e]=y(e,()=>w(e,r)),c[e](t.delayError)):(clearTimeout(l[e]),delete c[e])}return g.state.next({...!Hi(e)||(m.isValid||h.isValid)&&a!==n.isValid?{}:{name:e},...t.resolver||!e?{isValid:a}:{},errors:n.errors}),i.shouldFocus&&!o&&Ki(r,le,e?u:s.mount),o},de=(e,t)=>{let r={...o.mount?a:i};return t&&(r=ma(t.dirtyFields?n.dirtyFields:n.touchedFields,r)),V(e)?r:Hi(e)?U(r,e):e.map(e=>U(r,e))},fe=e=>V(e)?{...n.errors}:Hi(e)?U(n.errors,e):e.map(e=>U(n.errors,e)),pe=(e,t)=>{let r=t||n,i=U(r.errors,e);return{invalid:!!i,isDirty:!!U(r.dirtyFields,e),error:i,isValidating:!!U(n.validatingFields,e),isTouched:!!U(r.touchedFields,e)}},me=e=>{let t=e?sa(e):void 0;t?.forEach(e=>da(n.errors,e)),t?t.forEach(e=>{g.state.next({name:e,errors:n.errors})}):(n.errors={},g.state.next({errors:n.errors}))},he=(e,t,i)=>{let a=(U(r,e,{_f:{}})._f||{}).ref,{ref:o,message:s,type:c,...l}=U(n.errors,e)||{};Ni(n.errors,e,{...l,...t,ref:a}),g.state.next({name:e,errors:n.errors,isValid:!1}),i&&i.shouldFocus&&a&&a.focus&&a.focus()},ge=(e,t)=>{if(Mi(e)){u++;let{unsubscribe:n}=g.state.subscribe({next:n=>`values`in n&&e(n.values||re(void 0,t),n)}),r=!1;return{unsubscribe:()=>{r||(r=!0,u--,n())}}}return re(e,t,!0)},_e=e=>{let t=!!e.formState?.values;t&&u++;let{unsubscribe:r}=g.state.subscribe({next:t=>{if(za(e.name,t.name,e.exact)&&Ra(t,e.formState||m,ke,e.reRenderRoot)){let r={...a};e.callback({values:r,...n,...t,defaultValues:i})}}});if(!t)return r;let o=!1;return()=>{o||(o=!0,u--,r())}},ve=e=>(o.mount=!0,h={...h,...e.formState},_e({...e,formState:{...p,...e.formState}})),ye=(e,o={})=>{for(let c of e?sa(e):s.mount)s.mount.delete(c),s.array.delete(c),o.keepValue||(da(r,c),da(a,c)),!o.keepError&&da(n.errors,c),!o.keepDirty&&da(n.dirtyFields,c),!o.keepTouched&&da(n.touchedFields,c),!o.keepIsValidating&&da(n.validatingFields,c),!t.shouldUnregister&&!o.keepDefaultValue&&da(i,c);u&&g.state.next({values:B(a)}),g.state.next({...n,...o.keepDirty?{}:{isDirty:N()}}),!o.keepIsValid&&b()},be=({disabled:e,name:t})=>{if(ji(e)&&o.mount||e||s.disabled.has(t)){let n=s.disabled.has(t)!==!!e;e?s.disabled.add(t):s.disabled.delete(t),n&&o.mount&&!o.action&&b()}},xe=(e,n={})=>{let a=U(r,e),c=ji(n.disabled)||ji(t.disabled),l=!s.registerName.has(e)&&a&&a._f&&!a._f.mount;return Ni(r,e,{...a||{},_f:{...a&&a._f?a._f:{ref:{name:e}},name:e,mount:!0,...n}}),s.mount.add(e),a&&!l?be({disabled:ji(n.disabled)?n.disabled:t.disabled,name:e}):O(e,!0,n.value),{...c?{disabled:n.disabled||t.disabled}:{},...t.progressive?{required:!!n.required,min:Na(n.min),max:Na(n.max),minLength:Na(n.minLength),maxLength:Na(n.maxLength),pattern:Na(n.pattern)}:{},name:e,onChange:ce,onBlur:ce,ref:c=>{if(c){s.registerName.add(e),xe(e,n),s.registerName.delete(e),a=U(r,e);let t=V(c.value)&&c.querySelectorAll&&c.querySelectorAll(`input,select,textarea`)[0]||c,o=va(t),l=a._f.refs||[];if(o?l.find(e=>e===t):t===a._f.ref)return;let u={...a._f};o?(u.refs=[...l.filter(ya),t,...Array.isArray(U(i,e))?[{}]:[]],u.ref={type:t.type,name:e}):(u.ref=t,delete u.refs),Ni(r,e,{_f:u}),O(e,!1,void 0,t)}else a=U(r,e,{}),a._f&&(a._f.mount=!1),(t.shouldUnregister||n.shouldUnregister)&&!(xi(s.array,e)&&o.action)&&s.unMount.add(e)}}},Se=()=>t.shouldFocusError&&!t.shouldUseNativeValidation&&Ki(r,le,s.mount),Ce=e=>{ji(e)&&(g.state.next({disabled:e}),Ki(r,(t,n)=>{let i=U(r,n);i&&(t.disabled=i._f.disabled||e,Array.isArray(i._f.refs)&&i._f.refs.forEach(t=>{t.disabled=i._f.disabled||e}))},0,!1))},we=(e,i)=>async o=>{let c,l;o&&(o.preventDefault&&o.preventDefault(),o.persist&&o.persist());let u=B(a);if(g.state.next({isSubmitting:!0}),t.resolver){let{errors:e,values:t}=await k();x(),n.errors=e,u=B(t)}else await j({fields:r,eventType:Ci.SUBMIT});if(s.disabled.size)for(let e of s.disabled)da(u,e);if(da(n.errors,Ei),Ji(n.errors)){g.state.next({errors:{}});try{c=await e(u,o)}catch(e){l=e}}else i&&await i({...n.errors},o),Se(),setTimeout(Se);if(g.state.next({isSubmitted:!0,isSubmitting:!1,isSubmitSuccessful:Ji(n.errors)&&!l,submitCount:n.submitCount+1,errors:n.errors}),l)throw l;return c},Te=(e,t={})=>{U(r,e)&&(V(t.defaultValue)?oe(e,B(U(i,e))):(oe(e,t.defaultValue),Ni(i,e,B(t.defaultValue))),t.keepTouched||da(n.touchedFields,e),t.keepDirty||(da(n.dirtyFields,e),n.isDirty=t.defaultValue?N(e,B(U(i,e))):N()),t.keepError||(da(n.errors,e),m.isValid&&b()),g.state.next({...n}))},Ee=(e,c={})=>{let l=e?B(e):i,u=B(l),d=Ji(e),f=u,p=r;if(c.keepDefaultValues||(i=l),!c.keepValues){if(c.keepDirtyValues){let e=new Set([...s.mount,...xa(Oa(i,a,void 0,p),n.dirtyFields)]);for(let t of e){let e=U(n.dirtyFields,t),r=U(a,t),i=U(f,t);e&&!V(r)?Ni(f,t,r):!e&&!V(i)&&oe(t,i)}}else{if(Si&&V(e))for(let e of s.mount){let t=U(r,e);if(t&&t._f){let e=Array.isArray(t._f.refs)?t._f.refs[0]:t._f.ref;if(Yi(e)){let t=e.closest(`form`);if(t){t.reset();break}}}}if(c.keepFieldsRef)for(let e of s.mount)oe(e,U(f,e));else r={}}if(t.shouldUnregister){if(a=c.keepDefaultValues?B(i):{},c.keepFieldsRef)for(let e of s.mount)Ni(a,e,U(f,e))}else a=B(f);g.array.next({values:{...f}}),g.state.next({name:void 0,type:void 0,values:{...f}})}s={mount:c.keepDirtyValues?s.mount:new Set,unMount:new Set,array:new Set,registerName:new Set,disabled:new Set,watch:new Set,watchAll:!1,focus:``},o.mount=!m.isValid||!!c.keepIsValid||!!c.keepDirtyValues||!t.shouldUnregister&&!Ji(f),o.watch=!!t.shouldUnregister,o.keepIsValid=!!c.keepIsValid,o.action=!1,o.actionArrayLengths.clear(),c.keepErrors||(n.errors={}),g.state.next({submitCount:c.keepSubmitCount?n.submitCount:0,isDirty:d?!1:c.keepDirty?n.isDirty:c.keepValues?N():!!(c.keepDefaultValues&&!Bi(e,i)),isSubmitted:c.keepIsSubmitted?n.isSubmitted:!1,dirtyFields:d?{}:c.keepDirtyValues?c.keepDefaultValues&&a?Oa(i,a,void 0,p):n.dirtyFields:c.keepDefaultValues&&e?Oa(i,e,void 0,p):c.keepDirty?n.dirtyFields:{},touchedFields:c.keepTouched?n.touchedFields:{},errors:c.keepErrors?n.errors:{},isSubmitSuccessful:c.keepIsSubmitSuccessful?n.isSubmitSuccessful:!1,isSubmitting:!1,defaultValues:i})},De=(e,n)=>Ee(Mi(e)?e(a):e,{...t.resetOptions,...n}),Oe=(e,t={})=>{let n=U(r,e),i=n&&n._f;if(i){let e=i.refs?i.refs[0]:i.ref;e.focus&&setTimeout(()=>{e.focus(),t.shouldSelect&&Mi(e.select)&&e.select()})}},ke=e=>{let{name:t,type:r,values:i,...a}=e;n={...n,...a}};g.state.subscribe({next:ke});let Ae={control:{register:xe,unregister:ye,getFieldState:pe,handleSubmit:we,setError:he,_subscribe:_e,_runSchema:k,_updateIsValidating:x,_focusError:Se,_getWatch:re,_getDirty:N,_setValid:b,_setFieldArray:C,_setDisabledField:be,_setErrors:T,_getFieldArray:ie,_reset:Ee,_resetDefaultValues:()=>Mi(t.defaultValues)&&t.defaultValues().then(e=>{De(e,t.resetOptions),g.state.next({isLoading:!1})}),_removeUnmounted:M,_disableForm:Ce,_subjects:g,_proxyFormState:m,get _fields(){return r},get _formValues(){return a},get _state(){return o},set _state(e){o=e},get _defaultValues(){return i},get _names(){return s},set _names(e){s=e},get _formState(){return n},get _options(){return t},set _options(e){t={...t,...e},d=Wi(t.mode),f=Wi(t.reValidateMode)}},subscribe:ve,trigger:ue,register:xe,handleSubmit:we,watch:ge,setValue:oe,setValues:se,getValues:de,getErrors:fe,reset:De,resetField:Te,resetDefaultValues:(e,t={})=>{if(i=B(e),!t.keepDirty){let e=Oa(i,a,void 0,r);n.dirtyFields=e,n.isDirty=!Ji(e)}t.keepIsValid||b(),g.state.next({...n,defaultValues:i})},clearErrors:me,unregister:ye,setError:he,setFocus:Oe,getFieldState:pe};return{...Ae,formControl:Ae}}function qa(e={}){let t=p.useRef(void 0),n=p.useRef(void 0),r=p.useRef(e.formControl),[i,a]=p.useState(()=>({...B(Ga),isLoading:Mi(e.defaultValues),errors:e.errors||{},disabled:e.disabled||!1,defaultValues:Mi(e.defaultValues)?void 0:e.defaultValues}));if(!t.current||e.formControl&&r.current!==e.formControl){if(r.current=e.formControl,e.formControl)t.current={...e.formControl,formState:i},e.defaultValues&&!Mi(e.defaultValues)&&e.formControl.reset(e.defaultValues,e.resetOptions);else{let{formControl:n,...r}=Ka(e);t.current={...r,formState:i}}}let o=t.current.control;o._options=e;let{resyncIfNeeded:s,snapshot:c}=Vi();return Ii(()=>{let e=()=>({...o._formState,defaultValues:o._defaultValues});s(!0,e,a);let t=o._subscribe({formState:o._proxyFormState,callback:()=>a({...o._formState,defaultValues:o._defaultValues}),reRenderRoot:!0});return a(e=>({...e,isReady:!0})),o._formState.isReady=!0,()=>{t(),c(!0,e)}},[o,s,c]),p.useEffect(()=>o._disableForm(e.disabled),[o,e.disabled]),p.useEffect(()=>{e.mode&&(o._options.mode=e.mode),e.reValidateMode&&(o._options.reValidateMode=e.reValidateMode)},[o,e.mode,e.reValidateMode]),p.useEffect(()=>{e.errors&&(o._setErrors(e.errors),o._focusError())},[o,e.errors]),p.useEffect(()=>{e.shouldUnregister&&o._subjects.state.next({values:o._getWatch()})},[o,e.shouldUnregister]),p.useEffect(()=>{if(o._proxyFormState.isDirty){let e=o._getDirty();e!==i.isDirty&&o._subjects.state.next({isDirty:e})}},[o,i.isDirty]),p.useEffect(()=>{e.values&&!Bi(e.values,n.current)?(o._reset(e.values,{keepFieldsRef:!0,...o._options.resetOptions}),o._options.resetOptions?.keepIsValid||o._setValid(),n.current=e.values,a(e=>({...e}))):o._resetDefaultValues()},[o,e.values]),p.useEffect(()=>{o._state.mount||(o._setValid(),o._state.mount=!0),o._state.watch&&(o._state.watch=!1,o._subjects.state.next({...o._formState})),o._removeUnmounted()}),t.current.formState=p.useMemo(()=>Fi(i,o),[o,i]),t.current}var Ja=(e,t,n)=>{if(e&&`reportValidity`in e){let r=U(n,t);e.setCustomValidity(r&&r.message||``),e.reportValidity()}},Ya=(e,t)=>{for(let n in t.fields){let r=t.fields[n];r&&r.ref&&`reportValidity`in r.ref?Ja(r.ref,n,e):r&&r.refs&&r.refs.forEach(t=>Ja(t,n,e))}},Xa=(e,t)=>{t.shouldUseNativeValidation&&Ya(e,t);let n={};for(let r in e){let i=U(t.fields,r),a=Object.assign(e[r]||{},{ref:i&&i.refs?i.refs[0]:i&&i.ref});if(Za(t.names||Object.keys(e),r)){let e=Object.assign({},U(n,r));Ni(e,`root`,a),Ni(n,r,e)}else Ni(n,r,a)}return n},Za=(e,t)=>{let n=Qa(t).replace(/[.*+?^${}()|\\]/g,`\\$&`);return e.some(e=>Qa(e).match(`^${n}\\.\\d+`))};function Qa(e){return e.replace(/\[(\d+)]/g,`.$1`).replace(/[[\]]/g,``)}function $a(){return $a=Object.assign?Object.assign.bind():function(e){for(var t=1;t0){var s=r.errors.reduce(function(e,t){return t.lengthn?.Parent&&t instanceof n.Parent?!0:t?._zod?.traits?.has(e)}),Object.defineProperty(o,"name",{value:e}),o}var ao=class extends Error{constructor(){super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`)}},G=class extends Error{constructor(e){super(`Encountered unidirectional transform during encode: ${e}`),this.name=`ZodEncodeError`}};(io=globalThis).__zod_globalConfig??(io.__zod_globalConfig={});var oo=globalThis.__zod_globalConfig;function so(e){return e&&Object.assign(oo,e),oo}function co(e){let t=Object.values(e).filter(e=>typeof e==`number`);return Object.entries(e).filter(([e,n])=>t.indexOf(+e)===-1).map(([e,t])=>t)}function lo(e,t){return typeof t==`bigint`?t.toString():t}function uo(e){return{get value(){{let t=e();return Object.defineProperty(this,"value",{value:t}),t}}}}function fo(e){return e==null}function po(e){let t=+!!e.startsWith(`^`),n=e.endsWith(`$`)?e.length-1:e.length;return e.slice(t,n)}function mo(e,t){let n=e/t,r=Math.round(n),i=2**-52*Math.max(Math.abs(n),1);return Math.abs(n-r){};function xo(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}var So=uo(()=>{if(oo.jitless||typeof navigator<`u`&&navigator?.userAgent?.includes(`Cloudflare`))return!1;try{return Function(``),!0}catch{return!1}});function Co(e){if(xo(e)===!1)return!1;let t=e.constructor;if(t===void 0||typeof t!=`function`)return!0;let n=t.prototype;return xo(n)!==!1&&Object.prototype.hasOwnProperty.call(n,`isPrototypeOf`)!==!1}function wo(e){return Co(e)?{...e}:Array.isArray(e)?[...e]:e instanceof Map?new Map(e):e instanceof Set?new Set(e):e}var To=new Set([`string`,`number`,`symbol`]);function Eo(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function Do(e,t,n){let r=new e._zod.constr(t??e._zod.def);return(!t||n?.parent)&&(r._zod.parent=e),r}function q(e){let t=e;if(!t)return{};if(typeof t==`string`)return{error:()=>t};if(t?.message!==void 0){if(t?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");t.error=t.message}return delete t.message,typeof t.error==`string`?{...t,error:()=>t.error}:t}function Oo(e){return Object.keys(e).filter(t=>e[t]._zod.optin===`optional`&&e[t]._zod.optout===`optional`)}var ko={safeint:[-(2**53-1),2**53-1],int32:[-2147483648,2147483647],uint32:[0,4294967295],float32:[-34028234663852886e22,34028234663852886e22],float64:[-Number.MAX_VALUE,Number.MAX_VALUE]};function Ao(e,t){let n=e._zod.def,r=n.checks;if(r&&r.length>0)throw Error(`.pick() cannot be used on object schemas containing refinements`);return Do(e,_o(e._zod.def,{get shape(){let e={};for(let r in t){if(!(r in n.shape))throw Error(`Unrecognized key: "${r}"`);t[r]&&(e[r]=n.shape[r])}return go(this,`shape`,e),e},checks:[]}))}function jo(e,t){let n=e._zod.def,r=n.checks;if(r&&r.length>0)throw Error(`.omit() cannot be used on object schemas containing refinements`);return Do(e,_o(e._zod.def,{get shape(){let r={...e._zod.def.shape};for(let e in t){if(!(e in n.shape))throw Error(`Unrecognized key: "${e}"`);t[e]&&delete r[e]}return go(this,`shape`,r),r},checks:[]}))}function Mo(e,t){if(!Co(t))throw Error(`Invalid input to extend: expected a plain object`);let n=e._zod.def.checks;if(n&&n.length>0){let n=e._zod.def.shape;for(let e in t)if(Object.getOwnPropertyDescriptor(n,e)!==void 0)throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}return Do(e,_o(e._zod.def,{get shape(){let n={...e._zod.def.shape,...t};return go(this,`shape`,n),n}}))}function No(e,t){if(!Co(t))throw Error(`Invalid input to safeExtend: expected a plain object`);return Do(e,_o(e._zod.def,{get shape(){let n={...e._zod.def.shape,...t};return go(this,`shape`,n),n}}))}function Po(e,t){if(e._zod.def.checks?.length)throw Error(`.merge() cannot be used on object schemas containing refinements. Use .safeExtend() instead.`);return Do(e,_o(e._zod.def,{get shape(){let n={...e._zod.def.shape,...t._zod.def.shape};return go(this,`shape`,n),n},get catchall(){return t._zod.def.catchall},checks:t._zod.def.checks??[]}))}function Fo(e,t,n){let r=t._zod.def.checks;if(r&&r.length>0)throw Error(`.partial() cannot be used on object schemas containing refinements`);return Do(t,_o(t._zod.def,{get shape(){let r=t._zod.def.shape,i={...r};if(n)for(let t in n){if(!(t in r))throw Error(`Unrecognized key: "${t}"`);n[t]&&(i[t]=e?new e({type:`optional`,innerType:r[t]}):r[t])}else for(let t in r)i[t]=e?new e({type:`optional`,innerType:r[t]}):r[t];return go(this,`shape`,i),i},checks:[]}))}function Io(e,t,n){return Do(t,_o(t._zod.def,{get shape(){let r=t._zod.def.shape,i={...r};if(n)for(let t in n){if(!(t in i))throw Error(`Unrecognized key: "${t}"`);n[t]&&(i[t]=new e({type:`nonoptional`,innerType:r[t]}))}else for(let t in r)i[t]=new e({type:`nonoptional`,innerType:r[t]});return go(this,`shape`,i),i}}))}function Lo(e,t=0){if(e.aborted===!0)return!0;for(let n=t;n{var n;return(n=t).path??(n.path=[]),t.path.unshift(e),t})}function Bo(e){return typeof e==`string`?e:e?.message}function Vo(e,t,n){let r=e.message?e.message:Bo(e.inst?._zod.def?.error?.(e))??Bo(t?.error?.(e))??Bo(n.customError?.(e))??Bo(n.localeError?.(e))??`Invalid input`,{inst:i,continue:a,input:o,...s}=e;return s.path??=[],s.message=r,t?.reportInput&&(s.input=o),s}function Ho(e){return Array.isArray(e)?`array`:typeof e==`string`?`string`:`unknown`}function Uo(...e){let[t,n,r]=e;return typeof t==`string`?{message:t,code:`custom`,input:n,inst:r}:{...t}}var Wo=(e,t)=>{e.name=`$ZodError`,Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,lo,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},Go=W(`$ZodError`,Wo),Ko=W(`$ZodError`,Wo,{Parent:Error});function qo(e,t=e=>e.message){let n={},r=[];for(let i of e.issues)i.path.length>0?(n[i.path[0]]=n[i.path[0]]||[],n[i.path[0]].push(t(i))):r.push(t(i));return{formErrors:r,fieldErrors:n}}function Jo(e,t=e=>e.message){let n={_errors:[]},r=(e,i=[])=>{for(let a of e.issues)if(a.code===`invalid_union`&&a.errors.length)a.errors.map(e=>r({issues:e},[...i,...a.path]));else if(a.code===`invalid_key`)r({issues:a.issues},[...i,...a.path]);else if(a.code===`invalid_element`)r({issues:a.issues},[...i,...a.path]);else{let e=[...i,...a.path];if(e.length===0)n._errors.push(t(a));else{let r=n,i=0;for(;i(t,n,r,i)=>{let a=r?{...r,async:!1}:{async:!1},o=t._zod.run({value:n,issues:[]},a);if(o instanceof Promise)throw new ao;if(o.issues.length){let t=new((i?.Err)??e)(o.issues.map(e=>Vo(e,a,so())));throw bo(t,i?.callee),t}return o.value},Xo=e=>async(t,n,r,i)=>{let a=r?{...r,async:!0}:{async:!0},o=t._zod.run({value:n,issues:[]},a);if(o instanceof Promise&&(o=await o),o.issues.length){let t=new((i?.Err)??e)(o.issues.map(e=>Vo(e,a,so())));throw bo(t,i?.callee),t}return o.value},Zo=e=>(t,n,r)=>{let i=r?{...r,async:!1}:{async:!1},a=t._zod.run({value:n,issues:[]},i);if(a instanceof Promise)throw new ao;return a.issues.length?{success:!1,error:new(e??Go)(a.issues.map(e=>Vo(e,i,so())))}:{success:!0,data:a.value}},Qo=Zo(Ko),$o=e=>async(t,n,r)=>{let i=r?{...r,async:!0}:{async:!0},a=t._zod.run({value:n,issues:[]},i);return a instanceof Promise&&(a=await a),a.issues.length?{success:!1,error:new e(a.issues.map(e=>Vo(e,i,so())))}:{success:!0,data:a.value}},es=$o(Ko),ts=e=>(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return Yo(e)(t,n,i)},ns=e=>(t,n,r)=>Yo(e)(t,n,r),rs=e=>async(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return Xo(e)(t,n,i)},is=e=>async(t,n,r)=>Xo(e)(t,n,r),as=e=>(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return Zo(e)(t,n,i)},os=e=>(t,n,r)=>Zo(e)(t,n,r),ss=e=>async(t,n,r)=>{let i=r?{...r,direction:`backward`}:{direction:`backward`};return $o(e)(t,n,i)},cs=e=>async(t,n,r)=>$o(e)(t,n,r),ls=/^[cC][0-9a-z]{6,}$/,us=/^[0-9a-z]+$/,ds=/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/,fs=/^[0-9a-vA-V]{20}$/,ps=/^[A-Za-z0-9]{27}$/,ms=/^[a-zA-Z0-9_-]{21}$/,hs=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,gs=/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/,_s=e=>e?RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${e}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`):/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/,vs=/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/,ys=`^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`;function bs(){return new RegExp(ys,`u`)}var xs=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,Ss=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/,Cs=/^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/,ws=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,Ts=/^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/,Es=/^[A-Za-z0-9_-]*$/,Ds=/^https?$/,Os=/^\+[1-9]\d{6,14}$/,ks=`(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))`,As=RegExp(`^${ks}$`);function js(e){let t=`(?:[01]\\d|2[0-3]):[0-5]\\d`;return typeof e.precision==`number`?e.precision===-1?`${t}`:e.precision===0?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}function Ms(e){return RegExp(`^${js(e)}$`)}function Ns(e){let t=js({precision:e.precision}),n=[`Z`];e.local&&n.push(``),e.offset&&n.push(`([+-](?:[01]\\d|2[0-3]):[0-5]\\d)`);let r=`${t}(?:${n.join(`|`)})`;return RegExp(`^${ks}T(?:${r})$`)}var Ps=e=>{let t=e?`[\\s\\S]{${e?.minimum??0},${e?.maximum??``}}`:`[\\s\\S]*`;return RegExp(`^${t}$`)},Fs=/^-?\d+$/,Is=/^-?\d+(?:\.\d+)?$/,Ls=/^(?:true|false)$/i,Rs=/^[^A-Z]*$/,zs=/^[^a-z]*$/,Bs=W(`$ZodCheck`,(e,t)=>{var n;e._zod??={},e._zod.def=t,(n=e._zod).onattach??(n.onattach=[])}),Vs={number:`number`,bigint:`bigint`,object:`date`},Hs=W(`$ZodCheckLessThan`,(e,t)=>{Bs.init(e,t);let n=Vs[typeof t.value];e._zod.onattach.push(e=>{let n=e._zod.bag,r=(t.inclusive?n.maximum:n.exclusiveMaximum)??1/0;t.value{(t.inclusive?r.value<=t.value:r.value{Bs.init(e,t);let n=Vs[typeof t.value];e._zod.onattach.push(e=>{let n=e._zod.bag,r=(t.inclusive?n.minimum:n.exclusiveMinimum)??-1/0;t.value>r&&(t.inclusive?n.minimum=t.value:n.exclusiveMinimum=t.value)}),e._zod.check=r=>{(t.inclusive?r.value>=t.value:r.value>t.value)||r.issues.push({origin:n,code:`too_small`,minimum:typeof t.value==`object`?t.value.getTime():t.value,input:r.value,inclusive:t.inclusive,inst:e,continue:!t.abort})}}),Ws=W(`$ZodCheckMultipleOf`,(e,t)=>{Bs.init(e,t),e._zod.onattach.push(e=>{var n;(n=e._zod.bag).multipleOf??(n.multipleOf=t.value)}),e._zod.check=n=>{if(typeof n.value!=typeof t.value)throw Error(`Cannot mix number and bigint in multiple_of check.`);(typeof n.value==`bigint`?n.value%t.value===BigInt(0):mo(n.value,t.value)===0)||n.issues.push({origin:typeof n.value,code:`not_multiple_of`,divisor:t.value,input:n.value,inst:e,continue:!t.abort})}}),Gs=W(`$ZodCheckNumberFormat`,(e,t)=>{Bs.init(e,t),t.format=t.format||`float64`;let n=t.format?.includes(`int`),r=n?`int`:`number`,[i,a]=ko[t.format];e._zod.onattach.push(e=>{let r=e._zod.bag;r.format=t.format,r.minimum=i,r.maximum=a,n&&(r.pattern=Fs)}),e._zod.check=o=>{let s=o.value;if(n){if(!Number.isInteger(s)){o.issues.push({expected:r,format:t.format,code:`invalid_type`,continue:!1,input:s,inst:e});return}if(!Number.isSafeInteger(s)){s>0?o.issues.push({input:s,code:`too_big`,maximum:2**53-1,note:`Integers must be within the safe integer range.`,inst:e,origin:r,inclusive:!0,continue:!t.abort}):o.issues.push({input:s,code:`too_small`,minimum:-(2**53-1),note:`Integers must be within the safe integer range.`,inst:e,origin:r,inclusive:!0,continue:!t.abort});return}}sa&&o.issues.push({origin:`number`,input:s,code:`too_big`,maximum:a,inclusive:!0,inst:e,continue:!t.abort})}}),Ks=W(`$ZodCheckMaxLength`,(e,t)=>{var n;Bs.init(e,t),(n=e._zod.def).when??(n.when=e=>{let t=e.value;return!fo(t)&&t.length!==void 0}),e._zod.onattach.push(e=>{let n=e._zod.bag.maximum??1/0;t.maximum{let r=n.value;if(r.length<=t.maximum)return;let i=Ho(r);n.issues.push({origin:i,code:`too_big`,maximum:t.maximum,inclusive:!0,input:r,inst:e,continue:!t.abort})}}),qs=W(`$ZodCheckMinLength`,(e,t)=>{var n;Bs.init(e,t),(n=e._zod.def).when??(n.when=e=>{let t=e.value;return!fo(t)&&t.length!==void 0}),e._zod.onattach.push(e=>{let n=e._zod.bag.minimum??-1/0;t.minimum>n&&(e._zod.bag.minimum=t.minimum)}),e._zod.check=n=>{let r=n.value;if(r.length>=t.minimum)return;let i=Ho(r);n.issues.push({origin:i,code:`too_small`,minimum:t.minimum,inclusive:!0,input:r,inst:e,continue:!t.abort})}}),Js=W(`$ZodCheckLengthEquals`,(e,t)=>{var n;Bs.init(e,t),(n=e._zod.def).when??(n.when=e=>{let t=e.value;return!fo(t)&&t.length!==void 0}),e._zod.onattach.push(e=>{let n=e._zod.bag;n.minimum=t.length,n.maximum=t.length,n.length=t.length}),e._zod.check=n=>{let r=n.value,i=r.length;if(i===t.length)return;let a=Ho(r),o=i>t.length;n.issues.push({origin:a,...o?{code:`too_big`,maximum:t.length}:{code:`too_small`,minimum:t.length},inclusive:!0,exact:!0,input:n.value,inst:e,continue:!t.abort})}}),Ys=W(`$ZodCheckStringFormat`,(e,t)=>{var n,r;Bs.init(e,t),e._zod.onattach.push(e=>{let n=e._zod.bag;n.format=t.format,t.pattern&&(n.patterns??=new Set,n.patterns.add(t.pattern))}),t.pattern?(n=e._zod).check??(n.check=n=>{t.pattern.lastIndex=0,!t.pattern.test(n.value)&&n.issues.push({origin:`string`,code:`invalid_format`,format:t.format,input:n.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(r=e._zod).check??(r.check=()=>{})}),Xs=W(`$ZodCheckRegex`,(e,t)=>{Ys.init(e,t),e._zod.check=n=>{t.pattern.lastIndex=0,!t.pattern.test(n.value)&&n.issues.push({origin:`string`,code:`invalid_format`,format:`regex`,input:n.value,pattern:t.pattern.toString(),inst:e,continue:!t.abort})}}),Zs=W(`$ZodCheckLowerCase`,(e,t)=>{t.pattern??=Rs,Ys.init(e,t)}),Qs=W(`$ZodCheckUpperCase`,(e,t)=>{t.pattern??=zs,Ys.init(e,t)}),$s=W(`$ZodCheckIncludes`,(e,t)=>{Bs.init(e,t);let n=Eo(t.includes),r=new RegExp(typeof t.position==`number`?`^.{${t.position}}${n}`:n);t.pattern=r,e._zod.onattach.push(e=>{let t=e._zod.bag;t.patterns??=new Set,t.patterns.add(r)}),e._zod.check=n=>{n.value.includes(t.includes,t.position)||n.issues.push({origin:`string`,code:`invalid_format`,format:`includes`,includes:t.includes,input:n.value,inst:e,continue:!t.abort})}}),ec=W(`$ZodCheckStartsWith`,(e,t)=>{Bs.init(e,t);let n=RegExp(`^${Eo(t.prefix)}.*`);t.pattern??=n,e._zod.onattach.push(e=>{let t=e._zod.bag;t.patterns??=new Set,t.patterns.add(n)}),e._zod.check=n=>{n.value.startsWith(t.prefix)||n.issues.push({origin:`string`,code:`invalid_format`,format:`starts_with`,prefix:t.prefix,input:n.value,inst:e,continue:!t.abort})}}),tc=W(`$ZodCheckEndsWith`,(e,t)=>{Bs.init(e,t);let n=RegExp(`.*${Eo(t.suffix)}$`);t.pattern??=n,e._zod.onattach.push(e=>{let t=e._zod.bag;t.patterns??=new Set,t.patterns.add(n)}),e._zod.check=n=>{n.value.endsWith(t.suffix)||n.issues.push({origin:`string`,code:`invalid_format`,format:`ends_with`,suffix:t.suffix,input:n.value,inst:e,continue:!t.abort})}}),nc=W(`$ZodCheckOverwrite`,(e,t)=>{Bs.init(e,t),e._zod.check=e=>{e.value=t.tx(e.value)}}),rc=class{constructor(e=[]){this.content=[],this.indent=0,this&&(this.args=e)}indented(e){this.indent+=1,e(this),--this.indent}write(e){if(typeof e==`function`){e(this,{execution:`sync`}),e(this,{execution:`async`});return}let t=e.split(` +`).filter(e=>e),n=Math.min(...t.map(e=>e.length-e.trimStart().length)),r=t.map(e=>e.slice(n)).map(e=>` `.repeat(this.indent*2)+e);for(let e of r)this.content.push(e)}compile(){let e=Function,t=this?.args,n=[...(this?.content??[``]).map(e=>` ${e}`)];return new e(...t,n.join(` +`))}},ic={major:4,minor:4,patch:3},ac=W(`$ZodType`,(e,t)=>{var n;e??={},e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=ic;let r=[...e._zod.def.checks??[]];e._zod.traits.has(`$ZodCheck`)&&r.unshift(e);for(let t of r)for(let n of t._zod.onattach)n(e);if(r.length===0)(n=e._zod).deferred??(n.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{let t=(e,t,n)=>{let r=Lo(e),i;for(let a of t){if(a._zod.def.when){if(Ro(e)||!a._zod.def.when(e))continue}else if(r)continue;let t=e.issues.length,o=a._zod.check(e);if(o instanceof Promise&&n?.async===!1)throw new ao;if(i||o instanceof Promise)i=(i??Promise.resolve()).then(async()=>{await o,e.issues.length!==t&&(r||=Lo(e,t))});else{if(e.issues.length===t)continue;r||=Lo(e,t)}}return i?i.then(()=>e):e},n=(n,i,a)=>{if(Lo(n))return n.aborted=!0,n;let o=t(i,r,a);if(o instanceof Promise){if(a.async===!1)throw new ao;return o.then(t=>e._zod.parse(t,a))}return e._zod.parse(o,a)};e._zod.run=(i,a)=>{if(a.skipChecks)return e._zod.parse(i,a);if(a.direction===`backward`){let t=e._zod.parse({value:i.value,issues:[]},{...a,skipChecks:!0});return t instanceof Promise?t.then(e=>n(e,i,a)):n(t,i,a)}let o=e._zod.parse(i,a);if(o instanceof Promise){if(a.async===!1)throw new ao;return o.then(e=>t(e,r,a))}return t(o,r,a)}}K(e,`~standard`,()=>({validate:t=>{try{let n=Qo(e,t);return n.success?{value:n.data}:{issues:n.error?.issues}}catch{return es(e,t).then(e=>e.success?{value:e.data}:{issues:e.error?.issues})}},vendor:`zod`,version:1}))}),oc=W(`$ZodString`,(e,t)=>{ac.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??Ps(e._zod.bag),e._zod.parse=(n,r)=>{if(t.coerce)try{n.value=String(n.value)}catch{}return typeof n.value==`string`||n.issues.push({expected:`string`,code:`invalid_type`,input:n.value,inst:e}),n}}),sc=W(`$ZodStringFormat`,(e,t)=>{Ys.init(e,t),oc.init(e,t)}),cc=W(`$ZodGUID`,(e,t)=>{t.pattern??=gs,sc.init(e,t)}),lc=W(`$ZodUUID`,(e,t)=>{if(t.version){let e={v1:1,v2:2,v3:3,v4:4,v5:5,v6:6,v7:7,v8:8}[t.version];if(e===void 0)throw Error(`Invalid UUID version: "${t.version}"`);t.pattern??=_s(e)}else t.pattern??=_s();sc.init(e,t)}),uc=W(`$ZodEmail`,(e,t)=>{t.pattern??=vs,sc.init(e,t)}),dc=W(`$ZodURL`,(e,t)=>{sc.init(e,t),e._zod.check=n=>{try{let r=n.value.trim();if(!t.normalize&&t.protocol?.source===Ds.source&&!/^https?:\/\//i.test(r)){n.issues.push({code:`invalid_format`,format:`url`,note:`Invalid URL format`,input:n.value,inst:e,continue:!t.abort});return}let i=new URL(r);t.hostname&&(t.hostname.lastIndex=0,t.hostname.test(i.hostname)||n.issues.push({code:`invalid_format`,format:`url`,note:`Invalid hostname`,pattern:t.hostname.source,input:n.value,inst:e,continue:!t.abort})),t.protocol&&(t.protocol.lastIndex=0,t.protocol.test(i.protocol.endsWith(`:`)?i.protocol.slice(0,-1):i.protocol)||n.issues.push({code:`invalid_format`,format:`url`,note:`Invalid protocol`,pattern:t.protocol.source,input:n.value,inst:e,continue:!t.abort})),n.value=t.normalize?i.href:r;return}catch{n.issues.push({code:`invalid_format`,format:`url`,input:n.value,inst:e,continue:!t.abort})}}}),fc=W(`$ZodEmoji`,(e,t)=>{t.pattern??=bs(),sc.init(e,t)}),pc=W(`$ZodNanoID`,(e,t)=>{t.pattern??=ms,sc.init(e,t)}),mc=W(`$ZodCUID`,(e,t)=>{t.pattern??=ls,sc.init(e,t)}),hc=W(`$ZodCUID2`,(e,t)=>{t.pattern??=us,sc.init(e,t)}),gc=W(`$ZodULID`,(e,t)=>{t.pattern??=ds,sc.init(e,t)}),_c=W(`$ZodXID`,(e,t)=>{t.pattern??=fs,sc.init(e,t)}),vc=W(`$ZodKSUID`,(e,t)=>{t.pattern??=ps,sc.init(e,t)}),yc=W(`$ZodISODateTime`,(e,t)=>{t.pattern??=Ns(t),sc.init(e,t)}),bc=W(`$ZodISODate`,(e,t)=>{t.pattern??=As,sc.init(e,t)}),xc=W(`$ZodISOTime`,(e,t)=>{t.pattern??=Ms(t),sc.init(e,t)}),Sc=W(`$ZodISODuration`,(e,t)=>{t.pattern??=hs,sc.init(e,t)}),Cc=W(`$ZodIPv4`,(e,t)=>{t.pattern??=xs,sc.init(e,t),e._zod.bag.format=`ipv4`}),wc=W(`$ZodIPv6`,(e,t)=>{t.pattern??=Ss,sc.init(e,t),e._zod.bag.format=`ipv6`,e._zod.check=n=>{try{new URL(`http://[${n.value}]`)}catch{n.issues.push({code:`invalid_format`,format:`ipv6`,input:n.value,inst:e,continue:!t.abort})}}}),Tc=W(`$ZodCIDRv4`,(e,t)=>{t.pattern??=Cs,sc.init(e,t)}),Ec=W(`$ZodCIDRv6`,(e,t)=>{t.pattern??=ws,sc.init(e,t),e._zod.check=n=>{let r=n.value.split(`/`);try{if(r.length!==2)throw Error();let[e,t]=r;if(!t)throw Error();let n=Number(t);if(`${n}`!==t||n<0||n>128)throw Error();new URL(`http://[${e}]`)}catch{n.issues.push({code:`invalid_format`,format:`cidrv6`,input:n.value,inst:e,continue:!t.abort})}}});function Dc(e){if(e===``)return!0;if(/\s/.test(e)||e.length%4!=0)return!1;try{return atob(e),!0}catch{return!1}}var Oc=W(`$ZodBase64`,(e,t)=>{t.pattern??=Ts,sc.init(e,t),e._zod.bag.contentEncoding=`base64`,e._zod.check=n=>{Dc(n.value)||n.issues.push({code:`invalid_format`,format:`base64`,input:n.value,inst:e,continue:!t.abort})}});function kc(e){if(!Es.test(e))return!1;let t=e.replace(/[-_]/g,e=>e===`-`?`+`:`/`);return Dc(t.padEnd(Math.ceil(t.length/4)*4,`=`))}var Ac=W(`$ZodBase64URL`,(e,t)=>{t.pattern??=Es,sc.init(e,t),e._zod.bag.contentEncoding=`base64url`,e._zod.check=n=>{kc(n.value)||n.issues.push({code:`invalid_format`,format:`base64url`,input:n.value,inst:e,continue:!t.abort})}}),jc=W(`$ZodE164`,(e,t)=>{t.pattern??=Os,sc.init(e,t)});function Mc(e,t=null){try{let n=e.split(`.`);if(n.length!==3)return!1;let[r]=n;if(!r)return!1;let i=JSON.parse(atob(r));return!(`typ`in i&&i?.typ!==`JWT`||!i.alg||t&&(!(`alg`in i)||i.alg!==t))}catch{return!1}}var Nc=W(`$ZodJWT`,(e,t)=>{sc.init(e,t),e._zod.check=n=>{Mc(n.value,t.alg)||n.issues.push({code:`invalid_format`,format:`jwt`,input:n.value,inst:e,continue:!t.abort})}}),Pc=W(`$ZodNumber`,(e,t)=>{ac.init(e,t),e._zod.pattern=e._zod.bag.pattern??Is,e._zod.parse=(n,r)=>{if(t.coerce)try{n.value=Number(n.value)}catch{}let i=n.value;if(typeof i==`number`&&!Number.isNaN(i)&&Number.isFinite(i))return n;let a=typeof i==`number`?Number.isNaN(i)?`NaN`:Number.isFinite(i)?void 0:`Infinity`:void 0;return n.issues.push({expected:`number`,code:`invalid_type`,input:i,inst:e,...a?{received:a}:{}}),n}}),Fc=W(`$ZodNumberFormat`,(e,t)=>{Gs.init(e,t),Pc.init(e,t)}),Ic=W(`$ZodBoolean`,(e,t)=>{ac.init(e,t),e._zod.pattern=Ls,e._zod.parse=(n,r)=>{if(t.coerce)try{n.value=!!n.value}catch{}let i=n.value;return typeof i==`boolean`||n.issues.push({expected:`boolean`,code:`invalid_type`,input:i,inst:e}),n}}),Lc=W(`$ZodUnknown`,(e,t)=>{ac.init(e,t),e._zod.parse=e=>e}),Rc=W(`$ZodNever`,(e,t)=>{ac.init(e,t),e._zod.parse=(t,n)=>(t.issues.push({expected:`never`,code:`invalid_type`,input:t.value,inst:e}),t)});function zc(e,t,n){e.issues.length&&t.issues.push(...zo(n,e.issues)),t.value[n]=e.value}var Bc=W(`$ZodArray`,(e,t)=>{ac.init(e,t),e._zod.parse=(n,r)=>{let i=n.value;if(!Array.isArray(i))return n.issues.push({expected:`array`,code:`invalid_type`,input:i,inst:e}),n;n.value=Array(i.length);let a=[];for(let e=0;ezc(t,n,e))):zc(s,n,e)}return a.length?Promise.all(a).then(()=>n):n}});function Vc(e,t,n,r,i,a){let o=n in r;if(e.issues.length){if(i&&a&&!o)return;t.issues.push(...zo(n,e.issues))}if(!o&&!i){e.issues.length||t.issues.push({code:`invalid_type`,expected:`nonoptional`,input:void 0,path:[n]});return}e.value===void 0?o&&(t.value[n]=void 0):t.value[n]=e.value}function Hc(e){let t=Object.keys(e.shape);for(let n of t)if(!e.shape?.[n]?._zod?.traits?.has(`$ZodType`))throw Error(`Invalid element at key "${n}": expected a Zod schema`);let n=Oo(e.shape);return{...e,keys:t,keySet:new Set(t),numKeys:t.length,optionalKeys:new Set(n)}}function Uc(e,t,n,r,i,a){let o=[],s=i.keySet,c=i.catchall._zod,l=c.def.type,u=c.optin===`optional`,d=c.optout===`optional`;for(let i in t){if(i===`__proto__`||s.has(i))continue;if(l===`never`){o.push(i);continue}let a=c.run({value:t[i],issues:[]},r);a instanceof Promise?e.push(a.then(e=>Vc(e,n,i,t,u,d))):Vc(a,n,i,t,u,d)}return o.length&&n.issues.push({code:`unrecognized_keys`,keys:o,input:t,inst:a}),e.length?Promise.all(e).then(()=>n):n}var Wc=W(`$ZodObject`,(e,t)=>{if(ac.init(e,t),!Object.getOwnPropertyDescriptor(t,`shape`)?.get){let e=t.shape;Object.defineProperty(t,"shape",{get:()=>{let n={...e};return Object.defineProperty(t,"shape",{value:n}),n}})}let n=uo(()=>Hc(t));K(e._zod,`propValues`,()=>{let e=t.shape,n={};for(let t in e){let r=e[t]._zod;if(r.values){n[t]??(n[t]=new Set);for(let e of r.values)n[t].add(e)}}return n});let r=xo,i=t.catchall,a;e._zod.parse=(t,o)=>{a??=n.value;let s=t.value;if(!r(s))return t.issues.push({expected:`object`,code:`invalid_type`,input:s,inst:e}),t;t.value={};let c=[],l=a.shape;for(let e of a.keys){let n=l[e],r=n._zod.optin===`optional`,i=n._zod.optout===`optional`,a=n._zod.run({value:s[e],issues:[]},o);a instanceof Promise?c.push(a.then(n=>Vc(n,t,e,s,r,i))):Vc(a,t,e,s,r,i)}return i?Uc(c,s,t,o,n.value,e):c.length?Promise.all(c).then(()=>t):t}}),Gc=W(`$ZodObjectJIT`,(e,t)=>{Wc.init(e,t);let n=e._zod.parse,r=uo(()=>Hc(t)),i=e=>{let t=new rc([`shape`,`payload`,`ctx`]),n=r.value,i=e=>{let t=vo(e);return`shape[${t}]._zod.run({ value: input[${t}], issues: [] }, ctx)`};t.write(`const input = payload.value;`);let a=Object.create(null),o=0;for(let e of n.keys)a[e]=`key_${o++}`;t.write(`const newResult = {};`);for(let r of n.keys){let n=a[r],o=vo(r),s=e[r],c=s?._zod?.optin===`optional`,l=s?._zod?.optout===`optional`;t.write(`const ${n} = ${i(r)};`),c&&l?t.write(` + if (${n}.issues.length) { + if (${o} in input) { + payload.issues = payload.issues.concat(${n}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${o}, ...iss.path] : [${o}] + }))); + } + } + + if (${n}.value === undefined) { + if (${o} in input) { + newResult[${o}] = undefined; + } + } else { + newResult[${o}] = ${n}.value; + } + + `):c?t.write(` + if (${n}.issues.length) { + payload.issues = payload.issues.concat(${n}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${o}, ...iss.path] : [${o}] + }))); + } + + if (${n}.value === undefined) { + if (${o} in input) { + newResult[${o}] = undefined; + } + } else { + newResult[${o}] = ${n}.value; + } + + `):t.write(` + const ${n}_present = ${o} in input; + if (${n}.issues.length) { + payload.issues = payload.issues.concat(${n}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${o}, ...iss.path] : [${o}] + }))); + } + if (!${n}_present && !${n}.issues.length) { + payload.issues.push({ + code: "invalid_type", + expected: "nonoptional", + input: undefined, + path: [${o}] + }); + } + + if (${n}_present) { + if (${n}.value === undefined) { + newResult[${o}] = undefined; + } else { + newResult[${o}] = ${n}.value; + } + } + + `)}t.write(`payload.value = newResult;`),t.write(`return payload;`);let s=t.compile();return(t,n)=>s(e,t,n)},a,o=xo,s=!oo.jitless,c=s&&So.value,l=t.catchall,u;e._zod.parse=(d,f)=>{u??=r.value;let p=d.value;return o(p)?s&&c&&f?.async===!1&&f.jitless!==!0?(a||=i(t.shape),d=a(d,f),l?Uc([],p,d,f,u,e):d):n(d,f):(d.issues.push({expected:`object`,code:`invalid_type`,input:p,inst:e}),d)}});function Kc(e,t,n,r){for(let n of e)if(n.issues.length===0)return t.value=n.value,t;let i=e.filter(e=>!Lo(e));return i.length===1?(t.value=i[0].value,i[0]):(t.issues.push({code:`invalid_union`,input:t.value,inst:n,errors:e.map(e=>e.issues.map(e=>Vo(e,r,so())))}),t)}var qc=W(`$ZodUnion`,(e,t)=>{ac.init(e,t),K(e._zod,`optin`,()=>t.options.some(e=>e._zod.optin===`optional`)?`optional`:void 0),K(e._zod,`optout`,()=>t.options.some(e=>e._zod.optout===`optional`)?`optional`:void 0),K(e._zod,`values`,()=>{if(t.options.every(e=>e._zod.values))return new Set(t.options.flatMap(e=>Array.from(e._zod.values)))}),K(e._zod,`pattern`,()=>{if(t.options.every(e=>e._zod.pattern)){let e=t.options.map(e=>e._zod.pattern);return RegExp(`^(${e.map(e=>po(e.source)).join(`|`)})$`)}});let n=t.options.length===1?t.options[0]._zod.run:null;e._zod.parse=(r,i)=>{if(n)return n(r,i);let a=!1,o=[];for(let e of t.options){let t=e._zod.run({value:r.value,issues:[]},i);if(t instanceof Promise)o.push(t),a=!0;else{if(t.issues.length===0)return t;o.push(t)}}return a?Promise.all(o).then(t=>Kc(t,r,e,i)):Kc(o,r,e,i)}}),Jc=W(`$ZodIntersection`,(e,t)=>{ac.init(e,t),e._zod.parse=(e,n)=>{let r=e.value,i=t.left._zod.run({value:r,issues:[]},n),a=t.right._zod.run({value:r,issues:[]},n);return i instanceof Promise||a instanceof Promise?Promise.all([i,a]).then(([t,n])=>Xc(e,t,n)):Xc(e,i,a)}});function Yc(e,t){if(e===t||e instanceof Date&&t instanceof Date&&+e==+t)return{valid:!0,data:e};if(Co(e)&&Co(t)){let n=Object.keys(t),r=Object.keys(e).filter(e=>n.indexOf(e)!==-1),i={...e,...t};for(let n of r){let r=Yc(e[n],t[n]);if(!r.valid)return{valid:!1,mergeErrorPath:[n,...r.mergeErrorPath]};i[n]=r.data}return{valid:!0,data:i}}if(Array.isArray(e)&&Array.isArray(t)){if(e.length!==t.length)return{valid:!1,mergeErrorPath:[]};let n=[];for(let r=0;re.l&&e.r).map(([e])=>e);if(a.length&&i&&e.issues.push({...i,keys:a}),Lo(e))return e;let o=Yc(t.value,n.value);if(!o.valid)throw Error(`Unmergable intersection. Error path: ${JSON.stringify(o.mergeErrorPath)}`);return e.value=o.data,e}var Zc=W(`$ZodEnum`,(e,t)=>{ac.init(e,t);let n=co(t.entries),r=new Set(n);e._zod.values=r,e._zod.pattern=RegExp(`^(${n.filter(e=>To.has(typeof e)).map(e=>typeof e==`string`?Eo(e):e.toString()).join(`|`)})$`),e._zod.parse=(t,i)=>{let a=t.value;return r.has(a)||t.issues.push({code:`invalid_value`,values:n,input:a,inst:e}),t}}),Qc=W(`$ZodLiteral`,(e,t)=>{if(ac.init(e,t),t.values.length===0)throw Error(`Cannot create literal schema with no valid values`);let n=new Set(t.values);e._zod.values=n,e._zod.pattern=RegExp(`^(${t.values.map(e=>typeof e==`string`?Eo(e):e?Eo(e.toString()):String(e)).join(`|`)})$`),e._zod.parse=(r,i)=>{let a=r.value;return n.has(a)||r.issues.push({code:`invalid_value`,values:t.values,input:a,inst:e}),r}}),$c=W(`$ZodTransform`,(e,t)=>{ac.init(e,t),e._zod.optin=`optional`,e._zod.parse=(n,r)=>{if(r.direction===`backward`)throw new G(e.constructor.name);let i=t.transform(n.value,n);if(r.async)return(i instanceof Promise?i:Promise.resolve(i)).then(e=>(n.value=e,n.fallback=!0,n));if(i instanceof Promise)throw new ao;return n.value=i,n.fallback=!0,n}});function el(e,t){return t===void 0&&(e.issues.length||e.fallback)?{issues:[],value:void 0}:e}var tl=W(`$ZodOptional`,(e,t)=>{ac.init(e,t),e._zod.optin=`optional`,e._zod.optout=`optional`,K(e._zod,`values`,()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),K(e._zod,`pattern`,()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${po(e.source)})?$`):void 0}),e._zod.parse=(e,n)=>{if(t.innerType._zod.optin===`optional`){let r=e.value,i=t.innerType._zod.run(e,n);return i instanceof Promise?i.then(e=>el(e,r)):el(i,r)}return e.value===void 0?e:t.innerType._zod.run(e,n)}}),nl=W(`$ZodExactOptional`,(e,t)=>{tl.init(e,t),K(e._zod,`values`,()=>t.innerType._zod.values),K(e._zod,`pattern`,()=>t.innerType._zod.pattern),e._zod.parse=(e,n)=>t.innerType._zod.run(e,n)}),rl=W(`$ZodNullable`,(e,t)=>{ac.init(e,t),K(e._zod,`optin`,()=>t.innerType._zod.optin),K(e._zod,`optout`,()=>t.innerType._zod.optout),K(e._zod,`pattern`,()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${po(e.source)}|null)$`):void 0}),K(e._zod,`values`,()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(e,n)=>e.value===null?e:t.innerType._zod.run(e,n)}),il=W(`$ZodDefault`,(e,t)=>{ac.init(e,t),e._zod.optin=`optional`,K(e._zod,`values`,()=>t.innerType._zod.values),e._zod.parse=(e,n)=>{if(n.direction===`backward`)return t.innerType._zod.run(e,n);if(e.value===void 0)return e.value=t.defaultValue,e;let r=t.innerType._zod.run(e,n);return r instanceof Promise?r.then(e=>al(e,t)):al(r,t)}});function al(e,t){return e.value===void 0&&(e.value=t.defaultValue),e}var ol=W(`$ZodPrefault`,(e,t)=>{ac.init(e,t),e._zod.optin=`optional`,K(e._zod,`values`,()=>t.innerType._zod.values),e._zod.parse=(e,n)=>(n.direction===`backward`||e.value===void 0&&(e.value=t.defaultValue),t.innerType._zod.run(e,n))}),sl=W(`$ZodNonOptional`,(e,t)=>{ac.init(e,t),K(e._zod,`values`,()=>{let e=t.innerType._zod.values;return e?new Set([...e].filter(e=>e!==void 0)):void 0}),e._zod.parse=(n,r)=>{let i=t.innerType._zod.run(n,r);return i instanceof Promise?i.then(t=>cl(t,e)):cl(i,e)}});function cl(e,t){return!e.issues.length&&e.value===void 0&&e.issues.push({code:`invalid_type`,expected:`nonoptional`,input:e.value,inst:t}),e}var ll=W(`$ZodCatch`,(e,t)=>{ac.init(e,t),e._zod.optin=`optional`,K(e._zod,`optout`,()=>t.innerType._zod.optout),K(e._zod,`values`,()=>t.innerType._zod.values),e._zod.parse=(e,n)=>{if(n.direction===`backward`)return t.innerType._zod.run(e,n);let r=t.innerType._zod.run(e,n);return r instanceof Promise?r.then(r=>(e.value=r.value,r.issues.length&&(e.value=t.catchValue({...e,error:{issues:r.issues.map(e=>Vo(e,n,so()))},input:e.value}),e.issues=[],e.fallback=!0),e)):(e.value=r.value,r.issues.length&&(e.value=t.catchValue({...e,error:{issues:r.issues.map(e=>Vo(e,n,so()))},input:e.value}),e.issues=[],e.fallback=!0),e)}}),ul=W(`$ZodPipe`,(e,t)=>{ac.init(e,t),K(e._zod,`values`,()=>t.in._zod.values),K(e._zod,`optin`,()=>t.in._zod.optin),K(e._zod,`optout`,()=>t.out._zod.optout),K(e._zod,`propValues`,()=>t.in._zod.propValues),e._zod.parse=(e,n)=>{if(n.direction===`backward`){let r=t.out._zod.run(e,n);return r instanceof Promise?r.then(e=>dl(e,t.in,n)):dl(r,t.in,n)}let r=t.in._zod.run(e,n);return r instanceof Promise?r.then(e=>dl(e,t.out,n)):dl(r,t.out,n)}});function dl(e,t,n){return e.issues.length?(e.aborted=!0,e):t._zod.run({value:e.value,issues:e.issues,fallback:e.fallback},n)}var fl=W(`$ZodReadonly`,(e,t)=>{ac.init(e,t),K(e._zod,`propValues`,()=>t.innerType._zod.propValues),K(e._zod,`values`,()=>t.innerType._zod.values),K(e._zod,`optin`,()=>t.innerType?._zod?.optin),K(e._zod,`optout`,()=>t.innerType?._zod?.optout),e._zod.parse=(e,n)=>{if(n.direction===`backward`)return t.innerType._zod.run(e,n);let r=t.innerType._zod.run(e,n);return r instanceof Promise?r.then(pl):pl(r)}});function pl(e){return e.value=Object.freeze(e.value),e}var ml=W(`$ZodCustom`,(e,t)=>{Bs.init(e,t),ac.init(e,t),e._zod.parse=(e,t)=>e,e._zod.check=n=>{let r=n.value,i=t.fn(r);if(i instanceof Promise)return i.then(t=>hl(t,n,r,e));hl(i,n,r,e)}});function hl(e,t,n,r){if(!e){let e={code:`custom`,input:n,inst:r,path:[...r._zod.def.path??[]],continue:!r._zod.def.abort};r._zod.def.params&&(e.params=r._zod.def.params),t.issues.push(Uo(e))}}var gl,_l=class{constructor(){this._map=new WeakMap,this._idmap=new Map}add(e,...t){let n=t[0];return this._map.set(e,n),n&&typeof n==`object`&&`id`in n&&this._idmap.set(n.id,e),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(e){let t=this._map.get(e);return t&&typeof t==`object`&&`id`in t&&this._idmap.delete(t.id),this._map.delete(e),this}get(e){let t=e._zod.parent;if(t){let n={...this.get(t)??{}};delete n.id;let r={...n,...this._map.get(e)};return Object.keys(r).length?r:void 0}return this._map.get(e)}has(e){return this._map.has(e)}};function vl(){return new _l}(gl=globalThis).__zod_globalRegistry??(gl.__zod_globalRegistry=vl());var yl=globalThis.__zod_globalRegistry;function bl(e,t){return new e({type:`string`,...q(t)})}function xl(e,t){return new e({type:`string`,format:`email`,check:`string_format`,abort:!1,...q(t)})}function Sl(e,t){return new e({type:`string`,format:`guid`,check:`string_format`,abort:!1,...q(t)})}function Cl(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,...q(t)})}function wl(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,version:`v4`,...q(t)})}function Tl(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,version:`v6`,...q(t)})}function El(e,t){return new e({type:`string`,format:`uuid`,check:`string_format`,abort:!1,version:`v7`,...q(t)})}function Dl(e,t){return new e({type:`string`,format:`url`,check:`string_format`,abort:!1,...q(t)})}function Ol(e,t){return new e({type:`string`,format:`emoji`,check:`string_format`,abort:!1,...q(t)})}function kl(e,t){return new e({type:`string`,format:`nanoid`,check:`string_format`,abort:!1,...q(t)})}function Al(e,t){return new e({type:`string`,format:`cuid`,check:`string_format`,abort:!1,...q(t)})}function jl(e,t){return new e({type:`string`,format:`cuid2`,check:`string_format`,abort:!1,...q(t)})}function Ml(e,t){return new e({type:`string`,format:`ulid`,check:`string_format`,abort:!1,...q(t)})}function Nl(e,t){return new e({type:`string`,format:`xid`,check:`string_format`,abort:!1,...q(t)})}function Pl(e,t){return new e({type:`string`,format:`ksuid`,check:`string_format`,abort:!1,...q(t)})}function Fl(e,t){return new e({type:`string`,format:`ipv4`,check:`string_format`,abort:!1,...q(t)})}function Il(e,t){return new e({type:`string`,format:`ipv6`,check:`string_format`,abort:!1,...q(t)})}function J(e,t){return new e({type:`string`,format:`cidrv4`,check:`string_format`,abort:!1,...q(t)})}function Y(e,t){return new e({type:`string`,format:`cidrv6`,check:`string_format`,abort:!1,...q(t)})}function Ll(e,t){return new e({type:`string`,format:`base64`,check:`string_format`,abort:!1,...q(t)})}function Rl(e,t){return new e({type:`string`,format:`base64url`,check:`string_format`,abort:!1,...q(t)})}function zl(e,t){return new e({type:`string`,format:`e164`,check:`string_format`,abort:!1,...q(t)})}function Bl(e,t){return new e({type:`string`,format:`jwt`,check:`string_format`,abort:!1,...q(t)})}function Vl(e,t){return new e({type:`string`,format:`datetime`,check:`string_format`,offset:!1,local:!1,precision:null,...q(t)})}function Hl(e,t){return new e({type:`string`,format:`date`,check:`string_format`,...q(t)})}function Ul(e,t){return new e({type:`string`,format:`time`,check:`string_format`,precision:null,...q(t)})}function Wl(e,t){return new e({type:`string`,format:`duration`,check:`string_format`,...q(t)})}function Gl(e,t){return new e({type:`number`,checks:[],...q(t)})}function Kl(e,t){return new e({type:`number`,check:`number_format`,abort:!1,format:`safeint`,...q(t)})}function ql(e,t){return new e({type:`boolean`,...q(t)})}function Jl(e){return new e({type:`unknown`})}function Yl(e,t){return new e({type:`never`,...q(t)})}function Xl(e,t){return new Hs({check:`less_than`,...q(t),value:e,inclusive:!1})}function Zl(e,t){return new Hs({check:`less_than`,...q(t),value:e,inclusive:!0})}function Ql(e,t){return new Us({check:`greater_than`,...q(t),value:e,inclusive:!1})}function $l(e,t){return new Us({check:`greater_than`,...q(t),value:e,inclusive:!0})}function eu(e,t){return new Ws({check:`multiple_of`,...q(t),value:e})}function tu(e,t){return new Ks({check:`max_length`,...q(t),maximum:e})}function nu(e,t){return new qs({check:`min_length`,...q(t),minimum:e})}function ru(e,t){return new Js({check:`length_equals`,...q(t),length:e})}function iu(e,t){return new Xs({check:`string_format`,format:`regex`,...q(t),pattern:e})}function au(e){return new Zs({check:`string_format`,format:`lowercase`,...q(e)})}function ou(e){return new Qs({check:`string_format`,format:`uppercase`,...q(e)})}function su(e,t){return new $s({check:`string_format`,format:`includes`,...q(t),includes:e})}function cu(e,t){return new ec({check:`string_format`,format:`starts_with`,...q(t),prefix:e})}function lu(e,t){return new tc({check:`string_format`,format:`ends_with`,...q(t),suffix:e})}function uu(e){return new nc({check:`overwrite`,tx:e})}function du(e){return uu(t=>t.normalize(e))}function fu(){return uu(e=>e.trim())}function pu(){return uu(e=>e.toLowerCase())}function mu(){return uu(e=>e.toUpperCase())}function hu(){return uu(e=>yo(e))}function gu(e,t,n){return new e({type:`array`,element:t,...q(n)})}function _u(e,t,n){return new e({type:`custom`,check:`custom`,fn:t,...q(n)})}function vu(e,t){let n=yu(t=>(t.addIssue=e=>{if(typeof e==`string`)t.issues.push(Uo(e,t.value,n._zod.def));else{let r=e;r.fatal&&(r.continue=!1),r.code??=`custom`,r.input??=t.value,r.inst??=n,r.continue??=!n._zod.def.abort,t.issues.push(Uo(r))}},e(t.value,t)),t);return n}function yu(e,t){let n=new Bs({check:`custom`,...q(t)});return n._zod.check=e,n}function bu(e){let t=e?.target??`draft-2020-12`;return t===`draft-4`&&(t=`draft-04`),t===`draft-7`&&(t=`draft-07`),{processors:e.processors??{},metadataRegistry:e?.metadata??yl,target:t,unrepresentable:e?.unrepresentable??`throw`,override:e?.override??(()=>{}),io:e?.io??`output`,counter:0,seen:new Map,cycles:e?.cycles??`ref`,reused:e?.reused??`inline`,external:e?.external??void 0}}function xu(e,t,n={path:[],schemaPath:[]}){var r;let i=e._zod.def,a=t.seen.get(e);if(a)return a.count++,n.schemaPath.includes(e)&&(a.cycle=n.path),a.schema;let o={schema:{},count:1,cycle:void 0,path:n.path};t.seen.set(e,o);let s=e._zod.toJSONSchema?.();if(s)o.schema=s;else{let r={...n,schemaPath:[...n.schemaPath,e],path:n.path};if(e._zod.processJSONSchema)e._zod.processJSONSchema(t,o.schema,r);else{let n=o.schema,a=t.processors[i.type];if(!a)throw Error(`[toJSONSchema]: Non-representable type encountered: ${i.type}`);a(e,t,n,r)}let a=e._zod.parent;a&&(o.ref||=a,xu(a,t,r),t.seen.get(a).isParent=!0)}let c=t.metadataRegistry.get(e);return c&&Object.assign(o.schema,c),t.io===`input`&&wu(e)&&(delete o.schema.examples,delete o.schema.default),t.io===`input`&&`_prefault`in o.schema&&((r=o.schema).default??(r.default=o.schema._prefault)),delete o.schema._prefault,t.seen.get(e).schema}function Su(e,t){let n=e.seen.get(t);if(!n)throw Error(`Unprocessed schema. This is a bug in Zod.`);let r=new Map;for(let t of e.seen.entries()){let n=e.metadataRegistry.get(t[0])?.id;if(n){let e=r.get(n);if(e&&e!==t[0])throw Error(`Duplicate schema id "${n}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`);r.set(n,t[0])}}let i=t=>{let r=e.target===`draft-2020-12`?`$defs`:`definitions`;if(e.external){let n=e.external.registry.get(t[0])?.id,i=e.external.uri??(e=>e);if(n)return{ref:i(n)};let a=t[1].defId??t[1].schema.id??`schema${e.counter++}`;return t[1].defId=a,{defId:a,ref:`${i(`__shared`)}#/${r}/${a}`}}if(t[1]===n)return{ref:`#`};let i=`#/${r}/`,a=t[1].schema.id??`__schema${e.counter++}`;return{defId:a,ref:i+a}},a=e=>{if(e[1].schema.$ref)return;let t=e[1],{ref:n,defId:r}=i(e);t.def={...t.schema},r&&(t.defId=r);let a=t.schema;for(let e in a)delete a[e];a.$ref=n};if(e.cycles===`throw`)for(let t of e.seen.entries()){let e=t[1];if(e.cycle)throw Error(`Cycle detected: #/${e.cycle?.join(`/`)}/ + +Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(let n of e.seen.entries()){let r=n[1];if(t===n[0]){a(n);continue}if(e.external){let r=e.external.registry.get(n[0])?.id;if(t!==n[0]&&r){a(n);continue}}if(e.metadataRegistry.get(n[0])?.id){a(n);continue}if(r.cycle){a(n);continue}if(r.count>1&&e.reused===`ref`){a(n);continue}}}function Cu(e,t){let n=e.seen.get(t);if(!n)throw Error(`Unprocessed schema. This is a bug in Zod.`);let r=t=>{let n=e.seen.get(t);if(n.ref===null)return;let i=n.def??n.schema,a={...i},o=n.ref;if(n.ref=null,o){r(o);let n=e.seen.get(o),s=n.schema;if(s.$ref&&(e.target===`draft-07`||e.target===`draft-04`||e.target===`openapi-3.0`)?(i.allOf=i.allOf??[],i.allOf.push(s)):Object.assign(i,s),Object.assign(i,a),t._zod.parent===o)for(let e in i)e!==`$ref`&&e!==`allOf`&&(e in a||delete i[e]);if(s.$ref&&n.def)for(let e in i)e!==`$ref`&&e!==`allOf`&&e in n.def&&JSON.stringify(i[e])===JSON.stringify(n.def[e])&&delete i[e]}let s=t._zod.parent;if(s&&s!==o){r(s);let t=e.seen.get(s);if(t?.schema.$ref&&(i.$ref=t.schema.$ref,t.def))for(let e in i)e!==`$ref`&&e!==`allOf`&&e in t.def&&JSON.stringify(i[e])===JSON.stringify(t.def[e])&&delete i[e]}e.override({zodSchema:t,jsonSchema:i,path:n.path??[]})};for(let t of[...e.seen.entries()].reverse())r(t[0]);let i={};if(e.target===`draft-2020-12`?i.$schema=`https://json-schema.org/draft/2020-12/schema`:e.target===`draft-07`?i.$schema=`http://json-schema.org/draft-07/schema#`:e.target===`draft-04`?i.$schema=`http://json-schema.org/draft-04/schema#`:e.target,e.external?.uri){let n=e.external.registry.get(t)?.id;if(!n)throw Error("Schema is missing an `id` property");i.$id=e.external.uri(n)}Object.assign(i,n.def??n.schema);let a=e.metadataRegistry.get(t)?.id;a!==void 0&&i.id===a&&delete i.id;let o=e.external?.defs??{};for(let t of e.seen.entries()){let e=t[1];e.def&&e.defId&&(e.def.id===e.defId&&delete e.def.id,o[e.defId]=e.def)}e.external||Object.keys(o).length>0&&(e.target===`draft-2020-12`?i.$defs=o:i.definitions=o);try{let n=JSON.parse(JSON.stringify(i));return Object.defineProperty(n,"~standard",{value:{...t[`~standard`],jsonSchema:{input:Eu(t,`input`,e.processors),output:Eu(t,`output`,e.processors)}},enumerable:!1,writable:!1}),n}catch{throw Error(`Error converting schema to JSON.`)}}function wu(e,t){let n=t??{seen:new Set};if(n.seen.has(e))return!1;n.seen.add(e);let r=e._zod.def;if(r.type===`transform`)return!0;if(r.type===`array`)return wu(r.element,n);if(r.type===`set`)return wu(r.valueType,n);if(r.type===`lazy`)return wu(r.getter(),n);if(r.type===`promise`||r.type===`optional`||r.type===`nonoptional`||r.type===`nullable`||r.type===`readonly`||r.type==="default"||r.type===`prefault`)return wu(r.innerType,n);if(r.type===`intersection`)return wu(r.left,n)||wu(r.right,n);if(r.type===`record`||r.type===`map`)return wu(r.keyType,n)||wu(r.valueType,n);if(r.type===`pipe`)return e._zod.traits.has(`$ZodCodec`)?!0:wu(r.in,n)||wu(r.out,n);if(r.type===`object`){for(let e in r.shape)if(wu(r.shape[e],n))return!0;return!1}if(r.type===`union`){for(let e of r.options)if(wu(e,n))return!0;return!1}if(r.type===`tuple`){for(let e of r.items)if(wu(e,n))return!0;return!!(r.rest&&wu(r.rest,n))}return!1}var Tu=(e,t={})=>n=>{let r=bu({...n,processors:t});return xu(e,r),Su(r,e),Cu(r,e)},Eu=(e,t,n={})=>r=>{let{libraryOptions:i,target:a}=r??{},o=bu({...i??{},target:a,io:t,processors:n});return xu(e,o),Su(o,e),Cu(o,e)},Du={guid:`uuid`,url:`uri`,datetime:`date-time`,json_string:`json-string`,regex:``},Ou=(e,t,n,r)=>{let i=n;i.type=`string`;let{minimum:a,maximum:o,format:s,patterns:c,contentEncoding:l}=e._zod.bag;if(typeof a==`number`&&(i.minLength=a),typeof o==`number`&&(i.maxLength=o),s&&(i.format=Du[s]??s,i.format===``&&delete i.format,s===`time`&&delete i.format),l&&(i.contentEncoding=l),c&&c.size>0){let e=[...c];e.length===1?i.pattern=e[0].source:e.length>1&&(i.allOf=[...e.map(e=>({...t.target===`draft-07`||t.target===`draft-04`||t.target===`openapi-3.0`?{type:`string`}:{},pattern:e.source}))])}},ku=(e,t,n,r)=>{let i=n,{minimum:a,maximum:o,format:s,multipleOf:c,exclusiveMaximum:l,exclusiveMinimum:u}=e._zod.bag;i.type=typeof s==`string`&&s.includes(`int`)?`integer`:`number`;let d=typeof u==`number`&&u>=(a??-1/0),f=typeof l==`number`&&l<=(o??1/0),p=t.target===`draft-04`||t.target===`openapi-3.0`;d?p?(i.minimum=u,i.exclusiveMinimum=!0):i.exclusiveMinimum=u:typeof a==`number`&&(i.minimum=a),f?p?(i.maximum=l,i.exclusiveMaximum=!0):i.exclusiveMaximum=l:typeof o==`number`&&(i.maximum=o),typeof c==`number`&&(i.multipleOf=c)},Au=(e,t,n,r)=>{n.type=`boolean`},ju=(e,t,n,r)=>{n.not={}},Mu=(e,t,n,r)=>{let i=e._zod.def,a=co(i.entries);a.every(e=>typeof e==`number`)&&(n.type=`number`),a.every(e=>typeof e==`string`)&&(n.type=`string`),n.enum=a},Nu=(e,t,n,r)=>{let i=e._zod.def,a=[];for(let e of i.values)if(e===void 0){if(t.unrepresentable===`throw`)throw Error("Literal `undefined` cannot be represented in JSON Schema")}else if(typeof e==`bigint`){if(t.unrepresentable===`throw`)throw Error(`BigInt literals cannot be represented in JSON Schema`);a.push(Number(e))}else a.push(e);if(a.length!==0){if(a.length===1){let e=a[0];n.type=e===null?`null`:typeof e,t.target===`draft-04`||t.target===`openapi-3.0`?n.enum=[e]:n.const=e}else a.every(e=>typeof e==`number`)&&(n.type=`number`),a.every(e=>typeof e==`string`)&&(n.type=`string`),a.every(e=>typeof e==`boolean`)&&(n.type=`boolean`),a.every(e=>e===null)&&(n.type=`null`),n.enum=a}},Pu=(e,t,n,r)=>{if(t.unrepresentable===`throw`)throw Error(`Custom types cannot be represented in JSON Schema`)},Fu=(e,t,n,r)=>{if(t.unrepresentable===`throw`)throw Error(`Transforms cannot be represented in JSON Schema`)},Iu=(e,t,n,r)=>{let i=n,a=e._zod.def,{minimum:o,maximum:s}=e._zod.bag;typeof o==`number`&&(i.minItems=o),typeof s==`number`&&(i.maxItems=s),i.type=`array`,i.items=xu(a.element,t,{...r,path:[...r.path,`items`]})},Lu=(e,t,n,r)=>{let i=n,a=e._zod.def;i.type=`object`,i.properties={};let o=a.shape;for(let e in o)i.properties[e]=xu(o[e],t,{...r,path:[...r.path,`properties`,e]});let s=new Set(Object.keys(o)),c=new Set([...s].filter(e=>{let n=a.shape[e]._zod;return t.io===`input`?n.optin===void 0:n.optout===void 0}));c.size>0&&(i.required=Array.from(c)),a.catchall?._zod.def.type===`never`?i.additionalProperties=!1:a.catchall?a.catchall&&(i.additionalProperties=xu(a.catchall,t,{...r,path:[...r.path,`additionalProperties`]})):t.io===`output`&&(i.additionalProperties=!1)},Ru=(e,t,n,r)=>{let i=e._zod.def,a=i.inclusive===!1,o=i.options.map((e,n)=>xu(e,t,{...r,path:[...r.path,a?`oneOf`:`anyOf`,n]}));a?n.oneOf=o:n.anyOf=o},zu=(e,t,n,r)=>{let i=e._zod.def,a=xu(i.left,t,{...r,path:[...r.path,`allOf`,0]}),o=xu(i.right,t,{...r,path:[...r.path,`allOf`,1]}),s=e=>`allOf`in e&&Object.keys(e).length===1;n.allOf=[...s(a)?a.allOf:[a],...s(o)?o.allOf:[o]]},Bu=(e,t,n,r)=>{let i=e._zod.def,a=xu(i.innerType,t,r),o=t.seen.get(e);t.target===`openapi-3.0`?(o.ref=i.innerType,n.nullable=!0):n.anyOf=[a,{type:`null`}]},Vu=(e,t,n,r)=>{let i=e._zod.def;xu(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType},Hu=(e,t,n,r)=>{let i=e._zod.def;xu(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType,n.default=JSON.parse(JSON.stringify(i.defaultValue))},Uu=(e,t,n,r)=>{let i=e._zod.def;xu(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType,t.io===`input`&&(n._prefault=JSON.parse(JSON.stringify(i.defaultValue)))},Wu=(e,t,n,r)=>{let i=e._zod.def;xu(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType;let o;try{o=i.catchValue(void 0)}catch{throw Error(`Dynamic catch values are not supported in JSON Schema`)}n.default=o},Gu=(e,t,n,r)=>{let i=e._zod.def,a=i.in._zod.traits.has(`$ZodTransform`),o=t.io===`input`?a?i.out:i.in:i.out;xu(o,t,r);let s=t.seen.get(e);s.ref=o},Ku=(e,t,n,r)=>{let i=e._zod.def;xu(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType,n.readOnly=!0},qu=(e,t,n,r)=>{let i=e._zod.def;xu(i.innerType,t,r);let a=t.seen.get(e);a.ref=i.innerType},Ju=W(`ZodISODateTime`,(e,t)=>{yc.init(e,t),bd.init(e,t)});function Yu(e){return Vl(Ju,e)}var Xu=W(`ZodISODate`,(e,t)=>{bc.init(e,t),bd.init(e,t)});function Zu(e){return Hl(Xu,e)}var Qu=W(`ZodISOTime`,(e,t)=>{xc.init(e,t),bd.init(e,t)});function $u(e){return Ul(Qu,e)}var ed=W(`ZodISODuration`,(e,t)=>{Sc.init(e,t),bd.init(e,t)});function td(e){return Wl(ed,e)}var nd=W(`ZodError`,(e,t)=>{Go.init(e,t),e.name=`ZodError`,Object.defineProperties(e,{format:{value:t=>Jo(e,t)},flatten:{value:t=>qo(e,t)},addIssue:{value:t=>{e.issues.push(t),e.message=JSON.stringify(e.issues,lo,2)}},addIssues:{value:t=>{e.issues.push(...t),e.message=JSON.stringify(e.issues,lo,2)}},isEmpty:{get(){return e.issues.length===0}}})},{Parent:Error}),rd=Yo(nd),id=Xo(nd),ad=Zo(nd),od=$o(nd),sd=ts(nd),cd=ns(nd),ld=rs(nd),ud=is(nd),dd=as(nd),fd=os(nd),pd=ss(nd),md=cs(nd),hd=new WeakMap;function gd(e,t,n){let r=Object.getPrototypeOf(e),i=hd.get(r);if(i||(i=new Set,hd.set(r,i)),!i.has(t)){i.add(t);for(let e in n){let t=n[e];Object.defineProperty(r,e,{configurable:!0,enumerable:!1,get(){let n=t.bind(this);return Object.defineProperty(this,e,{configurable:!0,writable:!0,enumerable:!0,value:n}),n},set(t){Object.defineProperty(this,e,{configurable:!0,writable:!0,enumerable:!0,value:t})}})}}}var _d=W(`ZodType`,(e,t)=>(ac.init(e,t),Object.assign(e[`~standard`],{jsonSchema:{input:Eu(e,`input`),output:Eu(e,`output`)}}),e.toJSONSchema=Tu(e,{}),e.def=t,e.type=t.type,Object.defineProperty(e,"_def",{value:t}),e.parse=(t,n)=>rd(e,t,n,{callee:e.parse}),e.safeParse=(t,n)=>ad(e,t,n),e.parseAsync=async(t,n)=>id(e,t,n,{callee:e.parseAsync}),e.safeParseAsync=async(t,n)=>od(e,t,n),e.spa=e.safeParseAsync,e.encode=(t,n)=>sd(e,t,n),e.decode=(t,n)=>cd(e,t,n),e.encodeAsync=async(t,n)=>ld(e,t,n),e.decodeAsync=async(t,n)=>ud(e,t,n),e.safeEncode=(t,n)=>dd(e,t,n),e.safeDecode=(t,n)=>fd(e,t,n),e.safeEncodeAsync=async(t,n)=>pd(e,t,n),e.safeDecodeAsync=async(t,n)=>md(e,t,n),gd(e,`ZodType`,{check(...e){let t=this.def;return this.clone(_o(t,{checks:[...t.checks??[],...e.map(e=>typeof e==`function`?{_zod:{check:e,def:{check:`custom`},onattach:[]}}:e)]}),{parent:!0})},with(...e){return this.check(...e)},clone(e,t){return Do(this,e,t)},brand(){return this},register(e,t){return e.add(this,t),this},refine(e,t){return this.check(Af(e,t))},superRefine(e,t){return this.check(jf(e,t))},overwrite(e){return this.check(uu(e))},optional(){return ff(this)},exactOptional(){return mf(this)},nullable(){return gf(this)},nullish(){return ff(gf(this))},nonoptional(e){return Sf(this,e)},array(){return Zd(this)},or(e){return tf([this,e])},and(e){return rf(this,e)},transform(e){return Ef(this,uf(e))},default(e){return vf(this,e)},prefault(e){return bf(this,e)},catch(e){return wf(this,e)},pipe(e){return Ef(this,e)},readonly(){return Of(this)},describe(e){let t=this.clone();return yl.add(t,{description:e}),t},meta(...e){if(e.length===0)return yl.get(this);let t=this.clone();return yl.add(t,e[0]),t},isOptional(){return this.safeParse(void 0).success},isNullable(){return this.safeParse(null).success},apply(e){return e(this)}}),Object.defineProperty(e,"description",{get(){return yl.get(e)?.description},configurable:!0}),e)),vd=W(`_ZodString`,(e,t)=>{oc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ou(e,t,n,r);let n=e._zod.bag;e.format=n.format??null,e.minLength=n.minimum??null,e.maxLength=n.maximum??null,gd(e,`_ZodString`,{regex(...e){return this.check(iu(...e))},includes(...e){return this.check(su(...e))},startsWith(...e){return this.check(cu(...e))},endsWith(...e){return this.check(lu(...e))},min(...e){return this.check(nu(...e))},max(...e){return this.check(tu(...e))},length(...e){return this.check(ru(...e))},nonempty(...e){return this.check(nu(1,...e))},lowercase(e){return this.check(au(e))},uppercase(e){return this.check(ou(e))},trim(){return this.check(fu())},normalize(...e){return this.check(du(...e))},toLowerCase(){return this.check(pu())},toUpperCase(){return this.check(mu())},slugify(){return this.check(hu())}})}),yd=W(`ZodString`,(e,t)=>{oc.init(e,t),vd.init(e,t),e.email=t=>e.check(xl(xd,t)),e.url=t=>e.check(Dl(wd,t)),e.jwt=t=>e.check(Bl(zd,t)),e.emoji=t=>e.check(Ol(Td,t)),e.guid=t=>e.check(Sl(Sd,t)),e.uuid=t=>e.check(Cl(Cd,t)),e.uuidv4=t=>e.check(wl(Cd,t)),e.uuidv6=t=>e.check(Tl(Cd,t)),e.uuidv7=t=>e.check(El(Cd,t)),e.nanoid=t=>e.check(kl(Ed,t)),e.guid=t=>e.check(Sl(Sd,t)),e.cuid=t=>e.check(Al(Dd,t)),e.cuid2=t=>e.check(jl(Od,t)),e.ulid=t=>e.check(Ml(kd,t)),e.base64=t=>e.check(Ll(Id,t)),e.base64url=t=>e.check(Rl(Ld,t)),e.xid=t=>e.check(Nl(Ad,t)),e.ksuid=t=>e.check(Pl(jd,t)),e.ipv4=t=>e.check(Fl(Md,t)),e.ipv6=t=>e.check(Il(Nd,t)),e.cidrv4=t=>e.check(J(Pd,t)),e.cidrv6=t=>e.check(Y(Fd,t)),e.e164=t=>e.check(zl(Rd,t)),e.datetime=t=>e.check(Yu(t)),e.date=t=>e.check(Zu(t)),e.time=t=>e.check($u(t)),e.duration=t=>e.check(td(t))});function X(e){return bl(yd,e)}var bd=W(`ZodStringFormat`,(e,t)=>{sc.init(e,t),vd.init(e,t)}),xd=W(`ZodEmail`,(e,t)=>{uc.init(e,t),bd.init(e,t)}),Sd=W(`ZodGUID`,(e,t)=>{cc.init(e,t),bd.init(e,t)}),Cd=W(`ZodUUID`,(e,t)=>{lc.init(e,t),bd.init(e,t)}),wd=W(`ZodURL`,(e,t)=>{dc.init(e,t),bd.init(e,t)}),Td=W(`ZodEmoji`,(e,t)=>{fc.init(e,t),bd.init(e,t)}),Ed=W(`ZodNanoID`,(e,t)=>{pc.init(e,t),bd.init(e,t)}),Dd=W(`ZodCUID`,(e,t)=>{mc.init(e,t),bd.init(e,t)}),Od=W(`ZodCUID2`,(e,t)=>{hc.init(e,t),bd.init(e,t)}),kd=W(`ZodULID`,(e,t)=>{gc.init(e,t),bd.init(e,t)}),Ad=W(`ZodXID`,(e,t)=>{_c.init(e,t),bd.init(e,t)}),jd=W(`ZodKSUID`,(e,t)=>{vc.init(e,t),bd.init(e,t)}),Md=W(`ZodIPv4`,(e,t)=>{Cc.init(e,t),bd.init(e,t)}),Nd=W(`ZodIPv6`,(e,t)=>{wc.init(e,t),bd.init(e,t)}),Pd=W(`ZodCIDRv4`,(e,t)=>{Tc.init(e,t),bd.init(e,t)}),Fd=W(`ZodCIDRv6`,(e,t)=>{Ec.init(e,t),bd.init(e,t)}),Id=W(`ZodBase64`,(e,t)=>{Oc.init(e,t),bd.init(e,t)}),Ld=W(`ZodBase64URL`,(e,t)=>{Ac.init(e,t),bd.init(e,t)}),Rd=W(`ZodE164`,(e,t)=>{jc.init(e,t),bd.init(e,t)}),zd=W(`ZodJWT`,(e,t)=>{Nc.init(e,t),bd.init(e,t)}),Bd=W(`ZodNumber`,(e,t)=>{Pc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>ku(e,t,n,r),gd(e,`ZodNumber`,{gt(e,t){return this.check(Ql(e,t))},gte(e,t){return this.check($l(e,t))},min(e,t){return this.check($l(e,t))},lt(e,t){return this.check(Xl(e,t))},lte(e,t){return this.check(Zl(e,t))},max(e,t){return this.check(Zl(e,t))},int(e){return this.check(Ud(e))},safe(e){return this.check(Ud(e))},positive(e){return this.check(Ql(0,e))},nonnegative(e){return this.check($l(0,e))},negative(e){return this.check(Xl(0,e))},nonpositive(e){return this.check(Zl(0,e))},multipleOf(e,t){return this.check(eu(e,t))},step(e,t){return this.check(eu(e,t))},finite(){return this}});let n=e._zod.bag;e.minValue=Math.max(n.minimum??-1/0,n.exclusiveMinimum??-1/0)??null,e.maxValue=Math.min(n.maximum??1/0,n.exclusiveMaximum??1/0)??null,e.isInt=(n.format??``).includes(`int`)||Number.isSafeInteger(n.multipleOf??.5),e.isFinite=!0,e.format=n.format??null});function Vd(e){return Gl(Bd,e)}var Hd=W(`ZodNumberFormat`,(e,t)=>{Fc.init(e,t),Bd.init(e,t)});function Ud(e){return Kl(Hd,e)}var Wd=W(`ZodBoolean`,(e,t)=>{Ic.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Au(e,t,n,r)});function Gd(e){return ql(Wd,e)}var Kd=W(`ZodUnknown`,(e,t)=>{Lc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(e,t,n)=>void 0});function qd(){return Jl(Kd)}var Jd=W(`ZodNever`,(e,t)=>{Rc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>ju(e,t,n,r)});function Yd(e){return Yl(Jd,e)}var Xd=W(`ZodArray`,(e,t)=>{Bc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Iu(e,t,n,r),e.element=t.element,gd(e,`ZodArray`,{min(e,t){return this.check(nu(e,t))},nonempty(e){return this.check(nu(1,e))},max(e,t){return this.check(tu(e,t))},length(e,t){return this.check(ru(e,t))},unwrap(){return this.element}})});function Zd(e,t){return gu(Xd,e,t)}var Qd=W(`ZodObject`,(e,t)=>{Gc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Lu(e,t,n,r),K(e,`shape`,()=>t.shape),gd(e,`ZodObject`,{keyof(){return of(Object.keys(this._zod.def.shape))},catchall(e){return this.clone({...this._zod.def,catchall:e})},passthrough(){return this.clone({...this._zod.def,catchall:qd()})},loose(){return this.clone({...this._zod.def,catchall:qd()})},strict(){return this.clone({...this._zod.def,catchall:Yd()})},strip(){return this.clone({...this._zod.def,catchall:void 0})},extend(e){return Mo(this,e)},safeExtend(e){return No(this,e)},merge(e){return Po(this,e)},pick(e){return Ao(this,e)},omit(e){return jo(this,e)},partial(...e){return Fo(df,this,e[0])},required(...e){return Io(xf,this,e[0])}})});function $d(e,t){return new Qd({type:`object`,shape:e??{},...q(t)})}var ef=W(`ZodUnion`,(e,t)=>{qc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ru(e,t,n,r),e.options=t.options});function tf(e,t){return new ef({type:`union`,options:e,...q(t)})}var nf=W(`ZodIntersection`,(e,t)=>{Jc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>zu(e,t,n,r)});function rf(e,t){return new nf({type:`intersection`,left:e,right:t})}var af=W(`ZodEnum`,(e,t)=>{Zc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Mu(e,t,n,r),e.enum=t.entries,e.options=Object.values(t.entries);let n=new Set(Object.keys(t.entries));e.extract=(e,r)=>{let i={};for(let r of e)if(n.has(r))i[r]=t.entries[r];else throw Error(`Key ${r} not found in enum`);return new af({...t,checks:[],...q(r),entries:i})},e.exclude=(e,r)=>{let i={...t.entries};for(let t of e)if(n.has(t))delete i[t];else throw Error(`Key ${t} not found in enum`);return new af({...t,checks:[],...q(r),entries:i})}});function of(e,t){return new af({type:`enum`,entries:Array.isArray(e)?Object.fromEntries(e.map(e=>[e,e])):e,...q(t)})}var sf=W(`ZodLiteral`,(e,t)=>{Qc.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Nu(e,t,n,r),e.values=new Set(t.values),Object.defineProperty(e,"value",{get(){if(t.values.length>1)throw Error("This schema contains multiple valid literal values. Use `.values` instead.");return t.values[0]}})});function cf(e,t){return new sf({type:`literal`,values:Array.isArray(e)?e:[e],...q(t)})}var lf=W(`ZodTransform`,(e,t)=>{$c.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Fu(e,t,n,r),e._zod.parse=(n,r)=>{if(r.direction===`backward`)throw new G(e.constructor.name);n.addIssue=r=>{if(typeof r==`string`)n.issues.push(Uo(r,n.value,t));else{let t=r;t.fatal&&(t.continue=!1),t.code??=`custom`,t.input??=n.value,t.inst??=e,n.issues.push(Uo(t))}};let i=t.transform(n.value,n);return i instanceof Promise?i.then(e=>(n.value=e,n.fallback=!0,n)):(n.value=i,n.fallback=!0,n)}});function uf(e){return new lf({type:`transform`,transform:e})}var df=W(`ZodOptional`,(e,t)=>{tl.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>qu(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function ff(e){return new df({type:`optional`,innerType:e})}var pf=W(`ZodExactOptional`,(e,t)=>{nl.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>qu(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function mf(e){return new pf({type:`optional`,innerType:e})}var hf=W(`ZodNullable`,(e,t)=>{rl.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Bu(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function gf(e){return new hf({type:`nullable`,innerType:e})}var _f=W(`ZodDefault`,(e,t)=>{il.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Hu(e,t,n,r),e.unwrap=()=>e._zod.def.innerType,e.removeDefault=e.unwrap});function vf(e,t){return new _f({type:`default`,innerType:e,get defaultValue(){return typeof t==`function`?t():wo(t)}})}var yf=W(`ZodPrefault`,(e,t)=>{ol.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Uu(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function bf(e,t){return new yf({type:`prefault`,innerType:e,get defaultValue(){return typeof t==`function`?t():wo(t)}})}var xf=W(`ZodNonOptional`,(e,t)=>{sl.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Vu(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function Sf(e,t){return new xf({type:`nonoptional`,innerType:e,...q(t)})}var Cf=W(`ZodCatch`,(e,t)=>{ll.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Wu(e,t,n,r),e.unwrap=()=>e._zod.def.innerType,e.removeCatch=e.unwrap});function wf(e,t){return new Cf({type:`catch`,innerType:e,catchValue:typeof t==`function`?t:()=>t})}var Tf=W(`ZodPipe`,(e,t)=>{ul.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Gu(e,t,n,r),e.in=t.in,e.out=t.out});function Ef(e,t){return new Tf({type:`pipe`,in:e,out:t})}var Df=W(`ZodReadonly`,(e,t)=>{fl.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Ku(e,t,n,r),e.unwrap=()=>e._zod.def.innerType});function Of(e){return new Df({type:`readonly`,innerType:e})}var kf=W(`ZodCustom`,(e,t)=>{ml.init(e,t),_d.init(e,t),e._zod.processJSONSchema=(t,n,r)=>Pu(e,t,n,r)});function Af(e,t={}){return _u(kf,e,t)}function jf(e,t){return vu(e,t)}var Mf=Vd().int().min(1).max(1e3),Nf=$d({keepCount:Mf}),Pf=$d({provider:X().trim().min(1).max(200).regex(/^[A-Za-z0-9._-]+$/),modelMode:of([`provider-default`,`keep-root-model`,`explicit`]),model:X().trim().max(500).optional(),keepCount:Mf}).superRefine((e,t)=>{e.modelMode===`explicit`&&!e.model&&t.addIssue({code:`custom`,path:[`model`],message:`model-required`}),e.modelMode!==`explicit`&&e.model&&t.addIssue({code:`custom`,path:[`model`],message:`model-not-accepted`})}),Ff=$d({backupId:X().trim().min(1).max(300),restoreConfig:Gd(),restoreDatabase:Gd(),restoreSessions:Gd(),allowSqliteHomeRelocation:Gd(),relocationTargetProfileId:X().trim().max(80).optional()}).superRefine((e,t)=>{!e.restoreConfig&&!e.restoreDatabase&&!e.restoreSessions&&t.addIssue({code:`custom`,path:[`restoreSessions`],message:`restore-required`}),e.allowSqliteHomeRelocation&&(!e.relocationTargetProfileId||e.restoreConfig)&&t.addIssue({code:`custom`,path:[`relocationTargetProfileId`],message:`relocation-invalid`})}),If=X().trim().min(1).max(4096).refine(e=>/^(?:[A-Za-z]:[\\/]|\\\\|\/)/.test(e),`absolute-path-required`),Lf=$d({profileId:X().trim().min(1).max(80).regex(/^[A-Za-z0-9._-]+$/),name:X().trim().min(1).max(120),codexHome:If,sqliteHome:tf([If,cf(``)]).optional()}),Rf=Object.defineProperty,zf=(e,t)=>Rf(e,`name`,{value:t,configurable:!0}),Bf=!!(typeof window<`u`&&window.document&&window.document.createElement);function Vf(e,t,{checkForDefaultPrevented:n=!0}={}){return zf(function(r){if(e?.(r),n===!1||!r||!r.defaultPrevented)return t?.(r)},`handleEvent`)}zf(Vf,`composeEventHandlers`);function Hf(e){if(!Bf)throw Error(`Cannot access window outside of the DOM`);return e?.ownerDocument?.defaultView??window}zf(Hf,`getOwnerWindow`);function Uf(e){if(!Bf)throw Error(`Cannot access document outside of the DOM`);return e?.ownerDocument??document}zf(Uf,`getOwnerDocument`);function Wf(e,t=!1){let{activeElement:n}=Uf(e);if(!n?.nodeName)return null;if(Gf(n)&&n.contentDocument)return Wf(n.contentDocument.body,t);if(t){let e=n.getAttribute(`aria-activedescendant`);if(e){let t=Uf(n).getElementById(e);if(t)return t}}return n}zf(Wf,`getActiveElement`);function Gf(e){return e.tagName===`IFRAME`}zf(Gf,`isFrame`);var Kf=Object.defineProperty,qf=(e,t)=>Kf(e,`name`,{value:t,configurable:!0});function Jf(e,t){if(typeof e==`function`)return e(t);e!=null&&(e.current=t)}qf(Jf,`setRef`);function Yf(...e){return t=>{let n=!1,r=e.map(e=>{let r=Jf(e,t);return!n&&typeof r==`function`&&(n=!0),r});if(n)return()=>{for(let t=0;tZf(e,`name`,{value:t,configurable:!0});function $f(e,t){let n=p.createContext(t);n.displayName=e+`Context`;let r=Qf(e=>{let{children:t,...r}=e,i=p.useMemo(()=>r,Object.values(r));return(0,m.jsx)(n.Provider,{value:i,children:t})},`Provider`);r.displayName=e+`Provider`;function i(r,i={}){let{optional:a=!1}=i,o=p.useContext(n);if(o)return o;if(t!==void 0)return t;if(!a)throw Error(`\`${r}\` must be used within \`${e}\``)}return Qf(i,`useContext`),[r,i]}Qf($f,`createContext`);function ep(e,t=[]){let n=[];function r(t,r){let i=p.createContext(r);i.displayName=t+`Context`;let a=n.length;n=[...n,r];let o=Qf(t=>{let{scope:n,children:r,...o}=t,s=n?.[e]?.[a]||i,c=p.useMemo(()=>o,Object.values(o));return(0,m.jsx)(s.Provider,{value:c,children:r})},`Provider`);o.displayName=t+`Provider`;function s(n,o,s={}){let{optional:c=!1}=s,l=o?.[e]?.[a]||i,u=p.useContext(l);if(u)return u;if(r!==void 0)return r;if(!c)throw Error(`\`${n}\` must be used within \`${t}\``)}return Qf(s,`useContext`),[o,s]}Qf(r,`createContext`);let i=Qf(()=>{let t=n.map(e=>p.createContext(e));return Qf(function(n){let r=n?.[e]||t;return p.useMemo(()=>({[`__scope${e}`]:{...n,[e]:r}}),[n,r])},`useScope`)},`createScope`);return i.scopeName=e,[r,tp(i,...t)]}Qf(ep,`createContextScope`);function tp(...e){let t=e[0];if(e.length===1)return t;let n=Qf(()=>{let n=e.map(e=>({useScope:e(),scopeName:e.scopeName}));return Qf(function(e){let r=n.reduce((t,{useScope:n,scopeName:r})=>{let i=n(e)[`__scope${r}`];return{...t,...i}},{});return p.useMemo(()=>({[`__scope${t.scopeName}`]:r}),[r])},`useComposedScopes`)},`createScope`);return n.scopeName=t.scopeName,n}Qf(tp,`composeContextScopes`);var np=globalThis?.document?p.useLayoutEffect:()=>{},rp=Object.defineProperty,ip=(e,t)=>rp(e,`name`,{value:t,configurable:!0}),ap=p.useId||(()=>void 0),op=0;function sp(e){let[t,n]=p.useState(ap());return np(()=>{e||n(e=>e??String(op++))},[e]),e||(t?`radix-${t}`:``)}ip(sp,`useId`);var cp=Object.defineProperty,lp=(e,t)=>cp(e,`name`,{value:t,configurable:!0}),up=p.useEffectEvent,dp=p.useInsertionEffect;function fp(e){if(typeof up==`function`)return up(e);let t=p.useRef(()=>{throw Error(`Cannot call an event handler while rendering.`)});return typeof dp==`function`?dp(()=>{t.current=e}):np(()=>{t.current=e}),p.useMemo(()=>((...e)=>t.current?.(...e)),[])}lp(fp,`useEffectEvent`);var pp=Object.defineProperty,mp=(e,t)=>pp(e,`name`,{value:t,configurable:!0}),hp=p.useInsertionEffect||np;function gp({prop:e,defaultProp:t,onChange:n=mp(()=>{},`onChange`),caller:r}){let[i,a,o]=_p({defaultProp:t,onChange:n}),s=e!==void 0;return[s?e:i,p.useCallback(t=>{if(s){let n=vp(t)?t(e):t;n!==e&&o.current?.(n)}else a(t)},[s,e,a,o])]}mp(gp,`useControllableState`);function _p({defaultProp:e,onChange:t}){let[n,r]=p.useState(e),i=p.useRef(n),a=p.useRef(t);return hp(()=>{a.current=t},[t]),p.useEffect(()=>{i.current!==n&&(a.current?.(n),i.current=n)},[n,i]),[n,r,a]}mp(_p,`useUncontrolledState`);function vp(e){return typeof e==`function`}mp(vp,`isFunction`);var yp=Symbol(`RADIX:SYNC_STATE`);function bp(e,t,n,r){let{prop:i,defaultProp:a,onChange:o,caller:s}=t,c=i!==void 0,l=fp(o),u=[{...n,state:a}];r&&u.push(r);let[d,f]=p.useReducer((t,n)=>{if(n.type===yp)return{...t,state:n.state};let r=e(t,n);return c&&!Object.is(r.state,t.state)&&l(r.state),r},...u),m=d.state,h=p.useRef(m);p.useEffect(()=>{h.current!==m&&(h.current=m,c||l(m))},[m,h,c]);let g=p.useMemo(()=>i===void 0?d:{...d,state:i},[d,i]);return p.useEffect(()=>{c&&!Object.is(i,d.state)&&f({type:yp,state:i})},[i,d.state,c]),[g,f]}mp(bp,`useControllableStateReducer`);var xp=o((e=>{var t=f();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=xp()})),Cp=c(Sp(),1),wp=Object.defineProperty,Tp=(e,t)=>wp(e,`name`,{value:t,configurable:!0});function Ep(e){let t=p.forwardRef((t,n)=>{let{children:r,...i}=t,a=null,o=!1,s=[];Fp(r)&&typeof zp==`function`&&(r=zp(r._payload)),p.Children.forEach(r,e=>{if(Np(e)){o=!0;let t=e,n=`child`in t.props?t.props.child:t.props.children;Fp(n)&&typeof zp==`function`&&(n=zp(n._payload)),a=Ap(t,n),s.push(a?.props?.children)}else s.push(e)}),a?a=p.cloneElement(a,void 0,s):!o&&p.Children.count(r)===1&&p.isValidElement(r)&&(a=r);let c=a?Mp(a):void 0,l=Xf(n,c);if(!a){if(r||r===0)throw Error(o?Rp(e):Lp(e));return r}let u=jp(i,a.props??{});return a.type!==p.Fragment&&(u.ref=n?l:c),p.cloneElement(a,u)});return t.displayName=`${e}.Slot`,t}Tp(Ep,`createSlot`);var Dp=Ep(`Slot`),Op=Symbol.for(`radix.slottable`);function kp(e){let t=Tp(e=>`child`in e?e.children(e.child):e.children,`Slottable`);return t.displayName=`${e}.Slottable`,t.__radixId=Op,t}Tp(kp,`createSlottable`);var Ap=Tp((e,t)=>{if(`child`in e.props){let t=e.props.child;return p.isValidElement(t)?p.cloneElement(t,void 0,e.props.children(t.props.children)):null}return p.isValidElement(t)?t:null},`getSlottableElementFromSlottable`);function jp(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}Tp(jp,`mergeProps`);function Mp(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}Tp(Mp,`getElementRef`);function Np(e){return p.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===Op}Tp(Np,`isSlottable`);var Pp=Symbol.for(`react.lazy`);function Fp(e){return typeof e==`object`&&!!e&&`$$typeof`in e&&e.$$typeof===Pp&&`_payload`in e&&Ip(e._payload)}Tp(Fp,`isLazyComponent`);function Ip(e){return typeof e==`object`&&!!e&&`then`in e}Tp(Ip,`isPromiseLike`);var Lp=Tp(e=>`${e} failed to slot onto its children. Expected a single React element child or \`Slottable\`.`,`createSlotError`),Rp=Tp(e=>`${e} failed to slot onto its \`Slottable\`. Expected \`Slottable\` to receive a single React element child.`,`createSlottableError`),zp=p.use,Bp=Object.defineProperty,Vp=(e,t)=>Bp(e,`name`,{value:t,configurable:!0}),Hp=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=Ep(`Primitive.${t}`),r=p.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,m.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{});function Up(e,t){e&&Cp.flushSync(()=>e.dispatchEvent(t))}Vp(Up,`dispatchDiscreteCustomEvent`);var Wp=Object.defineProperty,Gp=(e,t)=>Wp(e,`name`,{value:t,configurable:!0});function Kp(e){let t=p.useRef(e);return p.useEffect(()=>{t.current=e}),p.useMemo(()=>((...e)=>t.current?.(...e)),[])}Gp(Kp,`useCallbackRef`);var qp=Object.defineProperty,Jp=(e,t)=>qp(e,`name`,{value:t,configurable:!0}),Yp=`dismissableLayer.update`,Xp=`dismissableLayer.pointerDownOutside`,Zp=`dismissableLayer.focusOutside`,Qp,$p=p.createContext({layers:new Set,layersWithOutsidePointerEventsDisabled:new Set,branches:new Set,dismissableSurfaces:new Set}),em=p.forwardRef(Jp(function(e,t){let{disableOutsidePointerEvents:n=!1,deferPointerDownOutside:r=!1,onEscapeKeyDown:i,onPointerDownOutside:a,onFocusOutside:o,onInteractOutside:s,onDismiss:c,...l}=e,u=p.useContext($p),[d,f]=p.useState(null),h=d?.ownerDocument??globalThis?.document,[,g]=p.useState({}),_=Xf(t,f),v=Array.from(u.layers),[y]=[...u.layersWithOutsidePointerEventsDisabled].slice(-1),b=y?v.indexOf(y):-1,x=d?v.indexOf(d):-1,S=u.layersWithOutsidePointerEventsDisabled.size>0,C=x>=b,w=p.useRef(!1),T=im(e=>{a?.(e),s?.(e),e.defaultPrevented||c?.()},{ownerDocument:h,deferPointerDownOutside:r,isDeferredPointerDownOutsideRef:w,dismissableSurfaces:u.dismissableSurfaces,shouldHandlePointerDownOutside:p.useCallback(e=>{if(!(e instanceof Node))return!1;let t=[...u.branches].some(t=>t.contains(e));return C&&!t},[u.branches,C])}),E=am(e=>{if(r&&w.current)return;let t=e.target;[...u.branches].some(e=>e.contains(t))||(o?.(e),s?.(e),e.defaultPrevented||c?.())},h),D=d?x===v.length-1:!1,O=Kp(e=>{e.key===`Escape`&&(i?.(e),!e.defaultPrevented&&c&&(e.preventDefault(),c()))});return p.useEffect(()=>{if(D)return h.addEventListener(`keydown`,O,{capture:!0}),()=>h.removeEventListener(`keydown`,O,{capture:!0})},[h,D,O]),p.useEffect(()=>{if(d)return n&&(u.layersWithOutsidePointerEventsDisabled.size===0&&(Qp=h.body.style.pointerEvents,h.body.style.pointerEvents=`none`),u.layersWithOutsidePointerEventsDisabled.add(d)),u.layers.add(d),om(),()=>{n&&(u.layersWithOutsidePointerEventsDisabled.delete(d),u.layersWithOutsidePointerEventsDisabled.size===0&&(h.body.style.pointerEvents=Qp))}},[d,h,n,u]),p.useEffect(()=>()=>{d&&(u.layers.delete(d),u.layersWithOutsidePointerEventsDisabled.delete(d),om())},[d,u]),p.useEffect(()=>{let e=Jp(()=>g({}),`handleUpdate`);return document.addEventListener(Yp,e),()=>document.removeEventListener(Yp,e)},[]),(0,m.jsx)(Hp.div,{...l,ref:_,style:{pointerEvents:S?C?`auto`:`none`:void 0,...e.style},onFocusCapture:Vf(e.onFocusCapture,E.onFocusCapture),onBlurCapture:Vf(e.onBlurCapture,E.onBlurCapture),onPointerDownCapture:Vf(e.onPointerDownCapture,T.onPointerDownCapture)})},`DismissableLayer`)),tm=p.forwardRef(Jp(function(e,t){let n=p.useContext($p),r=p.useRef(null),i=Xf(t,r);return p.useEffect(()=>{let e=r.current;if(e)return n.branches.add(e),()=>{n.branches.delete(e)}},[n.branches]),(0,m.jsx)(Hp.div,{...e,ref:i})},`DismissableLayerBranch`));function nm(){let e=p.useContext($p),[t,n]=p.useState(null);return p.useEffect(()=>{if(t)return e.dismissableSurfaces.add(t),()=>{e.dismissableSurfaces.delete(t)}},[t,e.dismissableSurfaces]),n}Jp(nm,`useDismissableLayerSurface`);var rm=Jp(()=>!0,`IS_TRUE`);function im(e,t){let{ownerDocument:n=globalThis?.document,deferPointerDownOutside:r=!1,isDeferredPointerDownOutsideRef:i,dismissableSurfaces:a,shouldHandlePointerDownOutside:o=rm}=t,s=Kp(e),c=p.useRef(!1),l=p.useRef(!1),u=p.useRef(new Map),d=p.useRef(()=>{});return p.useEffect(()=>{function e(){l.current=!1,i.current=!1,u.current.clear()}Jp(e,`resetOutsideInteraction`);function t(){return Array.from(u.current.values()).some(Boolean)}Jp(t,`isOutsideInteractionIntercepted`);function f(e){if(!l.current)return;let t=e.target;t instanceof Node&&[...a].some(e=>e.contains(t))||u.current.set(e.type,!0),e.type===`click`&&window.setTimeout(()=>{l.current&&d.current()},0)}Jp(f,`handleInteractionCapture`);function p(e){l.current&&u.current.set(e.type,!1)}Jp(p,`handleInteractionBubble`);let m=Jp(a=>{if(a.target&&!c.current){let f=function(){n.removeEventListener(`click`,d.current);let r=t();e(),r||sm(Xp,s,p,{discrete:!0})};if(Jp(f,`handleAndDispatchPointerDownOutsideEvent`),!o(a.target)){n.removeEventListener(`click`,d.current),e(),c.current=!1;return}let p={originalEvent:a};l.current=!0,i.current=r&&a.button===0,u.current.clear(),!r||a.button!==0?f():(n.removeEventListener(`click`,d.current),d.current=f,n.addEventListener(`click`,d.current,{once:!0}))}else n.removeEventListener(`click`,d.current),e();c.current=!1},`handlePointerDown`),h=[`pointerup`,`mousedown`,`mouseup`,`touchstart`,`touchend`,`click`];for(let e of h)n.addEventListener(e,f,!0),n.addEventListener(e,p);let g=window.setTimeout(()=>{n.addEventListener(`pointerdown`,m)},0);return()=>{window.clearTimeout(g),n.removeEventListener(`pointerdown`,m),n.removeEventListener(`click`,d.current);for(let e of h)n.removeEventListener(e,f,!0),n.removeEventListener(e,p)}},[n,s,r,i,a,o]),{onPointerDownCapture:Jp(()=>c.current=!0,`onPointerDownCapture`)}}Jp(im,`usePointerDownOutside`);function am(e,t=globalThis?.document){let n=Kp(e),r=p.useRef(!1);return p.useEffect(()=>{let e=Jp(e=>{e.target&&!r.current&&sm(Zp,n,{originalEvent:e},{discrete:!1})},`handleFocus`);return t.addEventListener(`focusin`,e),()=>t.removeEventListener(`focusin`,e)},[t,n]),{onFocusCapture:Jp(()=>r.current=!0,`onFocusCapture`),onBlurCapture:Jp(()=>r.current=!1,`onBlurCapture`)}}Jp(am,`useFocusOutside`);function om(){let e=new CustomEvent(Yp);document.dispatchEvent(e)}Jp(om,`dispatchUpdate`);function sm(e,t,n,{discrete:r}){let i=n.originalEvent.target,a=new CustomEvent(e,{bubbles:!1,cancelable:!0,detail:n});t&&i.addEventListener(e,t,{once:!0}),r?Up(i,a):i.dispatchEvent(a)}Jp(sm,`handleAndDispatchCustomEvent`);var cm=em,lm=tm,um=Object.defineProperty,dm=(e,t)=>um(e,`name`,{value:t,configurable:!0}),fm=`focusScope.autoFocusOnMount`,pm=`focusScope.autoFocusOnUnmount`,mm={bubbles:!1,cancelable:!0},hm=p.forwardRef(dm(function(e,t){let{loop:n=!1,trapped:r=!1,onMountAutoFocus:i,onUnmountAutoFocus:a,...o}=e,[s,c]=p.useState(null),l=Kp(i),u=Kp(a),d=p.useRef(null),f=Xf(t,c),h=p.useRef({paused:!1,pause(){this.paused=!0},resume(){this.paused=!1}}).current;p.useEffect(()=>{if(r){let e=function(e){if(h.paused||!s)return;let t=e.target;s.contains(t)?d.current=t:Sm(d.current,{select:!0})},t=function(e){if(h.paused||!s)return;let t=e.relatedTarget;t!==null&&(s.contains(t)||Sm(d.current,{select:!0}))},n=function(e){if(document.activeElement===document.body)for(let t of e)t.removedNodes.length>0&&Sm(s)};dm(e,`handleFocusIn`),dm(t,`handleFocusOut`),dm(n,`handleMutations`),document.addEventListener(`focusin`,e),document.addEventListener(`focusout`,t);let r=new MutationObserver(n);return s&&r.observe(s,{childList:!0,subtree:!0}),()=>{document.removeEventListener(`focusin`,e),document.removeEventListener(`focusout`,t),r.disconnect()}}},[r,s,h.paused]),p.useEffect(()=>{if(s){Cm.add(h);let e=document.activeElement;if(!s.contains(e)){let t=new CustomEvent(fm,mm);s.addEventListener(fm,l),s.dispatchEvent(t),t.defaultPrevented||(gm(Em(vm(s)),{select:!0}),document.activeElement===e&&Sm(s))}return()=>{s.removeEventListener(fm,l),setTimeout(()=>{let t=new CustomEvent(pm,mm);s.addEventListener(pm,u),s.dispatchEvent(t),t.defaultPrevented||Sm(e??document.body,{select:!0}),s.removeEventListener(pm,u),Cm.remove(h)},0)}}},[s,l,u,h]);let g=p.useCallback(e=>{if(!n&&!r||h.paused)return;let t=e.key===`Tab`&&!e.altKey&&!e.ctrlKey&&!e.metaKey,i=document.activeElement;if(t&&i){let t=e.currentTarget,[r,a]=_m(t);r&&a?!e.shiftKey&&i===a?(e.preventDefault(),n&&Sm(r,{select:!0})):e.shiftKey&&i===r&&(e.preventDefault(),n&&Sm(a,{select:!0})):i===t&&e.preventDefault()}},[n,r,h.paused]);return(0,m.jsx)(Hp.div,{tabIndex:-1,...o,ref:f,onKeyDown:g})},`FocusScope`));function gm(e,{select:t=!1}={}){let n=document.activeElement;for(let r of e)if(Sm(r,{select:t}),document.activeElement!==n)return}dm(gm,`focusFirst`);function _m(e){let t=vm(e);return[ym(t,e),ym(t.reverse(),e)]}dm(_m,`getTabbableEdges`);function vm(e){let t=[],n=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT,{acceptNode:dm(e=>{let t=e.tagName===`INPUT`&&e.type===`hidden`;return e.disabled||e.hidden||t?NodeFilter.FILTER_SKIP:e.tabIndex>=0?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP},`acceptNode`)});for(;n.nextNode();)t.push(n.currentNode);return t}dm(vm,`getTabbableCandidates`);function ym(e,t){let n=typeof t.checkVisibility==`function`&&t.checkVisibility({checkVisibilityCSS:!0});for(let r of e)if(!(n?!r.checkVisibility({checkVisibilityCSS:!0}):bm(r,{upTo:t})))return r}dm(ym,`findVisible`);function bm(e,{upTo:t}){if(getComputedStyle(e).visibility===`hidden`)return!0;for(;e;){if(t!==void 0&&e===t)return!1;if(getComputedStyle(e).display===`none`)return!0;e=e.parentElement}return!1}dm(bm,`isHidden`);function xm(e){return e instanceof HTMLInputElement&&`select`in e}dm(xm,`isSelectableInput`);function Sm(e,{select:t=!1}={}){if(e&&e.focus){let n=document.activeElement;e.focus({preventScroll:!0}),e!==n&&xm(e)&&t&&e.select()}}dm(Sm,`focus`);var Cm=wm();function wm(){let e=[];return{add(t){let n=e[0];t!==n&&n?.pause(),e=Tm(e,t),e.unshift(t)},remove(t){e=Tm(e,t),e[0]?.resume()}}}dm(wm,`createFocusScopesStack`);function Tm(e,t){let n=[...e],r=n.indexOf(t);return r!==-1&&n.splice(r,1),n}dm(Tm,`arrayRemove`);function Em(e){return e.filter(e=>e.tagName!==`A`)}dm(Em,`removeLinks`);var Dm=Object.defineProperty,Om=p.forwardRef(((e,t)=>Dm(e,`name`,{value:t,configurable:!0}))(function(e,t){let{container:n,...r}=e,[i,a]=p.useState(!1);np(()=>a(!0),[]);let o=n||i&&globalThis?.document?.body;return o?Cp.createPortal((0,m.jsx)(Hp.div,{...r,ref:t}),o):null},`Portal`)),km=Object.defineProperty,Am=(e,t)=>km(e,`name`,{value:t,configurable:!0});function jm(e,t){return p.useReducer((e,n)=>t[e][n]??e,e)}Am(jm,`useStateMachine`);var Mm=Am(e=>{let{present:t,children:n}=e,r=Nm(t),i=typeof n==`function`?n({present:r.isPresent}):p.Children.only(n),a=Fm(r.ref,Lm(i));return typeof n==`function`||r.isPresent?p.cloneElement(i,{ref:a}):null},`Presence`);function Nm(e){let[t,n]=p.useState(),r=p.useRef(null),i=p.useRef(e),a=p.useRef(`none`),o=p.useRef(void 0),[s,c]=jm(e?`mounted`:`unmounted`,{mounted:{UNMOUNT:`unmounted`,ANIMATION_OUT:`unmountSuspended`},unmountSuspended:{MOUNT:`mounted`,ANIMATION_END:`unmounted`},unmounted:{MOUNT:`mounted`}});return p.useEffect(()=>{s===`mounted`?(a.current=o.current??Im(r.current),o.current=void 0):a.current=`none`},[s]),np(()=>{let t=r.current,n=i.current;if(n!==e){let r=a.current,s=Im(t);e?(o.current=s,c(`MOUNT`)):s===`none`||t?.display===`none`?c(`UNMOUNT`):c(n&&r!==s?`ANIMATION_OUT`:`UNMOUNT`),i.current=e}},[e,c]),np(()=>{if(t){let e,n=t.ownerDocument.defaultView??window,o=Am(a=>{let o=Im(r.current).includes(CSS.escape(a.animationName));if(a.target===t&&o&&(c(`ANIMATION_END`),!i.current)){let r=t.style.animationFillMode;t.style.animationFillMode=`forwards`,e=n.setTimeout(()=>{t.style.animationFillMode===`forwards`&&(t.style.animationFillMode=r)})}},`handleAnimationEnd`),s=Am(e=>{e.target===t&&(a.current=Im(r.current))},`handleAnimationStart`);return t.addEventListener(`animationstart`,s),t.addEventListener(`animationcancel`,o),t.addEventListener(`animationend`,o),()=>{n.clearTimeout(e),t.removeEventListener(`animationstart`,s),t.removeEventListener(`animationcancel`,o),t.removeEventListener(`animationend`,o)}}c(`ANIMATION_END`)},[t,c]),{isPresent:[`mounted`,`unmountSuspended`].includes(s),ref:p.useCallback(e=>{if(e){let t=getComputedStyle(e);r.current=t,o.current=Im(t)}else r.current=null;n(e)},[])}}Am(Nm,`usePresence`);function Pm(e,t){if(typeof e==`function`)return e(t);e!=null&&(e.current=t)}Am(Pm,`setRef`);function Fm(...e){let t=p.useRef(e);return t.current=e,p.useCallback(e=>{let n=t.current,r=!1,i=n.map(t=>{let n=Pm(t,e);return!r&&typeof n==`function`&&(r=!0),n});if(r)return()=>{for(let e=0;eRm(e,`name`,{value:t,configurable:!0}),Bm=0,Vm=null;function Hm(e){return Um(),e.children}zm(Hm,`FocusGuards`);function Um(){p.useEffect(()=>{Vm||={start:Wm(),end:Wm()};let{start:e,end:t}=Vm;return document.body.firstElementChild!==e&&document.body.insertAdjacentElement(`afterbegin`,e),document.body.lastElementChild!==t&&document.body.insertAdjacentElement(`beforeend`,t),Bm++,()=>{Bm===1&&(Vm?.start.remove(),Vm?.end.remove(),Vm=null),Bm=Math.max(0,Bm-1)}},[])}zm(Um,`useFocusGuards`);function Wm(){let e=document.createElement(`span`);return e.setAttribute(`data-radix-focus-guard`,``),e.tabIndex=0,e.style.outline=`none`,e.style.opacity=`0`,e.style.position=`fixed`,e.style.pointerEvents=`none`,e}zm(Wm,`createFocusGuard`);var Gm=function(){return Gm=Object.assign||function(e){for(var t,n=1,r=arguments.length;n`u`)return vh;var t=bh(e),n=document.documentElement.clientWidth,r=window.innerWidth;return{left:t[0],top:t[1],right:t[2],gap:Math.max(0,r-n+t[2]-t[0])}},Sh=_h(),Ch=`data-scroll-locked`,wh=function(e,t,n,r){var i=e.left,a=e.top,o=e.right,s=e.gap;return n===void 0&&(n=`margin`),` + .${Xm} { + overflow: hidden ${r}; + padding-right: ${s}px ${r}; + } + body[${Ch}] { + overflow: hidden ${r}; + overscroll-behavior: contain; + ${[t&&`position: relative ${r};`,n===`margin`&&` + padding-left: ${i}px; + padding-top: ${a}px; + padding-right: ${o}px; + margin-left:0; + margin-top:0; + margin-right: ${s}px ${r}; + `,n===`padding`&&`padding-right: ${s}px ${r};`].filter(Boolean).join(``)} + } + + .${Jm} { + right: ${s}px ${r}; + } + + .${Ym} { + margin-right: ${s}px ${r}; + } + + .${Jm} .${Jm} { + right: 0 ${r}; + } + + .${Ym} .${Ym} { + margin-right: 0 ${r}; + } + + body[${Ch}] { + ${Zm}: ${s}px; + } +`},Th=function(){var e=parseInt(document.body.getAttribute(`data-scroll-locked`)||`0`,10);return isFinite(e)?e:0},Eh=function(){p.useEffect(function(){return document.body.setAttribute(Ch,(Th()+1).toString()),function(){var e=Th()-1;e<=0?document.body.removeAttribute(Ch):document.body.setAttribute(Ch,e.toString())}},[])},Dh=function(e){var t=e.noRelative,n=e.noImportant,r=e.gapMode,i=r===void 0?`margin`:r;Eh();var a=p.useMemo(function(){return xh(i)},[i]);return p.createElement(Sh,{styles:wh(a,!t,i,n?``:`!important`)})},Oh=!1;if(typeof window<`u`)try{var kh=Object.defineProperty({},"passive",{get:function(){return Oh=!0,!0}});window.addEventListener(`test`,kh,kh),window.removeEventListener(`test`,kh,kh)}catch{Oh=!1}var Ah=Oh?{passive:!1}:!1,jh=function(e){return e.tagName===`TEXTAREA`},Mh=function(e,t){if(!(e instanceof Element))return!1;var n=window.getComputedStyle(e);return n[t]!==`hidden`&&!(n.overflowY===n.overflowX&&!jh(e)&&n[t]===`visible`)},Nh=function(e){return Mh(e,`overflowY`)},Ph=function(e){return Mh(e,`overflowX`)},Fh=function(e,t){var n=t.ownerDocument,r=t;do{if(typeof ShadowRoot<`u`&&r instanceof ShadowRoot&&(r=r.host),Rh(e,r)){var i=zh(e,r);if(i[1]>i[2])return!0}r=r.parentNode}while(r&&r!==n.body);return!1},Ih=function(e){return[e.scrollTop,e.scrollHeight,e.clientHeight]},Lh=function(e){return[e.scrollLeft,e.scrollWidth,e.clientWidth]},Rh=function(e,t){return e===`v`?Nh(t):Ph(t)},zh=function(e,t){return e===`v`?Ih(t):Lh(t)},Bh=function(e,t){return e===`h`&&t===`rtl`?-1:1},Vh=function(e,t,n,r,i){var a=Bh(e,window.getComputedStyle(t).direction),o=a*r,s=n.target,c=t.contains(s),l=!1,u=o>0,d=0,f=0;do{if(!s)break;var p=zh(e,s),m=p[0],h=p[1]-p[2]-a*m;(m||h)&&Rh(e,s)&&(d+=h,f+=m);var g=s.parentNode;s=g&&g.nodeType===Node.DOCUMENT_FRAGMENT_NODE?g.host:g}while(!c&&s!==document.body||c&&(t.contains(s)||t===s));return(u&&(i&&Math.abs(d)<1||!i&&o>d)||!u&&(i&&Math.abs(f)<1||!i&&-o>f))&&(l=!0),l},Hh=function(e){return`changedTouches`in e?[e.changedTouches[0].clientX,e.changedTouches[0].clientY]:[0,0]},Uh=function(e){return[e.deltaX,e.deltaY]},Wh=function(e){return e&&`current`in e?e.current:e},Gh=function(e,t){return e[0]===t[0]&&e[1]===t[1]},Kh=function(e){return` + .block-interactivity-${e} {pointer-events: none;} + .allow-interactivity-${e} {pointer-events: all;} +`},qh=0,Jh=[];function Yh(e){var t=p.useRef([]),n=p.useRef([0,0]),r=p.useRef(),i=p.useState(qh++)[0],a=p.useState(_h)[0],o=p.useRef(e);p.useEffect(function(){o.current=e},[e]),p.useEffect(function(){if(e.inert){document.body.classList.add(`block-interactivity-${i}`);var t=qm([e.lockRef.current],(e.shards||[]).map(Wh),!0).filter(Boolean);return t.forEach(function(e){return e.classList.add(`allow-interactivity-${i}`)}),function(){document.body.classList.remove(`block-interactivity-${i}`),t.forEach(function(e){return e.classList.remove(`allow-interactivity-${i}`)})}}},[e.inert,e.lockRef.current,e.shards]);var s=p.useCallback(function(e,t){if(`touches`in e&&e.touches.length===2||e.type===`wheel`&&e.ctrlKey)return!o.current.allowPinchZoom;var i=Hh(e),a=n.current,s=`deltaX`in e?e.deltaX:a[0]-i[0],c=`deltaY`in e?e.deltaY:a[1]-i[1],l,u=e.target,d=Math.abs(s)>Math.abs(c)?`h`:`v`;if(`touches`in e&&d===`h`&&u.type===`range`)return!1;var f=window.getSelection(),p=f&&f.anchorNode;if(p&&(p===u||p.contains(u)))return!1;var m=Fh(d,u);if(!m)return!0;if(m?l=d:(l=d===`v`?`h`:`v`,m=Fh(d,u)),!m)return!1;if(!r.current&&`changedTouches`in e&&(s||c)&&(r.current=l),!l)return!0;var h=r.current||l;return Vh(h,t,e,h===`h`?s:c,!0)},[]),c=p.useCallback(function(e){var n=e;if(!(!Jh.length||Jh[Jh.length-1]!==a)){var r=`deltaY`in n?Uh(n):Hh(n),i=t.current.filter(function(e){return e.name===n.type&&(e.target===n.target||n.target===e.shadowParent)&&Gh(e.delta,r)})[0];if(i&&i.should){n.cancelable&&n.preventDefault();return}if(!i){var c=(o.current.shards||[]).map(Wh).filter(Boolean).filter(function(e){return e.contains(n.target)});(c.length>0?s(n,c[0]):!o.current.noIsolation)&&n.cancelable&&n.preventDefault()}}},[]),l=p.useCallback(function(e,n,r,i){var a={name:e,delta:n,target:r,should:i,shadowParent:Xh(r)};t.current.push(a),setTimeout(function(){t.current=t.current.filter(function(e){return e!==a})},1)},[]),u=p.useCallback(function(e){n.current=Hh(e),r.current=void 0},[]),d=p.useCallback(function(t){l(t.type,Uh(t),t.target,s(t,e.lockRef.current))},[]),f=p.useCallback(function(t){l(t.type,Hh(t),t.target,s(t,e.lockRef.current))},[]);p.useEffect(function(){return Jh.push(a),e.setCallbacks({onScrollCapture:d,onWheelCapture:d,onTouchMoveCapture:f}),document.addEventListener(`wheel`,c,Ah),document.addEventListener(`touchmove`,c,Ah),document.addEventListener(`touchstart`,u,Ah),function(){Jh=Jh.filter(function(e){return e!==a}),document.removeEventListener(`wheel`,c,Ah),document.removeEventListener(`touchmove`,c,Ah),document.removeEventListener(`touchstart`,u,Ah)}},[]);var m=e.removeScrollBar,h=e.inert;return p.createElement(p.Fragment,null,h?p.createElement(a,{styles:Kh(i)}):null,m?p.createElement(Dh,{noRelative:e.noRelative,gapMode:e.gapMode}):null)}function Xh(e){for(var t=null;e!==null;)e instanceof ShadowRoot&&(t=e.host,e=e.host),e=e.parentNode;return t}var Zh=sh(ch,Yh),Qh=p.forwardRef(function(e,t){return p.createElement(uh,Gm({},e,{ref:t,sideCar:Zh}))});Qh.classNames=uh.classNames;var $h=function(e){return typeof document>`u`?null:(Array.isArray(e)?e[0]:e).ownerDocument.body},eg=new WeakMap,tg=new WeakMap,ng={},rg=0,ig=function(e){return e&&(e.host||ig(e.parentNode))},ag=function(e,t){return t.map(function(t){if(e.contains(t))return t;var n=ig(t);return n&&e.contains(n)?n:(console.error(`aria-hidden`,t,`in not contained inside`,e,`. Doing nothing`),null)}).filter(function(e){return!!e})},og=function(e,t,n,r){var i=ag(t,Array.isArray(e)?e:[e]);ng[n]||(ng[n]=new WeakMap);var a=ng[n],o=[],s=new Set,c=new Set(i),l=function(e){!e||s.has(e)||(s.add(e),l(e.parentNode))};i.forEach(l);var u=function(e){!e||c.has(e)||Array.prototype.forEach.call(e.children,function(e){if(s.has(e))u(e);else try{var t=e.getAttribute(r),i=t!==null&&t!==`false`,c=(eg.get(e)||0)+1,l=(a.get(e)||0)+1;eg.set(e,c),a.set(e,l),o.push(e),c===1&&i&&tg.set(e,!0),l===1&&e.setAttribute(n,`true`),i||e.setAttribute(r,`true`)}catch(t){console.error(`aria-hidden: cannot operate on `,e,t)}})};return u(t),s.clear(),rg++,function(){o.forEach(function(e){var t=eg.get(e)-1,i=a.get(e)-1;eg.set(e,t),a.set(e,i),t||(tg.has(e)||e.removeAttribute(r),tg.delete(e)),i||e.removeAttribute(n)}),rg--,rg||(eg=new WeakMap,eg=new WeakMap,tg=new WeakMap,ng={})}},sg=function(e,t,n){n===void 0&&(n=`data-aria-hidden`);var r=Array.from(Array.isArray(e)?e:[e]),i=t||$h(e);return i?(r.push.apply(r,Array.from(i.querySelectorAll(`[aria-live], script`))),og(r,i,n,`aria-hidden`)):function(){return null}},cg=Object.defineProperty,lg=(e,t)=>cg(e,`name`,{value:t,configurable:!0}),ug=`Dialog`,[dg,fg]=ep(ug),[pg,mg]=dg(ug),hg=lg(e=>{let{__scopeDialog:t,children:n,open:r,defaultOpen:i,onOpenChange:a,modal:o=!0}=e,s=p.useRef(null),c=p.useRef(null),[l,u]=gp({prop:r,defaultProp:i??!1,onChange:a,caller:ug}),[d,f]=p.useState(0),[h,g]=p.useState(0);return(0,m.jsx)(pg,{scope:t,triggerRef:s,contentRef:c,contentId:sp(),titleId:sp(),descriptionId:sp(),titlePresent:d>0,descriptionPresent:h>0,setTitleCount:f,setDescriptionCount:g,open:l,onOpenChange:u,onOpenToggle:p.useCallback(()=>u(e=>!e),[u]),modal:o,children:n})},`Dialog`),gg=`DialogPortal`,[_g,vg]=dg(gg,{forceMount:void 0}),yg=lg(e=>{let{__scopeDialog:t,forceMount:n,children:r,container:i}=e,a=mg(gg,t);return(0,m.jsx)(_g,{scope:t,forceMount:n,children:p.Children.map(r,e=>(0,m.jsx)(Mm,{present:n||a.open,children:(0,m.jsx)(Om,{asChild:!0,container:i,children:e})}))})},`DialogPortal`),bg=`DialogOverlay`,xg=p.forwardRef(lg(function(e,t){let n=vg(bg,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=mg(bg,e.__scopeDialog);return a.modal?(0,m.jsx)(Mm,{present:r||a.open,children:(0,m.jsx)(Cg,{...i,ref:t})}):null},`DialogOverlay`)),Sg=Ep(`DialogOverlay.RemoveScroll`),Cg=p.forwardRef(lg(function(e,t){let{__scopeDialog:n,...r}=e,i=mg(bg,n),a=Xf(t,nm());return(0,m.jsx)(Qh,{as:Sg,allowPinchZoom:!0,shards:[i.contentRef],children:(0,m.jsx)(Hp.div,{"data-state":Fg(i.open),...r,ref:a,style:{pointerEvents:`auto`,...r.style}})})},`DialogOverlayImpl`)),wg=`DialogContent`,Tg=p.forwardRef(lg(function(e,t){let n=vg(wg,e.__scopeDialog),{forceMount:r=n.forceMount,...i}=e,a=mg(wg,e.__scopeDialog);return(0,m.jsx)(Mm,{present:r||a.open,children:a.modal?(0,m.jsx)(Eg,{...i,ref:t}):(0,m.jsx)(Dg,{...i,ref:t})})},`DialogContent`)),Eg=p.forwardRef(lg(function(e,t){let n=mg(wg,e.__scopeDialog),r=p.useRef(null),i=Xf(t,n.contentRef,r);return p.useEffect(()=>{let e=r.current;if(e)return sg(e)},[]),(0,m.jsx)(Og,{...e,ref:i,trapFocus:n.open,disableOutsidePointerEvents:n.open,onCloseAutoFocus:Vf(e.onCloseAutoFocus,e=>{e.preventDefault(),n.triggerRef.current?.focus()}),onPointerDownOutside:Vf(e.onPointerDownOutside,e=>{let t=e.detail.originalEvent,n=t.button===0&&t.ctrlKey===!0;(t.button===2||n)&&e.preventDefault()}),onFocusOutside:Vf(e.onFocusOutside,e=>e.preventDefault())})},`DialogContentModal`)),Dg=p.forwardRef(lg(function(e,t){let n=mg(wg,e.__scopeDialog),r=p.useRef(!1),i=p.useRef(!1);return(0,m.jsx)(Og,{...e,ref:t,trapFocus:!1,disableOutsidePointerEvents:!1,onCloseAutoFocus:t=>{e.onCloseAutoFocus?.(t),t.defaultPrevented||(r.current||n.triggerRef.current?.focus(),t.preventDefault()),r.current=!1,i.current=!1},onInteractOutside:t=>{e.onInteractOutside?.(t),t.defaultPrevented||(r.current=!0,t.detail.originalEvent.type===`pointerdown`&&(i.current=!0));let a=t.target;n.triggerRef.current?.contains(a)&&t.preventDefault(),t.detail.originalEvent.type===`focusin`&&i.current&&t.preventDefault()}})},`DialogContentNonModal`)),Og=p.forwardRef(lg(function(e,t){let{__scopeDialog:n,trapFocus:r,onOpenAutoFocus:i,onCloseAutoFocus:a,...o}=e,s=mg(wg,n);return Um(),(0,m.jsx)(m.Fragment,{children:(0,m.jsx)(hm,{asChild:!0,loop:!0,trapped:r,onMountAutoFocus:i,onUnmountAutoFocus:a,children:(0,m.jsx)(em,{role:`dialog`,id:s.contentId,"aria-describedby":s.descriptionPresent?s.descriptionId:void 0,"aria-labelledby":s.titlePresent?s.titleId:void 0,"data-state":Fg(s.open),...o,ref:t,deferPointerDownOutside:!0,onDismiss:()=>s.onOpenChange(!1)})})})},`DialogContentImpl`)),kg=`DialogTitle`,Ag=p.forwardRef(lg(function(e,t){let{__scopeDialog:n,...r}=e,i=mg(kg,n),{setTitleCount:a}=i;return np(()=>(a(e=>e+1),()=>a(e=>e-1)),[a]),(0,m.jsx)(Hp.h2,{id:i.titleId,...r,ref:t})},`DialogTitle`)),jg=`DialogDescription`,Mg=p.forwardRef(lg(function(e,t){let{__scopeDialog:n,...r}=e,i=mg(jg,n),{setDescriptionCount:a}=i;return np(()=>(a(e=>e+1),()=>a(e=>e-1)),[a]),(0,m.jsx)(Hp.p,{id:i.descriptionId,...r,ref:t})},`DialogDescription`)),Ng=`DialogClose`,Pg=p.forwardRef(lg(function(e,t){let{__scopeDialog:n,...r}=e,i=mg(Ng,n);return(0,m.jsx)(Hp.button,{type:`button`,...r,ref:t,onClick:Vf(e.onClick,()=>i.onOpenChange(!1))})},`DialogClose`));function Fg(e){return e?`open`:`closed`}lg(Fg,`getState`);var Ig=Object.defineProperty,Lg=(e,t)=>Ig(e,`name`,{value:t,configurable:!0});function Rg(e){let t=e+`CollectionProvider`,[n,r]=ep(t),[i,a]=n(t,{collectionRef:{current:null},itemMap:new Map}),o=Lg(e=>{let{scope:t,children:n}=e,r=p.useRef(null),a=p.useRef(new Map).current;return(0,m.jsx)(i,{scope:t,itemMap:a,collectionRef:r,children:n})},`CollectionProvider`);o.displayName=t;let s=e+`CollectionSlot`,c=Ep(s),l=p.forwardRef((e,t)=>{let{scope:n,children:r}=e,i=Xf(t,a(s,n).collectionRef);return(0,m.jsx)(c,{ref:i,children:r})});l.displayName=s;let u=e+`CollectionItemSlot`,d=`data-radix-collection-item`,f=Ep(u),h=p.forwardRef((e,t)=>{let{scope:n,children:r,...i}=e,o=p.useRef(null),s=Xf(t,o),c=a(u,n);return p.useEffect(()=>(c.itemMap.set(o,{ref:o,...i}),()=>void c.itemMap.delete(o))),(0,m.jsx)(f,{[d]:``,ref:s,children:r})});h.displayName=u;function g(t){let n=a(e+`CollectionConsumer`,t);return p.useCallback(()=>{let e=n.collectionRef.current;if(!e)return[];let t=Array.from(e.querySelectorAll(`[${d}]`));return Array.from(n.itemMap.values()).sort((e,n)=>t.indexOf(e.ref.current)-t.indexOf(n.ref.current))},[n.collectionRef,n.itemMap])}return Lg(g,`useCollection`),[{Provider:o,Slot:l,ItemSlot:h},g,r]}Lg(Rg,`createCollection`);var zg=new WeakMap,Bg=class e extends Map{static{Lg(this,`OrderedDict`)}#e;constructor(e){super(e),this.#e=[...super.keys()],zg.set(this,!0)}set(e,t){return zg.get(this)&&(this.has(e)?this.#e[this.#e.indexOf(e)]=e:this.#e.push(e)),super.set(e,t),this}insert(e,t,n){let r=this.has(t),i=this.#e.length,a=Ug(e),o=a>=0?a:i+a,s=o<0||o>=i?-1:o;if(s===this.size||r&&s===this.size-1||s===-1)return this.set(t,n),this;let c=this.size+ +!r;a<0&&o++;let l=[...this.#e],u,d=!1;for(let e=o;e=this.size&&(r=this.size-1),this.at(r)}keyFrom(e,t){let n=this.indexOf(e);if(n===-1)return;let r=n+t;return r<0&&(r=0),r>=this.size&&(r=this.size-1),this.keyAt(r)}find(e,t){let n=0;for(let r of this){if(Reflect.apply(e,t,[r,n,this]))return r;n++}}findIndex(e,t){let n=0;for(let r of this){if(Reflect.apply(e,t,[r,n,this]))return n;n++}return-1}filter(t,n){let r=[],i=0;for(let e of this)Reflect.apply(t,n,[e,i,this])&&r.push(e),i++;return new e(r)}map(t,n){let r=[],i=0;for(let e of this)r.push([e[0],Reflect.apply(t,n,[e,i,this])]),i++;return new e(r)}reduce(...e){let[t,n]=e,r=0,i=n??this.at(0);for(let n of this)i=r===0&&e.length===1?n:Reflect.apply(t,this,[i,n,r,this]),r++;return i}reduceRight(...e){let[t,n]=e,r=n??this.at(-1);for(let n=this.size-1;n>=0;n--){let i=this.at(n);r=n===this.size-1&&e.length===1?i:Reflect.apply(t,this,[r,i,n,this])}return r}toSorted(t){let n=[...this.entries()].sort(t);return new e(n)}toReversed(){let t=new e;for(let e=this.size-1;e>=0;e--){let n=this.keyAt(e),r=this.get(n);t.set(n,r)}return t}toSpliced(...t){let n=[...this.entries()];return n.splice(...t),new e(n)}slice(t,n){let r=new e,i=this.size-1;if(t===void 0)return r;t<0&&(t+=this.size),n!==void 0&&n>0&&(i=n-1);for(let e=t;e<=i;e++){let t=this.keyAt(e),n=this.get(t);r.set(t,n)}return r}every(e,t){let n=0;for(let r of this){if(!Reflect.apply(e,t,[r,n,this]))return!1;n++}return!0}some(e,t){let n=0;for(let r of this){if(Reflect.apply(e,t,[r,n,this]))return!0;n++}return!1}};function Vg(e,t){if(`at`in Array.prototype)return Array.prototype.at.call(e,t);let n=Hg(e,t);return n===-1?void 0:e[n]}Lg(Vg,`at`);function Hg(e,t){let n=e.length,r=Ug(t),i=r>=0?r:n+r;return i<0||i>=n?-1:i}Lg(Hg,`toSafeIndex`);function Ug(e){return e!==e||e===0?0:Math.trunc(e)}Lg(Ug,`toSafeInteger`);function Wg(e){let t=e+`CollectionProvider`,[n,r]=ep(t),[i,a]=n(t,{collectionElement:null,collectionRef:{current:null},collectionRefObject:{current:null},itemMap:new Bg,setItemMap:Lg(()=>void 0,`setItemMap`)}),o=Lg(({state:e,...t})=>e?(0,m.jsx)(c,{...t,state:e}):(0,m.jsx)(s,{...t}),`CollectionProvider`);o.displayName=t;let s=Lg(e=>{let t=_();return(0,m.jsx)(c,{...e,state:t})},`CollectionInit`);s.displayName=t+`Init`;let c=Lg(e=>{let{scope:t,children:n,state:r}=e,a=p.useRef(null),[o,s]=p.useState(null),c=Xf(a,s),[l,u]=r;return p.useEffect(()=>{if(!o)return;let e=Jg(()=>{});return e.observe(o,{childList:!0,subtree:!0}),()=>{e.disconnect()}},[o]),(0,m.jsx)(i,{scope:t,itemMap:l,setItemMap:u,collectionRef:c,collectionRefObject:a,collectionElement:o,children:n})},`CollectionProviderImpl`);c.displayName=t+`Impl`;let l=e+`CollectionSlot`,u=Ep(l),d=p.forwardRef((e,t)=>{let{scope:n,children:r}=e,i=Xf(t,a(l,n).collectionRef);return(0,m.jsx)(u,{ref:i,children:r})});d.displayName=l;let f=e+`CollectionItemSlot`,h=Ep(f),g=p.forwardRef((e,t)=>{let{scope:n,children:r,...i}=e,o=p.useRef(null),[s,c]=p.useState(null),l=Xf(t,o,c),{setItemMap:u}=a(f,n),d=p.useRef(i);Gg(d.current,i)||(d.current=i);let g=d.current;return p.useEffect(()=>{let e=g;return u(t=>s?t.has(s)?t.set(s,{...e,element:s}).toSorted(qg):(t.set(s,{...e,element:s}),t.toSorted(qg)):t),()=>{u(e=>!s||!e.has(s)?e:(e.delete(s),new Bg(e)))}},[s,g,u]),(0,m.jsx)(h,{"data-radix-collection-item":``,ref:l,children:r})});g.displayName=f;function _(){return p.useState(new Bg)}Lg(_,`useInitCollection`);function v(t){let{itemMap:n}=a(e+`CollectionConsumer`,t);return n}return Lg(v,`useCollection`),[{Provider:o,Slot:d,ItemSlot:g},{createCollectionScope:r,useCollection:v,useInitCollection:_}]}Lg(Wg,`createCollection`);function Gg(e,t){if(e===t)return!0;if(typeof e!=`object`||typeof t!=`object`||e==null||t==null)return!1;let n=Object.keys(e),r=Object.keys(t);if(n.length!==r.length)return!1;for(let r of n)if(!Object.prototype.hasOwnProperty.call(t,r)||e[r]!==t[r])return!1;return!0}Lg(Gg,`shallowEqual`);function Kg(e,t){return!!(t.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_PRECEDING)}Lg(Kg,`isElementPreceding`);function qg(e,t){return!e[1].element||!t[1].element?0:Kg(e[1].element,t[1].element)?-1:1}Lg(qg,`sortByDocumentPosition`);function Jg(e){return new MutationObserver(t=>{for(let n of t)if(n.type===`childList`){e();return}})}Lg(Jg,`getChildListObserver`);var Yg=Object.defineProperty,Xg=(e,t)=>Yg(e,`name`,{value:t,configurable:!0}),Zg=Object.freeze({position:`absolute`,border:0,width:1,height:1,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,wordWrap:`normal`}),Qg=p.forwardRef(Xg(function(e,t){return(0,m.jsx)(Hp.span,{...e,ref:t,style:{...Zg,...e.style}})},`VisuallyHidden`)),$g=Object.defineProperty,e_=(e,t)=>$g(e,`name`,{value:t,configurable:!0}),t_=`ToastProvider`,[n_,r_,i_]=Rg(`Toast`),[a_,o_]=ep(`Toast`,[i_]),[s_,c_]=a_(t_),l_=e_(e=>{let{__scopeToast:t,label:n=`Notification`,duration:r=5e3,swipeDirection:i=`right`,swipeThreshold:a=50,announcerContainer:o,children:s}=e,[c,l]=p.useState(null),[u,d]=p.useState(0),f=p.useRef(!1);return n.trim()||console.error(`Invalid prop \`label\` supplied to \`${t_}\`. Expected non-empty \`string\`.`),(0,m.jsx)(n_.Provider,{scope:t,children:(0,m.jsx)(s_,{scope:t,label:n,duration:r,swipeDirection:i,swipeThreshold:a,toastCount:u,viewport:c,onViewportChange:l,onToastAdd:p.useCallback(()=>d(e=>e+1),[]),onToastRemove:p.useCallback(()=>d(e=>e-1),[]),isClosePausedRef:f,announcerContainer:o,children:s})})},`ToastProvider`),u_=`ToastViewport`,d_=[`F8`],f_=`toast.viewportPause`,p_=`toast.viewportResume`,m_=p.forwardRef(e_(function(e,t){let{__scopeToast:n,hotkey:r=d_,label:i=`Notifications ({hotkey})`,...a}=e,o=c_(u_,n),s=r_(n),c=p.useRef(null),l=p.useRef(null),u=p.useRef(null),d=p.useRef(null),f=Xf(t,d,o.onViewportChange),h=r.join(`+`).replace(/Key/g,``).replace(/Digit/g,``),g=o.toastCount>0;p.useEffect(()=>{let e=e_(e=>{r.length!==0&&r.every(t=>e[t]||e.code===t)&&d.current?.focus()},`handleKeyDown`);return document.addEventListener(`keydown`,e),()=>document.removeEventListener(`keydown`,e)},[r]),p.useEffect(()=>{let e=c.current,t=d.current;if(g&&e&&t){let n=e_(()=>{if(!o.isClosePausedRef.current){let e=new CustomEvent(f_);t.dispatchEvent(e),o.isClosePausedRef.current=!0}},`handlePause`),r=e_(()=>{if(o.isClosePausedRef.current){let e=new CustomEvent(p_);t.dispatchEvent(e),o.isClosePausedRef.current=!1}},`handleResume`),i=e_(t=>{e.contains(t.relatedTarget)||r()},`handleFocusOutResume`),a=e_(()=>{e.contains(document.activeElement)||r()},`handlePointerLeaveResume`);return e.addEventListener(`focusin`,n),e.addEventListener(`focusout`,i),e.addEventListener(`pointermove`,n),e.addEventListener(`pointerleave`,a),window.addEventListener(`blur`,n),window.addEventListener(`focus`,r),()=>{e.removeEventListener(`focusin`,n),e.removeEventListener(`focusout`,i),e.removeEventListener(`pointermove`,n),e.removeEventListener(`pointerleave`,a),window.removeEventListener(`blur`,n),window.removeEventListener(`focus`,r)}}},[g,o.isClosePausedRef]);let _=p.useCallback(({tabbingDirection:e})=>{let t=s().map(t=>{let n=t.ref.current,r=[n,...P_(n)];return e===`forwards`?r:r.reverse()});return(e===`forwards`?t.reverse():t).flat()},[s]);return p.useEffect(()=>{let e=d.current;if(e){let t=e_(t=>{let n=t.altKey||t.ctrlKey||t.metaKey;if(t.key===`Tab`&&!n){let n=document.activeElement,r=t.shiftKey;if(t.target===e&&r){l.current?.focus();return}let i=_({tabbingDirection:r?`backwards`:`forwards`}),a=i.findIndex(e=>e===n);F_(i.slice(a+1))?t.preventDefault():r?l.current?.focus():u.current?.focus()}},`handleKeyDown`);return e.addEventListener(`keydown`,t),()=>e.removeEventListener(`keydown`,t)}},[s,_]),(0,m.jsxs)(lm,{ref:c,role:`region`,"aria-label":i.replace(`{hotkey}`,h),tabIndex:-1,style:{pointerEvents:g?void 0:`none`},children:[g&&(0,m.jsx)(g_,{ref:l,onFocusFromOutsideViewport:()=>{F_(_({tabbingDirection:`forwards`}))}}),(0,m.jsx)(n_.Slot,{scope:n,children:(0,m.jsx)(Hp.ol,{tabIndex:-1,...a,ref:f})}),g&&(0,m.jsx)(g_,{ref:u,onFocusFromOutsideViewport:()=>{F_(_({tabbingDirection:`backwards`}))}})]})},`ToastViewport`)),h_=`ToastFocusProxy`,g_=p.forwardRef(e_(function(e,t){let{__scopeToast:n,onFocusFromOutsideViewport:r,...i}=e,a=c_(h_,n);return(0,m.jsx)(Qg,{tabIndex:0,...i,ref:t,style:{position:`fixed`},onFocus:e=>{let t=e.relatedTarget;a.viewport?.contains(t)||r()}})},`ToastFocusProxy`)),__=`Toast`,v_=`toast.swipeStart`,y_=`toast.swipeMove`,b_=`toast.swipeCancel`,x_=`toast.swipeEnd`,S_=p.forwardRef(e_(function(e,t){let{forceMount:n,open:r,defaultOpen:i,onOpenChange:a,...o}=e,[s,c]=gp({prop:r,defaultProp:i??!0,onChange:a,caller:__});return(0,m.jsx)(Mm,{present:n||s,children:(0,m.jsx)(T_,{open:s,...o,ref:t,onClose:()=>c(!1),onPause:Kp(e.onPause),onResume:Kp(e.onResume),onSwipeStart:Vf(e.onSwipeStart,e=>{e.currentTarget.setAttribute(`data-swipe`,`start`)}),onSwipeMove:Vf(e.onSwipeMove,e=>{let{x:t,y:n}=e.detail.delta;e.currentTarget.setAttribute(`data-swipe`,`move`),e.currentTarget.style.setProperty(`--radix-toast-swipe-move-x`,`${t}px`),e.currentTarget.style.setProperty(`--radix-toast-swipe-move-y`,`${n}px`)}),onSwipeCancel:Vf(e.onSwipeCancel,e=>{e.currentTarget.setAttribute(`data-swipe`,`cancel`),e.currentTarget.style.removeProperty(`--radix-toast-swipe-move-x`),e.currentTarget.style.removeProperty(`--radix-toast-swipe-move-y`),e.currentTarget.style.removeProperty(`--radix-toast-swipe-end-x`),e.currentTarget.style.removeProperty(`--radix-toast-swipe-end-y`)}),onSwipeEnd:Vf(e.onSwipeEnd,e=>{let{x:t,y:n}=e.detail.delta;e.currentTarget.setAttribute(`data-swipe`,`end`),e.currentTarget.style.removeProperty(`--radix-toast-swipe-move-x`),e.currentTarget.style.removeProperty(`--radix-toast-swipe-move-y`),e.currentTarget.style.setProperty(`--radix-toast-swipe-end-x`,`${t}px`),e.currentTarget.style.setProperty(`--radix-toast-swipe-end-y`,`${n}px`),c(!1)})})})},`Toast`)),[C_,w_]=a_(__,{onClose(){}}),T_=p.forwardRef(e_(function(e,t){let{__scopeToast:n,type:r=`foreground`,duration:i,open:a,onClose:o,onEscapeKeyDown:s,onPause:c,onResume:l,onSwipeStart:u,onSwipeMove:d,onSwipeCancel:f,onSwipeEnd:h,...g}=e,_=c_(__,n),v=r_(n),[y,b]=p.useState(null),x=Xf(t,b),S=p.useRef(null),C=p.useRef(null),w=i||_.duration,T=p.useRef(0),E=p.useRef(w),D=p.useRef(0),{onToastAdd:O,onToastRemove:ee}=_,te=Kp(()=>{y?.contains(document.activeElement)&&_.viewport?.focus(),o()}),k=p.useCallback(e=>{!e||e===1/0||(window.clearTimeout(D.current),T.current=new Date().getTime(),D.current=window.setTimeout(te,e))},[te]);p.useEffect(()=>{let e=_.viewport;if(e){let t=e_(()=>{k(E.current),l?.()},`handleResume`),n=e_(()=>{let e=new Date().getTime()-T.current;E.current-=e,window.clearTimeout(D.current),c?.()},`handlePause`);return e.addEventListener(f_,n),e.addEventListener(p_,t),()=>{e.removeEventListener(f_,n),e.removeEventListener(p_,t)}}},[_.viewport,w,c,l,k]),p.useEffect(()=>{a&&!_.isClosePausedRef.current&&k(w)},[a,w,_.isClosePausedRef,k]),p.useEffect(()=>()=>{window.clearTimeout(D.current)},[]),p.useEffect(()=>(O(),()=>ee()),[O,ee]);let A=p.useMemo(()=>y?k_(y):null,[y]);return _.viewport?(0,m.jsxs)(m.Fragment,{children:[A&&(0,m.jsx)(E_,{__scopeToast:n,role:`status`,"aria-live":r===`foreground`?`assertive`:`polite`,children:A}),(0,m.jsx)(C_,{scope:n,onClose:te,children:Cp.createPortal((0,m.jsx)(n_.ItemSlot,{scope:n,children:(0,m.jsx)(cm,{asChild:!0,onEscapeKeyDown:Vf(s,e=>{v().some(t=>t.ref.current?.contains(e.target))||te()}),children:(0,m.jsx)(Hp.li,{tabIndex:0,"data-state":a?`open`:`closed`,"data-swipe-direction":_.swipeDirection,...g,ref:x,style:{userSelect:`none`,touchAction:`none`,...e.style},onKeyDown:Vf(e.onKeyDown,e=>{e.key===`Escape`&&(s?.(e.nativeEvent),e.nativeEvent.defaultPrevented||te())}),onPointerDown:Vf(e.onPointerDown,e=>{e.button===0&&(S.current={x:e.clientX,y:e.clientY})}),onPointerMove:Vf(e.onPointerMove,e=>{if(!S.current)return;let t=e.clientX-S.current.x,n=e.clientY-S.current.y,r=!!C.current,i=[`left`,`right`].includes(_.swipeDirection),a=[`left`,`up`].includes(_.swipeDirection)?Math.min:Math.max,o=i?a(0,t):0,s=i?0:a(0,n),c=e.pointerType===`touch`?10:2,l={x:o,y:s},f={originalEvent:e,delta:l};r?(C.current=l,A_(y_,d,f,{discrete:!1})):j_(l,_.swipeDirection,c)?(C.current=l,A_(v_,u,f,{discrete:!1}),e.target.setPointerCapture(e.pointerId)):(Math.abs(t)>c||Math.abs(n)>c)&&(S.current=null)}),onPointerUp:Vf(e.onPointerUp,e=>{let t=C.current,n=e.target;if(n.hasPointerCapture(e.pointerId)&&n.releasePointerCapture(e.pointerId),C.current=null,S.current=null,t){let n=e.currentTarget,r={originalEvent:e,delta:t};j_(t,_.swipeDirection,_.swipeThreshold)?A_(x_,h,r,{discrete:!0}):A_(b_,f,r,{discrete:!0}),n.addEventListener(`click`,e=>e.preventDefault(),{once:!0})}})})})}),_.viewport)})]}):null},`ToastImpl`)),E_=e_(e=>{let{__scopeToast:t,children:n,...r}=e,i=c_(__,t),[a,o]=p.useState(!1),[s,c]=p.useState(!1);return M_(()=>o(!0)),p.useEffect(()=>{let e=window.setTimeout(()=>c(!0),1e3);return()=>window.clearTimeout(e)},[]),s?null:(0,m.jsx)(Om,{asChild:!0,container:i.announcerContainer||void 0,children:(0,m.jsx)(Qg,{...r,children:a&&(0,m.jsxs)(m.Fragment,{children:[i.label,` `,n]})})})},`ToastAnnounce`),D_=p.forwardRef(e_(function(e,t){let{__scopeToast:n,...r}=e;return(0,m.jsx)(Hp.div,{...r,ref:t})},`ToastTitle`)),O_=p.forwardRef(e_(function(e,t){let{__scopeToast:n,...r}=e;return(0,m.jsx)(Hp.div,{...r,ref:t})},`ToastDescription`));function k_(e){let t=[];return Array.from(e.childNodes).forEach(e=>{if(e.nodeType===e.TEXT_NODE&&e.textContent&&t.push(e.textContent),N_(e)){let n=e.ariaHidden||e.hidden||e.style.display===`none`,r=e.dataset.radixToastAnnounceExclude===``;if(!n){if(r){let n=e.dataset.radixToastAnnounceAlt;n&&t.push(n)}else t.push(...k_(e))}}}),t}e_(k_,`getAnnounceTextContent`);function A_(e,t,n,{discrete:r}){let i=n.originalEvent.currentTarget,a=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:n});t&&i.addEventListener(e,t,{once:!0}),r?Up(i,a):i.dispatchEvent(a)}e_(A_,`handleAndDispatchCustomEvent`);var j_=e_((e,t,n=0)=>{let r=Math.abs(e.x),i=Math.abs(e.y),a=r>i;return t===`left`||t===`right`?a&&r>n:!a&&i>n},`isDeltaInDirection`);function M_(e=()=>{}){let t=Kp(e);np(()=>{let e=0,n=0;return e=window.requestAnimationFrame(()=>n=window.requestAnimationFrame(t)),()=>{window.cancelAnimationFrame(e),window.cancelAnimationFrame(n)}},[t])}e_(M_,`useNextFrame`);function N_(e){return e.nodeType===e.ELEMENT_NODE}e_(N_,`isHTMLElement`);function P_(e){let t=[],n=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT,{acceptNode:e_(e=>{let t=e.tagName===`INPUT`&&e.type===`hidden`;return e.disabled||e.hidden||t?NodeFilter.FILTER_SKIP:e.tabIndex>=0?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP},`acceptNode`)});for(;n.nextNode();)t.push(n.currentNode);return t}e_(P_,`getTabbableCandidates`);function F_(e){let t=document.activeElement;return e.some(e=>e===t||(e.focus(),document.activeElement!==t))}e_(F_,`focusFirst`);var I_=l_,L_=m_,R_=S_,z_=D_,B_=O_;function V_(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`){if(Array.isArray(e)){var i=e.length;for(t=0;ttypeof e==`boolean`?`${e}`:e===0?`0`:e,W_=H_,G_=(e,t)=>n=>{if(t?.variants==null)return W_(e,n?.class,n?.className);let{variants:r,defaultVariants:i}=t,a=Object.keys(r).map(e=>{let t=n?.[e],a=i?.[e];if(t===null)return null;let o=U_(t)||U_(a);return r[e][o]}),o=n&&Object.entries(n).reduce((e,t)=>{let[n,r]=t;return r===void 0||(e[n]=r),e},{});return W_(e,a,t?.compoundVariants?.reduce((e,t)=>{let{class:n,className:r,...a}=t;return Object.entries(a).every(e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...i,...o}[t]):{...i,...o}[t]===n})?[...e,n,r]:e},[]),n?.class,n?.className)},K_=(e,t)=>{let n=Array(e.length+t.length);for(let t=0;t({classGroupId:e,validator:t}),J_=(e=new Map,t=null,n)=>({nextPart:e,validators:t,classGroupId:n}),Y_=`-`,X_=[],Z_=`arbitrary..`,Q_=e=>{let t=tv(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{if(e.startsWith(`[`)&&e.endsWith(`]`))return ev(e);let n=e.split(Y_);return $_(n,+(n[0]===``&&n.length>1),t)},getConflictingClassGroupIds:(e,t)=>{if(t){let t=r[e],i=n[e];return t?i?K_(i,t):t:i||X_}return n[e]||X_}}},$_=(e,t,n)=>{if(e.length-t===0)return n.classGroupId;let r=e[t],i=n.nextPart.get(r);if(i){let n=$_(e,t+1,i);if(n)return n}let a=n.validators;if(a===null)return;let o=t===0?e.join(Y_):e.slice(t).join(Y_),s=a.length;for(let e=0;ee.slice(1,-1).indexOf(`:`)===-1?void 0:(()=>{let t=e.slice(1,-1),n=t.indexOf(`:`),r=t.slice(0,n);return r?Z_+r:void 0})(),tv=e=>{let{theme:t,classGroups:n}=e;return nv(n,t)},nv=(e,t)=>{let n=J_();for(let r in e){let i=e[r];rv(i,n,r,t)}return n},rv=(e,t,n,r)=>{let i=e.length;for(let a=0;a{if(typeof e==`string`){av(e,t,n);return}if(typeof e==`function`){ov(e,t,n,r);return}sv(e,t,n,r)},av=(e,t,n)=>{let r=e===``?t:cv(t,e);r.classGroupId=n},ov=(e,t,n,r)=>{if(lv(e)){rv(e(r),t,n,r);return}t.validators===null&&(t.validators=[]),t.validators.push(q_(n,e))},sv=(e,t,n,r)=>{let i=Object.entries(e),a=i.length;for(let e=0;e{let n=e,r=t.split(Y_),i=r.length;for(let e=0;e`isThemeGetter`in e&&e.isThemeGetter===!0,uv=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=Object.create(null),r=Object.create(null),i=(i,a)=>{n[i]=a,t++,t>e&&(t=0,r=n,n=Object.create(null))};return{get(e){let t=n[e];if(t!==void 0)return t;if((t=r[e])!==void 0)return i(e,t),t},set(e,t){e in n?n[e]=t:i(e,t)}}},dv=`!`,fv=`:`,pv=[],mv=(e,t,n,r,i)=>({modifiers:e,hasImportantModifier:t,baseClassName:n,maybePostfixModifierPosition:r,isExternal:i}),hv=e=>{let{prefix:t,experimentalParseClassName:n}=e,r=e=>{let t=[],n=0,r=0,i=0,a,o=e.length;for(let s=0;si?a-i:void 0;return mv(t,l,c,u)};if(t){let e=t+fv,n=r;r=t=>t.startsWith(e)?n(t.slice(e.length)):mv(pv,!1,t,void 0,!0)}if(n){let e=r;r=t=>n({className:t,parseClassName:e})}return r},gv=e=>{let t=new Map;return e.orderSensitiveModifiers.forEach((e,n)=>{t.set(e,1e6+n)}),e=>{let n=[],r=[];for(let i=0;i0&&(r.sort(),n.push(...r),r=[]),n.push(a)):r.push(a)}return r.length>0&&(r.sort(),n.push(...r)),n}},_v=e=>({cache:uv(e.cacheSize),parseClassName:hv(e),sortModifiers:gv(e),postfixLookupClassGroupIds:vv(e),...Q_(e)}),vv=e=>{let t=Object.create(null),n=e.postfixLookupClassGroups;if(n)for(let e=0;e{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i,sortModifiers:a,postfixLookupClassGroupIds:o}=t,s=[],c=e.trim().split(yv),l=``;for(let e=c.length-1;e>=0;--e){let t=c[e],{isExternal:u,modifiers:d,hasImportantModifier:f,baseClassName:p,maybePostfixModifierPosition:m}=n(t);if(u){l=t+(l.length>0?` `+l:l);continue}let h=!!m,g;if(h){g=r(p.substring(0,m));let e=g&&o[g]?r(p):void 0;e&&e!==g&&(g=e,h=!1)}else g=r(p);if(!g){if(!h){l=t+(l.length>0?` `+l:l);continue}if(g=r(p),!g){l=t+(l.length>0?` `+l:l);continue}h=!1}let _=d.length===0?``:d.length===1?d[0]:a(d).join(`:`),v=f?_+dv:_,y=v+g;if(s.indexOf(y)>-1)continue;s.push(y);let b=i(g,h);for(let e=0;e0?` `+l:l)}return l},xv=(...e)=>{let t=0,n,r,i=``;for(;t{if(typeof e==`string`)return e;let t,n=``;for(let r=0;r{let n,r,i,a,o=o=>(n=_v(t.reduce((e,t)=>t(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)),s=e=>{let t=r(e);if(t)return t;let a=bv(e,n);return i(e,a),a};return a=o,(...e)=>a(xv(...e))},wv=[],Tv=e=>{let t=t=>t[e]||wv;return t.isThemeGetter=!0,t},Ev=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,Dv=/^\((?:(\w[\w-]*):)?(.+)\)$/i,Ov=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,kv=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,Av=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,jv=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,Mv=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,Nv=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,Pv=e=>Ov.test(e),Z=e=>!!e&&!Number.isNaN(Number(e)),Fv=e=>!!e&&Number.isInteger(Number(e)),Iv=e=>e.endsWith(`%`)&&Z(e.slice(0,-1)),Lv=e=>kv.test(e),Rv=()=>!0,zv=e=>Av.test(e)&&!jv.test(e),Bv=()=>!1,Vv=e=>Mv.test(e),Hv=e=>Nv.test(e),Uv=e=>!Q(e)&&!$(e),Wv=e=>e.startsWith(`@container`)&&(e[10]===`/`&&e[11]!==void 0||e[11]===`s`&&e[16]!==void 0&&e.startsWith(`-size/`,10)||e[11]===`n`&&e[18]!==void 0&&e.startsWith(`-normal/`,10)),Gv=e=>oy(e,uy,Bv),Q=e=>Ev.test(e),Kv=e=>oy(e,dy,zv),qv=e=>oy(e,fy,Z),Jv=e=>oy(e,my,Rv),Yv=e=>oy(e,py,Bv),Xv=e=>oy(e,cy,Bv),Zv=e=>oy(e,ly,Hv),Qv=e=>oy(e,hy,Vv),$=e=>Dv.test(e),$v=e=>sy(e,dy),ey=e=>sy(e,py),ty=e=>sy(e,cy),ny=e=>sy(e,uy),ry=e=>sy(e,ly),iy=e=>sy(e,hy,!0),ay=e=>sy(e,my,!0),oy=(e,t,n)=>{let r=Ev.exec(e);return r?r[1]?t(r[1]):n(r[2]):!1},sy=(e,t,n=!1)=>{let r=Dv.exec(e);return r?r[1]?t(r[1]):n:!1},cy=e=>e===`position`||e===`percentage`,ly=e=>e===`image`||e===`url`,uy=e=>e===`length`||e===`size`||e===`bg-size`,dy=e=>e===`length`,fy=e=>e===`number`,py=e=>e===`family-name`,my=e=>e===`number`||e===`weight`,hy=e=>e===`shadow`,gy=Cv(()=>{let e=Tv(`color`),t=Tv(`font`),n=Tv(`text`),r=Tv(`font-weight`),i=Tv(`tracking`),a=Tv(`leading`),o=Tv(`breakpoint`),s=Tv(`container`),c=Tv(`spacing`),l=Tv(`radius`),u=Tv(`shadow`),d=Tv(`inset-shadow`),f=Tv(`text-shadow`),p=Tv(`drop-shadow`),m=Tv(`blur`),h=Tv(`perspective`),g=Tv(`aspect`),_=Tv(`ease`),v=Tv(`animate`),y=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],b=()=>[`center`,`top`,`bottom`,`left`,`right`,`top-left`,`left-top`,`top-right`,`right-top`,`bottom-right`,`right-bottom`,`bottom-left`,`left-bottom`],x=()=>[...b(),$,Q],S=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],C=()=>[`auto`,`contain`,`none`],w=()=>[$,Q,c],T=()=>[Pv,`full`,`auto`,...w()],E=()=>[Fv,`none`,`subgrid`,$,Q],D=()=>[`auto`,{span:[`full`,Fv,$,Q]},Fv,$,Q],O=()=>[Fv,`auto`,$,Q],ee=()=>[`auto`,`min`,`max`,`fr`,$,Q],te=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`,`baseline`,`center-safe`,`end-safe`],k=()=>[`start`,`end`,`center`,`stretch`,`center-safe`,`end-safe`],A=()=>[`auto`,...w()],ne=()=>[Pv,`auto`,`full`,`dvw`,`dvh`,`lvw`,`lvh`,`svw`,`svh`,`min`,`max`,`fit`,...w()],j=()=>[Pv,`screen`,`full`,`dvw`,`lvw`,`svw`,`min`,`max`,`fit`,...w()],M=()=>[Pv,`screen`,`full`,`lh`,`dvh`,`lvh`,`svh`,`min`,`max`,`fit`,...w()],N=()=>[e,$,Q],re=()=>[...b(),ty,Xv,{position:[$,Q]}],ie=()=>[`no-repeat`,{repeat:[``,`x`,`y`,`space`,`round`]}],ae=()=>[`auto`,`cover`,`contain`,ny,Gv,{size:[$,Q]}],P=()=>[Iv,$v,Kv],F=()=>[``,`none`,`full`,l,$,Q],oe=()=>[``,Z,$v,Kv],se=()=>[`solid`,`dashed`,`dotted`,`double`],ce=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],le=()=>[Z,Iv,ty,Xv],ue=()=>[``,`none`,m,$,Q],de=()=>[`none`,Z,$,Q],fe=()=>[`none`,Z,$,Q],pe=()=>[Z,$,Q],me=()=>[Pv,`full`,...w()];return{cacheSize:500,theme:{animate:[`spin`,`ping`,`pulse`,`bounce`],aspect:[`video`],blur:[Lv],breakpoint:[Lv],color:[Rv],container:[Lv],"drop-shadow":[Lv],ease:[`in`,`out`,`in-out`],font:[Uv],"font-weight":[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`],"inset-shadow":[Lv],leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`],perspective:[`dramatic`,`near`,`normal`,`midrange`,`distant`,`none`],radius:[Lv],shadow:[Lv],spacing:[`px`,Z],text:[Lv],"text-shadow":[Lv],tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`]},classGroups:{aspect:[{aspect:[`auto`,`square`,Pv,Q,$,g]}],container:[`container`],"container-type":[{"@container":[``,`normal`,`size`,$,Q]}],"container-named":[Wv],columns:[{columns:[Z,Q,$,s]}],"break-after":[{"break-after":y()}],"break-before":[{"break-before":y()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],sr:[`sr-only`,`not-sr-only`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:x()}],overflow:[{overflow:S()}],"overflow-x":[{"overflow-x":S()}],"overflow-y":[{"overflow-y":S()}],overscroll:[{overscroll:C()}],"overscroll-x":[{"overscroll-x":C()}],"overscroll-y":[{"overscroll-y":C()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:T()}],"inset-x":[{"inset-x":T()}],"inset-y":[{"inset-y":T()}],start:[{"inset-s":T(),start:T()}],end:[{"inset-e":T(),end:T()}],"inset-bs":[{"inset-bs":T()}],"inset-be":[{"inset-be":T()}],top:[{top:T()}],right:[{right:T()}],bottom:[{bottom:T()}],left:[{left:T()}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[Fv,`auto`,$,Q]}],basis:[{basis:[Pv,`full`,`auto`,s,...w()]}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`nowrap`,`wrap`,`wrap-reverse`]}],flex:[{flex:[Z,Pv,`auto`,`initial`,`none`,Q]}],grow:[{grow:[``,Z,$,Q]}],shrink:[{shrink:[``,Z,$,Q]}],order:[{order:[Fv,`first`,`last`,`none`,$,Q]}],"grid-cols":[{"grid-cols":E()}],"col-start-end":[{col:D()}],"col-start":[{"col-start":O()}],"col-end":[{"col-end":O()}],"grid-rows":[{"grid-rows":E()}],"row-start-end":[{row:D()}],"row-start":[{"row-start":O()}],"row-end":[{"row-end":O()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":ee()}],"auto-rows":[{"auto-rows":ee()}],gap:[{gap:w()}],"gap-x":[{"gap-x":w()}],"gap-y":[{"gap-y":w()}],"justify-content":[{justify:[...te(),`normal`]}],"justify-items":[{"justify-items":[...k(),`normal`]}],"justify-self":[{"justify-self":[`auto`,...k()]}],"align-content":[{content:[`normal`,...te()]}],"align-items":[{items:[...k(),{baseline:[``,`last`]}]}],"align-self":[{self:[`auto`,...k(),{baseline:[``,`last`]}]}],"place-content":[{"place-content":te()}],"place-items":[{"place-items":[...k(),`baseline`]}],"place-self":[{"place-self":[`auto`,...k()]}],p:[{p:w()}],px:[{px:w()}],py:[{py:w()}],ps:[{ps:w()}],pe:[{pe:w()}],pbs:[{pbs:w()}],pbe:[{pbe:w()}],pt:[{pt:w()}],pr:[{pr:w()}],pb:[{pb:w()}],pl:[{pl:w()}],m:[{m:A()}],mx:[{mx:A()}],my:[{my:A()}],ms:[{ms:A()}],me:[{me:A()}],mbs:[{mbs:A()}],mbe:[{mbe:A()}],mt:[{mt:A()}],mr:[{mr:A()}],mb:[{mb:A()}],ml:[{ml:A()}],"space-x":[{"space-x":w()}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":w()}],"space-y-reverse":[`space-y-reverse`],size:[{size:ne()}],"inline-size":[{inline:[`auto`,...j()]}],"min-inline-size":[{"min-inline":[`auto`,...j()]}],"max-inline-size":[{"max-inline":[`none`,...j()]}],"block-size":[{block:[`auto`,...M()]}],"min-block-size":[{"min-block":[`auto`,...M()]}],"max-block-size":[{"max-block":[`none`,...M()]}],w:[{w:[s,`screen`,...ne()]}],"min-w":[{"min-w":[s,`screen`,`none`,...ne()]}],"max-w":[{"max-w":[s,`screen`,`none`,`prose`,{screen:[o]},...ne()]}],h:[{h:[`screen`,`lh`,...ne()]}],"min-h":[{"min-h":[`screen`,`lh`,`none`,...ne()]}],"max-h":[{"max-h":[`screen`,`lh`,...ne()]}],"font-size":[{text:[`base`,n,$v,Kv]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[r,ay,Jv]}],"font-stretch":[{"font-stretch":[`ultra-condensed`,`extra-condensed`,`condensed`,`semi-condensed`,`normal`,`semi-expanded`,`expanded`,`extra-expanded`,`ultra-expanded`,Iv,Q]}],"font-family":[{font:[ey,Yv,t]}],"font-features":[{"font-features":[Q]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[i,$,Q]}],"line-clamp":[{"line-clamp":[Z,`none`,$,qv]}],leading:[{leading:[a,...w()]}],"list-image":[{"list-image":[`none`,$,Q]}],"list-style-position":[{list:[`inside`,`outside`]}],"list-style-type":[{list:[`disc`,`decimal`,`none`,$,Q]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"placeholder-color":[{placeholder:N()}],"text-color":[{text:N()}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[...se(),`wavy`]}],"text-decoration-thickness":[{decoration:[Z,`from-font`,`auto`,$,Kv]}],"text-decoration-color":[{decoration:N()}],"underline-offset":[{"underline-offset":[Z,`auto`,$,Q]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:w()}],"tab-size":[{tab:[Fv,$,Q]}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,$,Q]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],wrap:[{wrap:[`break-word`,`anywhere`,`normal`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,$,Q]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:re()}],"bg-repeat":[{bg:ie()}],"bg-size":[{bg:ae()}],"bg-image":[{bg:[`none`,{linear:[{to:[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},Fv,$,Q],radial:[``,$,Q],conic:[Fv,$,Q]},ry,Zv]}],"bg-color":[{bg:N()}],"gradient-from-pos":[{from:P()}],"gradient-via-pos":[{via:P()}],"gradient-to-pos":[{to:P()}],"gradient-from":[{from:N()}],"gradient-via":[{via:N()}],"gradient-to":[{to:N()}],rounded:[{rounded:F()}],"rounded-s":[{"rounded-s":F()}],"rounded-e":[{"rounded-e":F()}],"rounded-t":[{"rounded-t":F()}],"rounded-r":[{"rounded-r":F()}],"rounded-b":[{"rounded-b":F()}],"rounded-l":[{"rounded-l":F()}],"rounded-ss":[{"rounded-ss":F()}],"rounded-se":[{"rounded-se":F()}],"rounded-ee":[{"rounded-ee":F()}],"rounded-es":[{"rounded-es":F()}],"rounded-tl":[{"rounded-tl":F()}],"rounded-tr":[{"rounded-tr":F()}],"rounded-br":[{"rounded-br":F()}],"rounded-bl":[{"rounded-bl":F()}],"border-w":[{border:oe()}],"border-w-x":[{"border-x":oe()}],"border-w-y":[{"border-y":oe()}],"border-w-s":[{"border-s":oe()}],"border-w-e":[{"border-e":oe()}],"border-w-bs":[{"border-bs":oe()}],"border-w-be":[{"border-be":oe()}],"border-w-t":[{"border-t":oe()}],"border-w-r":[{"border-r":oe()}],"border-w-b":[{"border-b":oe()}],"border-w-l":[{"border-l":oe()}],"divide-x":[{"divide-x":oe()}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":oe()}],"divide-y-reverse":[`divide-y-reverse`],"border-style":[{border:[...se(),`hidden`,`none`]}],"divide-style":[{divide:[...se(),`hidden`,`none`]}],"border-color":[{border:N()}],"border-color-x":[{"border-x":N()}],"border-color-y":[{"border-y":N()}],"border-color-s":[{"border-s":N()}],"border-color-e":[{"border-e":N()}],"border-color-bs":[{"border-bs":N()}],"border-color-be":[{"border-be":N()}],"border-color-t":[{"border-t":N()}],"border-color-r":[{"border-r":N()}],"border-color-b":[{"border-b":N()}],"border-color-l":[{"border-l":N()}],"divide-color":[{divide:N()}],"outline-style":[{outline:[...se(),`none`,`hidden`]}],"outline-offset":[{"outline-offset":[Z,$,Q]}],"outline-w":[{outline:[``,Z,$v,Kv]}],"outline-color":[{outline:N()}],shadow:[{shadow:[``,`none`,u,iy,Qv]}],"shadow-color":[{shadow:N()}],"inset-shadow":[{"inset-shadow":[`none`,d,iy,Qv]}],"inset-shadow-color":[{"inset-shadow":N()}],"ring-w":[{ring:oe()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:N()}],"ring-offset-w":[{"ring-offset":[Z,Kv]}],"ring-offset-color":[{"ring-offset":N()}],"inset-ring-w":[{"inset-ring":oe()}],"inset-ring-color":[{"inset-ring":N()}],"text-shadow":[{"text-shadow":[`none`,f,iy,Qv]}],"text-shadow-color":[{"text-shadow":N()}],opacity:[{opacity:[Z,$,Q]}],"mix-blend":[{"mix-blend":[...ce(),`plus-darker`,`plus-lighter`]}],"bg-blend":[{"bg-blend":ce()}],"mask-clip":[{"mask-clip":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]},`mask-no-clip`],"mask-composite":[{mask:[`add`,`subtract`,`intersect`,`exclude`]}],"mask-image-linear-pos":[{"mask-linear":[Z]}],"mask-image-linear-from-pos":[{"mask-linear-from":le()}],"mask-image-linear-to-pos":[{"mask-linear-to":le()}],"mask-image-linear-from-color":[{"mask-linear-from":N()}],"mask-image-linear-to-color":[{"mask-linear-to":N()}],"mask-image-t-from-pos":[{"mask-t-from":le()}],"mask-image-t-to-pos":[{"mask-t-to":le()}],"mask-image-t-from-color":[{"mask-t-from":N()}],"mask-image-t-to-color":[{"mask-t-to":N()}],"mask-image-r-from-pos":[{"mask-r-from":le()}],"mask-image-r-to-pos":[{"mask-r-to":le()}],"mask-image-r-from-color":[{"mask-r-from":N()}],"mask-image-r-to-color":[{"mask-r-to":N()}],"mask-image-b-from-pos":[{"mask-b-from":le()}],"mask-image-b-to-pos":[{"mask-b-to":le()}],"mask-image-b-from-color":[{"mask-b-from":N()}],"mask-image-b-to-color":[{"mask-b-to":N()}],"mask-image-l-from-pos":[{"mask-l-from":le()}],"mask-image-l-to-pos":[{"mask-l-to":le()}],"mask-image-l-from-color":[{"mask-l-from":N()}],"mask-image-l-to-color":[{"mask-l-to":N()}],"mask-image-x-from-pos":[{"mask-x-from":le()}],"mask-image-x-to-pos":[{"mask-x-to":le()}],"mask-image-x-from-color":[{"mask-x-from":N()}],"mask-image-x-to-color":[{"mask-x-to":N()}],"mask-image-y-from-pos":[{"mask-y-from":le()}],"mask-image-y-to-pos":[{"mask-y-to":le()}],"mask-image-y-from-color":[{"mask-y-from":N()}],"mask-image-y-to-color":[{"mask-y-to":N()}],"mask-image-radial":[{"mask-radial":[$,Q]}],"mask-image-radial-from-pos":[{"mask-radial-from":le()}],"mask-image-radial-to-pos":[{"mask-radial-to":le()}],"mask-image-radial-from-color":[{"mask-radial-from":N()}],"mask-image-radial-to-color":[{"mask-radial-to":N()}],"mask-image-radial-shape":[{"mask-radial":[`circle`,`ellipse`]}],"mask-image-radial-size":[{"mask-radial":[{closest:[`side`,`corner`],farthest:[`side`,`corner`]}]}],"mask-image-radial-pos":[{"mask-radial-at":b()}],"mask-image-conic-pos":[{"mask-conic":[Z]}],"mask-image-conic-from-pos":[{"mask-conic-from":le()}],"mask-image-conic-to-pos":[{"mask-conic-to":le()}],"mask-image-conic-from-color":[{"mask-conic-from":N()}],"mask-image-conic-to-color":[{"mask-conic-to":N()}],"mask-mode":[{mask:[`alpha`,`luminance`,`match`]}],"mask-origin":[{"mask-origin":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]}],"mask-position":[{mask:re()}],"mask-repeat":[{mask:ie()}],"mask-size":[{mask:ae()}],"mask-type":[{"mask-type":[`alpha`,`luminance`]}],"mask-image":[{mask:[`none`,$,Q]}],filter:[{filter:[``,`none`,$,Q]}],blur:[{blur:ue()}],brightness:[{brightness:[Z,$,Q]}],contrast:[{contrast:[Z,$,Q]}],"drop-shadow":[{"drop-shadow":[``,`none`,p,iy,Qv]}],"drop-shadow-color":[{"drop-shadow":N()}],grayscale:[{grayscale:[``,Z,$,Q]}],"hue-rotate":[{"hue-rotate":[Z,$,Q]}],invert:[{invert:[``,Z,$,Q]}],saturate:[{saturate:[Z,$,Q]}],sepia:[{sepia:[``,Z,$,Q]}],"backdrop-filter":[{"backdrop-filter":[``,`none`,$,Q]}],"backdrop-blur":[{"backdrop-blur":ue()}],"backdrop-brightness":[{"backdrop-brightness":[Z,$,Q]}],"backdrop-contrast":[{"backdrop-contrast":[Z,$,Q]}],"backdrop-grayscale":[{"backdrop-grayscale":[``,Z,$,Q]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[Z,$,Q]}],"backdrop-invert":[{"backdrop-invert":[``,Z,$,Q]}],"backdrop-opacity":[{"backdrop-opacity":[Z,$,Q]}],"backdrop-saturate":[{"backdrop-saturate":[Z,$,Q]}],"backdrop-sepia":[{"backdrop-sepia":[``,Z,$,Q]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":w()}],"border-spacing-x":[{"border-spacing-x":w()}],"border-spacing-y":[{"border-spacing-y":w()}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[``,`all`,`colors`,`opacity`,`shadow`,`transform`,`none`,$,Q]}],"transition-behavior":[{transition:[`normal`,`discrete`]}],duration:[{duration:[Z,`initial`,$,Q]}],ease:[{ease:[`linear`,`initial`,_,$,Q]}],delay:[{delay:[Z,$,Q]}],animate:[{animate:[`none`,v,$,Q]}],backface:[{backface:[`hidden`,`visible`]}],perspective:[{perspective:[h,$,Q]}],"perspective-origin":[{"perspective-origin":x()}],rotate:[{rotate:de()}],"rotate-x":[{"rotate-x":de()}],"rotate-y":[{"rotate-y":de()}],"rotate-z":[{"rotate-z":de()}],scale:[{scale:fe()}],"scale-x":[{"scale-x":fe()}],"scale-y":[{"scale-y":fe()}],"scale-z":[{"scale-z":fe()}],"scale-3d":[`scale-3d`],skew:[{skew:pe()}],"skew-x":[{"skew-x":pe()}],"skew-y":[{"skew-y":pe()}],transform:[{transform:[$,Q,``,`none`,`gpu`,`cpu`]}],"transform-origin":[{origin:x()}],"transform-style":[{transform:[`3d`,`flat`]}],translate:[{translate:me()}],"translate-x":[{"translate-x":me()}],"translate-y":[{"translate-y":me()}],"translate-z":[{"translate-z":me()}],"translate-none":[`translate-none`],zoom:[{zoom:[Fv,$,Q]}],accent:[{accent:N()}],appearance:[{appearance:[`none`,`auto`]}],"caret-color":[{caret:N()}],"color-scheme":[{scheme:[`normal`,`dark`,`light`,`light-dark`,`only-dark`,`only-light`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,$,Q]}],"field-sizing":[{"field-sizing":[`fixed`,`content`]}],"pointer-events":[{"pointer-events":[`auto`,`none`]}],resize:[{resize:[`none`,``,`y`,`x`]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scrollbar-thumb-color":[{"scrollbar-thumb":N()}],"scrollbar-track-color":[{"scrollbar-track":N()}],"scrollbar-gutter":[{"scrollbar-gutter":[`auto`,`stable`,`both`]}],"scrollbar-w":[{scrollbar:[`auto`,`thin`,`none`]}],"scroll-m":[{"scroll-m":w()}],"scroll-mx":[{"scroll-mx":w()}],"scroll-my":[{"scroll-my":w()}],"scroll-ms":[{"scroll-ms":w()}],"scroll-me":[{"scroll-me":w()}],"scroll-mbs":[{"scroll-mbs":w()}],"scroll-mbe":[{"scroll-mbe":w()}],"scroll-mt":[{"scroll-mt":w()}],"scroll-mr":[{"scroll-mr":w()}],"scroll-mb":[{"scroll-mb":w()}],"scroll-ml":[{"scroll-ml":w()}],"scroll-p":[{"scroll-p":w()}],"scroll-px":[{"scroll-px":w()}],"scroll-py":[{"scroll-py":w()}],"scroll-ps":[{"scroll-ps":w()}],"scroll-pe":[{"scroll-pe":w()}],"scroll-pbs":[{"scroll-pbs":w()}],"scroll-pbe":[{"scroll-pbe":w()}],"scroll-pt":[{"scroll-pt":w()}],"scroll-pr":[{"scroll-pr":w()}],"scroll-pb":[{"scroll-pb":w()}],"scroll-pl":[{"scroll-pl":w()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,$,Q]}],fill:[{fill:[`none`,...N()]}],"stroke-w":[{stroke:[Z,$v,Kv,qv]}],stroke:[{stroke:[`none`,...N()]}],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{"container-named":[`container-type`],overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`inset-bs`,`inset-be`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pbs`,`pbe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mbs`,`mbe`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-x`,`border-w-y`,`border-w-s`,`border-w-e`,`border-w-bs`,`border-w-be`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-x`,`border-color-y`,`border-color-s`,`border-color-e`,`border-color-bs`,`border-color-be`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],translate:[`translate-x`,`translate-y`,`translate-none`],"translate-none":[`translate`,`translate-x`,`translate-y`,`translate-z`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mbs`,`scroll-mbe`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pbs`,`scroll-pbe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]},postfixLookupClassGroups:[`container-type`],orderSensitiveModifiers:[`*`,`**`,`after`,`backdrop`,`before`,`details-content`,`file`,`first-letter`,`first-line`,`marker`,`placeholder`,`selection`]}});function _y(...e){return gy(H_(e))}var vy=G_(`inline-flex min-h-[var(--control-height)] items-center justify-center gap-[var(--space-2)] rounded-[var(--radius-control)] px-[var(--space-4)] [font-size:var(--text-sm)] leading-[var(--leading-tight)] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] disabled:pointer-events-none disabled:opacity-50`,{variants:{variant:{primary:`bg-[var(--accent)] text-white hover:bg-[var(--accent-strong)]`,secondary:`border border-[var(--border)] bg-[var(--surface-raised)] text-[var(--text)] hover:bg-[var(--surface-hover)]`,danger:`bg-[var(--danger)] text-white hover:brightness-95`,ghost:`text-[var(--muted)] hover:bg-[var(--surface-hover)] hover:text-[var(--text)]`},size:{default:`h-[var(--control-height)]`,compact:`h-9 min-h-9 px-[var(--space-3)]`,icon:`h-10 w-10 px-0`}},defaultVariants:{variant:`primary`,size:`default`}}),yy=(0,p.forwardRef)(function({asChild:e=!1,className:t,variant:n,size:r,...i},a){return(0,m.jsx)(e?Dp:`button`,{className:_y(vy({variant:n,size:r}),t),ref:a,...i})}),by=(0,p.forwardRef)(function({className:e,...t},n){return(0,m.jsx)(`div`,{ref:n,className:_y(`rounded-[var(--radius-panel)] border border-[var(--border)] bg-[var(--surface-raised)] p-[var(--space-5)] [box-shadow:var(--shadow-panel)]`,e),...t})}),xy=(0,p.forwardRef)(function({className:e,...t},n){return(0,m.jsx)(`input`,{ref:n,className:_y(`min-h-[var(--control-height)] w-full rounded-[var(--radius-control)] border border-[var(--border)] bg-[var(--input)] px-[var(--space-3)] [font-size:var(--text-sm)] leading-[var(--leading-normal)] text-[var(--text)] outline-none placeholder:text-[var(--muted)] focus-visible:ring-2 focus-visible:ring-[var(--focus)]`,e),...t})});function Sy({label:e,error:t,hint:n,children:r}){return(0,m.jsxs)(`label`,{className:`grid gap-[var(--space-1)] [font-size:var(--text-sm)] leading-[var(--leading-normal)] font-medium text-[var(--text)]`,children:[(0,m.jsx)(`span`,{children:e}),r,t?(0,m.jsx)(`span`,{className:`[font-size:var(--text-xs)] text-[var(--danger)]`,role:`alert`,children:t}):null,!t&&n?(0,m.jsx)(`span`,{className:`[font-size:var(--text-xs)] font-normal text-[var(--muted)]`,children:n}):null]})}function Cy({tone:e=`neutral`,children:t}){return(0,m.jsx)(`span`,{className:_y(`inline-flex items-center rounded-full px-[var(--space-3)] py-[var(--space-1)] [font-size:var(--text-xs)] leading-[var(--leading-tight)] font-semibold`,{neutral:`bg-[var(--surface-hover)] text-[var(--muted)]`,success:`bg-[var(--success-soft)] text-[var(--success)]`,warning:`bg-[var(--warning-soft)] text-[var(--warning)]`,danger:`bg-[var(--danger-soft)] text-[var(--danger)]`}[e]),children:t})}function wy({open:e,onOpenChange:t,restoreFocus:n,title:r,description:i,children:a,footer:o,closeLabel:s,closeDisabled:c=!1}){return(0,m.jsx)(hg,{open:e,onOpenChange:t,children:(0,m.jsxs)(yg,{children:[(0,m.jsx)(xg,{className:`fixed inset-0 z-40 bg-black/50 backdrop-blur-[2px] data-[state=closed]:animate-none`}),(0,m.jsxs)(Tg,{className:`fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[min(92vw,680px)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-[var(--radius-panel)] border border-[var(--border)] bg-[var(--surface-raised)] p-[var(--space-6)] text-[var(--text)] shadow-2xl focus:outline-none`,onCloseAutoFocus:e=>{n&&(e.preventDefault(),n())},children:[(0,m.jsxs)(`div`,{className:`pr-10`,children:[(0,m.jsx)(Ag,{className:`text-xl font-bold`,children:r}),i?(0,m.jsx)(Mg,{className:`mt-1 text-sm text-[var(--muted)]`,children:i}):null]}),(0,m.jsx)(Pg,{asChild:!0,children:(0,m.jsx)(yy,{"aria-label":s,className:`absolute right-4 top-4`,disabled:c,size:`icon`,type:`button`,variant:`ghost`,children:(0,m.jsx)(pi,{size:18})})}),(0,m.jsx)(`div`,{className:`mt-[var(--space-5)]`,children:a}),o?(0,m.jsx)(`div`,{className:`mt-[var(--space-6)] flex flex-wrap justify-end gap-[var(--space-3)]`,children:o}):null]})]})})}var Ty=(0,p.createContext)(null);function Ey({children:e}){let[t,n]=(0,p.useState)([]),r=(0,p.useCallback)(e=>{let t=Date.now()+Math.floor(Math.random()*1e3);n(n=>[...n,{...e,id:t}])},[]),i=(0,p.useMemo)(()=>({push:r}),[r]);return(0,m.jsx)(Ty.Provider,{value:i,children:(0,m.jsxs)(I_,{duration:5e3,swipeDirection:`right`,children:[e,t.map(e=>(0,m.jsxs)(R_,{className:_y(`grid w-[min(92vw,420px)] gap-[var(--space-1)] rounded-xl border bg-[var(--surface-raised)] p-[var(--space-4)] text-[var(--text)] shadow-xl`,e.tone===`danger`?`border-[var(--danger)]`:e.tone===`warning`?`border-[var(--warning)]`:`border-[var(--success)]`),onOpenChange:t=>{t||n(t=>t.filter(t=>t.id!==e.id))},children:[(0,m.jsx)(z_,{className:`font-semibold`,children:e.title}),e.description?(0,m.jsx)(B_,{className:`[font-size:var(--text-sm)] leading-[var(--leading-normal)] text-[var(--muted)]`,children:e.description}):null]},e.id)),(0,m.jsx)(L_,{className:`fixed bottom-5 right-5 z-[60] grid gap-[var(--space-3)] outline-none`})]})})}function Dy(){let e=(0,p.useContext)(Ty);if(!e)throw Error(`useToast must be used inside ToastProvider.`);return e}function Oy(e){return{profileId:e.id,profileRevision:e.revision}}function ky(e){let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=Number.isFinite(e)?e:0,r=0;for(;n>=1024&&r=10?1:2)} ${t[r]}`}function Ay(e,t=`en`){if(!e)return`—`;let n=new Date(e);return Number.isNaN(n.valueOf())?`—`:new Intl.DateTimeFormat(t,{dateStyle:`medium`,timeStyle:`medium`}).format(n)}function jy(e,t){return e instanceof Er?`${e.dto.message} (${e.code})`:t}function My({title:e,subtitle:t,action:n,headingRef:r,headingTabIndex:i}){return(0,m.jsxs)(`div`,{className:`mb-[var(--space-6)] flex flex-wrap items-start justify-between gap-[var(--space-4)]`,children:[(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`h1`,{className:`[font-size:var(--text-2xl)] leading-[var(--leading-tight)] font-bold tracking-tight text-[var(--text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)]`,ref:r,tabIndex:i,children:e}),(0,m.jsx)(`p`,{className:`mt-[var(--space-1)] max-w-3xl [font-size:var(--text-sm)] leading-[var(--leading-relaxed)] text-[var(--muted)]`,children:t})]}),n]})}function Ny({label:e,value:t,mono:n=!1}){return(0,m.jsxs)(`div`,{className:`grid gap-1 border-b border-[var(--border)] py-3 last:border-0 sm:grid-cols-[180px_1fr]`,children:[(0,m.jsx)(`dt`,{className:`text-sm text-[var(--muted)]`,children:e}),(0,m.jsx)(`dd`,{className:_y(`min-w-0 break-words text-sm font-medium text-[var(--text)]`,n&&`font-mono text-xs`),children:t})]})}function Py({backup:e,selected:t,onSelect:n}){let r=(0,m.jsxs)(p.Fragment,{children:[(0,m.jsxs)(`div`,{className:`flex flex-wrap items-center justify-between gap-2`,children:[(0,m.jsx)(`span`,{className:`font-mono text-xs font-semibold`,children:e.backupId}),(0,m.jsx)(Cy,{children:ky(e.sizeBytes)})]}),e.createdAt?(0,m.jsx)(`div`,{className:`mt-2 text-xs text-[var(--muted)]`,children:Ay(e.createdAt)}):null]}),i=_y(`w-full rounded-lg border p-4 text-left`,t?`border-[var(--accent)] bg-[var(--accent-soft)]`:`border-[var(--border)]`);return n?(0,m.jsx)(`button`,{"aria-pressed":t,className:_y(i,`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)] hover:bg-[var(--surface-hover)]`),onClick:n,type:`button`,children:r}):(0,m.jsx)(`div`,{className:i,children:r})}function Fy({profile:e,profiles:t,backups:n,loading:r,disabled:i,canRestore:a,canPrune:o,prepare:s,prune:c}){let{t:l}=Tn(),u=qa({resolver:ro(Ff),defaultValues:{backupId:``,restoreConfig:!0,restoreDatabase:!0,restoreSessions:!0,allowSqliteHomeRelocation:!1,relocationTargetProfileId:``}}),d=u.watch(`allowSqliteHomeRelocation`),[f,h]=(0,p.useState)(5),g=(0,p.useRef)(null),_=u.watch(`backupId`);return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:l(`backups.title`),subtitle:l(`backups.subtitle`)}),(0,m.jsxs)(`div`,{className:_y(`grid gap-4`,(a||o)&&`xl:grid-cols-[minmax(0,1fr)_minmax(320px,440px)]`),children:[(0,m.jsxs)(by,{children:[(0,m.jsx)(`div`,{className:`grid gap-3`,children:r?(0,m.jsx)(`span`,{className:`text-sm text-[var(--muted)]`,children:l(`common.loading`)}):n.length===0?(0,m.jsx)(`span`,{className:`text-sm text-[var(--muted)]`,children:l(`backups.empty`)}):n.map(e=>(0,m.jsx)(Py,{backup:e,onSelect:a?()=>u.setValue(`backupId`,e.backupId,{shouldValidate:!0}):void 0,selected:a&&_===e.backupId},e.backupId))}),!a&&!o?(0,m.jsx)(`p`,{className:`mt-4 text-xs text-[var(--muted)]`,children:l(`backups.readOnly`)}):null]}),a||o?(0,m.jsxs)(`div`,{className:`grid content-start gap-4`,children:[a?(0,m.jsx)(by,{children:(0,m.jsxs)(`form`,{className:`grid gap-4`,onSubmit:u.handleSubmit(e=>s(e,g.current)),children:[[`restoreConfig`,`restoreDatabase`,`restoreSessions`].map(e=>(0,m.jsxs)(`label`,{className:`flex items-center gap-3 text-sm`,children:[(0,m.jsx)(`input`,{className:`h-4 w-4 accent-[var(--accent)]`,type:`checkbox`,...u.register(e)}),l(`backups.${e}`)]},e)),(0,m.jsxs)(`label`,{className:`flex items-center gap-3 text-sm`,children:[(0,m.jsx)(`input`,{className:`h-4 w-4 accent-[var(--accent)]`,type:`checkbox`,...u.register(`allowSqliteHomeRelocation`)}),l(`backups.relocation`)]}),d?(0,m.jsx)(Sy,{error:u.formState.errors.relocationTargetProfileId?l(`validation.restore`):void 0,label:l(`backups.targetProfile`),children:(0,m.jsxs)(`select`,{className:`min-h-10 rounded-lg border border-[var(--border)] bg-[var(--input)] px-3`,...u.register(`relocationTargetProfileId`),children:[(0,m.jsx)(`option`,{value:``,children:`—`}),t.filter(t=>t.id!==e.id&&(!!t.sqliteHome||t.sqliteHomeConfigured===!0)).map(e=>(0,m.jsx)(`option`,{value:e.id,children:e.name},e.id))]})}):null,u.formState.errors.restoreSessions?(0,m.jsx)(`span`,{className:`text-xs text-[var(--danger)]`,role:`alert`,children:l(`validation.restore`)}):null,(0,m.jsxs)(yy,{disabled:i||!_,ref:g,type:`submit`,children:[(0,m.jsx)(Yr,{size:17}),l(`backups.prepare`)]})]})}):null,o?(0,m.jsxs)(by,{children:[(0,m.jsx)(Sy,{label:l(`backups.pruneKeep`),children:(0,m.jsx)(xy,{max:1e3,min:0,onChange:e=>h(Number(e.target.value)),type:`number`,value:f})}),(0,m.jsx)(yy,{className:`mt-4 w-full`,disabled:i||!Number.isInteger(f)||f<0,onClick:()=>c(f),type:`button`,variant:`secondary`,children:l(`backups.prune`)})]}):null]}):null]})]})}function Iy({diagnostics:e,loading:t,exporting:n,canExport:r,refresh:i,exportBundle:a}){let{t:o}=Tn(),s=e?[[`runtime`,e.runtime],[`storage`,e.storage],[`provider`,e.provider],[`safety`,e.safety]]:[],c=e=>e==null||e===``?o(`common.none`):typeof e==`boolean`?(0,m.jsx)(Cy,{tone:e?`success`:`neutral`,children:o(e?`common.yes`:`common.no`)}):typeof e==`string`||typeof e==`number`?String(e):Array.isArray(e)?o(`diagnostics.items`,{count:e.length}):typeof e==`object`?o(`diagnostics.fieldsAvailable`,{count:Object.keys(e).length}):o(`common.unknown`);return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:o(`diagnostics.title`),subtitle:o(`diagnostics.subtitle`),action:(0,m.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,m.jsxs)(yy,{disabled:t,onClick:i,type:`button`,variant:`secondary`,children:[(0,m.jsx)(ai,{size:16}),o(`common.refresh`)]}),r?(0,m.jsxs)(yy,{disabled:n||!e,onClick:a,type:`button`,children:[(0,m.jsx)(Yr,{size:16}),o(n?`diagnostics.exporting`:`diagnostics.export`)]}):null]})}),(0,m.jsx)(`div`,{className:`grid gap-4 lg:grid-cols-2`,children:s.map(([e,t])=>(0,m.jsxs)(by,{children:[(0,m.jsx)(`h2`,{className:`mb-2 font-semibold`,children:o(`diagnostics.${e}`)}),(0,m.jsx)(`dl`,{children:Object.entries(t).map(([e,t])=>(0,m.jsx)(Ny,{label:o(`diagnostics.fields.${e}`,{defaultValue:e}),value:c(t)},e))}),(0,m.jsxs)(`details`,{className:`mt-3 rounded-[var(--radius-control)] border border-[var(--border)] bg-[var(--surface)] p-3 text-sm`,children:[(0,m.jsx)(`summary`,{className:`cursor-pointer font-medium`,children:o(`diagnostics.technicalDetails`)}),(0,m.jsx)(`pre`,{className:`mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-words text-xs leading-5 text-[var(--muted)]`,children:JSON.stringify(t,null,2)})]})]},e))})]})}function Ly({core:e,profile:t}){let{t:n,i18n:r}=Tn(),[i,a]=(0,p.useState)(1),[o,s]=(0,p.useState)(null),[c,l]=(0,p.useState)(null),[u,d]=(0,p.useState)(!1),[f,h]=(0,p.useState)(null),[g,_]=(0,p.useState)(null),v=(0,p.useRef)(null),y=(0,p.useRef)(new Map),b=nt({queryKey:[`history`,t.id,t.revision,i,50],queryFn:({signal:n})=>e.listHistory({profile:Oy(t),page:i,pageSize:50},{signal:n}),gcTime:0,staleTime:0});if((0,p.useEffect)(()=>{a(1),s(null),l(null),h(null)},[t.id,t.revision]),(0,p.useEffect)(()=>{if(!o){l(null),h(null),d(!1);return}let r=new AbortController;return l(null),h(null),d(!0),e.getHistorySession({profile:Oy(t),sessionId:o,messageLimit:200},{signal:r.signal}).then(e=>{r.signal.aborted||l(e)}).catch(e=>{r.signal.aborted||h(jy(e,n(`global.failed`)))}).finally(()=>{r.signal.aborted||d(!1)}),()=>{r.abort(),l(null)}},[e,t.id,t.revision,o,n]),(0,p.useEffect)(()=>{c&&v.current?.focus()},[c]),(0,p.useEffect)(()=>{if(o||!g)return;let e=y.current.get(g);e&&(e.focus(),_(null))},[b.data,g,o]),o)return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:c?c.session.title||n(`history.untitled`):n(`history.title`),subtitle:n(`history.subtitle`),action:(0,m.jsx)(yy,{onClick:()=>{_(o),s(null)},type:`button`,variant:`secondary`,children:n(`history.back`)}),headingRef:v,headingTabIndex:-1}),(0,m.jsx)(by,{"aria-busy":u,children:u?(0,m.jsx)(`span`,{"aria-live":`polite`,role:`status`,children:n(`common.loading`)}):f?(0,m.jsx)(`span`,{className:`text-[var(--danger)]`,role:`alert`,children:f}):c?(0,m.jsx)(`div`,{className:`grid gap-4`,children:c.messages.map(e=>(0,m.jsxs)(`article`,{className:`rounded-lg border border-[var(--border)] bg-[var(--surface)] p-4`,children:[(0,m.jsxs)(`div`,{className:`mb-2 flex justify-between text-xs font-semibold text-[var(--muted)]`,children:[(0,m.jsx)(`span`,{children:n(`history.roles.${e.role}`,{defaultValue:e.role})}),(0,m.jsx)(`span`,{children:Ay(e.timestamp,r.language)})]}),(0,m.jsx)(`pre`,{className:`whitespace-pre-wrap break-words font-sans text-sm leading-6`,children:e.text})]},`${e.sequence}-${e.role}`))}):null})]});let x=b.data?.sessions??[];return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:n(`history.title`),subtitle:n(`history.subtitle`)}),(0,m.jsxs)(by,{children:[b.isPending?(0,m.jsx)(`span`,{children:n(`common.loading`)}):b.isError?(0,m.jsx)(`span`,{className:`text-[var(--danger)]`,role:`alert`,children:jy(b.error,n(`global.failed`))}):x.length===0?(0,m.jsx)(`span`,{className:`text-[var(--muted)]`,children:n(`history.empty`)}):(0,m.jsx)(`div`,{className:`divide-y divide-[var(--border)]`,children:x.map(e=>(0,m.jsxs)(`div`,{className:`flex flex-wrap items-center justify-between gap-4 py-4`,children:[(0,m.jsxs)(`div`,{className:`min-w-0`,children:[(0,m.jsx)(`div`,{className:`truncate font-semibold`,children:e.title||n(`history.untitled`)}),(0,m.jsxs)(`div`,{className:`mt-1 flex flex-wrap gap-2 text-xs text-[var(--muted)]`,children:[(0,m.jsx)(`span`,{children:e.provider}),(0,m.jsxs)(`span`,{children:[e.messageCount,` `,n(`history.messages`)]}),(0,m.jsx)(`span`,{children:Ay(e.updatedAt,r.language)}),e.archived?(0,m.jsx)(Cy,{children:n(`history.archived`)}):null]})]}),(0,m.jsx)(yy,{onClick:()=>{_(null),s(e.id)},ref:t=>{t?y.current.set(e.id,t):y.current.delete(e.id)},type:`button`,variant:`secondary`,children:n(`history.open`)})]},e.id))}),b.data?(0,m.jsxs)(`nav`,{"aria-label":n(`history.pagination`),className:`mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border)] pt-4`,children:[(0,m.jsx)(`span`,{className:`text-xs text-[var(--muted)]`,children:n(`history.pageSummary`,{page:b.data.page,total:b.data.total})}),(0,m.jsxs)(`div`,{className:`flex gap-2`,children:[(0,m.jsx)(yy,{disabled:i<=1||b.isFetching,onClick:()=>a(e=>Math.max(1,e-1)),type:`button`,variant:`secondary`,children:n(`history.previous`)}),(0,m.jsx)(yy,{disabled:!b.data.hasNextPage||b.isFetching,onClick:()=>a(e=>e+1),type:`button`,variant:`secondary`,children:n(`history.next`)})]})]}):null]})]})}var Ry={completed:{tone:`success`,titleKey:`operationResult.completed.title`,descriptionKey:`operationResult.completed.description`,toastKey:`global.completed`},partial:{tone:`warning`,titleKey:`operationResult.partial.title`,descriptionKey:`operationResult.partial.description`,toastKey:`global.partial`},failed_rolled_back:{tone:`warning`,titleKey:`operationResult.failedRolledBack.title`,descriptionKey:`operationResult.failedRolledBack.description`,toastKey:`global.failed`},recovery_required:{tone:`danger`,titleKey:`operationResult.recoveryRequired.title`,descriptionKey:`operationResult.recoveryRequired.description`,toastKey:`global.failed`},cancelled:{tone:`warning`,titleKey:`operationResult.cancelled.title`,descriptionKey:`operationResult.cancelled.description`,toastKey:`global.cancelled`},stale:{tone:`warning`,titleKey:`operationResult.stale.title`,descriptionKey:`operationResult.stale.description`,toastKey:`global.stale`}};function zy(e){return Ry[e]}function By(e){if(!e||typeof e!=`object`||Array.isArray(e))return[];let t=[],n=new Set([`targetProvider`,`targetModel`,`modelSource`,`restoreOperationId`,`preRestoreSnapshotId`,`restoreJournalState`]),r=new Set([`backupDurationMs`,`changedSessionFiles`,`sqliteRowsUpdated`,`sqliteProviderRowsUpdated`,`sqliteUserEventRowsUpdated`,`sqliteCwdRowsUpdated`,`updatedWorkspaceRoots`,`savedWorkspaceRootCount`,`restoreVersion`,`resolvedOperationCount`]),i=new Set([`commitAcknowledgementRecovered`]);for(let[a,o]of Object.entries(e))!(n.has(a)&&typeof o==`string`)&&!(r.has(a)&&typeof o==`number`&&Number.isSafeInteger(o)&&o>=0)&&!(i.has(a)&&typeof o==`boolean`)||t.push([a,String(o)]);return t}function Vy(e){if(!e||typeof e!=`object`||Array.isArray(e))return[];let t=e.skippedLockedRolloutFiles;return Array.isArray(t)?t.filter(e=>typeof e==`string`):[]}function Hy({result:e,close:t,closeDisabled:n=!1,restoreFocus:r}){let{t:i}=Tn(),a=e?zy(e.outcome):null,o=e?By(e.result):[],s=e?Vy(e.result):[],c=e?.outcome===`recovery_required`;return(0,m.jsx)(wy,{closeDisabled:n,closeLabel:i(`common.close`),description:e?n?`${e.operationId} · ${i(`operationResult.resolveBeforeClose`)}`:e.operationId:void 0,footer:(0,m.jsx)(yy,{disabled:n,onClick:t,type:`button`,children:i(`common.close`)}),onOpenChange:e=>{!e&&!n&&t()},open:!!e,restoreFocus:r,title:i(`operationResult.title`),children:e&&a?(0,m.jsxs)(`div`,{"aria-live":`polite`,className:`grid gap-4`,role:c?`alert`:`status`,children:[(0,m.jsxs)(`div`,{className:a.tone===`danger`?`rounded-lg border border-[var(--danger)] bg-[var(--danger-soft)] p-4`:a.tone===`warning`?`rounded-lg border border-[var(--warning)] bg-[var(--warning-soft)] p-4`:`rounded-lg border border-[var(--success)] bg-[var(--success-soft)] p-4`,children:[(0,m.jsx)(`h3`,{className:`font-semibold`,children:i(a.titleKey)}),(0,m.jsx)(`p`,{className:`mt-1 text-sm`,children:i(a.descriptionKey)})]}),(0,m.jsx)(by,{children:(0,m.jsxs)(`dl`,{className:`grid gap-3 text-sm`,children:[(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`dt`,{className:`text-[var(--muted)]`,children:i(`operationResult.operationId`)}),(0,m.jsx)(`dd`,{className:`mt-1 break-all font-mono text-xs`,children:e.operationId})]}),e.backup?(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`dt`,{className:`text-[var(--muted)]`,children:i(`operationResult.backupId`)}),(0,m.jsx)(`dd`,{className:`mt-1 break-all font-mono text-xs`,children:e.backup.backupId})]}):null,o.map(([e,t])=>(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`dt`,{className:`text-[var(--muted)]`,children:i(`operationResult.fields.${e}`,{defaultValue:e})}),(0,m.jsx)(`dd`,{className:`mt-1 break-words`,children:t})]},e))]})}),e.warnings.length?(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`h3`,{className:`font-semibold`,children:i(`common.warnings`)}),(0,m.jsx)(`ul`,{className:`mt-2 list-disc space-y-1 pl-5 text-sm`,children:e.warnings.map((e,t)=>(0,m.jsx)(`li`,{children:e},`${t}-${e}`))})]}):null,s.length?(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`h3`,{className:`font-semibold`,children:i(`operationResult.skippedRollouts`)}),(0,m.jsx)(`ul`,{className:`mt-2 list-disc space-y-1 pl-5 font-mono text-xs`,children:s.map(e=>(0,m.jsx)(`li`,{children:e},e))})]}):null,n?(0,m.jsx)(`p`,{className:`text-sm text-[var(--danger)]`,children:i(`operationResult.resolveBeforeClose`)}):null]}):null})}function Uy(e,t,n){return t==null||t===``?n(`common.none`):typeof t==`boolean`?n(t?`common.yes`:`common.no`):e===`modelMode`&&typeof t==`string`?n(`plan.modelModes.${t}`,{defaultValue:t}):Array.isArray(t)?n(`plan.items`,{count:t.length}):String(t)}function Wy({plan:e,applying:t,cancelling:n,confirmDisabled:r=!1,operationId:i,progress:a,close:o,apply:s,cancel:c,restoreFocus:l}){let{t:u,i18n:d}=Tn(),f=e?u(`plan.operations.${e.operation}`,{defaultValue:e.operation}):``,h=e?[[`provider`,u(`common.provider`)],[`model`,u(`common.model`)],[`modelMode`,u(`plan.fields.modelMode`)],[`backupId`,u(`operationResult.backupId`)],[`restoreConfig`,u(`plan.fields.restoreConfig`)],[`restoreDatabase`,u(`plan.fields.restoreDatabase`)],[`restoreSessions`,u(`plan.fields.restoreSessions`)],[`allowSqliteHomeRelocation`,u(`plan.fields.relocation`)]].filter(([t])=>t in e.target):[],g=e?[[`rolloutFilesToChange`,u(`plan.fields.rolloutFiles`)],[`sqliteRowsToChange`,u(`plan.fields.sqliteRows`)],[`workspaceRootsToChange`,u(`plan.fields.workspaceRoots`)],[`stateDbFilesToChange`,u(`plan.fields.stateDbFiles`)],[`configFilesToChange`,u(`plan.fields.configFiles`)],[`lockedRolloutFiles`,u(`plan.fields.lockedRollouts`)]].filter(([t])=>t in e.impact):[];return(0,m.jsx)(wy,{closeDisabled:t,closeLabel:u(`common.close`),description:e?`${f} · ${u(`plan.expires`)} ${Ay(e.expiresAt,d.language)}`:void 0,footer:(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(yy,{disabled:t,onClick:o,type:`button`,variant:`secondary`,children:u(`common.close`)}),t?(0,m.jsx)(yy,{disabled:n,onClick:c,type:`button`,variant:`danger`,children:u(n?`plan.cancelling`:`plan.cancelOperation`)}):(0,m.jsx)(yy,{disabled:r,onClick:s,type:`button`,children:u(`common.confirm`)})]}),onOpenChange:e=>{!e&&!t&&o()},open:!!e,restoreFocus:l,title:u(`plan.title`),children:e?(0,m.jsxs)(`div`,{className:`grid gap-4`,children:[(0,m.jsxs)(by,{children:[(0,m.jsx)(`h3`,{className:`mb-2 text-sm font-semibold`,children:u(`plan.target`)}),(0,m.jsx)(`dl`,{children:h.map(([t,n])=>(0,m.jsx)(Ny,{label:n,value:Uy(t,e.target[t],u)},t))})]}),(0,m.jsxs)(by,{children:[(0,m.jsx)(`h3`,{className:`mb-2 text-sm font-semibold`,children:u(`plan.impact`)}),(0,m.jsx)(`dl`,{children:g.map(([t,n])=>(0,m.jsx)(Ny,{label:n,value:Uy(t,e.impact[t],u)},t))})]}),e.impact.backupExpected===!0?(0,m.jsx)(`div`,{className:`rounded-[var(--radius-control)] border border-[var(--success)] bg-[var(--success-soft)] p-4 text-sm font-medium text-[var(--success)]`,children:u(`plan.backupExpected`)}):null,e.warnings.length?(0,m.jsxs)(`div`,{className:`rounded-lg border border-[var(--warning)] bg-[var(--warning-soft)] p-4`,children:[(0,m.jsx)(`h3`,{className:`font-semibold`,children:u(`common.warnings`)}),(0,m.jsx)(`ul`,{className:`mt-2 list-disc space-y-1 pl-5 text-sm`,children:e.warnings.map((e,t)=>(0,m.jsx)(`li`,{children:e},`${t}-${e}`))})]}):null,t?(0,m.jsxs)(by,{"aria-live":`polite`,role:`status`,children:[(0,m.jsx)(`h3`,{className:`text-sm font-semibold`,children:u(`plan.progress`)}),(0,m.jsx)(`div`,{className:`mt-2 font-mono text-xs text-[var(--muted)]`,children:i??u(`plan.starting`)}),a?(0,m.jsxs)(`div`,{className:`mt-3 grid gap-2`,children:[(0,m.jsxs)(`div`,{className:`text-sm`,children:[u(`plan.stages.${a.stage}`,{defaultValue:a.stage}),` · `,u(`plan.statuses.${a.status}`,{defaultValue:a.status}),a.count===void 0?``:` · ${a.count}`]}),a.progress===void 0?null:(0,m.jsx)(`progress`,{"aria-label":u(`plan.progress`),className:`w-full`,max:1,value:a.progress})]}):null,n?(0,m.jsx)(`p`,{className:`mt-3 text-sm text-[var(--warning)]`,children:u(`plan.cancelPending`)}):null]}):null,r&&!t?(0,m.jsx)(`p`,{className:`text-sm font-medium text-[var(--warning)]`,role:`status`,children:u(`plan.writeBlocked`)}):null,(0,m.jsx)(`p`,{className:`text-sm text-[var(--muted)]`,children:u(`plan.exactApply`)}),(0,m.jsxs)(`details`,{className:`rounded-[var(--radius-control)] border border-[var(--border)] bg-[var(--surface)] p-3 text-sm`,children:[(0,m.jsx)(`summary`,{className:`cursor-pointer font-medium`,children:u(`plan.technicalDetails`)}),(0,m.jsx)(`pre`,{className:`mt-3 max-h-52 overflow-auto whitespace-pre-wrap break-words text-xs text-[var(--muted)]`,children:JSON.stringify({target:e.target,impact:e.impact},null,2)})]})]}):null})}function Gy({title:e,counts:t,current:n}){let r=t&&typeof t==`object`&&!Array.isArray(t)?t:{},i=new Map;for(let e of[`sessions`,`archived_sessions`]){let t=r[e];if(!(!t||typeof t!=`object`||Array.isArray(t)))for(let[e,n]of Object.entries(t))typeof n==`number`&&i.set(e,(i.get(e)??0)+n)}let a=[...i.entries()].sort((e,t)=>t[1]-e[1]),o=a.reduce((e,[,t])=>e+t,0);return(0,m.jsxs)(by,{children:[(0,m.jsxs)(`div`,{className:`mb-4 flex items-center justify-between`,children:[(0,m.jsx)(`h2`,{className:`font-semibold`,children:e}),(0,m.jsx)(Cy,{children:o})]}),(0,m.jsx)(`div`,{className:`grid gap-3`,children:a.length===0?(0,m.jsx)(`span`,{className:`text-sm text-[var(--muted)]`,children:`—`}):a.map(([e,t])=>(0,m.jsxs)(`div`,{children:[(0,m.jsxs)(`div`,{className:`mb-1 flex justify-between text-sm`,children:[(0,m.jsx)(`span`,{className:`font-medium`,children:e}),(0,m.jsx)(`span`,{children:t})]}),(0,m.jsx)(`progress`,{"aria-label":`${e}: ${t}`,className:_y(`h-2 w-full overflow-hidden rounded-full`,e===n?`accent-[var(--accent)]`:`accent-[var(--muted)]`),max:Math.max(o,1),value:t})]},e))})]})}function Ky({status:e,loading:t,refresh:n}){let{t:r,i18n:i}=Tn(),a=e?.alignment&&typeof e.alignment==`object`?e.alignment.aligned===!0:!1;return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:r(`overview.title`),subtitle:r(`overview.subtitle`),action:(0,m.jsxs)(yy,{disabled:t,onClick:n,type:`button`,variant:`secondary`,children:[(0,m.jsx)(ai,{className:_y(t&&`animate-spin`),size:16}),r(`common.refresh`)]})}),(0,m.jsxs)(`div`,{className:`grid gap-4 md:grid-cols-2 xl:grid-cols-4`,children:[(0,m.jsxs)(by,{children:[(0,m.jsx)(`div`,{className:`text-sm text-[var(--muted)]`,children:r(`common.provider`)}),(0,m.jsx)(`div`,{className:`mt-2 text-xl font-bold`,children:e?.currentProvider??`—`})]}),(0,m.jsxs)(by,{children:[(0,m.jsx)(`div`,{className:`text-sm text-[var(--muted)]`,children:r(`overview.alignment`)}),(0,m.jsxs)(`div`,{className:`mt-2 flex items-center gap-2 text-lg font-bold`,children:[a?(0,m.jsx)(Xr,{className:`text-[var(--success)]`,size:20}):(0,m.jsx)(di,{className:`text-[var(--warning)]`,size:20}),r(a?`overview.aligned`:`overview.notAligned`)]})]}),(0,m.jsxs)(by,{children:[(0,m.jsx)(`div`,{className:`text-sm text-[var(--muted)]`,children:r(`overview.backupCount`)}),(0,m.jsx)(`div`,{className:`mt-2 text-xl font-bold`,children:e?.backupSummary.count??0}),(0,m.jsx)(`div`,{className:`text-xs text-[var(--muted)]`,children:ky(e?.backupSummary.totalBytes??0)})]}),(0,m.jsxs)(by,{children:[(0,m.jsx)(`div`,{className:`text-sm text-[var(--muted)]`,children:r(`overview.locked`)}),(0,m.jsx)(`div`,{className:`mt-2 text-xl font-bold`,children:e?.lockedRolloutFiles.length??0})]})]}),(0,m.jsx)(by,{className:`mt-4`,children:(0,m.jsxs)(`dl`,{children:[(0,m.jsx)(Ny,{label:r(`overview.codexHomeSource`),value:e?.codexHomeSource??`—`}),(0,m.jsx)(Ny,{label:r(`overview.sqliteHomeSource`),value:e?.sqliteHomeSource??`—`}),(0,m.jsx)(Ny,{label:r(`overview.snapshot`),value:Ay(e?.snapshotAt,i.language)})]})}),(0,m.jsxs)(`div`,{className:`mt-4 grid gap-4 lg:grid-cols-2`,children:[(0,m.jsx)(Gy,{counts:e?.rolloutCounts,current:e?.currentProvider??``,title:r(`overview.rollout`)}),(0,m.jsx)(Gy,{counts:e?.sqliteCounts,current:e?.currentProvider??``,title:r(`overview.sqlite`)})]})]})}function qy({profiles:e,refresh:t,host:n,canManage:r,revealPaths:i,surface:a}){let{t:o}=Tn(),s=Dy(),[c,l]=(0,p.useState)(null),u=qa({resolver:ro(Lf),defaultValues:{profileId:``,name:``,codexHome:``,sqliteHome:``}});(0,p.useEffect)(()=>{u.reset(c?{profileId:c.id,name:c.name,codexHome:c.codexHome??``,sqliteHome:c.sqliteHome??``}:{profileId:``,name:``,codexHome:``,sqliteHome:``})},[c,u]);let d=ot({mutationFn:async e=>{if(!r||!n.saveProfile)throw Error(`Profile management is unavailable.`);return n.saveProfile({...e,...c?{profileRevision:c.revision}:{}})},onSuccess:async()=>{await t(),l(null),u.reset(),s.push({title:o(`common.save`),tone:`success`})},onError:e=>s.push({title:o(`global.failed`),description:jy(e,o(`global.unexpected`)),tone:`danger`})}),f=ot({mutationFn:e=>{if(!r||!n.deleteProfile)throw Error(`Profile management is unavailable.`);return n.deleteProfile(e.id,e.revision)},onSuccess:async()=>{await t(),l(null),s.push({title:o(`common.delete`),tone:`success`})},onError:e=>s.push({title:o(`global.failed`),description:jy(e,o(`global.unexpected`)),tone:`danger`})});return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:o(`profiles.title`),subtitle:o(`profiles.subtitle`)}),(0,m.jsxs)(`div`,{className:_y(`grid min-w-0 gap-4`,r&&`xl:grid-cols-[minmax(0,1fr)_420px]`),children:[(0,m.jsxs)(by,{className:`min-w-0`,children:[(0,m.jsx)(`div`,{className:`grid gap-3`,children:e.map(e=>{let t=(0,m.jsxs)(p.Fragment,{children:[(0,m.jsxs)(`div`,{className:`flex min-w-0 justify-between gap-2`,children:[(0,m.jsx)(`span`,{className:`min-w-0 truncate font-semibold`,children:e.name}),e.id==="default"?(0,m.jsx)(Cy,{children:o(`common.current`)}):null]}),(0,m.jsx)(`div`,{className:`mt-2 font-mono text-xs text-[var(--muted)]`,children:e.id}),i&&e.codexHome?(0,m.jsx)(`div`,{className:`mt-1 max-w-full truncate font-mono text-xs text-[var(--muted)]`,children:e.codexHome}):(0,m.jsx)(`div`,{className:`mt-1 text-xs text-[var(--muted)]`,children:o(`profiles.pathManaged.${a}`)})]});return!r||e.id==="default"?(0,m.jsx)(`div`,{className:`min-w-0 max-w-full overflow-hidden rounded-lg border border-[var(--border)] p-4 text-left`,children:t},e.id):(0,m.jsx)(`button`,{className:_y(`min-w-0 max-w-full overflow-hidden rounded-lg border p-4 text-left`,c?.id===e.id?`border-[var(--accent)] bg-[var(--accent-soft)]`:`border-[var(--border)] hover:bg-[var(--surface-hover)]`),onClick:()=>l(e),type:`button`,children:t},e.id)})}),r?null:(0,m.jsx)(`p`,{className:`mt-4 text-xs text-[var(--muted)]`,children:o(`profiles.readOnly`)})]}),r?(0,m.jsxs)(by,{className:`min-w-0`,children:[(0,m.jsxs)(`form`,{className:`grid min-w-0 gap-4`,onSubmit:u.handleSubmit(e=>d.mutateAsync(e)),children:[(0,m.jsx)(Sy,{error:u.formState.errors.profileId?o(`validation.profileId`):void 0,label:o(`profiles.id`),children:(0,m.jsx)(xy,{disabled:!!c,...u.register(`profileId`)})}),(0,m.jsx)(Sy,{error:u.formState.errors.name?o(`validation.required`):void 0,label:o(`profiles.name`),children:(0,m.jsx)(xy,{...u.register(`name`)})}),(0,m.jsx)(Sy,{error:u.formState.errors.codexHome?o(`validation.path`):void 0,label:o(`profiles.codexHome`),children:(0,m.jsx)(xy,{...u.register(`codexHome`)})}),(0,m.jsx)(Sy,{error:u.formState.errors.sqliteHome?o(`validation.path`):void 0,label:o(`profiles.sqliteHome`),children:(0,m.jsx)(xy,{...u.register(`sqliteHome`)})}),(0,m.jsxs)(`div`,{className:`flex flex-wrap gap-3`,children:[(0,m.jsx)(yy,{disabled:d.isPending,type:`submit`,children:o(c?`profiles.update`:`profiles.create`)}),c?(0,m.jsx)(yy,{disabled:f.isPending,onClick:()=>f.mutate(c),type:`button`,variant:`danger`,children:o(`common.delete`)}):null]})]}),(0,m.jsx)(`p`,{className:`mt-4 text-xs text-[var(--muted)]`,children:o(`profiles.defaultManaged`)})]}):null]})]})}function Jy(e){return e?`watches`in e?e.watches.find(e=>e.status!==`stopped`)??e.watches[0]??null:e:null}function Yy({props:e,profile:t,capabilities:n,recoveryBlocked:r,writeBlocked:i}){let{t:a,i18n:o}=Tn(),s=g(),[c,l]=(0,p.useState)(e.preferences.getTheme()??e.initialTheme),u=nt({queryKey:[`watch-status`],queryFn:()=>e.core.getWatchStatus({}),enabled:n.watch}),d=Jy(u.data),f=ot({mutationFn:()=>e.core.startWatch({profile:Oy(t),includeStateDb:!0}),onSuccess:e=>s.setQueryData([`watch-status`],e)}),h=ot({mutationFn:t=>e.core.stopWatch({watchId:t}),onSuccess:e=>s.setQueryData([`watch-status`],e)}),_=nt({queryKey:[`desktop-update-status`],queryFn:({signal:t})=>e.host.getUpdateStatus?.(t),enabled:n.viewUpdateStatus&&!!e.host.getUpdateStatus}),v=n.watch||n.viewUpdateStatus&&!!e.host.getUpdateStatus,y=u.isFetching||_.isFetching,b=async()=>{await Promise.all([n.watch?u.refetch():Promise.resolve(),n.viewUpdateStatus&&e.host.getUpdateStatus?_.refetch():Promise.resolve()])},x=e=>s.setQueryData([`desktop-update-status`],e),S=ot({mutationFn:()=>e.host.checkForUpdates?.()??Promise.reject(Error(`Update check unavailable.`)),onSuccess:x}),C=ot({mutationFn:()=>e.host.downloadUpdate?.()??Promise.reject(Error(`Update download unavailable.`)),onSuccess:x}),w=ot({mutationFn:()=>e.host.installUpdate?.()??Promise.reject(Error(`Update install unavailable.`)),onSuccess:x}),T=async t=>{e.preferences.setLocale(t),await o.changeLanguage(t)},E=t=>{l(t),e.preferences.setTheme(t),document.documentElement.dataset.theme=t};return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{action:v?(0,m.jsxs)(yy,{disabled:y,onClick:()=>void b(),type:`button`,variant:`secondary`,children:[(0,m.jsx)(ai,{className:_y(y&&`animate-spin`),size:16}),a(`common.refresh`)]}):void 0,title:a(`settings.title`),subtitle:a(`settings.subtitle.${e.surface}`)}),(0,m.jsxs)(`div`,{className:`grid gap-4 lg:grid-cols-2`,children:[(0,m.jsxs)(by,{children:[(0,m.jsx)(Sy,{label:a(`settings.language`),children:(0,m.jsxs)(`select`,{className:`min-h-10 rounded-lg border border-[var(--border)] bg-[var(--input)] px-3`,onChange:e=>void T(e.target.value),value:o.language===`zh-CN`?`zh-CN`:`en`,children:[(0,m.jsx)(`option`,{value:`zh-CN`,children:`简体中文`}),(0,m.jsx)(`option`,{value:`en`,children:`English`})]})}),(0,m.jsxs)(`div`,{className:`mt-3 flex items-center gap-2 text-xs text-[var(--muted)]`,children:[(0,m.jsx)(ni,{size:15}),a(`settings.englishFallback`)]})]}),(0,m.jsx)(by,{children:(0,m.jsxs)(`fieldset`,{children:[(0,m.jsx)(`legend`,{className:`mb-1.5 text-sm font-medium text-[var(--text)]`,children:a(`settings.theme`)}),(0,m.jsx)(`div`,{className:`grid grid-cols-1 gap-2 sm:grid-cols-3`,children:[`system`,`light`,`dark`].map(e=>(0,m.jsxs)(yy,{"aria-pressed":c===e,onClick:()=>E(e),type:`button`,variant:c===e?`primary`:`secondary`,children:[e===`system`?(0,m.jsx)(Qr,{size:16}):e===`light`?(0,m.jsx)(ui,{size:16}):(0,m.jsx)(ri,{size:16}),a(`settings.${e}`)]},e))})]})}),n.watch?(0,m.jsxs)(by,{children:[(0,m.jsx)(`h2`,{className:`font-semibold`,children:a(`settings.watch`)}),(0,m.jsxs)(`div`,{className:`mt-3 flex items-center justify-between gap-3`,children:[(0,m.jsxs)(`div`,{children:[(0,m.jsx)(Cy,{tone:d?.status===`running`?`success`:`neutral`,children:d?.status??a(`common.none`)}),d?(0,m.jsx)(`div`,{className:`mt-2 font-mono text-xs text-[var(--muted)]`,children:d.watchId}):null]}),d?.status===`running`?(0,m.jsx)(yy,{disabled:h.isPending,onClick:()=>h.mutate(d.watchId),type:`button`,variant:`secondary`,children:a(`settings.watchStop`)}):(0,m.jsxs)(yy,{disabled:f.isPending||r||i,onClick:()=>f.mutate(),type:`button`,children:[(0,m.jsx)(ii,{size:16}),a(`settings.watchStart`)]})]}),r&&d?.status!==`running`?(0,m.jsx)(`p`,{className:`mt-3 text-xs text-[var(--danger)]`,children:a(`settings.watchRecoveryBlocked`)}):null]}):null,n.viewUpdateStatus&&e.host.getUpdateStatus?(0,m.jsxs)(by,{children:[(0,m.jsx)(`h2`,{className:`font-semibold`,children:a(`settings.update`)}),(0,m.jsxs)(`div`,{className:`mt-3`,children:[(0,m.jsx)(Cy,{tone:_.data?.state===`error`||_.data?.installBlockedReason?`warning`:_.data?.state===`downloaded`?`success`:`neutral`,children:_.isPending?a(`common.loading`):_.data?a(`settings.updateStatus.${_.data.state}`):a(`common.unknown`)}),_.data?.version?(0,m.jsx)(`p`,{className:`mt-3 text-sm`,children:a(`settings.updateVersion`,{version:_.data.version})}):null,_.data?.progressPercent===void 0?null:(0,m.jsx)(`p`,{className:`mt-2 text-sm text-[var(--muted)]`,children:a(`settings.updateProgress`,{percent:_.data.progressPercent})}),_.data?.reason?(0,m.jsx)(`p`,{className:`mt-3 text-sm text-[var(--muted)]`,children:a(`settings.updateReason.${_.data.reason}`)}):null,_.data?.installBlockedReason?(0,m.jsx)(`p`,{className:`mt-3 text-sm text-[var(--danger)]`,children:a(`settings.updateBlocked.${_.data.installBlockedReason}`)}):null,(0,m.jsxs)(`div`,{className:`mt-4 flex flex-wrap gap-2`,children:[_.data&&[`idle`,`not-available`,`error`].includes(_.data.state)&&e.host.checkForUpdates?(0,m.jsx)(yy,{disabled:S.isPending,onClick:()=>S.mutate(),type:`button`,variant:`secondary`,children:a(`settings.updateCheck`)}):null,_.data?.state===`available`&&e.host.downloadUpdate?(0,m.jsx)(yy,{disabled:C.isPending,onClick:()=>C.mutate(),type:`button`,children:a(`settings.updateDownload`)}):null,_.data?.state===`downloaded`&&e.host.installUpdate?(0,m.jsx)(yy,{disabled:!_.data.installAllowed||w.isPending,onClick:()=>w.mutate(),type:`button`,children:a(`settings.updateInstall`)}):null]})]})]}):null,n.forgetBrowser?(0,m.jsxs)(by,{children:[(0,m.jsx)(`h2`,{className:`font-semibold`,children:a(`settings.forget`)}),(0,m.jsx)(`p`,{className:`mt-2 text-sm text-[var(--muted)]`,children:a(`settings.forgetHint`)}),(0,m.jsx)(yy,{className:`mt-4`,onClick:()=>void(e.onForgetBrowser?.()??e.host.forgetBrowser?.()),type:`button`,variant:`danger`,children:a(`settings.forget`)})]}):null]})]})}function Xy({disabled:e,providers:t,prepare:n}){let{t:r}=Tn(),i=(0,p.useRef)(null),a=qa({resolver:ro(Pf),defaultValues:{provider:t[0]??`openai`,modelMode:`provider-default`,model:``,keepCount:5}}),o=a.watch(`modelMode`);return(0,p.useEffect)(()=>{o!==`explicit`&&a.setValue(`model`,``)},[a,o]),(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:r(`switchPage.title`),subtitle:r(`switchPage.subtitle`)}),(0,m.jsx)(by,{className:`max-w-2xl`,children:(0,m.jsxs)(`form`,{className:`grid gap-5`,onSubmit:a.handleSubmit(e=>n(e,i.current)),children:[(0,m.jsx)(Sy,{error:a.formState.errors.provider?r(`validation.provider`):void 0,label:r(`switchPage.provider`),children:(0,m.jsx)(xy,{list:`configured-providers`,...a.register(`provider`)})}),(0,m.jsx)(`datalist`,{id:`configured-providers`,children:t.map(e=>(0,m.jsx)(`option`,{value:e},e))}),(0,m.jsx)(Sy,{label:r(`switchPage.modelMode`),children:(0,m.jsxs)(`select`,{className:`min-h-10 rounded-lg border border-[var(--border)] bg-[var(--input)] px-3`,...a.register(`modelMode`),children:[(0,m.jsx)(`option`,{value:`provider-default`,children:r(`switchPage.providerDefault`)}),(0,m.jsx)(`option`,{value:`keep-root-model`,children:r(`switchPage.keepModel`)}),(0,m.jsx)(`option`,{value:`explicit`,children:r(`switchPage.explicitModel`)})]})}),o===`explicit`?(0,m.jsx)(Sy,{error:a.formState.errors.model?r(`validation.model`):void 0,label:r(`switchPage.model`),children:(0,m.jsx)(xy,{...a.register(`model`)})}):null,(0,m.jsx)(Sy,{error:a.formState.errors.keepCount?r(`validation.keep`):void 0,label:r(`sync.keep`),children:(0,m.jsx)(xy,{max:1e3,min:1,type:`number`,...a.register(`keepCount`,{valueAsNumber:!0})})}),(0,m.jsxs)(yy,{disabled:e||a.formState.isSubmitting,ref:i,type:`submit`,children:[(0,m.jsx)(si,{size:17}),r(`switchPage.prepare`)]})]})})]})}function Zy({disabled:e,prepare:t}){let{t:n}=Tn(),r=qa({resolver:ro(Nf),defaultValues:{keepCount:5}}),i=(0,p.useRef)(null);return(0,m.jsxs)(p.Fragment,{children:[(0,m.jsx)(My,{title:n(`sync.title`),subtitle:n(`sync.subtitle`)}),(0,m.jsx)(by,{className:`max-w-2xl`,children:(0,m.jsxs)(`form`,{className:`grid gap-5`,onSubmit:r.handleSubmit(e=>t(e,i.current)),children:[(0,m.jsx)(Sy,{error:r.formState.errors.keepCount?n(`validation.keep`):void 0,label:n(`sync.keep`),children:(0,m.jsx)(xy,{max:1e3,min:1,type:`number`,...r.register(`keepCount`,{valueAsNumber:!0})})}),(0,m.jsxs)(yy,{disabled:e||r.formState.isSubmitting,ref:i,type:`submit`,children:[(0,m.jsx)(fi,{size:17}),n(`sync.prepare`)]})]})})]})}var Qy=Object.freeze({sync:!0,switchProvider:!0,restore:!0,pruneBackups:!0,watch:!0,manageProfiles:!0,revealProfilePaths:!0,forgetBrowser:!0,exportDiagnostics:!0,viewUpdateStatus:!0});Object.freeze({sync:!1,switchProvider:!1,restore:!1,pruneBackups:!1,watch:!1,manageProfiles:!1,revealProfilePaths:!1,forgetBrowser:!1,exportDiagnostics:!1,viewUpdateStatus:!1}),Object.freeze({sync:!0,switchProvider:!0,restore:!1,pruneBackups:!1,watch:!1,manageProfiles:!1,revealProfilePaths:!1,forgetBrowser:!1,exportDiagnostics:!1,viewUpdateStatus:!1}),Object.freeze({sync:!0,switchProvider:!0,restore:!0,pruneBackups:!0,watch:!0,manageProfiles:!1,revealProfilePaths:!1,forgetBrowser:!1,exportDiagnostics:!0,viewUpdateStatus:!0});var $y=[[`overview`,`nav.overview`,ti],[`sync`,`nav.sync`,fi],[`switch-provider`,`nav.switchProvider`,si],[`backups-restore`,`nav.backupsRestore`,Yr],[`history`,`nav.history`,oi],[`profiles`,`nav.profiles`,ei],[`diagnostics`,`nav.diagnostics`,Jr],[`settings`,`nav.settings`,ci]];function eb(e){return{...Qy,...e}}function tb(e,t){return e===`sync`?t.sync:e!==`switch-provider`||t.switchProvider}function nb(e){return e instanceof Er&&(e.code===`PROFILE_CHANGED`||e.code===`STALE_STATE`&&e.dto.details?.reason===`profile`)}function rb({props:e}){let{t,i18n:n}=Tn(),r=Dy(),i=g(),a=(0,p.useMemo)(()=>eb(e.capabilities),[e.capabilities]),o=(0,p.useMemo)(()=>$y.filter(([e])=>tb(e,a)),[a]),[s,c]=(0,p.useState)(`overview`),[l,u]=(0,p.useState)(`default`),[d,f]=(0,p.useState)(null),[h,_]=(0,p.useState)(null),[v,y]=(0,p.useState)(!0),[b,x]=(0,p.useState)(null),[S,C]=(0,p.useState)(null),[w,T]=(0,p.useState)(!1),E=(0,p.useRef)(null),D=(0,p.useRef)(!1),O=(0,p.useRef)(null),ee=(0,p.useRef)(!1),te=(0,p.useRef)(!1),k=(0,p.useRef)(null),A=rt(),ne=nt({queryKey:[`profiles`],queryFn:({signal:t})=>e.host.listProfiles(t)}),j=ne.data??[],M=j.find(e=>e.id===l)??j[0],N=(0,p.useCallback)(async()=>{if(te.current||(te.current=!0,r.push({title:t(`global.profileChanged`),description:t(`global.profileChangedHint`),tone:`warning`})),!k.current){let e=ne.refetch().then(()=>void 0).finally(()=>{k.current===e&&(k.current=null)});k.current=e}await k.current},[ne.refetch,t,r]);(0,p.useEffect)(()=>{document.documentElement.lang=n.resolvedLanguage?.toLowerCase().startsWith(`zh`)?`zh-CN`:`en`},[n.resolvedLanguage]),(0,p.useEffect)(()=>{j.length&&!j.some(e=>e.id===l)&&u(j[0].id)},[j,l]),(0,p.useEffect)(()=>{tb(s,a)||c(`overview`)},[a,s]);let re=nt({queryKey:[`status`,M?.id,M?.revision],queryFn:({signal:t})=>e.core.getStatus({profile:Oy(M)},{signal:t}),enabled:!!M}),ie=re.data,ae=re.isSuccess&&ie!==void 0;(0,p.useEffect)(()=>{if(ae&&ie.profile.revision===M?.revision){te.current=!1;return}nb(re.error)&&!te.current&&N()},[N,M?.revision,ie?.profile.revision,re.error,ae]);let P=ie?.operationInProgress!=null,F=!M||!ae||ie?.pendingRecovery===!0||P||A>0,oe=!M||!ae||P||A>0,se=nt({queryKey:[`backups`,M?.id,M?.revision],queryFn:({signal:t})=>e.core.listBackups({profile:Oy(M)},{signal:t}),enabled:!!(M&&s===`backups-restore`)}),ce=nt({queryKey:[`diagnostics`,M?.id,M?.revision],queryFn:({signal:t})=>e.core.getDiagnostics({profile:Oy(M)},{signal:t}),enabled:!!(M&&s===`diagnostics`)}),le=(0,p.useCallback)(async({refreshStatus:e=!0}={})=>{let t=[i.invalidateQueries({queryKey:[`backups`]}),i.invalidateQueries({queryKey:[`history`]}),i.invalidateQueries({queryKey:[`diagnostics`]})];e&&t.push(i.invalidateQueries({queryKey:[`status`]})),await Promise.all(t)},[i]),ue=(0,p.useCallback)(async(e,n)=>{O.current=n;try{f(await e())}catch(e){if(O.current=null,nb(e)){await N();return}r.push({title:t(`global.failed`),description:jy(e,t(`global.unexpected`)),tone:`danger`})}},[N,t,r]),de=(0,p.useCallback)(()=>{let e=O.current;ee.current=!1,f(null),x(null),C(null),T(!1),globalThis.requestAnimationFrame(()=>globalThis.requestAnimationFrame(()=>{e?.isConnected&&e.focus(),O.current===e&&(O.current=null)}))},[]),fe=(0,p.useCallback)(()=>{f(null),x(null),C(null),T(!1)},[]),pe=(0,p.useCallback)(()=>{if(ee.current)return;let e=O.current;O.current=null,e?.focus()},[]),me=(0,p.useCallback)(()=>{let e=O.current;O.current=null,ee.current=!1,e?.focus()},[]),he=ot({mutationFn:async t=>{let n={schemaVersion:1,planId:t.planId},r=new AbortController;E.current=r,x(null),C(null),T(!1);let i={signal:r.signal,onOperationStarted:e=>x(e.operationId),onProgress:e=>C(e.progress)};try{return t.operation===`sync`?await e.core.applySync(n,i):t.operation===`switch`?await e.core.applySwitch(n,i):await e.core.applyRestore(n,i)}finally{E.current===r&&(E.current=null)}},onSuccess:async e=>{let n=zy(e.outcome),i=e.outcome===`recovery_required`;ee.current=!0,y(!i),_(e),fe();try{if(i){await le({refreshStatus:!1});let e=await re.refetch();y(e.isSuccess)}else await le()}catch{}r.push({title:t(n.toastKey),description:e.backup?.backupId,tone:n.tone})},onError:async e=>{if(await le(),de(),e instanceof Er&&e.code===`OPERATION_CANCELLED`){r.push({title:t(`global.cancelled`),tone:`warning`});return}if(nb(e)){await N();return}r.push({title:t(`global.failed`),description:jy(e,t(`global.unexpected`)),tone:`danger`})}}),ge=ot({mutationFn:async t=>{if(!M)throw Error(`No profile is selected.`);return e.core.pruneBackups({profile:Oy(M),keepCount:t})},onSuccess:async()=>{await le(),r.push({title:t(`global.completed`),tone:`success`})},onError:e=>{r.push({title:t(`global.failed`),description:jy(e,t(`global.unexpected`)),tone:`danger`})}}),_e=ot({mutationFn:async()=>{if(!M||!e.host.exportDiagnostics)throw Error(`Diagnostics export is unavailable.`);return e.host.exportDiagnostics(Oy(M))},onSuccess:e=>{r.push({title:e.status===`created`?t(`diagnostics.exportCreated`):e.status===`cancelled`?t(`diagnostics.exportCancelled`):t(`diagnostics.exportFailed`),tone:e.status===`created`?`success`:e.status===`cancelled`?`warning`:`danger`})},onError:()=>r.push({title:t(`diagnostics.exportFailed`),tone:`danger`})}),ve=ie?.configuredProviders&&Array.isArray(ie.configuredProviders)?ie.configuredProviders.filter(e=>typeof e==`string`):[ie?.currentProvider??`openai`],ye=M?s===`overview`?(0,m.jsx)(Ky,{loading:re.isFetching,refresh:()=>void re.refetch(),status:ie}):s===`sync`&&a.sync?(0,m.jsx)(Zy,{disabled:F,prepare:(t,n)=>ue(()=>e.core.prepareSync({profile:Oy(M),keepCount:t.keepCount}),n)}):s===`switch-provider`&&a.switchProvider?(0,m.jsx)(Xy,{disabled:F,prepare:(t,n)=>ue(()=>e.core.prepareSwitch({profile:Oy(M),provider:t.provider,modelMode:t.modelMode,...t.modelMode===`explicit`?{model:t.model}:{},keepCount:t.keepCount}),n),providers:ve}):s===`backups-restore`?(0,m.jsx)(Fy,{backups:se.data?.backups??[],canPrune:a.pruneBackups,canRestore:a.restore,disabled:oe||ge.isPending,loading:se.isPending,prepare:(t,n)=>ue(()=>e.core.prepareRestore({profile:Oy(M),backupId:t.backupId,restoreConfig:t.restoreConfig,restoreDatabase:t.restoreDatabase,restoreSessions:t.restoreSessions,...t.allowSqliteHomeRelocation?{allowSqliteHomeRelocation:!0,relocationTargetProfileId:t.relocationTargetProfileId}:{}}),n),profile:M,profiles:j,prune:e=>ge.mutate(e)}):s===`history`?(0,m.jsx)(Ly,{core:e.core,profile:M},`${M.id}:${M.revision}`):s===`profiles`?(0,m.jsx)(qy,{canManage:a.manageProfiles,host:e.host,profiles:j,refresh:()=>ne.refetch(),revealPaths:a.revealProfilePaths,surface:e.surface}):s===`diagnostics`?(0,m.jsx)(Iy,{canExport:a.exportDiagnostics&&!!e.host.exportDiagnostics,diagnostics:ce.data,exportBundle:()=>_e.mutate(),exporting:_e.isPending,loading:ce.isFetching,refresh:()=>void ce.refetch()}):s===`settings`?(0,m.jsx)(Yy,{capabilities:a,profile:M,props:e,recoveryBlocked:ie?.pendingRecovery===!0,writeBlocked:!ae||P||A>0}):(0,m.jsx)(Ky,{loading:re.isFetching,refresh:()=>void re.refetch(),status:ie}):(0,m.jsx)(by,{children:ne.isPending?t(`common.loading`):jy(ne.error,t(`global.failed`))});return(0,m.jsxs)(`div`,{className:`min-h-screen bg-[var(--surface)] text-[var(--text)]`,children:[(0,m.jsx)(`a`,{className:`sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[70] focus:rounded focus:bg-[var(--accent)] focus:px-4 focus:py-2 focus:text-white`,href:`#main-content`,onClick:e=>{e.preventDefault(),document.getElementById(`main-content`)?.focus()},children:t(`a11y.skipToContent`)}),(0,m.jsxs)(`header`,{className:`sticky top-0 z-30 flex min-h-16 flex-wrap items-center justify-between gap-3 border-b border-[var(--border)] bg-[color:var(--surface-raised)/.96] px-4 py-3 backdrop-blur md:px-6`,children:[(0,m.jsxs)(`div`,{className:`flex min-w-0 items-center gap-3`,children:[(0,m.jsx)(`div`,{className:`grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-[var(--accent)] text-white`,children:(0,m.jsx)(Zr,{size:20})}),(0,m.jsxs)(`div`,{className:`min-w-0`,children:[(0,m.jsxs)(`div`,{className:`flex min-w-0 items-center gap-2`,children:[(0,m.jsx)(`div`,{className:`truncate font-bold`,children:`Codex Provider Sync`}),(0,m.jsx)(Cy,{children:t(`brand.${e.surface}.label`)})]}),(0,m.jsx)(`div`,{className:`truncate text-xs text-[var(--muted)]`,children:t(`brand.${e.surface}.subtitle`)})]})]}),(0,m.jsxs)(`div`,{className:`flex w-full min-w-0 items-center justify-between gap-3 sm:w-auto sm:justify-end`,children:[(0,m.jsx)(`select`,{"aria-label":t(`a11y.profile`),className:`min-w-0 max-w-[min(12rem,70vw)] rounded-[var(--radius-control)] border border-[var(--border)] bg-[var(--input)] px-3 py-2 text-sm`,disabled:A>0||P,onChange:e=>u(e.target.value),value:M?.id??``,children:j.map(e=>(0,m.jsx)(`option`,{value:e.id,children:e.name},e.id))}),(0,m.jsx)(Cy,{tone:A>0||P?`warning`:`success`,children:t(A>0||P?`global.busy`:`global.ready`)})]})]}),(0,m.jsxs)(`div`,{className:`mx-auto grid w-full min-w-0 max-w-[1600px] md:grid-cols-[240px_minmax(0,1fr)]`,children:[(0,m.jsx)(`aside`,{className:`min-w-0 max-w-full overflow-hidden border-b border-[var(--border)] bg-[var(--surface-raised)] p-3 md:min-h-[calc(100vh-4rem)] md:border-b-0 md:border-r`,children:(0,m.jsx)(`nav`,{"aria-label":t(`a11y.primaryNavigation`),className:`flex w-full min-w-0 max-w-full gap-1 overflow-x-auto pb-1 sm:grid sm:grid-cols-4 sm:overflow-visible sm:pb-0 md:grid-cols-1`,children:o.map(([e,n,r])=>(0,m.jsxs)(`button`,{"aria-current":s===e?`page`:void 0,className:_y(`flex min-h-11 shrink-0 items-center gap-3 whitespace-nowrap rounded-[var(--radius-control)] px-3 text-left text-sm font-medium text-[var(--muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)] sm:shrink`,s===e?`bg-[var(--accent-soft)] text-[var(--accent-strong)]`:`hover:bg-[var(--surface-hover)] hover:text-[var(--text)]`),onClick:()=>c(e),type:`button`,children:[(0,m.jsx)(r,{size:17}),(0,m.jsx)(`span`,{children:t(n)})]},e))})}),(0,m.jsxs)(`main`,{className:`min-w-0 p-4 md:p-8`,id:`main-content`,tabIndex:-1,children:[ie?.pendingRecovery?(0,m.jsxs)(`div`,{className:`mb-5 flex items-start gap-3 rounded-xl border border-[var(--danger)] bg-[var(--danger-soft)] p-4 text-sm`,role:`alert`,children:[(0,m.jsx)(li,{className:`mt-0.5 shrink-0 text-[var(--danger)]`,size:20}),(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`div`,{className:`font-semibold`,children:`RECOVERY_REQUIRED`}),(0,m.jsx)(`div`,{className:`mt-1`,children:t(`global.recovery`)})]})]}):null,ie?.operationInProgress?(0,m.jsxs)(`div`,{className:`mb-5 flex items-start gap-3 rounded-xl border border-[var(--warning)] bg-[var(--warning-soft)] p-4 text-sm`,role:`status`,children:[(0,m.jsx)($r,{className:`mt-0.5 shrink-0 text-[var(--warning)]`,size:20}),(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`div`,{className:`font-semibold`,children:t(`global.busy`)}),(0,m.jsxs)(`div`,{className:`mt-1 text-[var(--muted)]`,children:[t(`plan.operations.${String(ie.operationInProgress.operation??`operation`)}`,{defaultValue:t(`plan.operations.operation`)}),` · `,String(ie.operationInProgress.busyScope??``)]})]})]}):null,re.isError?(0,m.jsx)(`div`,{className:`mb-5 rounded-xl border border-[var(--danger)] bg-[var(--danger-soft)] p-4 text-sm text-[var(--danger)]`,role:`alert`,children:jy(re.error,t(`global.failed`))}):null,ye]})]}),a.sync||a.switchProvider||a.restore?(0,m.jsx)(Wy,{apply:()=>{!d||D.current||he.isPending||(D.current=!0,he.mutate(d,{onSettled:()=>{D.current=!1}}))},applying:he.isPending,cancel:()=>{!he.isPending||w||(T(!0),E.current?.abort())},cancelling:w,close:de,confirmDisabled:!ae||P||ie?.pendingRecovery===!0,operationId:b,plan:d,progress:S,restoreFocus:pe}):null,(0,m.jsx)(Hy,{close:()=>{_(null),y(!0)},closeDisabled:h?.outcome===`recovery_required`&&(!v||ie?.pendingRecovery!==!1),restoreFocus:me,result:h})]})}var ib=class extends p.Component{state={failed:!1};static getDerivedStateFromError(){return{failed:!0}}componentDidCatch(e,t){}render(){if(!this.state.failed)return this.props.children;let e=this.props.locale().toLowerCase().startsWith(`zh`);return(0,m.jsx)(`div`,{className:`grid min-h-screen place-items-center bg-[var(--surface)] p-6 text-[var(--text)]`,children:(0,m.jsxs)(by,{className:`max-w-lg text-center`,children:[(0,m.jsx)(li,{className:`mx-auto text-[var(--danger)]`,size:40}),(0,m.jsx)(`h1`,{className:`mt-4 text-xl font-bold`,children:e?`应用错误`:`Application error`}),(0,m.jsx)(`p`,{className:`mt-2 text-sm text-[var(--muted)]`,children:e?`页面遇到未预期错误;系统没有自动启动任何写操作。`:`The page encountered an unexpected error. No write was started automatically.`}),(0,m.jsx)(yy,{className:`mt-5`,onClick:()=>globalThis.location?.reload(),type:`button`,children:e?`重新加载`:`Reload`})]})})}},ab={en:{translation:{brand:{desktop:{label:`Desktop`,subtitle:`V1 primary desktop candidate · .NET post-handoff Legacy target`},web:{label:`Web`,subtitle:`Local Web companion`}},a11y:{skipToContent:`Skip to content`,profile:`Profile`,primaryNavigation:`Primary navigation`},nav:{overview:`Overview`,sync:`Sync`,switchProvider:`Switch Provider`,backupsRestore:`Backups / Restore`,history:`History`,profiles:`Profiles`,diagnostics:`Diagnostics`,settings:`Settings`},common:{refresh:`Refresh`,loading:`Loading…`,cancel:`Cancel`,confirm:`Confirm and apply`,save:`Save`,delete:`Delete`,close:`Close`,yes:`Yes`,no:`No`,none:`None`,unknown:`Unknown`,current:`Current`,provider:`Provider`,model:`Model`,status:`Status`,warnings:`Warnings`,retry:`Retry`},global:{ready:`Local service ready`,busy:`Operation in progress`,recovery:`Recovery required. Writes are disabled until the pending transaction is resolved.`,stale:`Protected state changed. Prepare the operation again.`,unexpected:`The page encountered an unexpected error.`,partial:`Completed with locked rollout files skipped.`,completed:`Operation completed.`,cancelled:`Operation cancelled.`,profileChanged:`Profile changed.`,profileChangedHint:`Review the current profile and prepare the operation again.`,failed:`Operation failed.`},overview:{title:`Provider metadata overview`,subtitle:`Compare rollout files, the SQLite thread index, and the selected profile.`,alignment:`Alignment`,aligned:`Aligned`,notAligned:`Needs attention`,rollout:`Rollout metadata`,sqlite:`SQLite metadata`,codexHomeSource:`Codex Home source`,sqliteHomeSource:`SQLite Home source`,snapshot:`Snapshot`,backupCount:`Managed backups`,locked:`Locked rollouts`},sync:{title:`Sync current Provider`,subtitle:`Use the selected profile's root model_provider and align rollout and SQLite metadata.`,keep:`Backups to keep`,prepare:`Prepare sync`},switchPage:{title:`Switch Provider`,subtitle:`Update root model_provider and synchronize history in one protected operation.`,provider:`Provider ID`,modelMode:`Model handling`,providerDefault:`Use provider default`,keepModel:`Keep root model`,explicitModel:`Set explicit model`,model:`Model name`,prepare:`Prepare switch`},backups:{title:`Backups and Restore`,subtitle:`Only managed backup IDs can be restored.`,empty:`No managed backups.`,restoreConfig:`Restore config.toml`,restoreDatabase:`Restore State DB`,restoreSessions:`Restore rollout files`,relocation:`Confirm SQLite Home relocation`,targetProfile:`Relocation target profile`,prepare:`Prepare restore`,pruneKeep:`Keep newest backups`,prune:`Prune older backups`,readOnly:`This build lists managed backups read-only; Restore and Prune are not exposed.`},history:{title:`History`,subtitle:`Session bodies load only after you explicitly open a session.`,empty:`No sessions found.`,untitled:`Untitled session`,open:`Open session`,back:`Back to sessions`,messages:`messages`,archived:`Archived`,active:`Active`,pagination:`History pagination`,pageSummary:`Page {{page}} · {{total}} sessions`,previous:`Previous`,next:`Next`,roles:{user:`You`,assistant:`Assistant`}},profiles:{title:`Profiles`,subtitle:`The host resolves paths; Core requests receive only profile IDs and revisions.`,id:`Profile ID`,name:`Name`,codexHome:`Codex Home`,sqliteHome:`SQLite Home (optional)`,create:`Create profile`,update:`Update profile`,defaultManaged:`The default profile is managed by startup flags.`,pathManaged:{desktop:`Storage paths are retained by the trusted desktop Host.`,web:`Storage paths are retained by the local Web Host.`},readOnly:`This build exposes profile IDs and revisions only; profile editing is not enabled.`},diagnostics:{title:`Diagnostics`,subtitle:`Read-only, redacted runtime and safety state.`,runtime:`Runtime`,storage:`Storage`,provider:`Provider`,safety:`Safety`,items:`{{count}} items`,fieldsAvailable:`{{count}} redacted fields`,technicalDetails:`Show technical details`,fields:{arch:`Architecture`,node:`Node.js`,platform:`Platform`,sqliteHomeSource:`SQLite Home source`,sqliteSupported:`SQLite supported`,stateDbFound:`State DB found`,configured:`Configured Providers`,current:`Current Provider`,implicit:`Implicit Provider`,rolloutCounts:`Rollout distribution`,sqliteCounts:`SQLite distribution`,lockedRolloutCount:`Locked rollouts`,operationInProgress:`Operation in progress`,pendingRecovery:`Recovery required`,pendingTransactions:`Pending transactions`,projectThreadVisibilityAvailable:`Project visibility available`,rolloutScanComplete:`Rollout scan complete`,storageRevision:`Storage revision`},export:`Export redacted bundle`,exporting:`Exporting…`,exportCreated:`Redacted diagnostics bundle created.`,exportCancelled:`Diagnostics export cancelled.`,exportFailed:`Diagnostics export failed.`},settings:{title:`Settings`,subtitle:{desktop:`Language and theme preferences stay on this device.`,web:`Language and theme preferences stay in this browser; pairing remains managed by the local Web Host.`},language:`Language`,theme:`Theme`,system:`System`,light:`Light`,dark:`Dark`,watch:`Watch`,watchStart:`Start watch`,watchStop:`Stop watch`,watchRecoveryBlocked:`Resolve the pending recovery before starting Watch.`,update:`Updates`,updateStatus:{disabled:`Unavailable`,idle:`Ready to check`,checking:`Checking`,available:`Update available`,downloading:`Downloading`,downloaded:`Ready to install`,"not-available":`Up to date`,error:`Update failed`,installing:`Restarting to install`},updateReason:{"not-packaged":`Update checks are available only in a packaged build.`,"not-authorized":`This candidate build is not authorized to use a production update channel.`,"not-configured":`No release update channel is configured.`,"unsupported-target":`Updates are not supported for this platform target.`,"check-failed":`The update check failed without affecting Core operations.`,"download-failed":`The update download failed without affecting Core operations.`,"install-failed":`The installer could not be started; the current version remains active.`},updateBlocked:{"write-in-progress":`An update cannot be installed while a protected operation is running.`,"watch-active":`Stop Watch before installing an update.`,"pending-recovery":`An update cannot be installed while a transaction requires recovery.`,"recovery-unverified":`Recovery state could not be verified; installation remains blocked.`},updateVersion:`Version {{version}}`,updateProgress:`{{percent}}% downloaded`,updateCheck:`Check for updates`,updateDownload:`Download update`,updateInstall:`Restart and install`,forget:`Forget this browser`,englishFallback:`English fallback`,forgetHint:`Pairing credentials are removed by the local host.`},plan:{title:`Review plan`,operations:{sync:`Sync Provider metadata`,switch:`Switch Provider`,restore:`Restore backup`,operation:`Protected operation`},modelModes:{"provider-default":`Use Provider default model`,"keep-root-model":`Keep root model`,explicit:`Use explicit model`},fields:{modelMode:`Model handling`,restoreConfig:`Restore config.toml`,restoreDatabase:`Restore State DB`,restoreSessions:`Restore rollout files`,relocation:`SQLite Home relocation`,rolloutFiles:`Rollout files affected`,sqliteRows:`SQLite rows affected`,workspaceRoots:`Workspace roots affected`,stateDbFiles:`State DB files affected`,configFiles:`Config files affected`,lockedRollouts:`Currently locked rollouts`},stages:{scan_rollout_files:`Scan rollout files`,check_locked_rollout_files:`Check locked rollouts`,create_backup:`Create managed backup`,rewrite_rollout_files:`Update rollout files`,update_sqlite:`Update SQLite metadata`,update_config:`Update config.toml`,clean_backups:`Clean old backups`,create_restore_pre_snapshot:`Create pre-restore snapshot`,persist_restore_journal:`Persist Restore journal`,apply_restore_targets:`Restore selected targets`,commit_restore:`Commit Restore`,acknowledge_restore_commit:`Acknowledge Restore commit`,rollback_restore:`Roll back Restore`},statuses:{start:`Starting`,progress:`In progress`,complete:`Completed`},target:`Target`,impact:`Impact`,expires:`Expires`,items:`{{count}} items`,backupExpected:`A backup will be created before writes.`,exactApply:`Apply sends only this one-time plan ID.`,writeBlocked:`Another protected operation or recovery state currently blocks confirmation.`,technicalDetails:`Technical details`,progress:`Operation progress`,starting:`Starting protected operation…`,cancelOperation:`Cancel operation`,cancelling:`Cancelling…`,cancelPending:`Cancellation will take effect at the next safe point.`},operationResult:{title:`Operation result`,operationId:`Operation ID`,backupId:`Managed backup ID`,skippedRollouts:`Skipped locked rollout files`,resolveBeforeClose:`Resolve the pending recovery before closing this result.`,fields:{targetProvider:`Target Provider`,targetModel:`Target model`,modelSource:`Model source`,restoreOperationId:`Restore operation ID`,preRestoreSnapshotId:`Pre-restore snapshot ID`,restoreJournalState:`Restore journal state`,backupDurationMs:`Backup duration (ms)`,changedSessionFiles:`Rollout files changed`,sqliteRowsUpdated:`SQLite rows updated`,sqliteProviderRowsUpdated:`Provider rows updated`,sqliteUserEventRowsUpdated:`User-event rows updated`,sqliteCwdRowsUpdated:`Workspace rows updated`,updatedWorkspaceRoots:`Workspace roots updated`,savedWorkspaceRootCount:`Saved workspace roots`,restoreVersion:`Restore format version`,resolvedOperationCount:`Resolved operations`,commitAcknowledgementRecovered:`Commit acknowledgement recovered`},completed:{title:`Completed`,description:`The protected operation reached a durable completed state.`},partial:{title:`Partially completed`,description:`Committed changes are durable, but one or more locked rollout files were skipped.`},failedRolledBack:{title:`Failed and rolled back`,description:`The operation failed, and the previous state was restored successfully.`},recoveryRequired:{title:`Recovery required`,description:`A durable journal remains unresolved. Further writes stay blocked until recovery is completed.`},cancelled:{title:`Cancelled`,description:`The operation stopped at a safe cancellation point.`},stale:{title:`Plan became stale`,description:`Protected state changed after planning. Review a newly prepared plan before retrying.`}},validation:{required:`This field is required.`,keep:`Use a whole number from 1 to 1000.`,provider:`Enter a valid Provider ID.`,model:`Enter a model name for explicit mode.`,restore:`Select at least one item to restore.`,profileId:`Use letters, numbers, dots, underscores, or hyphens.`,path:`Enter an absolute path.`}}},"zh-CN":{translation:{brand:{desktop:{label:`桌面端`,subtitle:`V1 新版主桌面端候选 · .NET 交接后 Legacy fallback 目标`},web:{label:`Web`,subtitle:`本地 Web companion`}},a11y:{skipToContent:`跳到主要内容`,profile:`存储配置`,primaryNavigation:`主导航`},nav:{overview:`概览`,sync:`同步`,switchProvider:`切换 Provider`,backupsRestore:`备份 / 恢复`,history:`聊天记录`,profiles:`存储配置`,diagnostics:`诊断`,settings:`设置`},common:{refresh:`刷新`,loading:`正在加载…`,cancel:`取消`,confirm:`确认并执行`,save:`保存`,delete:`删除`,close:`关闭`,yes:`是`,no:`否`,none:`无`,unknown:`未知`,current:`当前`,provider:`Provider`,model:`模型`,status:`状态`,warnings:`警告`,retry:`重试`},global:{ready:`本地服务就绪`,busy:`操作执行中`,recovery:`存在待恢复事务;在明确恢复前已禁用写操作。`,stale:`受保护状态已变化,请重新生成计划。`,unexpected:`页面遇到未预期错误。`,partial:`操作完成,但跳过了仍被锁定的 rollout 文件。`,completed:`操作已完成。`,cancelled:`操作已取消。`,profileChanged:`存储配置已变化。`,profileChangedHint:`请检查当前存储配置,然后重新生成操作计划。`,failed:`操作失败。`},overview:{title:`Provider 元数据总览`,subtitle:`比较 rollout 文件、SQLite 线程索引与当前存储配置。`,alignment:`对齐状态`,aligned:`已对齐`,notAligned:`需要处理`,rollout:`Rollout 元数据`,sqlite:`SQLite 元数据`,codexHomeSource:`Codex Home 来源`,sqliteHomeSource:`SQLite Home 来源`,snapshot:`快照时间`,backupCount:`受管备份`,locked:`锁定的 rollout`},sync:{title:`同步当前 Provider`,subtitle:`读取当前配置的根 model_provider,并对齐 rollout 与 SQLite 元数据。`,keep:`保留备份数量`,prepare:`生成同步计划`},switchPage:{title:`切换 Provider`,subtitle:`在一次受保护操作中修改根 model_provider 并同步历史。`,provider:`Provider ID`,modelMode:`模型处理方式`,providerDefault:`使用 Provider 默认模型`,keepModel:`保留根模型`,explicitModel:`显式设置模型`,model:`模型名称`,prepare:`生成切换计划`},backups:{title:`备份与恢复`,subtitle:`恢复只能使用服务端管理的 backupId。`,empty:`暂无受管备份。`,restoreConfig:`恢复 config.toml`,restoreDatabase:`恢复 State DB`,restoreSessions:`恢复 rollout 文件`,relocation:`确认 SQLite Home 迁移`,targetProfile:`迁移目标配置`,prepare:`生成恢复计划`,pruneKeep:`保留最新备份数`,prune:`清理旧备份`,readOnly:`此构建仅只读列出受管备份;未开放恢复和清理。`},history:{title:`聊天记录`,subtitle:`只有在你明确打开会话后才加载消息正文。`,empty:`没有找到会话。`,untitled:`未命名会话`,open:`打开会话`,back:`返回会话列表`,messages:`条消息`,archived:`已归档`,active:`活动`,pagination:`聊天记录分页`,pageSummary:`第 {{page}} 页 · 共 {{total}} 个会话`,previous:`上一页`,next:`下一页`,roles:{user:`你`,assistant:`助手`}},profiles:{title:`存储配置`,subtitle:`路径由 Host 可信解析;Core 请求只携带配置 ID 与 revision。`,id:`配置 ID`,name:`名称`,codexHome:`Codex Home`,sqliteHome:`SQLite Home(可选)`,create:`新建配置`,update:`更新配置`,defaultManaged:`默认配置由启动参数管理。`,pathManaged:{desktop:`存储路径仅由可信桌面 Host 持有。`,web:`存储路径仅由本地 Web Host 持有。`},readOnly:`此构建只公开配置 ID 与 revision;未开放配置编辑。`},diagnostics:{title:`诊断`,subtitle:`只读展示经脱敏的运行时与安全状态。`,runtime:`运行时`,storage:`存储`,provider:`Provider`,safety:`安全状态`,items:`{{count}} 项`,fieldsAvailable:`{{count}} 个脱敏字段`,technicalDetails:`显示技术详情`,fields:{arch:`架构`,node:`Node.js`,platform:`平台`,sqliteHomeSource:`SQLite Home 来源`,sqliteSupported:`SQLite 支持状态`,stateDbFound:`State DB 是否存在`,configured:`已配置 Provider`,current:`当前 Provider`,implicit:`隐式 Provider`,rolloutCounts:`Rollout 分布`,sqliteCounts:`SQLite 分布`,lockedRolloutCount:`锁定的 rollout`,operationInProgress:`执行中的操作`,pendingRecovery:`需要恢复`,pendingTransactions:`待处理事务`,projectThreadVisibilityAvailable:`项目可见性可用`,rolloutScanComplete:`Rollout 扫描完成`,storageRevision:`存储 revision`},export:`导出脱敏诊断包`,exporting:`正在导出…`,exportCreated:`脱敏诊断包已创建。`,exportCancelled:`已取消诊断导出。`,exportFailed:`诊断导出失败。`},settings:{title:`设置`,subtitle:{desktop:`语言和主题偏好仅保存在此设备。`,web:`语言和主题偏好仅保存在此浏览器;配对仍由本地 Web Host 管理。`},language:`语言`,theme:`主题`,system:`跟随系统`,light:`浅色`,dark:`深色`,watch:`监视`,watchStart:`启动监视`,watchStop:`停止监视`,watchRecoveryBlocked:`请先解决待恢复事务,再启动监视。`,update:`更新`,updateStatus:{disabled:`不可用`,idle:`可检查更新`,checking:`正在检查`,available:`发现新版本`,downloading:`正在下载`,downloaded:`可安装`,"not-available":`已是最新版本`,error:`更新失败`,installing:`正在重启安装`},updateReason:{"not-packaged":`仅打包后的应用可检查更新。`,"not-authorized":`此候选构建未获生产更新通道授权。`,"not-configured":`尚未配置正式 Release 更新通道。`,"unsupported-target":`当前平台目标不支持应用内更新。`,"check-failed":`检查更新失败,不会影响 Core 操作。`,"download-failed":`下载更新失败,不会影响 Core 操作。`,"install-failed":`无法启动安装程序,当前版本仍保持可用。`},updateBlocked:{"write-in-progress":`受保护操作运行期间不能安装更新。`,"watch-active":`请先停止监视,再安装更新。`,"pending-recovery":`存在待恢复事务时不能安装或重启更新。`,"recovery-unverified":`无法确认所有 Profile 的恢复状态,已阻止安装。`},updateVersion:`版本 {{version}}`,updateProgress:`已下载 {{percent}}%`,updateCheck:`检查更新`,updateDownload:`下载更新`,updateInstall:`重启并安装`,forget:`忘记此浏览器`,englishFallback:`英文为兜底语言`,forgetHint:`配对凭据将由本地 Host 删除。`},plan:{title:`审核计划`,operations:{sync:`同步 Provider 元数据`,switch:`切换 Provider`,restore:`恢复备份`,operation:`受保护操作`},modelModes:{"provider-default":`使用 Provider 默认模型`,"keep-root-model":`保留根模型`,explicit:`使用显式模型`},fields:{modelMode:`模型处理方式`,restoreConfig:`恢复 config.toml`,restoreDatabase:`恢复 State DB`,restoreSessions:`恢复 rollout 文件`,relocation:`SQLite Home 迁移`,rolloutFiles:`受影响的 rollout 文件`,sqliteRows:`受影响的 SQLite 行`,workspaceRoots:`受影响的工作区根目录`,stateDbFiles:`受影响的 State DB 文件`,configFiles:`受影响的配置文件`,lockedRollouts:`当前锁定的 rollout`},stages:{scan_rollout_files:`扫描 rollout 文件`,check_locked_rollout_files:`检查锁定的 rollout`,create_backup:`创建受管备份`,rewrite_rollout_files:`更新 rollout 文件`,update_sqlite:`更新 SQLite 元数据`,update_config:`更新 config.toml`,clean_backups:`清理旧备份`,create_restore_pre_snapshot:`创建恢复前快照`,persist_restore_journal:`持久化 Restore journal`,apply_restore_targets:`恢复所选目标`,commit_restore:`提交恢复`,acknowledge_restore_commit:`确认恢复提交`,rollback_restore:`回滚恢复`},statuses:{start:`正在开始`,progress:`执行中`,complete:`已完成`},target:`目标`,impact:`影响`,expires:`失效时间`,items:`{{count}} 项`,backupExpected:`写入前会先创建备份。`,exactApply:`执行时只提交此一次性 planId。`,writeBlocked:`当前存在其他受保护操作或待恢复状态,暂不能确认执行。`,technicalDetails:`技术详情`,progress:`操作进度`,starting:`正在启动受保护操作…`,cancelOperation:`取消操作`,cancelling:`正在取消…`,cancelPending:`取消将在下一个安全点生效。`},operationResult:{title:`操作结果`,operationId:`操作 ID`,backupId:`受管备份 ID`,skippedRollouts:`跳过的锁定 rollout 文件`,resolveBeforeClose:`请先完成待处理的恢复,再关闭此结果。`,fields:{targetProvider:`目标 Provider`,targetModel:`目标模型`,modelSource:`模型来源`,restoreOperationId:`恢复操作 ID`,preRestoreSnapshotId:`恢复前快照 ID`,restoreJournalState:`恢复 journal 状态`,backupDurationMs:`备份耗时(毫秒)`,changedSessionFiles:`已修改 rollout 文件`,sqliteRowsUpdated:`已更新 SQLite 行`,sqliteProviderRowsUpdated:`已更新 Provider 行`,sqliteUserEventRowsUpdated:`已更新用户事件行`,sqliteCwdRowsUpdated:`已更新工作区行`,updatedWorkspaceRoots:`已更新工作区根目录`,savedWorkspaceRootCount:`已保存工作区根目录`,restoreVersion:`恢复格式版本`,resolvedOperationCount:`已解决操作数`,commitAcknowledgementRecovered:`已恢复提交确认`},completed:{title:`已完成`,description:`受保护操作已进入耐久的完成状态。`},partial:{title:`部分完成`,description:`已提交的更改已持久化,但仍有一个或多个锁定的 rollout 文件被跳过。`},failedRolledBack:{title:`失败并已回滚`,description:`操作失败,且先前状态已成功恢复。`},recoveryRequired:{title:`需要恢复`,description:`仍有未解决的耐久 journal;完成恢复前将继续阻止写操作。`},cancelled:{title:`已取消`,description:`操作已在安全取消点停止。`},stale:{title:`计划已失效`,description:`生成计划后受保护状态发生变化;重试前请重新生成并审核计划。`}},validation:{required:`此项必填。`,keep:`请输入 1 到 1000 的整数。`,provider:`请输入有效的 Provider ID。`,model:`显式模式必须填写模型名称。`,restore:`至少选择一种恢复内容。`,profileId:`只能使用字母、数字、点、下划线或连字符。`,path:`请输入绝对路径。`}}}};async function ob(e){let t=tn.createInstance();return await t.init({resources:ab,lng:e,fallbackLng:`en`,interpolation:{escapeValue:!1},returnNull:!1}),t}function sb(e){let t=e.preferences.getLocale()??e.initialLocale,n=e.preferences.getTheme()??e.initialTheme,[r,i]=(0,p.useState)(null),[a]=(0,p.useState)(()=>new Ue({defaultOptions:{queries:{retry:!1,staleTime:1/0,refetchOnWindowFocus:!1,refetchOnReconnect:!1},mutations:{retry:!1}}}));return(0,p.useEffect)(()=>{let e=!0;return ob(t).then(t=>{e&&i(t)}),()=>{e=!1,a.clear()}},[e.initialTheme,e.preferences,a,t]),(0,p.useLayoutEffect)(()=>{document.documentElement.dataset.theme=n},[n]),r?(0,m.jsx)(En,{i18n:r,children:(0,m.jsx)(ib,{locale:()=>r.language,children:(0,m.jsx)(_,{client:a,children:(0,m.jsx)(Ey,{children:(0,m.jsx)(rb,{props:e})})})})}):(0,m.jsx)(`div`,{className:`grid min-h-screen place-items-center bg-[var(--surface)] text-[var(--text)]`,children:t===`zh-CN`?`正在加载…`:`Loading…`})}var cb=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m){if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&k(x,t.startTime-e)}}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&k(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var ee=new MessageChannel,te=ee.port2;ee.port1.onmessage=D,O=function(){te.postMessage(null)}}else O=function(){_(D,0)};function k(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,k(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),lb=o(((e,t)=>{t.exports=cb()})),ub=o((e=>{var t=lb(),n=f(),r=Sp();function i(e){var t=`https://react.dev/errors/`+e;if(1ie||(e.current=re[ie],re[ie]=null,ie--)}function F(e,t){ie++,re[ie]=e.current,e.current=t}var oe=ae(null),se=ae(null),ce=ae(null),le=ae(null);function ue(e,t){switch(F(ce,t),F(se,e),F(oe,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Hd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Hd(t),e=Ud(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}P(oe),F(oe,e)}function de(){P(oe),P(se),P(ce)}function fe(e){e.memoizedState!==null&&F(le,e);var t=oe.current,n=Ud(t,e.type);t!==n&&(F(se,e),F(oe,n))}function pe(e){se.current===e&&(P(oe),P(se)),le.current===e&&(P(le),$f._currentValue=N)}var me,he;function ge(e){if(me===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);me=t&&t[1]||``,he=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{_e=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?ge(n):``}function ye(e,t){switch(e.tag){case 26:case 27:case 5:return ge(e.type);case 16:return ge(`Lazy`);case 13:return e.child!==t&&t!==null?ge(`Suspense Fallback`):ge(`Suspense`);case 19:return ge(`SuspenseList`);case 0:case 15:return ve(e.type,!1);case 11:return ve(e.type.render,!1);case 1:return ve(e.type,!0);case 31:return ge(`Activity`);default:return``}}function be(e){try{var t=``,n=null;do t+=ye(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var xe=Object.prototype.hasOwnProperty,Se=t.unstable_scheduleCallback,Ce=t.unstable_cancelCallback,we=t.unstable_shouldYield,Te=t.unstable_requestPaint,Ee=t.unstable_now,De=t.unstable_getCurrentPriorityLevel,Oe=t.unstable_ImmediatePriority,ke=t.unstable_UserBlockingPriority,Ae=t.unstable_NormalPriority,je=t.unstable_LowPriority,Me=t.unstable_IdlePriority,Ne=t.log,Pe=t.unstable_setDisableYieldValue,Fe=null,Ie=null;function Le(e){if(typeof Ne==`function`&&Pe(e),Ie&&typeof Ie.setStrictMode==`function`)try{Ie.setStrictMode(Fe,e)}catch{}}var Re=Math.clz32?Math.clz32:Ve,ze=Math.log,Be=Math.LN2;function Ve(e){return e>>>=0,e===0?32:31-(ze(e)/Be|0)|0}var He=256,Ue=262144,We=4194304;function Ge(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ke(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ge(n))):i=Ge(o):i=Ge(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ge(n))):i=Ge(o)):i=Ge(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function qe(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Je(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Ye(){var e=We;return We<<=1,!(We&62914560)&&(We=4194304),e}function Xe(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Ze(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Qe(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),un=!1;if(ln)try{var dn={};Object.defineProperty(dn,"passive",{get:function(){un=!0}}),window.addEventListener(`test`,dn,dn),window.removeEventListener(`test`,dn,dn)}catch{un=!1}var fn=null,pn=null,mn=null;function hn(){if(mn)return mn;var e,t=pn,n=t.length,r,i=`value`in fn?fn.value:fn.textContent,a=i.length;for(e=0;e=qn),Jn=` `,Yn=!1;function Xn(e,t){switch(e){case`keyup`:return Gn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function z(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Zn=!1;function Qn(e,t){switch(e){case`compositionend`:return z(t);case`keypress`:return t.which===32?(Yn=!0,Jn):null;case`textInput`:return e=t.data,e===Jn&&Yn?null:e;default:return null}}function $n(e,t){if(Zn)return e===`compositionend`||!Kn&&Xn(e,t)?(e=hn(),mn=pn=fn=null,Zn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=xr(n)}}function Cr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Cr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=It(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=It(e.document)}return t}function Tr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Er=ln&&`documentMode`in document&&11>=document.documentMode,Dr=null,Or=null,kr=null,Ar=!1;function jr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ar||Dr==null||Dr!==It(r)||(r=Dr,`selectionStart`in r&&Tr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),kr&&br(kr,r)||(kr=r,r=Ed(Or,`onSelect`),0>=o,i-=o,B=1<<32-Re(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),H&&wi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),H&&wi(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return H&&wi(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),H&&wi(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===_&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case h:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===_){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===E&&Sa(l)===r.type){n(e,r.sibling),c=a(r,o.props),ka(c,o),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}o.type===_?(c=ui(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=li(o.type,o.key,o.props,null,e.mode,c),ka(c,o),c.return=e,e=c)}return s(e);case g:a:{for(l=o.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}c=pi(o,e.mode,c),c.return=e,e=c}return s(e);case E:return o=Sa(o),b(e,r,o,c)}if(ne(o))return v(e,r,o,c);if(te(o)){if(l=te(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),y(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Oa(o),c);if(o.$$typeof===x)return b(e,r,Xi(e,o),c);Aa(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=di(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Da=0;var i=b(e,t,n,r);return Ea=null,i}catch(t){if(t===ga||t===va)throw t;var a=ai(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ma=ja(!0),Na=ja(!1),Pa=!1;function Fa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ia(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function La(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Ra(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Fl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ni(e),ti(e,null,n),t}return Qr(e,r,t,n),ni(e)}function za(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,et(e,n)}}function Ba(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Va=!1;function Ha(){if(Va){var e=sa;if(e!==null)throw e}}function Ua(e,t,n,r){Va=!1;var i=e.updateQueue;Pa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,m=f!==s.lane;if(m?(Y&f)===f:(r&f)===f){f!==0&&f===oa&&(Va=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=p({},d,f);break a;case 2:Pa=!0}}f=s.callback,f!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[f]:m.push(f))}else m={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Wl|=o,e.lanes=o,e.memoizedState=d}}function Wa(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Ga(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=j.T,s={};j.T=s,Os(e,!1,t,n);try{var c=i(),l=j.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ds(e,t,ua(c,r),fu(e)):Ds(e,t,r,fu(e))}catch(n){Ds(e,t,{then:function(){},status:`rejected`,reason:n},fu())}finally{M.p=a,o!==null&&s.types!==null&&(o.types=s.types),j.T=o}}function _s(){}function vs(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=ys(e).queue;gs(e,a,t,N,n===null?_s:function(){return bs(e),n(r)})}function ys(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:N,baseState:N,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ko,lastRenderedState:N},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ko,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function bs(e){var t=ys(e);t.next===null&&(t=e.alternate.memoizedState),Ds(e,t.next.queue,{},fu())}function xs(){return Yi($f)}function Ss(){return To().memoizedState}function Cs(){return To().memoizedState}function ws(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=fu();e=La(n);var r=Ra(t,e,n);r!==null&&(mu(r,t,n),za(r,t,n)),t={cache:na()},e.payload=t;return}t=t.return}}function Ts(e,t,n){var r=fu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},ks(e)?As(t,n):(n=$r(e,t,n,r),n!==null&&(mu(n,e,r),js(n,t,r)))}function Es(e,t,n){Ds(e,t,n,fu())}function Ds(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(ks(e))As(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,yr(s,o))return Qr(e,t,i,0),Il===null&&Zr(),!1}catch{}if(n=$r(e,t,i,r),n!==null)return mu(n,e,r),js(n,t,r),!0}return!1}function Os(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},ks(e)){if(t)throw Error(i(479))}else t=$r(e,n,r,2),t!==null&&mu(t,e,2)}function ks(e){var t=e.alternate;return e===G||t!==null&&t===G}function As(e,t){lo=co=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function js(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,et(e,n)}}var Ms={readContext:Yi,use:q,useCallback:K,useContext:K,useEffect:K,useImperativeHandle:K,useLayoutEffect:K,useInsertionEffect:K,useMemo:K,useReducer:K,useRef:K,useState:K,useDebugValue:K,useDeferredValue:K,useTransition:K,useSyncExternalStore:K,useId:K,useHostTransitionStatus:K,useFormState:K,useActionState:K,useOptimistic:K,useMemoCache:K,useCacheRefresh:K};Ms.useEffectEvent=K;var Ns={readContext:Yi,use:q,useCallback:function(e,t){return wo().memoizedState=[e,t===void 0?null:t],e},useContext:Yi,useEffect:rs,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ts(4194308,4,ls.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ts(4194308,4,e,t)},useInsertionEffect:function(e,t){ts(4,2,e,t)},useMemo:function(e,t){var n=wo();t=t===void 0?null:t;var r=e();if(uo){Le(!0);try{e()}finally{Le(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=wo();if(n!==void 0){var i=n(t);if(uo){Le(!0);try{n(t)}finally{Le(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ts.bind(null,G,e),[r.memoizedState,e]},useRef:function(e){var t=wo();return e={current:e},t.memoizedState=e},useState:function(e){e=zo(e);var t=e.queue,n=Es.bind(null,G,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ds,useDeferredValue:function(e,t){return ms(wo(),e,t)},useTransition:function(){var e=zo(!1);return e=gs.bind(null,G,e.queue,!0,!1),wo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=G,a=wo();if(H){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),Il===null)throw Error(i(349));Y&127||Po(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,rs(Io.bind(null,r,o,e),[e]),r.flags|=2048,$o(9,{destroy:void 0},Fo.bind(null,r,o,n,t),null),n},useId:function(){var e=wo(),t=Il.identifierPrefix;if(H){var n=Ci,r=B;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=fo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[I]=t,o[st]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Fd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Dc(t)}}return Mc(t),Oc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Dc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ce.current,Fi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=ki,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[I]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Mi(t,!0)}else e=Vd(e).createTextNode(r),e[I]=t,t.stateNode=e}return Mc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Fi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[I]=t}else Ii(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Mc(t),e=!1}else n=Li(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(ro(t),t):(ro(t),null);if(t.flags&128)throw Error(i(558))}return Mc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Fi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[I]=t}else Ii(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Mc(t),a=!1}else a=Li(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(ro(t),t):(ro(t),null)}return ro(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Ac(t,t.updateQueue),Mc(t),null);case 4:return de(),e===null&&Sd(t.stateNode.containerInfo),Mc(t),null;case 10:return Ui(t.type),Mc(t),null;case 19:if(P(io),r=t.memoizedState,r===null)return Mc(t),null;if(a=!!(t.flags&128),o=r.rendering,o===null){if(a)jc(r,!1);else{if(Ul!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=W(e),o!==null){for(t.flags|=128,jc(r,!1),e=o.updateQueue,t.updateQueue=e,Ac(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)ci(n,e),n=n.sibling;return F(io,io.current&1|2),H&&wi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ee()>eu&&(t.flags|=128,a=!0,jc(r,!1),t.lanes=4194304)}}else{if(!a){if(e=W(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Ac(t,e),jc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!H)return Mc(t),null}else 2*Ee()-r.renderingStartTime>eu&&n!==536870912&&(t.flags|=128,a=!0,jc(r,!1),t.lanes=4194304)}r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Mc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ee(),e.sibling=null,n=io.current,F(io,a?n&1|2:n&1),H&&wi(t,r.treeForkCount),e);case 22:case 23:return ro(t),Xa(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Mc(t),t.subtreeFlags&6&&(t.flags|=8192)):Mc(t),n=t.updateQueue,n!==null&&Ac(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&P(fa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ui(ta),Mc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Pc(e,t){switch(Di(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ui(ta),de(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return pe(t),null;case 31:if(t.memoizedState!==null){if(ro(t),t.alternate===null)throw Error(i(340));Ii()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(ro(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Ii()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return P(io),null;case 4:return de(),null;case 10:return Ui(t.type),null;case 22:case 23:return ro(t),Xa(),e!==null&&P(fa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ui(ta),null;case 25:return null;default:return null}}function Fc(e,t){switch(Di(t),t.tag){case 3:Ui(ta),de();break;case 26:case 27:case 5:pe(t);break;case 4:de();break;case 31:t.memoizedState!==null&&ro(t);break;case 13:ro(t);break;case 19:P(io);break;case 10:Ui(t.type);break;case 22:case 23:ro(t),Xa(),e!==null&&P(fa);break;case 24:Ui(ta)}}function Ic(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Wu(t,t.return,e)}}function Lc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Wu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Wu(t,t.return,e)}}function Rc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ga(t,n)}catch(t){Wu(e,e.return,t)}}}function zc(e,t,n){n.props=Bs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Wu(e,t,n)}}function Bc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Wu(e,t,n)}}function Vc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null){if(typeof r==`function`)try{r()}catch(n){Wu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Wu(e,t,n)}else n.current=null}}function Hc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Wu(e,e.return,t)}}function Uc(e,t,n){try{var r=e.stateNode;Id(r,e.type,n,t),r[st]=t}catch(t){Wu(e,e.return,t)}}function Wc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Qd(e.type)||e.tag===4}function Gc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Wc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Qd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Kc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=$t));else if(r!==4&&(r===27&&Qd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Kc(e,t,n),e=e.sibling;e!==null;)Kc(e,t,n),e=e.sibling}function qc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Qd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(qc(e,t,n),e=e.sibling;e!==null;)qc(e,t,n),e=e.sibling}function Jc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Fd(t,r,n),t[I]=e,t[st]=n}catch(t){Wu(e,e.return,t)}}var Yc=!1,Xc=!1,Zc=!1,Qc=typeof WeakSet==`function`?WeakSet:Set,$c=null;function el(e,t){if(e=e.containerInfo,zd=cp,e=wr(e),Tr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Bd={focusedElem:e,selectionRange:n},cp=!1,$c=t;$c!==null;)if(t=$c,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,$c=e;else for(;$c!==null;){switch(t=$c,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Fd(o,r,n),o[I]=e,yt(o),r=o;break a;case`link`:var s=Hf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Sr(s,h),v=Sr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,j.T=null,n=cu,cu=null;var o=iu,s=ou;if(ru=0,au=iu=null,ou=0,Fl&6)throw Error(i(331));var c=Fl;if(Fl|=4,Al(o.current),Sl(o,o.current,s,n),Fl=c,id(0,!1),Ie&&typeof Ie.onPostCommitFiberRoot==`function`)try{Ie.onPostCommitFiberRoot(Fe,o)}catch{}return!0}finally{M.p=a,j.T=r,Bu(e,t)}}function Uu(e,t,n){t=hi(n,t),t=Ks(e.stateNode,t,2),e=Ra(e,t,2),e!==null&&(Ze(e,2),rd(e))}function Wu(e,t,n){if(e.tag===3)Uu(e,e,n);else for(;t!==null;){if(t.tag===3){Uu(t,e,n);break}if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(nu===null||!nu.has(r))){e=hi(n,e),n=qs(2),r=Ra(t,n,2),r!==null&&(Js(n,r,t,e),Ze(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Pl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Vl=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Il===e&&(Y&n)===n&&(Ul===4||Ul===3&&(Y&62914560)===Y&&300>Ee()-Ql?!(Fl&2)&&xu(e,0):Kl|=n,Jl===Y&&(Jl=0)),rd(e)}function qu(e,t){t===0&&(t=Ye()),e=ei(e,t),e!==null&&(Ze(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Se(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t){if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Re(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=Y,a=Ke(r,r===Il?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||qe(r,a)||(n=!0,ld(r,a))}r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Kd()&&(e=nd);for(var t=Ee(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}ru!==0&&ru!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Ld(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function Sf(e,t,n){var r=xf;if(r&&typeof t==`string`&&t){var i=Rt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),gf.has(i)||(gf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Fd(t,`link`,e),yt(t),r.head.appendChild(t)))}}function Cf(e){vf.D(e),Sf(`dns-prefetch`,e,null)}function wf(e,t){vf.C(e,t),Sf(`preconnect`,e,t)}function Tf(e,t,n){vf.L(e,t,n);var r=xf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Rt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Rt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Rt(n.imageSizes)+`"]`)):i+=`[href="`+Rt(e)+`"]`;var a=i;switch(t){case`style`:a=jf(e);break;case`script`:a=Ff(e)}hf.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),hf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Mf(a))||t===`script`&&r.querySelector(If(a))||(t=r.createElement(`link`),Fd(t,`link`,e),yt(t),r.head.appendChild(t)))}}function Ef(e,t){vf.m(e,t);var n=xf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Rt(r)+`"][href="`+Rt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Ff(e)}if(!hf.has(a)&&(e=p({rel:`modulepreload`,href:e},t),hf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(If(a)))return}r=n.createElement(`link`),Fd(r,`link`,e),yt(r),n.head.appendChild(r)}}}function Df(e,t,n){vf.S(e,t,n);var r=xf;if(r&&e){var i=vt(r).hoistableStyles,a=jf(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Mf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=hf.get(a))&&zf(e,n);var c=o=r.createElement(`link`);yt(c),Fd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Rf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Of(e,t){vf.X(e,t);var n=xf;if(n&&e){var r=vt(n).hoistableScripts,i=Ff(e),a=r.get(i);a||(a=n.querySelector(If(i)),a||(e=p({src:e,async:!0},t),(t=hf.get(i))&&Bf(e,t),a=n.createElement(`script`),yt(a),Fd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t){vf.M(e,t);var n=xf;if(n&&e){var r=vt(n).hoistableScripts,i=Ff(e),a=r.get(i);a||(a=n.querySelector(If(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=hf.get(i))&&Bf(e,t),a=n.createElement(`script`),yt(a),Fd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Af(e,t,n,r){var a=(a=ce.current)?_f(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=jf(n.href),n=vt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=jf(n.href);var o=vt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(Mf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),hf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},hf.set(e,n),o||Pf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Ff(n),n=vt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function jf(e){return`href="`+Rt(e)+`"`}function Mf(e){return`link[rel="stylesheet"][`+e+`]`}function Nf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function Pf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Fd(t,`link`,n),yt(t),e.head.appendChild(t))}function Ff(e){return`[src="`+Rt(e)+`"]`}function If(e){return`script[async]`+e}function Lf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Rt(n.href)+`"]`);if(r)return t.instance=r,yt(r),r;var a=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),yt(r),Fd(r,`style`,a),Rf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=jf(n.href);var o=e.querySelector(Mf(a));if(o)return t.state.loading|=4,t.instance=o,yt(o),o;r=Nf(n),(a=hf.get(a))&&zf(r,a),o=(e.ownerDocument||e).createElement(`link`),yt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Fd(o,`link`,r),t.state.loading|=4,Rf(o,n.precedence,e),t.instance=o;case`script`:return o=Ff(n.src),(a=e.querySelector(If(o)))?(t.instance=a,yt(a),a):(r=n,(a=hf.get(o))&&(r=p({},n),Bf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),yt(a),Fd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Rf(r,n.precedence,e));return t.instance}function Rf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Wf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Gf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Kf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=jf(r.href),a=t.querySelector(Mf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Yf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,yt(a);return}a=t.ownerDocument||t,r=Nf(r),(i=hf.get(i))&&zf(r,i),a=a.createElement(`link`),yt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Fd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Yf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var qf=0;function Jf(e,t){return e.stylesheets&&e.count===0&&Zf(e,e.stylesheets),0qf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Yf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Zf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Xf=null;function Zf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Xf=new Map,t.forEach(Qf,e),Xf=null,Yf.call(e))}function Qf(e,t){if(!(t.state.loading&4)){var n=Xf.get(e);if(n)var r=n.get(null);else{n=new Map,Xf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=ub()}))(),fb=`cps.web.deviceCredential`,pb=`cps.preference.locale`,mb=`cps.preference.theme`;function hb(){return globalThis.localStorage.getItem(fb)??``}async function gb(e){let t=await e.json().catch(()=>({}));return t&&typeof t==`object`&&!Array.isArray(t)?t:{}}function _b(e,t){let n=typeof e.code==`string`?e.code:`HOST_REQUEST_FAILED`;return Object.assign(Error(`${t} (${n})`),{code:n})}async function vb(){let e=new URLSearchParams(globalThis.location.hash.replace(/^#/,``)).get(`pair`);if(!e)return hb()||null;globalThis.history.replaceState(null,``,`${globalThis.location.pathname}${globalThis.location.search}`);let t=await globalThis.fetch(`/api/pair`,{method:`POST`,redirect:`error`,credentials:`same-origin`,headers:{"X-Codex-Provider-Pairing":e}}),n=await gb(t),r=typeof n.deviceCredential==`string`?n.deviceCredential:``;return!t.ok||!r?null:(globalThis.localStorage.setItem(fb,r),r)}function yb(e){return async(t,n={})=>{let r=await globalThis.fetch(t,{...n,headers:{...Object.fromEntries(new Headers(n.headers).entries()),"X-Codex-Provider-Device":e}});return r.status===403&&(await gb(r.clone())).code===`PAIRING_REQUIRED`&&(globalThis.localStorage.removeItem(fb),globalThis.dispatchEvent(new CustomEvent(`cps:pairing-required`))),r}}function bb(e){let t=yb(e),n={"Content-Type":`application/json`};return{listProfiles:async e=>{let n=await t(`/api/profiles`,{credentials:`same-origin`,redirect:`error`,signal:e}),r=await gb(n);if(!n.ok||!Array.isArray(r.profiles))throw _b(r,`Unable to load profiles`);return r.profiles},async saveProfile(e,r){let i=await t(`/api/profiles/save`,{method:`POST`,credentials:`same-origin`,redirect:`error`,headers:n,body:JSON.stringify(e),signal:r}),a=await gb(i);if(!i.ok||!a.profile)throw _b(a,`Unable to save profile`);return a.profile},async deleteProfile(e,r,i){let a=await t(`/api/profiles/delete`,{method:`POST`,credentials:`same-origin`,redirect:`error`,headers:n,body:JSON.stringify({profileId:e,profileRevision:r}),signal:i}),o=await gb(a);if(!a.ok)throw _b(o,`Unable to delete profile`)},async forgetBrowser(){try{await t(`/api/access/forget`,{method:`POST`,credentials:`same-origin`,redirect:`error`,headers:n,body:`{}`})}finally{globalThis.localStorage.removeItem(fb)}}}}var xb={getLocale(){let e=globalThis.localStorage.getItem(pb);return e===`zh-CN`||e===`en`?e:null},setLocale(e){globalThis.localStorage.setItem(pb,e)},getTheme(){let e=globalThis.localStorage.getItem(mb);return e===`system`||e===`light`||e===`dark`?e:null},setTheme(e){globalThis.localStorage.setItem(mb,e)}};async function Sb(e){await e.forgetBrowser?.(),globalThis.location.reload()}var Cb=(0,db.createRoot)(document.getElementById(`root`)),wb=await vb();if(!wb)Cb.render((0,m.jsx)(p.StrictMode,{children:(0,m.jsx)(`main`,{className:`grid min-h-screen place-items-center bg-[var(--surface)] p-6 text-[var(--text)]`,children:(0,m.jsxs)(`section`,{className:`max-w-lg rounded-2xl border border-[var(--border)] bg-[var(--surface-raised)] p-8 text-center shadow-xl`,children:[(0,m.jsx)(`h1`,{className:`text-2xl font-bold`,children:`Codex Provider Sync`}),(0,m.jsxs)(`p`,{className:`mt-3 text-sm leading-6 text-[var(--muted)]`,children:[`This browser is not paired. Run `,(0,m.jsx)(`code`,{children:`codex-provider web`}),` again and open the new one-time link.`]})]})})}));else{let e=bb(wb),t=yb(wb),n=new Lr({baseUrl:globalThis.location.origin,fetch:t});globalThis.addEventListener(`cps:pairing-required`,()=>globalThis.location.reload(),{once:!0}),Cb.render((0,m.jsx)(p.StrictMode,{children:(0,m.jsx)(sb,{core:n,host:e,initialLocale:globalThis.navigator.language.toLowerCase().startsWith(`zh`)?`zh-CN`:`en`,initialTheme:`system`,onForgetBrowser:()=>Sb(e),preferences:xb,surface:`web`})}))} \ No newline at end of file diff --git a/web/dist/assets/index-aed55189.js b/web/dist/assets/index-aed55189.js deleted file mode 100644 index c39436e..0000000 --- a/web/dist/assets/index-aed55189.js +++ /dev/null @@ -1,42 +0,0 @@ -(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const s of i.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function t(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=t(l);fetch(l.href,i)}})();function Uc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var wu={exports:{}},kl={},ju={exports:{}},F={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var gr=Symbol.for("react.element"),Hc=Symbol.for("react.portal"),Bc=Symbol.for("react.fragment"),Vc=Symbol.for("react.strict_mode"),Qc=Symbol.for("react.profiler"),Wc=Symbol.for("react.provider"),qc=Symbol.for("react.context"),Kc=Symbol.for("react.forward_ref"),Yc=Symbol.for("react.suspense"),Xc=Symbol.for("react.memo"),Gc=Symbol.for("react.lazy"),cs=Symbol.iterator;function Zc(e){return e===null||typeof e!="object"?null:(e=cs&&e[cs]||e["@@iterator"],typeof e=="function"?e:null)}var Su={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ku=Object.assign,Cu={};function Pt(e,n,t){this.props=e,this.context=n,this.refs=Cu,this.updater=t||Su}Pt.prototype.isReactComponent={};Pt.prototype.setState=function(e,n){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,n,"setState")};Pt.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Nu(){}Nu.prototype=Pt.prototype;function ao(e,n,t){this.props=e,this.context=n,this.refs=Cu,this.updater=t||Su}var co=ao.prototype=new Nu;co.constructor=ao;ku(co,Pt.prototype);co.isPureReactComponent=!0;var ds=Array.isArray,Eu=Object.prototype.hasOwnProperty,fo={current:null},Pu={key:!0,ref:!0,__self:!0,__source:!0};function _u(e,n,t){var r,l={},i=null,s=null;if(n!=null)for(r in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(i=""+n.key),n)Eu.call(n,r)&&!Pu.hasOwnProperty(r)&&(l[r]=n[r]);var u=arguments.length-2;if(u===1)l.children=t;else if(1>>1,q=k[A];if(0>>1;Al(Rt,T))enl(Zn,Rt)?(k[A]=Zn,k[en]=T,A=en):(k[A]=Rt,k[be]=T,A=be);else if(enl(Zn,T))k[A]=Zn,k[en]=T,A=en;else break e}}return R}function l(k,R){var T=k.sortIndex-R.sortIndex;return T!==0?T:k.id-R.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var s=Date,u=s.now();e.unstable_now=function(){return s.now()-u}}var a=[],d=[],v=1,m=null,h=3,x=!1,j=!1,S=!1,D=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function p(k){for(var R=t(d);R!==null;){if(R.callback===null)r(d);else if(R.startTime<=k)r(d),R.sortIndex=R.expirationTime,n(a,R);else break;R=t(d)}}function g(k){if(S=!1,p(k),!j)if(t(a)!==null)j=!0,M(C);else{var R=t(d);R!==null&&ie(g,R.startTime-k)}}function C(k,R){j=!1,S&&(S=!1,f(_),_=-1),x=!0;var T=h;try{for(p(R),m=t(a);m!==null&&(!(m.expirationTime>R)||k&&!G());){var A=m.callback;if(typeof A=="function"){m.callback=null,h=m.priorityLevel;var q=A(m.expirationTime<=R);R=e.unstable_now(),typeof q=="function"?m.callback=q:m===t(a)&&r(a),p(R)}else r(a);m=t(a)}if(m!==null)var pn=!0;else{var be=t(d);be!==null&&ie(g,be.startTime-R),pn=!1}return pn}finally{m=null,h=T,x=!1}}var y=!1,N=null,_=-1,I=5,z=-1;function G(){return!(e.unstable_now()-zk||125A?(k.sortIndex=T,n(d,k),t(a)===null&&k===t(d)&&(S?(f(_),_=-1):S=!0,ie(g,T-A))):(k.sortIndex=q,n(a,k),j||x||(j=!0,M(C))),k},e.unstable_shouldYield=G,e.unstable_wrapCallback=function(k){var R=h;return function(){var T=h;h=R;try{return k.apply(this,arguments)}finally{h=T}}}})(Mu);Tu.exports=Mu;var ud=Tu.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var ad=E,Ee=ud;function w(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,t=1;t"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),hi=Object.prototype.hasOwnProperty,cd=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ps={},hs={};function dd(e){return hi.call(hs,e)?!0:hi.call(ps,e)?!1:cd.test(e)?hs[e]=!0:(ps[e]=!0,!1)}function fd(e,n,t,r){if(t!==null&&t.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return r?!1:t!==null?!t.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function pd(e,n,t,r){if(n===null||typeof n>"u"||fd(e,n,t,r))return!0;if(r)return!1;if(t!==null)switch(t.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function ge(e,n,t,r,l,i,s){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=t,this.propertyName=e,this.type=n,this.sanitizeURL=i,this.removeEmptyString=s}var ae={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ae[e]=new ge(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];ae[n]=new ge(n,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ae[e]=new ge(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ae[e]=new ge(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ae[e]=new ge(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ae[e]=new ge(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ae[e]=new ge(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ae[e]=new ge(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ae[e]=new ge(e,5,!1,e.toLowerCase(),null,!1,!1)});var mo=/[\-:]([a-z])/g;function vo(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(mo,vo);ae[n]=new ge(n,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(mo,vo);ae[n]=new ge(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(mo,vo);ae[n]=new ge(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ae[e]=new ge(e,1,!1,e.toLowerCase(),null,!1,!1)});ae.xlinkHref=new ge("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ae[e]=new ge(e,1,!1,e.toLowerCase(),null,!0,!0)});function go(e,n,t,r){var l=ae.hasOwnProperty(n)?ae[n]:null;(l!==null?l.type!==0:r||!(2u||l[s]!==i[u]){var a=` -`+l[s].replace(" at new "," at ");return e.displayName&&a.includes("")&&(a=a.replace("",e.displayName)),a}while(1<=s&&0<=u);break}}}finally{Vl=!1,Error.prepareStackTrace=t}return(e=e?e.displayName||e.name:"")?At(e):""}function hd(e){switch(e.tag){case 5:return At(e.type);case 16:return At("Lazy");case 13:return At("Suspense");case 19:return At("SuspenseList");case 0:case 2:case 15:return e=Ql(e.type,!1),e;case 11:return e=Ql(e.type.render,!1),e;case 1:return e=Ql(e.type,!0),e;default:return""}}function yi(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case nt:return"Fragment";case et:return"Portal";case mi:return"Profiler";case yo:return"StrictMode";case vi:return"Suspense";case gi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Du:return(e.displayName||"Context")+".Consumer";case Ou:return(e._context.displayName||"Context")+".Provider";case xo:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case wo:return n=e.displayName||null,n!==null?n:yi(e.type)||"Memo";case mn:n=e._payload,e=e._init;try{return yi(e(n))}catch{}}return null}function md(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return yi(n);case 8:return n===yo?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function zn(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function $u(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function vd(e){var n=$u(e)?"checked":"value",t=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),r=""+e[n];if(!e.hasOwnProperty(n)&&typeof t<"u"&&typeof t.get=="function"&&typeof t.set=="function"){var l=t.get,i=t.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return l.call(this)},set:function(s){r=""+s,i.call(this,s)}}),Object.defineProperty(e,n,{enumerable:t.enumerable}),{getValue:function(){return r},setValue:function(s){r=""+s},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function Cr(e){e._valueTracker||(e._valueTracker=vd(e))}function Au(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var t=n.getValue(),r="";return e&&(r=$u(e)?e.checked?"true":"false":e.value),e=r,e!==t?(n.setValue(e),!0):!1}function Jr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function xi(e,n){var t=n.checked;return X({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:t??e._wrapperState.initialChecked})}function vs(e,n){var t=n.defaultValue==null?"":n.defaultValue,r=n.checked!=null?n.checked:n.defaultChecked;t=zn(n.value!=null?n.value:t),e._wrapperState={initialChecked:r,initialValue:t,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function Uu(e,n){n=n.checked,n!=null&&go(e,"checked",n,!1)}function wi(e,n){Uu(e,n);var t=zn(n.value),r=n.type;if(t!=null)r==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+t):e.value!==""+t&&(e.value=""+t);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?ji(e,n.type,t):n.hasOwnProperty("defaultValue")&&ji(e,n.type,zn(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function gs(e,n,t){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var r=n.type;if(!(r!=="submit"&&r!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,t||n===e.value||(e.value=n),e.defaultValue=n}t=e.name,t!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,t!==""&&(e.name=t)}function ji(e,n,t){(n!=="number"||Jr(e.ownerDocument)!==e)&&(t==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+t&&(e.defaultValue=""+t))}var Ut=Array.isArray;function ft(e,n,t,r){if(e=e.options,n){n={};for(var l=0;l"+n.valueOf().toString()+"",n=Nr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function er(e,n){if(n){var t=e.firstChild;if(t&&t===e.lastChild&&t.nodeType===3){t.nodeValue=n;return}}e.textContent=n}var Vt={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},gd=["Webkit","ms","Moz","O"];Object.keys(Vt).forEach(function(e){gd.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),Vt[n]=Vt[e]})});function Qu(e,n,t){return n==null||typeof n=="boolean"||n===""?"":t||typeof n!="number"||n===0||Vt.hasOwnProperty(e)&&Vt[e]?(""+n).trim():n+"px"}function Wu(e,n){e=e.style;for(var t in n)if(n.hasOwnProperty(t)){var r=t.indexOf("--")===0,l=Qu(t,n[t],r);t==="float"&&(t="cssFloat"),r?e.setProperty(t,l):e[t]=l}}var yd=X({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Ci(e,n){if(n){if(yd[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(w(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(w(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(w(61))}if(n.style!=null&&typeof n.style!="object")throw Error(w(62))}}function Ni(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Ei=null;function jo(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Pi=null,pt=null,ht=null;function ws(e){if(e=wr(e)){if(typeof Pi!="function")throw Error(w(280));var n=e.stateNode;n&&(n=_l(n),Pi(e.stateNode,e.type,n))}}function qu(e){pt?ht?ht.push(e):ht=[e]:pt=e}function Ku(){if(pt){var e=pt,n=ht;if(ht=pt=null,ws(e),n)for(e=0;e>>=0,e===0?32:31-(zd(e)/Rd|0)|0}var Er=64,Pr=4194304;function Ht(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function tl(e,n){var t=e.pendingLanes;if(t===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,s=t&268435455;if(s!==0){var u=s&~l;u!==0?r=Ht(u):(i&=s,i!==0&&(r=Ht(i)))}else s=t&~l,s!==0?r=Ht(s):i!==0&&(r=Ht(i));if(r===0)return 0;if(n!==0&&n!==r&&!(n&l)&&(l=r&-r,i=n&-n,l>=i||l===16&&(i&4194240)!==0))return n;if(r&4&&(r|=t&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=r;0t;t++)n.push(e);return n}function yr(e,n,t){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Ue(n),e[n]=t}function Id(e,n){var t=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Wt),zs=String.fromCharCode(32),Rs=!1;function pa(e,n){switch(e){case"keyup":return af.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function ha(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var tt=!1;function df(e,n){switch(e){case"compositionend":return ha(n);case"keypress":return n.which!==32?null:(Rs=!0,zs);case"textInput":return e=n.data,e===zs&&Rs?null:e;default:return null}}function ff(e,n){if(tt)return e==="compositionend"||!zo&&pa(e,n)?(e=da(),Vr=Eo=xn=null,tt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:t,offset:n-e};e=r}e:{for(;t;){if(t.nextSibling){t=t.nextSibling;break e}t=t.parentNode}t=void 0}t=Is(t)}}function ya(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?ya(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function xa(){for(var e=window,n=Jr();n instanceof e.HTMLIFrameElement;){try{var t=typeof n.contentWindow.location.href=="string"}catch{t=!1}if(t)e=n.contentWindow;else break;n=Jr(e.document)}return n}function Ro(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function jf(e){var n=xa(),t=e.focusedElem,r=e.selectionRange;if(n!==t&&t&&t.ownerDocument&&ya(t.ownerDocument.documentElement,t)){if(r!==null&&Ro(t)){if(n=r.start,e=r.end,e===void 0&&(e=n),"selectionStart"in t)t.selectionStart=n,t.selectionEnd=Math.min(e,t.value.length);else if(e=(n=t.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var l=t.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Os(t,i);var s=Os(t,r);l&&s&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==s.node||e.focusOffset!==s.offset)&&(n=n.createRange(),n.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(n),e.extend(s.node,s.offset)):(n.setEnd(s.node,s.offset),e.addRange(n)))}}for(n=[],e=t;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof t.focus=="function"&&t.focus(),t=0;t=document.documentMode,rt=null,Mi=null,Kt=null,Ii=!1;function Ds(e,n,t){var r=t.window===t?t.document:t.nodeType===9?t:t.ownerDocument;Ii||rt==null||rt!==Jr(r)||(r=rt,"selectionStart"in r&&Ro(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Kt&&or(Kt,r)||(Kt=r,r=il(Mi,"onSelect"),0ot||(e.current=Ui[ot],Ui[ot]=null,ot--)}function H(e,n){ot++,Ui[ot]=e.current,e.current=n}var Rn={},pe=Tn(Rn),we=Tn(!1),Bn=Rn;function wt(e,n){var t=e.type.contextTypes;if(!t)return Rn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===n)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in t)l[i]=n[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=l),l}function je(e){return e=e.childContextTypes,e!=null}function sl(){V(we),V(pe)}function Vs(e,n,t){if(pe.current!==Rn)throw Error(w(168));H(pe,n),H(we,t)}function _a(e,n,t){var r=e.stateNode;if(n=n.childContextTypes,typeof r.getChildContext!="function")return t;r=r.getChildContext();for(var l in r)if(!(l in n))throw Error(w(108,md(e)||"Unknown",l));return X({},t,r)}function ul(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Rn,Bn=pe.current,H(pe,e),H(we,we.current),!0}function Qs(e,n,t){var r=e.stateNode;if(!r)throw Error(w(169));t?(e=_a(e,n,Bn),r.__reactInternalMemoizedMergedChildContext=e,V(we),V(pe),H(pe,e)):V(we),H(we,t)}var tn=null,zl=!1,li=!1;function za(e){tn===null?tn=[e]:tn.push(e)}function Mf(e){zl=!0,za(e)}function Mn(){if(!li&&tn!==null){li=!0;var e=0,n=U;try{var t=tn;for(U=1;e>=s,l-=s,rn=1<<32-Ue(n)+l|t<_?(I=N,N=null):I=N.sibling;var z=h(f,N,p[_],g);if(z===null){N===null&&(N=I);break}e&&N&&z.alternate===null&&n(f,N),c=i(z,c,_),y===null?C=z:y.sibling=z,y=z,N=I}if(_===p.length)return t(f,N),W&&In(f,_),C;if(N===null){for(;__?(I=N,N=null):I=N.sibling;var G=h(f,N,z.value,g);if(G===null){N===null&&(N=I);break}e&&N&&G.alternate===null&&n(f,N),c=i(G,c,_),y===null?C=G:y.sibling=G,y=G,N=I}if(z.done)return t(f,N),W&&In(f,_),C;if(N===null){for(;!z.done;_++,z=p.next())z=m(f,z.value,g),z!==null&&(c=i(z,c,_),y===null?C=z:y.sibling=z,y=z);return W&&In(f,_),C}for(N=r(f,N);!z.done;_++,z=p.next())z=x(N,f,_,z.value,g),z!==null&&(e&&z.alternate!==null&&N.delete(z.key===null?_:z.key),c=i(z,c,_),y===null?C=z:y.sibling=z,y=z);return e&&N.forEach(function(We){return n(f,We)}),W&&In(f,_),C}function D(f,c,p,g){if(typeof p=="object"&&p!==null&&p.type===nt&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case kr:e:{for(var C=p.key,y=c;y!==null;){if(y.key===C){if(C=p.type,C===nt){if(y.tag===7){t(f,y.sibling),c=l(y,p.props.children),c.return=f,f=c;break e}}else if(y.elementType===C||typeof C=="object"&&C!==null&&C.$$typeof===mn&&Ks(C)===y.type){t(f,y.sibling),c=l(y,p.props),c.ref=Dt(f,y,p),c.return=f,f=c;break e}t(f,y);break}else n(f,y);y=y.sibling}p.type===nt?(c=Hn(p.props.children,f.mode,g,p.key),c.return=f,f=c):(g=Zr(p.type,p.key,p.props,null,f.mode,g),g.ref=Dt(f,c,p),g.return=f,f=g)}return s(f);case et:e:{for(y=p.key;c!==null;){if(c.key===y)if(c.tag===4&&c.stateNode.containerInfo===p.containerInfo&&c.stateNode.implementation===p.implementation){t(f,c.sibling),c=l(c,p.children||[]),c.return=f,f=c;break e}else{t(f,c);break}else n(f,c);c=c.sibling}c=fi(p,f.mode,g),c.return=f,f=c}return s(f);case mn:return y=p._init,D(f,c,y(p._payload),g)}if(Ut(p))return j(f,c,p,g);if(Lt(p))return S(f,c,p,g);Ir(f,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,c!==null&&c.tag===6?(t(f,c.sibling),c=l(c,p),c.return=f,f=c):(t(f,c),c=di(p,f.mode,g),c.return=f,f=c),s(f)):t(f,c)}return D}var St=Ma(!0),Ia=Ma(!1),dl=Tn(null),fl=null,at=null,Io=null;function Oo(){Io=at=fl=null}function Do(e){var n=dl.current;V(dl),e._currentValue=n}function Vi(e,n,t){for(;e!==null;){var r=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,r!==null&&(r.childLanes|=n)):r!==null&&(r.childLanes&n)!==n&&(r.childLanes|=n),e===t)break;e=e.return}}function vt(e,n){fl=e,Io=at=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&n&&(xe=!0),e.firstContext=null)}function Me(e){var n=e._currentValue;if(Io!==e)if(e={context:e,memoizedValue:n,next:null},at===null){if(fl===null)throw Error(w(308));at=e,fl.dependencies={lanes:0,firstContext:e}}else at=at.next=e;return n}var Fn=null;function Fo(e){Fn===null?Fn=[e]:Fn.push(e)}function Oa(e,n,t,r){var l=n.interleaved;return l===null?(t.next=t,Fo(n)):(t.next=l.next,l.next=t),n.interleaved=t,an(e,r)}function an(e,n){e.lanes|=n;var t=e.alternate;for(t!==null&&(t.lanes|=n),t=e,e=e.return;e!==null;)e.childLanes|=n,t=e.alternate,t!==null&&(t.childLanes|=n),t=e,e=e.return;return t.tag===3?t.stateNode:null}var vn=!1;function $o(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Da(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function on(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function Nn(e,n,t){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,$&2){var l=r.pending;return l===null?n.next=n:(n.next=l.next,l.next=n),r.pending=n,an(e,t)}return l=r.interleaved,l===null?(n.next=n,Fo(r)):(n.next=l.next,l.next=n),r.interleaved=n,an(e,t)}function Wr(e,n,t){if(n=n.updateQueue,n!==null&&(n=n.shared,(t&4194240)!==0)){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,ko(e,t)}}function Ys(e,n){var t=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,t===r)){var l=null,i=null;if(t=t.firstBaseUpdate,t!==null){do{var s={eventTime:t.eventTime,lane:t.lane,tag:t.tag,payload:t.payload,callback:t.callback,next:null};i===null?l=i=s:i=i.next=s,t=t.next}while(t!==null);i===null?l=i=n:i=i.next=n}else l=i=n;t={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=t;return}e=t.lastBaseUpdate,e===null?t.firstBaseUpdate=n:e.next=n,t.lastBaseUpdate=n}function pl(e,n,t,r){var l=e.updateQueue;vn=!1;var i=l.firstBaseUpdate,s=l.lastBaseUpdate,u=l.shared.pending;if(u!==null){l.shared.pending=null;var a=u,d=a.next;a.next=null,s===null?i=d:s.next=d,s=a;var v=e.alternate;v!==null&&(v=v.updateQueue,u=v.lastBaseUpdate,u!==s&&(u===null?v.firstBaseUpdate=d:u.next=d,v.lastBaseUpdate=a))}if(i!==null){var m=l.baseState;s=0,v=d=a=null,u=i;do{var h=u.lane,x=u.eventTime;if((r&h)===h){v!==null&&(v=v.next={eventTime:x,lane:0,tag:u.tag,payload:u.payload,callback:u.callback,next:null});e:{var j=e,S=u;switch(h=n,x=t,S.tag){case 1:if(j=S.payload,typeof j=="function"){m=j.call(x,m,h);break e}m=j;break e;case 3:j.flags=j.flags&-65537|128;case 0:if(j=S.payload,h=typeof j=="function"?j.call(x,m,h):j,h==null)break e;m=X({},m,h);break e;case 2:vn=!0}}u.callback!==null&&u.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[u]:h.push(u))}else x={eventTime:x,lane:h,tag:u.tag,payload:u.payload,callback:u.callback,next:null},v===null?(d=v=x,a=m):v=v.next=x,s|=h;if(u=u.next,u===null){if(u=l.shared.pending,u===null)break;h=u,u=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(1);if(v===null&&(a=m),l.baseState=a,l.firstBaseUpdate=d,l.lastBaseUpdate=v,n=l.shared.interleaved,n!==null){l=n;do s|=l.lane,l=l.next;while(l!==n)}else i===null&&(l.shared.lanes=0);Wn|=s,e.lanes=s,e.memoizedState=m}}function Xs(e,n,t){if(e=n.effects,n.effects=null,e!==null)for(n=0;nt?t:4,e(!0);var r=oi.transition;oi.transition={};try{e(!1),n()}finally{U=t,oi.transition=r}}function ba(){return Ie().memoizedState}function Ff(e,n,t){var r=Pn(e);if(t={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null},ec(e))nc(n,t);else if(t=Oa(e,n,t,r),t!==null){var l=me();He(t,e,r,l),tc(t,n,r)}}function $f(e,n,t){var r=Pn(e),l={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null};if(ec(e))nc(n,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=n.lastRenderedReducer,i!==null))try{var s=n.lastRenderedState,u=i(s,t);if(l.hasEagerState=!0,l.eagerState=u,Ve(u,s)){var a=n.interleaved;a===null?(l.next=l,Fo(n)):(l.next=a.next,a.next=l),n.interleaved=l;return}}catch{}finally{}t=Oa(e,n,l,r),t!==null&&(l=me(),He(t,e,r,l),tc(t,n,r))}}function ec(e){var n=e.alternate;return e===Y||n!==null&&n===Y}function nc(e,n){Yt=ml=!0;var t=e.pending;t===null?n.next=n:(n.next=t.next,t.next=n),e.pending=n}function tc(e,n,t){if(t&4194240){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,ko(e,t)}}var vl={readContext:Me,useCallback:ce,useContext:ce,useEffect:ce,useImperativeHandle:ce,useInsertionEffect:ce,useLayoutEffect:ce,useMemo:ce,useReducer:ce,useRef:ce,useState:ce,useDebugValue:ce,useDeferredValue:ce,useTransition:ce,useMutableSource:ce,useSyncExternalStore:ce,useId:ce,unstable_isNewReconciler:!1},Af={readContext:Me,useCallback:function(e,n){return Ke().memoizedState=[e,n===void 0?null:n],e},useContext:Me,useEffect:Zs,useImperativeHandle:function(e,n,t){return t=t!=null?t.concat([e]):null,Kr(4194308,4,Ya.bind(null,n,e),t)},useLayoutEffect:function(e,n){return Kr(4194308,4,e,n)},useInsertionEffect:function(e,n){return Kr(4,2,e,n)},useMemo:function(e,n){var t=Ke();return n=n===void 0?null:n,e=e(),t.memoizedState=[e,n],e},useReducer:function(e,n,t){var r=Ke();return n=t!==void 0?t(n):n,r.memoizedState=r.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},r.queue=e,e=e.dispatch=Ff.bind(null,Y,e),[r.memoizedState,e]},useRef:function(e){var n=Ke();return e={current:e},n.memoizedState=e},useState:Gs,useDebugValue:qo,useDeferredValue:function(e){return Ke().memoizedState=e},useTransition:function(){var e=Gs(!1),n=e[0];return e=Df.bind(null,e[1]),Ke().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,t){var r=Y,l=Ke();if(W){if(t===void 0)throw Error(w(407));t=t()}else{if(t=n(),le===null)throw Error(w(349));Qn&30||Ua(r,n,t)}l.memoizedState=t;var i={value:t,getSnapshot:n};return l.queue=i,Zs(Ba.bind(null,r,i,e),[e]),r.flags|=2048,hr(9,Ha.bind(null,r,i,t,n),void 0,null),t},useId:function(){var e=Ke(),n=le.identifierPrefix;if(W){var t=ln,r=rn;t=(r&~(1<<32-Ue(r)-1)).toString(32)+t,n=":"+n+"R"+t,t=fr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=s.createElement(t,{is:r.is}):(e=s.createElement(t),t==="select"&&(s=e,r.multiple?s.multiple=!0:r.size&&(s.size=r.size))):e=s.createElementNS(e,t),e[Ye]=n,e[ar]=r,fc(e,n,!1,!1),n.stateNode=e;e:{switch(s=Ni(t,r),t){case"dialog":B("cancel",e),B("close",e),l=r;break;case"iframe":case"object":case"embed":B("load",e),l=r;break;case"video":case"audio":for(l=0;lNt&&(n.flags|=128,r=!0,Ft(i,!1),n.lanes=4194304)}else{if(!r)if(e=hl(s),e!==null){if(n.flags|=128,r=!0,t=e.updateQueue,t!==null&&(n.updateQueue=t,n.flags|=4),Ft(i,!0),i.tail===null&&i.tailMode==="hidden"&&!s.alternate&&!W)return de(n),null}else 2*J()-i.renderingStartTime>Nt&&t!==1073741824&&(n.flags|=128,r=!0,Ft(i,!1),n.lanes=4194304);i.isBackwards?(s.sibling=n.child,n.child=s):(t=i.last,t!==null?t.sibling=s:n.child=s,i.last=s)}return i.tail!==null?(n=i.tail,i.rendering=n,i.tail=n.sibling,i.renderingStartTime=J(),n.sibling=null,t=K.current,H(K,r?t&1|2:t&1),n):(de(n),null);case 22:case 23:return Jo(),r=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(n.flags|=8192),r&&n.mode&1?ke&1073741824&&(de(n),n.subtreeFlags&6&&(n.flags|=8192)):de(n),null;case 24:return null;case 25:return null}throw Error(w(156,n.tag))}function Kf(e,n){switch(To(n),n.tag){case 1:return je(n.type)&&sl(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return kt(),V(we),V(pe),Ho(),e=n.flags,e&65536&&!(e&128)?(n.flags=e&-65537|128,n):null;case 5:return Uo(n),null;case 13:if(V(K),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(w(340));jt()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return V(K),null;case 4:return kt(),null;case 10:return Do(n.type._context),null;case 22:case 23:return Jo(),null;case 24:return null;default:return null}}var Dr=!1,fe=!1,Yf=typeof WeakSet=="function"?WeakSet:Set,P=null;function ct(e,n){var t=e.ref;if(t!==null)if(typeof t=="function")try{t(null)}catch(r){Z(e,n,r)}else t.current=null}function Ji(e,n,t){try{t()}catch(r){Z(e,n,r)}}var uu=!1;function Xf(e,n){if(Oi=rl,e=xa(),Ro(e)){if("selectionStart"in e)var t={start:e.selectionStart,end:e.selectionEnd};else e:{t=(t=e.ownerDocument)&&t.defaultView||window;var r=t.getSelection&&t.getSelection();if(r&&r.rangeCount!==0){t=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{t.nodeType,i.nodeType}catch{t=null;break e}var s=0,u=-1,a=-1,d=0,v=0,m=e,h=null;n:for(;;){for(var x;m!==t||l!==0&&m.nodeType!==3||(u=s+l),m!==i||r!==0&&m.nodeType!==3||(a=s+r),m.nodeType===3&&(s+=m.nodeValue.length),(x=m.firstChild)!==null;)h=m,m=x;for(;;){if(m===e)break n;if(h===t&&++d===l&&(u=s),h===i&&++v===r&&(a=s),(x=m.nextSibling)!==null)break;m=h,h=m.parentNode}m=x}t=u===-1||a===-1?null:{start:u,end:a}}else t=null}t=t||{start:0,end:0}}else t=null;for(Di={focusedElem:e,selectionRange:t},rl=!1,P=n;P!==null;)if(n=P,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,P=e;else for(;P!==null;){n=P;try{var j=n.alternate;if(n.flags&1024)switch(n.tag){case 0:case 11:case 15:break;case 1:if(j!==null){var S=j.memoizedProps,D=j.memoizedState,f=n.stateNode,c=f.getSnapshotBeforeUpdate(n.elementType===n.type?S:De(n.type,S),D);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var p=n.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(w(163))}}catch(g){Z(n,n.return,g)}if(e=n.sibling,e!==null){e.return=n.return,P=e;break}P=n.return}return j=uu,uu=!1,j}function Xt(e,n,t){var r=n.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Ji(n,t,i)}l=l.next}while(l!==r)}}function Tl(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var t=n=n.next;do{if((t.tag&e)===e){var r=t.create;t.destroy=r()}t=t.next}while(t!==n)}}function bi(e){var n=e.ref;if(n!==null){var t=e.stateNode;switch(e.tag){case 5:e=t;break;default:e=t}typeof n=="function"?n(e):n.current=e}}function mc(e){var n=e.alternate;n!==null&&(e.alternate=null,mc(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[Ye],delete n[ar],delete n[Ai],delete n[Lf],delete n[Tf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function vc(e){return e.tag===5||e.tag===3||e.tag===4}function au(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||vc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function eo(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.nodeType===8?t.parentNode.insertBefore(e,n):t.insertBefore(e,n):(t.nodeType===8?(n=t.parentNode,n.insertBefore(e,t)):(n=t,n.appendChild(e)),t=t._reactRootContainer,t!=null||n.onclick!==null||(n.onclick=ol));else if(r!==4&&(e=e.child,e!==null))for(eo(e,n,t),e=e.sibling;e!==null;)eo(e,n,t),e=e.sibling}function no(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.insertBefore(e,n):t.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(no(e,n,t),e=e.sibling;e!==null;)no(e,n,t),e=e.sibling}var se=null,$e=!1;function hn(e,n,t){for(t=t.child;t!==null;)gc(e,n,t),t=t.sibling}function gc(e,n,t){if(Xe&&typeof Xe.onCommitFiberUnmount=="function")try{Xe.onCommitFiberUnmount(Cl,t)}catch{}switch(t.tag){case 5:fe||ct(t,n);case 6:var r=se,l=$e;se=null,hn(e,n,t),se=r,$e=l,se!==null&&($e?(e=se,t=t.stateNode,e.nodeType===8?e.parentNode.removeChild(t):e.removeChild(t)):se.removeChild(t.stateNode));break;case 18:se!==null&&($e?(e=se,t=t.stateNode,e.nodeType===8?ri(e.parentNode,t):e.nodeType===1&&ri(e,t),lr(e)):ri(se,t.stateNode));break;case 4:r=se,l=$e,se=t.stateNode.containerInfo,$e=!0,hn(e,n,t),se=r,$e=l;break;case 0:case 11:case 14:case 15:if(!fe&&(r=t.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,s=i.destroy;i=i.tag,s!==void 0&&(i&2||i&4)&&Ji(t,n,s),l=l.next}while(l!==r)}hn(e,n,t);break;case 1:if(!fe&&(ct(t,n),r=t.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=t.memoizedProps,r.state=t.memoizedState,r.componentWillUnmount()}catch(u){Z(t,n,u)}hn(e,n,t);break;case 21:hn(e,n,t);break;case 22:t.mode&1?(fe=(r=fe)||t.memoizedState!==null,hn(e,n,t),fe=r):hn(e,n,t);break;default:hn(e,n,t)}}function cu(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var t=e.stateNode;t===null&&(t=e.stateNode=new Yf),n.forEach(function(r){var l=lp.bind(null,e,r);t.has(r)||(t.add(r),r.then(l,l))})}}function Oe(e,n){var t=n.deletions;if(t!==null)for(var r=0;rl&&(l=s),r&=~i}if(r=l,r=J()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Zf(r/1960))-r,10e?16:e,wn===null)var r=!1;else{if(e=wn,wn=null,xl=0,$&6)throw Error(w(331));var l=$;for($|=4,P=e.current;P!==null;){var i=P,s=i.child;if(P.flags&16){var u=i.deletions;if(u!==null){for(var a=0;aJ()-Go?Un(e,0):Xo|=t),Se(e,n)}function Nc(e,n){n===0&&(e.mode&1?(n=Pr,Pr<<=1,!(Pr&130023424)&&(Pr=4194304)):n=1);var t=me();e=an(e,n),e!==null&&(yr(e,n,t),Se(e,t))}function rp(e){var n=e.memoizedState,t=0;n!==null&&(t=n.retryLane),Nc(e,t)}function lp(e,n){var t=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(t=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(w(314))}r!==null&&r.delete(n),Nc(e,t)}var Ec;Ec=function(e,n,t){if(e!==null)if(e.memoizedProps!==n.pendingProps||we.current)xe=!0;else{if(!(e.lanes&t)&&!(n.flags&128))return xe=!1,Wf(e,n,t);xe=!!(e.flags&131072)}else xe=!1,W&&n.flags&1048576&&Ra(n,cl,n.index);switch(n.lanes=0,n.tag){case 2:var r=n.type;Yr(e,n),e=n.pendingProps;var l=wt(n,pe.current);vt(n,t),l=Vo(null,n,r,e,l,t);var i=Qo();return n.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,je(r)?(i=!0,ul(n)):i=!1,n.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,$o(n),l.updater=Ll,n.stateNode=l,l._reactInternals=n,Wi(n,r,e,t),n=Yi(null,n,r,!0,i,t)):(n.tag=0,W&&i&&Lo(n),he(null,n,l,t),n=n.child),n;case 16:r=n.elementType;e:{switch(Yr(e,n),e=n.pendingProps,l=r._init,r=l(r._payload),n.type=r,l=n.tag=op(r),e=De(r,e),l){case 0:n=Ki(null,n,r,e,t);break e;case 1:n=iu(null,n,r,e,t);break e;case 11:n=ru(null,n,r,e,t);break e;case 14:n=lu(null,n,r,De(r.type,e),t);break e}throw Error(w(306,r,""))}return n;case 0:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:De(r,l),Ki(e,n,r,l,t);case 1:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:De(r,l),iu(e,n,r,l,t);case 3:e:{if(ac(n),e===null)throw Error(w(387));r=n.pendingProps,i=n.memoizedState,l=i.element,Da(e,n),pl(n,r,null,t);var s=n.memoizedState;if(r=s.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},n.updateQueue.baseState=i,n.memoizedState=i,n.flags&256){l=Ct(Error(w(423)),n),n=ou(e,n,r,t,l);break e}else if(r!==l){l=Ct(Error(w(424)),n),n=ou(e,n,r,t,l);break e}else for(Ce=Cn(n.stateNode.containerInfo.firstChild),Ne=n,W=!0,Ae=null,t=Ia(n,null,r,t),n.child=t;t;)t.flags=t.flags&-3|4096,t=t.sibling;else{if(jt(),r===l){n=cn(e,n,t);break e}he(e,n,r,t)}n=n.child}return n;case 5:return Fa(n),e===null&&Bi(n),r=n.type,l=n.pendingProps,i=e!==null?e.memoizedProps:null,s=l.children,Fi(r,l)?s=null:i!==null&&Fi(r,i)&&(n.flags|=32),uc(e,n),he(e,n,s,t),n.child;case 6:return e===null&&Bi(n),null;case 13:return cc(e,n,t);case 4:return Ao(n,n.stateNode.containerInfo),r=n.pendingProps,e===null?n.child=St(n,null,r,t):he(e,n,r,t),n.child;case 11:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:De(r,l),ru(e,n,r,l,t);case 7:return he(e,n,n.pendingProps,t),n.child;case 8:return he(e,n,n.pendingProps.children,t),n.child;case 12:return he(e,n,n.pendingProps.children,t),n.child;case 10:e:{if(r=n.type._context,l=n.pendingProps,i=n.memoizedProps,s=l.value,H(dl,r._currentValue),r._currentValue=s,i!==null)if(Ve(i.value,s)){if(i.children===l.children&&!we.current){n=cn(e,n,t);break e}}else for(i=n.child,i!==null&&(i.return=n);i!==null;){var u=i.dependencies;if(u!==null){s=i.child;for(var a=u.firstContext;a!==null;){if(a.context===r){if(i.tag===1){a=on(-1,t&-t),a.tag=2;var d=i.updateQueue;if(d!==null){d=d.shared;var v=d.pending;v===null?a.next=a:(a.next=v.next,v.next=a),d.pending=a}}i.lanes|=t,a=i.alternate,a!==null&&(a.lanes|=t),Vi(i.return,t,n),u.lanes|=t;break}a=a.next}}else if(i.tag===10)s=i.type===n.type?null:i.child;else if(i.tag===18){if(s=i.return,s===null)throw Error(w(341));s.lanes|=t,u=s.alternate,u!==null&&(u.lanes|=t),Vi(s,t,n),s=i.sibling}else s=i.child;if(s!==null)s.return=i;else for(s=i;s!==null;){if(s===n){s=null;break}if(i=s.sibling,i!==null){i.return=s.return,s=i;break}s=s.return}i=s}he(e,n,l.children,t),n=n.child}return n;case 9:return l=n.type,r=n.pendingProps.children,vt(n,t),l=Me(l),r=r(l),n.flags|=1,he(e,n,r,t),n.child;case 14:return r=n.type,l=De(r,n.pendingProps),l=De(r.type,l),lu(e,n,r,l,t);case 15:return oc(e,n,n.type,n.pendingProps,t);case 17:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:De(r,l),Yr(e,n),n.tag=1,je(r)?(e=!0,ul(n)):e=!1,vt(n,t),rc(n,r,l),Wi(n,r,l,t),Yi(null,n,r,!0,e,t);case 19:return dc(e,n,t);case 22:return sc(e,n,t)}throw Error(w(156,n.tag))};function Pc(e,n){return ea(e,n)}function ip(e,n,t,r){this.tag=e,this.key=t,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Le(e,n,t,r){return new ip(e,n,t,r)}function es(e){return e=e.prototype,!(!e||!e.isReactComponent)}function op(e){if(typeof e=="function")return es(e)?1:0;if(e!=null){if(e=e.$$typeof,e===xo)return 11;if(e===wo)return 14}return 2}function _n(e,n){var t=e.alternate;return t===null?(t=Le(e.tag,n,e.key,e.mode),t.elementType=e.elementType,t.type=e.type,t.stateNode=e.stateNode,t.alternate=e,e.alternate=t):(t.pendingProps=n,t.type=e.type,t.flags=0,t.subtreeFlags=0,t.deletions=null),t.flags=e.flags&14680064,t.childLanes=e.childLanes,t.lanes=e.lanes,t.child=e.child,t.memoizedProps=e.memoizedProps,t.memoizedState=e.memoizedState,t.updateQueue=e.updateQueue,n=e.dependencies,t.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},t.sibling=e.sibling,t.index=e.index,t.ref=e.ref,t}function Zr(e,n,t,r,l,i){var s=2;if(r=e,typeof e=="function")es(e)&&(s=1);else if(typeof e=="string")s=5;else e:switch(e){case nt:return Hn(t.children,l,i,n);case yo:s=8,l|=8;break;case mi:return e=Le(12,t,n,l|2),e.elementType=mi,e.lanes=i,e;case vi:return e=Le(13,t,n,l),e.elementType=vi,e.lanes=i,e;case gi:return e=Le(19,t,n,l),e.elementType=gi,e.lanes=i,e;case Fu:return Il(t,l,i,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Ou:s=10;break e;case Du:s=9;break e;case xo:s=11;break e;case wo:s=14;break e;case mn:s=16,r=null;break e}throw Error(w(130,e==null?e:typeof e,""))}return n=Le(s,t,n,l),n.elementType=e,n.type=r,n.lanes=i,n}function Hn(e,n,t,r){return e=Le(7,e,r,n),e.lanes=t,e}function Il(e,n,t,r){return e=Le(22,e,r,n),e.elementType=Fu,e.lanes=t,e.stateNode={isHidden:!1},e}function di(e,n,t){return e=Le(6,e,null,n),e.lanes=t,e}function fi(e,n,t){return n=Le(4,e.children!==null?e.children:[],e.key,n),n.lanes=t,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function sp(e,n,t,r,l){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ql(0),this.expirationTimes=ql(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ql(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function ns(e,n,t,r,l,i,s,u,a){return e=new sp(e,n,t,u,a),n===1?(n=1,i===!0&&(n|=8)):n=0,i=Le(3,null,null,n),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:t,cache:null,transitions:null,pendingSuspenseBoundaries:null},$o(i),e}function up(e,n,t){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Lc)}catch(e){console.error(e)}}Lc(),Lu.exports=Pe;var pp=Lu.exports,Tc,yu=pp;Tc=yu.createRoot,yu.hydrateRoot;const Al="cps.web.deviceCredential";class Sl extends Error{constructor(n="此浏览器需要重新配对。请重新运行 codex-provider web。"){super(n),this.name="PairingRequiredError"}}class bn extends Error{constructor(n,t,r){super(t||"配置已变更,请重新确认。"),this.name="ProfileRevisionError",this.code=n,this.profile=r}}function hp(){return!!window.localStorage.getItem(Al)}async function mp(){const n=new URLSearchParams(window.location.hash.replace(/^#/,"")).get("pair");if(!n)return hp();const t=await fetch("/api/pair",{method:"POST",headers:{"X-Codex-Provider-Pairing":n}}),r=await t.json().catch(()=>({}));if(!t.ok||!r.deviceCredential)throw new Sl(r.error??"配对链接无效、已过期或已被使用。请重新运行 codex-provider web。");return window.localStorage.setItem(Al,r.deviceCredential),window.history.replaceState(null,"",`${window.location.pathname}${window.location.search}`),!0}function is(){return{"X-Codex-Provider-Device":window.localStorage.getItem(Al)??""}}function vp(e,n,t){return n===409&&["PROFILE_REVISION_REQUIRED","PROFILE_CHANGED","STORAGE_REVISION_REQUIRED","STORAGE_CHANGED"].includes(e.code)?new bn(e.code,e.error,e.profile):new Error(e.error??t)}async function os(e,n){const t=await e.json().catch(()=>({}));if(!e.ok)throw t.code==="PAIRING_REQUIRED"?(window.dispatchEvent(new CustomEvent("cps:pairing-required",{detail:t.error})),new Sl(t.error)):vp(t,e.status,n);return t}async function Fe(e,n={},{signal:t}={}){const r=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json",...is()},body:JSON.stringify(n),signal:t});return os(r,`Request failed with HTTP ${r.status}.`)}async function gp(e=0){const n=await fetch(`/api/activity?after=${e}`,{headers:is()});return os(n,`Activity request failed with HTTP ${n.status}.`)}async function yp(){const e=await fetch("/api/profiles",{headers:is()});return os(e,`Profile request failed with HTTP ${e.status}.`)}async function xp(){try{await Fe("/api/access/forget")}finally{window.localStorage.removeItem(Al)}}async function wp(e={},n={}){return Fe("/api/history",e,n)}async function jp(e={},n={}){return Fe("/api/history/session",e,n)}function An(e,n){const[t,r]=E.useState(()=>{try{const l=window.localStorage.getItem(e);return l?JSON.parse(l):n}catch{return n}});return E.useEffect(()=>{window.localStorage.setItem(e,JSON.stringify(t))},[e,t]),[t,r]}function oo(){let e=0,n=null;return{begin(){return n==null||n.abort(),n=new AbortController,e+=1,{sequence:e,controller:n,signal:n.signal}},isLatest(t){return t===e},cancel(){n==null||n.abort(),e+=1}}}function Sp(e,n=300,t=globalThis){const r=t.setTimeout(e,n);return()=>t.clearTimeout(r)}function kp(e,n,t){return!(e!=null&&e.id)||!(e!=null&&e.revision)||(t==null?void 0:t.profileId)!==e.id||(t==null?void 0:t.profileRevision)!==e.revision||!(t!=null&&t.storageRevision)?null:{...n,profile:{...e},profileId:e.id,profileRevision:e.revision,storageRevision:t.storageRevision,status:{...t}}}function Cp(e){var t;const n=((t=e==null?void 0:e.result)==null?void 0:t.skippedLockedRolloutFiles)??(e==null?void 0:e.skippedLockedRolloutFiles)??[];return Array.isArray(n)?n.filter(Boolean):[]}function pi(e,{successTitle:n,partialTitle:t,message:r}){var u;const l=Cp(e);if(!(((u=e==null?void 0:e.result)==null?void 0:u.outcome)==="partial"||(e==null?void 0:e.outcome)==="partial"||l.length>0))return{tone:"success",title:n,message:r};const s=l.length?`已跳过 ${l.length} 个被占用的 rollout 文件:${l.join("、")}`:"部分项目未完成;请查看活动日志后重试。";return{tone:"warning",title:t,message:[r,s].filter(Boolean).join(";")}}function Np(e,n){const t=String(e??"").replaceAll("\\","/").replace(/\/{2,}/g,"/").replace(/\/$/,"");return n==="win32"?t.toLowerCase():t}function Ep(e=[],{platform:n=typeof process<"u"?process.platform:"browser"}={}){const t=new Set;return e.filter((r,l)=>{const i=(r==null?void 0:r.threadId)??(r==null?void 0:r.id),s=(r==null?void 0:r.rolloutPath)??(r==null?void 0:r.filePath)??(r==null?void 0:r.rolloutFile)??(r==null?void 0:r.path),u=i?`thread:${i}`:s?`rollout:${Np(s,n)}`:`item:${l}`;return t.has(u)?!1:(t.add(u),!0)})}function xu(e,{caseInsensitive:n=!1}={}){const t=String(e??"").replace(/[\\/]+$/,"").replaceAll("\\","/");return n?t.toLowerCase():t}function Mc(e,n,t){return!!(e&&n&&xu(e,t)===xu(n,t))}function Pp(e){const n=String(e??"").replace(/[\\/]+$/,""),t=Math.max(n.lastIndexOf("/"),n.lastIndexOf("\\"));return t<0?"":t===0?n.slice(0,1):t===2&&/^[A-Za-z]:/.test(n)?n.slice(0,3):n.slice(0,t)}function _p(e,n){var i;const t=(i=e==null?void 0:e.stateDbLocation)==null?void 0:i.path;if(t)return Pp(t);const r=n==null?void 0:n.metadata,l={caseInsensitive:(e==null?void 0:e.pathComparisonCaseInsensitive)===!0};return Number(r==null?void 0:r.version)>=2&&(e==null?void 0:e.sqliteHomeSource)==="default"&&Mc(r==null?void 0:r.sqliteHome,e==null?void 0:e.codexHome,l)?e.codexHome:(e==null?void 0:e.sqliteHome)??""}function zp({backup:e,profile:n,targetSqliteHome:t,restoreDatabase:r,restoreConfig:l,sqliteSupported:i,pathComparisonCaseInsensitive:s=!1}){var h,x;const u=(h=e==null?void 0:e.metadata)==null?void 0:h.sqliteHome,a=((x=n==null?void 0:n.sqliteHome)==null?void 0:x.trim())??"",d=!!(r&&u&&t&&!Mc(u,t,{caseInsensitive:s})),v=d&&!a,m=d&&l;return{requiresRelocation:d,missingExplicitTarget:v,configRestoreConflict:m,canSubmit:!!i&&!v&&!m}}function yt(e){return{profileId:e||"default"}}function Rp({fetchStatus:e,fetchBackups:n,gate:t=oo()}){const r=async({profileId:l,showLoading:i=!0,onLoading:s,onResult:u,onError:a})=>{const{controller:d,sequence:v}=t.begin();i&&(s==null||s(!0));try{const m=yt(l),[h,x]=await Promise.all([e(m,{signal:d.signal}),n(m,{signal:d.signal})]);return t.isLatest(v)?(u==null||u({profileId:l,status:h.status,backups:x}),!0):!1}catch(m){return(m==null?void 0:m.name)==="AbortError"||!t.isLatest(v)||a==null||a(m),!1}finally{t.isLatest(v)&&(s==null||s(!1))}};return r.cancel=()=>t.cancel(),r}function Qe({children:e,size:n=18,className:t=""}){return o.jsx("svg",{className:t,width:n,height:n,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",children:e})}const Kn=e=>o.jsxs(Qe,{...e,children:[o.jsx("path",{d:"M20 6v5h-5"}),o.jsx("path",{d:"M4 18v-5h5"}),o.jsx("path",{d:"M18.3 9A7 7 0 0 0 6.7 6.7L4 11"}),o.jsx("path",{d:"M5.7 15A7 7 0 0 0 17.3 17.3L20 13"})]}),ss=e=>o.jsxs(Qe,{...e,children:[o.jsx("ellipse",{cx:"12",cy:"5",rx:"8",ry:"3"}),o.jsx("path",{d:"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"}),o.jsx("path",{d:"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"})]}),Yn=e=>o.jsxs(Qe,{...e,children:[o.jsx("path",{d:"M3 12a9 9 0 1 0 3-6.7L3 8"}),o.jsx("path",{d:"M3 3v5h5"}),o.jsx("path",{d:"M12 7v5l3 2"})]}),Lp=e=>o.jsx(Qe,{...e,children:o.jsx("path",{d:"M3 12h4l2-7 4 14 2-7h6"})}),Tp=e=>o.jsxs(Qe,{...e,children:[o.jsx("rect",{x:"3",y:"3",width:"7",height:"7",rx:"1"}),o.jsx("rect",{x:"14",y:"3",width:"7",height:"7",rx:"1"}),o.jsx("rect",{x:"3",y:"14",width:"7",height:"7",rx:"1"}),o.jsx("rect",{x:"14",y:"14",width:"7",height:"7",rx:"1"})]}),so=e=>o.jsxs(Qe,{...e,children:[o.jsx("path",{d:"M12 3 4.5 6v5.3c0 4.6 3.2 8 7.5 9.7 4.3-1.7 7.5-5.1 7.5-9.7V6L12 3Z"}),o.jsx("path",{d:"m9 12 2 2 4-4"})]}),Mp=e=>o.jsx(Qe,{...e,children:o.jsx("path",{d:"m9 18 6-6-6-6"})}),Ic=e=>o.jsx(Qe,{...e,children:o.jsx("path",{d:"m6 6 12 12M18 6 6 18"})}),us=e=>o.jsx(Qe,{...e,children:o.jsx("path",{d:"m5 12 4 4L19 6"})}),Be=e=>o.jsxs(Qe,{...e,children:[o.jsx("path",{d:"M12 3 2.8 19h18.4L12 3Z"}),o.jsx("path",{d:"M12 9v4M12 17h.01"})]}),as=e=>o.jsx(Qe,{...e,children:o.jsx("path",{d:"M3 6.5h6l2 2h10v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6.5Z"})}),Ip=[{id:"overview",label:"概览",icon:Tp},{id:"history",label:"聊天记录",icon:Yn},{id:"backups",label:"备份",icon:Yn},{id:"activity",label:"活动",icon:Lp}],Op={backupRoot:"",backups:[]};function Jt(e){return new Intl.NumberFormat("zh-CN").format(Number(e)||0)}function vr(e){const n=["B","KB","MB","GB","TB"];let t=Number(e)||0,r=0;for(;t>=1024&&r=10?1:2).replace(/\.0$/,"")} ${n[r]}`}function Et(e){return e?new Intl.DateTimeFormat("zh-CN",{month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}).format(new Date(e)):"未知时间"}function Dp(e,n){return e.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g).map((r,l)=>r.startsWith("`")&&r.endsWith("`")?o.jsx("code",{children:r.slice(1,-1)},`${n}-code-${l}`):r.startsWith("**")&&r.endsWith("**")?o.jsx("strong",{children:r.slice(2,-2)},`${n}-strong-${l}`):r.startsWith("*")&&r.endsWith("*")?o.jsx("em",{children:r.slice(1,-1)},`${n}-em-${l}`):o.jsx(ho.Fragment,{children:r},`${n}-text-${l}`))}function Fp({text:e}){return String(e??"").split(/(```[^\n]*\n[\s\S]*?```)/g).map((t,r)=>{if(t.startsWith("```")&&t.endsWith("```")){const l=t.slice(3,-3).replace(/^\w*\n/,"");return o.jsx("pre",{children:o.jsx("code",{children:l})},`block-${r}`)}return t.split(` -`).map((l,i,s)=>o.jsxs(ho.Fragment,{children:[Dp(l,`${r}-${i}`),i{for(const d of u??[]){if(!d||d==="(missing)")continue;const v=n.get(d)??new Set;v.add(a),n.set(d,v)}};return t(e.configuredProviders,"config"),t(Object.keys(((r=e.rolloutCounts)==null?void 0:r.sessions)??{}),"rollout"),t(Object.keys(((l=e.rolloutCounts)==null?void 0:l.archived_sessions)??{}),"rollout"),t(Object.keys(((i=e.sqliteCounts)==null?void 0:i.sessions)??{}),"sqlite"),t(Object.keys(((s=e.sqliteCounts)==null?void 0:s.archived_sessions)??{}),"sqlite"),t([e.currentProvider],"config"),[...n.entries()].map(([u,a])=>{var d;return{id:u,sources:[...a],configured:(d=e.configuredProviders)==null?void 0:d.includes(u),current:u===e.currentProvider}}).sort((u,a)=>Number(a.current)-Number(u.current)||u.id.localeCompare(a.id))}function Oc({tone:e="neutral"}){return o.jsx("span",{className:`status-dot status-dot--${e}`,"aria-hidden":"true"})}function $p({status:e,busy:n,onRefresh:t}){var l,i;const r=((l=e==null?void 0:e.sqliteAccess)==null?void 0:l.supported)!==!1&&!((i=e==null?void 0:e.sqliteCounts)!=null&&i.unreadable);return o.jsxs("header",{className:"app-header",children:[o.jsxs("div",{className:"brand",children:[o.jsx("div",{className:"brand-mark",children:o.jsx(ss,{size:19})}),o.jsxs("div",{children:[o.jsx("div",{className:"brand-name",children:"Codex Provider Sync"}),o.jsx("div",{className:"brand-subtitle",children:"本机元数据一致性工具"})]})]}),o.jsxs("div",{className:"header-actions",children:[o.jsxs("div",{className:"service-state",children:[o.jsx(Oc,{tone:n?"warning":r?"success":"danger"}),o.jsx("span",{children:n?"操作执行中":r?"本地服务就绪":"需要检查"})]}),o.jsxs("button",{className:"button button--secondary button--compact",type:"button",onClick:t,disabled:n,children:[o.jsx(Kn,{size:16}),"刷新"]})]})]})}function Ap({view:e,setView:n,status:t,onForgetBrowser:r}){return o.jsxs("aside",{className:"sidebar",children:[o.jsx("nav",{className:"navigation","aria-label":"主导航",children:Ip.map(l=>{const i=l.icon;return o.jsxs("button",{className:`nav-item ${e===l.id?"nav-item--active":""}`,type:"button",onClick:()=>n(l.id),children:[o.jsx(i,{size:17}),o.jsx("span",{children:l.label})]},l.id)})}),o.jsxs("div",{className:"sidebar-foot",children:[o.jsx("div",{className:"sidebar-provider-label",children:"当前 Provider"}),o.jsxs("div",{className:"sidebar-provider-value",children:[o.jsx(Oc,{tone:"success"}),o.jsx("span",{children:(t==null?void 0:t.currentProvider)??"未读取"})]}),o.jsx("div",{className:"sidebar-version",children:"Web UI · localhost only"}),o.jsx("button",{className:"button button--quiet button--compact",type:"button",onClick:r,children:"忘记此浏览器"})]})]})}function Up({profiles:e,profileId:n,setProfileId:t,status:r,onAddProfile:l,onDeleteProfile:i,onRefresh:s,loading:u,profileSwitchDisabled:a}){return o.jsxs("section",{className:"storage-bar","aria-label":"存储位置",children:[o.jsxs("label",{className:"path-field path-field--wide",children:[o.jsx("span",{children:"存储配置"}),o.jsxs("div",{className:"path-input-wrap",children:[o.jsx(as,{size:16}),o.jsx("select",{value:n,onChange:d=>t(d.target.value),disabled:a,children:e.map(d=>o.jsx("option",{value:d.id,children:d.name},d.id))})]})]}),o.jsxs("label",{className:"path-field",children:[o.jsxs("span",{children:["当前路径 ",o.jsx("small",{children:"由服务端解析"})]}),o.jsxs("div",{className:"path-input-wrap",children:[o.jsx(ss,{size:16}),o.jsx("input",{value:(r==null?void 0:r.codexHome)??"",readOnly:!0,placeholder:"读取状态后显示"})]})]}),o.jsx("button",{className:"button button--secondary storage-refresh",type:"button",onClick:l,children:"新增配置"}),n!=="default"?o.jsx("button",{className:"button button--quiet storage-refresh",type:"button",onClick:i,children:"删除配置"}):null,o.jsxs("button",{className:"button button--secondary storage-refresh",type:"button",onClick:s,disabled:u,children:[o.jsx(Kn,{size:16,className:u?"spin":""}),"读取状态"]})]})}function Ar({title:e,counts:n,currentProvider:t}){const r=Object.entries(n??{}).sort((i,s)=>s[1]-i[1]),l=r.reduce((i,[,s])=>i+s,0);return o.jsxs("div",{className:"distribution-block",children:[o.jsxs("div",{className:"distribution-heading",children:[o.jsx("span",{children:e}),o.jsx("strong",{children:Jt(l)})]}),o.jsx("div",{className:"distribution-list",children:r.length===0?o.jsx("div",{className:"empty-inline",children:"无记录"}):r.map(([i,s])=>o.jsxs("div",{className:"distribution-row",children:[o.jsxs("div",{className:"distribution-meta",children:[o.jsx("span",{className:"provider-name",children:i}),o.jsx("span",{children:Jt(s)})]}),o.jsx("div",{className:"bar-track",children:o.jsx("span",{className:i===t?"bar-fill bar-fill--current":"bar-fill",style:{width:`${Math.max(5,l?s/l*100:0)}%`}})})]},i))})]})}function Hp({status:e,loading:n}){var i,s,u,a,d,v,m,h,x,j,S,D,f;const t=Object.values(((i=e==null?void 0:e.rolloutCounts)==null?void 0:i.sessions)??{}).reduce((c,p)=>c+p,0)+Object.values(((s=e==null?void 0:e.rolloutCounts)==null?void 0:s.archived_sessions)??{}).reduce((c,p)=>c+p,0),r=Object.values(((u=e==null?void 0:e.sqliteCounts)==null?void 0:u.sessions)??{}).reduce((c,p)=>c+p,0)+Object.values(((a=e==null?void 0:e.sqliteCounts)==null?void 0:a.archived_sessions)??{}).reduce((c,p)=>c+p,0),l=(d=e==null?void 0:e.alignment)==null?void 0:d.aligned;return o.jsxs("section",{className:"status-panel",children:[o.jsxs("div",{className:"section-title-row",children:[o.jsxs("div",{children:[o.jsx("h2",{children:"状态总览"}),o.jsx("p",{children:"比较 rollout 文件、SQLite 线程索引和当前 Provider。"})]}),o.jsxs("div",{className:`alignment-state ${l?"alignment-state--success":"alignment-state--warning"}`,children:[n?o.jsx(Kn,{size:16,className:"spin"}):l?o.jsx(us,{size:16}):o.jsx(Be,{size:16}),o.jsx("span",{children:n?"正在检查":l?"Provider 元数据已对齐":"发现不一致"})]})]}),o.jsxs("div",{className:"summary-strip",children:[o.jsxs("div",{className:"summary-item",children:[o.jsx("span",{children:"当前 Provider"}),o.jsx("strong",{children:(e==null?void 0:e.currentProvider)??"—"}),o.jsx("small",{children:e!=null&&e.currentProviderImplicit?"隐式默认":"config.toml 根级配置"})]}),o.jsxs("div",{className:"summary-item",children:[o.jsx("span",{children:"Rollout 文件"}),o.jsx("strong",{children:Jt(t)}),o.jsx("small",{children:"sessions + archived"})]}),o.jsxs("div",{className:"summary-item",children:[o.jsx("span",{children:"SQLite threads"}),o.jsx("strong",{children:(v=e==null?void 0:e.sqliteCounts)!=null&&v.unreadable?"不可读":Jt(r)}),o.jsx("small",{children:((m=e==null?void 0:e.stateDbLocation)==null?void 0:m.source)??"未定位数据库"})]}),o.jsxs("div",{className:"summary-item",children:[o.jsx("span",{children:"托管备份"}),o.jsx("strong",{children:Jt((h=e==null?void 0:e.backupSummary)==null?void 0:h.count)}),o.jsx("small",{children:vr((x=e==null?void 0:e.backupSummary)==null?void 0:x.totalBytes)})]})]}),o.jsxs("div",{className:"distribution-grid",children:[o.jsxs("div",{className:"distribution-column",children:[o.jsxs("div",{className:"column-label",children:[o.jsx(as,{size:16})," Rollout files"]}),o.jsx(Ar,{title:"sessions",counts:(j=e==null?void 0:e.rolloutCounts)==null?void 0:j.sessions,currentProvider:e==null?void 0:e.currentProvider}),o.jsx(Ar,{title:"archived_sessions",counts:(S=e==null?void 0:e.rolloutCounts)==null?void 0:S.archived_sessions,currentProvider:e==null?void 0:e.currentProvider})]}),o.jsx("div",{className:"distribution-divider"}),o.jsxs("div",{className:"distribution-column",children:[o.jsxs("div",{className:"column-label",children:[o.jsx(ss,{size:16})," SQLite state"]}),o.jsx(Ar,{title:"sessions",counts:(D=e==null?void 0:e.sqliteCounts)==null?void 0:D.sessions,currentProvider:e==null?void 0:e.currentProvider}),o.jsx(Ar,{title:"archived_sessions",counts:(f=e==null?void 0:e.sqliteCounts)==null?void 0:f.archived_sessions,currentProvider:e==null?void 0:e.currentProvider})]})]})]})}function Bp({status:e}){var r,l,i;const n=[];((r=e==null?void 0:e.sqliteAccess)==null?void 0:r.supported)===!1&&n.push({tone:"danger",title:"SQLite 路径不可安全访问",detail:e.sqliteAccess.message}),(l=e==null?void 0:e.sqliteCounts)!=null&&l.unreadable&&n.push({tone:"danger",title:"SQLite 当前不可读",detail:e.sqliteCounts.error}),(i=e==null?void 0:e.lockedRolloutFiles)!=null&&i.length&&n.push({tone:"warning",title:`${e.lockedRolloutFiles.length} 个 rollout 文件正在使用`,detail:"同步会跳过这些活跃文件;会话结束后可再次执行。"}),e!=null&&e.encryptedContentWarning&&n.push({tone:"warning",title:"检测到 encrypted_content",detail:e.encryptedContentWarning});const t=e==null?void 0:e.sqliteRepairStats;return(t!=null&&t.userEventRowsNeedingRepair||t!=null&&t.cwdRowsNeedingRepair)&&n.push({tone:"info",title:"SQLite 有待修复字段",detail:`user-event ${t.userEventRowsNeedingRepair??0},cwd ${t.cwdRowsNeedingRepair??0}`}),n.length===0?null:o.jsx("section",{className:"warning-stack","aria-label":"诊断信息",children:n.map((s,u)=>o.jsxs("div",{className:`warning-row warning-row--${s.tone}`,children:[o.jsx(Be,{size:17}),o.jsxs("div",{children:[o.jsx("strong",{children:s.title}),o.jsx("span",{children:s.detail})]})]},`${s.title}-${u}`))})}function Vp({projects:e=[]}){return o.jsxs("section",{className:"data-section",children:[o.jsx("div",{className:"section-title-row section-title-row--compact",children:o.jsxs("div",{children:[o.jsx("h2",{children:"项目可见性"}),o.jsx("p",{children:"检查 Desktop 项目路径、全局排序和首屏 50 条命中。"})]})}),o.jsx("div",{className:"table-scroll",children:o.jsxs("table",{className:"data-table",children:[o.jsx("thead",{children:o.jsxs("tr",{children:[o.jsx("th",{children:"项目根目录"}),o.jsx("th",{children:"交互会话"}),o.jsx("th",{children:"首屏"}),o.jsx("th",{children:"Ranks"}),o.jsx("th",{children:"CWD 精确匹配"}),o.jsx("th",{children:"Provider"})]})}),o.jsx("tbody",{children:e.length===0?o.jsx("tr",{children:o.jsx("td",{colSpan:"6",className:"table-empty",children:"没有可显示的项目诊断。"})}):e.map(n=>o.jsxs("tr",{children:[o.jsx("td",{className:"path-cell",children:n.root}),o.jsx("td",{children:n.interactiveThreads}),o.jsxs("td",{children:[n.firstPageThreads,"/50"]}),o.jsx("td",{children:n.rankPreview||"—"}),o.jsxs("td",{children:[n.exactCwdMatches,"/",n.interactiveThreads]}),o.jsx("td",{children:Object.entries(n.providerCounts??{}).map(([t,r])=>`${t} ${r}`).join(" · ")||"—"})]},n.root))})]})})]})}function Qp({value:e,onChange:n,disabled:t}){return o.jsxs("div",{className:"segmented",role:"radiogroup","aria-label":"执行模式",children:[o.jsxs("button",{type:"button",className:e==="sync"?"segmented-option segmented-option--active":"segmented-option",onClick:()=>n("sync"),disabled:t,children:[o.jsx("span",{children:"仅同步元数据"}),o.jsx("small",{children:"不修改 config.toml"})]}),o.jsxs("button",{type:"button",className:e==="switch"?"segmented-option segmented-option--active":"segmented-option",onClick:()=>n("switch"),disabled:t,children:[o.jsx("span",{children:"切换 Provider 并同步"}),o.jsx("small",{children:"更新根级配置"})]})]})}function Wp({status:e,providers:n,selectedProvider:t,setSelectedProvider:r,onAddManualProvider:l,onRemoveManualProvider:i,onRequestExecute:s,busy:u}){var N,_,I;const[a,d]=An("cps.web.mode","sync"),[v,m]=An("cps.web.modelMode","auto"),[h,x]=An("cps.web.customModel",""),[j,S]=An("cps.web.keepCount",5),[D,f]=E.useState(""),c=n.find(z=>z.id===t),p=c==null?void 0:c.configured,g=/^[A-Za-z0-9_.-]+$/.test(D.trim()),C=((N=e==null?void 0:e.sqliteAccess)==null?void 0:N.supported)===!1,y=u||!t||C||((_=e==null?void 0:e.sqliteCounts)==null?void 0:_.unreadable);return o.jsxs("section",{className:"execution-panel",children:[o.jsxs("div",{className:"execution-head",children:[o.jsxs("div",{children:[o.jsx("h2",{children:"执行同步"}),o.jsx("p",{children:"所有写操作都会先创建备份。执行前请关闭 Codex CLI、App 和 app-server。"})]}),o.jsx(so,{size:22})]}),o.jsx(Qp,{value:a,onChange:d,disabled:u}),o.jsxs("div",{className:"form-grid",children:[o.jsxs("label",{className:"form-field",children:[o.jsx("span",{children:"目标 Provider"}),o.jsx("select",{value:t,onChange:z=>r(z.target.value),disabled:u,children:n.map(z=>o.jsxs("option",{value:z.id,children:[z.id,z.current?"(当前)":""]},z.id))}),a==="switch"&&!p?o.jsx("small",{className:"field-error",children:"切换模式要求该 Provider 已定义在 config.toml。"}):null]}),o.jsxs("label",{className:"form-field form-field--short",children:[o.jsx("span",{children:"保留备份数"}),o.jsx("input",{type:"number",min:"1",max:"100000",value:j,onChange:z=>S(Number(z.target.value)),disabled:u}),o.jsx("small",{children:"同步后自动清理"})]})]}),o.jsxs("div",{className:"manual-provider-row",children:[o.jsx("input",{type:"text",value:D,onChange:z=>f(z.target.value),placeholder:"手动添加 Provider ID",spellCheck:"false",disabled:u}),o.jsx("button",{className:"button button--quiet button--compact",type:"button",disabled:u||!g,onClick:()=>{l(D.trim()),f("")},children:"添加"}),c!=null&&c.manual?o.jsx("button",{className:"manual-remove",type:"button",onClick:()=>i(t),disabled:u,children:"删除当前手动项"}):null]}),a==="switch"?o.jsxs("fieldset",{className:"model-options",disabled:u,children:[o.jsx("legend",{children:"根级 model"}),o.jsxs("label",{children:[o.jsx("input",{type:"radio",name:"model-mode",checked:v==="auto",onChange:()=>m("auto")}),o.jsxs("span",{children:[o.jsx("strong",{children:"跟随 Provider 配置"}),o.jsxs("small",{children:["采用 `[model_providers.",t,"]` 中的 model"]})]})]}),o.jsxs("label",{children:[o.jsx("input",{type:"radio",name:"model-mode",checked:v==="keep",onChange:()=>m("keep")}),o.jsxs("span",{children:[o.jsx("strong",{children:"保留当前根级 model"}),o.jsx("small",{children:"只切换 model_provider"})]})]}),o.jsxs("label",{className:"custom-model-option",children:[o.jsx("input",{type:"radio",name:"model-mode",checked:v==="custom",onChange:()=>m("custom")}),o.jsxs("span",{children:[o.jsx("strong",{children:"自定义 model"}),o.jsx("input",{type:"text",value:h,onFocus:()=>m("custom"),onChange:z=>x(z.target.value),placeholder:"例如 MiniMax-M3"})]})]})]}):null,C?o.jsxs("div",{className:"modal-callout modal-callout--danger",children:[o.jsx(Be,{size:18}),o.jsxs("div",{children:[o.jsx("strong",{children:"此 SQLite 布局仅供诊断"}),o.jsx("span",{children:((I=e==null?void 0:e.sqliteAccess)==null?void 0:I.message)||"当前 SQLite 路径不可由 Web UI 安全写入,因此已禁用执行同步。"})]})]}):null,o.jsxs("div",{className:"backup-assurance",children:[o.jsx(us,{size:16}),o.jsx("span",{children:"修改前创建 metadata v2 备份,并记录 SQLite Home"})]}),o.jsxs("button",{className:"button button--primary execute-button",type:"button",disabled:y||a==="switch"&&!p||a==="switch"&&v==="custom"&&!h.trim(),onClick:()=>s({mode:a,modelMode:v,model:h.trim(),keepCount:j}),children:[u?o.jsx(Kn,{size:17,className:"spin"}):o.jsx(so,{size:17}),u?"正在执行…":a==="switch"?"切换并同步":"执行同步"]})]})}function qp({backups:e,onViewAll:n,onRestore:t,restoreDisabled:r}){return o.jsxs("section",{className:"recent-backups",children:[o.jsxs("div",{className:"section-title-row section-title-row--compact",children:[o.jsxs("div",{children:[o.jsx("h2",{children:"最近备份"}),o.jsx("p",{children:e.backupRoot||"同步后将在 Codex Home 下创建备份"})]}),o.jsxs("button",{type:"button",className:"text-button",onClick:n,children:["查看全部 ",o.jsx(Mp,{size:14})]})]}),o.jsx("div",{className:"backup-rows",children:e.backups.length===0?o.jsx("div",{className:"empty-state",children:"尚无由本工具创建的备份。"}):e.backups.slice(0,3).map(l=>o.jsxs("div",{className:"backup-row",children:[o.jsx("div",{className:"backup-icon",children:o.jsx(Yn,{size:17})}),o.jsxs("div",{className:"backup-main",children:[o.jsx("strong",{children:Et(l.metadata.createdAt)}),o.jsxs("span",{children:[l.metadata.targetProvider," · ",l.metadata.changedSessionFiles??0," 个 rollout"]})]}),o.jsx("div",{className:"backup-size",children:vr(l.sizeBytes)}),o.jsx("button",{className:"button button--quiet button--compact",type:"button",disabled:r,title:r?"当前 SQLite 路径仅供诊断,不能恢复":void 0,onClick:()=>t(l),children:"恢复"})]},l.id))})]})}function Kp({status:e,backups:n,providers:t,selectedProvider:r,setSelectedProvider:l,onAddManualProvider:i,onRemoveManualProvider:s,onExecute:u,onRestore:a,setView:d,busy:v,loading:m}){var h;return o.jsxs("div",{className:"view-content",children:[o.jsx(Hp,{status:e,loading:m}),o.jsx(Bp,{status:e}),o.jsxs("div",{className:"overview-lower-grid",children:[o.jsxs("div",{className:"overview-main-column",children:[o.jsx(Vp,{projects:(e==null?void 0:e.projectThreadVisibility)??[]}),o.jsx(qp,{backups:n,onViewAll:()=>d("backups"),onRestore:a,restoreDisabled:((h=e==null?void 0:e.sqliteAccess)==null?void 0:h.supported)===!1})]}),o.jsx(Wp,{status:e,providers:t,selectedProvider:r,setSelectedProvider:l,onAddManualProvider:i,onRemoveManualProvider:s,onRequestExecute:u,busy:v})]})]})}function Yp({backups:e,status:n,busy:t,onRestore:r,onPrune:l}){var a;const[i,s]=An("cps.web.keepCount",5),u=((a=n==null?void 0:n.sqliteAccess)==null?void 0:a.supported)===!1;return o.jsxs("div",{className:"view-content",children:[o.jsxs("section",{className:"page-intro",children:[o.jsxs("div",{children:[o.jsx("h1",{children:"备份"}),o.jsx("p",{children:"管理当前 Codex Home 下由本工具创建的 metadata v2 备份。"})]}),o.jsxs("div",{className:"prune-control",children:[o.jsxs("label",{children:["保留最近 ",o.jsx("input",{type:"number",min:"0",max:"100000",value:i,onChange:d=>s(Number(d.target.value))})," 份"]}),o.jsx("button",{className:"button button--secondary",type:"button",disabled:t,onClick:()=>l(i),children:"清理旧备份"})]})]}),o.jsxs("section",{className:"backup-list-section",children:[o.jsxs("div",{className:"backup-root-line",children:[o.jsx(as,{size:16}),o.jsx("span",{children:e.backupRoot||(n==null?void 0:n.backupRoot)||"—"}),o.jsxs("strong",{children:[e.backups.length," 份 · ",vr(e.backups.reduce((d,v)=>d+v.sizeBytes,0))]})]}),o.jsx("div",{className:"full-backup-list",children:e.backups.length===0?o.jsxs("div",{className:"large-empty",children:[o.jsx(Yn,{size:26}),o.jsx("strong",{children:"还没有备份"}),o.jsx("span",{children:"执行一次同步或切换后,备份会显示在这里。"})]}):e.backups.map(d=>{var v;return o.jsxs("article",{className:"full-backup-row",children:[o.jsxs("div",{className:"backup-date",children:[o.jsx("strong",{children:Et(d.metadata.createdAt)}),o.jsx("span",{children:d.id})]}),o.jsxs("div",{className:"backup-facts",children:[o.jsxs("span",{children:["Provider ",o.jsx("strong",{children:d.metadata.targetProvider})]}),o.jsxs("span",{children:["Rollout ",o.jsx("strong",{children:d.metadata.changedSessionFiles??0})]}),o.jsxs("span",{children:["SQLite ",o.jsx("strong",{children:(v=d.metadata.sqliteDbFiles)!=null&&v.length?"已包含":"未包含"})]})]}),o.jsxs("div",{className:"backup-source",children:[o.jsx("span",{children:"SQLite Home"}),o.jsx("code",{children:d.metadata.sqliteHome??"旧版 metadata 未记录"})]}),o.jsxs("div",{className:"backup-row-actions",children:[o.jsx("span",{children:vr(d.sizeBytes)}),o.jsx("button",{className:"button button--secondary button--compact",type:"button",disabled:t||u,title:u?"当前 SQLite 路径仅供诊断,不能恢复":void 0,onClick:()=>r(d),children:"恢复"})]})]},d.id)})})]})]})}function Xp({activity:e,activeOperation:n}){return o.jsxs("div",{className:"view-content",children:[o.jsxs("section",{className:"page-intro",children:[o.jsxs("div",{children:[o.jsx("h1",{children:"活动日志"}),o.jsx("p",{children:"当前 Web UI 会话中的状态刷新、同步阶段和操作结果。"})]}),n?o.jsxs("div",{className:"live-operation",children:[o.jsx(Kn,{size:16,className:"spin"})," ",n.kind]}):null]}),o.jsxs("section",{className:"activity-console",children:[o.jsxs("div",{className:"console-head",children:[o.jsx("span",{children:"Activity log"}),o.jsxs("span",{children:[e.length," entries"]})]}),o.jsx("div",{className:"console-body",children:e.length===0?o.jsx("div",{className:"console-empty",children:"等待操作…"}):e.map(t=>o.jsxs("div",{className:`console-row console-row--${t.level}`,children:[o.jsx("time",{children:Et(t.timestamp)}),o.jsx("span",{className:"console-level",children:t.level}),o.jsx("span",{className:"console-message",children:t.message}),typeof t.detail=="string"?o.jsx("span",{className:"console-detail",children:t.detail}):null]},t.id))})]})]})}function Gp({profileId:e,status:n}){const[t,r]=E.useState(""),[l,i]=E.useState(""),[s,u]=E.useState(""),[a,d]=E.useState(""),[v,m]=E.useState("all"),[h,x]=E.useState(1),[j,S]=E.useState({sessions:[],total:0,pageSize:50,hasNextPage:!1}),[D,f]=E.useState(""),[c,p]=E.useState(null),[g,C]=E.useState(!1),[y,N]=E.useState(!1),[_,I]=E.useState(""),z=E.useRef(null),G=E.useRef(null);z.current||(z.current=oo()),G.current||(G.current=oo());const We=uo(n),Ze=[...new Set(((n==null?void 0:n.projectThreadVisibility)??[]).map(M=>M.root))];E.useEffect(()=>{z.current.cancel(),G.current.cancel(),r(""),i(""),u(""),d(""),m("all"),x(1),S({sessions:[],total:0,pageSize:50,hasNextPage:!1}),f(""),p(null),I("")},[e]),E.useEffect(()=>Sp(()=>i(t),300,window),[t]);const Je=E.useCallback(async()=>{var k;const{controller:M,sequence:ie}=z.current.begin();C(!0),I("");try{const R=await wp({...yt(e),page:h,pageSize:50,query:l,provider:s,project:a,archived:v},{signal:M.signal});if(!z.current.isLatest(ie))return;const T={...R.history,sessions:Ep((k=R.history)==null?void 0:k.sessions)};S(T),f(A=>{var q;return T.sessions.some(pn=>pn.id===A)?A:((q=T.sessions[0])==null?void 0:q.id)??""})}catch(R){R.name!=="AbortError"&&z.current.isLatest(ie)&&I(R.message)}finally{z.current.isLatest(ie)&&C(!1)}},[e,h,l,s,a,v]);E.useEffect(()=>(Je(),()=>z.current.cancel()),[Je]),E.useEffect(()=>{if(G.current.cancel(),!D){p(null),N(!1);return}const{controller:M,sequence:ie}=G.current.begin();return p(null),N(!0),I(""),jp({...yt(e),sessionId:D},{signal:M.signal}).then(k=>{G.current.isLatest(ie)&&p(k.history)}).catch(k=>{k.name!=="AbortError"&&G.current.isLatest(ie)&&I(k.message)}).finally(()=>{G.current.isLatest(ie)&&N(!1)}),()=>M.abort()},[e,D]);const fn=M=>ie=>{M(ie.target.value),x(1)};return o.jsxs("div",{className:"view-content history-view",children:[o.jsxs("section",{className:"page-intro",children:[o.jsxs("div",{children:[o.jsx("h1",{children:"聊天记录"}),o.jsx("p",{children:"从 rollout 文件读取历史会话,只读查看,不修改本地数据。"})]}),o.jsxs("button",{className:"button button--secondary",type:"button",onClick:Je,disabled:g,children:[o.jsx(Kn,{size:16,className:g?"spin":""}),"刷新"]})]}),o.jsxs("section",{className:"history-toolbar",children:[o.jsx("input",{value:t,onChange:fn(r),onKeyDown:M=>{M.key==="Enter"&&(i(t),x(1))},placeholder:"搜索标题、项目、Provider 或消息内容"}),o.jsxs("select",{value:s,onChange:fn(u),children:[o.jsx("option",{value:"",children:"全部 Provider"}),We.map(M=>o.jsx("option",{value:M.id,children:M.id},M.id))]}),o.jsxs("select",{value:a,onChange:fn(d),children:[o.jsx("option",{value:"",children:"全部项目"}),Ze.map(M=>o.jsx("option",{value:M,children:M},M))]}),o.jsxs("select",{value:v,onChange:fn(m),children:[o.jsx("option",{value:"all",children:"全部会话"}),o.jsx("option",{value:"active",children:"活跃会话"}),o.jsx("option",{value:"archived",children:"已归档"})]})]}),_?o.jsxs("div",{className:"history-error",children:[o.jsx(Be,{size:16}),_]}):null,o.jsxs("section",{className:"history-layout",children:[o.jsxs("div",{className:"history-list-panel",children:[o.jsxs("div",{className:"history-list-head",children:[o.jsxs("strong",{children:[j.total," 个会话"]}),o.jsxs("span",{children:["第 ",j.page," 页"]})]}),o.jsxs("div",{className:"history-list",children:[g&&!j.sessions.length?o.jsx("div",{className:"large-empty",children:"读取中…"}):null,!g&&!j.sessions.length?o.jsxs("div",{className:"large-empty",children:[o.jsx(Yn,{size:24}),o.jsx("strong",{children:"没有匹配的会话"})]}):null,j.sessions.map(M=>o.jsxs("button",{type:"button",className:`history-session-row ${M.id===D?"history-session-row--selected":""}`,onClick:()=>f(M.id),children:[o.jsxs("div",{className:"history-session-top",children:[o.jsx("strong",{children:M.title}),o.jsx("time",{children:Et(M.updatedAt)})]}),o.jsx("p",{children:M.firstUserMessage||"没有可读的用户消息"}),o.jsxs("div",{className:"history-session-meta",children:[o.jsx("span",{children:M.provider}),o.jsxs("span",{children:[M.messageCount," 条消息"]}),o.jsx("span",{children:M.archived?"已归档":"活跃"})]})]},M.id))]}),o.jsxs("div",{className:"history-pagination",children:[o.jsx("button",{className:"button button--quiet button--compact",type:"button",disabled:h<=1||g,onClick:()=>x(M=>M-1),children:"上一页"}),o.jsx("span",{children:h}),o.jsx("button",{className:"button button--quiet button--compact",type:"button",disabled:!j.hasNextPage||g,onClick:()=>x(M=>M+1),children:"下一页"})]})]}),o.jsx("div",{className:"history-detail-panel",children:y?o.jsxs("div",{className:"large-empty",children:[o.jsx(Kn,{size:24,className:"spin"}),o.jsx("strong",{children:"正在读取会话"})]}):c?o.jsxs(o.Fragment,{children:[o.jsxs("div",{className:"history-detail-head",children:[o.jsxs("div",{children:[o.jsx("h2",{children:c.session.title}),o.jsxs("p",{children:[c.session.cwd||"未知项目"," · ",c.session.provider," · ",c.session.messageCount," 条消息"]})]}),o.jsx("span",{children:c.session.archived?"已归档":"活跃"})]}),c.truncated?o.jsxs("div",{className:"history-truncated",children:[o.jsx(Be,{size:15}),"仅显示最近 ",c.returnedMessageCount," 条消息。"]}):null,o.jsx("div",{className:"message-stream",children:c.messages.map(M=>o.jsxs("article",{className:`chat-message chat-message--${M.role}`,children:[o.jsxs("div",{className:"chat-message-label",children:[M.role==="user"?"你":"Codex",o.jsx("time",{children:Et(M.timestamp)})]}),o.jsx("div",{className:"chat-message-body",children:o.jsx(Fp,{text:M.text})})]},`${M.sequence}-${M.timestamp}`))})]}):o.jsxs("div",{className:"large-empty",children:[o.jsx(Yn,{size:28}),o.jsx("strong",{children:"选择一个会话"}),o.jsx("span",{children:"聊天内容将在这里显示。"})]})})]})]})}function Ul({title:e,children:n,confirmLabel:t,onConfirm:r,onCancel:l,tone:i="primary",confirmDisabled:s=!1}){return o.jsx("div",{className:"modal-backdrop",role:"presentation",onMouseDown:u=>u.target===u.currentTarget&&l(),children:o.jsxs("section",{className:"modal",role:"dialog","aria-modal":"true","aria-labelledby":"modal-title",children:[o.jsxs("div",{className:"modal-head",children:[o.jsx("h2",{id:"modal-title",children:e}),o.jsx("button",{type:"button",className:"icon-button",onClick:l,"aria-label":"关闭",children:o.jsx(Ic,{size:18})})]}),o.jsx("div",{className:"modal-body",children:n}),o.jsxs("div",{className:"modal-actions",children:[o.jsx("button",{className:"button button--secondary",type:"button",onClick:l,children:"取消"}),o.jsx("button",{className:`button button--${i}`,type:"button",disabled:s,onClick:r,children:t})]})]})})}function Zp({plan:e,status:n,selectedProvider:t,onCancel:r,onConfirm:l}){return o.jsxs(Ul,{title:e.mode==="switch"?"确认切换并同步":"确认同步元数据",confirmLabel:e.mode==="switch"?"确认切换并同步":"确认执行同步",onCancel:r,onConfirm:l,children:[o.jsxs("div",{className:"modal-callout",children:[o.jsx(Be,{size:18}),o.jsxs("div",{children:[o.jsx("strong",{children:"请确认 Codex 已完全关闭"}),o.jsx("span",{children:"关闭 Codex CLI、Codex App、app-server 及相关终端,避免 SQLite 或 rollout 被占用。"})]})]}),o.jsxs("dl",{className:"operation-scope",children:[o.jsxs("div",{children:[o.jsx("dt",{children:"Codex Home"}),o.jsx("dd",{children:n==null?void 0:n.codexHome})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"SQLite Home"}),o.jsxs("dd",{children:[n==null?void 0:n.sqliteHome," ",o.jsxs("small",{children:["(",n==null?void 0:n.sqliteHomeSource,")"]})]})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"当前 Provider"}),o.jsx("dd",{children:n==null?void 0:n.currentProvider})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"目标 Provider"}),o.jsx("dd",{children:t})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"配置变更"}),o.jsx("dd",{children:e.mode==="switch"?"更新 config.toml 根级 model_provider":"不修改 config.toml"})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"Model 策略"}),o.jsx("dd",{children:e.mode!=="switch"?"跟随当前根级 model":e.modelMode==="auto"?"跟随目标 Provider 配置":e.modelMode==="keep"?"保留当前根级 model":`设置为 ${e.model}`})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"备份策略"}),o.jsxs("dd",{children:["修改前创建备份,保留最近 ",e.keepCount," 份"]})]})]})]})}function Jp({backup:e,status:n,profile:t,onCancel:r,onConfirm:l}){var x,j;const[i,s]=E.useState(!1),[u,a]=E.useState(!0),[d,v]=E.useState(!0),m=_p(n,e),h=zp({backup:e,profile:t,targetSqliteHome:m,restoreDatabase:u,restoreConfig:i,sqliteSupported:((x=n==null?void 0:n.sqliteAccess)==null?void 0:x.supported)!==!1,pathComparisonCaseInsensitive:(n==null?void 0:n.pathComparisonCaseInsensitive)===!0});return o.jsxs(Ul,{title:"恢复备份",confirmLabel:"覆盖当前元数据",tone:"danger",onCancel:r,confirmDisabled:!i&&!u&&!d||!h.canSubmit,onConfirm:()=>l({restoreConfig:i,restoreDatabase:u,restoreSessions:d,allowSqliteHomeRelocation:h.requiresRelocation}),children:[o.jsxs("div",{className:"restore-summary",children:[o.jsx(Yn,{size:20}),o.jsxs("div",{children:[o.jsxs("strong",{children:[Et(e.metadata.createdAt)," · ",e.metadata.targetProvider]}),o.jsx("code",{children:e.path})]})]}),o.jsxs("fieldset",{className:"restore-options",children:[o.jsx("legend",{children:"选择要覆盖的内容"}),o.jsxs("label",{children:[o.jsx("input",{type:"checkbox",checked:i,onChange:S=>s(S.target.checked)}),o.jsxs("span",{children:[o.jsx("strong",{children:"config.toml 与 global state"}),o.jsx("small",{children:"恢复 Provider 配置和 Desktop workspace roots"})]})]}),o.jsxs("label",{children:[o.jsx("input",{type:"checkbox",checked:u,onChange:S=>a(S.target.checked)}),o.jsxs("span",{children:[o.jsx("strong",{children:"SQLite 线程数据库"}),o.jsx("small",{children:"恢复 state_5.sqlite 及备份中的 WAL/SHM"})]})]}),o.jsxs("label",{children:[o.jsx("input",{type:"checkbox",checked:d,onChange:S=>v(S.target.checked)}),o.jsxs("span",{children:[o.jsx("strong",{children:"Rollout 元数据"}),o.jsx("small",{children:"恢复 session_meta 和被修改的 turn_context.model"})]})]})]}),((j=n==null?void 0:n.sqliteAccess)==null?void 0:j.supported)===!1?o.jsxs("div",{className:"modal-callout modal-callout--danger",children:[o.jsx(Be,{size:18}),o.jsxs("div",{children:[o.jsx("strong",{children:"当前 SQLite 路径仅供诊断"}),o.jsx("span",{children:n.sqliteAccess.message||"不能从 Web UI 执行恢复。"})]})]}):null,h.requiresRelocation?o.jsxs("div",{className:`modal-callout ${h.missingExplicitTarget||h.configRestoreConflict?"modal-callout--danger":"modal-callout--warning"}`,children:[o.jsx(Be,{size:18}),o.jsxs("div",{children:[o.jsx("strong",{children:"SQLite Home 与备份来源不同"}),o.jsxs("span",{children:["来源:",e.metadata.sqliteHome,o.jsx("br",{}),"目标:",m,o.jsx("br",{}),h.missingExplicitTarget?"当前 Profile 未明确配置 SQLite Home,不能提交数据库迁移恢复。":h.configRestoreConflict?"迁移数据库时不能同时恢复旧 config.toml。":"确认后数据库将恢复到当前 Profile 明确配置的目标位置。"]})]})]}):null,o.jsxs("div",{className:"modal-callout",children:[o.jsx(Be,{size:18}),o.jsxs("div",{children:[o.jsx("strong",{children:"恢复前请关闭 Codex"}),o.jsx("span",{children:"该操作将覆盖所选的当前元数据;请确认 Codex CLI、App 和 app-server 已关闭。"})]})]})]})}function bp({keepCount:e,backups:n,onCancel:t,onConfirm:r}){const l=Math.max(0,n.backups.length-e);return o.jsxs(Ul,{title:"清理旧备份",confirmLabel:`删除 ${l} 份旧备份`,tone:"danger",onCancel:t,onConfirm:r,confirmDisabled:l===0,children:[o.jsxs("div",{className:"modal-callout modal-callout--warning",children:[o.jsx(Be,{size:18}),o.jsxs("div",{children:[o.jsx("strong",{children:"被删除的备份无法直接恢复"}),o.jsx("span",{children:"只处理当前 Codex Home 下由本工具管理的备份目录。"})]})]}),o.jsxs("dl",{className:"operation-scope",children:[o.jsxs("div",{children:[o.jsx("dt",{children:"当前备份"}),o.jsxs("dd",{children:[n.backups.length," 份"]})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"保留"}),o.jsxs("dd",{children:["最近 ",e," 份"]})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"将删除"}),o.jsxs("dd",{children:[l," 份"]})]})]})]})}function eh({onCancel:e,onConfirm:n}){const[t,r]=E.useState(""),[l,i]=E.useState(""),[s,u]=E.useState(""),[a,d]=E.useState(""),v=/^[A-Za-z0-9_.-]{1,80}$/.test(t)&&l.trim()&&s.trim();return o.jsx(Ul,{title:"新增存储配置",confirmLabel:"保存配置",onCancel:e,confirmDisabled:!v,onConfirm:()=>n({profileId:t,name:l,codexHome:s,sqliteHome:a}),children:o.jsxs("div",{className:"profile-form",children:[o.jsxs("label",{children:[o.jsx("span",{children:"配置 ID"}),o.jsx("input",{value:t,onChange:m=>r(m.target.value),placeholder:"work",spellCheck:"false"})]}),o.jsxs("label",{children:[o.jsx("span",{children:"显示名称"}),o.jsx("input",{value:l,onChange:m=>i(m.target.value),placeholder:"工作环境"})]}),o.jsxs("label",{children:[o.jsx("span",{children:"Codex Home"}),o.jsx("input",{value:s,onChange:m=>u(m.target.value),placeholder:"/home/user/.codex",spellCheck:"false"})]}),o.jsxs("label",{children:[o.jsx("span",{children:"SQLite Home(可选)"}),o.jsx("input",{value:a,onChange:m=>d(m.target.value),placeholder:"留空时由服务端按配置解析",spellCheck:"false"})]})]})})}function nh({toast:e,onClose:n}){return E.useEffect(()=>{if(!e)return;const t=window.setTimeout(n,6e3);return()=>window.clearTimeout(t)},[e,n]),e?o.jsxs("div",{className:`toast toast--${e.tone}`,children:[o.jsxs("div",{children:[e.tone==="success"?o.jsx(us,{size:18}):o.jsx(Be,{size:18}),o.jsxs("span",{children:[o.jsx("strong",{children:e.title}),e.message?o.jsx("small",{children:e.message}):null]})]}),o.jsx("button",{type:"button",onClick:n,"aria-label":"关闭通知",children:o.jsx(Ic,{size:16})})]}):null}function th(){const[e,n]=E.useState("overview"),[t,r]=E.useState("checking"),[l,i]=E.useState(""),[s,u]=An("cps.web.profileId","default"),[a,d]=E.useState([]),[v,m]=An("cps.web.manualProviders",[]),[h,x]=E.useState(null),[j,S]=E.useState(Op),[D,f]=E.useState(""),[c,p]=E.useState(!0),[g,C]=E.useState(!1),[y,N]=E.useState(null),[_,I]=E.useState(null),[z,G]=E.useState([]),[We,Ze]=E.useState(null),Je=E.useRef(0),fn=E.useMemo(()=>{const L=uo(h),O=new Set(L.map(Q=>Q.id));return[...L.map(Q=>({...Q,manual:v.includes(Q.id)})),...v.filter(Q=>!O.has(Q)).map(Q=>({id:Q,sources:["manual"],configured:!1,current:!1,manual:!0}))]},[h,v]),M=a.find(L=>L.id===s)??null,ie=E.useRef(null);ie.current||(ie.current=Rp({fetchStatus:(L,O)=>Fe("/api/status",L,O),fetchBackups:(L,O)=>Fe("/api/backups",L,O)}));const k=E.useCallback(async({quiet:L=!1}={})=>{await ie.current({profileId:s,showLoading:!L,onLoading:p,onResult:({status:O,backups:Q})=>{x(O),S(Q),f(b=>b&&uo(O).some(oe=>oe.id===b)?b:O.currentProvider)},onError:O=>{I({tone:"error",title:"状态读取失败",message:O.message})}})},[s]),R=E.useCallback(async()=>{const L=await yp();d(L.profiles),u(O=>L.profiles.some(Q=>Q.id===O)?O:"default")},[u]),T=E.useCallback(async()=>{N(null);try{await R(),await k({quiet:!0})}catch{}I({tone:"warning",title:"配置已变更,请重新确认",message:"已刷新存储配置和当前状态;未自动重试原操作。"})},[k,R]),A=E.useCallback(L=>{const O=kp(M,L,h);if(!O){I({tone:"warning",title:"配置需要刷新",message:"没有可用的配置版本,请刷新后重新确认操作。"}),R().catch(()=>{});return}N(O)},[R,M,h]);E.useEffect(()=>{let L=!1;return mp().then(async O=>{if(!O)throw new Sl;await R(),L||r("ready")}).catch(O=>{L||(r(O instanceof Sl?"required":"error"),i(O.message))}),()=>{L=!0}},[R]),E.useEffect(()=>{t==="ready"&&k()},[t,s]),E.useEffect(()=>{const L=O=>{r("required"),i(O.detail||"设备凭证已失效。请重新运行 codex-provider web。")};return window.addEventListener("cps:pairing-required",L),()=>window.removeEventListener("cps:pairing-required",L)},[]),E.useEffect(()=>{if(t!=="ready")return;let L=!1;const O=async()=>{var b;try{const oe=await gp(Je.current);if(L)return;(b=oe.activity)!=null&&b.length&&(Je.current=oe.activity[oe.activity.length-1].id,G(Ac=>[...Ac,...oe.activity].slice(-250))),Ze(oe.activeOperation??null)}catch{}};O();const Q=window.setInterval(O,900);return()=>{L=!0,window.clearInterval(Q)}},[t]);const q=E.useCallback(async()=>{var Q;const L=y.plan,O=y.profileId;N(null),C(!0),n("activity");try{const b={...yt(O),profileRevision:y.profileRevision,storageRevision:y.storageRevision,provider:y.selectedProvider,keepCount:L.keepCount},oe=L.mode==="switch"?await Fe("/api/switch",{...b,model:L.modelMode==="custom"?L.model:void 0,keepRootModel:L.modelMode==="keep"}):await Fe("/api/sync",b);I(pi(oe,{successTitle:L.mode==="switch"?"切换并同步完成":"同步完成",partialTitle:L.mode==="switch"?"切换并同步部分完成":"同步部分完成",message:`备份:${((Q=oe.result)==null?void 0:Q.backupDir)??"已创建"}`})),await k({quiet:!0})}catch(b){if(b instanceof bn){await T();return}I({tone:"error",title:"操作失败",message:b.message})}finally{C(!1)}},[T,y,k]),pn=E.useCallback(async L=>{const O=y.backup,Q=y.profileId;N(null),C(!0),n("activity");try{const b=await Fe("/api/restore",{...yt(Q),profileRevision:y.profileRevision,storageRevision:y.storageRevision,backupId:O.id,...L});I(pi(b,{successTitle:"备份恢复完成",partialTitle:"备份恢复部分完成",message:O.id})),await k({quiet:!0})}catch(b){if(b instanceof bn){await T();return}I({tone:"error",title:"恢复失败",message:b.message})}finally{C(!1)}},[T,y,k]),be=E.useCallback(async()=>{var Q,b;const L=y.keepCount,O=y.profileId;N(null),C(!0);try{const oe=await Fe("/api/prune",{...yt(O),profileRevision:y.profileRevision,storageRevision:y.storageRevision,keepCount:L});I(pi(oe,{successTitle:"旧备份清理完成",partialTitle:"旧备份清理部分完成",message:`删除 ${((Q=oe.result)==null?void 0:Q.deletedCount)??0} 份,释放 ${vr((b=oe.result)==null?void 0:b.freedBytes)}`})),await k({quiet:!0})}catch(oe){if(oe instanceof bn){await T();return}I({tone:"error",title:"备份清理失败",message:oe.message})}finally{C(!1)}},[T,y,k]),Rt=E.useCallback(()=>I(null),[]),en=E.useCallback(async L=>{try{const O=await Fe("/api/profiles/save",L.revision?{...L,profileRevision:L.revision}:L);N(null),await R(),u(O.profile.id),I({tone:"success",title:"存储配置已保存",message:O.profile.name})}catch(O){if(O instanceof bn){await T();return}I({tone:"error",title:"配置保存失败",message:O.message})}},[T,R,u]),Zn=E.useCallback(async()=>{try{await Fe("/api/profiles/delete",{profileId:s,profileRevision:M==null?void 0:M.revision}),u("default"),await R()}catch(L){if(L instanceof bn){await T();return}I({tone:"error",title:"配置删除失败",message:L.message})}},[T,s,R,M==null?void 0:M.revision,u]),Dc=E.useCallback(async()=>{await xp().catch(()=>{}),r("required"),i("此浏览器的设备凭证已失效。重新运行 codex-provider web 即可自动配对。")},[]),Fc=E.useCallback(L=>{m(O=>[...new Set([...O,L])].sort()),f(L)},[m]),$c=E.useCallback(L=>{m(O=>O.filter(Q=>Q!==L)),f((h==null?void 0:h.currentProvider)??"")},[m,h==null?void 0:h.currentProvider]);return t!=="ready"?o.jsxs("div",{className:"access-gate",children:[o.jsx(so,{size:32}),o.jsx("h1",{children:t==="checking"?"正在完成安全配对":"需要重新配对"}),o.jsx("p",{children:t==="checking"?"请稍候…":l||"请重新运行 codex-provider web。"}),t!=="checking"?o.jsx("code",{children:"codex-provider web"}):null]}):o.jsxs("div",{className:"app-shell",children:[o.jsx($p,{status:h,busy:g||!!We,onRefresh:()=>k()}),o.jsx(Ap,{view:e,setView:n,status:h,onForgetBrowser:Dc}),o.jsxs("main",{className:"main-area",children:[o.jsx(Up,{profiles:a,profileId:s,setProfileId:u,status:h,onAddProfile:()=>N({type:"profile"}),onDeleteProfile:Zn,onRefresh:()=>k(),loading:c,profileSwitchDisabled:g||!!y}),e==="overview"?o.jsx(Kp,{status:h,backups:j,providers:fn,selectedProvider:D,setSelectedProvider:f,onAddManualProvider:Fc,onRemoveManualProvider:$c,onExecute:L=>A({type:"execute",plan:L,selectedProvider:D}),onRestore:L=>A({type:"restore",backup:L}),setView:n,busy:g,loading:c}):null,e==="history"?o.jsx(Gp,{profileId:s,status:h}):null,e==="backups"?o.jsx(Yp,{backups:j,status:h,busy:g,onRestore:L=>A({type:"restore",backup:L}),onPrune:L=>A({type:"prune",keepCount:L})}):null,e==="activity"?o.jsx(Xp,{activity:z,activeOperation:We}):null]}),(y==null?void 0:y.type)==="execute"?o.jsx(Zp,{plan:y.plan,status:y.status,selectedProvider:y.selectedProvider,onCancel:()=>N(null),onConfirm:q}):null,(y==null?void 0:y.type)==="restore"?o.jsx(Jp,{backup:y.backup,status:y.status,profile:y.profile,onCancel:()=>N(null),onConfirm:pn}):null,(y==null?void 0:y.type)==="prune"?o.jsx(bp,{keepCount:y.keepCount,backups:j,onCancel:()=>N(null),onConfirm:be}):null,(y==null?void 0:y.type)==="profile"?o.jsx(eh,{onCancel:()=>N(null),onConfirm:en}):null,o.jsx(nh,{toast:_,onClose:Rt})]})}Tc(document.getElementById("root")).render(o.jsx(ho.StrictMode,{children:o.jsx(th,{})})); -//# sourceMappingURL=index-aed55189.js.map diff --git a/web/dist/assets/index-aed55189.js.map b/web/dist/assets/index-aed55189.js.map deleted file mode 100644 index df8638c..0000000 --- a/web/dist/assets/index-aed55189.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index-aed55189.js","sources":["../../../node_modules/react/cjs/react.production.min.js","../../../node_modules/react/index.js","../../../node_modules/react/cjs/react-jsx-runtime.production.min.js","../../../node_modules/react/jsx-runtime.js","../../../node_modules/scheduler/cjs/scheduler.production.min.js","../../../node_modules/scheduler/index.js","../../../node_modules/react-dom/cjs/react-dom.production.min.js","../../../node_modules/react-dom/index.js","../../../node_modules/react-dom/client.js","../../src/api.js","../../src/hooks.js","../../src/history-requests.js","../../src/operation-state.js","../../src/profile-refresh.js","../../src/icons.jsx","../../src/App.jsx","../../src/main.jsx"],"sourcesContent":["/**\n * @license React\n * react.production.min.js\n *\n * Copyright (c) Facebook, Inc. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n */\n'use strict';var l=Symbol.for(\"react.element\"),n=Symbol.for(\"react.portal\"),p=Symbol.for(\"react.fragment\"),q=Symbol.for(\"react.strict_mode\"),r=Symbol.for(\"react.profiler\"),t=Symbol.for(\"react.provider\"),u=Symbol.for(\"react.context\"),v=Symbol.for(\"react.forward_ref\"),w=Symbol.for(\"react.suspense\"),x=Symbol.for(\"react.memo\"),y=Symbol.for(\"react.lazy\"),z=Symbol.iterator;function A(a){if(null===a||\"object\"!==typeof a)return null;a=z&&a[z]||a[\"@@iterator\"];return\"function\"===typeof a?a:null}\nvar B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},C=Object.assign,D={};function E(a,b,e){this.props=a;this.context=b;this.refs=D;this.updater=e||B}E.prototype.isReactComponent={};\nE.prototype.setState=function(a,b){if(\"object\"!==typeof a&&\"function\"!==typeof a&&null!=a)throw Error(\"setState(...): takes an object of state variables to update or a function which returns an object of state variables.\");this.updater.enqueueSetState(this,a,b,\"setState\")};E.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,\"forceUpdate\")};function F(){}F.prototype=E.prototype;function G(a,b,e){this.props=a;this.context=b;this.refs=D;this.updater=e||B}var H=G.prototype=new F;\nH.constructor=G;C(H,E.prototype);H.isPureReactComponent=!0;var I=Array.isArray,J=Object.prototype.hasOwnProperty,K={current:null},L={key:!0,ref:!0,__self:!0,__source:!0};\nfunction M(a,b,e){var d,c={},k=null,h=null;if(null!=b)for(d in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(k=\"\"+b.key),b)J.call(b,d)&&!L.hasOwnProperty(d)&&(c[d]=b[d]);var g=arguments.length-2;if(1===g)c.children=e;else if(1>>1,e=a[d];if(0>>1;dg(C,c))ng(x,C)?(a[d]=x,a[n]=c,d=n):(a[d]=C,a[m]=c,d=m);else if(ng(x,c))a[d]=x,a[n]=c,d=n;else break a}}return b}\nfunction g(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}if(\"object\"===typeof performance&&\"function\"===typeof performance.now){var l=performance;exports.unstable_now=function(){return l.now()}}else{var p=Date,q=p.now();exports.unstable_now=function(){return p.now()-q}}var r=[],t=[],u=1,v=null,y=3,z=!1,A=!1,B=!1,D=\"function\"===typeof setTimeout?setTimeout:null,E=\"function\"===typeof clearTimeout?clearTimeout:null,F=\"undefined\"!==typeof setImmediate?setImmediate:null;\n\"undefined\"!==typeof navigator&&void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function G(a){for(var b=h(t);null!==b;){if(null===b.callback)k(t);else if(b.startTime<=a)k(t),b.sortIndex=b.expirationTime,f(r,b);else break;b=h(t)}}function H(a){B=!1;G(a);if(!A)if(null!==h(r))A=!0,I(J);else{var b=h(t);null!==b&&K(H,b.startTime-a)}}\nfunction J(a,b){A=!1;B&&(B=!1,E(L),L=-1);z=!0;var c=y;try{G(b);for(v=h(r);null!==v&&(!(v.expirationTime>b)||a&&!M());){var d=v.callback;if(\"function\"===typeof d){v.callback=null;y=v.priorityLevel;var e=d(v.expirationTime<=b);b=exports.unstable_now();\"function\"===typeof e?v.callback=e:v===h(r)&&k(r);G(b)}else k(r);v=h(r)}if(null!==v)var w=!0;else{var m=h(t);null!==m&&K(H,m.startTime-b);w=!1}return w}finally{v=null,y=c,z=!1}}var N=!1,O=null,L=-1,P=5,Q=-1;\nfunction M(){return exports.unstable_now()-Qa||125d?(a.sortIndex=c,f(t,a),null===h(r)&&a===h(t)&&(B?(E(L),L=-1):B=!0,K(H,c-d))):(a.sortIndex=e,f(r,a),A||z||(A=!0,I(J)));return a};\nexports.unstable_shouldYield=M;exports.unstable_wrapCallback=function(a){var b=y;return function(){var c=y;y=b;try{return a.apply(this,arguments)}finally{y=c}}};\n","'use strict';\n\nif (process.env.NODE_ENV === 'production') {\n module.exports = require('./cjs/scheduler.production.min.js');\n} else {\n module.exports = require('./cjs/scheduler.development.js');\n}\n","/**\n * @license React\n * react-dom.production.min.js\n *\n * Copyright (c) Facebook, Inc. and its affiliates.\n *\n * This source code is licensed under the MIT license found in the\n * LICENSE file in the root directory of this source tree.\n */\n/*\n Modernizr 3.0.0pre (Custom Build) | MIT\n*/\n'use strict';var aa=require(\"react\"),ca=require(\"scheduler\");function p(a){for(var b=\"https://reactjs.org/docs/error-decoder.html?invariant=\"+a,c=1;cb}return!1}function v(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}var z={};\n\"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style\".split(\" \").forEach(function(a){z[a]=new v(a,0,!1,a,null,!1,!1)});[[\"acceptCharset\",\"accept-charset\"],[\"className\",\"class\"],[\"htmlFor\",\"for\"],[\"httpEquiv\",\"http-equiv\"]].forEach(function(a){var b=a[0];z[b]=new v(b,1,!1,a[1],null,!1,!1)});[\"contentEditable\",\"draggable\",\"spellCheck\",\"value\"].forEach(function(a){z[a]=new v(a,2,!1,a.toLowerCase(),null,!1,!1)});\n[\"autoReverse\",\"externalResourcesRequired\",\"focusable\",\"preserveAlpha\"].forEach(function(a){z[a]=new v(a,2,!1,a,null,!1,!1)});\"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope\".split(\" \").forEach(function(a){z[a]=new v(a,3,!1,a.toLowerCase(),null,!1,!1)});\n[\"checked\",\"multiple\",\"muted\",\"selected\"].forEach(function(a){z[a]=new v(a,3,!0,a,null,!1,!1)});[\"capture\",\"download\"].forEach(function(a){z[a]=new v(a,4,!1,a,null,!1,!1)});[\"cols\",\"rows\",\"size\",\"span\"].forEach(function(a){z[a]=new v(a,6,!1,a,null,!1,!1)});[\"rowSpan\",\"start\"].forEach(function(a){z[a]=new v(a,5,!1,a.toLowerCase(),null,!1,!1)});var ra=/[\\-:]([a-z])/g;function sa(a){return a[1].toUpperCase()}\n\"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height\".split(\" \").forEach(function(a){var b=a.replace(ra,\nsa);z[b]=new v(b,1,!1,a,null,!1,!1)});\"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type\".split(\" \").forEach(function(a){var b=a.replace(ra,sa);z[b]=new v(b,1,!1,a,\"http://www.w3.org/1999/xlink\",!1,!1)});[\"xml:base\",\"xml:lang\",\"xml:space\"].forEach(function(a){var b=a.replace(ra,sa);z[b]=new v(b,1,!1,a,\"http://www.w3.org/XML/1998/namespace\",!1,!1)});[\"tabIndex\",\"crossOrigin\"].forEach(function(a){z[a]=new v(a,1,!1,a.toLowerCase(),null,!1,!1)});\nz.xlinkHref=new v(\"xlinkHref\",1,!1,\"xlink:href\",\"http://www.w3.org/1999/xlink\",!0,!1);[\"src\",\"href\",\"action\",\"formAction\"].forEach(function(a){z[a]=new v(a,1,!1,a.toLowerCase(),null,!0,!0)});\nfunction ta(a,b,c,d){var e=z.hasOwnProperty(b)?z[b]:null;if(null!==e?0!==e.type:d||!(2h||e[g]!==f[h]){var k=\"\\n\"+e[g].replace(\" at new \",\" at \");a.displayName&&k.includes(\"\")&&(k=k.replace(\"\",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{Na=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:\"\")?Ma(a):\"\"}\nfunction Pa(a){switch(a.tag){case 5:return Ma(a.type);case 16:return Ma(\"Lazy\");case 13:return Ma(\"Suspense\");case 19:return Ma(\"SuspenseList\");case 0:case 2:case 15:return a=Oa(a.type,!1),a;case 11:return a=Oa(a.type.render,!1),a;case 1:return a=Oa(a.type,!0),a;default:return\"\"}}\nfunction Qa(a){if(null==a)return null;if(\"function\"===typeof a)return a.displayName||a.name||null;if(\"string\"===typeof a)return a;switch(a){case ya:return\"Fragment\";case wa:return\"Portal\";case Aa:return\"Profiler\";case za:return\"StrictMode\";case Ea:return\"Suspense\";case Fa:return\"SuspenseList\"}if(\"object\"===typeof a)switch(a.$$typeof){case Ca:return(a.displayName||\"Context\")+\".Consumer\";case Ba:return(a._context.displayName||\"Context\")+\".Provider\";case Da:var b=a.render;a=a.displayName;a||(a=b.displayName||\nb.name||\"\",a=\"\"!==a?\"ForwardRef(\"+a+\")\":\"ForwardRef\");return a;case Ga:return b=a.displayName||null,null!==b?b:Qa(a.type)||\"Memo\";case Ha:b=a._payload;a=a._init;try{return Qa(a(b))}catch(c){}}return null}\nfunction Ra(a){var b=a.type;switch(a.tag){case 24:return\"Cache\";case 9:return(b.displayName||\"Context\")+\".Consumer\";case 10:return(b._context.displayName||\"Context\")+\".Provider\";case 18:return\"DehydratedFragment\";case 11:return a=b.render,a=a.displayName||a.name||\"\",b.displayName||(\"\"!==a?\"ForwardRef(\"+a+\")\":\"ForwardRef\");case 7:return\"Fragment\";case 5:return b;case 4:return\"Portal\";case 3:return\"Root\";case 6:return\"Text\";case 16:return Qa(b);case 8:return b===za?\"StrictMode\":\"Mode\";case 22:return\"Offscreen\";\ncase 12:return\"Profiler\";case 21:return\"Scope\";case 13:return\"Suspense\";case 19:return\"SuspenseList\";case 25:return\"TracingMarker\";case 1:case 0:case 17:case 2:case 14:case 15:if(\"function\"===typeof b)return b.displayName||b.name||null;if(\"string\"===typeof b)return b}return null}function Sa(a){switch(typeof a){case \"boolean\":case \"number\":case \"string\":case \"undefined\":return a;case \"object\":return a;default:return\"\"}}\nfunction Ta(a){var b=a.type;return(a=a.nodeName)&&\"input\"===a.toLowerCase()&&(\"checkbox\"===b||\"radio\"===b)}\nfunction Ua(a){var b=Ta(a)?\"checked\":\"value\",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=\"\"+a[b];if(!a.hasOwnProperty(b)&&\"undefined\"!==typeof c&&\"function\"===typeof c.get&&\"function\"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=\"\"+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=\"\"+a},stopTracking:function(){a._valueTracker=\nnull;delete a[b]}}}}function Va(a){a._valueTracker||(a._valueTracker=Ua(a))}function Wa(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d=\"\";a&&(d=Ta(a)?a.checked?\"true\":\"false\":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Xa(a){a=a||(\"undefined\"!==typeof document?document:void 0);if(\"undefined\"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}}\nfunction Ya(a,b){var c=b.checked;return A({},b,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function Za(a,b){var c=null==b.defaultValue?\"\":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Sa(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:\"checkbox\"===b.type||\"radio\"===b.type?null!=b.checked:null!=b.value}}function ab(a,b){b=b.checked;null!=b&&ta(a,\"checked\",b,!1)}\nfunction bb(a,b){ab(a,b);var c=Sa(b.value),d=b.type;if(null!=c)if(\"number\"===d){if(0===c&&\"\"===a.value||a.value!=c)a.value=\"\"+c}else a.value!==\"\"+c&&(a.value=\"\"+c);else if(\"submit\"===d||\"reset\"===d){a.removeAttribute(\"value\");return}b.hasOwnProperty(\"value\")?cb(a,b.type,c):b.hasOwnProperty(\"defaultValue\")&&cb(a,b.type,Sa(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)}\nfunction db(a,b,c){if(b.hasOwnProperty(\"value\")||b.hasOwnProperty(\"defaultValue\")){var d=b.type;if(!(\"submit\"!==d&&\"reset\"!==d||void 0!==b.value&&null!==b.value))return;b=\"\"+a._wrapperState.initialValue;c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;\"\"!==c&&(a.name=\"\");a.defaultChecked=!!a._wrapperState.initialChecked;\"\"!==c&&(a.name=c)}\nfunction cb(a,b,c){if(\"number\"!==b||Xa(a.ownerDocument)!==a)null==c?a.defaultValue=\"\"+a._wrapperState.initialValue:a.defaultValue!==\"\"+c&&(a.defaultValue=\"\"+c)}var eb=Array.isArray;\nfunction fb(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e\"+b.valueOf().toString()+\"\";for(b=mb.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild)}});\nfunction ob(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b}\nvar pb={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,\nzoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},qb=[\"Webkit\",\"ms\",\"Moz\",\"O\"];Object.keys(pb).forEach(function(a){qb.forEach(function(b){b=b+a.charAt(0).toUpperCase()+a.substring(1);pb[b]=pb[a]})});function rb(a,b,c){return null==b||\"boolean\"===typeof b||\"\"===b?\"\":c||\"number\"!==typeof b||0===b||pb.hasOwnProperty(a)&&pb[a]?(\"\"+b).trim():b+\"px\"}\nfunction sb(a,b){a=a.style;for(var c in b)if(b.hasOwnProperty(c)){var d=0===c.indexOf(\"--\"),e=rb(c,b[c],d);\"float\"===c&&(c=\"cssFloat\");d?a.setProperty(c,e):a[c]=e}}var tb=A({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});\nfunction ub(a,b){if(b){if(tb[a]&&(null!=b.children||null!=b.dangerouslySetInnerHTML))throw Error(p(137,a));if(null!=b.dangerouslySetInnerHTML){if(null!=b.children)throw Error(p(60));if(\"object\"!==typeof b.dangerouslySetInnerHTML||!(\"__html\"in b.dangerouslySetInnerHTML))throw Error(p(61));}if(null!=b.style&&\"object\"!==typeof b.style)throw Error(p(62));}}\nfunction vb(a,b){if(-1===a.indexOf(\"-\"))return\"string\"===typeof b.is;switch(a){case \"annotation-xml\":case \"color-profile\":case \"font-face\":case \"font-face-src\":case \"font-face-uri\":case \"font-face-format\":case \"font-face-name\":case \"missing-glyph\":return!1;default:return!0}}var wb=null;function xb(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode:a}var yb=null,zb=null,Ab=null;\nfunction Bb(a){if(a=Cb(a)){if(\"function\"!==typeof yb)throw Error(p(280));var b=a.stateNode;b&&(b=Db(b),yb(a.stateNode,a.type,b))}}function Eb(a){zb?Ab?Ab.push(a):Ab=[a]:zb=a}function Fb(){if(zb){var a=zb,b=Ab;Ab=zb=null;Bb(a);if(b)for(a=0;a>>=0;return 0===a?32:31-(pc(a)/qc|0)|0}var rc=64,sc=4194304;\nfunction tc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;\ndefault:return a}}function uc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=tc(h):(f&=g,0!==f&&(d=tc(f)))}else g=c&~e,0!==g?d=tc(g):0!==f&&(d=tc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&&(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0c;c++)b.push(a);return b}\nfunction Ac(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-oc(b);a[b]=c}function Bc(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0=be),ee=String.fromCharCode(32),fe=!1;\nfunction ge(a,b){switch(a){case \"keyup\":return-1!==$d.indexOf(b.keyCode);case \"keydown\":return 229!==b.keyCode;case \"keypress\":case \"mousedown\":case \"focusout\":return!0;default:return!1}}function he(a){a=a.detail;return\"object\"===typeof a&&\"data\"in a?a.data:null}var ie=!1;function je(a,b){switch(a){case \"compositionend\":return he(b);case \"keypress\":if(32!==b.which)return null;fe=!0;return ee;case \"textInput\":return a=b.data,a===ee&&fe?null:a;default:return null}}\nfunction ke(a,b){if(ie)return\"compositionend\"===a||!ae&&ge(a,b)?(a=nd(),md=ld=kd=null,ie=!1,a):null;switch(a){case \"paste\":return null;case \"keypress\":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=Je(c)}}function Le(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?Le(a,b.parentNode):\"contains\"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}\nfunction Me(){for(var a=window,b=Xa();b instanceof a.HTMLIFrameElement;){try{var c=\"string\"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;b=Xa(a.document)}return b}function Ne(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&(\"input\"===b&&(\"text\"===a.type||\"search\"===a.type||\"tel\"===a.type||\"url\"===a.type||\"password\"===a.type)||\"textarea\"===b||\"true\"===a.contentEditable)}\nfunction Oe(a){var b=Me(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&Le(c.ownerDocument.documentElement,c)){if(null!==d&&Ne(c))if(b=d.start,a=d.end,void 0===a&&(a=b),\"selectionStart\"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length);else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=Ke(c,f);var g=Ke(c,\nd);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});\"function\"===typeof c.focus&&c.focus();for(c=0;c=document.documentMode,Qe=null,Re=null,Se=null,Te=!1;\nfunction Ue(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;Te||null==Qe||Qe!==Xa(d)||(d=Qe,\"selectionStart\"in d&&Ne(d)?d={start:d.selectionStart,end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d={anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),Se&&Ie(Se,d)||(Se=d,d=oe(Re,\"onSelect\"),0Tf||(a.current=Sf[Tf],Sf[Tf]=null,Tf--)}function G(a,b){Tf++;Sf[Tf]=a.current;a.current=b}var Vf={},H=Uf(Vf),Wf=Uf(!1),Xf=Vf;function Yf(a,b){var c=a.type.contextTypes;if(!c)return Vf;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}\nfunction Zf(a){a=a.childContextTypes;return null!==a&&void 0!==a}function $f(){E(Wf);E(H)}function ag(a,b,c){if(H.current!==Vf)throw Error(p(168));G(H,b);G(Wf,c)}function bg(a,b,c){var d=a.stateNode;b=b.childContextTypes;if(\"function\"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(p(108,Ra(a)||\"Unknown\",e));return A({},c,d)}\nfunction cg(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||Vf;Xf=H.current;G(H,a);G(Wf,Wf.current);return!0}function dg(a,b,c){var d=a.stateNode;if(!d)throw Error(p(169));c?(a=bg(a,b,Xf),d.__reactInternalMemoizedMergedChildContext=a,E(Wf),E(H),G(H,a)):E(Wf);G(Wf,c)}var eg=null,fg=!1,gg=!1;function hg(a){null===eg?eg=[a]:eg.push(a)}function ig(a){fg=!0;hg(a)}\nfunction jg(){if(!gg&&null!==eg){gg=!0;var a=0,b=C;try{var c=eg;for(C=1;a>=g;e-=g;rg=1<<32-oc(b)+e|c<w?(x=u,u=null):x=u.sibling;var n=r(e,u,h[w],k);if(null===n){null===u&&(u=x);break}a&&u&&null===n.alternate&&b(e,u);g=f(n,g,w);null===m?l=n:m.sibling=n;m=n;u=x}if(w===h.length)return c(e,u),I&&tg(e,w),l;if(null===u){for(;ww?(x=m,m=null):x=m.sibling;var t=r(e,m,n.value,k);if(null===t){null===m&&(m=x);break}a&&m&&null===t.alternate&&b(e,m);g=f(t,g,w);null===u?l=t:u.sibling=t;u=t;m=x}if(n.done)return c(e,\nm),I&&tg(e,w),l;if(null===m){for(;!n.done;w++,n=h.next())n=q(e,n.value,k),null!==n&&(g=f(n,g,w),null===u?l=n:u.sibling=n,u=n);I&&tg(e,w);return l}for(m=d(e,m);!n.done;w++,n=h.next())n=y(m,e,w,n.value,k),null!==n&&(a&&null!==n.alternate&&m.delete(null===n.key?w:n.key),g=f(n,g,w),null===u?l=n:u.sibling=n,u=n);a&&m.forEach(function(a){return b(e,a)});I&&tg(e,w);return l}function J(a,d,f,h){\"object\"===typeof f&&null!==f&&f.type===ya&&null===f.key&&(f=f.props.children);if(\"object\"===typeof f&&null!==f){switch(f.$$typeof){case va:a:{for(var k=\nf.key,l=d;null!==l;){if(l.key===k){k=f.type;if(k===ya){if(7===l.tag){c(a,l.sibling);d=e(l,f.props.children);d.return=a;a=d;break a}}else if(l.elementType===k||\"object\"===typeof k&&null!==k&&k.$$typeof===Ha&&Ng(k)===l.type){c(a,l.sibling);d=e(l,f.props);d.ref=Lg(a,l,f);d.return=a;a=d;break a}c(a,l);break}else b(a,l);l=l.sibling}f.type===ya?(d=Tg(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=Rg(f.type,f.key,f.props,null,a.mode,h),h.ref=Lg(a,d,f),h.return=a,a=h)}return g(a);case wa:a:{for(l=f.key;null!==\nd;){if(d.key===l)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=Sg(f,a.mode,h);d.return=a;a=d}return g(a);case Ha:return l=f._init,J(a,d,l(f._payload),h)}if(eb(f))return n(a,d,f,h);if(Ka(f))return t(a,d,f,h);Mg(a,f)}return\"string\"===typeof f&&\"\"!==f||\"number\"===typeof f?(f=\"\"+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):\n(c(a,d),d=Qg(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return J}var Ug=Og(!0),Vg=Og(!1),Wg=Uf(null),Xg=null,Yg=null,Zg=null;function $g(){Zg=Yg=Xg=null}function ah(a){var b=Wg.current;E(Wg);a._currentValue=b}function bh(a,b,c){for(;null!==a;){var d=a.alternate;(a.childLanes&b)!==b?(a.childLanes|=b,null!==d&&(d.childLanes|=b)):null!==d&&(d.childLanes&b)!==b&&(d.childLanes|=b);if(a===c)break;a=a.return}}\nfunction ch(a,b){Xg=a;Zg=Yg=null;a=a.dependencies;null!==a&&null!==a.firstContext&&(0!==(a.lanes&b)&&(dh=!0),a.firstContext=null)}function eh(a){var b=a._currentValue;if(Zg!==a)if(a={context:a,memoizedValue:b,next:null},null===Yg){if(null===Xg)throw Error(p(308));Yg=a;Xg.dependencies={lanes:0,firstContext:a}}else Yg=Yg.next=a;return b}var fh=null;function gh(a){null===fh?fh=[a]:fh.push(a)}\nfunction hh(a,b,c,d){var e=b.interleaved;null===e?(c.next=c,gh(b)):(c.next=e.next,e.next=c);b.interleaved=c;return ih(a,d)}function ih(a,b){a.lanes|=b;var c=a.alternate;null!==c&&(c.lanes|=b);c=a;for(a=a.return;null!==a;)a.childLanes|=b,c=a.alternate,null!==c&&(c.childLanes|=b),c=a,a=a.return;return 3===c.tag?c.stateNode:null}var jh=!1;function kh(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}\nfunction lh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue={baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate,shared:a.shared,effects:a.effects})}function mh(a,b){return{eventTime:a,lane:b,tag:0,payload:null,callback:null,next:null}}\nfunction nh(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(K&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;return ih(a,c)}e=d.interleaved;null===e?(b.next=b,gh(d)):(b.next=e.next,e.next=b);d.interleaved=b;return ih(a,c)}function oh(a,b,c){b=b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194240))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;Cc(a,c)}}\nfunction ph(a,b){var c=a.updateQueue,d=a.alternate;if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={eventTime:c.eventTime,lane:c.lane,tag:c.tag,payload:c.payload,callback:c.callback,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f,shared:d.shared,effects:d.effects};a.updateQueue=c;return}a=c.lastBaseUpdate;null===a?c.firstBaseUpdate=b:a.next=\nb;c.lastBaseUpdate=b}\nfunction qh(a,b,c,d){var e=a.updateQueue;jh=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,l=k.next;k.next=null;null===g?f=l:g.next=l;g=k;var m=a.alternate;null!==m&&(m=m.updateQueue,h=m.lastBaseUpdate,h!==g&&(null===h?m.firstBaseUpdate=l:h.next=l,m.lastBaseUpdate=k))}if(null!==f){var q=e.baseState;g=0;m=l=k=null;h=f;do{var r=h.lane,y=h.eventTime;if((d&r)===r){null!==m&&(m=m.next={eventTime:y,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,\nnext:null});a:{var n=a,t=h;r=b;y=c;switch(t.tag){case 1:n=t.payload;if(\"function\"===typeof n){q=n.call(y,q,r);break a}q=n;break a;case 3:n.flags=n.flags&-65537|128;case 0:n=t.payload;r=\"function\"===typeof n?n.call(y,q,r):n;if(null===r||void 0===r)break a;q=A({},q,r);break a;case 2:jh=!0}}null!==h.callback&&0!==h.lane&&(a.flags|=64,r=e.effects,null===r?e.effects=[h]:r.push(h))}else y={eventTime:y,lane:r,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===m?(l=m=y,k=q):m=m.next=y,g|=r;\nh=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else r=h,h=r.next,r.next=null,e.lastBaseUpdate=r,e.shared.pending=null}while(1);null===m&&(k=q);e.baseState=k;e.firstBaseUpdate=l;e.lastBaseUpdate=m;b=e.shared.interleaved;if(null!==b){e=b;do g|=e.lane,e=e.next;while(e!==b)}else null===f&&(e.shared.lanes=0);rh|=g;a.lanes=g;a.memoizedState=q}}\nfunction sh(a,b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;bc?c:4;a(!0);var d=Gh.transition;Gh.transition={};try{a(!1),b()}finally{C=c,Gh.transition=d}}function wi(){return Uh().memoizedState}\nfunction xi(a,b,c){var d=yi(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(zi(a))Ai(b,c);else if(c=hh(a,b,c,d),null!==c){var e=R();gi(c,a,d,e);Bi(c,b,d)}}\nfunction ii(a,b,c){var d=yi(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(zi(a))Ai(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(He(h,g)){var k=b.interleaved;null===k?(e.next=e,gh(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(l){}finally{}c=hh(a,b,e,d);null!==c&&(e=R(),gi(c,a,d,e),Bi(c,b,d))}}\nfunction zi(a){var b=a.alternate;return a===M||null!==b&&b===M}function Ai(a,b){Jh=Ih=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function Bi(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;Cc(a,c)}}\nvar Rh={readContext:eh,useCallback:P,useContext:P,useEffect:P,useImperativeHandle:P,useInsertionEffect:P,useLayoutEffect:P,useMemo:P,useReducer:P,useRef:P,useState:P,useDebugValue:P,useDeferredValue:P,useTransition:P,useMutableSource:P,useSyncExternalStore:P,useId:P,unstable_isNewReconciler:!1},Oh={readContext:eh,useCallback:function(a,b){Th().memoizedState=[a,void 0===b?null:b];return a},useContext:eh,useEffect:mi,useImperativeHandle:function(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return ki(4194308,\n4,pi.bind(null,b,a),c)},useLayoutEffect:function(a,b){return ki(4194308,4,a,b)},useInsertionEffect:function(a,b){return ki(4,2,a,b)},useMemo:function(a,b){var c=Th();b=void 0===b?null:b;a=a();c.memoizedState=[a,b];return a},useReducer:function(a,b,c){var d=Th();b=void 0!==c?c(b):b;d.memoizedState=d.baseState=b;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:a,lastRenderedState:b};d.queue=a;a=a.dispatch=xi.bind(null,M,a);return[d.memoizedState,a]},useRef:function(a){var b=\nTh();a={current:a};return b.memoizedState=a},useState:hi,useDebugValue:ri,useDeferredValue:function(a){return Th().memoizedState=a},useTransition:function(){var a=hi(!1),b=a[0];a=vi.bind(null,a[1]);Th().memoizedState=a;return[b,a]},useMutableSource:function(){},useSyncExternalStore:function(a,b,c){var d=M,e=Th();if(I){if(void 0===c)throw Error(p(407));c=c()}else{c=b();if(null===Q)throw Error(p(349));0!==(Hh&30)||di(d,b,c)}e.memoizedState=c;var f={value:c,getSnapshot:b};e.queue=f;mi(ai.bind(null,d,\nf,a),[a]);d.flags|=2048;bi(9,ci.bind(null,d,f,c,b),void 0,null);return c},useId:function(){var a=Th(),b=Q.identifierPrefix;if(I){var c=sg;var d=rg;c=(d&~(1<<32-oc(d)-1)).toString(32)+c;b=\":\"+b+\"R\"+c;c=Kh++;0\\x3c/script>\",a=a.removeChild(a.firstChild)):\n\"string\"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),\"select\"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Of]=b;a[Pf]=d;zj(a,b,!1,!1);b.stateNode=a;a:{g=vb(c,d);switch(c){case \"dialog\":D(\"cancel\",a);D(\"close\",a);e=d;break;case \"iframe\":case \"object\":case \"embed\":D(\"load\",a);e=d;break;case \"video\":case \"audio\":for(e=0;eGj&&(b.flags|=128,d=!0,Dj(f,!1),b.lanes=4194304)}else{if(!d)if(a=Ch(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Dj(f,!0),null===f.tail&&\"hidden\"===f.tailMode&&!g.alternate&&!I)return S(b),null}else 2*B()-f.renderingStartTime>Gj&&1073741824!==c&&(b.flags|=128,d=!0,Dj(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering=\nb,f.tail=b.sibling,f.renderingStartTime=B(),b.sibling=null,c=L.current,G(L,d?c&1|2:c&1),b;S(b);return null;case 22:case 23:return Hj(),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(fj&1073741824)&&(S(b),b.subtreeFlags&6&&(b.flags|=8192)):S(b),null;case 24:return null;case 25:return null}throw Error(p(156,b.tag));}\nfunction Ij(a,b){wg(b);switch(b.tag){case 1:return Zf(b.type)&&$f(),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return zh(),E(Wf),E(H),Eh(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return Bh(b),null;case 13:E(L);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(p(340));Ig()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return E(L),null;case 4:return zh(),null;case 10:return ah(b.type._context),null;case 22:case 23:return Hj(),\nnull;case 24:return null;default:return null}}var Jj=!1,U=!1,Kj=\"function\"===typeof WeakSet?WeakSet:Set,V=null;function Lj(a,b){var c=a.ref;if(null!==c)if(\"function\"===typeof c)try{c(null)}catch(d){W(a,b,d)}else c.current=null}function Mj(a,b,c){try{c()}catch(d){W(a,b,d)}}var Nj=!1;\nfunction Oj(a,b){Cf=dd;a=Me();if(Ne(a)){if(\"selectionStart\"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection();if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(F){c=null;break a}var g=0,h=-1,k=-1,l=0,m=0,q=a,r=null;b:for(;;){for(var y;;){q!==c||0!==e&&3!==q.nodeType||(h=g+e);q!==f||0!==d&&3!==q.nodeType||(k=g+d);3===q.nodeType&&(g+=\nq.nodeValue.length);if(null===(y=q.firstChild))break;r=q;q=y}for(;;){if(q===a)break b;r===c&&++l===e&&(h=g);r===f&&++m===d&&(k=g);if(null!==(y=q.nextSibling))break;q=r;r=q.parentNode}q=y}c=-1===h||-1===k?null:{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Df={focusedElem:a,selectionRange:c};dd=!1;for(V=b;null!==V;)if(b=V,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,V=a;else for(;null!==V;){b=V;try{var n=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break;\ncase 1:if(null!==n){var t=n.memoizedProps,J=n.memoizedState,x=b.stateNode,w=x.getSnapshotBeforeUpdate(b.elementType===b.type?t:Ci(b.type,t),J);x.__reactInternalSnapshotBeforeUpdate=w}break;case 3:var u=b.stateNode.containerInfo;1===u.nodeType?u.textContent=\"\":9===u.nodeType&&u.documentElement&&u.removeChild(u.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(p(163));}}catch(F){W(b,b.return,F)}a=b.sibling;if(null!==a){a.return=b.return;V=a;break}V=b.return}n=Nj;Nj=!1;return n}\nfunction Pj(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&Mj(b,c,f)}e=e.next}while(e!==d)}}function Qj(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Rj(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}\"function\"===typeof b?b(a):b.current=a}}\nfunction Sj(a){var b=a.alternate;null!==b&&(a.alternate=null,Sj(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Of],delete b[Pf],delete b[of],delete b[Qf],delete b[Rf]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Tj(a){return 5===a.tag||3===a.tag||4===a.tag}\nfunction Uj(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Tj(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags&2))return a.stateNode}}\nfunction Vj(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=Bf));else if(4!==d&&(a=a.child,null!==a))for(Vj(a,b,c),a=a.sibling;null!==a;)Vj(a,b,c),a=a.sibling}\nfunction Wj(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a);else if(4!==d&&(a=a.child,null!==a))for(Wj(a,b,c),a=a.sibling;null!==a;)Wj(a,b,c),a=a.sibling}var X=null,Xj=!1;function Yj(a,b,c){for(c=c.child;null!==c;)Zj(a,b,c),c=c.sibling}\nfunction Zj(a,b,c){if(lc&&\"function\"===typeof lc.onCommitFiberUnmount)try{lc.onCommitFiberUnmount(kc,c)}catch(h){}switch(c.tag){case 5:U||Lj(c,b);case 6:var d=X,e=Xj;X=null;Yj(a,b,c);X=d;Xj=e;null!==X&&(Xj?(a=X,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):X.removeChild(c.stateNode));break;case 18:null!==X&&(Xj?(a=X,c=c.stateNode,8===a.nodeType?Kf(a.parentNode,c):1===a.nodeType&&Kf(a,c),bd(a)):Kf(X,c.stateNode));break;case 4:d=X;e=Xj;X=c.stateNode.containerInfo;Xj=!0;\nYj(a,b,c);X=d;Xj=e;break;case 0:case 11:case 14:case 15:if(!U&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?Mj(c,b,g):0!==(f&4)&&Mj(c,b,g));e=e.next}while(e!==d)}Yj(a,b,c);break;case 1:if(!U&&(Lj(c,b),d=c.stateNode,\"function\"===typeof d.componentWillUnmount))try{d.props=c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){W(c,b,h)}Yj(a,b,c);break;case 21:Yj(a,b,c);break;case 22:c.mode&1?(U=(d=U)||null!==\nc.memoizedState,Yj(a,b,c),U=d):Yj(a,b,c);break;default:Yj(a,b,c)}}function ak(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Kj);b.forEach(function(b){var d=bk.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}}\nfunction ck(a,b){var c=b.deletions;if(null!==c)for(var d=0;de&&(e=g);d&=~f}d=e;d=B()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*lk(d/1960))-d;if(10a?16:a;if(null===wk)var d=!1;else{a=wk;wk=null;xk=0;if(0!==(K&6))throw Error(p(331));var e=K;K|=4;for(V=a.current;null!==V;){var f=V,g=f.child;if(0!==(V.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;kB()-fk?Kk(a,0):rk|=c);Dk(a,b)}function Yk(a,b){0===b&&(0===(a.mode&1)?b=1:(b=sc,sc<<=1,0===(sc&130023424)&&(sc=4194304)));var c=R();a=ih(a,b);null!==a&&(Ac(a,b,c),Dk(a,c))}function uj(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Yk(a,c)}\nfunction bk(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane);break;case 19:d=a.stateNode;break;default:throw Error(p(314));}null!==d&&d.delete(b);Yk(a,c)}var Vk;\nVk=function(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps||Wf.current)dh=!0;else{if(0===(a.lanes&c)&&0===(b.flags&128))return dh=!1,yj(a,b,c);dh=0!==(a.flags&131072)?!0:!1}else dh=!1,I&&0!==(b.flags&1048576)&&ug(b,ng,b.index);b.lanes=0;switch(b.tag){case 2:var d=b.type;ij(a,b);a=b.pendingProps;var e=Yf(b,H.current);ch(b,c);e=Nh(null,b,d,a,e,c);var f=Sh();b.flags|=1;\"object\"===typeof e&&null!==e&&\"function\"===typeof e.render&&void 0===e.$$typeof?(b.tag=1,b.memoizedState=null,b.updateQueue=\nnull,Zf(d)?(f=!0,cg(b)):f=!1,b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null,kh(b),e.updater=Ei,b.stateNode=e,e._reactInternals=b,Ii(b,d,a,c),b=jj(null,b,d,!0,f,c)):(b.tag=0,I&&f&&vg(b),Xi(null,b,e,c),b=b.child);return b;case 16:d=b.elementType;a:{ij(a,b);a=b.pendingProps;e=d._init;d=e(d._payload);b.type=d;e=b.tag=Zk(d);a=Ci(d,a);switch(e){case 0:b=cj(null,b,d,a,c);break a;case 1:b=hj(null,b,d,a,c);break a;case 11:b=Yi(null,b,d,a,c);break a;case 14:b=$i(null,b,d,Ci(d.type,a),c);break a}throw Error(p(306,\nd,\"\"));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Ci(d,e),cj(a,b,d,e,c);case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Ci(d,e),hj(a,b,d,e,c);case 3:a:{kj(b);if(null===a)throw Error(p(387));d=b.pendingProps;f=b.memoizedState;e=f.element;lh(a,b);qh(b,d,null,c);var g=b.memoizedState;d=g.element;if(f.isDehydrated)if(f={element:d,isDehydrated:!1,cache:g.cache,pendingSuspenseBoundaries:g.pendingSuspenseBoundaries,transitions:g.transitions},b.updateQueue.baseState=\nf,b.memoizedState=f,b.flags&256){e=Ji(Error(p(423)),b);b=lj(a,b,d,c,e);break a}else if(d!==e){e=Ji(Error(p(424)),b);b=lj(a,b,d,c,e);break a}else for(yg=Lf(b.stateNode.containerInfo.firstChild),xg=b,I=!0,zg=null,c=Vg(b,null,d,c),b.child=c;c;)c.flags=c.flags&-3|4096,c=c.sibling;else{Ig();if(d===e){b=Zi(a,b,c);break a}Xi(a,b,d,c)}b=b.child}return b;case 5:return Ah(b),null===a&&Eg(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps:null,g=e.children,Ef(d,e)?g=null:null!==f&&Ef(d,f)&&(b.flags|=32),\ngj(a,b),Xi(a,b,g,c),b.child;case 6:return null===a&&Eg(b),null;case 13:return oj(a,b,c);case 4:return yh(b,b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Ug(b,null,d,c):Xi(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Ci(d,e),Yi(a,b,d,e,c);case 7:return Xi(a,b,b.pendingProps,c),b.child;case 8:return Xi(a,b,b.pendingProps.children,c),b.child;case 12:return Xi(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;f=b.memoizedProps;\ng=e.value;G(Wg,d._currentValue);d._currentValue=g;if(null!==f)if(He(f.value,g)){if(f.children===e.children&&!Wf.current){b=Zi(a,b,c);break a}}else for(f=b.child,null!==f&&(f.return=b);null!==f;){var h=f.dependencies;if(null!==h){g=f.child;for(var k=h.firstContext;null!==k;){if(k.context===d){if(1===f.tag){k=mh(-1,c&-c);k.tag=2;var l=f.updateQueue;if(null!==l){l=l.shared;var m=l.pending;null===m?k.next=k:(k.next=m.next,m.next=k);l.pending=k}}f.lanes|=c;k=f.alternate;null!==k&&(k.lanes|=c);bh(f.return,\nc,b);h.lanes|=c;break}k=k.next}}else if(10===f.tag)g=f.type===b.type?null:f.child;else if(18===f.tag){g=f.return;if(null===g)throw Error(p(341));g.lanes|=c;h=g.alternate;null!==h&&(h.lanes|=c);bh(g,c,b);g=f.sibling}else g=f.child;if(null!==g)g.return=f;else for(g=f;null!==g;){if(g===b){g=null;break}f=g.sibling;if(null!==f){f.return=g.return;g=f;break}g=g.return}f=g}Xi(a,b,e.children,c);b=b.child}return b;case 9:return e=b.type,d=b.pendingProps.children,ch(b,c),e=eh(e),d=d(e),b.flags|=1,Xi(a,b,d,c),\nb.child;case 14:return d=b.type,e=Ci(d,b.pendingProps),e=Ci(d.type,e),$i(a,b,d,e,c);case 15:return bj(a,b,b.type,b.pendingProps,c);case 17:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:Ci(d,e),ij(a,b),b.tag=1,Zf(d)?(a=!0,cg(b)):a=!1,ch(b,c),Gi(b,d,e),Ii(b,d,e,c),jj(null,b,d,!0,a,c);case 19:return xj(a,b,c);case 22:return dj(a,b,c)}throw Error(p(156,b.tag));};function Fk(a,b){return ac(a,b)}\nfunction $k(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function Bg(a,b,c,d){return new $k(a,b,c,d)}function aj(a){a=a.prototype;return!(!a||!a.isReactComponent)}\nfunction Zk(a){if(\"function\"===typeof a)return aj(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===Da)return 11;if(a===Ga)return 14}return 2}\nfunction Pg(a,b){var c=a.alternate;null===c?(c=Bg(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child=a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext};\nc.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c}\nfunction Rg(a,b,c,d,e,f){var g=2;d=a;if(\"function\"===typeof a)aj(a)&&(g=1);else if(\"string\"===typeof a)g=5;else a:switch(a){case ya:return Tg(c.children,e,f,b);case za:g=8;e|=8;break;case Aa:return a=Bg(12,c,b,e|2),a.elementType=Aa,a.lanes=f,a;case Ea:return a=Bg(13,c,b,e),a.elementType=Ea,a.lanes=f,a;case Fa:return a=Bg(19,c,b,e),a.elementType=Fa,a.lanes=f,a;case Ia:return pj(c,e,f,b);default:if(\"object\"===typeof a&&null!==a)switch(a.$$typeof){case Ba:g=10;break a;case Ca:g=9;break a;case Da:g=11;\nbreak a;case Ga:g=14;break a;case Ha:g=16;d=null;break a}throw Error(p(130,null==a?a:typeof a,\"\"));}b=Bg(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function Tg(a,b,c,d){a=Bg(7,a,d,b);a.lanes=c;return a}function pj(a,b,c,d){a=Bg(22,a,d,b);a.elementType=Ia;a.lanes=c;a.stateNode={isHidden:!1};return a}function Qg(a,b,c){a=Bg(6,a,null,b);a.lanes=c;return a}\nfunction Sg(a,b,c){b=Bg(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b}\nfunction al(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority=0;this.eventTimes=zc(0);this.expirationTimes=zc(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=zc(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData=\nnull}function bl(a,b,c,d,e,f,g,h,k){a=new al(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=Bg(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null,pendingSuspenseBoundaries:null};kh(f);return a}function cl(a,b,c){var d=3 ({}));\r\n if (!response.ok || !payload.deviceCredential) {\r\n throw new PairingRequiredError(payload.error ?? \"配对链接无效、已过期或已被使用。请重新运行 codex-provider web。\");\r\n }\r\n window.localStorage.setItem(DEVICE_STORAGE_KEY, payload.deviceCredential);\r\n window.history.replaceState(null, \"\", `${window.location.pathname}${window.location.search}`);\r\n return true;\r\n}\r\n\r\nfunction deviceHeaders() {\r\n return { \"X-Codex-Provider-Device\": window.localStorage.getItem(DEVICE_STORAGE_KEY) ?? \"\" };\r\n}\r\n\r\nexport function toRequestError(payload, status, fallback) {\n if (status === 409 && [\n \"PROFILE_REVISION_REQUIRED\",\n \"PROFILE_CHANGED\",\n \"STORAGE_REVISION_REQUIRED\",\n \"STORAGE_CHANGED\"\n ].includes(payload.code)) {\n return new ProfileRevisionError(payload.code, payload.error, payload.profile);\n }\n return new Error(payload.error ?? fallback);\r\n}\r\n\r\nasync function parseResponse(response, fallback) {\r\n const payload = await response.json().catch(() => ({}));\r\n if (!response.ok) {\r\n if (payload.code === \"PAIRING_REQUIRED\") {\r\n window.dispatchEvent(new CustomEvent(\"cps:pairing-required\", { detail: payload.error }));\r\n throw new PairingRequiredError(payload.error);\r\n }\r\n throw toRequestError(payload, response.status, fallback);\r\n }\r\n return payload;\r\n}\r\n\r\nexport async function apiRequest(path, body = {}, { signal } = {}) {\r\n const response = await fetch(path, {\r\n method: \"POST\",\r\n headers: {\r\n \"Content-Type\": \"application/json\",\r\n ...deviceHeaders()\r\n },\r\n body: JSON.stringify(body),\r\n signal\r\n });\r\n return parseResponse(response, `Request failed with HTTP ${response.status}.`);\r\n}\r\n\r\nexport async function getActivity(after = 0) {\r\n const response = await fetch(`/api/activity?after=${after}`, {\r\n headers: deviceHeaders()\r\n });\r\n return parseResponse(response, `Activity request failed with HTTP ${response.status}.`);\r\n}\r\n\r\nexport async function getProfiles() {\r\n const response = await fetch(\"/api/profiles\", { headers: deviceHeaders() });\r\n return parseResponse(response, `Profile request failed with HTTP ${response.status}.`);\r\n}\r\n\r\nexport async function forgetThisBrowser() {\r\n try {\r\n await apiRequest(\"/api/access/forget\");\r\n } finally {\r\n window.localStorage.removeItem(DEVICE_STORAGE_KEY);\r\n }\r\n}\r\n\r\nexport async function getHistory(body = {}, options = {}) {\r\n return apiRequest(\"/api/history\", body, options);\r\n}\r\n\r\nexport async function getHistorySession(body = {}, options = {}) {\r\n return apiRequest(\"/api/history/session\", body, options);\r\n}\r\n","import { useEffect, useState } from \"react\";\r\n\r\nexport function usePersistentState(key, initialValue) {\r\n const [value, setValue] = useState(() => {\r\n try {\r\n const stored = window.localStorage.getItem(key);\r\n return stored ? JSON.parse(stored) : initialValue;\r\n } catch {\r\n return initialValue;\r\n }\r\n });\r\n\r\n useEffect(() => {\r\n window.localStorage.setItem(key, JSON.stringify(value));\r\n }, [key, value]);\r\n\r\n return [value, setValue];\r\n}\r\n","export function createLatestRequestGate() {\r\n let sequence = 0;\r\n let controller = null;\r\n return {\r\n begin() {\r\n controller?.abort();\r\n controller = new AbortController();\r\n sequence += 1;\r\n return { sequence, controller, signal: controller.signal };\r\n },\r\n isLatest(candidate) {\r\n return candidate === sequence;\r\n },\r\n cancel() {\r\n controller?.abort();\r\n sequence += 1;\r\n }\r\n };\r\n}\r\n\r\nexport function scheduleDebounced(callback, delay = 300, timers = globalThis) {\r\n const id = timers.setTimeout(callback, delay);\r\n return () => timers.clearTimeout(id);\r\n}\r\n","export function captureProfileOperation(profile, operation, status) {\n if (!profile?.id\n || !profile?.revision\n || status?.profileId !== profile.id\n || status?.profileRevision !== profile.revision\n || !status?.storageRevision) return null;\n return {\n ...operation,\n profile: { ...profile },\n profileId: profile.id,\n profileRevision: profile.revision,\n storageRevision: status.storageRevision,\n status: { ...status }\n };\n}\n\r\nexport function skippedLockedRolloutFiles(payload) {\r\n const files = payload?.result?.skippedLockedRolloutFiles ?? payload?.skippedLockedRolloutFiles ?? [];\r\n return Array.isArray(files) ? files.filter(Boolean) : [];\r\n}\r\n\r\nexport function operationToast(payload, { successTitle, partialTitle, message }) {\r\n const skipped = skippedLockedRolloutFiles(payload);\r\n const partial = payload?.result?.outcome === \"partial\" || payload?.outcome === \"partial\" || skipped.length > 0;\r\n if (!partial) return { tone: \"success\", title: successTitle, message };\r\n const skippedDetail = skipped.length\r\n ? `已跳过 ${skipped.length} 个被占用的 rollout 文件:${skipped.join(\"、\")}`\r\n : \"部分项目未完成;请查看活动日志后重试。\";\r\n return {\r\n tone: \"warning\",\r\n title: partialTitle,\r\n message: [message, skippedDetail].filter(Boolean).join(\";\")\r\n };\r\n}\r\n\r\nfunction normalizeHistoryPath(path, platform) {\r\n const normalized = String(path ?? \"\").replaceAll(\"\\\\\", \"/\").replace(/\\/{2,}/g, \"/\").replace(/\\/$/, \"\");\r\n return platform === \"win32\" ? normalized.toLowerCase() : normalized;\r\n}\r\n\r\nexport function dedupeHistorySessions(sessions = [], { platform = typeof process !== \"undefined\" ? process.platform : \"browser\" } = {}) {\n const seen = new Set();\r\n return sessions.filter((session, index) => {\r\n const threadId = session?.threadId ?? session?.id;\r\n const rolloutPath = session?.rolloutPath ?? session?.filePath ?? session?.rolloutFile ?? session?.path;\r\n const key = threadId ? `thread:${threadId}` : rolloutPath ? `rollout:${normalizeHistoryPath(rolloutPath, platform)}` : `item:${index}`;\r\n if (seen.has(key)) return false;\r\n seen.add(key);\r\n return true;\r\n });\n}\n\nfunction normalizeStoragePath(value, { caseInsensitive = false } = {}) {\n const normalized = String(value ?? \"\").replace(/[\\\\/]+$/, \"\").replaceAll(\"\\\\\", \"/\");\n return caseInsensitive ? normalized.toLowerCase() : normalized;\n}\n\nfunction storagePathsEqual(left, right, options) {\n return Boolean(left && right && normalizeStoragePath(left, options) === normalizeStoragePath(right, options));\n}\n\nfunction storageParentPath(value) {\n const normalized = String(value ?? \"\").replace(/[\\\\/]+$/, \"\");\n const separatorIndex = Math.max(normalized.lastIndexOf(\"/\"), normalized.lastIndexOf(\"\\\\\"));\n if (separatorIndex < 0) return \"\";\n if (separatorIndex === 0) return normalized.slice(0, 1);\n if (separatorIndex === 2 && /^[A-Za-z]:/.test(normalized)) return normalized.slice(0, 3);\n return normalized.slice(0, separatorIndex);\n}\n\nexport function resolveRestoreTargetSqliteHome(status, backup) {\n const currentDatabasePath = status?.stateDbLocation?.path;\n if (currentDatabasePath) return storageParentPath(currentDatabasePath);\n const backupMetadata = backup?.metadata;\n const comparison = { caseInsensitive: status?.pathComparisonCaseInsensitive === true };\n if (Number(backupMetadata?.version) >= 2\n && status?.sqliteHomeSource === \"default\"\n && storagePathsEqual(backupMetadata?.sqliteHome, status?.codexHome, comparison)) {\n return status.codexHome;\n }\n return status?.sqliteHome ?? \"\";\n}\n\nexport function restoreRelocationState({ backup, profile, targetSqliteHome, restoreDatabase, restoreConfig, sqliteSupported, pathComparisonCaseInsensitive = false }) {\n const sourceSqliteHome = backup?.metadata?.sqliteHome;\n const explicitSqliteHome = profile?.sqliteHome?.trim() ?? \"\";\n const requiresRelocation = Boolean(\n restoreDatabase\n && sourceSqliteHome\n && targetSqliteHome\n && !storagePathsEqual(sourceSqliteHome, targetSqliteHome, { caseInsensitive: pathComparisonCaseInsensitive })\n );\n const missingExplicitTarget = requiresRelocation && !explicitSqliteHome;\r\n const configRestoreConflict = requiresRelocation && restoreConfig;\r\n return {\r\n requiresRelocation,\r\n missingExplicitTarget,\r\n configRestoreConflict,\r\n canSubmit: Boolean(sqliteSupported) && !missingExplicitTarget && !configRestoreConflict\r\n };\r\n}\r\n","import { createLatestRequestGate } from \"./history-requests.js\";\r\n\r\nexport function storagePayload(profileId) {\r\n return { profileId: profileId || \"default\" };\r\n}\r\n\r\n// Loads status and backups for a storage profile while guaranteeing that only\r\n// the most recently started refresh may touch the UI. A stale request — for\r\n// example one started for profile A before the user switched to profile B —\r\n// is aborted and its results, errors, and loading transitions are discarded,\r\n// even when it finishes after the newer request.\r\nexport function createProfileRefresh({ fetchStatus, fetchBackups, gate = createLatestRequestGate() }) {\r\n const refresh = async ({ profileId, showLoading = true, onLoading, onResult, onError }) => {\r\n const { controller, sequence } = gate.begin();\r\n if (showLoading) onLoading?.(true);\r\n try {\r\n const storage = storagePayload(profileId);\r\n const [statusPayload, backupPayload] = await Promise.all([\r\n fetchStatus(storage, { signal: controller.signal }),\r\n fetchBackups(storage, { signal: controller.signal })\r\n ]);\r\n if (!gate.isLatest(sequence)) return false;\r\n onResult?.({ profileId, status: statusPayload.status, backups: backupPayload });\r\n return true;\r\n } catch (error) {\r\n if (error?.name === \"AbortError\" || !gate.isLatest(sequence)) return false;\r\n onError?.(error);\r\n return false;\r\n } finally {\r\n if (gate.isLatest(sequence)) onLoading?.(false);\r\n }\r\n };\r\n refresh.cancel = () => gate.cancel();\r\n return refresh;\r\n}\r\n","import React from \"react\";\r\n\r\nfunction Icon({ children, size = 18, className = \"\" }) {\r\n return (\r\n \r\n {children}\r\n \r\n );\r\n}\r\n\r\nexport const RefreshIcon = (props) => ;\r\nexport const DatabaseIcon = (props) => ;\r\nexport const HistoryIcon = (props) => ;\r\nexport const ActivityIcon = (props) => ;\r\nexport const OverviewIcon = (props) => ;\r\nexport const ShieldIcon = (props) => ;\r\nexport const ChevronIcon = (props) => ;\r\nexport const XIcon = (props) => ;\r\nexport const CheckIcon = (props) => ;\r\nexport const AlertIcon = (props) => ;\r\nexport const FolderIcon = (props) => ;\r\n","import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\r\n\r\nimport {\r\n PairingRequiredError,\r\n ProfileRevisionError,\r\n apiRequest,\r\n forgetThisBrowser,\r\n getActivity,\r\n getHistory,\r\n getHistorySession,\r\n getProfiles,\r\n initializeAccess\r\n} from \"./api.js\";\r\nimport { usePersistentState } from \"./hooks.js\";\r\nimport { createLatestRequestGate, scheduleDebounced } from \"./history-requests.js\";\r\nimport { captureProfileOperation, dedupeHistorySessions, operationToast, resolveRestoreTargetSqliteHome, restoreRelocationState } from \"./operation-state.js\";\nimport { createProfileRefresh, storagePayload } from \"./profile-refresh.js\";\r\nimport {\r\n ActivityIcon,\r\n AlertIcon,\r\n CheckIcon,\r\n ChevronIcon,\r\n DatabaseIcon,\r\n FolderIcon,\r\n HistoryIcon,\r\n OverviewIcon,\r\n RefreshIcon,\r\n ShieldIcon,\r\n XIcon\r\n} from \"./icons.jsx\";\r\n\r\nconst NAV_ITEMS = [\r\n { id: \"overview\", label: \"概览\", icon: OverviewIcon },\r\n { id: \"history\", label: \"聊天记录\", icon: HistoryIcon },\r\n { id: \"backups\", label: \"备份\", icon: HistoryIcon },\r\n { id: \"activity\", label: \"活动\", icon: ActivityIcon }\r\n];\r\n\r\nconst EMPTY_BACKUPS = { backupRoot: \"\", backups: [] };\r\n\r\nfunction formatNumber(value) {\r\n return new Intl.NumberFormat(\"zh-CN\").format(Number(value) || 0);\r\n}\r\n\r\nfunction formatBytes(bytes) {\r\n const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\r\n let value = Number(bytes) || 0;\r\n let index = 0;\r\n while (value >= 1024 && index < units.length - 1) {\r\n value /= 1024;\r\n index += 1;\r\n }\r\n return index === 0 ? `${value} B` : `${value.toFixed(value >= 10 ? 1 : 2).replace(/\\.0$/, \"\")} ${units[index]}`;\r\n}\r\n\r\nfunction formatDate(value) {\r\n if (!value) return \"未知时间\";\r\n return new Intl.DateTimeFormat(\"zh-CN\", {\r\n month: \"2-digit\",\r\n day: \"2-digit\",\r\n hour: \"2-digit\",\r\n minute: \"2-digit\",\r\n second: \"2-digit\",\r\n hour12: false\r\n }).format(new Date(value));\r\n}\r\n\r\nfunction renderInlineMarkdown(text, keyPrefix) {\r\n const parts = text.split(/(`[^`]+`|\\*\\*[^*]+\\*\\*|\\*[^*]+\\*)/g);\r\n return parts.map((part, index) => {\r\n if (part.startsWith(\"`\") && part.endsWith(\"`\")) return {part.slice(1, -1)};\r\n if (part.startsWith(\"**\") && part.endsWith(\"**\")) return {part.slice(2, -2)};\r\n if (part.startsWith(\"*\") && part.endsWith(\"*\")) return {part.slice(1, -1)};\r\n return {part};\r\n });\r\n}\r\n\r\nfunction SafeMarkdown({ text }) {\r\n const blocks = String(text ?? \"\").split(/(```[^\\n]*\\n[\\s\\S]*?```)/g);\r\n return blocks.map((block, index) => {\r\n if (block.startsWith(\"```\") && block.endsWith(\"```\")) {\r\n const lines = block.slice(3, -3).replace(/^\\w*\\n/, \"\");\r\n return
{lines}
;\r\n }\r\n return block.split(\"\\n\").map((line, lineIndex, lines) => {renderInlineMarkdown(line, `${index}-${lineIndex}`)}{lineIndex < lines.length - 1 ?
: null}
);\r\n });\r\n}\r\n\r\nfunction providersFromStatus(status) {\n if (!status) return [];\r\n const sources = new Map();\r\n const add = (values, source) => {\r\n for (const value of values ?? []) {\r\n if (!value || value === \"(missing)\") continue;\r\n const bucket = sources.get(value) ?? new Set();\r\n bucket.add(source);\r\n sources.set(value, bucket);\r\n }\r\n };\r\n add(status.configuredProviders, \"config\");\r\n add(Object.keys(status.rolloutCounts?.sessions ?? {}), \"rollout\");\r\n add(Object.keys(status.rolloutCounts?.archived_sessions ?? {}), \"rollout\");\r\n add(Object.keys(status.sqliteCounts?.sessions ?? {}), \"sqlite\");\r\n add(Object.keys(status.sqliteCounts?.archived_sessions ?? {}), \"sqlite\");\r\n add([status.currentProvider], \"config\");\r\n return [...sources.entries()]\r\n .map(([id, providerSources]) => ({\r\n id,\r\n sources: [...providerSources],\r\n configured: status.configuredProviders?.includes(id),\r\n current: id === status.currentProvider\r\n }))\r\n .sort((left, right) => Number(right.current) - Number(left.current) || left.id.localeCompare(right.id));\r\n}\r\n\r\nfunction StatusDot({ tone = \"neutral\" }) {\r\n return ;\r\n}\r\n\r\nfunction AppHeader({ status, busy, onRefresh }) {\r\n const healthy = status?.sqliteAccess?.supported !== false && !status?.sqliteCounts?.unreadable;\r\n return (\r\n
\r\n
\r\n
\r\n
\r\n
Codex Provider Sync
\r\n
本机元数据一致性工具
\r\n
\r\n
\r\n
\r\n
\r\n \r\n {busy ? \"操作执行中\" : healthy ? \"本地服务就绪\" : \"需要检查\"}\r\n
\r\n \r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction Sidebar({ view, setView, status, onForgetBrowser }) {\r\n return (\r\n \r\n );\r\n}\r\n\r\nfunction StorageBar({ profiles, profileId, setProfileId, status, onAddProfile, onDeleteProfile, onRefresh, loading, profileSwitchDisabled }) {\r\n return (\r\n
\r\n \r\n \r\n \r\n {profileId !== \"default\" ? : null}\r\n \r\n
\r\n );\r\n}\r\n\r\nfunction ProviderBars({ title, counts, currentProvider }) {\r\n const entries = Object.entries(counts ?? {}).sort((left, right) => right[1] - left[1]);\r\n const total = entries.reduce((sum, [, count]) => sum + count, 0);\r\n return (\r\n
\r\n
\r\n {title}\r\n {formatNumber(total)}\r\n
\r\n
\r\n {entries.length === 0 ?
无记录
: entries.map(([provider, count]) => (\r\n
\r\n
\r\n {provider}\r\n {formatNumber(count)}\r\n
\r\n
\r\n \r\n
\r\n
\r\n ))}\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction StatusPanel({ status, loading }) {\r\n const rolloutTotal = Object.values(status?.rolloutCounts?.sessions ?? {}).reduce((sum, value) => sum + value, 0)\r\n + Object.values(status?.rolloutCounts?.archived_sessions ?? {}).reduce((sum, value) => sum + value, 0);\r\n const sqliteTotal = Object.values(status?.sqliteCounts?.sessions ?? {}).reduce((sum, value) => sum + value, 0)\r\n + Object.values(status?.sqliteCounts?.archived_sessions ?? {}).reduce((sum, value) => sum + value, 0);\r\n const aligned = status?.alignment?.aligned;\r\n return (\r\n
\r\n
\r\n
\r\n

状态总览

\r\n

比较 rollout 文件、SQLite 线程索引和当前 Provider。

\r\n
\r\n
\r\n {loading ? : aligned ? : }\r\n {loading ? \"正在检查\" : aligned ? \"Provider 元数据已对齐\" : \"发现不一致\"}\n
\r\n
\r\n\r\n
\r\n
\r\n 当前 Provider\r\n {status?.currentProvider ?? \"—\"}\r\n {status?.currentProviderImplicit ? \"隐式默认\" : \"config.toml 根级配置\"}\r\n
\r\n
\r\n Rollout 文件\r\n {formatNumber(rolloutTotal)}\r\n sessions + archived\r\n
\r\n
\r\n SQLite threads\r\n {status?.sqliteCounts?.unreadable ? \"不可读\" : formatNumber(sqliteTotal)}\r\n {status?.stateDbLocation?.source ?? \"未定位数据库\"}\r\n
\r\n
\r\n 托管备份\r\n {formatNumber(status?.backupSummary?.count)}\r\n {formatBytes(status?.backupSummary?.totalBytes)}\r\n
\r\n
\r\n\r\n
\r\n
\r\n
Rollout files
\r\n \r\n \r\n
\r\n
\r\n
\r\n
SQLite state
\r\n \r\n \r\n
\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction Warnings({ status }) {\r\n const items = [];\r\n if (status?.sqliteAccess?.supported === false) {\r\n items.push({ tone: \"danger\", title: \"SQLite 路径不可安全访问\", detail: status.sqliteAccess.message });\r\n }\r\n if (status?.sqliteCounts?.unreadable) {\r\n items.push({ tone: \"danger\", title: \"SQLite 当前不可读\", detail: status.sqliteCounts.error });\r\n }\r\n if (status?.lockedRolloutFiles?.length) {\r\n items.push({ tone: \"warning\", title: `${status.lockedRolloutFiles.length} 个 rollout 文件正在使用`, detail: \"同步会跳过这些活跃文件;会话结束后可再次执行。\" });\r\n }\r\n if (status?.encryptedContentWarning) {\r\n items.push({ tone: \"warning\", title: \"检测到 encrypted_content\", detail: status.encryptedContentWarning });\r\n }\r\n const repairs = status?.sqliteRepairStats;\r\n if (repairs?.userEventRowsNeedingRepair || repairs?.cwdRowsNeedingRepair) {\r\n items.push({ tone: \"info\", title: \"SQLite 有待修复字段\", detail: `user-event ${repairs.userEventRowsNeedingRepair ?? 0},cwd ${repairs.cwdRowsNeedingRepair ?? 0}` });\r\n }\r\n if (items.length === 0) return null;\r\n return (\r\n
\r\n {items.map((item, index) => (\r\n
\r\n \r\n
{item.title}{item.detail}
\r\n
\r\n ))}\r\n
\r\n );\r\n}\r\n\r\nfunction ProjectVisibility({ projects = [] }) {\r\n return (\r\n
\r\n
\r\n

项目可见性

检查 Desktop 项目路径、全局排序和首屏 50 条命中。

\r\n
\r\n
\r\n \r\n \r\n \r\n {projects.length === 0 ? (\r\n \r\n ) : projects.map((project) => (\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n ))}\r\n \r\n
项目根目录交互会话首屏RanksCWD 精确匹配Provider
没有可显示的项目诊断。
{project.root}{project.interactiveThreads}{project.firstPageThreads}/50{project.rankPreview || \"—\"}{project.exactCwdMatches}/{project.interactiveThreads}{Object.entries(project.providerCounts ?? {}).map(([provider, count]) => `${provider} ${count}`).join(\" · \") || \"—\"}
\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction SegmentedControl({ value, onChange, disabled }) {\r\n return (\r\n
\r\n \r\n \r\n
\r\n );\r\n}\r\n\r\nfunction ExecutionPanel({ status, providers, selectedProvider, setSelectedProvider, onAddManualProvider, onRemoveManualProvider, onRequestExecute, busy }) {\r\n const [mode, setMode] = usePersistentState(\"cps.web.mode\", \"sync\");\r\n const [modelMode, setModelMode] = usePersistentState(\"cps.web.modelMode\", \"auto\");\r\n const [customModel, setCustomModel] = usePersistentState(\"cps.web.customModel\", \"\");\r\n const [keepCount, setKeepCount] = usePersistentState(\"cps.web.keepCount\", 5);\r\n const [manualProvider, setManualProvider] = useState(\"\");\r\n const selectedOption = providers.find((provider) => provider.id === selectedProvider);\r\n const switchAllowed = selectedOption?.configured;\r\n const validManualProvider = /^[A-Za-z0-9_.-]+$/.test(manualProvider.trim());\r\n const sqliteUnsupported = status?.sqliteAccess?.supported === false;\r\n const executeDisabled = busy || !selectedProvider || sqliteUnsupported || status?.sqliteCounts?.unreadable;\r\n\r\n return (\r\n
\r\n
\r\n

执行同步

所有写操作都会先创建备份。执行前请关闭 Codex CLI、App 和 app-server。

\r\n \r\n
\r\n \r\n
\r\n \r\n \r\n
\r\n
\r\n setManualProvider(event.target.value)}\r\n placeholder=\"手动添加 Provider ID\"\r\n spellCheck=\"false\"\r\n disabled={busy}\r\n />\r\n {\r\n onAddManualProvider(manualProvider.trim());\r\n setManualProvider(\"\");\r\n }}\r\n >\r\n 添加\r\n \r\n {selectedOption?.manual ? : null}\r\n
\r\n\r\n {mode === \"switch\" ? (\r\n
\r\n 根级 model\r\n \r\n \r\n \r\n
\r\n ) : null}\r\n\r\n {sqliteUnsupported ?
此 SQLite 布局仅供诊断{status?.sqliteAccess?.message || \"当前 SQLite 路径不可由 Web UI 安全写入,因此已禁用执行同步。\"}
: null}\r\n\r\n
修改前创建 metadata v2 备份,并记录 SQLite Home
\r\n onRequestExecute({ mode, modelMode, model: customModel.trim(), keepCount })}\r\n >\r\n {busy ? : }\r\n {busy ? \"正在执行…\" : mode === \"switch\" ? \"切换并同步\" : \"执行同步\"}\r\n \r\n
\r\n );\r\n}\r\n\r\nfunction RecentBackups({ backups, onViewAll, onRestore, restoreDisabled }) {\r\n return (\r\n
\r\n
\r\n

最近备份

{backups.backupRoot || \"同步后将在 Codex Home 下创建备份\"}

\r\n \r\n
\r\n
\r\n {backups.backups.length === 0 ?
尚无由本工具创建的备份。
: backups.backups.slice(0, 3).map((backup) => (\r\n
\r\n
\r\n
{formatDate(backup.metadata.createdAt)}{backup.metadata.targetProvider} · {backup.metadata.changedSessionFiles ?? 0} 个 rollout
\r\n
{formatBytes(backup.sizeBytes)}
\r\n \r\n
\r\n ))}\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction Overview({ status, backups, providers, selectedProvider, setSelectedProvider, onAddManualProvider, onRemoveManualProvider, onExecute, onRestore, setView, busy, loading }) {\r\n return (\r\n
\r\n \r\n \r\n
\r\n
\r\n \r\n setView(\"backups\")} onRestore={onRestore} restoreDisabled={status?.sqliteAccess?.supported === false} />\r\n
\r\n \r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction BackupsView({ backups, status, busy, onRestore, onPrune }) {\r\n const [keepCount, setKeepCount] = usePersistentState(\"cps.web.keepCount\", 5);\r\n const restoreDisabled = status?.sqliteAccess?.supported === false;\r\n return (\r\n
\r\n
\r\n

备份

管理当前 Codex Home 下由本工具创建的 metadata v2 备份。

\r\n
\r\n
\r\n
\r\n
{backups.backupRoot || status?.backupRoot || \"—\"}{backups.backups.length} 份 · {formatBytes(backups.backups.reduce((sum, backup) => sum + backup.sizeBytes, 0))}
\r\n
\r\n {backups.backups.length === 0 ?
还没有备份执行一次同步或切换后,备份会显示在这里。
: backups.backups.map((backup) => (\r\n
\r\n
{formatDate(backup.metadata.createdAt)}{backup.id}
\r\n
Provider {backup.metadata.targetProvider}Rollout {backup.metadata.changedSessionFiles ?? 0}SQLite {backup.metadata.sqliteDbFiles?.length ? \"已包含\" : \"未包含\"}
\r\n
SQLite Home{backup.metadata.sqliteHome ?? \"旧版 metadata 未记录\"}
\r\n
{formatBytes(backup.sizeBytes)}
\r\n
\r\n ))}\r\n
\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction ActivityView({ activity, activeOperation }) {\r\n return (\r\n
\r\n

活动日志

当前 Web UI 会话中的状态刷新、同步阶段和操作结果。

{activeOperation ?
{activeOperation.kind}
: null}
\r\n
\r\n
Activity log{activity.length} entries
\r\n
\r\n {activity.length === 0 ?
等待操作…
: activity.map((entry) => (\r\n
\r\n \r\n {entry.level}\r\n {entry.message}\r\n {typeof entry.detail === \"string\" ? {entry.detail} : null}\r\n
\r\n ))}\r\n
\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction HistoryView({ profileId, status }) {\r\n const [query, setQuery] = useState(\"\");\r\n const [committedQuery, setCommittedQuery] = useState(\"\");\r\n const [provider, setProvider] = useState(\"\");\r\n const [project, setProject] = useState(\"\");\r\n const [archived, setArchived] = useState(\"all\");\r\n const [page, setPage] = useState(1);\r\n const [history, setHistory] = useState({ sessions: [], total: 0, pageSize: 50, hasNextPage: false });\r\n const [selectedId, setSelectedId] = useState(\"\");\r\n const [detail, setDetail] = useState(null);\r\n const [loading, setLoading] = useState(false);\r\n const [detailLoading, setDetailLoading] = useState(false);\r\n const [error, setError] = useState(\"\");\r\n const listRequest = useRef(null);\r\n const detailRequest = useRef(null);\r\n if (!listRequest.current) listRequest.current = createLatestRequestGate();\r\n if (!detailRequest.current) detailRequest.current = createLatestRequestGate();\r\n const providers = providersFromStatus(status);\r\n const projects = [...new Set((status?.projectThreadVisibility ?? []).map((item) => item.root))];\r\n\r\n useEffect(() => {\r\n listRequest.current.cancel();\r\n detailRequest.current.cancel();\r\n setQuery(\"\");\r\n setCommittedQuery(\"\");\r\n setProvider(\"\");\r\n setProject(\"\");\r\n setArchived(\"all\");\r\n setPage(1);\r\n setHistory({ sessions: [], total: 0, pageSize: 50, hasNextPage: false });\r\n setSelectedId(\"\");\r\n setDetail(null);\r\n setError(\"\");\r\n }, [profileId]);\r\n\r\n useEffect(() => {\r\n return scheduleDebounced(() => setCommittedQuery(query), 300, window);\r\n }, [query]);\r\n\r\n const loadList = useCallback(async () => {\r\n const { controller, sequence } = listRequest.current.begin();\r\n setLoading(true); setError(\"\");\r\n try {\r\n const payload = await getHistory({ ...storagePayload(profileId), page, pageSize: 50, query: committedQuery, provider, project, archived }, { signal: controller.signal });\r\n if (!listRequest.current.isLatest(sequence)) return;\r\n const nextHistory = { ...payload.history, sessions: dedupeHistorySessions(payload.history?.sessions) };\r\n setHistory(nextHistory);\r\n setSelectedId((current) => nextHistory.sessions.some((session) => session.id === current) ? current : nextHistory.sessions[0]?.id ?? \"\");\r\n } catch (requestError) {\r\n if (requestError.name !== \"AbortError\" && listRequest.current.isLatest(sequence)) setError(requestError.message);\r\n } finally {\r\n if (listRequest.current.isLatest(sequence)) setLoading(false);\r\n }\r\n }, [profileId, page, committedQuery, provider, project, archived]);\r\n\r\n useEffect(() => {\r\n loadList();\r\n return () => listRequest.current.cancel();\r\n }, [loadList]);\r\n useEffect(() => {\r\n detailRequest.current.cancel();\r\n if (!selectedId) { setDetail(null); setDetailLoading(false); return undefined; }\r\n const { controller, sequence } = detailRequest.current.begin();\r\n setDetail(null);\r\n setDetailLoading(true);\r\n setError(\"\");\r\n getHistorySession({ ...storagePayload(profileId), sessionId: selectedId }, { signal: controller.signal })\r\n .then((payload) => { if (detailRequest.current.isLatest(sequence)) setDetail(payload.history); })\r\n .catch((requestError) => { if (requestError.name !== \"AbortError\" && detailRequest.current.isLatest(sequence)) setError(requestError.message); })\r\n .finally(() => { if (detailRequest.current.isLatest(sequence)) setDetailLoading(false); });\r\n return () => controller.abort();\r\n }, [profileId, selectedId]);\r\n\r\n const updateFilter = (setter) => (event) => { setter(event.target.value); setPage(1); };\r\n return (\r\n
\r\n

聊天记录

从 rollout 文件读取历史会话,只读查看,不修改本地数据。

\r\n
\r\n { if (event.key === \"Enter\") { setCommittedQuery(query); setPage(1); } }} placeholder=\"搜索标题、项目、Provider 或消息内容\" />\r\n \r\n \r\n \r\n
\r\n {error ?
{error}
: null}\r\n
\r\n
\r\n
{history.total} 个会话第 {history.page} 页
\r\n
\r\n {loading && !history.sessions.length ?
读取中…
: null}\r\n {!loading && !history.sessions.length ?
没有匹配的会话
: null}\r\n {history.sessions.map((session) => )}\r\n
\r\n
{page}
\r\n
\r\n
\r\n {detailLoading ?
正在读取会话
: !detail ?
选择一个会话聊天内容将在这里显示。
: <>

{detail.session.title}

{detail.session.cwd || \"未知项目\"} · {detail.session.provider} · {detail.session.messageCount} 条消息

{detail.session.archived ? \"已归档\" : \"活跃\"}
{detail.truncated ?
仅显示最近 {detail.returnedMessageCount} 条消息。
: null}
{detail.messages.map((message) =>
{message.role === \"user\" ? \"你\" : \"Codex\"}
)}
}\r\n
\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction Modal({ title, children, confirmLabel, onConfirm, onCancel, tone = \"primary\", confirmDisabled = false }) {\r\n return (\r\n
event.target === event.currentTarget && onCancel()}>\r\n
\r\n

{title}

\r\n
{children}
\r\n
\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction ExecuteModal({ plan, status, selectedProvider, onCancel, onConfirm }) {\r\n return (\r\n \r\n
请确认 Codex 已完全关闭关闭 Codex CLI、Codex App、app-server 及相关终端,避免 SQLite 或 rollout 被占用。
\r\n
\r\n
Codex Home
{status?.codexHome}
\r\n
SQLite Home
{status?.sqliteHome} ({status?.sqliteHomeSource})
\r\n
当前 Provider
{status?.currentProvider}
\r\n
目标 Provider
{selectedProvider}
\r\n
配置变更
{plan.mode === \"switch\" ? \"更新 config.toml 根级 model_provider\" : \"不修改 config.toml\"}
\r\n
Model 策略
{plan.mode !== \"switch\" ? \"跟随当前根级 model\" : plan.modelMode === \"auto\" ? \"跟随目标 Provider 配置\" : plan.modelMode === \"keep\" ? \"保留当前根级 model\" : `设置为 ${plan.model}`}
\r\n
备份策略
修改前创建备份,保留最近 {plan.keepCount} 份
\r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction RestoreModal({ backup, status, profile, onCancel, onConfirm }) {\r\n const [restoreConfig, setRestoreConfig] = useState(false);\r\n const [restoreDatabase, setRestoreDatabase] = useState(true);\r\n const [restoreSessions, setRestoreSessions] = useState(true);\r\n const targetSqliteHome = resolveRestoreTargetSqliteHome(status, backup);\n const relocation = restoreRelocationState({\r\n backup,\r\n profile,\r\n targetSqliteHome,\n restoreDatabase,\n restoreConfig,\n sqliteSupported: status?.sqliteAccess?.supported !== false,\n pathComparisonCaseInsensitive: status?.pathComparisonCaseInsensitive === true\n });\n return (\r\n onConfirm({ restoreConfig, restoreDatabase, restoreSessions, allowSqliteHomeRelocation: relocation.requiresRelocation })}\r\n >\r\n
{formatDate(backup.metadata.createdAt)} · {backup.metadata.targetProvider}{backup.path}
\r\n
\r\n 选择要覆盖的内容\r\n \r\n \r\n \r\n
\r\n {status?.sqliteAccess?.supported === false ?
当前 SQLite 路径仅供诊断{status.sqliteAccess.message || \"不能从 Web UI 执行恢复。\"}
: null}\r\n {relocation.requiresRelocation ?
SQLite Home 与备份来源不同来源:{backup.metadata.sqliteHome}
目标:{targetSqliteHome}
{relocation.missingExplicitTarget ? \"当前 Profile 未明确配置 SQLite Home,不能提交数据库迁移恢复。\" : relocation.configRestoreConflict ? \"迁移数据库时不能同时恢复旧 config.toml。\" : \"确认后数据库将恢复到当前 Profile 明确配置的目标位置。\"}
: null}\r\n
恢复前请关闭 Codex该操作将覆盖所选的当前元数据;请确认 Codex CLI、App 和 app-server 已关闭。
\r\n \r\n );\r\n}\r\n\r\nfunction PruneModal({ keepCount, backups, onCancel, onConfirm }) {\r\n const deleteCount = Math.max(0, backups.backups.length - keepCount);\r\n return (\r\n \r\n
被删除的备份无法直接恢复只处理当前 Codex Home 下由本工具管理的备份目录。
\r\n
当前备份
{backups.backups.length} 份
保留
最近 {keepCount} 份
将删除
{deleteCount} 份
\r\n
\r\n );\r\n}\r\n\r\nfunction ProfileModal({ onCancel, onConfirm }) {\r\n const [profileId, setProfileId] = useState(\"\");\r\n const [name, setName] = useState(\"\");\r\n const [codexHome, setCodexHome] = useState(\"\");\r\n const [sqliteHome, setSqliteHome] = useState(\"\");\r\n const valid = /^[A-Za-z0-9_.-]{1,80}$/.test(profileId) && name.trim() && codexHome.trim();\r\n return (\r\n onConfirm({ profileId, name, codexHome, sqliteHome })}>\r\n
\r\n \r\n \r\n \r\n \r\n
\r\n
\r\n );\r\n}\r\n\r\nfunction Toast({ toast, onClose }) {\r\n useEffect(() => {\r\n if (!toast) return undefined;\r\n const timer = window.setTimeout(onClose, 6000);\r\n return () => window.clearTimeout(timer);\r\n }, [toast, onClose]);\r\n if (!toast) return null;\r\n return
{toast.tone === \"success\" ? : }{toast.title}{toast.message ? {toast.message} : null}
;\r\n}\r\n\r\nexport default function App() {\r\n const [view, setView] = useState(\"overview\");\r\n const [accessState, setAccessState] = useState(\"checking\");\r\n const [accessMessage, setAccessMessage] = useState(\"\");\r\n const [profileId, setProfileId] = usePersistentState(\"cps.web.profileId\", \"default\");\r\n const [profiles, setProfiles] = useState([]);\r\n const [manualProviders, setManualProviders] = usePersistentState(\"cps.web.manualProviders\", []);\r\n const [status, setStatus] = useState(null);\r\n const [backups, setBackups] = useState(EMPTY_BACKUPS);\r\n const [selectedProvider, setSelectedProvider] = useState(\"\");\r\n const [loading, setLoading] = useState(true);\r\n const [busy, setBusy] = useState(false);\r\n const [modal, setModal] = useState(null);\r\n const [toast, setToast] = useState(null);\r\n const [activity, setActivity] = useState([]);\r\n const [activeOperation, setActiveOperation] = useState(null);\r\n const lastActivityId = useRef(0);\r\n\r\n const providers = useMemo(() => {\r\n const detected = providersFromStatus(status);\r\n const detectedIds = new Set(detected.map((provider) => provider.id));\r\n return [\r\n ...detected.map((provider) => ({\r\n ...provider,\r\n manual: manualProviders.includes(provider.id)\r\n })),\r\n ...manualProviders\r\n .filter((provider) => !detectedIds.has(provider))\r\n .map((id) => ({ id, sources: [\"manual\"], configured: false, current: false, manual: true }))\r\n ];\r\n }, [status, manualProviders]);\r\n const selectedProfile = profiles.find((profile) => profile.id === profileId) ?? null;\r\n\r\n const profileRefreshRef = useRef(null);\r\n if (!profileRefreshRef.current) {\r\n profileRefreshRef.current = createProfileRefresh({\r\n fetchStatus: (storage, options) => apiRequest(\"/api/status\", storage, options),\r\n fetchBackups: (storage, options) => apiRequest(\"/api/backups\", storage, options)\r\n });\r\n }\r\n\r\n // Only the latest request for the current profile may update status,\r\n // backups, selectedProvider, loading, and error toasts. Switching profiles\r\n // starts a newer request, which aborts and invalidates the older one even\r\n // if the older one finishes last.\r\n const refresh = useCallback(async ({ quiet = false } = {}) => {\r\n await profileRefreshRef.current({\r\n profileId,\r\n showLoading: !quiet,\r\n onLoading: setLoading,\r\n onResult: ({ status: nextStatus, backups: nextBackups }) => {\r\n setStatus(nextStatus);\r\n setBackups(nextBackups);\r\n setSelectedProvider((current) => current && providersFromStatus(nextStatus).some((provider) => provider.id === current)\r\n ? current\r\n : nextStatus.currentProvider);\r\n },\r\n onError: (error) => {\r\n setToast({ tone: \"error\", title: \"状态读取失败\", message: error.message });\r\n }\r\n });\r\n }, [profileId]);\r\n\r\n const refreshProfiles = useCallback(async () => {\r\n const payload = await getProfiles();\r\n setProfiles(payload.profiles);\r\n setProfileId((current) => payload.profiles.some((profile) => profile.id === current) ? current : \"default\");\r\n }, [setProfileId]);\r\n\r\n const handleProfileConflict = useCallback(async () => {\r\n setModal(null);\r\n try {\r\n await refreshProfiles();\r\n await refresh({ quiet: true });\r\n } catch {\r\n // The user can still retry after the next explicit refresh.\r\n }\r\n setToast({ tone: \"warning\", title: \"配置已变更,请重新确认\", message: \"已刷新存储配置和当前状态;未自动重试原操作。\" });\r\n }, [refresh, refreshProfiles]);\r\n\r\n const openProfileOperation = useCallback((operation) => {\n const captured = captureProfileOperation(selectedProfile, operation, status);\n if (!captured) {\r\n setToast({ tone: \"warning\", title: \"配置需要刷新\", message: \"没有可用的配置版本,请刷新后重新确认操作。\" });\r\n refreshProfiles().catch(() => {});\r\n return;\r\n }\r\n setModal(captured);\r\n }, [refreshProfiles, selectedProfile, status]);\n\r\n useEffect(() => {\r\n let cancelled = false;\r\n initializeAccess()\r\n .then(async (paired) => {\r\n if (!paired) throw new PairingRequiredError();\r\n await refreshProfiles();\r\n if (!cancelled) setAccessState(\"ready\");\r\n })\r\n .catch((error) => {\r\n if (cancelled) return;\r\n setAccessState(error instanceof PairingRequiredError ? \"required\" : \"error\");\r\n setAccessMessage(error.message);\r\n });\r\n return () => { cancelled = true; };\r\n }, [refreshProfiles]);\r\n\r\n useEffect(() => {\r\n if (accessState === \"ready\") refresh();\r\n }, [accessState, profileId]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n useEffect(() => {\r\n const requirePairing = (event) => {\r\n setAccessState(\"required\");\r\n setAccessMessage(event.detail || \"设备凭证已失效。请重新运行 codex-provider web。\");\r\n };\r\n window.addEventListener(\"cps:pairing-required\", requirePairing);\r\n return () => window.removeEventListener(\"cps:pairing-required\", requirePairing);\r\n }, []);\r\n\r\n useEffect(() => {\r\n if (accessState !== \"ready\") return undefined;\r\n let stopped = false;\r\n const poll = async () => {\r\n try {\r\n const payload = await getActivity(lastActivityId.current);\r\n if (stopped) return;\r\n if (payload.activity?.length) {\r\n lastActivityId.current = payload.activity[payload.activity.length - 1].id;\r\n setActivity((current) => [...current, ...payload.activity].slice(-250));\r\n }\r\n setActiveOperation(payload.activeOperation ?? null);\r\n } catch {\r\n // A transient poll failure should not interrupt a running operation.\r\n }\r\n };\r\n poll();\r\n const timer = window.setInterval(poll, 900);\r\n return () => { stopped = true; window.clearInterval(timer); };\r\n }, [accessState]);\r\n\r\n const execute = useCallback(async () => {\r\n const plan = modal.plan;\r\n const targetProfileId = modal.profileId;\r\n setModal(null);\r\n setBusy(true);\r\n setView(\"activity\");\r\n try {\r\n const common = { ...storagePayload(targetProfileId), profileRevision: modal.profileRevision, storageRevision: modal.storageRevision, provider: modal.selectedProvider, keepCount: plan.keepCount };\n const payload = plan.mode === \"switch\"\r\n ? await apiRequest(\"/api/switch\", { ...common, model: plan.modelMode === \"custom\" ? plan.model : undefined, keepRootModel: plan.modelMode === \"keep\" })\r\n : await apiRequest(\"/api/sync\", common);\r\n setToast(operationToast(payload, {\r\n successTitle: plan.mode === \"switch\" ? \"切换并同步完成\" : \"同步完成\",\r\n partialTitle: plan.mode === \"switch\" ? \"切换并同步部分完成\" : \"同步部分完成\",\r\n message: `备份:${payload.result?.backupDir ?? \"已创建\"}`\r\n }));\r\n await refresh({ quiet: true });\r\n } catch (error) {\r\n if (error instanceof ProfileRevisionError) {\r\n await handleProfileConflict();\r\n return;\r\n }\r\n setToast({ tone: \"error\", title: \"操作失败\", message: error.message });\r\n } finally {\r\n setBusy(false);\r\n }\r\n }, [handleProfileConflict, modal, refresh]);\r\n\r\n const restore = useCallback(async (options) => {\r\n const backup = modal.backup;\r\n const targetProfileId = modal.profileId;\r\n setModal(null);\r\n setBusy(true);\r\n setView(\"activity\");\r\n try {\r\n const payload = await apiRequest(\"/api/restore\", { ...storagePayload(targetProfileId), profileRevision: modal.profileRevision, storageRevision: modal.storageRevision, backupId: backup.id, ...options });\n setToast(operationToast(payload, { successTitle: \"备份恢复完成\", partialTitle: \"备份恢复部分完成\", message: backup.id }));\r\n await refresh({ quiet: true });\r\n } catch (error) {\r\n if (error instanceof ProfileRevisionError) {\r\n await handleProfileConflict();\r\n return;\r\n }\r\n setToast({ tone: \"error\", title: \"恢复失败\", message: error.message });\r\n } finally {\r\n setBusy(false);\r\n }\r\n }, [handleProfileConflict, modal, refresh]);\r\n\r\n const prune = useCallback(async () => {\r\n const keepCount = modal.keepCount;\r\n const targetProfileId = modal.profileId;\r\n setModal(null);\r\n setBusy(true);\r\n try {\r\n const payload = await apiRequest(\"/api/prune\", { ...storagePayload(targetProfileId), profileRevision: modal.profileRevision, storageRevision: modal.storageRevision, keepCount });\n setToast(operationToast(payload, {\r\n successTitle: \"旧备份清理完成\",\r\n partialTitle: \"旧备份清理部分完成\",\r\n message: `删除 ${payload.result?.deletedCount ?? 0} 份,释放 ${formatBytes(payload.result?.freedBytes)}`\r\n }));\r\n await refresh({ quiet: true });\r\n } catch (error) {\r\n if (error instanceof ProfileRevisionError) {\r\n await handleProfileConflict();\r\n return;\r\n }\r\n setToast({ tone: \"error\", title: \"备份清理失败\", message: error.message });\r\n } finally {\r\n setBusy(false);\r\n }\r\n }, [handleProfileConflict, modal, refresh]);\r\n\r\n const closeToast = useCallback(() => setToast(null), []);\r\n const saveProfile = useCallback(async (profile) => {\r\n try {\r\n const payload = await apiRequest(\"/api/profiles/save\", profile.revision ? { ...profile, profileRevision: profile.revision } : profile);\r\n setModal(null);\r\n await refreshProfiles();\r\n setProfileId(payload.profile.id);\r\n setToast({ tone: \"success\", title: \"存储配置已保存\", message: payload.profile.name });\r\n } catch (error) {\r\n if (error instanceof ProfileRevisionError) {\r\n await handleProfileConflict();\r\n return;\r\n }\r\n setToast({ tone: \"error\", title: \"配置保存失败\", message: error.message });\r\n }\r\n }, [handleProfileConflict, refreshProfiles, setProfileId]);\r\n const deleteProfile = useCallback(async () => {\r\n try {\r\n await apiRequest(\"/api/profiles/delete\", { profileId, profileRevision: selectedProfile?.revision });\r\n setProfileId(\"default\");\r\n await refreshProfiles();\r\n } catch (error) {\r\n if (error instanceof ProfileRevisionError) {\r\n await handleProfileConflict();\r\n return;\r\n }\r\n setToast({ tone: \"error\", title: \"配置删除失败\", message: error.message });\r\n }\r\n }, [handleProfileConflict, profileId, refreshProfiles, selectedProfile?.revision, setProfileId]);\r\n const forgetBrowser = useCallback(async () => {\r\n await forgetThisBrowser().catch(() => {});\r\n setAccessState(\"required\");\r\n setAccessMessage(\"此浏览器的设备凭证已失效。重新运行 codex-provider web 即可自动配对。\");\r\n }, []);\r\n const addManualProvider = useCallback((provider) => {\r\n setManualProviders((current) => [...new Set([...current, provider])].sort());\r\n setSelectedProvider(provider);\r\n }, [setManualProviders]);\r\n const removeManualProvider = useCallback((provider) => {\r\n setManualProviders((current) => current.filter((item) => item !== provider));\r\n setSelectedProvider(status?.currentProvider ?? \"\");\r\n }, [setManualProviders, status?.currentProvider]);\r\n\r\n if (accessState !== \"ready\") {\r\n return

{accessState === \"checking\" ? \"正在完成安全配对\" : \"需要重新配对\"}

{accessState === \"checking\" ? \"请稍候…\" : accessMessage || \"请重新运行 codex-provider web。\"}

{accessState !== \"checking\" ? codex-provider web : null}
;\r\n }\r\n\r\n return (\r\n
\r\n refresh()} />\r\n \r\n
\r\n setModal({ type: \"profile\" })} onDeleteProfile={deleteProfile} onRefresh={() => refresh()} loading={loading} profileSwitchDisabled={busy || Boolean(modal)} />\r\n {view === \"overview\" ? openProfileOperation({ type: \"execute\", plan, selectedProvider })} onRestore={(backup) => openProfileOperation({ type: \"restore\", backup })} setView={setView} busy={busy} loading={loading} /> : null}\r\n {view === \"history\" ? : null}\r\n {view === \"backups\" ? openProfileOperation({ type: \"restore\", backup })} onPrune={(keepCount) => openProfileOperation({ type: \"prune\", keepCount })} /> : null}\r\n {view === \"activity\" ? : null}\r\n
\r\n {modal?.type === \"execute\" ? setModal(null)} onConfirm={execute} /> : null}\n {modal?.type === \"restore\" ? setModal(null)} onConfirm={restore} /> : null}\n {modal?.type === \"prune\" ? setModal(null)} onConfirm={prune} /> : null}\r\n {modal?.type === \"profile\" ? setModal(null)} onConfirm={saveProfile} /> : null}\r\n \r\n
\r\n );\r\n}\r\n","import React from \"react\";\r\nimport { createRoot } from \"react-dom/client\";\r\n\r\nimport App from \"./App.jsx\";\r\nimport \"./styles.css\";\r\n\r\ncreateRoot(document.getElementById(\"root\")).render(\r\n \r\n \r\n \r\n);\r\n"],"names":["l","n","p","q","r","t","u","v","w","x","y","z","A","a","B","C","D","E","b","e","F","G","H","I","J","K","L","M","d","c","k","h","g","f","m","N","O","escape","P","Q","R","S","T","U","V","W","X","react_production_min","reactModule","require$$0","reactJsxRuntime_production_min","jsxRuntimeModule","exports","schedulerModule","aa","ca","require$$1","da","ea","fa","ha","ia","ja","ka","la","ma","oa","pa","qa","ra","sa","ta","ua","va","wa","ya","za","Aa","Ba","Ca","Da","Ea","Fa","Ga","Ha","Ia","Ja","Ka","La","Ma","Na","Oa","Pa","Qa","Ra","Sa","Ta","Ua","Va","Wa","Xa","Ya","Za","ab","bb","cb","db","eb","fb","gb","hb","ib","jb","kb","lb","mb","nb","ob","pb","qb","rb","sb","tb","ub","vb","wb","xb","yb","zb","Ab","Bb","Cb","Db","Eb","Fb","Gb","Hb","Ib","Jb","Kb","Lb","Mb","Nb","Ob","Pb","Qb","Rb","Sb","Tb","Ub","Vb","Wb","Xb","Yb","Zb","$b","ac","bc","cc","dc","ec","fc","gc","hc","ic","jc","kc","lc","mc","oc","nc","pc","qc","rc","sc","tc","uc","vc","wc","xc","yc","zc","Ac","Bc","Cc","Dc","Ec","Fc","Gc","Hc","Ic","Jc","Kc","Lc","Mc","Nc","Oc","Pc","Qc","Rc","Sc","Tc","Uc","Vc","Wc","Xc","Yc","Zc","$c","ad","bd","cd","dd","ed","fd","gd","hd","id","jd","kd","ld","md","nd","od","pd","qd","rd","sd","td","ud","vd","wd","xd","yd","Ad","zd","Bd","Cd","Dd","Ed","Fd","Gd","Hd","Id","Jd","Kd","Ld","Md","Nd","Od","Pd","Qd","Rd","Sd","Td","Ud","Vd","Wd","Xd","Yd","Zd","$d","ae","be","ce","de","ee","fe","ge","he","ie","je","ke","le","me","ne","oe","pe","qe","re","se","te","ue","ve","we","xe","ye","ze","Ae","Be","Ce","De","Ee","Fe","Ge","He","Ie","Je","Ke","Le","Me","Ne","Oe","Pe","Qe","Re","Se","Te","Ue","Ve","We","Xe","Ye","Ze","$e","af","bf","cf","df","ef","ff","gf","hf","jf","kf","lf","mf","nf","of","pf","qf","rf","sf","tf","uf","vf","wf","na","xa","$a","ba","xf","yf","zf","Af","Bf","Cf","Df","Ef","Ff","Gf","Hf","Jf","If","Kf","Lf","Mf","Nf","Of","Pf","Qf","Rf","Sf","Tf","Uf","Vf","Wf","Xf","Yf","Zf","$f","ag","bg","cg","dg","eg","fg","gg","hg","ig","jg","kg","lg","mg","ng","og","pg","qg","rg","sg","tg","ug","vg","wg","xg","yg","zg","Ag","Bg","Cg","Dg","Eg","Fg","Gg","Hg","Ig","Jg","Kg","Lg","Mg","Ng","Og","Pg","Qg","Rg","Sg","Tg","Ug","Vg","Wg","Xg","Yg","Zg","$g","ah","bh","ch","dh","eh","fh","gh","hh","ih","jh","kh","lh","mh","nh","oh","ph","qh","rh","sh","th","uh","vh","wh","xh","yh","zh","Ah","Bh","Ch","Dh","Eh","Fh","Gh","Hh","Ih","Jh","Kh","Lh","Mh","Nh","Oh","Ph","Qh","Rh","Sh","Th","Uh","Vh","Wh","Xh","Yh","Zh","$h","ai","bi","ci","di","ei","fi","gi","hi","ii","ji","ki","li","mi","ni","oi","pi","qi","ri","si","ti","ui","vi","wi","xi","yi","zi","Ai","Bi","Ci","Di","Ei","Fi","Gi","Hi","Ii","Ji","Ki","Li","Mi","Ni","Oi","Pi","Qi","Ri","Si","Ti","Ui","Vi","Wi","Xi","Yi","Zi","$i","aj","bj","cj","dj","ej","fj","gj","hj","ij","jj","kj","lj","mj","nj","oj","pj","qj","rj","sj","tj","uj","vj","wj","xj","yj","zj","Aj","Bj","Cj","Dj","Ej","Fj","Gj","Hj","Ij","Jj","Kj","Lj","Mj","Nj","Oj","Pj","Qj","Rj","Sj","Tj","Uj","Vj","Wj","Xj","Yj","Zj","ak","bk","ck","dk","ek","fk","gk","hk","ik","jk","kk","lk","mk","nk","ok","Y","Z","pk","qk","rk","sk","tk","uk","vk","wk","xk","yk","zk","Ak","Bk","Ck","Dk","Ek","Fk","Gk","Hk","Ik","Jk","Kk","Lk","Mk","Nk","Ok","Pk","Qk","Rk","Sk","Tk","Uk","Vk","Wk","Xk","Yk","Zk","$k","al","bl","cl","dl","el","fl","gl","hl","il","jl","kl","ll","ml","nl","ol","pl","ql","rl","sl","tl","ul","vl","reactDom_production_min","checkDCE","err","reactDomModule","createRoot","DEVICE_STORAGE_KEY","PairingRequiredError","message","ProfileRevisionError","code","profile","hasDeviceCredential","initializeAccess","pairingToken","response","payload","deviceHeaders","toRequestError","status","fallback","parseResponse","apiRequest","path","body","signal","getActivity","after","getProfiles","forgetThisBrowser","getHistory","options","getHistorySession","usePersistentState","key","initialValue","value","setValue","useState","stored","useEffect","createLatestRequestGate","sequence","controller","candidate","scheduleDebounced","callback","delay","timers","captureProfileOperation","operation","skippedLockedRolloutFiles","files","_a","operationToast","successTitle","partialTitle","skipped","skippedDetail","normalizeHistoryPath","platform","normalized","dedupeHistorySessions","sessions","seen","session","index","threadId","rolloutPath","normalizeStoragePath","caseInsensitive","storagePathsEqual","left","right","storageParentPath","separatorIndex","resolveRestoreTargetSqliteHome","backup","currentDatabasePath","backupMetadata","comparison","restoreRelocationState","targetSqliteHome","restoreDatabase","restoreConfig","sqliteSupported","pathComparisonCaseInsensitive","sourceSqliteHome","explicitSqliteHome","_b","requiresRelocation","missingExplicitTarget","configRestoreConflict","storagePayload","profileId","createProfileRefresh","fetchStatus","fetchBackups","gate","refresh","showLoading","onLoading","onResult","onError","storage","statusPayload","backupPayload","error","Icon","children","size","className","jsx","RefreshIcon","props","jsxs","DatabaseIcon","HistoryIcon","ActivityIcon","OverviewIcon","ShieldIcon","ChevronIcon","XIcon","CheckIcon","AlertIcon","FolderIcon","NAV_ITEMS","EMPTY_BACKUPS","formatNumber","formatBytes","bytes","units","formatDate","renderInlineMarkdown","text","keyPrefix","part","React","SafeMarkdown","block","lines","line","lineIndex","providersFromStatus","sources","add","values","source","bucket","_c","_d","providerSources","StatusDot","tone","AppHeader","busy","onRefresh","healthy","Sidebar","view","setView","onForgetBrowser","item","StorageBar","profiles","setProfileId","onAddProfile","onDeleteProfile","loading","profileSwitchDisabled","event","ProviderBars","title","counts","currentProvider","entries","total","sum","count","provider","StatusPanel","rolloutTotal","sqliteTotal","aligned","_e","_f","_g","_h","_i","_j","_k","_l","_m","Warnings","items","repairs","ProjectVisibility","projects","project","SegmentedControl","onChange","disabled","ExecutionPanel","providers","selectedProvider","setSelectedProvider","onAddManualProvider","onRemoveManualProvider","onRequestExecute","mode","setMode","modelMode","setModelMode","customModel","setCustomModel","keepCount","setKeepCount","manualProvider","setManualProvider","selectedOption","switchAllowed","validManualProvider","sqliteUnsupported","executeDisabled","RecentBackups","backups","onViewAll","onRestore","restoreDisabled","Overview","onExecute","BackupsView","onPrune","ActivityView","activity","activeOperation","entry","HistoryView","query","setQuery","committedQuery","setCommittedQuery","setProvider","setProject","archived","setArchived","page","setPage","history","setHistory","selectedId","setSelectedId","detail","setDetail","setLoading","detailLoading","setDetailLoading","setError","listRequest","useRef","detailRequest","loadList","useCallback","nextHistory","current","requestError","updateFilter","setter","Fragment","Modal","confirmLabel","onConfirm","onCancel","confirmDisabled","ExecuteModal","plan","RestoreModal","setRestoreConfig","setRestoreDatabase","restoreSessions","setRestoreSessions","relocation","PruneModal","deleteCount","ProfileModal","name","setName","codexHome","setCodexHome","sqliteHome","setSqliteHome","valid","Toast","toast","onClose","timer","App","accessState","setAccessState","accessMessage","setAccessMessage","setProfiles","manualProviders","setManualProviders","setStatus","setBackups","setBusy","modal","setModal","setToast","setActivity","setActiveOperation","lastActivityId","useMemo","detected","detectedIds","selectedProfile","profileRefreshRef","quiet","nextStatus","nextBackups","refreshProfiles","handleProfileConflict","openProfileOperation","captured","cancelled","paired","requirePairing","stopped","poll","execute","targetProfileId","common","restore","prune","closeToast","saveProfile","deleteProfile","forgetBrowser","addManualProvider","removeManualProvider"],"mappings":"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","x_google_ignoreList":[0,1,2,3,4,5,6,7,8]} \ No newline at end of file diff --git a/web/dist/assets/index-lKJzyFmh.css b/web/dist/assets/index-lKJzyFmh.css new file mode 100644 index 0000000..da9d206 --- /dev/null +++ b/web/dist/assets/index-lKJzyFmh.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-tight:1.25;--leading-normal:1.5;--leading-relaxed:1.625;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before{box-sizing:border-box;border:0 solid;margin:0;padding:0}::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.top-0{top:0}.top-1\/2{top:50%}.top-4{top:calc(var(--spacing) * 4)}.right-4{right:calc(var(--spacing) * 4)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.left-1\/2{left:50%}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-\[var\(--space-1\)\]{margin-top:var(--space-1)}.mt-\[var\(--space-5\)\]{margin-top:var(--space-5)}.mt-\[var\(--space-6\)\]{margin-top:var(--space-6)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-\[var\(--space-6\)\]{margin-bottom:var(--space-6)}.flex{display:flex}.grid{display:grid}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-4{height:calc(var(--spacing) * 4)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-\[var\(--control-height\)\]{height:var(--control-height)}.max-h-52{max-height:calc(var(--spacing) * 52)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-\[90vh\]{max-height:90vh}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-\[var\(--control-height\)\]{min-height:var(--control-height)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing) * 4)}.w-10{width:calc(var(--spacing) * 10)}.w-\[min\(92vw\,420px\)\]{width:min(92vw,420px)}.w-\[min\(92vw\,680px\)\]{width:min(92vw,680px)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-\[1600px\]{max-width:1600px}.max-w-\[min\(12rem\,70vw\)\]{max-width:min(12rem,70vw)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.min-w-0{min-width:0}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.content-start{align-content:flex-start}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-\[var\(--space-1\)\]{gap:var(--space-1)}.gap-\[var\(--space-2\)\]{gap:var(--space-2)}.gap-\[var\(--space-3\)\]{gap:var(--space-3)}.gap-\[var\(--space-4\)\]{gap:var(--space-4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-\[var\(--border\)\]>:not(:last-child)){border-color:var(--border)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[var\(--radius-control\)\]{border-radius:var(--radius-control)}.rounded-\[var\(--radius-panel\)\]{border-radius:var(--radius-panel)}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-\[var\(--accent\)\]{border-color:var(--accent)}.border-\[var\(--border\)\]{border-color:var(--border)}.border-\[var\(--danger\)\]{border-color:var(--danger)}.border-\[var\(--success\)\]{border-color:var(--success)}.border-\[var\(--warning\)\]{border-color:var(--warning)}.bg-\[color\:var\(--surface-raised\)\/\.96\]{background-color:var(--surface-raised)/.96}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--accent-soft\)\]{background-color:var(--accent-soft)}.bg-\[var\(--danger\)\]{background-color:var(--danger)}.bg-\[var\(--danger-soft\)\]{background-color:var(--danger-soft)}.bg-\[var\(--input\)\]{background-color:var(--input)}.bg-\[var\(--success-soft\)\]{background-color:var(--success-soft)}.bg-\[var\(--surface\)\]{background-color:var(--surface)}.bg-\[var\(--surface-hover\)\]{background-color:var(--surface-hover)}.bg-\[var\(--surface-raised\)\]{background-color:var(--surface-raised)}.bg-\[var\(--warning-soft\)\]{background-color:var(--warning-soft)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[var\(--space-4\)\]{padding:var(--space-4)}.p-\[var\(--space-5\)\]{padding:var(--space-5)}.p-\[var\(--space-6\)\]{padding:var(--space-6)}.px-0{padding-inline:0}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-\[var\(--space-3\)\]{padding-inline:var(--space-3)}.px-\[var\(--space-4\)\]{padding-inline:var(--space-4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-\[var\(--space-1\)\]{padding-block:var(--space-1)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pb-1{padding-bottom:var(--spacing)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\[font-size\:var\(--text-2xl\)\]{font-size:var(--text-2xl)}.\[font-size\:var\(--text-sm\)\]{font-size:var(--text-sm)}.\[font-size\:var\(--text-xs\)\]{font-size:var(--text-xs)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[var\(--leading-normal\)\]{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-\[var\(--leading-relaxed\)\]{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-\[var\(--leading-tight\)\]{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[var\(--accent-strong\)\]{color:var(--accent-strong)}.text-\[var\(--danger\)\]{color:var(--danger)}.text-\[var\(--muted\)\]{color:var(--muted)}.text-\[var\(--success\)\]{color:var(--success)}.text-\[var\(--text\)\]{color:var(--text)}.text-\[var\(--warning\)\]{color:var(--warning)}.text-white{color:var(--color-white)}.accent-\[var\(--accent\)\]{accent-color:var(--accent)}.accent-\[var\(--muted\)\]{accent-color:var(--muted)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.\[box-shadow\:var\(--shadow-panel\)\]{box-shadow:var(--shadow-panel)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.placeholder\:text-\[var\(--muted\)\]::placeholder{color:var(--muted)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:bg-\[var\(--accent-strong\)\]:hover{background-color:var(--accent-strong)}.hover\:bg-\[var\(--surface-hover\)\]:hover{background-color:var(--surface-hover)}.hover\:text-\[var\(--text\)\]:hover{color:var(--text)}.hover\:brightness-95:hover{--tw-brightness:brightness(95%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:not-sr-only:focus{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.focus\:fixed:focus{position:fixed}.focus\:top-4:focus{top:calc(var(--spacing) * 4)}.focus\:left-4:focus{left:calc(var(--spacing) * 4)}.focus\:z-\[70\]:focus{z-index:70}.focus\:rounded:focus{border-radius:.25rem}.focus\:bg-\[var\(--accent\)\]:focus{background-color:var(--accent)}.focus\:px-4:focus{padding-inline:calc(var(--spacing) * 4)}.focus\:py-2:focus{padding-block:calc(var(--spacing) * 2)}.focus\:text-white:focus{color:var(--color-white)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[var\(--focus\)\]:focus-visible{--tw-ring-color:var(--focus)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-\[var\(--surface\)\]:focus-visible{--tw-ring-offset-color:var(--surface)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.data-\[state\=closed\]\:animate-none[data-state=closed]{animation:none}@media (min-width:40rem){.sm\:grid{display:grid}.sm\:w-auto{width:auto}.sm\:shrink{flex-shrink:1}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[180px_1fr\]{grid-template-columns:180px 1fr}.sm\:justify-end{justify-content:flex-end}.sm\:overflow-visible{overflow:visible}.sm\:pb-0{padding-bottom:0}}@media (min-width:48rem){.md\:min-h-\[calc\(100vh-4rem\)\]{min-height:calc(100vh - 4rem)}.md\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-\[240px_minmax\(0\,1fr\)\]{grid-template-columns:240px minmax(0,1fr)}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1fr\)_420px\]{grid-template-columns:minmax(0,1fr) 420px}.xl\:grid-cols-\[minmax\(0\,1fr\)_minmax\(320px\,440px\)\]{grid-template-columns:minmax(0,1fr) minmax(320px,440px)}}}:root,:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--surface:#f6f7fb;--surface-raised:#fff;--surface-hover:#eef1f7;--input:#fff;--border:#dce1eb;--text:#172033;--muted:#657086;--accent:#4867e8;--accent-strong:#3452ce;--accent-soft:#e9edff;--focus:#315ee8;--success:#16734a;--success-soft:#e6f6ee;--warning:#9a5b00;--warning-soft:#fff3d8;--danger:#b42335;--danger-soft:#fdebed;--control-height:2.5rem;--radius-control:.5rem;--radius-panel:.75rem;--shadow-panel:0 1px 2px #17203314, 0 8px 24px #17203308;--font-sans:"Segoe UI Variable Text", "Segoe UI", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;--text-xs:.75rem;--text-sm:.875rem;--text-base:1rem;--text-lg:1.125rem;--text-xl:1.25rem;--text-2xl:1.5rem;--leading-tight:1.25;--leading-normal:1.5;--leading-relaxed:1.625;--space-1:.25rem;--space-2:.5rem;--space-3:.75rem;--space-4:1rem;--space-5:1.25rem;--space-6:1.5rem;font-family:var(--font-sans)}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--surface:#11141b;--surface-raised:#181d27;--surface-hover:#242b38;--input:#111722;--border:#30394a;--text:#eef2f8;--muted:#a6b0c1;--accent:#7189ff;--accent-strong:#8fa1ff;--accent-soft:#222d58;--focus:#91a3ff;--success:#67d8a4;--success-soft:#17392d;--warning:#f2bb61;--warning-soft:#3d2e17;--danger:#ff8c99;--danger-soft:#461e26}@media (prefers-color-scheme:dark){:root[data-theme=system]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--surface:#11141b;--surface-raised:#181d27;--surface-hover:#242b38;--input:#111722;--border:#30394a;--text:#eef2f8;--muted:#a6b0c1;--accent:#7189ff;--accent-strong:#8fa1ff;--accent-soft:#222d58;--focus:#91a3ff;--success:#67d8a4;--success-soft:#17392d;--warning:#f2bb61;--warning-soft:#3d2e17;--danger:#ff8c99;--danger-soft:#461e26}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.001ms!important;animation-duration:.001ms!important;animation-iteration-count:1!important}}html{background:var(--surface);min-width:320px}body{background:var(--surface);min-width:320px;min-height:100vh;margin:0}button,input,select{font:inherit}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}} diff --git a/web/dist/favicon.svg b/web/dist/favicon.svg new file mode 100644 index 0000000..d0188e0 --- /dev/null +++ b/web/dist/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/dist/index.html b/web/dist/index.html index 300e3d4..23bb139 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -1,16 +1,17 @@ - + - - + + + Codex Provider Sync - - + +
- + diff --git a/web/dist/theme-bootstrap.js b/web/dist/theme-bootstrap.js new file mode 100644 index 0000000..8692a8f --- /dev/null +++ b/web/dist/theme-bootstrap.js @@ -0,0 +1,10 @@ +(() => { + try { + const theme = globalThis.localStorage.getItem("cps.preference.theme"); + if (theme === "system" || theme === "light" || theme === "dark") { + document.documentElement.dataset.theme = theme; + } + } catch { + // Preferences are optional; the system theme remains the safe default. + } +})(); diff --git a/web/index.html b/web/index.html deleted file mode 100644 index c4f650d..0000000 --- a/web/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - Codex Provider Sync - - -
- - - diff --git a/web/src/App.jsx b/web/src/App.jsx deleted file mode 100644 index fd25458..0000000 --- a/web/src/App.jsx +++ /dev/null @@ -1,1025 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { - PairingRequiredError, - ProfileRevisionError, - apiRequest, - forgetThisBrowser, - getActivity, - getHistory, - getHistorySession, - getProfiles, - initializeAccess -} from "./api.js"; -import { usePersistentState } from "./hooks.js"; -import { createLatestRequestGate, scheduleDebounced } from "./history-requests.js"; -import { captureProfileOperation, dedupeHistorySessions, operationToast, resolveRestoreTargetSqliteHome, restoreRelocationState } from "./operation-state.js"; -import { createProfileRefresh, storagePayload } from "./profile-refresh.js"; -import { - ActivityIcon, - AlertIcon, - CheckIcon, - ChevronIcon, - DatabaseIcon, - FolderIcon, - HistoryIcon, - OverviewIcon, - RefreshIcon, - ShieldIcon, - XIcon -} from "./icons.jsx"; - -const NAV_ITEMS = [ - { id: "overview", label: "概览", icon: OverviewIcon }, - { id: "history", label: "聊天记录", icon: HistoryIcon }, - { id: "backups", label: "备份", icon: HistoryIcon }, - { id: "activity", label: "活动", icon: ActivityIcon } -]; - -const EMPTY_BACKUPS = { backupRoot: "", backups: [] }; - -function formatNumber(value) { - return new Intl.NumberFormat("zh-CN").format(Number(value) || 0); -} - -function formatBytes(bytes) { - const units = ["B", "KB", "MB", "GB", "TB"]; - let value = Number(bytes) || 0; - let index = 0; - while (value >= 1024 && index < units.length - 1) { - value /= 1024; - index += 1; - } - return index === 0 ? `${value} B` : `${value.toFixed(value >= 10 ? 1 : 2).replace(/\.0$/, "")} ${units[index]}`; -} - -function formatDate(value) { - if (!value) return "未知时间"; - return new Intl.DateTimeFormat("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false - }).format(new Date(value)); -} - -function renderInlineMarkdown(text, keyPrefix) { - const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g); - return parts.map((part, index) => { - if (part.startsWith("`") && part.endsWith("`")) return {part.slice(1, -1)}; - if (part.startsWith("**") && part.endsWith("**")) return {part.slice(2, -2)}; - if (part.startsWith("*") && part.endsWith("*")) return {part.slice(1, -1)}; - return {part}; - }); -} - -function SafeMarkdown({ text }) { - const blocks = String(text ?? "").split(/(```[^\n]*\n[\s\S]*?```)/g); - return blocks.map((block, index) => { - if (block.startsWith("```") && block.endsWith("```")) { - const lines = block.slice(3, -3).replace(/^\w*\n/, ""); - return
{lines}
; - } - return block.split("\n").map((line, lineIndex, lines) => {renderInlineMarkdown(line, `${index}-${lineIndex}`)}{lineIndex < lines.length - 1 ?
: null}
); - }); -} - -function providersFromStatus(status) { - if (!status) return []; - const sources = new Map(); - const add = (values, source) => { - for (const value of values ?? []) { - if (!value || value === "(missing)") continue; - const bucket = sources.get(value) ?? new Set(); - bucket.add(source); - sources.set(value, bucket); - } - }; - add(status.configuredProviders, "config"); - add(Object.keys(status.rolloutCounts?.sessions ?? {}), "rollout"); - add(Object.keys(status.rolloutCounts?.archived_sessions ?? {}), "rollout"); - add(Object.keys(status.sqliteCounts?.sessions ?? {}), "sqlite"); - add(Object.keys(status.sqliteCounts?.archived_sessions ?? {}), "sqlite"); - add([status.currentProvider], "config"); - return [...sources.entries()] - .map(([id, providerSources]) => ({ - id, - sources: [...providerSources], - configured: status.configuredProviders?.includes(id), - current: id === status.currentProvider - })) - .sort((left, right) => Number(right.current) - Number(left.current) || left.id.localeCompare(right.id)); -} - -function StatusDot({ tone = "neutral" }) { - return