diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml index 19db4d59ad..5bbbfb3077 100644 --- a/.github/workflows/cli-package.yml +++ b/.github/workflows/cli-package.yml @@ -26,7 +26,7 @@ concurrency: jobs: # ── Resolve version info (mirrors desktop-package.yml) ───────────── prepare: - if: github.event_name != 'release' || !startsWith(github.event.release.tag_name, 'data-migrator-v') + if: github.event_name != 'release' || (!startsWith(github.event.release.tag_name, 'data-tools-v') && !startsWith(github.event.release.tag_name, 'data-migrator-v')) name: Prepare runs-on: ubuntu-latest outputs: diff --git a/.github/workflows/data-migrator-package.yml b/.github/workflows/data-tools-package.yml similarity index 56% rename from .github/workflows/data-migrator-package.yml rename to .github/workflows/data-tools-package.yml index 50113fdf5a..253729f6c1 100644 --- a/.github/workflows/data-migrator-package.yml +++ b/.github/workflows/data-tools-package.yml @@ -1,20 +1,20 @@ -name: Data Migrator Package +name: Data Tools Package on: push: - tags: ['data-migrator-v*'] + tags: ['data-tools-v*', 'data-migrator-v*'] workflow_dispatch: permissions: contents: read concurrency: - group: data-migrator-package-${{ github.ref }} + group: data-tools-package-${{ github.ref }} cancel-in-progress: false jobs: build: - name: Migrator (${{ matrix.platform }}) + name: Data Tools (${{ matrix.platform }}) runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: @@ -49,7 +49,7 @@ jobs: targets: ${{ matrix.target }} - uses: swatinem/rust-cache@v2 with: - shared-key: data-migrator-${{ matrix.platform }} + shared-key: data-tools-${{ matrix.platform }} cache-bin: false - name: Install Linux WebView build dependencies if: runner.os == 'Linux' @@ -57,22 +57,24 @@ jobs: - run: pnpm install --frozen-lockfile --ignore-scripts - name: Verify independent version and packaging contract run: | - node scripts/data-migrator-release.mjs --check-version - node --test scripts/data-migrator-tauri-build.test.mjs + node scripts/data-tools-release.mjs --check-version + node --test scripts/data-tools-tauri-build.test.mjs - name: Check offline data compatibility - run: cargo test --locked -p openbitfun-data-migrator -p openbitfun-legacy-migration-adapters -p openbitfun-legacy-migration -p openbitfun-config-contracts --lib --test migration_engine_contracts + run: | + cargo test --locked -p openbitfun-services-core --no-default-features --features session-recovery --lib session_recovery + cargo test --locked -p openbitfun-data-tools -p openbitfun-legacy-migration-adapters -p openbitfun-legacy-migration -p openbitfun-config-contracts --lib --test migration_engine_contracts - name: Build independent package - run: pnpm run data-migrator:build -- --target ${{ matrix.target }} ${{ matrix.bundles }} + run: pnpm run data-tools:build -- --target ${{ matrix.target }} ${{ matrix.bundles }} - name: Stage package and checksum - run: node scripts/data-migrator-release.mjs ${{ matrix.platform }} + run: node scripts/data-tools-release.mjs ${{ matrix.platform }} - uses: actions/upload-artifact@v6 with: - name: data-migrator-${{ matrix.platform }} - path: target/data-migrator-release/${{ matrix.platform }}/* + name: data-tools-${{ matrix.platform }} + path: target/data-tools-release/${{ matrix.platform }}/* if-no-files-found: error draft-release: - if: startsWith(github.ref, 'refs/tags/data-migrator-v') + if: startsWith(github.ref, 'refs/tags/data-tools-v') || startsWith(github.ref, 'refs/tags/data-migrator-v') needs: build runs-on: ubuntu-latest permissions: @@ -86,29 +88,29 @@ jobs: package-manager-cache: false - uses: actions/download-artifact@v7 with: - pattern: data-migrator-* - path: migrator-release-assets + pattern: data-tools-* + path: data-tools-release-assets merge-multiple: true - - name: Sign and verify with the independent migrator release key + - name: Sign and verify with the independent Data Tools release key shell: bash env: - OPENBITFUN_SIGNING_KEY: ${{ secrets.DATA_MIGRATOR_SIGNING_PRIVATE_KEY }} - OPENBITFUN_SIGNING_PASSWORD: ${{ secrets.DATA_MIGRATOR_SIGNING_PRIVATE_KEY_PASSWORD }} - OPENBITFUN_SIGNING_PUBKEY: ${{ secrets.DATA_MIGRATOR_SIGNING_PUBKEY }} + OPENBITFUN_SIGNING_KEY: ${{ secrets.DATA_TOOLS_SIGNING_PRIVATE_KEY }} + OPENBITFUN_SIGNING_PASSWORD: ${{ secrets.DATA_TOOLS_SIGNING_PRIVATE_KEY_PASSWORD }} + OPENBITFUN_SIGNING_PUBKEY: ${{ secrets.DATA_TOOLS_SIGNING_PUBKEY }} run: | set -euo pipefail test -n "$OPENBITFUN_SIGNING_KEY" test -n "$OPENBITFUN_SIGNING_PUBKEY" - cat migrator-release-assets/*.sha256 | sort -k2 > migrator-release-assets/SHA256SUMS - (cd migrator-release-assets && sha256sum --check SHA256SUMS) - bash scripts/sign-release-assets.sh migrator-release-assets/* - node scripts/write-minisign-public-key.mjs --out migrator-release-assets/data-migrator.minisign.pub + cat data-tools-release-assets/*.sha256 | sort -k2 > data-tools-release-assets/SHA256SUMS + (cd data-tools-release-assets && sha256sum --check SHA256SUMS) + bash scripts/sign-release-assets.sh data-tools-release-assets/* + node scripts/write-minisign-public-key.mjs --out data-tools-release-assets/data-tools.minisign.pub - name: Create a separately reviewed release uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.ref_name }} - name: OpenBitFun Data Migrator ${{ github.ref_name }} + name: OpenBitFun Data Tools ${{ github.ref_name }} draft: true make_latest: false - files: migrator-release-assets/* - body_path: src/apps/data-migrator/RELEASE.md + files: data-tools-release-assets/* + body_path: src/apps/data-tools/RELEASE.md diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index e79a76e20a..3636229f47 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -44,7 +44,7 @@ concurrency: jobs: # ── Resolve version info ─────────────────────────────────────────── prepare: - if: github.event_name != 'release' || !startsWith(github.event.release.tag_name, 'data-migrator-v') + if: github.event_name != 'release' || (!startsWith(github.event.release.tag_name, 'data-tools-v') && !startsWith(github.event.release.tag_name, 'data-migrator-v')) name: Prepare runs-on: ubuntu-latest outputs: diff --git a/Cargo.lock b/Cargo.lock index 8743218fae..e4ac5609aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6297,13 +6297,14 @@ dependencies = [ ] [[package]] -name = "openbitfun-data-migrator" -version = "0.1.2" +name = "openbitfun-data-tools" +version = "0.2.0" dependencies = [ "openbitfun-core-types", "openbitfun-legacy-migration", "openbitfun-legacy-migration-adapters", "openbitfun-product-domains", + "openbitfun-services-core", "serde", "serde_json", "tauri", diff --git a/Cargo.toml b/Cargo.toml index b36f44ebfa..93386ab187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [ "src/apps/cli", "src/apps/sdk-host", "src/apps/desktop", - "src/apps/data-migrator", + "src/apps/data-tools", "src/apps/server", "src/apps/relay-server", "src/apps/miniapp-market-server", diff --git a/OpenBitFun-Installer/AGENTS.md b/OpenBitFun-Installer/AGENTS.md index efd0908199..da5f4a9936 100644 --- a/OpenBitFun-Installer/AGENTS.md +++ b/OpenBitFun-Installer/AGENTS.md @@ -61,7 +61,7 @@ cargo test --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml --lib previ For installer payload validation and the independent Data Migrator boundary, run: ```bash -node --test OpenBitFun-Installer/scripts/build-installer.test.cjs scripts/data-migrator-tauri-build.test.mjs +node --test OpenBitFun-Installer/scripts/build-installer.test.cjs scripts/data-tools-tauri-build.test.mjs cargo test --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml --lib installer::commands::tests ``` diff --git a/OpenBitFun-Installer/README.md b/OpenBitFun-Installer/README.md index 6000d02c88..bed1129cf5 100644 --- a/OpenBitFun-Installer/README.md +++ b/OpenBitFun-Installer/README.md @@ -14,7 +14,7 @@ Instead of relying on the generic NSIS wizard UI from Tauri's built-in bundler, ## Legacy data migration Data Migrator is distributed separately and is not included in this installer. -To import legacy data, download and run the [standalone Data Migrator](../src/apps/data-migrator/README.md) after closing both applications. +To import legacy data, download and run the [standalone Data Migrator](../src/apps/data-tools/README.md) after closing both applications. ## Common tasks diff --git a/README.md b/README.md index aae4e69c63..43e756b69f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ kinds of work, and major upgrades to cross-device collaboration and remote contr tasks move smoothly across desktop, mobile, and browser. **Migration note:** OpenBitFun 1.0.0 is not data-compatible with legacy 0.2.x releases. To migrate legacy data, -use the [OpenBitFun Data Migrator](src/apps/data-migrator/README.md), currently available +use the [OpenBitFun Data Tools](src/apps/data-tools/README.md), currently available for stable releases 0.2.17–0.2.19. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 059a86891e..3384d82085 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,7 +30,7 @@ OpenBitFun 1.0.0 带来全新界面、面向不同任务的四种 Agent Harness 多设备协同与远程控制,让任务在桌面、手机与浏览器之间顺畅接续。 **数据迁移提醒:** OpenBitFun 1.0.0 与 0.2.x 旧版数据不互通。需要迁移旧版数据时,请使用 -[OpenBitFun 数据迁移器](src/apps/data-migrator/README.zh-CN.md),当前支持 0.2.17~0.2.19 正式版。 +[OpenBitFun 数据工具](src/apps/data-tools/README.zh-CN.md),当前支持 0.2.17~0.2.19 正式版。 --- diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 1d563ee453..96b2c8c532 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -900,7 +900,7 @@ Shared Agent Runtime 是第一方多实例的目标部署,不是上表新增 ## 独立数据迁移工具的依赖边界 -Data Migrator 是独立发布的本地离线工具,不依赖 Core、Product Assembly、Desktop 或 Web UI。 +Data Tools 是独立发布的本地离线工具,不依赖 Core、Product Assembly、Desktop 或 Web UI。 主应用不检测、启动或捆绑迁移器。两者在同一源码工作区复用稳定的数据格式与存储实现: - contracts/config-contracts:配置 DTO、默认值、版本校验及到共享模型 DTO 的纯转换;Core 原路径保留转发,ConfigProvider 仍在 Core。 @@ -912,7 +912,7 @@ Data Migrator 是独立发布的本地离线工具,不依赖 Core、Product As WorkspaceInfo/WorkspaceIdentity 的运行操作由 Core 的 runtime extension traits 保留,稳定记录无需导入这些能力。 原 Core 存储入口保留错误映射;可选 legacy-migration facade 保留旧导入路径,但不再由 product-full 启用。 远程四种场景不提供迁移工具的执行入口;仅转换本机保存的连接记录,不连接远端。 -使用与发行契约以 [独立迁移器说明](../../src/apps/data-migrator/README.zh-CN.md) 为准。 +使用与发行契约以 [独立数据工具说明](../../src/apps/data-tools/README.zh-CN.md) 为准。 ## Agent 身份与产品概念 diff --git a/docs/architecture/product-customization-blueprint.md b/docs/architecture/product-customization-blueprint.md index 4e864d4a26..785d061f4c 100644 --- a/docs/architecture/product-customization-blueprint.md +++ b/docs/architecture/product-customization-blueprint.md @@ -75,9 +75,9 @@ i18n locale 集合和 key parity。 - 修改 schema、resolver 或 Desktop/CLI build adapter 行为时,再运行 `pnpm run product:test`; - 打包和平台矩阵只在变更触及对应交付路径时运行,不作为产品定义的默认本地预检。 -Data Migrator 已从产品 family 与 sibling binary 投影中移除,使用自己的版本、Tauri 身份、构建与发布工作流。 +Data Tools 已从产品 family 与 sibling binary 投影中移除,使用自己的版本、Tauri 身份、构建与发布工作流。 它固定面向 OpenBitFun 数据格式;工具身份与目标数据身份分开。旧定制产品定义应移除 `members.dataMigrator`。 -详见 [迁移器 README](../../src/apps/data-migrator/README.zh-CN.md)。 +详见 [数据工具 README](../../src/apps/data-tools/README.zh-CN.md)。 ## 1. 设计结论 diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 92d0943135..770d082c91 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -250,7 +250,7 @@ cargo check -p --timings ## 独立数据迁移工具的依赖边界 -Data Migrator 是独立发布的本地离线工具,不依赖 Core、Product Assembly、Desktop 或 Web UI。 +Data Tools 是独立发布的本地离线工具,不依赖 Core、Product Assembly、Desktop 或 Web UI。 主应用不检测、启动或捆绑迁移器。两者在同一源码工作区复用稳定的数据格式与存储实现: - contracts/config-contracts:配置 DTO、默认值、版本校验及到共享模型 DTO 的纯转换;Core 原路径保留转发,ConfigProvider 仍在 Core。 @@ -262,4 +262,4 @@ Data Migrator 是独立发布的本地离线工具,不依赖 Core、Product As WorkspaceInfo/WorkspaceIdentity 的运行操作由 Core 的 runtime extension traits 保留,稳定记录无需导入这些能力。 原 Core 存储入口保留错误映射;可选 legacy-migration facade 保留旧导入路径,但不再由 product-full 启用。 远程四种场景不提供迁移工具的执行入口;仅转换本机保存的连接记录,不连接远端。 -使用与发行契约以 [独立迁移器说明](../../src/apps/data-migrator/README.zh-CN.md) 为准。 +使用与发行契约以 [独立数据工具说明](../../src/apps/data-tools/README.zh-CN.md) 为准。 diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index 634e906649..b922444b52 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -211,9 +211,9 @@ Mobile Web 直接消费 `@openbitfun/theme-openbitfun`。`ThemeProvider` 与首 ### Desktop bootstrap 与 Native Mobile 预览 -Data Migrator 的独立静态界面直接消费设计系统公开的字体、间距、控件和语义颜色 Token。 -`generate-data-migrator-theme.mjs` 构建设计系统的 Token/主题包,并将公开 CSS 入口及其依赖打包为 -`src/apps/data-migrator/ui/generated/design-system.css`,随迁移程序离线交付。原生控件只使用 canonical +Data Tools 的独立静态界面直接消费设计系统公开的字体、间距、控件和语义颜色 Token。 +`generate-data-tools-theme.mjs` 构建设计系统的 Token/主题包,并将公开 CSS 入口及其依赖打包为 +`src/apps/data-tools/ui/generated/design-system.css`,随迁移程序离线交付。原生控件只使用 canonical Token;`theme.js` 在首屏绘制前选择系统浅色/深色和高对比模式,并监听系统设置变化。 仅独立迁移器打包刷新该资源,开发者也可显式运行生成命令;Desktop 开发和打包不依赖迁移器。 统一颜色审计同时检查源码和生成物漂移。 diff --git a/package.json b/package.json index 35a2615ff8..5bd8718f9b 100644 --- a/package.json +++ b/package.json @@ -117,12 +117,12 @@ "target:gc": "node scripts/cargo-target-gc.mjs", "product:check": "node scripts/product-customization/cli.mjs check", "product:explain": "node scripts/product-customization/cli.mjs explain", - "product:test": "node --test --test-concurrency=1 scripts/product-customization/*.test.mjs scripts/cli-product.test.mjs scripts/desktop-tauri-build.test.mjs scripts/data-migrator-tauri-build.test.mjs", - "data-migrator:dev": "cargo run -p openbitfun-data-migrator --bin openbitfun-data-migrator", - "data-migrator:check": "cargo check -p openbitfun-data-migrator", - "data-migrator:theme:generate": "node scripts/generate-data-migrator-theme.mjs", - "data-migrator:theme:check": "node scripts/generate-data-migrator-theme.mjs --check", - "data-migrator:build": "node scripts/data-migrator-tauri-build.mjs", + "product:test": "node --test --test-concurrency=1 scripts/product-customization/*.test.mjs scripts/cli-product.test.mjs scripts/desktop-tauri-build.test.mjs scripts/data-tools-tauri-build.test.mjs", + "data-tools:dev": "cargo run -p openbitfun-data-tools --bin openbitfun-data-tools", + "data-tools:check": "cargo check -p openbitfun-data-tools", + "data-tools:theme:generate": "node scripts/generate-data-tools-theme.mjs", + "data-tools:theme:check": "node scripts/generate-data-tools-theme.mjs --check", + "data-tools:build": "node scripts/data-tools-tauri-build.mjs", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index d79942c768..9b60456063 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -4153,6 +4153,7 @@ test('Services Core accepts only the reviewed feature-owned Tokio runtime graph' 'tokio/sync', 'tokio/time', ], + 'session-recovery': ['local-storage', 'workspace-persistence', 'dep:anyhow'], permission: ['dep:tokio', 'tokio/rt'], 'session-search': ['dep:tokio', 'tokio/rt'], 'process-runtime': [ diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index e094f45ba3..7bb04e3121 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -175,6 +175,8 @@ const SERVICES_CORE_TOKIO_FEATURES = new Map([ ['filesystem', ['fs', 'rt', 'sync']], ['json-io', ['fs', 'rt', 'sync', 'time']], ['local-storage', ['fs', 'rt', 'sync', 'time']], + // Offline repair uses strict atomic storage writes; no processes or network. + ['session-recovery', ['fs', 'rt', 'sync', 'time']], ['permission', ['rt']], ['process-runtime', ['io-util', 'process', 'rt', 'time']], ['session-search', ['rt']], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index cebf08d27d..fa4191fe8b 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -39,7 +39,8 @@ export const optionalDependencyFeatureOwnerRules = [ }, { crateName: 'services-core', - reviewedAggregateFeatures: ['workspace-persistence'], + // Offline recovery composes only existing storage/identity contracts. + reviewedAggregateFeatures: ['workspace-persistence', 'session-recovery'], reason: 'services-core optional implementation dependencies must stay behind their exact owner capability', dependencies: [ @@ -47,7 +48,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'zip', ownerFeatures: ['pet-packages'] }, { depName: 'uuid', ownerFeatures: ['pet-packages'] }, { depName: 'aes-gcm', ownerFeatures: ['credential-vault'] }, - { depName: 'anyhow', ownerFeatures: ['credential-vault', 'dispatch-workspace', 'workspace-runtime', 'workspace-transfer'] }, + { depName: 'anyhow', ownerFeatures: ['credential-vault', 'dispatch-workspace', 'workspace-runtime', 'workspace-transfer', 'session-recovery'] }, { depName: 'async-trait', ownerFeatures: ['permission', 'workspace-runtime'] }, { depName: 'base64', ownerFeatures: ['pet-packages', 'credential-vault', 'filesystem'] }, { @@ -507,6 +508,7 @@ export const capabilityContractDependencyRules = [ capabilityForwarder('workspace-transfer', 'workspace-ports'), ], ['permission', 'workspace-runtime', 'workspace-persistence', 'workspace-transfer'], + ['session-recovery'], )], ['openbitfun-services-integrations', capabilityConsumer( [capabilityEdge([], { optional: true })], diff --git a/scripts/data-migrator-tauri-build.test.mjs b/scripts/data-migrator-tauri-build.test.mjs deleted file mode 100644 index b045752d87..0000000000 --- a/scripts/data-migrator-tauri-build.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import test from 'node:test'; -import { prepareDataMigratorTauriConfig, dataMigratorEnvironment } from './data-migrator-tauri-build.mjs'; -import { releaseVersion, validateReleaseTag } from './data-migrator-release.mjs'; -import { prepareTauriConfig } from './desktop-tauri-build.mjs'; -const ROOT = resolve(import.meta.dirname, '..'); -const APP = join(ROOT, 'src/apps/data-migrator'); - -test('independent bundle owns version, offline assets, icons and identity', () => { - const output = prepareDataMigratorTauriConfig(join(APP, 'tauri.conf.json'), mkdtempSync(join(tmpdir(), 'migrator-config-'))); - const config = JSON.parse(readFileSync(output, 'utf8')); - assert.equal(config.productName, 'OpenBitFun Data Migrator'); - assert.equal(config.identifier, 'com.openbitfun.data-migrator'); - assert.equal(config.version, releaseVersion()); - assert.deepEqual(config.build, { frontendDist: 'ui' }); - assert.equal(config.plugins?.updater, undefined); - assert.equal(config.bundle.externalBin, undefined); - assert.ok(config.bundle.icon.every((icon) => !icon.includes('desktop') && existsSync(join(APP, icon)))); - assert.ok(config.app.security.csp.includes("connect-src 'self'")); -}); - -test('a Desktop product environment cannot change the migration destination identity', () => { - const env = dataMigratorEnvironment({ OPENBITFUN_PRODUCT_ID: 'acme', OPENBITFUN_DATA_NAMESPACE: 'acme', OPENBITFUN_DESKTOP_BINARY_NAME: 'acme', CI: '1' }); - assert.equal(env.OPENBITFUN_PRODUCT_ID, 'openbitfun'); - assert.equal(env.OPENBITFUN_DATA_NAMESPACE, 'openbitfun'); - assert.equal(env.OPENBITFUN_DESKTOP_BINARY_NAME, undefined); - assert.equal(env.CI, 'true'); -}); - -test('Desktop package generation has no migrator payload or build hook', () => { - const desktop = join(ROOT, 'src/apps/desktop'); - const output = prepareTauriConfig(join(desktop, 'tauri.conf.json'), { desktopDir: desktop }); - const config = JSON.parse(readFileSync(output, 'utf8')); - assert.ok(!(config.bundle.externalBin || []).some((file) => file.includes('migrator'))); - for (const file of ['scripts/dev.cjs', 'scripts/desktop-tauri-build.mjs', 'src/apps/desktop/src/lib.rs']) { - assert.doesNotMatch(readFileSync(join(ROOT, file), 'utf8'), /data-migrator|legacy_migration_api/); - } -}); - -test('migrator dependency and command closure exclude the main app and restart handshake', () => { - const manifest = readFileSync(join(APP, 'Cargo.toml'), 'utf8'); - assert.doesNotMatch(manifest, /^openbitfun-core\s*=|product-full|product-capabilities|plugin-runtime/m); - const source = readFileSync(join(APP, 'src/app_state.rs'), 'utf8'); - assert.doesNotMatch(source, /HandoffStore|MigrationOnboardingStore|restart_desktop|TrustedInstallationResolver/); - const capability = readFileSync(join(APP, 'capabilities/migrator.json'), 'utf8'); - assert.doesNotMatch(capability, /fs:|shell:|updater:|dialog:/); -}); - -test('independent release tags cannot be confused with main app versions', () => { - const version = releaseVersion(); - assert.doesNotThrow(() => validateReleaseTag('data-migrator-v' + version, version)); - assert.throws(() => validateReleaseTag('v' + version, version)); - assert.throws(() => validateReleaseTag('data-migrator-v9.0.0', version)); -}); - -test('all static UI labels and locale keys have complete translations', () => { - const html = readFileSync(join(APP, 'ui/index.html'), 'utf8'); - const locales = JSON.parse(readFileSync(join(APP, 'ui/locales.json'), 'utf8')); - const keys = Object.keys(locales.en).sort(); - for (const labels of Object.values(locales)) { - assert.deepEqual(Object.keys(labels).sort(), keys); - for (const match of html.matchAll(/data-i18n="([^"]+)"/g)) assert.ok(labels[match[1]], match[1]); - } -}); diff --git a/scripts/data-migrator-release.mjs b/scripts/data-tools-release.mjs similarity index 57% rename from scripts/data-migrator-release.mjs rename to scripts/data-tools-release.mjs index ae45a91e1e..7876a201f7 100644 --- a/scripts/data-migrator-release.mjs +++ b/scripts/data-tools-release.mjs @@ -13,46 +13,46 @@ export const PLATFORMS = { 'linux-x64': { target: 'x86_64-unknown-linux-gnu', extension: 'AppImage', folder: 'appimage' }, }; export function releaseVersion(root = ROOT) { - const manifest = readFileSync(join(root, 'src/apps/data-migrator/Cargo.toml'), 'utf8'); + const manifest = readFileSync(join(root, 'src/apps/data-tools/Cargo.toml'), 'utf8'); const version = manifest.match(/^version = "([^"]+)"/m)?.[1]; - if (!version || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) throw new Error('Invalid independent migrator version'); - const config = JSON.parse(readFileSync(join(root, 'src/apps/data-migrator/tauri.conf.json'), 'utf8')); - if (config.version !== version) throw new Error('Migrator version differs between Cargo and Tauri'); + if (!version || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) throw new Error('Invalid independent Data Tools version'); + const config = JSON.parse(readFileSync(join(root, 'src/apps/data-tools/tauri.conf.json'), 'utf8')); + if (config.version !== version) throw new Error('Data Tools version differs between Cargo and Tauri'); return version; } export function validateReleaseTag(tag, version) { - if (tag !== `data-migrator-v${version}`) throw new Error('Tag must match the independent migrator version'); + if (tag !== `data-tools-v${version}` && tag !== `data-migrator-v${version}`) throw new Error('Tag must match the independent Data Tools version'); } -function stage(platform) { +export function stage(platform, { root = ROOT, environment = process.env } = {}) { const metadata = PLATFORMS[platform]; - if (!metadata) throw new Error('Unsupported migrator release platform'); - const version = releaseVersion(); - if (process.env.GITHUB_REF_TYPE === 'tag') validateReleaseTag(process.env.GITHUB_REF_NAME, version); - const target = resolve(ROOT, process.env.CARGO_TARGET_DIR || 'target', metadata.target, 'release'); - const output = join(ROOT, 'target/data-migrator-release', platform); + if (!metadata) throw new Error('Unsupported Data Tools release platform'); + const version = releaseVersion(root); + if (environment.GITHUB_REF_TYPE === 'tag') validateReleaseTag(environment.GITHUB_REF_NAME, version); + const target = resolve(root, environment.CARGO_TARGET_DIR || 'target', metadata.target, 'release'); + const output = join(root, 'target/data-tools-release', platform); mkdirSync(output, { recursive: true }); - const asset = `openbitfun-data-migrator-v${version}-${platform}.${metadata.extension}`; + const asset = `openbitfun-data-tools-v${version}-${platform}.${metadata.extension}`; const destination = join(output, asset); if (platform === 'windows-x64') { - const portable = join(ROOT, 'target/data-migrator-portable', version); + const portable = join(root, 'target/data-tools-portable', version); mkdirSync(portable, { recursive: true }); - copyFileSync(join(target, 'openbitfun-data-migrator.exe'), join(portable, 'openbitfun-data-migrator.exe')); - for (const file of ['README.md', 'README.zh-CN.md']) copyFileSync(join(ROOT, 'src/apps/data-migrator', file), join(portable, file)); - copyFileSync(join(ROOT, 'THIRD_PARTY_NOTICES.md'), join(portable, 'THIRD_PARTY_NOTICES.md')); - const result = spawnSync('tar', ['-a', '-cf', destination, '-C', portable, 'openbitfun-data-migrator.exe', 'README.md', 'README.zh-CN.md', 'THIRD_PARTY_NOTICES.md'], { stdio: 'inherit', windowsHide: true }); + copyFileSync(join(target, 'openbitfun-data-tools.exe'), join(portable, 'openbitfun-data-tools.exe')); + for (const file of ['README.md', 'README.zh-CN.md']) copyFileSync(join(root, 'src/apps/data-tools', file), join(portable, file)); + copyFileSync(join(root, 'THIRD_PARTY_NOTICES.md'), join(portable, 'THIRD_PARTY_NOTICES.md')); + const result = spawnSync('tar', ['-a', '-cf', destination, '-C', portable, 'openbitfun-data-tools.exe', 'README.md', 'README.zh-CN.md', 'THIRD_PARTY_NOTICES.md'], { stdio: 'inherit', windowsHide: true }); if (result.error) throw result.error; - if (result.status !== 0) throw new Error('Could not archive the portable migrator'); + if (result.status !== 0) throw new Error('Could not archive the portable Data Tools bundle'); } else { const folder = join(target, 'bundle', metadata.folder); - const files = readdirSync(folder).filter((name) => name.startsWith(`OpenBitFun Data Migrator_${version}_`) && name.endsWith(`.${metadata.extension}`)); - if (files.length !== 1) throw new Error('Expected exactly one migrator package in the bundle output'); + const files = readdirSync(folder).filter((name) => name.startsWith(`OpenBitFun Data Tools_${version}_`) && name.endsWith(`.${metadata.extension}`)); + if (files.length !== 1) throw new Error('Expected exactly one Data Tools package in the bundle output'); copyFileSync(join(folder, files[0]), destination); } const sha256 = createHash('sha256').update(readFileSync(destination)).digest('hex'); writeFileSync(`${destination}.sha256`, `${sha256} ${asset}\n`); - console.log(destination); + return destination; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { @@ -60,5 +60,5 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur const version = releaseVersion(); if (process.env.GITHUB_REF_TYPE === 'tag') validateReleaseTag(process.env.GITHUB_REF_NAME, version); console.log(version); - } else stage(process.argv[2]); + } else console.log(stage(process.argv[2])); } diff --git a/scripts/data-migrator-report.test.mjs b/scripts/data-tools-report.test.mjs similarity index 95% rename from scripts/data-migrator-report.test.mjs rename to scripts/data-tools-report.test.mjs index 7eb56deab0..edcb83201e 100644 --- a/scripts/data-migrator-report.test.mjs +++ b/scripts/data-tools-report.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { reportRows } from '../src/apps/data-migrator/ui/report.mjs'; +import { reportRows } from '../src/apps/data-tools/ui/report.mjs'; const text = { sessions: 'Sessions', workspaces: 'Workspaces', assistantDirectories: 'Assistants', imported: 'imported', staged: 'staged', skipped: 'skipped', itemCountsUnavailable: 'unavailable' }; const report = { domainResults: [{ domain: 'workspace_sessions', state: 'verified', imported: 175, skipped: 173, warnings: [{ severity: 'info', code: 'session_path_not_migrated' }] }] }; diff --git a/scripts/data-migrator-tauri-build.mjs b/scripts/data-tools-tauri-build.mjs similarity index 71% rename from scripts/data-migrator-tauri-build.mjs rename to scripts/data-tools-tauri-build.mjs index 9c2a7a4cff..ff75a273db 100644 --- a/scripts/data-migrator-tauri-build.mjs +++ b/scripts/data-tools-tauri-build.mjs @@ -1,23 +1,23 @@ #!/usr/bin/env node -/** Builds the standalone, non-updating Data Migrator Tauri bundle. */ +/** Builds the standalone, non-updating OpenBitFun Data Tools bundle. */ import { spawnSync } from 'node:child_process'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generateDataMigratorTheme } from './generate-data-migrator-theme.mjs'; +import { generateDataToolsTheme } from './generate-data-tools-theme.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const APP_DIR = join(ROOT, 'src', 'apps', 'data-migrator'); +const APP_DIR = join(ROOT, 'src', 'apps', 'data-tools'); -export function prepareDataMigratorTauriConfig( +export function prepareDataToolsTauriConfig( baseConfigPath, outputDirectory = join(APP_DIR, 'gen'), ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); const manifest = readFileSync(join(APP_DIR, 'Cargo.toml'), 'utf8'); const version = manifest.match(/^version = "([^"]+)"/m)?.[1]; - if (!version || config.version !== version) throw new Error('Migrator Cargo and Tauri versions must match.'); + if (!version || config.version !== version) throw new Error('Data Tools Cargo and Tauri versions must match.'); config.build = { frontendDist: config.build?.frontendDist || 'ui', }; @@ -38,17 +38,17 @@ export function prepareDataMigratorTauriConfig( // The tool identity and the format destination are separate. Never inherit a // branded Desktop build's data namespace or sibling-executable projections. -export function dataMigratorEnvironment(environment = process.env) { +export function dataToolsEnvironment(environment = process.env) { const result = { ...environment }; - for (const key of ['OPENBITFUN_DESKTOP_BINARY_NAME', 'OPENBITFUN_DATA_MIGRATOR_BINARY_NAME']) delete result[key]; + delete result.OPENBITFUN_DESKTOP_BINARY_NAME; return { ...result, CI: 'true', OPENBITFUN_PRODUCT_ID: 'openbitfun', OPENBITFUN_DATA_NAMESPACE: 'openbitfun', OPENBITFUN_HIDDEN_DATA_DIRECTORY: '.openbitfun', - OPENBITFUN_PRODUCT_BINARY_NAME: 'openbitfun-data-migrator', - OPENBITFUN_PRODUCT_DISPLAY_NAME: 'OpenBitFun Data Migrator', + OPENBITFUN_PRODUCT_BINARY_NAME: 'openbitfun-data-tools', + OPENBITFUN_PRODUCT_DISPLAY_NAME: 'OpenBitFun Data Tools', }; } @@ -59,19 +59,19 @@ function tauriArguments(raw) { } async function main() { - await generateDataMigratorTheme(); + await generateDataToolsTheme(); const forwardArgs = tauriArguments(process.argv.slice(2)); if (forwardArgs.some((arg) => arg.startsWith('--product-config'))) { - throw new Error('The standalone migrator has its own identity and supports OpenBitFun data only.'); + throw new Error('The standalone Data Tools bundle has its own identity and supports OpenBitFun data only.'); } - const generated = prepareDataMigratorTauriConfig( + const generated = prepareDataToolsTauriConfig( join(APP_DIR, 'tauri.conf.json'), ); const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); const result = spawnSync(tauriBin, ['build', '--config', generated, ...forwardArgs], { cwd: APP_DIR, - env: dataMigratorEnvironment(), + env: dataToolsEnvironment(), stdio: 'inherit', shell: true, windowsHide: true, diff --git a/scripts/data-tools-tauri-build.test.mjs b/scripts/data-tools-tauri-build.test.mjs new file mode 100644 index 0000000000..f38396218e --- /dev/null +++ b/scripts/data-tools-tauri-build.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { prepareDataToolsTauriConfig, dataToolsEnvironment } from './data-tools-tauri-build.mjs'; +import { releaseVersion, validateReleaseTag, stage, PLATFORMS } from './data-tools-release.mjs'; +import { prepareTauriConfig } from './desktop-tauri-build.mjs'; +const ROOT = resolve(import.meta.dirname, '..'); +const APP = join(ROOT, 'src/apps/data-tools'); +const TEMP_ROOT = tmpdir(); + +function fixtureDirectory(t) { + mkdirSync(TEMP_ROOT, { recursive: true }); + const directory = mkdtempSync(join(TEMP_ROOT, 'data-tools-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +test('independent bundle owns version, offline assets, icons and identity', (t) => { + const output = prepareDataToolsTauriConfig(join(APP, 'tauri.conf.json'), fixtureDirectory(t)); + const config = JSON.parse(readFileSync(output, 'utf8')); + assert.equal(config.productName, 'OpenBitFun Data Tools'); + assert.equal(config.identifier, 'com.openbitfun.data-tools'); + assert.deepEqual(config.app.windows.map(({ label }) => label), ['data-tools']); + const capability = JSON.parse(readFileSync(join(APP, 'capabilities/data-tools.json'), 'utf8')); + assert.equal(capability.identifier, 'data-tools'); + assert.deepEqual(capability.windows, ['data-tools']); + assert.equal(config.mainBinaryName, 'openbitfun-data-tools'); + assert.equal(config.version, releaseVersion()); + assert.deepEqual(config.build, { frontendDist: 'ui' }); + assert.equal(config.plugins?.updater, undefined); + assert.equal(config.bundle.externalBin, undefined); + assert.ok(config.bundle.icon.every((icon) => !icon.includes('desktop') && existsSync(join(APP, icon)))); + assert.ok(config.app.security.csp.includes("connect-src 'self'")); +}); + +test('a Desktop product environment cannot change the migration destination identity', () => { + const env = dataToolsEnvironment({ OPENBITFUN_PRODUCT_ID: 'acme', OPENBITFUN_DATA_NAMESPACE: 'acme', OPENBITFUN_DESKTOP_BINARY_NAME: 'acme', CI: '1' }); + assert.equal(env.OPENBITFUN_PRODUCT_ID, 'openbitfun'); + assert.equal(env.OPENBITFUN_DATA_NAMESPACE, 'openbitfun'); + assert.equal(env.OPENBITFUN_DESKTOP_BINARY_NAME, undefined); + assert.equal(env.OPENBITFUN_PRODUCT_BINARY_NAME, 'openbitfun-data-tools'); + assert.equal(env.OPENBITFUN_PRODUCT_DISPLAY_NAME, 'OpenBitFun Data Tools'); + assert.equal(env.CI, 'true'); +}); + +test('Desktop package generation has no Data Tools payload or build hook', () => { + const desktop = join(ROOT, 'src/apps/desktop'); + const output = prepareTauriConfig(join(desktop, 'tauri.conf.json'), { desktopDir: desktop }); + const config = JSON.parse(readFileSync(output, 'utf8')); + assert.ok(!(config.bundle.externalBin || []).some((file) => /data-migrator|data-tools/.test(file))); + for (const file of ['scripts/dev.cjs', 'scripts/desktop-tauri-build.mjs', 'src/apps/desktop/src/lib.rs']) { + assert.doesNotMatch(readFileSync(join(ROOT, file), 'utf8'), /data-migrator|data-tools|legacy_migration_api/); + } +}); + +test('Data Tools dependency and command closure exclude the main app and restart handshake', () => { + const manifest = readFileSync(join(APP, 'Cargo.toml'), 'utf8'); + assert.doesNotMatch(manifest, /^openbitfun-core\s*=|product-full|product-capabilities|plugin-runtime/m); + const source = readFileSync(join(APP, 'src/app_state.rs'), 'utf8'); + assert.doesNotMatch(source, /HandoffStore|MigrationOnboardingStore|restart_desktop|TrustedInstallationResolver/); + const capability = readFileSync(join(APP, 'capabilities/data-tools.json'), 'utf8'); + assert.doesNotMatch(capability, /fs:|shell:|updater:|dialog:/); +}); + +test('independent release tags cannot be confused with main app versions', () => { + const version = releaseVersion(); + assert.doesNotThrow(() => validateReleaseTag('data-tools-v' + version, version)); + assert.doesNotThrow(() => validateReleaseTag('data-migrator-v' + version, version)); + assert.throws(() => validateReleaseTag('v' + version, version)); + assert.throws(() => validateReleaseTag('data-tools-v9.0.0', version)); +}); + +test('all static UI labels and locale keys have complete translations', () => { + const html = readFileSync(join(APP, 'ui/index.html'), 'utf8'); + const locales = JSON.parse(readFileSync(join(APP, 'ui/locales.json'), 'utf8')); + const keys = Object.keys(locales.en).sort(); + for (const labels of Object.values(locales)) { + assert.deepEqual(Object.keys(labels).sort(), keys); + for (const match of html.matchAll(/data-i18n(?:-aria)?="([^"]+)"/g)) assert.ok(labels[match[1]], match[1]); + } +}); + +for (const [platform, metadata] of Object.entries(PLATFORMS)) { + test(`release staging uses Data Tools names and checksums on ${platform}`, { + skip: platform === 'windows-x64' && process.platform !== 'win32', + }, (t) => { + const root = fixtureDirectory(t); + const app = join(root, 'src/apps/data-tools'); + mkdirSync(app, { recursive: true }); + const version = releaseVersion(); + writeFileSync(join(app, 'Cargo.toml'), `version = "${version}"\n`); + writeFileSync(join(app, 'tauri.conf.json'), JSON.stringify({ version })); + const target = join(root, 'custom-target', metadata.target, 'release'); + if (platform === 'windows-x64') { + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, 'openbitfun-data-tools.exe'), 'fixture executable'); + for (const file of ['README.md', 'README.zh-CN.md']) writeFileSync(join(app, file), 'fixture guide'); + writeFileSync(join(root, 'THIRD_PARTY_NOTICES.md'), 'fixture notices'); + } else { + const bundle = join(target, 'bundle', metadata.folder); + mkdirSync(bundle, { recursive: true }); + writeFileSync(join(bundle, `OpenBitFun Data Tools_${version}_arch.${metadata.extension}`), 'fixture package'); + writeFileSync(join(bundle, `Other App_${version}_arch.${metadata.extension}`), 'unrelated package'); + } + const output = stage(platform, { root, environment: { + CARGO_TARGET_DIR: 'custom-target', GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: `data-tools-v${version}`, + } }); + const asset = `openbitfun-data-tools-v${version}-${platform}.${metadata.extension}`; + assert.equal(output, join(root, 'target/data-tools-release', platform, asset)); + const hash = createHash('sha256').update(readFileSync(output)).digest('hex'); + assert.equal(readFileSync(`${output}.sha256`, 'utf8'), `${hash} ${asset}\n`); + if (platform === 'windows-x64') { + const listing = spawnSync('tar', ['-tf', output], { encoding: 'utf8', windowsHide: true }); + assert.equal(listing.status, 0, listing.stderr); + assert.deepEqual(listing.stdout.trim().split(/\r?\n/).sort(), [ + 'openbitfun-data-tools.exe', 'README.md', 'README.zh-CN.md', 'THIRD_PARTY_NOTICES.md', + ].sort()); + } else assert.equal(readFileSync(output, 'utf8'), 'fixture package'); + }); +} diff --git a/scripts/frontend-color-surface-registry.json b/scripts/frontend-color-surface-registry.json index 6639e2f703..b5f2cc5cb1 100644 --- a/scripts/frontend-color-surface-registry.json +++ b/scripts/frontend-color-surface-registry.json @@ -150,11 +150,11 @@ } }, { - "id": "data-migrator", - "label": "Offline Data Migrator", + "id": "data-tools", + "label": "Offline Data Tools", "kind": "canonical-web", "owner": "@openbitfun/design-tokens and @openbitfun/theme-openbitfun public CSS exports", - "root": "src/apps/data-migrator/ui", + "root": "src/apps/data-tools/ui", "audit": { "engine": "theme", "policy": "canonical-ui-zero", @@ -416,9 +416,9 @@ "command": ["node", "scripts/check-subagent-avatar-assets.mjs"] }, { - "id": "data-migrator-design-system", - "surfaceIds": ["data-migrator"], - "command": ["node", "scripts/generate-data-migrator-theme.mjs", "--check"] + "id": "data-tools-design-system", + "surfaceIds": ["data-tools"], + "command": ["node", "scripts/generate-data-tools-theme.mjs", "--check"] }, { "id": "desktop-appearance-projection", @@ -536,10 +536,10 @@ "reason": "Preview data is generated from the native mobile token contract and checked for drift." }, { - "id": "data-migrator-generated", - "path": "src/apps/data-migrator/ui/generated", + "id": "data-tools-generated", + "path": "src/apps/data-tools/ui/generated", "kind": "generated-output", - "owner": "scripts/generate-data-migrator-theme.mjs", + "owner": "scripts/generate-data-tools-theme.mjs", "reason": "Offline CSS is bundled from public design-system exports and checked for drift." }, { diff --git a/scripts/generate-data-migrator-theme.mjs b/scripts/generate-data-tools-theme.mjs similarity index 74% rename from scripts/generate-data-migrator-theme.mjs rename to scripts/generate-data-tools-theme.mjs index 1631e45c24..22099a990c 100644 --- a/scripts/generate-data-migrator-theme.mjs +++ b/scripts/generate-data-tools-theme.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node -/** Bundle the public design-system CSS for the offline, static migrator UI. */ +/** Bundle the public design-system CSS for the offline, static Data Tools UI. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(import.meta.dirname, '..'); -const OUTPUT = join(ROOT, 'src/apps/data-migrator/ui/generated/design-system.css'); +const OUTPUT = join(ROOT, 'src/apps/data-tools/ui/generated/design-system.css'); const themeRequire = createRequire(join(ROOT, 'design-system/packages/theme-openbitfun/package.json')); function bundleStylesheet(file, ancestors = new Set()) { @@ -19,29 +19,29 @@ function bundleStylesheet(file, ancestors = new Set()) { ); } -export async function generateDataMigratorTheme({ check = false } = {}) { +export async function generateDataToolsTheme({ check = false } = {}) { // Build the owning packages before reading their exports; an older dist must // never make a source change appear current in the generated-output check. await import('../design-system/packages/design-tokens/scripts/build.mjs'); await import('../design-system/packages/theme-openbitfun/scripts/build.mjs'); const css = bundleStylesheet(themeRequire.resolve('@openbitfun/theme-openbitfun/default.css')); if (/@import\b|url\(/i.test(css)) { - throw new Error('Data Migrator design-system CSS must have no external asset dependencies.'); + throw new Error('Data Tools design-system CSS must have no external asset dependencies.'); } - const content = '/* Generated by scripts/generate-data-migrator-theme.mjs. Do not edit. */\n' + const content = '/* Generated by scripts/generate-data-tools-theme.mjs. Do not edit. */\n' + `${css.trimEnd()}\n`; const current = existsSync(OUTPUT) ? readFileSync(OUTPUT, 'utf8').replace(/\r\n?/g, '\n') : null; if (current === content) return; if (check) { - throw new Error('Data Migrator theme is stale. Run `pnpm run data-migrator:theme:generate`.'); + throw new Error('Data Tools theme is stale. Run `pnpm run data-tools:theme:generate`.'); } mkdirSync(dirname(OUTPUT), { recursive: true }); writeFileSync(OUTPUT, content, 'utf8'); - console.log('[data-migrator] Generated offline design-system CSS'); + console.log('[data-tools] Generated offline design-system CSS'); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - generateDataMigratorTheme({ check: process.argv.includes('--check') }).catch((error) => { + generateDataToolsTheme({ check: process.argv.includes('--check') }).catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index fdc1bebc25..66ec728e93 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -29,6 +29,10 @@ const retiredIdentityDataBoundaryFiles = new Set([ 'OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md', 'deploy/openbitfun-host/README.md', 'deploy/openbitfun-host/migrate-market-data-v1.py', + // Offline recovery must recognize the pre-rename assistant data location. + 'scripts/recovery/repair-session-storage.py', + 'scripts/recovery/repair-session-storage.test.py', + 'scripts/recovery/repair-session-storage.md', 'src/apps/relay-server/README.md', // HarmonyOS must keep its published bundle id and encrypted-storage names // for in-place upgrades. Runtime identifiers are centralized in one source; @@ -38,10 +42,11 @@ const retiredIdentityDataBoundaryFiles = new Set([ 'src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json', ]); const retiredIdentityDataBoundaryPrefixes = Object.freeze([ - 'src/apps/data-migrator/', + 'src/apps/data-tools/', 'src/crates/assembly/core/src/legacy_migration/', 'src/crates/services/legacy-migration/', 'src/crates/services/legacy-migration-adapters/', + 'src/crates/services/services-core/src/session_recovery/', // Pinned legacy updater feeds reproduce the historical 0.2.x manifests // (download URLs and file names included) so old clients keep resolving // their last real release; they must not be rewritten to the new identity. diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index 2f17941ca0..3a698f65e3 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -174,10 +174,17 @@ test('limits retired identity data to the one-time production migration boundary assert.deepEqual( violationsFor( `const sourceLabel = "${retiredName}";`, - 'src/apps/data-migrator/ui/app.js', + 'src/apps/data-tools/ui/app.js', ), [], ); + for (const filename of [ + 'scripts/recovery/repair-session-storage.py', + 'src/crates/services/services-core/src/session_recovery/identity.rs', + ]) { + assert.deepEqual(violationsFor(`const oldDataRoot = ".${retiredLowerName}";`, filename), []); + } + assert.equal(violationsFor(`const label = "${retiredName}";`, 'scripts/recovery/other.py').length, 1); assert.deepEqual( violationsFor( `{"url": "https://github.com/example/${retiredLowerName}/releases/download/v0.2.19/${retiredLowerName}_0.2.19_windows-x86_64-setup.exe"}`, diff --git a/scripts/recovery/AGENTS.md b/scripts/recovery/AGENTS.md new file mode 100644 index 0000000000..a7d83d0647 --- /dev/null +++ b/scripts/recovery/AGENTS.md @@ -0,0 +1,15 @@ +# Recovery utilities + +Keep distributable recovery scripts standalone and standard-library-only. +Default to read-only planning; require explicit apply/rollback flags for data +mutations. Never test repairs against a developer's real session data. + +For session-storage repair changes, use the focused fixture suite: + +```shell +python scripts/recovery/repair-session-storage.test.py +``` + +Run from the repository root. Fixtures use the configured system temporary +directory; set `TEMP`/`TMP` externally when needed. Broad Rust, UI, and repository +suites are not required for this utility unless their contracts also change. diff --git a/scripts/recovery/repair-session-storage.md b/scripts/recovery/repair-session-storage.md new file mode 100644 index 0000000000..9f90af5277 --- /dev/null +++ b/scripts/recovery/repair-session-storage.md @@ -0,0 +1,198 @@ +# Offline session migration repair + +`repair-session-storage.py` repairs session anomalies left by the BitFun to +OpenBitFun migration and subsequent automatic runtime-directory migration. It +repairs local and remote-workspace sessions stored on this host, plus proven stale +assistant workspace IDs and paths. It is +standalone Python 3.8+ and uses only the standard library. Send this file to an +affected user; Node, Rust, OpenBitFun source code, and additional Python packages +are not required. + +The script scans `projects/*/sessions` and old `remote_ssh` session directories. +It compares session metadata, session state, and the workspace registry, then +calculates the current hashed runtime directory. Normal local workspaces, +assistant workspaces, registered remote workspaces, and their internal child +sessions are eligible. Metadata-only legacy sessions can be resolved by a unique +registered workspace path (plus SSH host for remote workspaces). + +For named assistant workspaces under `.openbitfun/personal_assistant`, it derives +the corresponding `.bitfun` path and old stable ID. Only this exact proven mapping +can rewrite an unknown ID. Existing old/current IDs or inconsistent paths are +never guessed over. Both metadata and state references are repaired where present, +including old assistant paths and execution-target roots. A session already in its +correct directory can receive an identity-only repair. Unknown fields and all +non-identity session data remain intact. + +## Windows usage + +Run on the machine that owns the data. Use `python --version` to check Python is +available; `python` below can be replaced with the full path to `python.exe`. +The Python launcher (`py -3`) is optional. + +Save the script in a convenient folder and open PowerShell there. Create a +read-only plan with a new output filename: + +```powershell +python .\repair-session-storage.py --plan "$env:USERPROFILE\Desktop\session-repair-plan.json" +``` + +Planning, applying, and rolling back display progress automatically on stderr. +Updates show the current stage, completed item count (with a total when known), +current path, and elapsed time. During long operations such as copying a session +backup, the latest status refreshes every two seconds; this indicates that the +process is still running, not a byte-level copy percentage. Output is flushed +immediately, including when redirected to a log. Result summaries stay on stdout. + +Inspect the plan's `operations` and `diagnostics`. Each repair records the session +name, source, target, workspace ID/path, lightweight directory fingerprints, required workspace +snapshot files, and `identity_changes` showing every old/new identity field. +`file_updates` stores original and repaired JSON bytes encoded as base64 for exact +verification and rollback. Skipped sessions have explicit reasons. Planning writes +only the requested plan, outside the data roots. It does not repair anything. +Planning while OpenBitFun is running is allowed, but changes made afterward can +make the plan stale; regenerate it after closing the app if applying is refused. +Original and repaired fingerprints share one directory traversal during planning +and plan validation. Planning reuses catalog metadata bytes and parsed documents +for ownership checks and identity updates; index rebuilding also reuses its catalog +metadata instead of reading it again. +Plans generated by older script builds used full-content fingerprints and are not +accepted by this build; generate a new plan before applying. + +Close every OpenBitFun Desktop/CLI process that uses these data roots, including +background hosts receiving remote requests. Keep them stopped until the repair +finishes. Then apply the reviewed plan, using a **new** backup directory: + +```powershell +python .\repair-session-storage.py --apply --plan "$env:USERPROFILE\Desktop\session-repair-plan.json" --backup-dir "$env:USERPROFILE\Desktop\session-repair-backup" --confirm-closed +``` + +Windows also checks for running OpenBitFun/BitFun executable names. This check +cannot identify every renamed/custom host; `--confirm-closed` remains the user's +confirmation that all writers are stopped. After a successful repair, reopen +OpenBitFun and check the previously affected sessions. + +Defaults match standard OpenBitFun storage: + +| Setting | Default | +| --- | --- | +| `--home` | `OPENBITFUN_HOME`, otherwise `~/.openbitfun` | +| `--user-root` | `OPENBITFUN_USER_ROOT`, otherwise the OS config directory's `openbitfun` folder | +| Registry | `/data/workspace_data.json` | + +On Windows the default config directory is `%APPDATA%`; on macOS it is +`~/Library/Application Support`; on Linux it is `$XDG_CONFIG_HOME` or `~/.config`. +Pass `--home` and `--user-root` explicitly when generating a plan for custom +storage or a different product namespace. The registry must have the current +`format_version: 1` and `product_id: "openbitfun"` format. Apply uses the roots +saved in the plan, not the current environment. Registered local workspace +directories must exist so canonical paths can be calculated reliably. + +## What changes + +Before modifying product data, the script copies and verifies each complete +session directory and saves original affected `index.json` and search-cache +files in the backup. It then renames whole session directories and applies only +the identity JSON changes shown in the plan. Turns, context snapshots, attachments, +prompt caches, and unrecognized files move together; unknown JSON fields remain. + +Workspace file snapshots are separate from session context snapshots. The script +copies operation histories for the session and its `snapshot_session_id`, their +referenced before/after snapshots, references in `session-revert.json`, and content +blobs. Baselines are copied only when explicitly referenced by those records; the +script does not scan the workspace baseline directory. Unreferenced baselines +remain at their original location, so a session moved to another runtime may lose +its workspace-wide baseline comparison until a new baseline is created. Session +file-operation history and its referenced snapshots are preserved. +Inline compressed snapshot content is supported. Original shared snapshot files +remain in place. An identical existing +destination asset is reused; a different one blocks that session's move. Missing +referenced metadata/content also blocks the move. + +Remote paths are compared as case-sensitive POSIX paths on every operating system. +Remote runtime keys hash the registered SSH host and remote root; the remote +workspace need not exist locally. Remote file snapshots are copied only from the +verified SSH connection's `snapshots/workspace-identities/` scope. Unscoped +legacy snapshots cannot prove which SSH profile owns them and remain untouched, +matching the product's policy. Missing scoped references or ambiguous profile +identity are reported rather than reassigned to another connection. Empty legacy +remote directory trees do not produce skipped-session diagnostics. + +Affected session indexes are rebuilt from all per-session metadata, retaining +unknown metadata fields and the product's hidden-child rules. Malformed metadata +in an affected directory blocks its repair. The rebuildable +`product-search-v1.sqlite` cache and its WAL/SHM files are backed up and removed +so the product rebuilds search data. Other workspace resources, settings, +memories, and registry records are left in place. + +## Interrupted repair and rollback + +A durable journal records backups, snapshot copies, index writes, and newly +created directories. If an ordinary error interrupts an apply, keep OpenBitFun +closed and repeat the **same apply command with the same plan and backup**. +Already moved directories are verified rather than overwritten. Repeating a +completed repair verifies it and performs no writes to product data. + +If the process was forcibly terminated, `.session-storage-repair.lock` may remain +under the product data root. Confirm that no repair process or OpenBitFun host is +running before manually removing that one lock file. Then repeat the command. +If the backup has no `journal.json`, preparation was interrupted before product +data was modified; retain that incomplete backup and apply using a new backup +directory. Do not edit a plan or journal to bypass validation. + +To undo the repair while all hosts remain closed: + +```powershell +python .\repair-session-storage.py --rollback "$env:USERPROFILE\Desktop\session-repair-backup" --confirm-closed +``` + +Rollback verifies sessions, catalogs, copied snapshots, and affected +indexes/caches before moving anything. It refuses if user data changed after the +repair. It restores session locations, original identity JSON bytes, and original +index/cache bytes, removes +only snapshot copies created by this repair, and removes newly created directories +only when empty. Backups and journals remain available. An interrupted rollback +can be continued with the same rollback command. Keep enough free space for a +complete copy of all planned session directories plus affected indexes/caches +and copied workspace snapshot assets. + +## Explicit limits + +- Unregistered remote identities, linked or isolated worktree identities, + unavailable local workspace paths, conflicting identities, duplicates, and destination + collisions are reported for manual handling. Registered remote sessions stored + on this machine are supported; the script does not contact the remote host. +- This is an offline local-data utility. It does not contact peers, relay services, + mobile clients, bots, or detached workers, and never falls back from remote data + to a controller's local workspace. Run it on the data-owning host for local + local or remote workspaces controlled from another device, with every writer stopped. +- Symlinks, junctions, other reparse points, and special files in session storage + are rejected. No existing destination session or differing snapshot is replaced. +- Filesystem permissions/timestamps are copied on a best-effort basis; relative + paths, file types, sizes, and directory structure are fingerprinted without + reading large session files. This repairs migration + anomalies in sessions; it is not a product migration or a general corrupted-data + recovery tool. + +Do not edit the old default assistant's path to a named assistant by guesswork: +when the corresponding assistant no longer exists in the registry, it remains +unresolved and is reported for manual mapping. + +## Focused verification + +```shell +python scripts/recovery/repair-session-storage.test.py +``` + +Tests use generated temporary data only, including Chinese workspace names, +legacy assistant ID/path rewrites, local/remote directory collisions, hidden +children, connection-scoped snapshot dependencies, +interrupted writes/renames, resume, rollback, stale plans, and link rejection. +They never read or modify the developer's real OpenBitFun sessions. Configure +`TEMP`/`TMP` externally when a particular temporary drive is required. + +## GUI alternative + +[OpenBitFun Data Tools](../../src/apps/data-tools/README.md) provides a native +Session repair GUI for the same migration anomalies without requiring Python. +GUI and script plans/journals use independent formats. Both tools share the +product repair lock and must not run concurrently. diff --git a/scripts/recovery/repair-session-storage.py b/scripts/recovery/repair-session-storage.py new file mode 100644 index 0000000000..f3bc2c521c --- /dev/null +++ b/scripts/recovery/repair-session-storage.py @@ -0,0 +1,1116 @@ +#!/usr/bin/env python3 +"""Offline repair of session identities and storage damaged by OpenBitFun migrations.""" + +import argparse +import base64 +import copy +import csv +import errno +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import threading +import time + + +VERSION = 1 +FINGERPRINT_KIND = "directory-layout-v1" +SAFE_ID = re.compile(r"^[A-Za-z0-9_-]+$") +CACHE_NAMES = ("product-search-v1.sqlite", "product-search-v1.sqlite-wal", + "product-search-v1.sqlite-shm") +PROGRESS = None + + +class ProgressReporter: + """CLI-only progress with periodic updates during blocking filesystem work.""" + def __init__(self, stream=None, interval=2.0): + self.stream = stream if stream is not None else sys.stderr + self.interval = interval + self.started = time.monotonic() + self.last_write = self.started + self.message = "Starting" + self.lock = threading.Lock() + self.stopped = threading.Event() + self.thread = threading.Thread(target=self._run, daemon=True) + + def _write(self): + now = time.monotonic() + try: + print("[Progress {:0.0f}s] {}".format(now - self.started, self.message), + file=self.stream, flush=True) + except (OSError, ValueError): + # Progress output must not interrupt a data repair if its stream closes. + self.stopped.set() + self.last_write = now + + def update(self, message, force=False): + with self.lock: + self.message = " ".join(str(message).split()) + if force or time.monotonic() - self.last_write >= self.interval: + self._write() + + def _run(self): + while not self.stopped.wait(self.interval): + with self.lock: + if time.monotonic() - self.last_write >= self.interval: + self._write() + + def start(self): + self.update(self.message, force=True) + self.thread.start() + + def stop(self): + self.stopped.set() + self.thread.join() + + +def report_progress(message, force=False): + if PROGRESS is not None: + PROGRESS.update(message, force) + + +def progress_items(items, stage, total=None): + """Report completed counts without enumerating filesystem entries twice.""" + if PROGRESS is None: + yield from items + return + if total is None: + try: + total = len(items) + except TypeError: + pass + count = 0 + + def status(detail=""): + counter = "{} / {}".format(count, total) if total is not None else str(count) + return "{}: {} completed{}".format(stage, counter, "; " + detail if detail else "") + + report_progress(status(), force=True) + for item in items: + detail = item.get("source", item.get("path", "")) if isinstance(item, dict) else str(item) + report_progress(status(detail)) + yield item + count += 1 + report_progress(status(), force=True) + + +def encoded(value): + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +def read_json(path): + return json.loads(Path(path).read_bytes().decode("utf-8-sig")) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def linked(path): + info = path.lstat() + return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & 0x400) + + +def safe_path(path, root): + """Reject path escapes and all symlink/junction ancestors, including the root.""" + path, root = Path(os.path.abspath(path)), Path(os.path.abspath(root)) + path.relative_to(root) + for part in (path,) + tuple(path.parents): + if os.path.lexists(part) and linked(part): + raise ValueError("Linked/reparse path is unsupported: {}".format(part)) + return path + + +def file_hash(path): + safe_path(path, Path(path).parent) + if not Path(path).is_file(): + raise ValueError("Not a regular file: {}".format(path)) + hasher = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + hasher.update(block) + return hasher.hexdigest() + + +def optional_hash(path): + return file_hash(path) if os.path.lexists(path) else None + + +def directory_manifest(root, overrides=None): + root = safe_path(root, root) + if not root.is_dir(): + raise ValueError("Not a directory: {}".format(root)) + def fail(error): + raise error + + manifest = [] + for current, dirs, files in os.walk(root, followlinks=False, onerror=fail): + dirs.sort() + for name in sorted(dirs + files): + path = safe_path(Path(current) / name, root) + relative = path.relative_to(root).as_posix() + if path.is_dir(): + manifest.append(["directory", relative]) + else: + size = (len(overrides[relative]) if overrides and relative in overrides + else path.stat().st_size) + manifest.append(["file", relative, size]) + return manifest + + +def directory_fingerprint(root, overrides=None): + """Fingerprint layout and file sizes without reading session content.""" + return digest(encoded(directory_manifest(root, overrides))) + + +def directory_fingerprint_pair(root, before_overrides=None, after_overrides=None): + """Compute before/after fingerprints from one directory enumeration.""" + manifest = directory_manifest(root) + + def with_overrides(overrides): + if not overrides: + return manifest + return [ + ["file", item[1], len(overrides[item[1]])] + if item[0] == "file" and item[1] in overrides else item + for item in manifest + ] + + return (digest(encoded(with_overrides(before_overrides))), + digest(encoded(with_overrides(after_overrides)))) + + +def atomic_write(path, data): + path = safe_path(path, Path(path).parent) + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=".repair-", dir=str(path.parent)) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def normalized(value): + if not isinstance(value, str) or not value: + raise ValueError("Missing or invalid workspace path") + return os.path.normcase(os.path.normpath(value)) + + +def runtime_key(root): + path = Path(root) + if not path.is_absolute() or not path.is_dir(): + raise ValueError("Workspace must be an existing absolute directory: {}".format(root)) + text = os.path.realpath(root) + if text.startswith("\\\\?\\UNC\\"): + text = "\\\\" + text[8:] + elif text.startswith("\\\\?\\"): + text = text[4:] + return digest(b"runtime-local\0" + text.replace("\\", "/").encode("utf-8"))[:24] + + +def remote_path(value): + if not isinstance(value, str) or not value.startswith("/") or "\0" in value: + raise ValueError("Remote workspace path must be absolute POSIX") + value = re.sub(r"/+", "/", value.replace("\\", "/")) + if any(part in (".", "..") for part in value.split("/")): + raise ValueError("Ambiguous remote workspace path") + return value.rstrip("/") or "/" + + +def record_host(record): + host = record.get("metadata", {}).get("sshHost") + if not isinstance(host, str) or not host.strip(): + raise ValueError("Remote registry record has no SSH host") + return host.strip().lower() + + +def record_runtime_key(record): + if record.get("workspaceKind") == "remote": + return digest(b"runtime-remote\0" + record_host(record).encode("utf-8") + b"\0" + + remote_path(record["rootPath"]).encode("utf-8"))[:24] + return runtime_key(record["rootPath"]) + + +def assistant_aliases(home, records): + """Only accept IDs derived from the exact pre-brand-rename assistant path.""" + aliases = {} + if home.name != ".openbitfun": + return aliases + for wid, record in records.items(): + if record.get("workspaceKind") != "assistant": + continue + root = Path(record["rootPath"]) + try: + relative = root.relative_to(home / "personal_assistant") + except ValueError: + continue + old_path = home.with_name(".bitfun") / "personal_assistant" / relative + old_id = "local_" + digest(("localhost\n" + str(old_path).replace("\\", "/")).encode("utf-8"))[:32] + if old_id in records: + continue + if old_id in aliases: + raise ValueError("Ambiguous legacy assistant identity") + aliases[old_id] = {"id": wid, "old_path": str(old_path), "new_path": record["rootPath"]} + return aliases + + +def registry(user_root): + path = safe_path(user_root / "data" / "workspace_data.json", user_root) + data = read_json(path) + if data.get("format_version") != 1 or data.get("product_id") != "openbitfun": + raise ValueError("Unsupported workspace registry format") + records = data.get("workspaces") + if not isinstance(records, dict): + raise ValueError("Workspace registry has no workspaces map") + for key, record in records.items(): + if not isinstance(record, dict) or record.get("id", key) != key: + raise ValueError("Invalid workspace registry record: {}".format(key)) + return records, file_hash(path) + + +def metadata(directory, raw=None): + value = raw if raw is not None else read_json(safe_path(directory / "metadata.json", directory)) + if isinstance(value, dict): + # These are the legacy aliases consumed by the product's SessionMetadata. + fields = ("sessionId", "sessionName", "agentType", "modelName", "createdAt", "lastActiveAt", + "turnCount", "messageCount", "toolCallCount", "workspacePath", "projectWorkspacePath", + "workspaceId", "executionTarget", "workspaceHostname", "sessionKind", "createdBy") + value = value.copy() + for key in fields: + alias = re.sub(r"([A-Z])", lambda m: "_" + m.group(1).lower(), key) + if alias in value: + if key in value and value[key] != value[alias]: + raise ValueError("Conflicting metadata field aliases: {}".format(key)) + value[key] = value.pop(alias) + if not isinstance(value, dict) or value.get("sessionId") != directory.name: + raise ValueError("Metadata session ID does not match directory: {}".format(directory)) + if value.get("schema_version", 2) != 2: + raise ValueError("Unsupported metadata schema: {}".format(directory)) + if not isinstance(value.get("sessionName"), str): + raise ValueError("Missing session name: {}".format(directory)) + for field in ("agentType", "modelName"): + if not isinstance(value.get(field), str): + raise ValueError("Missing/invalid metadata field: {}".format(field)) + for field in ("createdAt", "lastActiveAt", "turnCount", "messageCount", "toolCallCount"): + if type(value.get(field)) is not int or value[field] < 0: + raise ValueError("Missing/invalid metadata field: {}".format(field)) + if value.get("status") not in ("active", "archived", "completed"): + raise ValueError("Missing/invalid metadata status") + if value.get("sessionKind", "standard") not in ("standard", "subagent", "ephemeral_child"): + raise ValueError("Unsupported session kind: {}".format(directory)) + return value + + +def owner(directory, records, aliases=None, documents=None): + documents = copy.deepcopy(documents) if documents is not None else { + "metadata.json": read_json(directory / "metadata.json")} + state_path = safe_path(directory / "state.json", directory) + if "state.json" not in documents and state_path.exists(): + documents["state.json"] = read_json(state_path) + if not isinstance(documents["metadata.json"], dict) or not isinstance(documents.get("state.json", {}), dict): + raise ValueError("Metadata/state must be JSON objects") + if not isinstance(documents.get("state.json", {}).get("config", {}), dict): + raise ValueError("Session config must be a JSON object") + # Normalize proven migrated assistant identities before comparing metadata/state. + for alias_id, mapping in (aliases or {}).items(): + raw_meta = documents["metadata.json"] + config = documents.get("state.json", {}).get("config", {}) + containers = ((raw_meta, ("workspaceId", "workspace_id", "projectWorkspaceId", "project_workspace_id"), + ("workspacePath", "workspace_path", "projectWorkspacePath", "project_workspace_path")), + (config, ("workspace_id", "project_workspace_id"), + ("workspace_path", "project_workspace_path"))) + relevant = any(c.get(k) == alias_id for c, keys, _ in containers for k in keys) + relevant |= any(c.get(k) and normalized(c[k]) == normalized(mapping["old_path"]) + for c, _, keys in containers for k in keys) + if not relevant: + continue + for container, id_keys, path_keys in containers: + if not isinstance(container, dict): + raise ValueError("Invalid session identity container") + for key in id_keys: + if container.get(key) == alias_id: + container[key] = mapping["id"] + for key in path_keys: + if container.get(key) and normalized(container[key]) == normalized(mapping["old_path"]): + container[key] = mapping["new_path"] + for key in ("executionTarget", "execution_target"): + target = container.get(key) + if isinstance(target, dict): + for path_key in ("rootPath", "root_path"): + if target.get(path_key) and normalized(target[path_key]) == normalized(mapping["old_path"]): + target[path_key] = mapping["new_path"] + meta = metadata(directory, documents["metadata.json"]) + state = documents.get("state.json", {}) + if not isinstance(state, dict) or state.get("schema_version", 2) != 2: + raise ValueError("Unsupported session state") + config = state.get("config", {}) + if not isinstance(config, dict): + raise ValueError("Session config is not an object") + snapshot_ids = [meta.get(key) for key in ("snapshotSessionId", "sandboxSessionId", "sandbox_session_id")] + snapshot_ids.append(state.get("snapshot_session_id")) + snapshot_ids = {sid for sid in snapshot_ids if sid is not None} + if len(snapshot_ids) > 1: + raise ValueError("Metadata/state snapshot session IDs disagree") + state = dict(state, snapshot_session_id=next(iter(snapshot_ids), None)) + values = {} + is_remote = (config.get("workspace_kind") == "remote" + or str(meta.get("workspaceId") or "").startswith("remote_") + or str(config.get("workspace_id") or "").startswith("remote_") + or meta.get("workspaceHostname") not in (None, "", "localhost") + or config.get("remote_ssh_host") not in (None, "", "localhost")) + path_normalize = remote_path if is_remote else normalized + fields = ("workspaceId", "projectWorkspaceId", "workspacePath", "projectWorkspacePath", + "workspaceKind", "workspaceHostname", "executionTarget") + state_keys = ("workspace_id", "project_workspace_id", "workspace_path", "project_workspace_path", + "workspace_kind", "remote_ssh_host", "execution_target") + for key, state_key in zip(fields, state_keys): + first, second = meta.get(key), config.get(state_key) + if first is not None and second is not None: + left, right = (path_normalize(first), path_normalize(second)) if key.endswith("Path") else (first, second) + if left != right: + raise ValueError("Metadata/state disagree on {}".format(key)) + values[key] = first if first is not None else second + if values["workspaceKind"] not in (None, "normal", "assistant", "remote"): + raise ValueError("Worktree workspace requires manual repair") + wid = values["workspaceId"] + project_id = values["projectWorkspaceId"] + root = values["workspacePath"] + project_path = values["projectWorkspacePath"] + if project_id and wid and project_id != wid: + raise ValueError("Linked worktree session requires manual repair") + if root and project_path and path_normalize(root) != path_normalize(project_path): + raise ValueError("Execution/project workspace paths differ") + wid, root = project_id or wid, project_path or root + if wid: + if wid not in records: + raise ValueError("Unknown workspace ID: {}".format(wid)) + record = records[wid] + else: + matches = [(key, item) for key, item in records.items() + if root and item.get("rootPath") + and (item.get("workspaceKind") == "remote") == is_remote + and path_normalize(item["rootPath"]) == path_normalize(root) + and (not is_remote or record_host(item) == str(values["workspaceHostname"]).strip().lower())] + if len(matches) != 1: + raise ValueError("Legacy path does not identify exactly one workspace") + wid, record = matches[0] + if record.get("workspaceKind", "normal") not in ("normal", "assistant", "remote"): + raise ValueError("Worktree registry record requires manual repair") + if (record.get("workspaceKind") == "remote") != is_remote: + raise ValueError("Session and registry disagree on local/remote identity") + if is_remote: + if str(values["workspaceHostname"]).strip().lower() != record_host(record): + raise ValueError("Session and registry SSH hosts disagree") + connection = config.get("remote_connection_id") + expected_connection = record.get("metadata", {}).get("connectionId") + canonical = lambda value: re.sub(r"(:[0-9]+)$", "", value) if value and value.startswith("ssh-") and "@" in value else value + if connection and expected_connection and canonical(connection) != canonical(expected_connection): + raise ValueError("Session and registry SSH connections disagree") + state["repair_remote_identity"] = [record_host(record), remote_path(record["rootPath"]), + canonical(connection or expected_connection)] + if root and path_normalize(root) != path_normalize(record["rootPath"]): + raise ValueError("Session identity disagrees with registry path") + target = values["executionTarget"] + if target is not None: + if not isinstance(target, dict) or target.get("kind") != "local": + raise ValueError("Unsupported execution target") + if path_normalize(target.get("rootPath", target.get("root_path"))) != path_normalize(record["rootPath"]): + raise ValueError("Execution target disagrees with registry") + state["repair_documents"] = documents + return wid, record["rootPath"], meta, state + + +def catalog(root, metadata_cache=None): + result = {} + if not root.exists(): + safe_path(root, root) + return result + safe_path(root, root) + for directory in progress_items(sorted(root.iterdir()), "Read catalog {}".format(root)): + safe_path(directory, root) + if directory.is_dir() and (directory / "metadata.json").exists(): + if not SAFE_ID.fullmatch(directory.name): + raise ValueError("Unsupported session directory name: {}".format(directory)) + path = safe_path(directory / "metadata.json", directory) + raw = path.read_bytes() + document = json.loads(raw.decode("utf-8-sig")) + entry = metadata(directory, document) + if metadata_cache is not None: + metadata_cache[directory] = (raw, document, entry) + result[directory.name] = digest(raw) + return result + + +def index_bytes(root): + entries = [] + metadata_cache = {} + files = catalog(root, metadata_cache) + for sid in files: + entry = metadata_cache[root / sid][2].copy() + entry.pop("schema_version", None) + hidden = entry.get("sessionKind") in ("subagent", "ephemeral_child") + hidden |= (str(entry.get("createdBy", "")).startswith("session-") + and entry["sessionName"].startswith("Subagent: ")) + if not hidden: + entries.append(entry) + entries.sort(key=lambda item: item.get("lastActiveAt", 0), reverse=True) + return encoded({"schema_version": 2, "updated_at": int(time.time() * 1000), + "metadata_file_count": len(files), "sessions": entries}) + + +def references(value): + found = set() + if isinstance(value, dict): + for key, child in value.items(): + if key in ("snapshot_id", "snapshotId", "before_snapshot_id", "after_snapshot_id") and child: + if not isinstance(child, str) or not SAFE_ID.fullmatch(child): + raise ValueError("Invalid snapshot reference") + found.add(child) + else: + found.update(references(child)) + elif isinstance(value, list): + for child in value: + found.update(references(child)) + return found + + +def snapshot_assets(source, target, state, snapshot_relative=None, include_revert=True): + """Copy session histories and their explicit snapshot dependencies only.""" + source_runtime, target_runtime = source.parent.parent, target.parent.parent + remote = state.get("repair_remote_identity") + if remote and snapshot_relative is None: + if not remote[2]: + evidence_dir = source if source.exists() else target + revert = evidence_dir / "session-revert.json" + if (source_runtime / "snapshots/workspace-identities").exists() or ( + revert.exists() and references(read_json(revert))): + raise ValueError("Remote snapshot dependencies require an SSH connection ID") + return [] + scope = digest(json.dumps(remote, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + relative = Path("snapshots") / "workspace-identities" / scope + return snapshot_assets(source, target, state, relative, True) + snapshot_root = source_runtime / (snapshot_relative or Path("snapshots")) + assets = {} + + def add(path): + path = safe_path(path, source_runtime) + if not path.is_file(): + raise ValueError("Missing snapshot dependency: {}".format(path)) + destination = safe_path(target_runtime / path.relative_to(source_runtime), target_runtime) + sha, old = file_hash(path), optional_hash(destination) + if old is not None and old != sha: + raise ValueError("Snapshot destination conflict: {}".format(destination)) + assets[str(destination)] = {"source": str(path), "target": str(destination), + "sha256": sha, "existed": old is not None} + return read_json(path) if path.suffix == ".json" else None + + ids = set() + for sid in {source.name, state.get("snapshot_session_id") or source.name}: + if not isinstance(sid, str) or not SAFE_ID.fullmatch(sid): + raise ValueError("Unsupported snapshot session ID") + history = snapshot_root / "operations" / (sid + ".json") + safe_path(history, source_runtime) + if history.exists(): + data = add(history) + if data.get("session_id") != sid: + raise ValueError("Snapshot history session ID mismatch") + ids.update(references(data)) + evidence_dir = source if source.exists() else target + revert = evidence_dir / "session-revert.json" + if include_revert and revert.exists(): + ids.update(references(read_json(safe_path(revert, evidence_dir)))) + for sid in sorted(ids): + category = "baselines" if sid.startswith("baseline_") else "metadata" + data = add(snapshot_root / category / (sid + ".json")) + if data.get("snapshot_id") != sid: + raise ValueError("Snapshot metadata ID mismatch") + content_hash = data.get("content_hash") + if not isinstance(content_hash, str) or not re.fullmatch(r"[0-9a-f]{32}", content_hash): + raise ValueError("Unsupported snapshot content hash") + blob = snapshot_root / "by_hash" / (content_hash + ".snap") + safe_path(blob, source_runtime) + if blob.exists(): + add(blob) + elif not data.get("compressed_content"): + raise ValueError("Missing snapshot content: {}".format(sid)) + return sorted(assets.values(), key=lambda item: item["target"]) + + +def planned_updates(directory, repaired_documents, originals=None): + updates = {} + for filename, value in repaired_documents.items(): + before, original = (originals[filename] if originals is not None else + ((directory / filename).read_bytes(), read_json(directory / filename))) + if original != value: + after = encoded(value) + updates[filename] = {"before": base64.b64encode(before).decode("ascii"), + "after": base64.b64encode(after).decode("ascii")} + return updates + + +def update_bytes(operation, side): + return {name: base64.b64decode(update[side], validate=True) + for name, update in operation.get("file_updates", {}).items()} + + +def identity_changes(directory, repaired_documents, originals=None): + changes = [] + + def compare(filename, path, before, after): + if isinstance(before, dict) and isinstance(after, dict): + for key in sorted(set(before) | set(after)): + compare(filename, path + [key], before.get(key), after.get(key)) + elif before != after: + changes.append({"file": filename, "field": ".".join(path), "before": before, "after": after}) + + for filename, after in repaired_documents.items(): + before = originals[filename][1] if originals is not None else read_json(directory / filename) + compare(filename, [], before, after) + return changes + + +def sessions_roots(home): + projects = safe_path(home / "projects", home) + if projects.exists(): + for runtime in sorted(projects.iterdir()): + yield runtime / "sessions" + remote = safe_path(home / "remote_ssh", home) + if remote.exists(): + def fail(error): + raise error + for current, dirs, _ in os.walk(remote, followlinks=False, onerror=fail): + for name in dirs: + safe_path(Path(current) / name, home) + if Path(current).name == "sessions": + yield Path(current) + dirs[:] = [] + + +def make_plan(home, user_root): + report_progress("Plan: reading workspace registry", force=True) + home, user_root = safe_path(home, home), safe_path(user_root, user_root) + records, registry_sha = registry(user_root) + aliases = assistant_aliases(home, records) + operations, diagnostics, catalogs = [], [], {} + catalog_cache = {} + metadata_cache = {} + + def cached_catalog(root): + key = str(root) + if key not in catalog_cache: + catalog_cache[key] = catalog(root, metadata_cache) + return catalog_cache[key] + + projects = safe_path(home / "projects", home) + if home.exists(): + for sessions in progress_items(sessions_roots(home), "Plan runtime directories"): + runtime = sessions.parent + try: + safe_path(runtime, home) + if not sessions.is_dir(): + continue + # A malformed neighbor must not disappear from a rebuilt index. + catalogs[str(sessions)] = cached_catalog(sessions) + session_dirs = [path for path in sorted(sessions.iterdir()) if path.is_dir()] + for number, source in enumerate(progress_items(session_dirs, "Plan sessions in {}".format(sessions))): + try: + safe_path(source, sessions) + if not (source / "metadata.json").exists(): + raise ValueError("Session directory has no metadata.json") + raw, document, _ = metadata_cache[source] + originals = {"metadata.json": (raw, document)} + state_path = safe_path(source / "state.json", source) + if state_path.exists(): + raw = state_path.read_bytes() + originals["state.json"] = (raw, json.loads(raw.decode("utf-8-sig"))) + documents = {name: item[1] for name, item in originals.items()} + wid, workspace, meta, state = owner(source, records, aliases, documents) + updates = planned_updates(source, state["repair_documents"], originals) + target = safe_path(projects / record_runtime_key(records[wid]) / "sessions" / source.name, home) + if target == source and not updates: + diagnostics.append({"source": str(source), "status": "correct"}) + continue + if target != source and os.path.lexists(target): + raise ValueError("Destination session already exists: {}".format(target)) + catalogs[str(target.parent)] = cached_catalog(target.parent) + report_progress("Plan sessions: {} / {} completed; fingerprint and snapshot references for {}".format( + number, len(session_dirs), source)) + before_fingerprint, repaired_fingerprint = directory_fingerprint_pair( + source, None, update_bytes({"file_updates": updates}, "after")) + operations.append({"source": str(source), "target": str(target), + "session_id": source.name, "session_name": meta["sessionName"], + "workspace_id": wid, "workspace_path": workspace, + "sha256": before_fingerprint, "file_updates": updates, + "identity_changes": identity_changes(source, state["repair_documents"], originals), + "repaired_sha256": repaired_fingerprint, + "assets": snapshot_assets(source, target, state)}) + except (OSError, ValueError, TypeError, KeyError) as error: + diagnostics.append({"source": str(source), "status": "skipped", "reason": str(error)}) + except (OSError, ValueError, TypeError, KeyError) as error: + diagnostics.append({"source": str(runtime), "status": "skipped", "reason": str(error)}) + report_progress("Plan: checking destination and snapshot conflicts", force=True) + duplicates = {} + asset_targets = {} + for operation in operations: + duplicates.setdefault(operation["target"], []).append(operation) + for asset in operation["assets"]: + asset_targets.setdefault(asset["target"], set()).add(asset["sha256"]) + accepted = [] + for operation in operations: + reason = None + if len(duplicates[operation["target"]]) > 1: + reason = "Multiple source sessions claim the same destination" + elif any(len(asset_targets[a["target"]]) > 1 for a in operation["assets"]): + reason = "Conflicting shared snapshot dependencies in this plan" + if reason: + diagnostics.append({"source": operation["source"], "status": "skipped", "reason": reason}) + else: + accepted.append(operation) + affected = {str(Path(o[key]).parent) for o in accepted for key in ("source", "target")} + return {"version": VERSION, "fingerprint_kind": FINGERPRINT_KIND, + "home": str(home), "user_root": str(user_root), + "registry_sha256": registry_sha, "operations": accepted, "diagnostics": diagnostics, + "catalogs": {key: catalogs[key] for key in sorted(affected)}} + + +def validate_plan(plan): + report_progress("Validate: reading plan and workspace registry", force=True) + if plan.get("version") != VERSION: + raise ValueError("Unsupported repair plan version; generate a new plan with this script") + if plan.get("fingerprint_kind") != FINGERPRINT_KIND: + raise ValueError("This plan uses legacy full-content fingerprints; generate a new plan") + home, user_root = Path(plan["home"]), Path(plan["user_root"]) + if not home.is_absolute() or not user_root.is_absolute(): + raise ValueError("Plan roots must be absolute") + safe_path(home, home) + records, sha = registry(user_root) + aliases = assistant_aliases(home, records) + if sha != plan["registry_sha256"]: + raise ValueError("Workspace registry changed; generate a new plan") + targets, sources, affected = set(), set(), set() + for operation in progress_items(plan["operations"], "Validate sessions and snapshot dependencies"): + source, target = Path(operation["source"]), Path(operation["target"]) + safe_path(source, home) + safe_path(target, home / "projects") + sid = operation["session_id"] + if not SAFE_ID.fullmatch(sid) or source.name != sid or source.parent.name != "sessions": + raise ValueError("Invalid source session path") + if source.parent.parent.parent != home / "projects" and home / "remote_ssh" not in source.parents: + raise ValueError("Invalid source runtime root") + record = records[operation["workspace_id"]] + if record["rootPath"] != operation["workspace_path"]: + raise ValueError("Plan workspace disagrees with registry") + expected = home / "projects" / record_runtime_key(record) / "sessions" / sid + if target != expected or str(target) in targets or str(source) in sources: + raise ValueError("Invalid or duplicate destination") + targets.add(str(target)) + sources.add(str(source)) + affected.update((str(source.parent), str(target.parent))) + evidence_dir = source if source.exists() else target + documents = {"metadata.json": read_json(evidence_dir / "metadata.json")} + if (evidence_dir / "state.json").exists(): + documents["state.json"] = read_json(evidence_dir / "state.json") + for name, before in update_bytes(operation, "before").items(): + if name not in ("metadata.json", "state.json") or name not in documents: + raise ValueError("Invalid identity update path") + documents[name] = json.loads(before.decode("utf-8-sig")) + wid, root, _, state = owner(evidence_dir, records, aliases, documents) + if wid != operation["workspace_id"] or root != operation["workspace_path"]: + raise ValueError("Plan disagrees with session identity") + before = update_bytes(operation, "before") + after = update_bytes(operation, "after") + for name, document in documents.items(): + repaired = state["repair_documents"][name] + if name in after: + if document == repaired or after[name] != encoded(repaired): + raise ValueError("Invalid assistant identity rewrite") + elif document != repaired: + raise ValueError("Missing assistant identity rewrite") + if source == target and not after: + raise ValueError("Plan has no repair action") + before_fingerprint, repaired_fingerprint = directory_fingerprint_pair( + evidence_dir, before, after) + if before_fingerprint != operation["sha256"]: + raise ValueError("Session changed since planning") + if repaired_fingerprint != operation["repaired_sha256"]: + raise ValueError("Invalid repaired session fingerprint") + actual_assets = snapshot_assets(source, target, state) + # Existing identical copies are expected during resume. + comparable = lambda items: [{k: v for k, v in a.items() if k != "existed"} for a in items] + if comparable(actual_assets) != comparable(operation["assets"]): + raise ValueError("Snapshot dependencies changed since planning") + if affected != set(plan["catalogs"]): + raise ValueError("Invalid affected catalogs") + return home + + +def expected_catalogs(plan): + result = {key: dict(value) for key, value in plan["catalogs"].items()} + for operation in plan["operations"]: + source, target = Path(operation["source"]), Path(operation["target"]) + sha = result[str(source.parent)].pop(source.name) + if "metadata.json" in operation.get("file_updates", {}): + sha = digest(update_bytes(operation, "after")["metadata.json"]) + result[str(target.parent)][target.name] = sha + return result + + +def mutation_directories(plan): + projects = Path(plan["home"]) / "projects" + directories = set() + paths = [Path(o["target"]).parent for o in plan["operations"]] + paths.extend(Path(a["target"]).parent for o in plan["operations"] for a in o["assets"]) + for path in paths: + while path != projects: + safe_path(path, projects) + directories.add(str(path)) + path = path.parent + return directories + + +def check_sessions(plan, resume=False): + expected = {key: dict(value) for key, value in plan["catalogs"].items()} + for operation in progress_items(plan["operations"], "Check session locations and identities"): + source, target = Path(operation["source"]), Path(operation["target"]) + if source == target and source.exists(): + location = source + elif source.exists() and not target.exists(): + location = source + elif resume and target.exists() and not source.exists(): + location = target + sha = expected[str(source.parent)].pop(source.name) + expected[str(target.parent)][target.name] = sha + else: + raise ValueError("Session source/destination conflict: {}".format(source)) + before, after = update_bytes(operation, "before"), update_bytes(operation, "after") + for name in before: + raw = (location / name).read_bytes() + if raw != before[name] and (not resume or raw != after[name]): + raise ValueError("Session identity file changed: {}".format(location / name)) + if directory_fingerprint(location, before) != operation["sha256"]: + raise ValueError("Session changed since planning: {}".format(location)) + if "metadata.json" in before: + expected[str(location.parent)][location.name] = file_hash(location / "metadata.json") + for root, value in expected.items(): + if catalog(Path(root)) != value: + raise ValueError("Session catalog changed since planning: {}".format(root)) + + +def check_closed(confirmed): + if not confirmed: + raise ValueError("Close all OpenBitFun Desktop/CLI hosts, then pass --confirm-closed") + if os.name == "nt": + result = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, + creationflags=subprocess.CREATE_NO_WINDOW, check=True) + for row in csv.reader(result.stdout.decode(errors="replace").splitlines()): + if row and re.fullmatch(r"(?:openbitfun|bitfun)(?:[-_].*)?\.exe", row[0], re.I): + raise ValueError("OpenBitFun process is still running: {}".format(row[0])) + + +class RepairLock: + def __init__(self, home): + self.path = safe_path(home / ".session-storage-repair.lock", home) + + def __enter__(self): + with self.path.open("x", encoding="utf-8") as stream: + stream.write(str(os.getpid())) + return self + + def __exit__(self, *_): + self.path.unlink() + + +def outside_data(path, plan): + path = safe_path(path, path) + for root in (Path(plan["home"]), Path(plan["user_root"])): + # Disallow either containment direction (backup must not contain data either). + if path == root or root in path.parents or path in root.parents: + raise ValueError("Plan/backup path must be outside product data: {}".format(path)) + return path + + +def save_journal(backup, journal): + atomic_write(backup / "journal.json", encoded(journal)) + + +def prepare_backup(plan, backup): + backup.mkdir(parents=True, exist_ok=False) + atomic_write(backup / "plan.json", encoded(plan)) + # A prepared journal only appears after every full-session backup is verified. + for index, operation in enumerate(progress_items(plan["operations"], "Back up sessions")): + destination = backup / "sessions" / str(index) + destination.parent.mkdir(exist_ok=True) + shutil.copytree(operation["source"], destination) + if directory_fingerprint(destination) != operation["sha256"]: + raise ValueError("Backup verification failed") + controls = [] + for root in progress_items(plan["catalogs"], "Back up indexes and search caches"): + for name in ("index.json",) + CACHE_NAMES: + path = safe_path(Path(root) / name, Path(plan["home"])) + old = optional_hash(path) + entry = {"path": str(path), "old": old, "new": old, + "backup": "controls/{}.bin".format(len(controls)), "pending": None} + if old is not None: + atomic_write(backup / entry["backup"], path.read_bytes()) + if file_hash(backup / entry["backup"]) != old: + raise ValueError("Control-file backup verification failed") + controls.append(entry) + journal = {"version": VERSION, "plan_sha256": digest(encoded(plan)), "phase": "prepared", + "controls": controls, "asset_writes": [], + "created_directories": sorted(path for path in mutation_directories(plan) + if not Path(path).exists())} + save_journal(backup, journal) + return journal + + +def load_backup(backup, plan): + report_progress("Resume: reading backup journal", force=True) + journal = read_json(safe_path(backup / "journal.json", backup)) + if journal.get("version") != VERSION or journal["plan_sha256"] != digest(encoded(plan)): + raise ValueError("Backup belongs to another plan") + if journal["phase"] not in ("prepared", "indexing", "completed", "rolling_back", "rolled_back"): + raise ValueError("Unsupported backup journal phase") + if not set(journal["created_directories"]) <= mutation_directories(plan): + raise ValueError("Invalid created-directory journal") + for index, operation in enumerate(progress_items(plan["operations"], "Verify session backups")): + if directory_fingerprint(safe_path(backup / "sessions" / str(index), backup)) != operation["sha256"]: + raise ValueError("Session backup changed or is incomplete") + expected_paths = {str(Path(root) / name) for root in plan["catalogs"] + for name in ("index.json",) + CACHE_NAMES} + if {entry["path"] for entry in journal["controls"]} != expected_paths: + raise ValueError("Invalid backup control paths") + for entry in journal["controls"]: + if entry["old"] is not None: + if file_hash(safe_path(backup / entry["backup"], backup)) != entry["old"]: + raise ValueError("Control-file backup changed") + if entry["pending"] is not None: + if file_hash(safe_path(backup / entry["pending"], backup)) != entry["new"]: + raise ValueError("Pending index changed") + assets = {a["target"] for o in plan["operations"] for a in o["assets"] if not a["existed"]} + if not set(journal["asset_writes"]) <= assets: + raise ValueError("Invalid asset journal") + return journal + + +def check_controls(journal, home): + for entry in journal["controls"]: + sha = optional_hash(safe_path(entry["path"], home)) + if sha not in (entry["old"], entry["new"]): + raise ValueError("Index/search cache changed after repair: {}".format(entry["path"])) + + +def apply_plan(plan, backup): + home = validate_plan(plan) + backup = outside_data(backup, plan) + with RepairLock(home): + resume = backup.exists() + if resume: + journal = load_backup(backup, plan) + if journal["phase"] in ("rolling_back", "rolled_back"): + raise ValueError("This backup is being/has been rolled back; generate a new plan") + check_sessions(plan, resume) + if not resume: + # Reject changed snapshot destinations even if they now match the source. + for operation in plan["operations"]: + for asset in operation["assets"]: + if (optional_hash(asset["target"]) is not None) != asset["existed"]: + raise ValueError("Snapshot destination changed; generate a new plan") + journal = prepare_backup(plan, backup) + check_controls(journal, home) + if journal["phase"] == "completed": + report_progress("Repair: verifying previously completed repair", force=True) + if any(not Path(o["target"]).exists() for o in plan["operations"]): + raise ValueError("Completed repair session locations changed") + if any(directory_fingerprint(Path(o["target"])) != o["repaired_sha256"] for o in plan["operations"]): + raise ValueError("Completed session identity changed") + for entry in journal["controls"]: + if optional_hash(entry["path"]) != entry["new"]: + raise ValueError("Completed repair controls changed") + for target in journal["asset_writes"]: + assets = [a for o in plan["operations"] for a in o["assets"] if a["target"] == target] + if optional_hash(target) != assets[0]["sha256"]: + raise ValueError("Completed repair snapshot copies changed") + return + for operation in progress_items(plan["operations"], "Repair sessions and copy referenced snapshots"): + for asset in operation["assets"]: + path = safe_path(asset["target"], home) + old = optional_hash(path) + if old is not None: + if old != asset["sha256"]: + raise ValueError("Snapshot copy changed: {}".format(path)) + if not asset["existed"] and str(path) not in journal["asset_writes"]: + raise ValueError("Unjournaled snapshot destination appeared") + continue + if asset["existed"]: + raise ValueError("Existing snapshot disappeared") + if str(path) not in journal["asset_writes"]: + journal["asset_writes"].append(str(path)) + save_journal(backup, journal) + atomic_write(path, Path(asset["source"]).read_bytes()) + if file_hash(path) != asset["sha256"]: + raise ValueError("Snapshot copy verification failed") + source, target = Path(operation["source"]), Path(operation["target"]) + if source != target and source.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + source.rename(target) + for name, data in update_bytes(operation, "after").items(): + if (target / name).read_bytes() != data: + atomic_write(target / name, data) + if journal["phase"] == "prepared": + for number, entry in enumerate(progress_items(journal["controls"], "Prepare repaired indexes")): + if Path(entry["path"]).name == "index.json": + data = index_bytes(Path(entry["path"]).parent) + entry["pending"] = "pending/{}.json".format(number) + atomic_write(backup / entry["pending"], data) + entry["new"] = digest(data) + else: + entry["new"] = None + journal["phase"] = "indexing" + save_journal(backup, journal) + for entry in progress_items(journal["controls"], "Write indexes and clear search caches"): + path = safe_path(entry["path"], home) + if entry["pending"]: + atomic_write(path, (backup / entry["pending"]).read_bytes()) + elif path.exists(): + path.unlink() + report_progress("Repair: verifying repaired catalogs", force=True) + if any(catalog(Path(root)) != value for root, value in expected_catalogs(plan).items()): + raise ValueError("Session catalog changed during repair") + check_sessions(plan, True) + journal["phase"] = "completed" + save_journal(backup, journal) + + +def rollback(backup): + report_progress("Rollback: reading backup plan", force=True) + backup = safe_path(backup, backup) + plan = read_json(backup / "plan.json") + home = validate_plan(plan) + outside_data(backup, plan) + with RepairLock(home): + journal = load_backup(backup, plan) + check_sessions(plan, True) + check_controls(journal, home) + by_target = {a["target"]: a for o in plan["operations"] for a in o["assets"]} + for target in journal["asset_writes"]: + sha = optional_hash(safe_path(target, home)) + if sha is not None and sha != by_target[target]["sha256"]: + raise ValueError("Copied snapshot changed; rollback refused: {}".format(target)) + journal["phase"] = "rolling_back" + save_journal(backup, journal) + for operation in progress_items(reversed(plan["operations"]), "Restore sessions", + total=len(plan["operations"])): + source, target = Path(operation["source"]), Path(operation["target"]) + location = target if target.exists() else source + for name, data in update_bytes(operation, "before").items(): + if (location / name).read_bytes() != data: + atomic_write(location / name, data) + if source != target and target.exists(): + source.parent.mkdir(parents=True, exist_ok=True) + target.rename(source) + for entry in progress_items(journal["controls"], "Restore indexes and search caches"): + path = safe_path(entry["path"], home) + if entry["old"] is not None: + atomic_write(path, (backup / entry["backup"]).read_bytes()) + elif path.exists(): + path.unlink() + for target in progress_items(journal["asset_writes"], "Remove snapshot copies created by repair"): + path = safe_path(target, home) + if path.exists(): + path.unlink() + for directory in sorted(journal["created_directories"], key=lambda p: len(Path(p).parts), reverse=True): + path = safe_path(directory, home) + try: + path.rmdir() + except FileNotFoundError: + pass + except OSError as error: + if error.errno not in (errno.ENOTEMPTY, errno.EEXIST): + raise + journal["phase"] = "rolled_back" + save_journal(backup, journal) + + +def default_user_root(): + if os.name == "nt": + return Path(os.environ["APPDATA"]) / "openbitfun" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "openbitfun" + return Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "openbitfun" + + +def main(): + global PROGRESS + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(errors="backslashreplace") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--home", type=Path, help="Product data root (default: ~/.openbitfun)") + parser.add_argument("--user-root", type=Path, help="Config root containing data/workspace_data.json") + parser.add_argument("--plan", type=Path, help="Output plan, or existing plan with --apply") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--apply", action="store_true", help="Apply an existing plan") + mode.add_argument("--rollback", type=Path, metavar="BACKUP_DIR", help="Roll back a repair") + parser.add_argument("--backup-dir", type=Path, help="New backup directory; same directory to resume") + parser.add_argument("--confirm-closed", action="store_true", help="Confirm all data-owning hosts are stopped") + args = parser.parse_args() + PROGRESS = ProgressReporter() + PROGRESS.start() + try: + if args.rollback: + report_progress("Rollback: checking that OpenBitFun hosts are stopped", force=True) + check_closed(args.confirm_closed) + rollback(args.rollback.absolute()) + print("Rollback completed; backup retained.") + elif args.apply: + if not args.plan or not args.backup_dir: + parser.error("--apply requires --plan and --backup-dir") + report_progress("Repair: checking that OpenBitFun hosts are stopped", force=True) + check_closed(args.confirm_closed) + plan = read_json(args.plan) + apply_plan(plan, args.backup_dir.absolute()) + print("Repair completed: {} sessions. Backup: {}".format(len(plan["operations"]), args.backup_dir)) + else: + if not args.plan: + parser.error("--plan is required") + home = args.home or Path(os.environ.get("OPENBITFUN_HOME", str(Path.home() / ".openbitfun"))) + user_root = args.user_root or Path(os.environ.get("OPENBITFUN_USER_ROOT") or default_user_root()) + plan = make_plan(home.absolute(), user_root.absolute()) + output = outside_data(args.plan.absolute(), plan) + if output.exists(): + raise ValueError("Plan output already exists; choose a new filename") + report_progress("Plan: writing {}".format(output), force=True) + atomic_write(output, encoded(plan)) + print("Read-only plan: {} repairs, {} skipped, {} already correct.".format( + len(plan["operations"]), sum(d["status"] == "skipped" for d in plan["diagnostics"]), + sum(d["status"] == "correct" for d in plan["diagnostics"]))) + for operation in plan["operations"]: + preview = " ".join(operation["session_name"].split()) + preview = preview[:80] + ("..." if len(preview) > 80 else "") + print("REPAIR {}: {} -> {}; identity files: {}".format( + preview, operation["source"], operation["target"], + ", ".join(operation["file_updates"]) or "none")) + print("Review diagnostics and operations in {} before applying.".format(output)) + return 0 + except (OSError, ValueError, TypeError, KeyError, subprocess.SubprocessError) as error: + print("ERROR: {}".format(error), file=sys.stderr) + return 1 + finally: + PROGRESS.stop() + PROGRESS = None + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/recovery/repair-session-storage.test.py b/scripts/recovery/repair-session-storage.test.py new file mode 100644 index 0000000000..c7d42d64e0 --- /dev/null +++ b/scripts/recovery/repair-session-storage.test.py @@ -0,0 +1,824 @@ +"""Focused offline recovery tests; all data is created in temporary fixtures.""" + +import copy +import hashlib +import importlib.util +import io +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).with_name("repair-session-storage.py") +sys.dont_write_bytecode = True +SPEC = importlib.util.spec_from_file_location("repair_session_storage", SCRIPT) +repair = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(repair) + + +class RepairTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="session-repair-test-") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name).resolve() + self.home, self.user = self.base / "home", self.base / "user" + self.home.mkdir() + self.registry = {"format_version": 1, "product_id": "openbitfun", "workspaces": {}} + self.workspaces = {} + for name in ("GameOps", "武林手游", "天子编辑器"): + root = self.base / "workspaces" / name + root.mkdir(parents=True) + wid = "local_" + hashlib.sha256(name.encode()).hexdigest()[:32] + self.registry["workspaces"][wid] = {"id": wid, "rootPath": str(root), + "workspaceKind": "normal", "name": name} + self.workspaces[name] = (wid, root) + self.save_registry() + self.source_root = self.home / "projects" / "old-collided-slug" / "sessions" + self.source_root.mkdir(parents=True) + self.backup = self.base / "backup" + + def save_registry(self): + repair.atomic_write(self.user / "data" / "workspace_data.json", repair.encoded(self.registry)) + + def session(self, sid="session-one", workspace="GameOps", root=None, kind="standard"): + wid, path = self.workspaces[workspace] + directory = (root or self.source_root) / sid + directory.mkdir(parents=True) + meta = {"schema_version": 2, "sessionId": sid, "sessionName": workspace + " session", + "workspaceId": wid, "projectWorkspaceId": wid, "workspacePath": str(path), + "projectWorkspacePath": str(path), "workspaceHostname": "localhost", + "executionTarget": {"kind": "local", "rootPath": str(path)}, + "sessionKind": kind, "lastActiveAt": 1234, "turnCount": 305, + "agentType": "Cowork", "modelName": "deepseek-v4-flash", "createdAt": 1000, + "messageCount": 1977, "toolCallCount": 5974, "status": "active", + "customMetadata": {"unknownFutureField": True}} + config = {"workspace_id": wid, "project_workspace_id": wid, + "workspace_path": str(path), "project_workspace_path": str(path), + "workspace_kind": "normal", "execution_target": meta["executionTarget"], + "permission_mode": "auto_approve"} + self.write(directory / "metadata.json", meta) + self.write(directory / "state.json", {"schema_version": 2, "config": config, + "snapshot_session_id": None}) + for name in ("turns/turn-0001.json", "snapshots/context-0001.json", + "artifacts/attachments.bin", "prompt_cache.json", "token-anchors.json"): + repair.atomic_write(directory / name, b"preserved bytes\x00\xff") + (directory / "empty-directory").mkdir() + return directory + + def write(self, path, value): + repair.atomic_write(path, repair.encoded(value)) + + def target_root(self, name="GameOps"): + return self.home / "projects" / repair.runtime_key(self.workspaces[name][1]) / "sessions" + + def plan(self): + return repair.make_plan(self.home, self.user) + + def snapshot(self, sid, file, inline=False): + runtime = self.source_root.parent + content = b"old file contents" + md5 = hashlib.md5(content).hexdigest() + category = "baselines" if sid.startswith("baseline_") else "metadata" + value = {"snapshot_id": sid, "file_path": str(file), "content_hash": md5, + "compressed_content": [1, 2, 3] if inline else []} + self.write(runtime / "snapshots" / category / (sid + ".json"), value) + if not inline: + repair.atomic_write(runtime / "snapshots" / "by_hash" / (md5 + ".snap"), content) + + def test_plan_and_index_reuse_metadata_reads(self): + session, _, _ = self.assistant_fixture(move=True, old_paths=True) + read_bytes = Path.read_bytes + reads = [] + + def tracked_read(path): + reads.append(path) + return read_bytes(path) + + with patch.object(Path, "read_bytes", tracked_read): + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1, plan["diagnostics"]) + self.assertEqual(reads.count(session / "metadata.json"), 1) + self.assertEqual(reads.count(session / "state.json"), 1) + self.assertEqual(set(plan["operations"][0]["file_updates"]), + {"metadata.json", "state.json"}) + reads.clear() + with patch.object(Path, "read_bytes", tracked_read): + repair.index_bytes(session.parent) + self.assertEqual(reads.count(session / "metadata.json"), 1) + + def test_before_after_fingerprints_share_directory_enumeration(self): + session, _, _ = self.assistant_fixture(move=True, old_paths=True) + with patch.object(repair, "directory_manifest", wraps=repair.directory_manifest) as scan: + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1, plan["diagnostics"]) + self.assertEqual(scan.call_count, 1) + self.assertEqual(scan.call_args.args[0], session) + with patch.object(repair, "directory_manifest", wraps=repair.directory_manifest) as scan: + repair.validate_plan(plan) + self.assertEqual(scan.call_count, 1) + + def test_collision_apply_indexes_preservation_backup_resume_and_rollback(self): + sessions = [self.session("session-" + str(i), name) for i, name in enumerate(self.workspaces)] + before = {s.name: repair.directory_fingerprint(s) for s in sessions} + self.write(self.source_root / "index.json", {"old": "raw legacy index"}) + repair.atomic_write(self.source_root / repair.CACHE_NAMES[0], b"old sqlite bytes") + hidden = self.session("hidden", root=self.target_root(), kind="subagent") + legacy_hidden = self.session("leaked", root=self.target_root()) + meta = repair.read_json(legacy_hidden / "metadata.json") + meta.update({"createdBy": "session-parent", "sessionName": "Subagent: Review"}) + self.write(legacy_hidden / "metadata.json", meta) + original_index = (self.source_root / "index.json").read_bytes() + original_home = repair.directory_fingerprint(self.home) + plan = self.plan() + self.assertEqual(len(plan["operations"]), 3) + self.assertEqual(repair.directory_fingerprint(self.home), original_home, "planning must be read-only") + repair.apply_plan(plan, self.backup) + for number, operation in enumerate(plan["operations"]): + target = Path(operation["target"]) + self.assertFalse(Path(operation["source"]).exists()) + self.assertEqual(repair.directory_fingerprint(target), before[target.name]) + self.assertEqual(repair.directory_fingerprint(self.backup / "sessions" / str(number)), before[target.name]) + index = repair.read_json(self.target_root() / "index.json") + self.assertEqual(index["metadata_file_count"], 3) + self.assertEqual(len(index["sessions"]), 1) + self.assertTrue(index["sessions"][0]["customMetadata"]["unknownFutureField"]) + self.assertNotIn("schema_version", index["sessions"][0]) + self.assertEqual(repair.read_json(self.source_root / "index.json")["sessions"], []) + self.assertFalse((self.source_root / repair.CACHE_NAMES[0]).exists()) + repaired_home = repair.directory_fingerprint(self.home) + repair.apply_plan(plan, self.backup) + self.assertEqual(repair.directory_fingerprint(self.home), repaired_home, "completed rerun must be a no-op") + repair.rollback(self.backup) + self.assertEqual((self.source_root / "index.json").read_bytes(), original_index) + self.assertEqual((self.source_root / repair.CACHE_NAMES[0]).read_bytes(), b"old sqlite bytes") + for session in sessions: + self.assertEqual(repair.directory_fingerprint(session), before[session.name]) + self.assertTrue(hidden.exists()) + self.assertEqual(repair.directory_fingerprint(self.home), original_home) + repair.rollback(self.backup) + self.assertEqual(repair.read_json(self.backup / "journal.json")["phase"], "rolled_back") + + def test_state_and_registry_disagreement_skipped(self): + session = self.session() + state = repair.read_json(session / "state.json") + state["config"]["workspace_id"] = "local_other" + self.write(session / "state.json", state) + plan = self.plan() + self.assertEqual(plan["operations"], []) + self.assertIn("disagree", plan["diagnostics"][0]["reason"]) + state["config"]["workspace_id"] = self.workspaces["GameOps"][0] + self.write(session / "state.json", state) + self.registry["workspaces"][self.workspaces["GameOps"][0]]["rootPath"] = str(self.workspaces["武林手游"][1]) + self.save_registry() + self.assertEqual(self.plan()["operations"], []) + + def test_legacy_path_and_metadata_only(self): + session = self.session() + meta = repair.read_json(session / "metadata.json") + for field in ("workspaceId", "projectWorkspaceId"): + meta.pop(field) + self.write(session / "metadata.json", meta) + (session / "state.json").unlink() + self.assertEqual(len(self.plan()["operations"]), 1) + wid, root = self.workspaces["GameOps"] + self.registry["workspaces"]["duplicate"] = {"id": "duplicate", "rootPath": str(root)} + self.save_registry() + self.assertEqual(self.plan()["operations"], []) + + def test_legacy_aliases_hidden_sessions_and_metadata_snapshot_owner(self): + session = self.session(kind="ephemeral_child") + meta = repair.read_json(session / "metadata.json") + meta["snapshotSessionId"] = "parent-history" + for key in ("sessionId", "sessionName", "agentType", "modelName", "createdAt", "lastActiveAt", + "turnCount", "messageCount", "toolCallCount", "workspacePath", "workspaceId", + "sessionKind"): + alias = repair.re.sub(r"([A-Z])", lambda m: "_" + m.group(1).lower(), key) + meta[alias] = meta.pop(key) + self.write(session / "metadata.json", meta) + self.write(self.source_root.parent / "snapshots/operations/parent-history.json", + {"session_id": "parent-history", "turns": {}}) + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1) + self.assertEqual(len(plan["operations"][0]["assets"]), 1) + repair.apply_plan(plan, self.backup) + index = repair.read_json(self.target_root() / "index.json") + self.assertEqual(index["metadata_file_count"], 1) + self.assertEqual(index["sessions"], []) + self.assertEqual(repair.read_json(Path(plan["operations"][0]["target"]) / "metadata.json"), meta) + repair.rollback(self.backup) + + def test_invalid_required_metadata_and_changed_index_block_repair(self): + session = self.session() + meta = repair.read_json(session / "metadata.json") + invalid = dict(meta, turnCount="305") + self.write(session / "metadata.json", invalid) + self.assertEqual(self.plan()["operations"], []) + self.write(session / "metadata.json", meta) + plan = self.plan() + repair.apply_plan(plan, self.backup) + index = self.target_root() / "index.json" + original = index.read_bytes() + repair.atomic_write(index, b"changed by another writer") + with self.assertRaises(ValueError): + repair.apply_plan(plan, self.backup) + with self.assertRaises(ValueError): + repair.rollback(self.backup) + self.assertEqual(index.read_bytes(), b"changed by another writer") + repair.atomic_write(index, original) + repair.rollback(self.backup) + + def test_remote_linked_worktree_and_missing_workspace_skip(self): + for kind in ("remote", "worktree"): + session = self.session("session-" + kind) + state = repair.read_json(session / "state.json") + state["config"]["workspace_kind"] = kind + self.write(session / "state.json", state) + missing = self.session("missing") + shutil.rmtree(self.workspaces["GameOps"][1]) + (self.home / "remote_ssh").mkdir() + plan = self.plan() + self.assertFalse(plan["operations"]) + self.assertEqual(len(plan["diagnostics"]), 3) + self.assertTrue(missing.exists()) + + def test_destination_and_duplicate_conflicts(self): + self.session() + self.session(root=self.target_root()) + self.assertEqual(self.plan()["operations"], []) + shutil.rmtree(self.target_root()) + self.session(root=self.home / "projects" / "another-slug" / "sessions") + plan = self.plan() + self.assertEqual(plan["operations"], []) + self.assertTrue(all("Multiple source" in d["reason"] for d in plan["diagnostics"])) + + def test_malformed_neighbor_blocks_index_rebuild(self): + self.session() + bad = self.session("bad") + repair.atomic_write(bad / "metadata.json", b"{not json}") + self.assertEqual(self.plan()["operations"], []) + shutil.rmtree(bad) + self.session("bad", root=self.target_root()) + repair.atomic_write(self.target_root() / "bad" / "metadata.json", b"{not json}") + self.assertEqual(self.plan()["operations"], []) + + def test_shared_snapshots_baselines_revert_inline_and_history(self): + session = self.session() + workspace = self.workspaces["GameOps"][1] + self.snapshot("before-one", workspace / "one.txt") + self.snapshot("after-one", workspace / "one.txt", inline=True) + self.snapshot("baseline_one", workspace / "one.txt") + self.snapshot("baseline_revert", workspace / "one.txt", inline=True) + self.snapshot("baseline_unreferenced", workspace / "one.txt") + self.snapshot("baseline_other", self.workspaces["武林手游"][1] / "two.txt") + self.snapshot("revert-one", workspace / "one.txt", inline=True) + runtime = self.source_root.parent + history = runtime / "snapshots" / "operations" / "shared-history.json" + self.write(history, {"session_id": "shared-history", "turns": {"0": {"operations": [ + {"before_snapshot_id": "before-one", "after_snapshot_id": "after-one"}, + {"before_snapshot_id": "baseline_one", "after_snapshot_id": "after-one"}]}}}) + state = repair.read_json(session / "state.json") + state["snapshot_session_id"] = "shared-history" + self.write(session / "state.json", state) + self.write(session / "session-revert.json", {"schemaVersion": 1, "boundaryTurn": 2, + "workspaceCheckpoint": [{"path": str(workspace / "one.txt"), + "snapshotId": "revert-one"}, + {"snapshotId": "baseline_revert"}]}) + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1) + assets = plan["operations"][0]["assets"] + self.assertTrue(any("baseline_one" in a["source"] for a in assets)) + self.assertTrue(any("baseline_revert" in a["source"] for a in assets)) + self.assertFalse(any("baseline_unreferenced" in a["source"] for a in assets)) + self.assertFalse(any("baseline_other" in a["source"] for a in assets)) + repair.apply_plan(plan, self.backup) + repair.apply_plan(plan, self.backup) + for asset in assets: + self.assertEqual(Path(asset["source"]).read_bytes(), Path(asset["target"]).read_bytes()) + repair.rollback(self.backup) + for asset in assets: + self.assertTrue(Path(asset["source"]).exists()) + self.assertFalse(Path(asset["target"]).exists()) + + def test_unreferenced_baselines_are_never_scanned_or_read(self): + self.session() + baselines = self.source_root.parent / "snapshots" / "baselines" + unrelated = baselines / "baseline_unreferenced.json" + repair.atomic_write(unrelated, b"invalid unrelated baseline JSON") + iterdir, read_bytes = Path.iterdir, Path.read_bytes + + def guarded_iterdir(path): + if path == baselines: + self.fail("Workspace baselines must not be enumerated") + return iterdir(path) + + def guarded_read(path): + if path == unrelated: + self.fail("Unreferenced baseline must not be read") + return read_bytes(path) + + with patch.object(Path, "iterdir", guarded_iterdir), patch.object(Path, "read_bytes", guarded_read): + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1, plan["diagnostics"]) + self.assertEqual(plan["operations"][0]["assets"], []) + repair.apply_plan(plan, self.backup) + repair.apply_plan(plan, self.backup) + repair.rollback(self.backup) + self.assertEqual(unrelated.read_bytes(), b"invalid unrelated baseline JSON") + + def test_missing_explicit_baseline_reference_blocks_repair(self): + session = self.session() + self.snapshot("baseline_one", self.workspaces["GameOps"][1] / "one.txt") + self.write(session / "session-revert.json", {"snapshotId": "baseline_one"}) + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1, plan["diagnostics"]) + (self.source_root.parent / "snapshots/baselines/baseline_one.json").unlink() + with self.assertRaisesRegex(ValueError, "Missing snapshot dependency"): + repair.apply_plan(plan, self.backup) + self.assertEqual(self.plan()["operations"], []) + self.assertTrue(session.exists()) + + def test_snapshot_conflict_and_missing_blob_skip(self): + session = self.session() + workspace = self.workspaces["GameOps"][1] + self.snapshot("snap-one", workspace / "one.txt") + self.write(session / "session-revert.json", {"snapshot_id": "snap-one"}) + plan = self.plan() + asset = next(a for a in plan["operations"][0]["assets"] if a["target"].endswith("snap-one.json")) + repair.atomic_write(Path(asset["target"]), b"conflicting snapshot") + self.assertEqual(self.plan()["operations"], []) + Path(asset["target"]).unlink() + for blob in (self.source_root.parent / "snapshots" / "by_hash").iterdir(): + blob.unlink() + self.assertEqual(self.plan()["operations"], []) + + def test_existing_identical_snapshot_preserved_by_rollback(self): + session = self.session() + self.snapshot("snap-one", self.workspaces["GameOps"][1] / "one.txt", inline=True) + self.write(session / "session-revert.json", {"snapshot_id": "snap-one"}) + asset = self.plan()["operations"][0]["assets"][0] + repair.atomic_write(Path(asset["target"]), Path(asset["source"]).read_bytes()) + plan = self.plan() + self.assertTrue(plan["operations"][0]["assets"][0]["existed"]) + repair.apply_plan(plan, self.backup) + repair.rollback(self.backup) + self.assertTrue(Path(asset["target"]).exists()) + + def test_stale_plan_registry_session_neighbor_target_and_crafted_path(self): + session = self.session() + plan = self.plan() + repair.atomic_write(session / "new-turn.json", b"new") + with self.assertRaises(ValueError): + repair.apply_plan(plan, self.backup) + (session / "new-turn.json").unlink() + self.session("new-neighbor") + with self.assertRaises(ValueError): + repair.apply_plan(plan, self.backup) + shutil.rmtree(self.source_root / "new-neighbor") + self.registry["extra"] = True + self.save_registry() + with self.assertRaises(ValueError): + repair.apply_plan(plan, self.backup) + plan = self.plan() + bad = copy.deepcopy(plan) + bad["operations"][0]["target"] = str(self.base / "arbitrary-target") + with self.assertRaises(ValueError): + repair.apply_plan(bad, self.backup) + self.session(root=self.target_root()) + with self.assertRaises(ValueError): + repair.apply_plan(plan, self.backup) + self.assertFalse(self.backup.exists()) + + def test_resume_and_rollback_after_interrupted_move(self): + session = self.session() + self.write(self.source_root / "index.json", {"original": True}) + plan = self.plan() + with patch.object(repair, "index_bytes", side_effect=OSError("simulated interruption")): + with self.assertRaises(OSError): + repair.apply_plan(plan, self.backup) + self.assertFalse(session.exists()) + repair.apply_plan(plan, self.backup) + self.assertEqual(len(repair.read_json(self.target_root() / "index.json")["sessions"]), 1) + repair.rollback(self.backup) + self.assertTrue(session.exists()) + self.assertEqual(repair.read_json(self.source_root / "index.json"), {"original": True}) + + def test_rollback_directly_after_interrupted_index_write_and_resume_rollback(self): + session = self.session() + plan = self.plan() + original = repair.atomic_write + count = [0] + + def fail(path, data): + if Path(path).name == "index.json" and self.home in Path(path).parents: + count[0] += 1 + if count[0] == 2: + raise OSError("simulated interruption") + return original(path, data) + + with patch.object(repair, "atomic_write", side_effect=fail): + with self.assertRaises(OSError): + repair.apply_plan(plan, self.backup) + with patch.object(repair, "atomic_write", side_effect=OSError("rollback interruption")): + with self.assertRaises(OSError): + repair.rollback(self.backup) + repair.rollback(self.backup) + self.assertTrue(session.exists()) + + def test_rollback_refuses_changes_after_application(self): + self.session() + plan = self.plan() + repair.apply_plan(plan, self.backup) + target = Path(plan["operations"][0]["target"]) + repair.atomic_write(target / "new-turn.json", b"new user data") + with self.assertRaises(ValueError): + repair.rollback(self.backup) + (target / "new-turn.json").unlink() + self.session("new-session", root=self.target_root()) + with self.assertRaises(ValueError): + repair.rollback(self.backup) + self.assertTrue(target.exists()) + + def test_links_junction_attributes_and_backup_confinement(self): + session = self.session() + if os.name == "nt": + # Junctions do not require Windows developer mode or symlink privileges. + result = subprocess.run(["cmd", "/c", "mklink", "/J", str(session / "linked"), + str(self.workspaces["GameOps"][1])], capture_output=True, + creationflags=subprocess.CREATE_NO_WINDOW) + self.assertEqual(result.returncode, 0, result.stderr) + else: + (session / "linked").symlink_to(self.workspaces["GameOps"][1], target_is_directory=True) + self.addCleanup(lambda: (os.rmdir(session / "linked") if os.name == "nt" + else (session / "linked").unlink()) + if os.path.lexists(session / "linked") else None) + self.assertEqual(self.plan()["operations"], []) + with self.assertRaises(ValueError): + repair.directory_fingerprint(session) + if os.name == "nt": + os.rmdir(session / "linked") + else: + (session / "linked").unlink() + plan = self.plan() + with self.assertRaises(ValueError): + repair.apply_plan(plan, self.home / "backup") + + def test_cli_read_only_and_closed_confirmation(self): + self.session() + self.write(self.source_root / "index.json", {"sessions": []}) + before = repair.directory_fingerprint(self.home) + output = self.base / "plan.json" + result = subprocess.run([sys.executable, str(SCRIPT), "--home", str(self.home), + "--user-root", str(self.user), "--plan", str(output)], + capture_output=True, **({"creationflags": subprocess.CREATE_NO_WINDOW} + if os.name == "nt" else {})) + self.assertEqual(result.returncode, 0, result.stderr) + progress = result.stderr.decode(errors="replace") + self.assertIn("[Progress", progress) + self.assertIn("Plan sessions in", progress) + self.assertIn("1 / 1 completed", progress) + self.assertNotIn(b"[Progress", result.stdout) + self.assertEqual(repair.directory_fingerprint(self.home), before) + self.assertEqual(len(repair.read_json(output)["operations"]), 1) + with self.assertRaises(ValueError): + repair.check_closed(False) + if os.name == "nt": + with patch.object(repair.subprocess, "run", return_value=subprocess.CompletedProcess( + [], 0, b'"openbitfun-cli.exe","1234"\n')): + with self.assertRaises(ValueError): + repair.check_closed(True) + + def test_progress_refreshes_during_blocking_work_and_stops(self): + refreshed = threading.Event() + + class RecordingStream(io.StringIO): + def flush(stream): + if stream.getvalue().count("[Progress") >= 2: + refreshed.set() + + output = RecordingStream() + reporter = repair.ProgressReporter(output, interval=0.01) + reporter.message = "Back up sessions: 0 / 1 completed; large-session" + reporter.start() + try: + self.assertTrue(refreshed.wait(2), "Blocked work should still report progress") + finally: + reporter.stop() + self.assertFalse(reporter.thread.is_alive()) + self.assertIn("large-session", output.getvalue()) + self.assertNotIn("1 / 1 completed", output.getvalue()) + + def test_apply_and_rollback_progress_counts(self): + self.session() + plan = self.plan() + output = io.StringIO() + reporter = repair.ProgressReporter(output, interval=0) + with patch.object(repair, "PROGRESS", reporter): + repair.apply_plan(plan, self.backup) + repair.rollback(self.backup) + messages = output.getvalue() + for stage in ("Validate sessions and snapshot dependencies", "Back up sessions", + "Repair sessions and copy referenced snapshots", "Restore sessions"): + self.assertIn(stage + ": 0 / 1 completed", messages) + self.assertIn(stage + ": 1 / 1 completed", messages) + + def test_snapshot_copy_interruption_and_unjournaled_destinations(self): + session = self.session() + self.snapshot("snap-one", self.workspaces["GameOps"][1] / "one.txt", inline=True) + self.write(session / "session-revert.json", {"snapshot_id": "snap-one"}) + plan = self.plan() + asset = plan["operations"][0]["assets"][0] + original = repair.atomic_write + + def fail(path, data): + if str(path) == asset["target"]: + raise OSError("interrupted snapshot copy") + return original(path, data) + + with patch.object(repair, "atomic_write", side_effect=fail): + with self.assertRaises(OSError): + repair.apply_plan(plan, self.backup) + self.assertTrue(session.exists()) + repair.apply_plan(plan, self.backup) + repair.rollback(self.backup) + plan = self.plan() + repair.atomic_write(Path(asset["target"]), Path(asset["source"]).read_bytes()) + with self.assertRaises(ValueError): + repair.apply_plan(plan, self.base / "another-backup") + self.assertTrue(session.exists()) + + def test_rollback_resumes_after_one_of_multiple_moves_is_restored(self): + sessions = [self.session("session-" + str(i), name) for i, name in enumerate(self.workspaces)] + plan = self.plan() + repair.apply_plan(plan, self.backup) + original = Path.rename + count = [0] + + def fail(path, destination): + count[0] += 1 + if count[0] == 2: + raise OSError("interrupted rollback rename") + return original(path, destination) + + with patch.object(Path, "rename", new=fail): + with self.assertRaises(OSError): + repair.rollback(self.backup) + repair.rollback(self.backup) + self.assertTrue(all(s.exists() for s in sessions)) + + def assistant_fixture(self, move=False, old_paths=False): + home = self.base / ".openbitfun" + home.mkdir() + self.home = home + self.source_root = home / "projects/old-collided-slug/sessions" + self.source_root.mkdir(parents=True) + root = home / "personal_assistant/workspace-assistant" + root.mkdir(parents=True) + wid = "local_" + repair.digest(("localhost\n" + str(root).replace("\\", "/")).encode())[:32] + self.registry["workspaces"][wid] = {"id": wid, "rootPath": str(root), "workspaceKind": "assistant"} + self.workspaces["Assistant"] = (wid, root) + self.save_registry() + session = self.session("assistant-session", "Assistant", root=None if move else self.target_root("Assistant")) + alias_id, mapping = next(iter(repair.assistant_aliases(home, self.registry["workspaces"]).items())) + for name in ("metadata.json", "state.json"): + value = repair.read_json(session / name) + container = value if name == "metadata.json" else value["config"] + for key in (("workspaceId", "projectWorkspaceId") if name == "metadata.json" + else ("workspace_id", "project_workspace_id")): + container[key] = alias_id + if old_paths: + for key in (("workspacePath", "projectWorkspacePath") if name == "metadata.json" + else ("workspace_path", "project_workspace_path")): + container[key] = mapping["old_path"] + container["executionTarget" if name == "metadata.json" else "execution_target"]["rootPath"] = mapping["old_path"] + self.write(session / name, value) + return session, wid, alias_id + + def test_assistant_identity_only_repair_preserves_unknown_data_and_rollback_bytes(self): + session, wid, alias = self.assistant_fixture() + state = repair.read_json(session / "state.json") + state["config"]["unknownFutureField"] = {"keep": True} + self.write(session / "state.json", state) + original = repair.directory_fingerprint(self.home) + original_state = (session / "state.json").read_bytes() + plan = self.plan() + op = plan["operations"][0] + self.assertEqual(op["source"], op["target"]) + self.assertEqual(set(op["file_updates"]), {"metadata.json", "state.json"}) + repair.apply_plan(plan, self.backup) + self.assertEqual(repair.read_json(session / "state.json")["config"]["workspace_id"], wid) + self.assertEqual(repair.read_json(session / "metadata.json")["workspaceId"], wid) + self.assertEqual(repair.read_json(session / "state.json")["config"]["permission_mode"], "auto_approve") + self.assertTrue(repair.read_json(session / "state.json")["config"]["unknownFutureField"]["keep"]) + self.assertEqual(self.plan()["operations"], []) + repair.apply_plan(plan, self.backup) + repair.rollback(self.backup) + repair.rollback(self.backup) + self.assertEqual(repair.directory_fingerprint(self.home), original) + self.assertEqual((session / "state.json").read_bytes(), original_state) + self.assertEqual(repair.read_json(session / "state.json")["config"]["workspace_id"], alias) + + def test_assistant_move_and_path_identity_updates_resume_partial_write(self): + session, wid, _ = self.assistant_fixture(move=True, old_paths=True) + original = repair.directory_fingerprint(self.home) + plan = self.plan() + target = Path(plan["operations"][0]["target"]) + atomic = repair.atomic_write + + def fail(path, data): + if Path(path) == target / "state.json": + raise OSError("interrupted after metadata rewrite") + return atomic(path, data) + + with patch.object(repair, "atomic_write", side_effect=fail): + with self.assertRaises(OSError): + repair.apply_plan(plan, self.backup) + self.assertFalse(session.exists()) + self.assertEqual(repair.read_json(target / "metadata.json")["workspaceId"], wid) + repair.apply_plan(plan, self.backup) + self.assertEqual(repair.read_json(target / "state.json")["config"]["workspace_id"], wid) + repair.rollback(self.backup) + self.assertEqual(repair.directory_fingerprint(self.home), original) + + def test_assistant_partial_write_can_rollback_directly(self): + session, _, _ = self.assistant_fixture() + before = repair.directory_fingerprint(self.home) + plan = self.plan() + atomic = repair.atomic_write + + def fail(path, data): + if Path(path) == session / "state.json": + raise OSError("identity write interruption") + return atomic(path, data) + + with patch.object(repair, "atomic_write", side_effect=fail): + with self.assertRaises(OSError): + repair.apply_plan(plan, self.backup) + repair.rollback(self.backup) + self.assertEqual(repair.directory_fingerprint(self.home), before) + + def test_assistant_state_only_and_crafted_rewrite_refused(self): + session, wid, _ = self.assistant_fixture() + meta = repair.read_json(session / "metadata.json") + meta.pop("workspaceId") + meta.pop("projectWorkspaceId") + self.write(session / "metadata.json", meta) + plan = self.plan() + self.assertEqual(set(plan["operations"][0]["file_updates"]), {"state.json"}) + self.assertEqual(plan["operations"][0]["identity_changes"][0]["file"], "state.json") + crafted = copy.deepcopy(plan) + state = repair.read_json(session / "state.json") + state["config"]["permission_mode"] = "unsafe-replacement" + crafted["operations"][0]["file_updates"]["state.json"]["after"] = repair.base64.b64encode( + repair.encoded(state)).decode() + with self.assertRaises(ValueError): + repair.apply_plan(crafted, self.backup) + self.assertFalse(self.backup.exists()) + repair.apply_plan(plan, self.backup) + self.assertEqual(repair.read_json(session / "metadata.json"), meta) + repair.rollback(self.backup) + + def test_identity_rollback_interruption_between_json_restores(self): + session, _, _ = self.assistant_fixture() + before = repair.directory_fingerprint(self.home) + plan = self.plan() + repair.apply_plan(plan, self.backup) + atomic = repair.atomic_write + + def fail(path, data): + if Path(path) == session / "state.json": + raise OSError("interrupted restoring state") + return atomic(path, data) + + with patch.object(repair, "atomic_write", side_effect=fail): + with self.assertRaises(OSError): + repair.rollback(self.backup) + repair.rollback(self.backup) + self.assertEqual(repair.directory_fingerprint(self.home), before) + + def test_assistant_arbitrary_id_and_different_path_not_guessed(self): + session, _, alias = self.assistant_fixture() + meta = repair.read_json(session / "metadata.json") + state = repair.read_json(session / "state.json") + meta["workspaceId"] = meta["projectWorkspaceId"] = "local_unrelated" + state["config"]["workspace_id"] = state["config"]["project_workspace_id"] = "local_unrelated" + self.write(session / "metadata.json", meta) + self.write(session / "state.json", state) + self.assertFalse(self.plan()["operations"]) + meta["workspaceId"] = meta["projectWorkspaceId"] = alias + state["config"]["workspace_id"] = state["config"]["project_workspace_id"] = alias + meta["workspacePath"] = str(self.workspaces["GameOps"][1]) + state["config"]["workspace_path"] = meta["workspacePath"] + self.write(session / "metadata.json", meta) + self.write(session / "state.json", state) + self.assertFalse(self.plan()["operations"]) + + def remote_fixture(self, legacy=False): + wid, workspace = "remote_test", "/home/TestUser/游戏" + self.registry["workspaces"][wid] = {"id": wid, "rootPath": workspace, "workspaceKind": "remote", + "metadata": {"sshHost": "test-host", "connectionId": "ssh-user@test-host"}} + self.save_registry() + if legacy: + root = self.home / "remote_ssh/test-host/home/TestUser/游戏/sessions" + else: + root = self.source_root + session = self.session("remote-session", root=root) + meta = repair.read_json(session / "metadata.json") + state = repair.read_json(session / "state.json") + meta.update(workspaceId=wid, projectWorkspaceId=wid, workspacePath=workspace, + projectWorkspacePath=workspace, workspaceHostname="test-host", + executionTarget={"kind": "local", "rootPath": workspace}) + state["config"].update(workspace_id=wid, project_workspace_id=wid, workspace_path=workspace, + project_workspace_path=workspace, workspace_kind="remote", + remote_ssh_host="test-host", remote_connection_id="ssh-user@test-host", + execution_target=meta["executionTarget"]) + self.write(session / "metadata.json", meta) + self.write(session / "state.json", state) + return session, wid, workspace + + def test_remote_posix_identity_directory_move_and_legacy_mirror(self): + for legacy in (False, True): + with self.subTest(legacy=legacy): + session, wid, workspace = self.remote_fixture(legacy) + before = repair.directory_fingerprint(self.home) + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1, plan["diagnostics"]) + op = plan["operations"][0] + expected = repair.digest(b"runtime-remote\0test-host\0" + workspace.encode())[:24] + self.assertEqual(Path(op["target"]).parent.parent.name, expected) + self.assertEqual(op["file_updates"], {}) + with patch.object(repair, "runtime_key", side_effect=AssertionError("No local fallback")): + repair.apply_plan(plan, self.backup) + self.assertEqual(repair.directory_fingerprint(Path(op["target"])), op["sha256"]) + self.assertEqual(self.plan()["operations"], []) + repair.rollback(self.backup) + self.assertEqual(repair.directory_fingerprint(self.home), before) + shutil.rmtree(self.backup) + shutil.rmtree(session) + + def test_remote_connection_scoped_snapshots_preserved(self): + session, wid, workspace = self.remote_fixture(legacy=True) + identity = ["test-host", workspace, "ssh-user@test-host"] + scope = repair.digest(repair.json.dumps(identity, ensure_ascii=False, separators=(",", ":")).encode()) + snapshots = session.parent.parent / "snapshots/workspace-identities" / scope + self.write(snapshots / "operations/remote-session.json", { + "session_id": session.name, "turns": {"0": {"operations": [ + {"before_snapshot_id": "baseline_remote", "after_snapshot_id": "snap-remote"}]}}}) + self.write(snapshots / "metadata/snap-remote.json", { + "snapshot_id": "snap-remote", "file_path": workspace + "/file.txt", + "compressed_content": [1], "content_hash": "a" * 32}) + self.write(snapshots / "baselines/baseline_remote.json", { + "snapshot_id": "baseline_remote", "file_path": workspace + "/file.txt", + "compressed_content": [1], "content_hash": "b" * 32}) + self.write(snapshots / "baselines/baseline_unreferenced.json", { + "snapshot_id": "baseline_unreferenced", "file_path": workspace + "/file.txt", + "compressed_content": [1], "content_hash": "c" * 32}) + self.write(session / "session-revert.json", {"workspaceCheckpoint": [{"snapshotId": "snap-remote"}]}) + other = session.parent.parent / "snapshots/workspace-identities/unrelated-profile" + self.write(other / "metadata/private.json", {"keep": True}) + before = repair.directory_fingerprint(self.home) + plan = self.plan() + self.assertEqual(len(plan["operations"]), 1, plan["diagnostics"]) + assets = plan["operations"][0]["assets"] + self.assertEqual(len(assets), 3) + self.assertFalse(any("baseline_unreferenced" in a["source"] for a in assets)) + self.assertTrue(all(scope in a["target"] for a in assets)) + repair.apply_plan(plan, self.backup) + for a in assets: + self.assertEqual(Path(a["source"]).read_bytes(), Path(a["target"]).read_bytes()) + self.assertTrue((other / "metadata/private.json").exists()) + repair.rollback(self.backup) + self.assertEqual(repair.directory_fingerprint(self.home), before) + + def test_remote_same_path_other_host_case_and_connection_conflicts(self): + session, wid, _ = self.remote_fixture() + meta = repair.read_json(session / "metadata.json") + state = repair.read_json(session / "state.json") + for field, wrong in (("remote_ssh_host", "another-host"), + ("workspace_path", "/home/testuser/游戏"), + ("remote_connection_id", "ssh-other@test-host")): + broken = copy.deepcopy(state) + broken["config"][field] = wrong + self.write(session / "state.json", broken) + self.assertFalse(self.plan()["operations"]) + self.write(session / "state.json", state) + remote = self.registry["workspaces"][wid] + self.registry["workspaces"]["remote_another"] = dict(remote, id="remote_another", + metadata={"sshHost": "another-host"}) + self.save_registry() + for key in ("workspaceId", "projectWorkspaceId"): + meta.pop(key) + for key in ("workspace_id", "project_workspace_id"): + state["config"].pop(key) + self.write(session / "metadata.json", meta) + self.write(session / "state.json", state) + self.assertEqual(self.plan()["operations"][0]["workspace_id"], wid) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/apps/data-migrator/AGENTS.md b/src/apps/data-tools/AGENTS.md similarity index 55% rename from src/apps/data-migrator/AGENTS.md rename to src/apps/data-tools/AGENTS.md index 3677830e73..8796c92de3 100644 --- a/src/apps/data-migrator/AGENTS.md +++ b/src/apps/data-tools/AGENTS.md @@ -1,6 +1,6 @@ -# Data Migrator Agent Guide +# Data Tools Agent Guide -Scope: src/apps/data-migrator. This is a separately versioned, offline local tool. +Scope: src/apps/data-tools. This is a separately versioned, offline local tool. Read README.md for its user-facing contract. ## Boundaries @@ -19,22 +19,33 @@ Read README.md for its user-facing contract. data, old plans/reports, snapshots, journals, backups and owner conflict policies. - The UI uses public @openbitfun/design-tokens and @openbitfun/theme-openbitfun exports bundled in ui/generated/design-system.css. Regenerate through - pnpm run data-migrator:theme:generate. Direct Cargo builds are offline; - Desktop dev/build must never generate or build migrator assets. + pnpm run data-tools:theme:generate. Direct Cargo builds are offline; + Desktop dev/build must never generate or build Data Tools assets. - ui/theme.js selects system scheme/contrast before paint. Workflow translations are app-owned; do not import Web UI catalogs. No mocks or browser automation for visual verification. - Tool version lives in Cargo.toml and tauri.conf.json. Packaging/signing uses - the independent Data Migrator workflow and data-migrator-v tags. + the independent Data Tools workflow and data-tools-v tags (legacy tags remain supported). + +- Session repair is parallel to import, not an import domain. It must work + without a legacy source. Reuse services-core/session-recovery with native + commands; never require Python in the shipped GUI. Keep repair tasks/backups + under the tool app-config identity outside product data and preserve the + existing migration task paths. Tool preferences use the data-tools identity + without importing the old tool's configuration. Accept the old release tag + prefix while commands, executables and release assets use data-tools. +- Migration, reset and repair share one operation guard and cancellation/close + lifecycle. Exercise mutations only on fixtures or explicitly supplied copies. ## Focused verification ```bash -cargo test -p openbitfun-data-migrator -p openbitfun-legacy-migration-adapters -p openbitfun-legacy-migration --lib +cargo test -p openbitfun-data-tools -p openbitfun-legacy-migration-adapters -p openbitfun-legacy-migration --lib +cargo test -p openbitfun-services-core --no-default-features --features session-recovery --lib session_recovery cargo test -p openbitfun-legacy-migration --test migration_engine_contracts -node --test scripts/data-migrator-tauri-build.test.mjs -node --check src/apps/data-migrator/ui/app.js -node --test scripts/data-migrator-report.test.mjs +node --test scripts/data-tools-tauri-build.test.mjs +node --check src/apps/data-tools/ui/app.js +node --test scripts/data-tools-report.test.mjs pnpm run theme:color-audit:all ``` diff --git a/src/apps/data-migrator/Cargo.toml b/src/apps/data-tools/Cargo.toml similarity index 71% rename from src/apps/data-migrator/Cargo.toml rename to src/apps/data-tools/Cargo.toml index f47d7feb5d..c7f69cfb01 100644 --- a/src/apps/data-migrator/Cargo.toml +++ b/src/apps/data-tools/Cargo.toml @@ -1,22 +1,23 @@ [package] -name = "openbitfun-data-migrator" -version = "0.1.2" +name = "openbitfun-data-tools" +version = "0.2.0" authors.workspace = true edition.workspace = true -description = "OpenBitFun offline legacy data migrator" +description = "OpenBitFun offline data migration, reset and session repair tools" [lib] -name = "openbitfun_data_migrator_lib" +name = "openbitfun_data_tools_lib" crate-type = ["staticlib", "cdylib", "rlib"] [[bin]] -name = "openbitfun-data-migrator" +name = "openbitfun-data-tools" path = "src/main.rs" [build-dependencies] tauri-build = { workspace = true } [dependencies] +openbitfun-services-core = { path = "../../crates/services/services-core", features = ["session-recovery"] } openbitfun-legacy-migration-adapters = { path = "../../crates/services/legacy-migration-adapters" } openbitfun-core-types = { path = "../../crates/contracts/core-types" } openbitfun-legacy-migration = { path = "../../crates/services/legacy-migration" } diff --git a/src/apps/data-migrator/README.md b/src/apps/data-tools/README.md similarity index 63% rename from src/apps/data-migrator/README.md rename to src/apps/data-tools/README.md index 00d10b49d8..4682c8ab91 100644 --- a/src/apps/data-migrator/README.md +++ b/src/apps/data-tools/README.md @@ -1,27 +1,71 @@ -# OpenBitFun Data Migrator +# OpenBitFun Data Tools [中文](README.zh-CN.md) -A separate, optional desktop utility for importing old **BitFun** data into -**OpenBitFun**. +A separate, optional offline desktop utility. Its home page offers three tools, +in order: **Data migration**, **Reset OpenBitFun data**, and **Session repair**. +Choose a tool to enter its workflow; return home when idle to choose another. +Migration imports legacy BitFun data. Reset permanently clears the selected +OpenBitFun destination when you need a clean start. Session repair restores +sessions affected by earlier data or runtime-directory migrations. +The GUI repair engine is native Rust; Python is not required. + +## Session repair + +Open **Session repair**, confirm the OpenBitFun home and config roots, and select +**Scan and prepare repair plan**. A legacy BitFun install is not required. +Review directory moves, assistant identity field changes and skipped items. +Then close all product writers and select **Apply / resume repair**. + +Repair moves entire session directories into the registered workspace runtime, +repairs proven old named-assistant IDs after the `.bitfun` rename, copies shared +snapshot dependencies without removing originals, rebuilds visible session +indexes, and invalidates rebuildable search caches. Local persisted SSH mirrors +are included without connecting to SSH. IDs absent from older sessions remain +absent; a unique registered path must prove their owner. Ambiguous/unregistered +workspaces, conflicting targets, malformed neighbors and missing snapshot +references are skipped with reasons. This tool does not change permission modes. + +Plans, full-session backups and durable journals are stored beneath the existing +tool app-config directory in `session-repairs/`, outside product data. +The UI shows that path. **Saved repair tasks** survive tool restarts; review one +to resume an interrupted repair or select **Roll back / resume rollback**. If +product data changed after planning or repair, execution/rollback refuses to +replace it; keep backups and review the diagnostic instead of resetting data. +Cancellation and window close stop at safe boundaries; close again once idle. +Backups include conversation content: keep them private and retain them until +sessions are verified. The standalone standard-library Python alternative is +[documented separately](../../../scripts/recovery/repair-session-storage.md); +its task files are independent of GUI task files. +Both tools share `.session-storage-repair.lock`. After a hard kill, close all +repair/product writers before manually removing that stale sentinel to resume. + +The product and executable are **OpenBitFun Data Tools** and +`openbitfun-data-tools`. New releases use `data-tools-v` tags. The +migration history remains readable. Tool settings use the new +`com.openbitfun.data-tools` identity; previous tool locations and language +preferences are not imported. Historical `data-migrator-v` tags remain +supported; release signing secrets use the `DATA_TOOLS_SIGNING_*` prefix. +Existing v0.1.2 downloads below predate the Session repair feature; build this +source or use a subsequent release to access it. ## Download and run -**Latest release: [v0.1.2 — download Data Migrator](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.2).** +**Historical migration-only release: [v0.1.2 — download Data Migrator](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.2).** | Platform | Download | Launch | | --- | --- | --- | -| Windows x64 | `openbitfun-data-migrator-v-windows-x64.zip` | Extract, then double-click `openbitfun-data-migrator.exe` | -| macOS Apple Silicon | `openbitfun-data-migrator-v-macos-arm64.dmg` | Open the DMG and its Data Migrator app | -| macOS Intel | `openbitfun-data-migrator-v-macos-x64.dmg` | Open the DMG and its Data Migrator app | -| Linux x64 | `openbitfun-data-migrator-v-linux-x64.AppImage` | Make executable and launch in a desktop session | +| Windows x64 | `openbitfun-data-tools-v-windows-x64.zip` | Extract, then double-click `openbitfun-data-tools.exe` | +| macOS Apple Silicon | `openbitfun-data-tools-v-macos-arm64.dmg` | Open the DMG and its Data Tools app | +| macOS Intel | `openbitfun-data-tools-v-macos-x64.dmg` | Open the DMG and its Data Tools app | +| Linux x64 | `openbitfun-data-tools-v-linux-x64.AppImage` | Make executable and launch in a desktop session | Windows needs the Microsoft Edge WebView2 runtime. macOS uses the system WebView; Linux packages are built on Ubuntu 22.04. No login or network connection is needed for migration. ARM Windows/Linux packages are not currently produced. 1. Close BitFun, OpenBitFun, their CLI instances, and background data writers. -2. Open Data Migrator. Check the **source and destination** directories. +2. Open Data Tools, choose **Data migration**, and check the **source and destination** directories. 3. Select the data groups, scan, then run the preflight plan. 4. Review the destination, scope, and conflicts, then start migration. If known writers remain open, the tool waits for them to stop; it does not terminate them. @@ -79,7 +123,7 @@ never replace earlier journals. Old handoff-based plans remain readable even if their `request.json` has expired; select the original locations before resuming them. Unreadable files are not deleted or reset. -The tool remembers selected locations in its own `com.openbitfun.data-migrator` +The tool remembers selected locations in its own `com.openbitfun.data-tools` application configuration directory. It does not write main-app onboarding or reminder preferences. **Export failure diagnostics** writes a sanitized file containing result codes and journal phases. Review personal information before @@ -98,16 +142,16 @@ If you have already launched OpenBitFun or run a migration, existing destination If OpenBitFun contains no new data you need to keep, you can clear its destination data and retry: -1. Fully quit BitFun, OpenBitFun, their CLI/background writers, and other migrator instances. -2. Open Data Migrator and check the destination locations. Under **Reset OpenBitFun data**, select **Review reset directories**. +1. Fully quit BitFun, OpenBitFun, their CLI/background writers, and other Data Tools instances. +2. Open Data Tools, choose **Reset OpenBitFun data** on the home page, check the destination locations, and select **Review reset directories**. 3. Save any data and migration logs/backups you need from the listed directories. **Reset permanently removes all their contents, including existing OpenBitFun settings, credentials, assistant workspaces, and sessions.** It does not create a backup. 4. Type `RESET`, then select **Permanently reset OpenBitFun data**. If a directory cannot be fully removed, close applications using it and retry before migrating. -5. Scan again and start a new migration. Launch OpenBitFun after migration finishes. +5. Return to the home page, choose **Data migration**, and start a new migration. Launch OpenBitFun after migration finishes. The reset preview uses the selected destination locations. For standard locations, it also covers platform Skills/SSH parent directories and Desktop WebView/UI data under `com.openbitfun.desktop`. It preserves the original BitFun source, the -migrator's preferences, and ordinary project directories outside the listed roots. +tool's preferences, and ordinary project directories outside the listed roots. Unsafe overlaps and linked reset paths are rejected. Windows destination directories: @@ -128,7 +172,7 @@ Always review the actual paths shown by the tool before confirming. Open a report in [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) or share your feedback in the OpenBitFun user WeChat group. Include: -- Your operating system and the BitFun, OpenBitFun, and migrator versions. +- Your operating system and the BitFun, OpenBitFun, and Data Tools versions. - Steps to reproduce, the expected result, and the actual result. - The workspace and session name or ID associated with missing data. - Migration logs from the affected run. @@ -149,32 +193,33 @@ From the repository root with Rust, Node, pnpm and the platform's Tauri build pr ```bash pnpm install -pnpm run data-migrator:dev # independent window; no Desktop or dev server -pnpm run data-migrator:build # independent release bundle -cargo build -p openbitfun-data-migrator --bin openbitfun-data-migrator +pnpm run data-tools:dev # independent window; no Desktop or dev server +pnpm run data-tools:build # independent release bundle +cargo build -p openbitfun-data-tools --bin openbitfun-data-tools ``` Direct Cargo builds embed the committed UI and design-system CSS. After changing -the token/theme owners run `pnpm run data-migrator:theme:generate`; the packaging +the token/theme owners run `pnpm run data-tools:theme:generate`; the packaging entry does this automatically. Desktop development/build commands do not build -the migrator. Shared Rust crates remain in the same source workspace to preserve +Data Tools. Shared Rust crates remain in the same source workspace to preserve storage compatibility; there is no dependency on the main application's Core, runtime assembly, Web UI, installer or updater. The tool version is maintained in its own `Cargo.toml` and `tauri.conf.json`. -The **Data Migrator Package** workflow builds four platform artifacts manually -or on `data-migrator-v` tags. Tag builds require the separate -`DATA_MIGRATOR_SIGNING_PRIVATE_KEY`, `DATA_MIGRATOR_SIGNING_PRIVATE_KEY_PASSWORD` -and `DATA_MIGRATOR_SIGNING_PUBKEY` secrets, verify checksums/signatures, and create +The **Data Tools Package** workflow builds four platform artifacts manually +or on `data-tools-v` tags (historical `data-migrator-v` tags remain supported). Tag builds require the separate +`DATA_TOOLS_SIGNING_PRIVATE_KEY`, `DATA_TOOLS_SIGNING_PRIVATE_KEY_PASSWORD` +and `DATA_TOOLS_SIGNING_PUBKEY` secrets, verify checksums/signatures, and create a **draft** release for review. Manual workflow runs only upload CI artifacts. -Publishing migrator releases does not start main-app packaging or update feeds. +Publishing Data Tools releases does not start main-app packaging or update feeds. When publishing a new version, update the latest-release links in both README files on the default branch and review [RELEASE.md](RELEASE.md), the release -description template used by the workflow. Keep guide paths stable for shared links. +description template used by the workflow. Source and guide paths live under +`src/apps/data-tools`. Each asset has a SHA-256 sidecar and a base64-encoded minisign `.sig`; the -release also carries `SHA256SUMS` and `data-migrator.minisign.pub`. Verify the key +release also carries `SHA256SUMS` and `data-tools.minisign.pub`. Verify the key against the maintainer's trusted key before checking signatures. Detached signatures are distinct from Apple/Authenticode platform signing; the workflow does not currently configure those certificates or macOS notarization. diff --git a/src/apps/data-migrator/README.zh-CN.md b/src/apps/data-tools/README.zh-CN.md similarity index 58% rename from src/apps/data-migrator/README.zh-CN.md rename to src/apps/data-tools/README.zh-CN.md index a70f0e2289..8fc0c25f51 100644 --- a/src/apps/data-migrator/README.zh-CN.md +++ b/src/apps/data-tools/README.zh-CN.md @@ -1,28 +1,59 @@ -# OpenBitFun 独立数据迁移器 +# OpenBitFun 数据工具 [English](README.md) -这是一个可选的独立桌面工具,用于将旧版 **BitFun** 数据导入 **OpenBitFun**。 +这是一个可选的离线桌面工具。首页依次提供三个并列入口:**数据迁移**、 +**重置 OpenBitFun 数据**、**会话修复**。点击后进入独立流程,空闲时可返回首页。 +数据迁移用于导入旧 BitFun 数据;重置用于清空目标数据后重新开始; +会话修复用于处理此前数据或运行目录迁移造成的会话异常。 + +## 会话修复 + +“数据迁移”“重置 OpenBitFun 数据”和“会话修复”是并列功能。无需旧 BitFun 安装即可打开会话修复, +确认 OpenBitFun 主目录和配置目录后,点击“扫描并生成修复计划”。扫描只读取产品数据。 +查看会话目录归位、助理身份字段修改及跳过原因,再关闭产品写入进程并执行修复。 +GUI 使用原生 Rust 后端,不需要安装 Python。 + +修复保留整个会话目录,处理已证实的旧命名助理 ID,复制所需共享快照而不删除原件, +重建会话索引并清理可重建的搜索缓存。支持本机保存的远程 SSH 会话镜像,无需连接远程主机。 +旧会话缺失 ID 时仅通过唯一登记路径判断归属,不补写 ID;归属不明、目标冲突、 +邻居元数据损坏或快照依赖缺失会显示原因并跳过。工具不修改会话权限模式。 + +计划、完整会话备份和恢复记录位于工具原有应用配置目录的 `session-repairs/<任务 ID>` 下, +与产品数据分离;界面会显示完整路径。重启工具后从“已保存的修复任务”查看任务,可继续修复 +或回滚。取消和关闭请求在安全边界生效,任务停止后再次关闭窗口即可退出。 +若计划生成后或修复后数据发生变化,工具会拒绝替换,请保留备份并查看诊断,避免重置数据。 +备份包含会话内容,请妥善保管并在验证恢复前保留。 +[独立 Python 脚本](../../../scripts/recovery/repair-session-storage.md)仍可使用, +与 GUI 使用各自独立的计划和恢复记录格式。两者共享 `.session-storage-repair.lock` 锁。 +异常强制退出后,请先确认所有修复工具及产品写入进程已停止,再手动删除残留锁并继续。 + +产品名改为 **OpenBitFun 数据工具**,可执行文件为 `openbitfun-data-tools`, +新发布使用 `data-tools-v<版本>` 标签。保留原有迁移历史;工具配置使用新的 +`com.openbitfun.data-tools` 身份,不导入旧工具的位置和语言偏好。 +兼容历史 `data-migrator-v` 标签;签名配置使用 `DATA_TOOLS_SIGNING_*` 前缀。 +下方现有 v0.1.2 下载不包含新修复功能, +请构建当前源码或使用后续发布版本。 ## 下载和使用 -**最新版本:[v0.1.2 — 下载数据迁移器](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.2)。** +**历史版本(仅支持迁移):[v0.1.2 — 下载数据迁移器](https://github.com/GCWing/OpenBitFun/releases/tag/data-migrator-v0.1.2)。** | 系统 | 下载文件 | 启动方式 | | --- | --- | --- | -| Windows x64 | `openbitfun-data-migrator-v<版本>-windows-x64.zip` | 解压后双击 `openbitfun-data-migrator.exe` | -| macOS Apple Silicon | `openbitfun-data-migrator-v<版本>-macos-arm64.dmg` | 打开 DMG 中的迁移器 | -| macOS Intel | `openbitfun-data-migrator-v<版本>-macos-x64.dmg` | 打开 DMG 中的迁移器 | -| Linux x64 | `openbitfun-data-migrator-v<版本>-linux-x64.AppImage` | 添加可执行权限,在桌面会话中启动 | +| Windows x64 | `openbitfun-data-tools-v<版本>-windows-x64.zip` | 解压后双击 `openbitfun-data-tools.exe` | +| macOS Apple Silicon | `openbitfun-data-tools-v<版本>-macos-arm64.dmg` | 打开 DMG 中的数据工具 | +| macOS Intel | `openbitfun-data-tools-v<版本>-macos-x64.dmg` | 打开 DMG 中的数据工具 | +| Linux x64 | `openbitfun-data-tools-v<版本>-linux-x64.AppImage` | 添加可执行权限,在桌面会话中启动 | Windows 需要 Microsoft Edge WebView2;macOS 使用系统 WebView,Linux 包以 Ubuntu 22.04 为构建基线。迁移不需要登录或联网,目前不产出 Windows/Linux ARM 安装包。 1. 关闭 BitFun、OpenBitFun、CLI 实例及其后台数据写入进程。 -2. 启动迁移器,检查**来源和目标目录**。 +2. 启动数据工具,选择“数据迁移”,检查**来源和目标目录**。 3. 选择迁移范围,扫描数据,再运行预检。 4. 确认目标、范围和冲突后开始迁移。发现已知写入进程时会等待其退出,不会强制终止进程。 -5. 查看结果,根据提示重新登录或修复路径,关闭迁移器,再自行打开 OpenBitFun。 +5. 查看结果,根据提示重新登录或修复路径,关闭数据工具,再自行打开 OpenBitFun。 ## 支持范围与数据保护 @@ -53,17 +84,17 @@ Windows 需要 Microsoft Edge WebView2;macOS 使用系统 WebView,Linux 包 <目标设置与数据目录>/data/migrations/bitfun-to-openbitfun/runs/<任务 ID>/ ``` -重新打开迁移器,选择原来的目录,在“历史迁移任务”中查看或恢复。恢复会校验计划、 +重新打开数据工具,选择原来的目录,在“历史迁移任务”中查看或恢复。恢复会校验计划、 来源指纹和原目录,并沿用日志,不受原来十分钟交接请求有效期限制。 已完成任务仍可查看报告;新扫描会创建新任务,不会覆盖旧日志。 旧版交接流程留下的计划与日志仍可读取,即使 `request.json` 已过期;恢复前须选择原目录。 无法读取的任务文件不会被删除或重置。 -迁移器只在自己的 `com.openbitfun.data-migrator` 配置目录中记住所选位置,不写主应用的 +数据工具只在自己的 `com.openbitfun.data-tools` 配置目录中记住所选位置,不写主应用的 引导或提醒状态。“导出失败诊断”生成包含结果码和执行阶段的去敏文件。 完整报告与数据目录可能含个人信息;反馈时请按下文说明检查内容,并自行选择是否提供数据目录。 -迁移器只操作运行电脑可访问的文件,不接入远程工作区执行、远程控制、Peer Device Mode +数据工具只操作运行电脑可访问的文件,不接入远程工作区执行、远程控制、Peer Device Mode 或 Detached Dispatch。请在数据所在电脑上运行;迁移连接记录并不连接或迁移远端主机。 ## 异常排障 @@ -74,15 +105,15 @@ Windows 需要 Microsoft Edge WebView2;macOS 使用系统 WebView,Linux 包 如果 OpenBitFun 中没有需要保留的新数据,可以清空目标数据后重新迁移: -1. 完全退出 BitFun、OpenBitFun、CLI 及后台写入进程,以及其他迁移器实例。 -2. 打开迁移器,检查目标位置,在“重置 OpenBitFun 数据”中点击“查看待重置目录”。 +1. 完全退出 BitFun、OpenBitFun、CLI 及后台写入进程,以及其他数据工具实例。 +2. 打开数据工具,在首页选择“重置 OpenBitFun 数据”,检查目标位置,再点击“查看待重置目录”。 3. 另存列表目录中需要保留的数据、迁移日志及备份。**重置会永久删除这些目录的全部内容,包括 OpenBitFun 配置、凭据、助理工作区和会话,不会自动备份。** 4. 输入 `RESET`,点击“永久重置 OpenBitFun 数据”。如果部分目录未能完全删除,请关闭占用应用并重试,再进行迁移。 -5. 重新扫描并开始新迁移,完成后再启动 OpenBitFun。 +5. 返回首页,选择“数据迁移”并开始新迁移,完成后再启动 OpenBitFun。 重置预览以所选目标位置为准;使用标准位置时,还会包含系统 Skills/SSH 所属的 OpenBitFun 目录,以及 `com.openbitfun.desktop` 下的桌面 WebView/UI 数据。 -旧 BitFun 来源、迁移器自身配置和列表之外的普通项目目录会保留。 +旧 BitFun 来源、数据工具自身配置和列表之外的普通项目目录会保留。 危险目录重叠、带符号链接或 junction 的重置路径会被拒绝。 Windows 目标目录: @@ -97,13 +128,13 @@ macOS 标准目录为 `~/.openbitfun` 和 `~/Library/Application Support/{openbitfun,OpenBitFun}`;Linux 为 `~/.openbitfun`、`~/.config/openbitfun` 和 `~/.local/share/{openbitfun,OpenBitFun}`(或对应的 XDG 目录)。 -确认前请以迁移器显示的实际路径为准。 +确认前请以数据工具显示的实际路径为准。 **重试后仍然异常** 请在 [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) 提交问题,或在 OpenBitFun 用户微信群中反馈,并提供: -- 操作系统、BitFun 版本、OpenBitFun 版本和迁移器版本。 +- 操作系统、BitFun 版本、OpenBitFun 版本和数据工具版本。 - 操作步骤、预期结果及实际结果。 - 缺失数据所属的工作区、会话名称或 ID。 - 对应运行的迁移日志。 @@ -124,28 +155,28 @@ Windows 迁移日志目录: ```bash pnpm install -pnpm run data-migrator:dev # 独立窗口,无需主应用或开发服务器 -pnpm run data-migrator:build # 独立发行包 -cargo build -p openbitfun-data-migrator --bin openbitfun-data-migrator +pnpm run data-tools:dev # 独立窗口,无需主应用或开发服务器 +pnpm run data-tools:build # 独立发行包 +cargo build -p openbitfun-data-tools --bin openbitfun-data-tools ``` 直接 Cargo 构建使用已提交的静态 UI 和设计系统 CSS。修改主题源后运行 -`pnpm run data-migrator:theme:generate`;独立打包入口会自动生成。 -主应用开发和构建不再构建迁移器。两者仍在同一源码工作区共享稳定的数据契约和存储模块, -以保证格式一致,但迁移器不依赖主应用 Core、运行时组装、Web UI、安装器或更新器。 - -版本由迁移器自己的 `Cargo.toml` 和 `tauri.conf.json` 维护。**Data Migrator Package** -工作流支持手动构建,或通过 `data-migrator-v<版本>` 标签生成独立发布草稿。 -标签发布使用专用的 `DATA_MIGRATOR_SIGNING_PRIVATE_KEY`、 -`DATA_MIGRATOR_SIGNING_PRIVATE_KEY_PASSWORD`、`DATA_MIGRATOR_SIGNING_PUBKEY`, +`pnpm run data-tools:theme:generate`;独立打包入口会自动生成。 +主应用开发和构建不再构建数据工具。两者仍在同一源码工作区共享稳定的数据契约和存储模块, +以保证格式一致,但数据工具不依赖主应用 Core、运行时组装、Web UI、安装器或更新器。 + +版本由数据工具自己的 `Cargo.toml` 和 `tauri.conf.json` 维护。**Data Tools Package** +工作流支持手动构建,或通过 `data-tools-v<版本>` 标签(兼容历史 `data-migrator-v` 标签)生成独立发布草稿。 +标签发布使用专用的 `DATA_TOOLS_SIGNING_PRIVATE_KEY`、 +`DATA_TOOLS_SIGNING_PRIVATE_KEY_PASSWORD`、`DATA_TOOLS_SIGNING_PUBKEY`, 完成签名和校验后创建草稿,审核后再发布。手动运行只生成 CI 构建产物。 -迁移器发布不会触发主应用打包或更新源。 +数据工具发布不会触发主应用打包或更新源。 发布新版本时,同步更新默认分支上两份 README 顶部的最新版本链接,并检查工作流使用的 [RELEASE.md](RELEASE.md) 发布描述模板。保持指南路径不变,让用户分享的链接持续有效。 每个产物带 SHA-256 校验文件和 base64 编码的 minisign `.sig`,同时提供 -`SHA256SUMS` 与 `data-migrator.minisign.pub`。验证签名前应通过可信渠道确认公钥。 +`SHA256SUMS` 与 `data-tools.minisign.pub`。验证签名前应通过可信渠道确认公钥。 独立文件签名不等于 Apple/Authenticode 系统代码签名;当前工作流尚未配置这些证书及 macOS 公证。 开发约束和针对性检查见 [AGENTS.md](AGENTS.md)。 diff --git a/src/apps/data-migrator/RELEASE.md b/src/apps/data-tools/RELEASE.md similarity index 55% rename from src/apps/data-migrator/RELEASE.md rename to src/apps/data-tools/RELEASE.md index 4163f8e720..4ba601428a 100644 --- a/src/apps/data-migrator/RELEASE.md +++ b/src/apps/data-tools/RELEASE.md @@ -1,10 +1,27 @@ -# BitFun → OpenBitFun Data Migrator +# OpenBitFun Data Tools -将旧版 BitFun 的配置、会话、工作区及其他受支持数据迁移到 OpenBitFun。迁移器保留旧版数据,不会自动删除 BitFun 的数据目录。 +OpenBitFun 数据工具提供数据迁移、数据重置和会话修复。数据迁移保留旧版 BitFun 数据,不会自动删除 BitFun 的数据目录。 -Migrate settings, sessions, workspaces, and other supported data from BitFun to OpenBitFun. The migrator preserves the original BitFun data and does not automatically delete its data directories. +OpenBitFun Data Tools provides data migration, data reset and session repair. Migration preserves the original BitFun data and does not automatically delete its data directories. -最新版本与使用指南:[中文](https://github.com/GCWing/OpenBitFun/blob/main/src/apps/data-migrator/README.zh-CN.md) · Latest release and guide: [English](https://github.com/GCWing/OpenBitFun/blob/main/src/apps/data-migrator/README.md) +首页提供“数据迁移”“重置 OpenBitFun 数据”“会话修复”三个独立入口,按需进入流程。 +重置用于清空目标后重新开始,会永久删除所选 OpenBitFun 数据。 + +新增并列的“会话修复”功能:扫描迁移造成的会话目录与助理身份异常,查看计划后执行。 +包含本机保存的远程会话镜像,提供验证后的完整备份、任务恢复和回滚;无需 Python。 + +The home page offers **Data migration**, **Reset OpenBitFun data**, and +**Session repair**, each with its own workflow. Reset permanently removes the +selected OpenBitFun data when you need a clean start. + +**Session repair** is a parallel tool for migration-damaged session locations and +assistant identities, including local remote-workspace mirrors. Scan and review +before applying; verified full backups, durable resume and rollback are included. +The GUI uses native Rust and does not require Python. Older migration tasks and +the existing bundle identity remain compatible. The executable is now +`openbitfun-data-tools`; build with `pnpm run data-tools:build`. + +最新版本与使用指南:[中文](https://github.com/GCWing/OpenBitFun/blob/main/src/apps/data-tools/README.zh-CN.md) · Latest release and guide: [English](https://github.com/GCWing/OpenBitFun/blob/main/src/apps/data-tools/README.md) ## 中文 @@ -16,10 +33,10 @@ Migrate settings, sessions, workspaces, and other supported data from BitFun to ### 如何使用 -1. 下载本 Release 中适合你系统的迁移器,完成解压(如有)。Windows 用户解压 ZIP 后双击迁移器运行。 +1. 下载本 Release 中适合你系统的数据工具,完成解压(如有)。Windows 用户解压 ZIP 后双击数据工具运行。 2. 完全退出 BitFun 和 OpenBitFun。 -3. 使用原来运行 BitFun 的系统用户启动迁移器。 -4. 按界面提示确认来源和目标目录,选择需要迁移的数据,然后开始迁移。 +3. 使用原来运行 BitFun 的系统用户启动数据工具。 +4. 在首页选择“数据迁移”,按提示确认来源和目标目录,选择需要迁移的数据,然后开始迁移。 5. 迁移完成后,检查结果中的警告和跳过项,再启动 OpenBitFun 确认数据。 建议迁移前备份数据,并在确认迁移结果前保留旧版 BitFun 数据。中断的迁移可以从已保存的迁移任务中恢复。 @@ -32,9 +49,9 @@ Migrate settings, sessions, workspaces, and other supported data from BitFun to 如果 OpenBitFun 中没有需要保留的新数据,可以清空目标数据后重新迁移: -1. 完全退出 BitFun、OpenBitFun 和迁移器。 -2. 备份以下目录,然后删除它们。**这会清除 OpenBitFun 的现有配置、会话及其他本地数据。** -3. 重新运行迁移器,完成后再启动 OpenBitFun。 +1. 完全退出 BitFun、OpenBitFun、CLI 和后台写入进程。 +2. 在数据工具首页选择“重置 OpenBitFun 数据”,检查目标位置,查看待重置目录并另存需要保留的数据和日志。**重置会永久清除现有配置、会话及其他本地数据,不会自动备份。** +3. 输入 `RESET` 并确认重置,再返回首页选择“数据迁移”。完成后再启动 OpenBitFun。 Windows 目标目录: @@ -48,7 +65,7 @@ Windows 目标目录: 请在 [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) 提交问题,或在 OpenBitFun 用户微信群中反馈,并提供: -- 操作系统、BitFun 版本、OpenBitFun 版本和迁移器版本。 +- 操作系统、BitFun 版本、OpenBitFun 版本和数据工具版本。 - 操作步骤、预期结果及实际结果。 - 缺失数据所属的工作区、会话名称或 ID。 - 对应运行的迁移日志。 @@ -65,7 +82,7 @@ Windows 迁移日志目录: ### 下载校验 -每个安装包均附带 SHA-256 校验文件和 minisign 分离签名。该发布流程未配置平台代码签名或 macOS 公证。兼容性、恢复及签名校验说明请参阅本 Release 标签下的迁移器 README。 +每个安装包均附带 SHA-256 校验文件和 minisign 分离签名。该发布流程未配置平台代码签名或 macOS 公证。兼容性、恢复及签名校验说明请参阅本 Release 标签下的数据工具 README。 ## English @@ -77,10 +94,10 @@ Windows 迁移日志目录: ### How to use -1. Download the migrator for your system from this release and extract it if necessary. On Windows, extract the ZIP and double-click the migrator. +1. Download the data tool for your system from this release and extract it if necessary. On Windows, extract the ZIP and double-click the data tool. 2. Fully quit BitFun and OpenBitFun. -3. Run the migrator under the same system account you used for BitFun. -4. Follow the prompts to confirm the source and destination directories, select the data to migrate, and start migration. +3. Run the data tool under the same system account you used for BitFun. +4. Choose **Data migration** on the home page, confirm the source and destination directories, select the data to migrate, and start migration. 5. Review any warnings or skipped items, then launch OpenBitFun and check your data. Back up your data before migrating. Keep the original BitFun data until you have verified the results. Interrupted runs can be resumed from saved migration tasks. @@ -93,9 +110,9 @@ If you have already launched OpenBitFun or run a migration, existing destination If OpenBitFun contains no new data you need to keep, you can clear its destination data and retry: -1. Fully quit BitFun, OpenBitFun, and the migrator. -2. Back up the following directories, then delete them. **This removes existing OpenBitFun settings, sessions, and other local data.** -3. Run the migrator again, then launch OpenBitFun after migration finishes. +1. Fully quit BitFun, OpenBitFun, their CLI instances and background writers. +2. Choose **Reset OpenBitFun data** on the tool's home page, check the destination locations and review reset directories. Save any data and logs you need. **Reset permanently removes existing settings, sessions and other local data without creating a backup.** +3. Type `RESET` and confirm. Return to the home page and choose **Data migration**, then launch OpenBitFun after migration finishes. Windows destination directories: @@ -109,7 +126,7 @@ Windows destination directories: Open a report in [GitHub Issues](https://github.com/GCWing/OpenBitFun/issues) or share your feedback in the OpenBitFun user WeChat group. Include: -- Your operating system and the BitFun, OpenBitFun, and migrator versions. +- Your operating system and the BitFun, OpenBitFun, and Data Tools versions. - Steps to reproduce, the expected result, and the actual result. - The workspace and session name or ID associated with missing data. - Migration logs from the affected run. @@ -126,4 +143,4 @@ Review the logs for personal information, such as usernames and paths, and redac ### Download verification -Each package includes a SHA-256 checksum file and a detached minisign signature. Platform code signing and macOS notarization are not configured by this release workflow. See the migrator README at this release tag for compatibility, recovery, and signature verification details. +Each package includes a SHA-256 checksum file and a detached minisign signature. Platform code signing and macOS notarization are not configured by this release workflow. See the data tool README at this release tag for compatibility, recovery, and signature verification details. diff --git a/src/apps/data-migrator/build.rs b/src/apps/data-tools/build.rs similarity index 83% rename from src/apps/data-migrator/build.rs rename to src/apps/data-tools/build.rs index 2b49de0736..a779d2bcbd 100644 --- a/src/apps/data-migrator/build.rs +++ b/src/apps/data-tools/build.rs @@ -3,7 +3,7 @@ fn main() { "OPENBITFUN_RELEASE_CHANNEL", "OPENBITFUN_PRODUCT_ID", "OPENBITFUN_DESKTOP_BINARY_NAME", - "OPENBITFUN_DATA_MIGRATOR_BINARY_NAME", + "OPENBITFUN_PRODUCT_BINARY_NAME", ] { println!("cargo:rerun-if-env-changed={name}"); } diff --git a/src/apps/data-migrator/capabilities/migrator.json b/src/apps/data-tools/capabilities/data-tools.json similarity index 73% rename from src/apps/data-migrator/capabilities/migrator.json rename to src/apps/data-tools/capabilities/data-tools.json index 444fec74c0..620f0eea87 100644 --- a/src/apps/data-migrator/capabilities/migrator.json +++ b/src/apps/data-tools/capabilities/data-tools.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "migrator", - "description": "Minimal local capability set for OpenBitFun Data Migrator", - "windows": ["migrator"], + "identifier": "data-tools", + "description": "Minimal local capability set for OpenBitFun Data Tools", + "windows": ["data-tools"], "permissions": [ "core:default", "core:window:allow-close" diff --git a/src/apps/data-migrator/gen/.gitignore b/src/apps/data-tools/gen/.gitignore similarity index 100% rename from src/apps/data-migrator/gen/.gitignore rename to src/apps/data-tools/gen/.gitignore diff --git a/src/apps/data-migrator/icons/openbitfun-app-icon.icns b/src/apps/data-tools/icons/openbitfun-app-icon.icns similarity index 100% rename from src/apps/data-migrator/icons/openbitfun-app-icon.icns rename to src/apps/data-tools/icons/openbitfun-app-icon.icns diff --git a/src/apps/data-migrator/icons/openbitfun-app-icon.ico b/src/apps/data-tools/icons/openbitfun-app-icon.ico similarity index 100% rename from src/apps/data-migrator/icons/openbitfun-app-icon.ico rename to src/apps/data-tools/icons/openbitfun-app-icon.ico diff --git a/src/apps/data-migrator/icons/openbitfun-app-icon.png b/src/apps/data-tools/icons/openbitfun-app-icon.png similarity index 100% rename from src/apps/data-migrator/icons/openbitfun-app-icon.png rename to src/apps/data-tools/icons/openbitfun-app-icon.png diff --git a/src/apps/data-migrator/src/app_state.rs b/src/apps/data-tools/src/app_state.rs similarity index 94% rename from src/apps/data-migrator/src/app_state.rs rename to src/apps/data-tools/src/app_state.rs index 4c0b84d56c..3872ef6cc3 100644 --- a/src/apps/data-migrator/src/app_state.rs +++ b/src/apps/data-tools/src/app_state.rs @@ -13,6 +13,8 @@ use serde::Serialize; use std::path::PathBuf; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Duration; +mod repair; +pub(crate) use repair::RepairView; #[derive(Debug, Clone)] struct TaskRequest { @@ -39,7 +41,7 @@ impl CommandError { pub(crate) fn worker_failed() -> Self { Self::new( "worker_failed", - "The migration worker stopped before returning a result.", + "The data tools worker stopped before returning a result.", true, ) } @@ -47,7 +49,7 @@ impl CommandError { fn operation_in_progress() -> Self { Self::new( "operation_in_progress", - "Another migration operation is still running.", + "Another data tools operation is still running.", true, ) } @@ -66,12 +68,12 @@ impl CommandError { ), LegacyMigrationError::UnsupportedSource(_) => Self::new( "unsupported_source", - "This legacy BitFun data format is not supported by this migrator.", + "This legacy BitFun data format is not supported by Data Tools.", false, ), LegacyMigrationError::UnsupportedTarget(_) => Self::new( "unsupported_target", - "The destination data format is not supported. Use a compatible migrator or an empty destination.", + "The destination data format is not supported. Use a compatible Data Tools version or an empty destination.", true, ), LegacyMigrationError::InvalidRequest(_) => Self::new( @@ -158,7 +160,8 @@ impl CommandError { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct MigratorView { +pub(crate) struct DataToolsView { + pub repair: RepairView, pub tool_version: String, pub locations: MigrationRoots, pub saved_tasks: Vec, @@ -194,7 +197,8 @@ pub(crate) struct DiagnosticsExportView { } #[derive(Debug)] -struct MigratorSession { +struct DataToolsSession { + repair: RepairView, roots: MigrationRoots, request: TaskRequest, recovery: bool, @@ -217,11 +221,11 @@ struct MigratorSession { } #[derive(Clone)] -pub(crate) struct MigratorCoordinator { - session: Arc>, +pub(crate) struct DataToolsCoordinator { + session: Arc>, } -impl MigratorCoordinator { +impl DataToolsCoordinator { pub(crate) fn bootstrap(settings_path: PathBuf) -> LegacyMigrationResult { let defaults = MigrationRoots::current_user_locations()?; let layout = MigrationLayout::new(&defaults, "preferences"); @@ -253,7 +257,8 @@ impl MigratorCoordinator { } }; Self { - session: Arc::new(Mutex::new(MigratorSession { + session: Arc::new(Mutex::new(DataToolsSession { + repair: repair::bootstrap(&roots, &settings_path), roots, settings_path, saved_tasks, @@ -286,7 +291,7 @@ impl MigratorCoordinator { pub(crate) fn set_locations( &self, roots: MigrationRoots, - ) -> Result { + ) -> Result { let mut session = self.lock(); if session.running { return Err(CommandError::operation_in_progress()); @@ -299,7 +304,7 @@ impl MigratorCoordinator { Ok(snapshot(&session)) } - pub(crate) fn new_task(&self) -> Result { + pub(crate) fn new_task(&self) -> Result { let mut session = self.lock(); if session.running { return Err(CommandError::operation_in_progress()); @@ -310,7 +315,7 @@ impl MigratorCoordinator { Ok(snapshot(&session)) } - pub(crate) fn resume_task(&self, run_id: &str) -> Result { + pub(crate) fn resume_task(&self, run_id: &str) -> Result { let mut session = self.lock(); if session.running { return Err(CommandError::operation_in_progress()); @@ -334,12 +339,12 @@ impl MigratorCoordinator { Ok(snapshot(&session)) } - pub(crate) fn snapshot(&self) -> MigratorView { + pub(crate) fn snapshot(&self) -> DataToolsView { let session = self.lock(); snapshot(&session) } - pub(crate) fn preview_reset(&self) -> Result { + pub(crate) fn preview_reset(&self) -> Result { let mut session = self.lock(); if session.running { return Err(CommandError::operation_in_progress()); @@ -360,7 +365,7 @@ impl MigratorCoordinator { &self, confirmation_id: String, confirmation: String, - ) -> Result { + ) -> Result { let (roots, preview, protected) = { let mut session = self.lock(); if session.running { @@ -409,11 +414,11 @@ impl MigratorCoordinator { let result = (|| { blockers = openbitfun_legacy_migration::blocking_writer_processes_for_product( 0, - &["openbitfun-data-migrator"], + &["openbitfun-data-tools", "openbitfun-data-migrator"], ) .map_err(|error| CommandError::from_legacy(&error))?; if !blockers.is_empty() { - return Err(CommandError::new("reset_writers_running", "Close BitFun, OpenBitFun, their CLI/background writers, and other Data Migrator instances, then retry reset.", true)); + return Err(CommandError::new("reset_writers_running", "Close BitFun, OpenBitFun, their CLI/background writers, and other Data Tools instances, then retry reset.", true)); } openbitfun_legacy_migration::reset_target_data(&roots, &preview.directories, &protected) .map_err(|_| CommandError::new("reset_scope_changed", "Reset stopped because the directories failed revalidation. Review the paths again; some data may already have been removed.", true)) @@ -471,7 +476,10 @@ impl MigratorCoordinator { }) } - pub(crate) fn scan(&self, selection: MigrationSelection) -> Result { + pub(crate) fn scan( + &self, + selection: MigrationSelection, + ) -> Result { let (roots, request, cancellation) = self.begin_operation(&selection)?; let coordinator = self.clone(); self.spawn_worker("legacy-migration-scan", move || { @@ -533,7 +541,7 @@ impl MigratorCoordinator { pub(crate) fn prepare( &self, selection: MigrationSelection, - ) -> Result { + ) -> Result { let (roots, request, cancellation) = self.begin_operation(&selection)?; let coordinator = self.clone(); self.spawn_worker("legacy-migration-plan", move || { @@ -596,7 +604,7 @@ impl MigratorCoordinator { } } - pub(crate) fn refresh_blockers(&self) -> Result { + pub(crate) fn refresh_blockers(&self) -> Result { if self.is_running() { return Err(CommandError::operation_in_progress()); } @@ -614,7 +622,7 @@ impl MigratorCoordinator { } } - pub(crate) fn start(&self, plan_hash: String) -> Result { + pub(crate) fn start(&self, plan_hash: String) -> Result { let (roots, request, plan, cancellation) = { let mut session = self.lock(); if session.running { @@ -681,7 +689,7 @@ impl MigratorCoordinator { }) } - pub(crate) fn cancel(&self) -> MigratorView { + pub(crate) fn cancel(&self) -> DataToolsView { let mut session = self.lock(); if session.resetting { return snapshot(&session); @@ -749,7 +757,7 @@ impl MigratorCoordinator { &self, name: &str, worker: impl FnOnce() + Send + 'static, - ) -> Result { + ) -> Result { if std::thread::Builder::new() .name(name.to_string()) .spawn(worker) @@ -758,6 +766,10 @@ impl MigratorCoordinator { let mut session = self.lock(); session.running = false; session.resetting = false; + if session.repair.active { + session.repair.active = false; + session.repair.phase = "failed".into(); + } let error = CommandError::worker_failed(); session.error = Some(error.clone()); return Err(error); @@ -871,7 +883,7 @@ impl MigratorCoordinator { fn finish_error_locked( &self, - session: &mut MigratorSession, + session: &mut DataToolsSession, error: &LegacyMigrationError, ) -> CommandError { session.running = false; @@ -891,7 +903,7 @@ impl MigratorCoordinator { command_error } - fn lock(&self) -> MutexGuard<'_, MigratorSession> { + fn lock(&self) -> MutexGuard<'_, DataToolsSession> { self.session .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -915,21 +927,21 @@ fn reset_protected_directories(settings: &std::path::Path) -> Result Result<(), CommandError Ok(()) } -fn snapshot(session: &MigratorSession) -> MigratorView { +fn snapshot(session: &DataToolsSession) -> DataToolsView { let plan = session.plan.as_ref().map(redact_plan_for_ui); let report = session.report.as_ref().map(redact_report_for_ui); let workspace_counts = session.report.as_ref().and_then(|report| { openbitfun_legacy_migration_adapters::workspace_report_counts(&session.roots, report).ok() }); - MigratorView { + DataToolsView { + repair: session.repair.clone(), tool_version: env!("CARGO_PKG_VERSION").to_string(), locations: session.roots.clone(), saved_tasks: session.saved_tasks.clone(), @@ -1065,7 +1078,7 @@ mod tests { fn starts_without_desktop_request_or_data() { let temp = tempfile::tempdir().unwrap(); let roots = fixture_roots(temp.path()); - let coordinator = MigratorCoordinator::bootstrap_with( + let coordinator = DataToolsCoordinator::bootstrap_with( roots.clone(), temp.path().join("tool/locations.json"), ); @@ -1088,7 +1101,7 @@ mod tests { ) .unwrap(); let coordinator = - MigratorCoordinator::bootstrap_with(roots, temp.path().join("tool/locations.json")); + DataToolsCoordinator::bootstrap_with(roots, temp.path().join("tool/locations.json")); let selection = MigrationSelection::all(); let (roots, request, cancellation) = coordinator.begin_operation(&selection).unwrap(); assert!(coordinator.finish().is_err()); @@ -1104,7 +1117,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let mut roots = fixture_roots(temp.path()); let settings = temp.path().join("tool/locations.json"); - let coordinator = MigratorCoordinator::bootstrap_with(roots.clone(), settings.clone()); + let coordinator = DataToolsCoordinator::bootstrap_with(roots.clone(), settings.clone()); roots.target_home_root = roots.legacy_user_root.join("nested"); assert!(coordinator.set_locations(roots).is_err()); assert!(!settings.exists()); @@ -1119,7 +1132,7 @@ mod tests { let keep = roots.target_user_root.join("keep"); fs::write(&keep, "target").unwrap(); let coordinator = - MigratorCoordinator::bootstrap_with(roots, root.join("tool/locations.json")); + DataToolsCoordinator::bootstrap_with(roots, root.join("tool/locations.json")); assert!(coordinator .start_reset("stale".into(), "RESET".into()) .is_err()); @@ -1146,7 +1159,7 @@ mod tests { let roots = fixture_roots(&root); let settings = root.join("tool/locations.json"); atomic_write_json(&settings, &roots).unwrap(); - let coordinator = MigratorCoordinator::bootstrap_with(roots.clone(), settings.clone()); + let coordinator = DataToolsCoordinator::bootstrap_with(roots.clone(), settings.clone()); fs::create_dir_all(&roots.target_user_root).unwrap(); fs::write(roots.target_user_root.join("keep"), "target").unwrap(); let preview = coordinator.preview_reset().unwrap().reset_preview.unwrap(); diff --git a/src/apps/data-tools/src/app_state/repair.rs b/src/apps/data-tools/src/app_state/repair.rs new file mode 100644 index 0000000000..54d0fc10d7 --- /dev/null +++ b/src/apps/data-tools/src/app_state/repair.rs @@ -0,0 +1,400 @@ +use super::*; +use openbitfun_services_core::session_recovery as recovery; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RepairLocations { + pub home: PathBuf, + pub user_root: PathBuf, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RepairTask { + pub run_id: String, + pub phase: String, + pub readable: bool, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RepairAction { + pub session_name: String, + pub session_id: String, + pub source: PathBuf, + pub target: PathBuf, + pub identity_changes: Vec, + pub snapshot_files: usize, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RepairView { + pub locations: RepairLocations, + pub saved_tasks: Vec, + pub run_id: Option, + pub backup_path: Option, + pub phase: String, + pub actions: Vec, + pub diagnostics: Vec, + pub processed: usize, + pub total: usize, + pub active: bool, + pub current_path: Option, +} +fn tasks_root(settings: &Path) -> PathBuf { + settings.parent().unwrap().join("session-repairs") +} +fn task_path(settings: &Path, run_id: &str) -> Result { + let id = uuid::Uuid::parse_str(run_id) + .map_err(|_| CommandError::new("invalid_repair_task", "Invalid repair task ID.", false))?; + if id.to_string() != run_id { + return Err(CommandError::new( + "invalid_repair_task", + "Invalid repair task ID.", + false, + )); + } + Ok(tasks_root(settings).join(run_id)) +} +fn tasks(settings: &Path) -> Vec { + let mut result = vec![]; + if let Ok(entries) = std::fs::read_dir(tasks_root(settings)) { + for entry in entries.flatten() { + let run_id = entry.file_name().to_string_lossy().to_string(); + if task_path(settings, &run_id).is_err() { + continue; + } + let phase = recovery::read_plan(&entry.path()) + .and_then(|_| recovery::task_phase(&entry.path())); + result.push(RepairTask { + run_id, + readable: phase.is_ok(), + phase: phase.unwrap_or_else(|_| "unreadable".into()), + }); + } + } + result.sort_by(|a, b| b.run_id.cmp(&a.run_id)); + result +} +pub(super) fn bootstrap(roots: &MigrationRoots, settings: &Path) -> RepairView { + let defaults = RepairLocations { + home: roots.target_home_root.clone(), + user_root: roots.target_user_root.clone(), + }; + let locations = std::fs::read(settings.parent().unwrap().join("repair-locations.json")) + .ok() + .and_then(|raw| serde_json::from_slice(&raw).ok()) + .unwrap_or(defaults); + RepairView { + locations, + saved_tasks: tasks(settings), + run_id: None, + backup_path: None, + phase: "idle".into(), + actions: vec![], + diagnostics: vec![], + processed: 0, + total: 0, + active: false, + current_path: None, + } +} +fn update_view( + view: &mut RepairView, + plan: &recovery::RepairPlan, + run_id: String, + path: PathBuf, + phase: String, +) { + view.locations = RepairLocations { + home: plan.home.clone(), + user_root: plan.user_root.clone(), + }; + view.run_id = Some(run_id); + view.backup_path = Some(path); + view.phase = phase; + view.actions = plan + .operations + .iter() + .map(|o| RepairAction { + session_name: o.session_name.clone(), + session_id: o.session_id.clone(), + source: o.source.clone(), + target: o.target.clone(), + identity_changes: o.identity_changes.clone(), + snapshot_files: o.assets.len(), + }) + .collect(); + view.diagnostics = plan.diagnostics.clone(); + view.total = view.actions.len(); + view.processed = 0; + view.current_path = None; +} +fn failure(error: impl std::fmt::Display) -> CommandError { + CommandError::new("session_repair_failed", &error.to_string(), true) +} + +impl DataToolsCoordinator { + pub(crate) fn scan_repair( + &self, + home: PathBuf, + user_root: PathBuf, + ) -> Result { + let (task, run_id, cancel) = { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + if !home.is_absolute() || !user_root.is_absolute() { + return Err(failure("Repair locations must be absolute paths.")); + } + let locations = RepairLocations { + home: home.clone(), + user_root: user_root.clone(), + }; + atomic_write_json( + &session + .settings_path + .parent() + .unwrap() + .join("repair-locations.json"), + &locations, + ) + .map_err(|e| failure(e))?; + let run_id = uuid::Uuid::new_v4().to_string(); + let task = task_path(&session.settings_path, &run_id)?; + session.cancellation = CancellationToken::default(); + session.running = true; + session.error = None; + session.blockers.clear(); + session.progress = None; + session.repair.locations = locations; + session.repair.active = true; + session.repair.phase = "scanning".into(); + session.repair.run_id = None; + session.repair.actions.clear(); + session.repair.diagnostics.clear(); + session.repair.backup_path = None; + (task, run_id, session.cancellation.clone()) + }; + let coordinator = self.clone(); + self.spawn_worker("session-repair-scan", move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let progress = |processed, total, phase: &str, path: &Path| { + let mut session = coordinator.lock(); + session.repair.processed = processed; + session.repair.total = total; + session.repair.phase = phase.into(); + session.repair.current_path = Some(path.into()); + }; + let plan = recovery::scan_with_progress( + &home, + &user_root, + || cancel.is_cancelled(), + progress, + ) + .map_err(failure)?; + recovery::save_plan(&task, &plan).map_err(failure)?; + Ok::<_, CommandError>(plan) + })) + .unwrap_or_else(|_| Err(CommandError::worker_failed())); + let mut session = coordinator.lock(); + if let Ok(plan) = result.as_ref() { + update_view(&mut session.repair, plan, run_id, task, "planned".into()); + } else { + session.repair.phase = if cancel.is_cancelled() { + "cancelled" + } else { + "failed" + } + .into(); + } + session.error = result.err(); + session.running = false; + session.repair.active = false; + session.repair.saved_tasks = tasks(&session.settings_path); + }) + } + pub(crate) fn load_repair(&self, run_id: &str) -> Result { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + let task = task_path(&session.settings_path, run_id)?; + let plan = recovery::read_plan(&task).map_err(failure)?; + let phase = recovery::task_phase(&task).map_err(failure)?; + update_view(&mut session.repair, &plan, run_id.into(), task, phase); + session.error = None; + session.repair.active = false; + Ok(snapshot(&session)) + } + pub(crate) fn execute_repair( + &self, + run_id: &str, + rollback: bool, + ) -> Result { + let (task, cancel) = { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + if session.repair.run_id.as_deref() != Some(run_id) { + return Err(failure( + "Review the selected repair task before continuing.", + )); + } + let task = task_path(&session.settings_path, run_id)?; + let phase = recovery::task_phase(&task).map_err(failure)?; + if rollback && phase == "planned" + || !rollback + && ["rolling_back", "rolled_back", "completed"].contains(&phase.as_str()) + { + return Err(failure( + "This repair task cannot perform the selected action.", + )); + } + if !rollback && session.repair.actions.is_empty() { + return Err(failure("This scan has no repair actions.")); + } + session.cancellation = CancellationToken::default(); + session.running = true; + session.repair.active = true; + session.error = None; + session.progress = None; + session.repair.phase = "waiting_for_processes".into(); + session.repair.processed = 0; + (task, session.cancellation.clone()) + }; + let coordinator = self.clone(); + self.spawn_worker("session-repair-execute", move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + loop { + if cancel.is_cancelled() { + return Err(failure("Repair cancelled at a safe boundary.")); + } + // Includes other Data Tools processes; current PID is ignored. + let blockers = + openbitfun_legacy_migration::blocking_writer_processes_for_product( + 0, + &["openbitfun-data-tools", "openbitfun-data-migrator"], + ) + .map_err(failure)?; + let stopped = blockers.is_empty(); + coordinator.lock().blockers = blockers; + if stopped { + break; + } + std::thread::sleep(Duration::from_millis(500)); + } + let progress = |processed, total, phase: &str| { + let mut session = coordinator.lock(); + session.repair.processed = processed; + session.repair.total = total; + session.repair.phase = phase.into(); + }; + if rollback { + recovery::rollback(&task, || cancel.is_cancelled(), progress).map_err(failure) + } else { + recovery::apply(&task, || cancel.is_cancelled(), progress).map_err(failure) + } + })) + .unwrap_or_else(|_| Err(CommandError::worker_failed())); + let mut session = coordinator.lock(); + session.repair.phase = recovery::task_phase(&task).unwrap_or_else(|_| "failed".into()); + session.error = result.err(); + session.running = false; + session.repair.active = false; + session.repair.saved_tasks = tasks(&session.settings_path); + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn native_scan_creates_durable_task_and_reloads_without_legacy_install() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let mut roots = MigrationRoots::current_user_locations().unwrap(); + roots.legacy_home_root = base.join("legacy-home"); + roots.legacy_user_root = base.join("legacy-config"); + roots.legacy_skills_root = base.join("legacy-skills"); + roots.legacy_ssh_root = base.join("legacy-ssh"); + roots.target_home_root = base.join("home"); + roots.target_user_root = base.join("config"); + roots.target_skills_root = base.join("skills"); + roots.target_ssh_root = base.join("ssh"); + let workspace = base.join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + atomic_write_json(&roots.target_user_root.join("data/workspace_data.json"), &serde_json::json!({"format_version":1,"product_id":"openbitfun","workspaces":{"workspace-1":{"id":"workspace-1","rootPath":workspace,"workspaceKind":"normal"}}})).unwrap(); + let source = roots + .target_home_root + .join("projects/legacy/sessions/session-1"); + let metadata = openbitfun_services_core::session::SessionMetadata::new( + "session-1".into(), + "Fixture".into(), + "agent".into(), + "model".into(), + ); + let mut metadata = serde_json::to_value(metadata).unwrap(); + metadata["workspaceId"] = serde_json::json!("workspace-1"); + metadata["workspacePath"] = serde_json::json!(workspace); + atomic_write_json(&source.join("metadata.json"), &metadata).unwrap(); + let settings = base.join("tool/locations.json"); + let coordinator = DataToolsCoordinator::bootstrap_with(roots.clone(), settings.clone()); + assert!(coordinator.snapshot().source.is_none()); + coordinator + .scan_repair( + roots.target_home_root.clone(), + roots.target_user_root.clone(), + ) + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while coordinator.is_running() { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(10)); + } + let view = coordinator.snapshot(); + assert!(view.error.is_none(), "{:?}", view.error); + assert_eq!(view.repair.phase, "planned"); + assert_eq!(view.repair.actions.len(), 1); + assert!(source.exists()); + assert!(!view.repair.actions[0].target.exists()); + let run_id = view.repair.run_id.unwrap(); + let restarted = DataToolsCoordinator::bootstrap_with(roots, settings); + assert!(restarted + .snapshot() + .repair + .saved_tasks + .iter() + .any(|t| t.run_id == run_id)); + assert_eq!( + restarted.load_repair(&run_id).unwrap().repair.actions.len(), + 1 + ); + } + #[test] + fn repair_available_without_legacy_source_and_shares_operation_guard() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let mut roots = MigrationRoots::current_user_locations().unwrap(); + roots.legacy_home_root = base.join("missing"); + roots.legacy_user_root = base.join("missing-config"); + roots.target_home_root = base.join("home"); + roots.target_user_root = base.join("config"); + let coordinator = + DataToolsCoordinator::bootstrap_with(roots, base.join("tool/locations.json")); + assert!(!coordinator.snapshot().repair.active); + coordinator.lock().running = true; + assert!(coordinator + .scan_repair(base.join("home"), base.join("config")) + .is_err()); + assert!(coordinator.load_repair("../../escape").is_err()); + assert!(coordinator.finish().is_err()); + } + #[test] + fn task_selection_cannot_escape_tool_history() { + assert!(task_path(Path::new("/settings/locations.json"), "../outside").is_err()); + } +} diff --git a/src/apps/data-migrator/src/commands.rs b/src/apps/data-tools/src/commands.rs similarity index 54% rename from src/apps/data-migrator/src/commands.rs rename to src/apps/data-tools/src/commands.rs index 62c4631246..17df1efa09 100644 --- a/src/apps/data-migrator/src/commands.rs +++ b/src/apps/data-tools/src/commands.rs @@ -1,9 +1,46 @@ -use crate::app_state::{CommandError, DiagnosticsExportView, MigratorCoordinator, MigratorView}; +use crate::app_state::{CommandError, DataToolsCoordinator, DataToolsView, DiagnosticsExportView}; use openbitfun_legacy_migration::MigrationRoots; use openbitfun_product_domains::legacy_migration::MigrationSelection; use serde::Deserialize; +use std::path::PathBuf; use tauri::{AppHandle, State}; +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct RepairScanRequest { + pub home: PathBuf, + pub user_root: PathBuf, +} + +#[tauri::command] +pub(crate) fn scan_session_repair( + state: State<'_, DataToolsCoordinator>, + request: RepairScanRequest, +) -> Result { + state.scan_repair(request.home, request.user_root) +} +#[tauri::command] +pub(crate) fn load_session_repair( + state: State<'_, DataToolsCoordinator>, + request: ResumeRequest, +) -> Result { + state.load_repair(&request.run_id) +} +#[tauri::command] +pub(crate) fn start_session_repair( + state: State<'_, DataToolsCoordinator>, + request: ResumeRequest, +) -> Result { + state.execute_repair(&request.run_id, false) +} +#[tauri::command] +pub(crate) fn rollback_session_repair( + state: State<'_, DataToolsCoordinator>, + request: ResumeRequest, +) -> Result { + state.execute_repair(&request.run_id, true) +} + #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct EmptyRequest {} @@ -41,100 +78,100 @@ pub(crate) struct ResetRequest { #[tauri::command] pub(crate) fn preview_openbitfun_reset( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: EmptyRequest, -) -> Result { +) -> Result { let _ = request; state.preview_reset() } #[tauri::command] pub(crate) fn reset_openbitfun_data( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: ResetRequest, -) -> Result { +) -> Result { state.start_reset(request.confirmation_id, request.confirmation) } #[tauri::command] -pub(crate) fn set_migration_locations( - state: State<'_, MigratorCoordinator>, +pub(crate) fn set_data_tools_locations( + state: State<'_, DataToolsCoordinator>, request: LocationsRequest, -) -> Result { +) -> Result { state.set_locations(request.locations) } #[tauri::command] pub(crate) fn new_migration_task( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: EmptyRequest, -) -> Result { +) -> Result { let _ = request; state.new_task() } #[tauri::command] pub(crate) fn resume_migration_task( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: ResumeRequest, -) -> Result { +) -> Result { state.resume_task(&request.run_id) } #[tauri::command] -pub(crate) fn get_migrator_bootstrap( - state: State<'_, MigratorCoordinator>, +pub(crate) fn get_data_tools_bootstrap( + state: State<'_, DataToolsCoordinator>, request: EmptyRequest, -) -> MigratorView { +) -> DataToolsView { let _ = request; state.snapshot() } #[tauri::command] pub(crate) fn scan_legacy_migration( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: SelectionRequest, -) -> Result { +) -> Result { state.scan(request.selection) } #[tauri::command] pub(crate) fn prepare_legacy_migration( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: SelectionRequest, -) -> Result { +) -> Result { state.prepare(request.selection) } #[tauri::command] pub(crate) fn retry_writer_check( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: EmptyRequest, -) -> Result { +) -> Result { let _ = request; state.refresh_blockers() } #[tauri::command] pub(crate) fn start_legacy_migration( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: ExecuteRequest, -) -> Result { +) -> Result { state.start(request.plan_hash) } #[tauri::command] -pub(crate) fn cancel_legacy_migration( - state: State<'_, MigratorCoordinator>, +pub(crate) fn cancel_data_tools_operation( + state: State<'_, DataToolsCoordinator>, request: EmptyRequest, -) -> MigratorView { +) -> DataToolsView { let _ = request; state.cancel() } #[tauri::command] pub(crate) fn export_migration_diagnostics( - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: EmptyRequest, ) -> Result { let _ = request; @@ -142,9 +179,9 @@ pub(crate) fn export_migration_diagnostics( } #[tauri::command] -pub(crate) fn finish_legacy_migration( +pub(crate) fn finish_data_tools( app: AppHandle, - state: State<'_, MigratorCoordinator>, + state: State<'_, DataToolsCoordinator>, request: EmptyRequest, ) -> Result<(), CommandError> { let _ = request; diff --git a/src/apps/data-migrator/src/lib.rs b/src/apps/data-tools/src/lib.rs similarity index 68% rename from src/apps/data-migrator/src/lib.rs rename to src/apps/data-tools/src/lib.rs index 695f6db876..40d039af22 100644 --- a/src/apps/data-migrator/src/lib.rs +++ b/src/apps/data-tools/src/lib.rs @@ -2,7 +2,7 @@ mod app_state; mod commands; mod locations; -use app_state::MigratorCoordinator; +use app_state::DataToolsCoordinator; use std::fmt; use tauri::Manager; @@ -15,8 +15,8 @@ pub enum RunError { impl fmt::Display for RunError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Bootstrap => formatter.write_str("Data Migrator bootstrap failed"), - Self::EventLoop => formatter.write_str("Data Migrator event loop failed"), + Self::Bootstrap => formatter.write_str("Data Tools bootstrap failed"), + Self::EventLoop => formatter.write_str("Data Tools event loop failed"), } } } @@ -27,13 +27,13 @@ pub fn run() -> Result<(), RunError> { tauri::Builder::default() .setup(|app| { let settings = app.path().app_config_dir()?.join("locations.json"); - let coordinator = MigratorCoordinator::bootstrap(settings)?; + let coordinator = DataToolsCoordinator::bootstrap(settings)?; app.manage(coordinator); Ok(()) }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { - let coordinator = window.app_handle().state::(); + let coordinator = window.app_handle().state::(); api.prevent_close(); if coordinator.is_running() { coordinator.cancel(); @@ -45,19 +45,23 @@ pub fn run() -> Result<(), RunError> { } }) .invoke_handler(tauri::generate_handler![ - commands::get_migrator_bootstrap, + commands::scan_session_repair, + commands::load_session_repair, + commands::start_session_repair, + commands::rollback_session_repair, + commands::get_data_tools_bootstrap, commands::preview_openbitfun_reset, commands::reset_openbitfun_data, - commands::set_migration_locations, + commands::set_data_tools_locations, commands::new_migration_task, commands::resume_migration_task, commands::scan_legacy_migration, commands::prepare_legacy_migration, commands::retry_writer_check, commands::start_legacy_migration, - commands::cancel_legacy_migration, + commands::cancel_data_tools_operation, commands::export_migration_diagnostics, - commands::finish_legacy_migration, + commands::finish_data_tools, ]) .run(tauri::generate_context!()) .map_err(|_| RunError::EventLoop) diff --git a/src/apps/data-migrator/src/locations.rs b/src/apps/data-tools/src/locations.rs similarity index 100% rename from src/apps/data-migrator/src/locations.rs rename to src/apps/data-tools/src/locations.rs diff --git a/src/apps/data-migrator/src/main.rs b/src/apps/data-tools/src/main.rs similarity index 80% rename from src/apps/data-migrator/src/main.rs rename to src/apps/data-tools/src/main.rs index 27588eb2a5..b84e1aa320 100644 --- a/src/apps/data-migrator/src/main.rs +++ b/src/apps/data-tools/src/main.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; fn main() -> ExitCode { - match openbitfun_data_migrator_lib::run() { + match openbitfun_data_tools_lib::run() { Ok(()) => ExitCode::SUCCESS, Err(_) => ExitCode::from(1), } diff --git a/src/apps/data-migrator/tauri.conf.json b/src/apps/data-tools/tauri.conf.json similarity index 78% rename from src/apps/data-migrator/tauri.conf.json rename to src/apps/data-tools/tauri.conf.json index 82071ccd23..2d0c53003f 100644 --- a/src/apps/data-migrator/tauri.conf.json +++ b/src/apps/data-tools/tauri.conf.json @@ -1,9 +1,9 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "OpenBitFun Data Migrator", - "version": "0.1.2", - "mainBinaryName": "openbitfun-data-migrator", - "identifier": "com.openbitfun.data-migrator", + "productName": "OpenBitFun Data Tools", + "version": "0.2.0", + "mainBinaryName": "openbitfun-data-tools", + "identifier": "com.openbitfun.data-tools", "build": { "frontendDist": "ui" }, @@ -23,8 +23,8 @@ "app": { "windows": [ { - "label": "migrator", - "title": "OpenBitFun Data Migrator", + "label": "data-tools", + "title": "OpenBitFun Data Tools", "width": 860, "height": 680, "minWidth": 680, diff --git a/src/apps/data-migrator/ui/app.js b/src/apps/data-tools/ui/app.js similarity index 65% rename from src/apps/data-migrator/ui/app.js rename to src/apps/data-tools/ui/app.js index aec26b5f3f..cd23fa876a 100644 --- a/src/apps/data-migrator/ui/app.js +++ b/src/apps/data-tools/ui/app.js @@ -10,7 +10,7 @@ async function loadTranslations() { return catalogs; } catch (error) { const output = document.getElementById('notice'); - output.textContent = 'Data Migrator could not load its language files. Close it and download or rebuild the complete package.'; + output.textContent = 'Data Tools could not load its language files. Close it and download or rebuild the complete package.'; output.hidden = false; document.querySelectorAll('button, select').forEach((node) => { node.disabled = true; }); throw error; @@ -19,7 +19,7 @@ async function loadTranslations() { const translations = await loadTranslations(); -const locale = localStorage.getItem('migrator.language') || (navigator.language.startsWith('zh-TW') || navigator.language.startsWith('zh-HK') +const locale = localStorage.getItem('data-tools.language') || (navigator.language.startsWith('zh-TW') || navigator.language.startsWith('zh-HK') ? 'zh-TW' : navigator.language.startsWith('zh') ? 'zh-CN' : 'en'); const text = translations[locale] || translations.en; @@ -27,6 +27,7 @@ document.documentElement.lang = locale; document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = text[node.dataset.i18n] || translations.en[node.dataset.i18n]; }); +document.querySelectorAll('[data-i18n-aria]').forEach((node) => node.setAttribute('aria-label', text[node.dataset.i18nAria])); const groups = [ ['settings_and_credentials', { @@ -62,6 +63,12 @@ let locationsDirty = false; let locationSnapshot; let scopeSnapshot; let resetConfirmationId; +let activeTool = 'home'; +let repairLocationSnapshot; +let repairLocationsDirty = false; +let resetLocationsDirty = false; +let resetLocationSnapshot; +let requestBusy = false; function format(template, values) { return Object.entries(values).reduce((value, [key, replacement]) => @@ -73,6 +80,7 @@ function show(id, visible = true) { } function setBusy(busy) { + requestBusy = busy; document.querySelectorAll('button').forEach((button) => { button.disabled = busy; }); } @@ -140,6 +148,11 @@ function selection() { function render(view) { current = view; + if (view.repair?.active) activeTool = 'repair'; + show('home-panel', activeTool === 'home'); + show('migration-panel', activeTool === 'migration'); + show('reset-panel', activeTool === 'reset'); + show('repair-panel', activeTool === 'repair'); const resetPreview = view.resetPreview; if (resetConfirmationId !== resetPreview?.confirmationId) { resetConfirmationId = resetPreview?.confirmationId; @@ -166,6 +179,7 @@ function render(view) { ? format(text.sourceFound, { version: source.productVersion }) : text.missing; if (!source) document.getElementById('source-summary').textContent = text.noSource; renderLocations(view.locations); + renderResetLocations(view.locations); document.getElementById('tool-version').textContent = 'v' + view.toolVersion; const tasks = document.getElementById('saved-task'); const previousTask = tasks.value; @@ -228,16 +242,19 @@ function render(view) { output.textContent = ''; output.hidden = true; } - document.querySelectorAll('button, #locations input, #language, #saved-task').forEach((node) => { node.disabled = view.running; }); + document.querySelectorAll('button, #locations input, #repair-panel input, #repair-task, #language, #saved-task').forEach((node) => { node.disabled = view.running; }); document.getElementById('cancel').disabled = !view.running || view.resetting; - document.getElementById('preview-reset').disabled = view.running || locationsDirty; - document.getElementById('reset-confirmation').disabled = view.running || locationsDirty; - document.getElementById('reset-data').disabled = view.running || locationsDirty || !resetPreview + document.querySelectorAll('#reset-locations input').forEach((node) => { node.disabled = view.running; }); + document.getElementById('preview-reset').disabled = view.running || resetLocationsDirty; + document.getElementById('reset-confirmation').disabled = view.running || resetLocationsDirty; + document.getElementById('reset-data').disabled = view.running || resetLocationsDirty || !resetPreview || document.getElementById('reset-confirmation').value !== 'RESET'; document.getElementById('start').disabled = !view.canExecute || locationsDirty; document.getElementById('resume-task').disabled = !tasks.selectedOptions[0] || tasks.selectedOptions[0].disabled || view.running || locationsDirty; for (const id of ['scan', 'prepare']) document.getElementById(id).disabled = view.running || locationsDirty; - if (locationsDirty) notice(text.dirtyLocations, 'info'); + if ((activeTool === 'migration' && locationsDirty) || (activeTool === 'reset' && resetLocationsDirty)) notice(text.dirtyLocations, 'info'); + renderRepair(view); + if (requestBusy) document.querySelectorAll('button').forEach((node) => { node.disabled = true; }); if (view.running && !pollTimer) { pollTimer = window.setInterval(refresh, 500); @@ -247,6 +264,57 @@ function render(view) { } } +function renderRepair(view) { + const repair = view.repair; + const signature = JSON.stringify(repair.locations); + if (signature !== repairLocationSnapshot && !repairLocationsDirty) { + document.getElementById('repair-home').value = repair.locations.home; + document.getElementById('repair-user-root').value = repair.locations.userRoot; + repairLocationSnapshot = signature; + } + const tasks = document.getElementById('repair-task'); + const previous = tasks.value; + tasks.replaceChildren(...repair.savedTasks.map((task) => { + const option = document.createElement('option'); + option.value = task.runId; + option.textContent = task.runId + ' · ' + (text['repairPhase_' + task.phase] || task.phase); + option.disabled = !task.readable; + return option; + })); + if ([...tasks.options].some((o) => o.value === previous)) tasks.value = previous; + show('repair-history', repair.savedTasks.length > 0); + document.getElementById('repair-load').disabled = view.running || !tasks.selectedOptions[0] || tasks.selectedOptions[0].disabled; + document.getElementById('repair-phase').textContent = (text['repairPhase_' + repair.phase] || repair.phase) + + (repair.active && repair.total ? ` · ${repair.processed} / ${repair.total}` : '') + + (repair.active && repair.currentPath ? ` · ${repair.currentPath}` : ''); + document.getElementById('repair-backup').textContent = repair.backupPath ? format(text.repairBackup, { path: repair.backupPath }) : ''; + const actions = repair.actions; + const skipped = repair.diagnostics.filter((d) => d.status === 'skipped'); + const correct = repair.diagnostics.filter((d) => d.status === 'correct').length; + document.getElementById('repair-summary').replaceChildren(...(repair.runId ? [ + row(format(text.repairCount, { count: actions.length }), format(text.repairCorrect, { count: correct })), + row(format(text.repairMoves, { count: actions.filter((a) => a.source !== a.target).length }), + format(text.repairIdentity, { count: actions.filter((a) => a.identityChanges.length).length })), + ] : [])); + document.getElementById('repair-actions').replaceChildren(...actions.map((action) => { + const node = document.createElement('details'); + const summary = document.createElement('summary'); + summary.textContent = action.sessionName + ' · ' + action.sessionId; + node.append(summary, row(text.repairSource, action.source), row(text.repairTarget, action.target)); + for (const change of action.identityChanges) node.append(row(change.file + ' · ' + change.field, `${String(change.before)} → ${String(change.after)}`)); + node.append(row(text.repairSnapshots, String(action.snapshotFiles))); + return node; + })); + show('repair-diagnostics-title', skipped.length > 0); + document.getElementById('repair-diagnostics').replaceChildren(...skipped.map((d) => row(d.source, d.reason))); + show('repair-blockers', repair.active && view.blockers.length > 0); + document.getElementById('repair-blockers').textContent = format(text.blockers, { count: view.blockers.length }); + document.getElementById('repair-start').disabled = view.running || repairLocationsDirty || !actions.length || !['planned', 'prepared', 'indexing'].includes(repair.phase); + document.getElementById('repair-rollback').disabled = view.running || repairLocationsDirty || !['prepared', 'indexing', 'completed', 'rolling_back'].includes(repair.phase); + show('repair-cancel', repair.active); + document.getElementById('repair-cancel').disabled = !repair.active; +} + async function call(command, request = {}) { setBusy(true); let failure; @@ -266,7 +334,7 @@ async function call(command, request = {}) { async function refresh() { try { - render(await invoke('get_migrator_bootstrap', { request: {} })); + render(await invoke('get_data_tools_bootstrap', { request: {} })); } catch (error) { const message = error?.message || (typeof error === 'string' ? error : ''); notice(message || text.bootstrapFailed); @@ -301,20 +369,70 @@ function renderLocations(locations) { } } +function renderResetLocations(locations) { + const signature = JSON.stringify(locations); + if (signature === resetLocationSnapshot || resetLocationsDirty) return; + resetLocationSnapshot = signature; + const host = document.getElementById('reset-locations'); + host.replaceChildren(); + for (const suffix of ['UserRoot', 'HomeRoot', 'SkillsRoot', 'SshRoot']) { + const label = document.createElement('label'); + const input = document.createElement('input'); + input.id = 'reset-target' + suffix; + label.htmlFor = input.id; + label.textContent = text[suffix[0].toLowerCase() + suffix.slice(1)]; + input.type = 'text'; input.value = locations['target' + suffix]; + input.autocomplete = 'off'; input.spellcheck = false; + input.addEventListener('input', () => { resetLocationsDirty = true; if (current) render(current); }); + host.append(label, input); + } +} + document.getElementById('language').value = locale; +function navigate(tool) { + if (!current || current.running || requestBusy) return; + activeTool = tool; + if (current) render(current); +} +for (const tool of ['migration', 'reset', 'repair']) document.getElementById('choose-' + tool).addEventListener('click', () => navigate(tool)); +for (const tool of ['migration', 'reset', 'repair']) document.getElementById('back-' + tool).addEventListener('click', () => navigate('home')); +for (const id of ['repair-home', 'repair-user-root']) document.getElementById(id).addEventListener('input', () => { + repairLocationsDirty = true; if (current) render(current); +}); +document.getElementById('repair-scan').addEventListener('click', async () => { + const result = await call('scan_session_repair', { + home: document.getElementById('repair-home').value.trim(), + userRoot: document.getElementById('repair-user-root').value.trim(), + }); + if (result) { repairLocationsDirty = false; repairLocationSnapshot = undefined; render(result); } +}); +document.getElementById('repair-load').addEventListener('click', async () => { + const result = await call('load_session_repair', { runId: document.getElementById('repair-task').value }); + if (result) { repairLocationsDirty = false; repairLocationSnapshot = undefined; render(result); } +}); +document.getElementById('repair-start').addEventListener('click', () => call('start_session_repair', { runId: current.repair.runId })); +document.getElementById('repair-rollback').addEventListener('click', () => call('rollback_session_repair', { runId: current.repair.runId })); +document.getElementById('repair-cancel').addEventListener('click', () => call('cancel_data_tools_operation')); document.getElementById('language').addEventListener('change', (event) => { - localStorage.setItem('migrator.language', event.target.value); + localStorage.setItem('data-tools.language', event.target.value); window.location.reload(); }); document.getElementById('apply-locations').addEventListener('click', async () => { if (!requireBootstrap()) return; const locations = Object.fromEntries([...document.querySelectorAll('#locations input')].map((input) => [input.id, input.value.trim()])); - const result = await call('set_migration_locations', { locations }); + const result = await call('set_data_tools_locations', { locations }); if (result) { locationsDirty = false; locationSnapshot = undefined; render(result); } }); document.getElementById('resume-task').addEventListener('click', () => call('resume_migration_task', { runId: document.getElementById('saved-task').value })); document.getElementById('new-task').addEventListener('click', () => call('new_migration_task')); document.getElementById('preview-reset').addEventListener('click', () => call('preview_openbitfun_reset')); +document.getElementById('apply-reset-locations').addEventListener('click', async () => { + if (!requireBootstrap()) return; + const locations = { ...current.locations }; + for (const input of document.querySelectorAll('#reset-locations input')) locations[input.id.slice('reset-'.length)] = input.value.trim(); + const result = await call('set_data_tools_locations', { locations }); + if (result) { resetLocationsDirty = false; resetLocationSnapshot = undefined; render(result); } +}); document.getElementById('reset-confirmation').addEventListener('input', () => { if (current) render(current); }); document.getElementById('reset-data').addEventListener('click', () => { if (!current?.resetPreview) return; @@ -323,7 +441,7 @@ document.getElementById('reset-data').addEventListener('click', () => { confirmation: document.getElementById('reset-confirmation').value, }); }); -for (const id of ['close', 'finish']) document.getElementById(id).addEventListener('click', () => call('finish_legacy_migration')); +document.getElementById('finish').addEventListener('click', () => call('finish_data_tools')); document.getElementById('scan').addEventListener('click', () => call('scan_legacy_migration', { selection: selection() })); @@ -334,7 +452,7 @@ document.getElementById('retry-writers').addEventListener('click', () => document.getElementById('start').addEventListener('click', () => call('start_legacy_migration', { planHash: current.plan.planHash })); document.getElementById('cancel').addEventListener('click', () => - call('cancel_legacy_migration')); + call('cancel_data_tools_operation')); document.getElementById('export-diagnostics').addEventListener('click', async () => { setBusy(true); let failure; diff --git a/src/apps/data-migrator/ui/generated/design-system.css b/src/apps/data-tools/ui/generated/design-system.css similarity index 99% rename from src/apps/data-migrator/ui/generated/design-system.css rename to src/apps/data-tools/ui/generated/design-system.css index b74316bd01..4b8b944d71 100644 --- a/src/apps/data-migrator/ui/generated/design-system.css +++ b/src/apps/data-tools/ui/generated/design-system.css @@ -1,4 +1,4 @@ -/* Generated by scripts/generate-data-migrator-theme.mjs. Do not edit. */ +/* Generated by scripts/generate-data-tools-theme.mjs. Do not edit. */ @layer openbitfun.tokens.system, openbitfun.tokens.theme, openbitfun.reset, openbitfun.base, openbitfun.components, openbitfun.overrides; @layer openbitfun.tokens.system { diff --git a/src/apps/data-migrator/ui/index.html b/src/apps/data-tools/ui/index.html similarity index 57% rename from src/apps/data-migrator/ui/index.html rename to src/apps/data-tools/ui/index.html index 6a0e1ff45a..f28c273243 100644 --- a/src/apps/data-migrator/ui/index.html +++ b/src/apps/data-tools/ui/index.html @@ -3,7 +3,7 @@ - OpenBitFun Data Migrator + OpenBitFun Data Tools @@ -12,20 +12,38 @@
-

OpenBitFun maintenance

-

Import data from BitFun

-

- Choose what to bring forward. Your original BitFun data will not be deleted. -

-
- - - +
+
+

OpenBitFun Data Tools

+ +
+
+ + +
+ + + + + -
+ +
diff --git a/src/apps/data-migrator/ui/locales.json b/src/apps/data-tools/ui/locales.json similarity index 61% rename from src/apps/data-migrator/ui/locales.json rename to src/apps/data-tools/ui/locales.json index 24f885a1c1..afc22e6b9e 100644 --- a/src/apps/data-migrator/ui/locales.json +++ b/src/apps/data-tools/ui/locales.json @@ -4,9 +4,7 @@ "workspaces": "Workspaces", "assistantDirectories": "Assistant directories", "itemCountsUnavailable": "Item counts are unavailable for this run.", - "eyebrow": "OpenBitFun Data Migrator", - "title": "Import data from BitFun", - "intro": "A separate, offline tool. Choose what to import; your original BitFun data is preserved.", + "title": "OpenBitFun Data Tools", "stepSource": "Step 1", "sourceTitle": "Source and destination", "stepScope": "Step 2", @@ -33,12 +31,12 @@ "ready": "Ready", "unsupported": "Unsupported", "missing": "Not found", - "closeMigrator": "Close Data Migrator", - "bootstrapPending": "Data Migrator is still loading. Please try again.", - "bootstrapFailed": "Data Migrator could not load its local state.", + "closeDataTools": "Close Data Tools", + "bootstrapPending": "Data Tools is still loading. Please try again.", + "bootstrapFailed": "Data Tools could not load its local state.", "sourceFound": "BitFun {version} was found. The source stays read-only.", "recovery": "A previous migration journal was found and can be resumed.", - "blockers": "{count} data-writing process(es) must stop before migration can continue.", + "blockers": "{count} data-writing process(es) must stop before this operation can continue.", "noBlockers": "No data-writing processes are blocking migration.", "steps": "{count} migration step(s)", "conflicts": "{count} conflict(s)", @@ -53,14 +51,14 @@ "resumeTask": "Review / resume task", "newTask": "New migration", "resetTitle": "Reset OpenBitFun data", - "resetHelp": "Start over after an unsuccessful migration. Close BitFun, OpenBitFun, their background writers, and other Data Migrator instances first.", + "resetHelp": "Start over after an unsuccessful migration. Close BitFun, OpenBitFun, their background writers, and other Data Tools instances first.", "previewReset": "Review reset directories", "resetWarning": "Permanently deletes all data in the directories below, including OpenBitFun settings, credentials, assistant workspaces, sessions, and migration logs/backups. Save anything you need before continuing. Original BitFun source data is preserved.", "resetConfirmLabel": "Type RESET to confirm permanent deletion", "resetAction": "Permanently reset OpenBitFun data", "resetExists": "Will be deleted", "resetMissing": "Not present", - "resetProgress": "Resetting OpenBitFun data. Keep Data Migrator open until it finishes.", + "resetProgress": "Resetting OpenBitFun data. Keep Data Tools open until it finishes.", "resetSuccess": "OpenBitFun data was reset. Scan the source again to begin a new migration.", "resetIncomplete": "Some directories could not be fully removed. Close applications using them, then review and retry before migrating.", "resetFailed": "Could not fully remove this directory", @@ -89,16 +87,58 @@ "workspace_item_skipped": "An unreadable workspace registration was skipped; other workspaces and sessions remain available.", "session_entry_skipped": "Some Session files will be skipped; valid Sessions can continue.", "session_item_skipped": "Some Session files will be skipped; valid Sessions can continue.", - "extension_item_skipped": "Incomplete or unsupported extensions will be skipped; valid extensions can continue." + "extension_item_skipped": "Incomplete or unsupported extensions will be skipped; valid extensions can continue.", + "toolsNavigation": "Data tools", + "migrationTool": "Data migration", + "migrationToolHelp": "Import legacy BitFun settings, workspaces and sessions into OpenBitFun.", + "openMigration": "Open data migration", + "resetTool": "Reset OpenBitFun data", + "resetToolHelp": "Remove OpenBitFun data so you can start over with a clean destination.", + "openReset": "Open data reset", + "repairTool": "Session repair", + "repairToolHelp": "Restore session locations and assistant identities damaged by data or runtime directory migration.", + "openRepair": "Open session repair", + "backToTools": "Back to data tools", + "repairHelp": "Scan existing OpenBitFun data without changing it. Review the repair plan, then apply it with verified backups. Remote workspace sessions saved on this computer are included; no SSH connection is needed.", + "repairLocations": "OpenBitFun data locations", + "repairScan": "Scan and prepare repair plan", + "repairHistory": "Saved repair tasks", + "repairLoad": "Review selected task", + "repairBackup": "Plan, journal and backups: {path}", + "repairCount": "Sessions to repair: {count}", + "repairCorrect": "Already correct: {count}", + "repairMoves": "Directory moves: {count}", + "repairIdentity": "Assistant identity repairs: {count}", + "repairUnresolved": "Skipped items requiring review", + "repairSource": "Current location", + "repairTarget": "Correct location", + "repairSnapshots": "Snapshot dependency files", + "repairCloseWriters": "Close BitFun, OpenBitFun, CLI/background writers and other Data Tools instances before applying or rolling back. The tool waits for known writers to stop. Keep backups until you have verified your sessions.", + "repairStart": "Apply / resume repair", + "repairRollback": "Roll back / resume rollback", + "repairPhase_idle": "Ready to scan", + "repairPhase_scanning": "Scanning sessions", + "repairPhase_planned": "Plan ready for review", + "repairPhase_prepared": "Backups ready; repair can be resumed or rolled back", + "repairPhase_indexing": "Publishing session indexes", + "repairPhase_completed": "Repair completed", + "repairPhase_rolling_back": "Rollback in progress or interrupted", + "repairPhase_rolled_back": "Rollback completed", + "repairPhase_waiting_for_processes": "Waiting for data writers to stop", + "repairPhase_repairing_sessions": "Repairing sessions", + "repairPhase_restoring_sessions": "Restoring sessions", + "repairPhase_rebuilding_indexes": "Rebuilding indexes", + "repairPhase_backing_up": "Creating and verifying full-session backups", + "repairPhase_failed": "Repair failed", + "repairPhase_cancelled": "Cancelled", + "repairPhase_unreadable": "Task cannot be read" }, "zh-CN": { "sessions": "会话", "workspaces": "工作区", "assistantDirectories": "助手目录", "itemCountsUnavailable": "本次迁移的分类计数不可用。", - "eyebrow": "OpenBitFun 独立数据迁移器", - "title": "从 BitFun 导入数据", - "intro": "独立运行,离线导入。选择需要迁移的内容,原始 BitFun 数据会被保留。", + "title": "OpenBitFun 数据工具", "stepSource": "第 1 步", "sourceTitle": "来源与目标目录", "stepScope": "第 2 步", @@ -122,15 +162,15 @@ "reportPrivacy": "此摘要仅包含计数和结果码,不包含凭据或用户正文。", "exportDiagnostics": "导出失败诊断", "diagnosticsExported": "去敏诊断已保存到 {path}", - "closeMigrator": "关闭数据迁移器", - "bootstrapPending": "数据迁移器仍在加载,请稍后重试。", - "bootstrapFailed": "无法加载迁移器的本地状态。", + "closeDataTools": "关闭数据工具", + "bootstrapPending": "数据工具仍在加载,请稍后重试。", + "bootstrapFailed": "无法加载数据工具的本地状态。", "ready": "可迁移", "unsupported": "不受支持", "missing": "未发现", "sourceFound": "已发现 BitFun {version}。迁移期间来源保持只读。", "recovery": "发现上次迁移日志,可以从安全状态继续。", - "blockers": "迁移前还需停止 {count} 个数据写入进程。", + "blockers": "继续操作前还需停止 {count} 个数据写入进程。", "noBlockers": "没有进程阻止迁移。", "steps": "{count} 个迁移步骤", "conflicts": "{count} 个冲突", @@ -145,14 +185,14 @@ "resumeTask": "查看 / 恢复任务", "newTask": "新建迁移", "resetTitle": "重置 OpenBitFun 数据", - "resetHelp": "迁移异常时清空目标后重试。请先关闭 BitFun、OpenBitFun、后台写入进程及其他迁移器实例。", + "resetHelp": "迁移异常时清空目标后重试。请先关闭 BitFun、OpenBitFun、后台写入进程及其他数据工具实例。", "previewReset": "查看待重置目录", "resetWarning": "将永久删除以下目录内的全部数据,包括 OpenBitFun 配置、凭据、助理工作区、会话和迁移日志及备份。请先另存需要保留的内容。旧 BitFun 来源数据会保留。", "resetConfirmLabel": "输入 RESET,确认永久删除", "resetAction": "永久重置 OpenBitFun 数据", "resetExists": "将被删除", "resetMissing": "目录不存在", - "resetProgress": "正在重置 OpenBitFun 数据,请保持迁移器打开,等待完成。", + "resetProgress": "正在重置 OpenBitFun 数据,请保持数据工具打开,等待完成。", "resetSuccess": "OpenBitFun 数据已重置。重新扫描来源,开始新的迁移。", "resetIncomplete": "部分目录未能完全删除。请关闭占用它们的应用,重新查看目录并重试,再进行迁移。", "resetFailed": "未能完全删除此目录", @@ -181,16 +221,58 @@ "workspace_item_skipped": "已跳过无法读取的工作区登记,其余工作区和会话可继续迁移。", "session_entry_skipped": "部分会话文件将被跳过,其他有效会话可继续迁移。", "session_item_skipped": "部分会话文件将被跳过,其他有效会话可继续迁移。", - "extension_item_skipped": "将跳过不完整或不支持的扩展,其他有效扩展可继续迁移。" + "extension_item_skipped": "将跳过不完整或不支持的扩展,其他有效扩展可继续迁移。", + "toolsNavigation": "数据工具", + "migrationTool": "数据迁移", + "migrationToolHelp": "将旧版 BitFun 设置、工作区和会话导入 OpenBitFun。", + "openMigration": "打开数据迁移", + "resetTool": "重置 OpenBitFun 数据", + "resetToolHelp": "清空 OpenBitFun 数据,让目标环境从干净状态重新开始。", + "openReset": "打开数据重置", + "repairTool": "会话修复", + "repairToolHelp": "修复数据迁移或运行目录迁移造成的会话位置和助理身份异常。", + "openRepair": "打开会话修复", + "backToTools": "返回数据工具", + "repairHelp": "扫描已有 OpenBitFun 数据,不修改原始数据。查看修复计划后再执行,工具会先验证完整备份。包含本机保存的远程工作区会话,无需连接 SSH。", + "repairLocations": "OpenBitFun 数据位置", + "repairScan": "扫描并生成修复计划", + "repairHistory": "已保存的修复任务", + "repairLoad": "查看所选任务", + "repairBackup": "计划、恢复记录和备份:{path}", + "repairCount": "待修复会话:{count}", + "repairCorrect": "位置及身份正常:{count}", + "repairMoves": "目录归位:{count}", + "repairIdentity": "助理身份修复:{count}", + "repairUnresolved": "已跳过,需要人工确认", + "repairSource": "当前位置", + "repairTarget": "正确位置", + "repairSnapshots": "快照依赖文件数", + "repairCloseWriters": "执行或回滚前,请关闭 BitFun、OpenBitFun、CLI、后台数据写入进程和其他数据工具实例。工具会等待已识别的写入进程退出。确认会话恢复正常前,请保留备份。", + "repairStart": "执行 / 继续修复", + "repairRollback": "回滚 / 继续回滚", + "repairPhase_idle": "等待扫描", + "repairPhase_scanning": "正在扫描会话", + "repairPhase_planned": "计划已生成,请查看", + "repairPhase_prepared": "备份已完成,可继续修复或回滚", + "repairPhase_indexing": "正在写入会话索引", + "repairPhase_completed": "修复完成", + "repairPhase_rolling_back": "回滚进行中或已中断", + "repairPhase_rolled_back": "回滚完成", + "repairPhase_waiting_for_processes": "等待数据写入进程退出", + "repairPhase_repairing_sessions": "正在修复会话", + "repairPhase_restoring_sessions": "正在还原会话", + "repairPhase_rebuilding_indexes": "正在重建索引", + "repairPhase_backing_up": "正在创建并验证完整会话备份", + "repairPhase_failed": "修复失败", + "repairPhase_cancelled": "已取消", + "repairPhase_unreadable": "任务无法读取" }, "zh-TW": { "sessions": "工作階段", "workspaces": "工作區", "assistantDirectories": "助理目錄", "itemCountsUnavailable": "本次遷移的分類計數無法取得。", - "eyebrow": "OpenBitFun 獨立資料遷移器", - "title": "從 BitFun 匯入資料", - "intro": "獨立執行,離線匯入。選擇需要遷移的內容,原始 BitFun 資料會被保留。", + "title": "OpenBitFun 資料工具", "stepSource": "第 1 步", "sourceTitle": "來源與目標目錄", "stepScope": "第 2 步", @@ -214,15 +296,15 @@ "reportPrivacy": "此摘要僅包含計數和結果碼,不包含憑據或使用者正文。", "exportDiagnostics": "匯出失敗診斷", "diagnosticsExported": "去敏診斷已儲存至 {path}", - "closeMigrator": "關閉資料遷移器", - "bootstrapPending": "資料遷移器仍在載入,請稍後重試。", - "bootstrapFailed": "無法載入遷移器的本機狀態。", + "closeDataTools": "關閉資料工具", + "bootstrapPending": "資料工具仍在載入,請稍後重試。", + "bootstrapFailed": "無法載入資料工具的本機狀態。", "ready": "可遷移", "unsupported": "不支援", "missing": "未發現", "sourceFound": "已發現 BitFun {version}。遷移期間來源保持唯讀。", "recovery": "發現上次遷移日誌,可以從安全狀態繼續。", - "blockers": "遷移前還需停止 {count} 個資料寫入程序。", + "blockers": "繼續操作前還需停止 {count} 個資料寫入程序。", "noBlockers": "沒有程序阻止遷移。", "steps": "{count} 個遷移步驟", "conflicts": "{count} 個衝突", @@ -237,14 +319,14 @@ "resumeTask": "檢視 / 恢復工作", "newTask": "新增遷移", "resetTitle": "重設 OpenBitFun 資料", - "resetHelp": "遷移異常時清空目標後重試。請先關閉 BitFun、OpenBitFun、背景寫入程序及其他遷移器實例。", + "resetHelp": "遷移異常時清空目標後重試。請先關閉 BitFun、OpenBitFun、背景寫入程序及其他資料工具實例。", "previewReset": "檢視待重設目錄", "resetWarning": "將永久刪除以下目錄內的全部資料,包括 OpenBitFun 設定、憑據、助理工作區、工作階段和遷移記錄及備份。請先另存需要保留的內容。舊 BitFun 來源資料會保留。", "resetConfirmLabel": "輸入 RESET,確認永久刪除", "resetAction": "永久重設 OpenBitFun 資料", "resetExists": "將被刪除", "resetMissing": "目錄不存在", - "resetProgress": "正在重設 OpenBitFun 資料,請保持遷移器開啟,等待完成。", + "resetProgress": "正在重設 OpenBitFun 資料,請保持資料工具開啟,等待完成。", "resetSuccess": "OpenBitFun 資料已重設。重新掃描來源,開始新的遷移。", "resetIncomplete": "部分目錄未能完全刪除。請關閉佔用它們的應用程式,重新檢視目錄並重試,再進行遷移。", "resetFailed": "未能完全刪除此目錄", @@ -273,6 +355,50 @@ "workspace_item_skipped": "已略過無法讀取的工作區登記,其餘工作區和工作階段可繼續遷移。", "session_entry_skipped": "部分工作階段檔案將被略過,其他有效工作階段可繼續遷移。", "session_item_skipped": "部分工作階段檔案將被略過,其他有效工作階段可繼續遷移。", - "extension_item_skipped": "將略過不完整或不支援的擴充功能,其他有效項目可繼續遷移。" + "extension_item_skipped": "將略過不完整或不支援的擴充功能,其他有效項目可繼續遷移。", + "toolsNavigation": "資料工具", + "migrationTool": "資料遷移", + "migrationToolHelp": "將舊版 BitFun 設定、工作區和工作階段匯入 OpenBitFun。", + "openMigration": "開啟資料遷移", + "resetTool": "重設 OpenBitFun 資料", + "resetToolHelp": "清空 OpenBitFun 資料,讓目標環境從乾淨狀態重新開始。", + "openReset": "開啟資料重設", + "repairTool": "工作階段修復", + "repairToolHelp": "修復資料遷移或執行目錄遷移造成的工作階段位置和助理身分異常。", + "openRepair": "開啟工作階段修復", + "backToTools": "返回資料工具", + "repairHelp": "掃描現有 OpenBitFun 資料,不修改原始資料。檢視修復計畫後再執行,工具會先驗證完整備份。包含本機儲存的遠端工作區工作階段,無需連接 SSH。", + "repairLocations": "OpenBitFun 資料位置", + "repairScan": "掃描並產生修復計畫", + "repairHistory": "已儲存的修復任務", + "repairLoad": "檢視所選任務", + "repairBackup": "計畫、復原記錄和備份:{path}", + "repairCount": "待修復工作階段:{count}", + "repairCorrect": "位置及身分正常:{count}", + "repairMoves": "目錄歸位:{count}", + "repairIdentity": "助理身分修復:{count}", + "repairUnresolved": "已略過,需要人工確認", + "repairSource": "目前位置", + "repairTarget": "正確位置", + "repairSnapshots": "快照相依檔案數", + "repairCloseWriters": "執行或回復前,請關閉 BitFun、OpenBitFun、CLI、背景資料寫入程序和其他資料工具實例。工具會等待已識別的寫入程序結束。確認工作階段恢復正常前,請保留備份。", + "repairStart": "執行 / 繼續修復", + "repairRollback": "回復 / 繼續回復", + "repairPhase_idle": "等待掃描", + "repairPhase_scanning": "正在掃描工作階段", + "repairPhase_planned": "計畫已產生,請檢視", + "repairPhase_prepared": "備份已完成,可繼續修復或回復", + "repairPhase_indexing": "正在寫入工作階段索引", + "repairPhase_completed": "修復完成", + "repairPhase_rolling_back": "回復進行中或已中斷", + "repairPhase_rolled_back": "回復完成", + "repairPhase_waiting_for_processes": "等待資料寫入程序結束", + "repairPhase_repairing_sessions": "正在修復工作階段", + "repairPhase_restoring_sessions": "正在還原工作階段", + "repairPhase_rebuilding_indexes": "正在重建索引", + "repairPhase_failed": "修復失敗", + "repairPhase_cancelled": "已取消", + "repairPhase_unreadable": "無法讀取任務", + "repairPhase_backing_up": "正在建立並驗證完整工作階段備份" } } diff --git a/src/apps/data-migrator/ui/report.mjs b/src/apps/data-tools/ui/report.mjs similarity index 100% rename from src/apps/data-migrator/ui/report.mjs rename to src/apps/data-tools/ui/report.mjs diff --git a/src/apps/data-migrator/ui/styles.css b/src/apps/data-tools/ui/styles.css similarity index 91% rename from src/apps/data-migrator/ui/styles.css rename to src/apps/data-tools/ui/styles.css index c45a472d48..a53ab0c26c 100644 --- a/src/apps/data-migrator/ui/styles.css +++ b/src/apps/data-tools/ui/styles.css @@ -39,6 +39,28 @@ body { margin-block: var(--openbitfun-space-4); } +.tool-grid { + align-items: stretch; +} + +.tool-card { + display: flex; + flex-direction: column; +} + +.tool-card .muted { + flex: 1; +} + +.tool-card button { + align-self: flex-start; + margin-block-start: var(--openbitfun-space-4); +} + +.panel-toolbar { + margin-block-end: var(--openbitfun-space-4); +} + fieldset { min-inline-size: 0; margin: 0; @@ -74,6 +96,27 @@ input[type="text"], inline-size: 100%; } +#repair-task { inline-size: 100%; } + +button[aria-pressed="true"] { + border-color: var(--openbitfun-color-field-border-focus); +} + +#repair-actions details { + padding-block: var(--openbitfun-space-3); + overflow-wrap: anywhere; +} + +#repair-actions summary { + cursor: pointer; + font-weight: var(--openbitfun-type-heading-card-font-weight); +} + +#repair-actions summary:focus-visible { + outline: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border-focus); + outline-offset: var(--openbitfun-space-1); +} + input[type="text"]:focus-visible, select:focus-visible { outline: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border-focus); @@ -88,6 +131,31 @@ header { margin-block-end: var(--openbitfun-space-6); } +.header-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--openbitfun-space-4); +} + +.tool-title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--openbitfun-space-2); +} + +.tool-title h1 { + margin-block-end: 0; +} + +.language-control { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + margin-inline-start: auto; +} + h1, h2, p { @@ -114,7 +182,6 @@ h2 { letter-spacing: var(--openbitfun-type-heading-card-letter-spacing); } -.eyebrow, .step { margin-block-end: var(--openbitfun-space-2); color: var(--openbitfun-color-content-muted); @@ -125,11 +192,6 @@ h2 { letter-spacing: var(--openbitfun-type-meta-letter-spacing); } -.eyebrow { - color: var(--openbitfun-color-accent-default); -} - -.lead, .muted { color: var(--openbitfun-color-content-secondary); } diff --git a/src/apps/data-migrator/ui/theme.js b/src/apps/data-tools/ui/theme.js similarity index 100% rename from src/apps/data-migrator/ui/theme.js rename to src/apps/data-tools/ui/theme.js diff --git a/src/apps/data-migrator/ui/transport.js b/src/apps/data-tools/ui/transport.js similarity index 100% rename from src/apps/data-migrator/ui/transport.js rename to src/apps/data-tools/ui/transport.js diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 4e90151bd1..3d76f97ccb 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -59,9 +59,9 @@ pnpm run desktop:preview:debug pnpm run prepare:dsh-profile # optional: local DeepSeek Harness sessions ``` -Data Migrator runs independently. Desktop development launchers do not build, -launch, or supervise the migrator; use its own development and release entry -points under `src/apps/data-migrator`. +Data Tools runs independently. Desktop development launchers do not build, +launch, or supervise the tool; use its own development and release entry +points under `src/apps/data-tools`. ## Fast builds diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index f0ddbb06be..117745a40c 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -14,7 +14,6 @@ fn main() { } println!("cargo:rerun-if-env-changed=OPENBITFUN_RELEASE_CHANNEL"); println!("cargo:rerun-if-env-changed=OPENBITFUN_DESKTOP_BINARY_NAME"); - println!("cargo:rerun-if-env-changed=OPENBITFUN_DATA_MIGRATOR_BINARY_NAME"); println!("cargo:rerun-if-env-changed=OPENBITFUN_UPDATER_PRIMARY_ENDPOINT"); println!("cargo:rerun-if-env-changed=OPENBITFUN_UPDATER_FALLBACK_ENDPOINT"); // The Windows primary thread keeps the Tauri event loop and native window diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 8abdfec1c7..bc26b86ceb 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -170,7 +170,7 @@ impl ProductCapabilityPack { pub enum DeliveryProfile { ProductFull, Desktop, - DataMigrator, + DataTools, Cli, Server, Remote, @@ -185,7 +185,7 @@ impl DeliveryProfile { match self { Self::ProductFull => "product-full", Self::Desktop => "desktop", - Self::DataMigrator => "data-migrator", + Self::DataTools => "data-tools", Self::Cli => "cli", Self::Server => "server", Self::Remote => "remote", @@ -200,7 +200,7 @@ impl DeliveryProfile { &[ Self::ProductFull, Self::Desktop, - Self::DataMigrator, + Self::DataTools, Self::Cli, Self::Server, Self::Remote, @@ -262,7 +262,7 @@ const PRODUCT_DELIVERY_PROFILE_ENTRIES: &[ProductDeliveryProfileEntry] = &[ ProductCoreDependencyMode::ProductFullCompatibility, ), ProductDeliveryProfileEntry::new( - DeliveryProfile::DataMigrator, + DeliveryProfile::DataTools, ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, ), ProductDeliveryProfileEntry::new( @@ -956,7 +956,7 @@ pub fn product_extension_capabilities_for_profile( DeliveryProfile::Server | DeliveryProfile::Remote | DeliveryProfile::Acp - | DeliveryProfile::DataMigrator + | DeliveryProfile::DataTools | DeliveryProfile::Web | DeliveryProfile::MobileWeb | DeliveryProfile::Sdk => PluginRuntimeUnavailableReason::UnsupportedProfile, @@ -1153,7 +1153,7 @@ fn product_capability_registry_for_profile(profile: DeliveryProfile) -> ProductC } DeliveryProfile::Server | DeliveryProfile::Remote - | DeliveryProfile::DataMigrator + | DeliveryProfile::DataTools | DeliveryProfile::Web | DeliveryProfile::MobileWeb => { ProductCapabilityRegistry::new(EMPTY_PRODUCT_CAPABILITY_PACKS) diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs index 84656f9a9c..27a574a61f 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs @@ -322,7 +322,7 @@ fn product_delivery_profile_matrix_documents_current_core_dependency_shape() { ProductCoreDependencyMode::ProductFullCompatibility, ), ( - DeliveryProfile::DataMigrator, + DeliveryProfile::DataTools, ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, ), ( @@ -370,20 +370,20 @@ fn product_assembly_plan_follows_core_dependency_matrix() { match entry.core_dependency_mode() { ProductCoreDependencyMode::ProductFullCompatibility | ProductCoreDependencyMode::ExplicitCoreCapabilityClosure => { - if entry.profile() == DeliveryProfile::DataMigrator { + if entry.profile() == DeliveryProfile::DataTools { assert!( plan.capability_set().ids().is_empty(), - "data-migrator must not assemble Agent Runtime capabilities" + "data-tools must not assemble Agent Runtime capabilities" ); assert!( plan.capability_assembly() .tool_provider_group_plan() .is_empty(), - "data-migrator must not assemble runtime tool groups" + "data-tools must not assemble runtime tool groups" ); assert!( plan.feature_groups().is_empty(), - "data-migrator must not expose runtime feature groups" + "data-tools must not expose runtime feature groups" ); continue; } @@ -434,10 +434,10 @@ fn product_assembly_plan_follows_core_dependency_matrix() { } #[test] -fn data_migrator_profile_is_a_minimal_non_agent_product_plan() { - let plan = product_assembly_plan_for_profile(DeliveryProfile::DataMigrator); +fn data_tools_profile_is_a_minimal_non_agent_product_plan() { + let plan = product_assembly_plan_for_profile(DeliveryProfile::DataTools); - assert_eq!(plan.profile().id(), "data-migrator"); + assert_eq!(plan.profile().id(), "data-tools"); assert!(plan.capability_set().ids().is_empty()); assert!(plan.capability_assembly().agent_ids().is_empty()); assert!(plan.capability_assembly().service_requirements().is_empty()); diff --git a/src/crates/services/legacy-migration-adapters/src/remote_connect.rs b/src/crates/services/legacy-migration-adapters/src/remote_connect.rs index 9d69da43ac..7d8e871cb3 100644 --- a/src/crates/services/legacy-migration-adapters/src/remote_connect.rs +++ b/src/crates/services/legacy-migration-adapters/src/remote_connect.rs @@ -13,9 +13,9 @@ use openbitfun_product_domains::legacy_migration::{ }; use openbitfun_services_integrations::remote_persistence as owner; use owner::{ - AccountHintRecord, AccountSessionRecord, BotChatStateRecord, BotConfigRecord, - BotPersistenceRecord, LegacyAccountSessionKeyDomains, MachineBinding, - RemoteConnectFormStateRecord, SavedBotConnectionRecord, + AccountHintRecord, BotChatStateRecord, BotConfigRecord, BotPersistenceRecord, + LegacyAccountSessionKeyDomains, MachineBinding, RemoteConnectFormStateRecord, + SavedBotConnectionRecord, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -26,7 +26,6 @@ use std::path::{Path, PathBuf}; const DOMAIN_DIR: &str = "remote-connect"; const SOURCE_SCHEMA: &str = "bitfun.remote-connect.v0.2.19"; const TARGET_SCHEMA: &str = "openbitfun.remote-connect.current"; -const MAX_REMOTE_FILES: usize = 4_096; const MAX_SECRET_BYTES: u64 = 16 * 1024 * 1024; const LEGACY_ACCOUNT_SESSION_KEY_DOMAINS: LegacyAccountSessionKeyDomains<'static> = LegacyAccountSessionKeyDomains { @@ -489,23 +488,17 @@ fn apply_merge( } else { None }; - let target_session = if target.account_session_present { - match owner::read_current_account_session(&backup_domain_dir(context, DOMAIN_DIR), &binding) - { - Ok(value) => value, - Err(_) => { - warn_reauthentication( - outcome, - "remote_connect_account", - "target_account_session_unavailable", - "The existing Remote Connect account session needs authentication repair.", - ); - None - } - } - } else { - None - }; + if target.account_session_present + && owner::read_current_account_session(&backup_domain_dir(context, DOMAIN_DIR), &binding) + .is_err() + { + warn_reauthentication( + outcome, + "remote_connect_account", + "target_account_session_unavailable", + "The existing Remote Connect account session needs authentication repair.", + ); + } if !target.account_session_present { if let Some(session) = &source_session { owner::write_current_account_session(target_root, &binding, session) @@ -540,15 +533,6 @@ fn apply_merge( Ok(()) } -fn same_account(left: &AccountSessionRecord, right: &AccountSessionRecord) -> bool { - left.user_id == right.user_id - && normalized_url(&left.relay_url) == normalized_url(&right.relay_url) -} - -fn normalized_url(value: &str) -> &str { - value.trim().trim_end_matches('/') -} - fn merge_bot( source: &BotPersistenceRecord, target: &BotPersistenceRecord, @@ -922,27 +906,6 @@ fn existing_regular(root: &Path, path: &Path, max_bytes: u64) -> LegacyMigration } } -fn existing_directory(root: &Path, path: &Path) -> LegacyMigrationResult { - match fs::symlink_metadata(path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(LegacyMigrationError::InvalidRequest(format!( - "expected a regular Remote Connect directory at {}", - relative_display(root, path) - ))); - } - let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?; - let canonical_path = fs::canonicalize(path).map_err(|error| io_error(path, error))?; - if !canonical_path.starts_with(canonical_root) { - return Err(LegacyMigrationError::PathEscape(path.to_path_buf())); - } - Ok(true) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(io_error(path, error)), - } -} - fn record_file( root: &Path, path: &Path, diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 13ebb03fdf..a598e46165 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -110,6 +110,15 @@ command here only when it becomes a recurring owner workflow. ## Offline-compatible storage formats +`session-recovery` owns offline migration-damaged session scanning, verified +full-session backup, resumable repair and rollback. It reuses workspace identity +and session metadata/index owners and never starts runtime or SSH connections. +App hosts must stop data writers before calling its mutation entry points. + +```bash +cargo test -p openbitfun-services-core --no-default-features --features session-recovery --lib session_recovery +``` + workspace-persistence owns workspace records and registry validation; coordination-store owns the durable coordination SQLite schema; session-event-format owns the logged-event envelope and durable-prefix validator. diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 7dbf14168c..671af5216f 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -55,6 +55,7 @@ libc = { workspace = true, optional = true } asar = [] installed-apps = ["dep:windows", "windows/Win32_Foundation", "windows/Win32_System_Registry"] default = [] +session-recovery = ["local-storage", "workspace-persistence", "dep:anyhow"] pet-packages = ["dep:image", "image/png", "image/webp", "image/jpeg", "image/gif", "dep:zip", "dep:uuid", "dep:sha2", "dep:base64", "dep:dunce", "dep:fs2"] workspace-persistence = ["workspace-identity", "dep:openbitfun-core-types", "dep:openbitfun-runtime-ports", "dep:chrono"] coordination-store = ["dep:rusqlite"] diff --git a/src/crates/services/services-core/src/json_store.rs b/src/crates/services/services-core/src/json_store.rs index 1c71536225..2ec82fb962 100644 --- a/src/crates/services/services-core/src/json_store.rs +++ b/src/crates/services/services-core/src/json_store.rs @@ -276,6 +276,32 @@ impl JsonFileStore { .await } + /// Copy an existing file to a strictly atomically published destination + /// without loading the file into the process heap. + pub async fn copy_file_atomic_strict( + &self, + source: &Path, + path: &Path, + ) -> Result<(), JsonFileStoreError> { + let parent = path + .parent() + .ok_or_else(|| JsonFileStoreError::NoParentDirectory { + path: path.to_path_buf(), + })?; + fs::create_dir_all(parent) + .await + .map_err(|source| JsonFileStoreError::CreateParent { source })?; + let lock = Self::get_file_write_lock(path).await; + let _lock_guard = lock.lock().await; + let tmp_path = Self::build_temp_json_path(path, 0)?; + fs::copy(source, &tmp_path) + .await + .map_err(|source| JsonFileStoreError::WriteTemp { source })?; + Self::replace_file_from_temp_strict(path, &tmp_path) + .await + .map_err(|source| JsonFileStoreError::Replace { source }) + } + /// Atomically publish a new UTF-8 text file, failing without changing the /// target when another process creates it first. pub async fn write_text_atomic_create_new( diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 972e0b7032..ae7f48f184 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -64,6 +64,8 @@ pub use openbitfun_core_types::product_identity; pub mod runtime_ownership; #[cfg(feature = "local-storage")] pub mod session; +#[cfg(feature = "session-recovery")] +pub mod session_recovery; #[cfg(feature = "session-search")] pub mod session_search; #[cfg(feature = "local-storage")] diff --git a/src/crates/services/services-core/src/session_recovery/engine.rs b/src/crates/services/services-core/src/session_recovery/engine.rs new file mode 100644 index 0000000000..b3193fab33 --- /dev/null +++ b/src/crates/services/services-core/src/session_recovery/engine.rs @@ -0,0 +1,740 @@ +use super::*; +use anyhow::bail; + +#[derive(Debug, Serialize, Deserialize)] +struct Journal { + version: u32, + plan_sha256: String, + phase: String, + controls: Vec, + asset_writes: BTreeSet, + created_directories: BTreeSet, +} +#[derive(Debug, Serialize, Deserialize)] +struct Control { + path: PathBuf, + old: Option, + new: Option, + backup: String, + pending: Option, +} + +pub fn task_phase(task: &Path) -> Result { + if !task.join("journal.json").exists() { + return Ok("planned".into()); + } + let journal: Journal = serde_json::from_slice(&read(&task.join("journal.json"))?)?; + ensure!( + journal.version == FORMAT_VERSION + && [ + "prepared", + "indexing", + "completed", + "rolling_back", + "rolled_back" + ] + .contains(&journal.phase.as_str()), + "Unsupported repair journal" + ); + Ok(journal.phase) +} +struct RepairLease { + path: PathBuf, + file: Option, +} +impl Drop for RepairLease { + fn drop(&mut self) { + drop(self.file.take()); + let _ = fs::remove_file(&self.path); + } +} +fn lock(home: &Path) -> Result { + use std::io::Write; + let path = home.join(".session-storage-repair.lock"); + safe(&path, home)?; + // Shares the standalone script lock. Hard kills leave a sentinel requiring + // deliberate cleanup after all repair writers stop. + let file = fs::OpenOptions::new().write(true).create_new(true).open(&path) + .context("Repair lock already exists or cannot be acquired. Close all repair tools; after an abnormal exit, remove the stale .session-storage-repair.lock before resuming")?; + let mut lease = RepairLease { + path, + file: Some(file), + }; + writeln!(lease.file.as_mut().unwrap(), "{}", std::process::id())?; + lease.file.as_ref().unwrap().sync_all()?; + Ok(lease) +} +fn save(task: &Path, journal: &Journal) -> Result<()> { + write(&task.join("journal.json"), &encode(journal)?) +} +fn mutation_directories(plan: &RepairPlan) -> Result> { + let projects = plan.home.join("projects"); + let mut result = BTreeSet::new(); + for operation in &plan.operations { + for path in + std::iter::once(&operation.target).chain(operation.assets.iter().map(|a| &a.target)) + { + let mut directory = path.parent().context("Invalid repair target")?; + while directory != projects { + safe(directory, &projects)?; + result.insert(directory.into()); + directory = directory.parent().context("Invalid repair directory")?; + } + } + } + Ok(result) +} +fn validate(plan: &RepairPlan) -> Result<()> { + ensure!( + plan.version == FORMAT_VERSION, + "Unsupported repair plan version" + ); + safe(&plan.home, &plan.home)?; + safe(&plan.user_root, &plan.user_root)?; + let (records, sha) = identity::registry(&plan.user_root)?; + ensure!( + sha == plan.registry_sha256, + "Workspace registry changed; scan again" + ); + let aliases = identity::aliases(&plan.home, &records)?; + let mut sources = BTreeSet::new(); + let mut targets = BTreeSet::new(); + let mut affected = BTreeSet::new(); + for operation in &plan.operations { + safe(&operation.source, &plan.home)?; + safe(&operation.target, &plan.home.join("projects"))?; + ensure!( + safe_id(&operation.session_id) + && filename(&operation.source)? == operation.session_id + && operation + .source + .parent() + .is_some_and(|p| p.file_name().is_some_and(|n| n == "sessions")), + "Invalid source session path" + ); + let runtime = operation + .source + .parent() + .unwrap() + .parent() + .context("Invalid runtime root")?; + ensure!( + runtime.parent() == Some(plan.home.join("projects").as_path()) + || runtime.starts_with(plan.home.join("remote_ssh")), + "Invalid source runtime root" + ); + let record = records + .get(&operation.workspace_id) + .context("Plan workspace is absent from registry")?; + let expected = plan + .home + .join("projects") + .join(identity::runtime_key(record)?) + .join("sessions") + .join(&operation.session_id); + ensure!( + expected == operation.target + && sources.insert(operation.source.clone()) + && targets.insert(operation.target.clone()), + "Invalid or duplicate repair destination" + ); + affected.insert(operation.source.parent().unwrap().to_path_buf()); + affected.insert(operation.target.parent().unwrap().to_path_buf()); + let evidence = if operation.source.exists() { + &operation.source + } else { + &operation.target + }; + let mut docs = documents(evidence, None)?; + for (name, update) in &operation.updates { + ensure!( + ["metadata.json", "state.json"].contains(&name.as_str()) && docs.contains_key(name), + "Invalid identity update path" + ); + let before = update + .before + .strip_prefix(&[0xef, 0xbb, 0xbf]) + .unwrap_or(&update.before); + docs.insert(name.clone(), serde_json::from_slice(before)?); + } + let owner = identity::owner(evidence, docs.clone(), &records, &aliases, None)?; + ensure!( + owner.id == operation.workspace_id && owner.root == operation.workspace_path, + "Plan disagrees with session identity" + ); + for (name, doc) in &docs { + if let Some(update) = operation.updates.get(name) { + ensure!( + doc != &owner.documents[name] + && update.after == encode(&owner.documents[name])?, + "Invalid assistant identity rewrite" + ); + } else { + ensure!( + doc == &owner.documents[name], + "Missing assistant identity rewrite" + ); + } + } + ensure!( + operation.source != operation.target || !operation.updates.is_empty(), + "Plan has no repair action" + ); + let (before_fingerprint, repaired_fingerprint) = directory_fingerprint_pair( + evidence, + &update_side(&operation.updates, true), + &update_side(&operation.updates, false), + )?; + ensure!( + before_fingerprint == operation.sha256 + && repaired_fingerprint == operation.repaired_sha256, + "Session changed since planning" + ); + let assets = identity::snapshot_assets(&operation.source, &operation.target, &owner)?; + let comparable = |assets: &[Asset]| { + assets + .iter() + .map(|a| (a.source.clone(), a.target.clone(), a.sha256.clone())) + .collect::>() + }; + // Ignore destination presence during resume, but never ignore content. + ensure!( + comparable(&assets) == comparable(&operation.assets), + "Snapshot dependencies changed since planning" + ); + } + ensure!( + affected == plan.catalogs.keys().cloned().collect(), + "Invalid affected session catalogs" + ); + Ok(()) +} +fn check_sessions(plan: &RepairPlan, resume: bool) -> Result<()> { + let mut expected = plan.catalogs.clone(); + for operation in &plan.operations { + let source = &operation.source; + let target = &operation.target; + let location = if source == target || source.exists() && !target.exists() { + source + } else if resume && target.exists() && !source.exists() { + let sha = expected + .get_mut(source.parent().unwrap()) + .unwrap() + .remove(&operation.session_id) + .context("Missing source catalog entry")?; + expected + .get_mut(target.parent().unwrap()) + .unwrap() + .insert(operation.session_id.clone(), sha); + target + } else { + bail!("Session source/destination conflict: {}", source.display()); + }; + for (name, update) in &operation.updates { + let bytes = read(&location.join(name))?; + ensure!( + bytes == update.before || resume && bytes == update.after, + "Session identity file changed: {}", + location.join(name).display() + ); + } + ensure!( + tree_hash(location, &update_side(&operation.updates, true))? == operation.sha256, + "Session changed since planning: {}", + location.display() + ); + if operation.updates.contains_key("metadata.json") { + expected + .get_mut(location.parent().unwrap()) + .unwrap() + .insert( + operation.session_id.clone(), + file_hash(&location.join("metadata.json"))?, + ); + } + } + for (root, entries) in expected { + ensure!( + catalog(&root)? == entries, + "Session catalog changed since planning: {}", + root.display() + ); + } + Ok(()) +} +fn copy_tree(source: &Path, target: &Path) -> Result<()> { + safe(target, target)?; + fs::create_dir_all(target)?; + for child in children(source)? { + let dest = target.join(filename(&child)?); + if child.is_dir() { + copy_tree(&child, &dest)?; + } else { + if dest.exists() { + ensure!( + file_hash(&child)? == file_hash(&dest)?, + "Incomplete backup has conflicting content" + ); + } else { + copy_file(&child, &dest)?; + } + } + } + Ok(()) +} +fn prepare_backup( + plan: &RepairPlan, + task: &Path, + cancelled: &impl Fn() -> bool, +) -> Result { + let mut controls = vec![]; + for (number, operation) in plan.operations.iter().enumerate() { + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + let backup = task.join("sessions").join(number.to_string()); + copy_tree(&operation.source, &backup)?; + ensure!( + tree_hash(&backup, &BTreeMap::new())? == operation.sha256, + "Full-session backup verification failed" + ); + } + for root in plan.catalogs.keys() { + for name in CONTROLS { + let path = root.join(name); + safe(&path, &plan.home)?; + let old = optional_hash(&path)?; + let backup = format!("controls/{}.bin", controls.len()); + if let Some(sha) = &old { + write(&task.join(&backup), &read(&path)?)?; + ensure!( + &file_hash(&task.join(&backup))? == sha, + "Control backup verification failed" + ); + } + controls.push(Control { + path, + old: old.clone(), + new: old, + backup, + pending: None, + }); + } + } + let journal = Journal { + version: FORMAT_VERSION, + plan_sha256: hash(&encode(plan)?), + phase: "prepared".into(), + controls, + asset_writes: BTreeSet::new(), + created_directories: mutation_directories(plan)? + .into_iter() + .filter(|p| !p.exists()) + .collect(), + }; + save(task, &journal)?; + Ok(journal) +} +fn load_backup(plan: &RepairPlan, task: &Path) -> Result { + let journal: Journal = serde_json::from_slice(&read(&task.join("journal.json"))?)?; + ensure!( + journal.version == FORMAT_VERSION && journal.plan_sha256 == hash(&encode(plan)?), + "Backup belongs to another repair plan" + ); + task_phase(task)?; + ensure!( + journal + .created_directories + .is_subset(&mutation_directories(plan)?), + "Invalid repair directory journal" + ); + for (number, operation) in plan.operations.iter().enumerate() { + ensure!( + tree_hash( + &task.join("sessions").join(number.to_string()), + &BTreeMap::new() + )? == operation.sha256, + "Session backup changed or is incomplete" + ); + } + let expected: BTreeSet<_> = plan + .catalogs + .keys() + .flat_map(|root| CONTROLS.iter().map(|name| root.join(name))) + .collect(); + ensure!( + journal.controls.len() == expected.len() + && journal + .controls + .iter() + .map(|c| c.path.clone()) + .collect::>() + == expected, + "Invalid backup control paths" + ); + for control in &journal.controls { + let backup = task.join(&control.backup); + safe(&backup, task)?; + if let Some(old) = &control.old { + ensure!(&file_hash(&backup)? == old, "Control backup changed"); + } + if let Some(pending) = &control.pending { + let path = task.join(pending); + safe(&path, task)?; + ensure!( + Some(file_hash(&path)?) == control.new, + "Pending index changed" + ); + } + } + let assets = plan + .operations + .iter() + .flat_map(|o| { + o.assets + .iter() + .filter(|a| !a.existed) + .map(|a| a.target.clone()) + }) + .collect(); + ensure!( + journal.asset_writes.is_subset(&assets), + "Invalid snapshot journal" + ); + Ok(journal) +} +fn check_controls(journal: &Journal, home: &Path) -> Result<()> { + for control in &journal.controls { + safe(&control.path, home)?; + let sha = optional_hash(&control.path)?; + ensure!( + sha == control.old || sha == control.new, + "Session index/search cache changed: {}", + control.path.display() + ); + } + Ok(()) +} +/// Caller must stop all data writers. Cancellation is observed between whole +/// session actions, never halfway through an index or session identity write. +/// A returned error retains the journal and backups for resume/rollback. +pub fn apply( + task: &Path, + cancelled: impl Fn() -> bool, + progress: impl Fn(usize, usize, &str), +) -> Result<()> { + let plan = read_plan(task)?; + outside_data(task, &plan)?; + let _lock = lock(&plan.home)?; + validate(&plan)?; + let mut journal = if task.join("journal.json").exists() { + let journal = load_backup(&plan, task)?; + ensure!( + !["rolling_back", "rolled_back"].contains(&journal.phase.as_str()), + "Repair is rolling back or already rolled back" + ); + check_sessions(&plan, true)?; + journal + } else { + check_sessions(&plan, false)?; + for operation in &plan.operations { + for asset in &operation.assets { + ensure!( + optional_hash(&asset.target)?.is_some() == asset.existed, + "Snapshot destination changed; scan again" + ); + } + } + progress(0, plan.operations.len(), "backing_up"); + prepare_backup(&plan, task, &cancelled)? + }; + check_controls(&journal, &plan.home)?; + if journal.phase == "completed" { + for operation in &plan.operations { + ensure!( + tree_hash(&operation.target, &BTreeMap::new())? == operation.repaired_sha256, + "Completed repair session changed" + ); + } + for control in &journal.controls { + ensure!( + optional_hash(&control.path)? == control.new, + "Completed repair controls changed" + ); + } + for asset in plan.operations.iter().flat_map(|o| &o.assets) { + ensure!( + optional_hash(&asset.target)? == Some(asset.sha256.clone()), + "Completed repair snapshot changed" + ); + } + return Ok(()); + } + for (number, operation) in plan.operations.iter().enumerate() { + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + progress(number, plan.operations.len(), "repairing_sessions"); + for asset in &operation.assets { + if let Some(sha) = optional_hash(&asset.target)? { + ensure!( + sha == asset.sha256 + && (asset.existed || journal.asset_writes.contains(&asset.target)), + "Snapshot changed or an unjournaled destination appeared" + ); + continue; + } + ensure!(!asset.existed, "Existing snapshot disappeared"); + journal.asset_writes.insert(asset.target.clone()); + save(task, &journal)?; + copy_file(&asset.source, &asset.target)?; + ensure!( + file_hash(&asset.target)? == asset.sha256, + "Snapshot copy verification failed" + ); + } + if operation.source != operation.target && operation.source.exists() { + fs::create_dir_all(operation.target.parent().unwrap())?; + fs::rename(&operation.source, &operation.target)?; + } + for (name, update) in &operation.updates { + if read(&operation.target.join(name))? != update.after { + write(&operation.target.join(name), &update.after)?; + } + } + ensure!( + tree_hash(&operation.target, &BTreeMap::new())? == operation.repaired_sha256, + "Repaired session verification failed" + ); + } + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + progress( + plan.operations.len(), + plan.operations.len(), + "rebuilding_indexes", + ); + if journal.phase == "prepared" { + for (number, control) in journal.controls.iter_mut().enumerate() { + if filename(&control.path)? == "index.json" { + let bytes = index_bytes(control.path.parent().unwrap())?; + let pending = format!("pending/{number}.json"); + write(&task.join(&pending), &bytes)?; + control.new = Some(hash(&bytes)); + control.pending = Some(pending); + } else { + control.new = None; + } + } + journal.phase = "indexing".into(); + save(task, &journal)?; + } + for control in &journal.controls { + if let Some(pending) = &control.pending { + write(&control.path, &read(&task.join(pending))?)?; + } else if control.path.exists() { + fs::remove_file(&control.path)?; + } + } + check_sessions(&plan, true)?; + journal.phase = "completed".into(); + save(task, &journal)?; + progress(plan.operations.len(), plan.operations.len(), "completed"); + Ok(()) +} +pub fn rollback( + task: &Path, + cancelled: impl Fn() -> bool, + progress: impl Fn(usize, usize, &str), +) -> Result<()> { + let plan = read_plan(task)?; + outside_data(task, &plan)?; + let _lock = lock(&plan.home)?; + validate(&plan)?; + let mut journal = load_backup(&plan, task)?; + check_sessions(&plan, true)?; + check_controls(&journal, &plan.home)?; + for target in &journal.asset_writes { + let asset = plan + .operations + .iter() + .flat_map(|o| &o.assets) + .find(|a| &a.target == target) + .context("Unknown snapshot journal path")?; + ensure!( + optional_hash(target)?.is_none_or(|sha| sha == asset.sha256), + "Copied snapshot changed; rollback refused" + ); + } + journal.phase = "rolling_back".into(); + save(task, &journal)?; + for (number, operation) in plan.operations.iter().rev().enumerate() { + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + progress(number, plan.operations.len(), "restoring_sessions"); + let location = if operation.target.exists() { + &operation.target + } else { + &operation.source + }; + for (name, update) in &operation.updates { + if read(&location.join(name))? != update.before { + write(&location.join(name), &update.before)?; + } + } + if operation.source != operation.target && operation.target.exists() { + fs::create_dir_all(operation.source.parent().unwrap())?; + fs::rename(&operation.target, &operation.source)?; + } + } + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + for control in &journal.controls { + if control.old.is_some() { + write(&control.path, &read(&task.join(&control.backup))?)?; + } else if control.path.exists() { + fs::remove_file(&control.path)?; + } + } + for target in &journal.asset_writes { + if target.exists() { + fs::remove_file(target)?; + } + } + let mut directories: Vec<_> = journal.created_directories.iter().collect(); + directories.sort_by_key(|p| std::cmp::Reverse(p.components().count())); + for directory in directories { + match fs::remove_dir(directory) { + Ok(()) => {} + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => {} + Err(e) => return Err(e.into()), + } + } + journal.phase = "rolled_back".into(); + save(task, &journal)?; + progress(plan.operations.len(), plan.operations.len(), "rolled_back"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session_recovery::tests::Fixture; + + #[test] + fn torn_index_publication_and_rollback_resume_from_durable_journal() { + for roll_back in [false, true] { + let f = Fixture::new(); + let plan = f.prepare(); + let mut journal = prepare_backup(&plan, &f.task, &|| false).unwrap(); + let op = &plan.operations[0]; + fs::create_dir_all(op.target.parent().unwrap()).unwrap(); + fs::rename(&op.source, &op.target).unwrap(); + for (number, control) in journal.controls.iter_mut().enumerate() { + if filename(&control.path).unwrap() == "index.json" { + let bytes = index_bytes(control.path.parent().unwrap()).unwrap(); + let pending = format!("pending/{number}.json"); + write(&f.task.join(&pending), &bytes).unwrap(); + control.new = Some(hash(&bytes)); + control.pending = Some(pending); + } else { + control.new = None; + } + } + journal.phase = "indexing".into(); + save(&f.task, &journal).unwrap(); + let first = journal + .controls + .iter() + .find(|c| c.path == f.source.parent().unwrap().join("index.json")) + .unwrap(); + write( + &first.path, + &read(&f.task.join(first.pending.as_ref().unwrap())).unwrap(), + ) + .unwrap(); + if roll_back { + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + } else { + apply(&f.task, || false, |_, _, _| {}).unwrap(); + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + } + assert!(f.source.exists()); + assert_eq!( + json(&first.path).unwrap(), + serde_json::json!({"old":"index"}) + ); + } + } + + #[test] + fn identity_only_partial_rewrite_is_resumable_and_byte_exactly_reversible() { + for resume in [false, true] { + let mut f = Fixture::new(); + let root = f.home.join("personal_assistant/workspace-named"); + fs::create_dir_all(&root).unwrap(); + let old = f + .home + .with_file_name(".bitfun") + .join("personal_assistant/workspace-named"); + let old_id = crate::workspace_identity::local_workspace_stable_storage_id( + &old.to_str().unwrap().replace('\\', "/"), + ); + let registry = serde_json::json!({"format_version":1,"product_id":"openbitfun","workspaces":{"new":{"id":"new","rootPath":root,"workspaceKind":"assistant"}}}); + write( + &f.user.join("data/workspace_data.json"), + &encode(®istry).unwrap(), + ) + .unwrap(); + let target = f + .home + .join("projects") + .join(crate::workspace_identity::local_workspace_runtime_key( + &crate::workspace_identity::normalize_local_workspace_root_for_stable_id(&root) + .unwrap(), + )) + .join("sessions/session-one"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::rename(&f.source, &target).unwrap(); + f.source = target; + let mut meta = json(&f.source.join("metadata.json")).unwrap(); + meta["workspaceId"] = serde_json::json!(old_id); + meta["workspacePath"] = serde_json::json!(old); + write(&f.source.join("metadata.json"), &encode(&meta).unwrap()).unwrap(); + let state = serde_json::json!({"config":{"workspace_id":old_id,"workspace_path":old,"permission_mode":"full_access"}}); + write(&f.source.join("state.json"), &encode(&state).unwrap()).unwrap(); + let original = tree_hash(&f.source, &BTreeMap::new()).unwrap(); + let plan = f.prepare(); + assert_eq!(plan.operations.len(), 1); + let op = &plan.operations[0]; + assert_eq!(op.source, op.target); + assert_eq!(op.updates.len(), 2); + prepare_backup(&plan, &f.task, &|| false).unwrap(); + write( + &op.target.join("metadata.json"), + &op.updates["metadata.json"].after, + ) + .unwrap(); + if resume { + apply(&f.task, || false, |_, _, _| {}).unwrap(); + } + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + assert_eq!(tree_hash(&f.source, &BTreeMap::new()).unwrap(), original); + } + } + + #[test] + fn changed_user_data_refuses_rollback_and_script_lock_excludes_gui() { + let f = Fixture::new(); + let plan = f.prepare(); + let lease = lock(&f.home).unwrap(); + assert!(apply(&f.task, || false, |_, _, _| {}).is_err()); + drop(lease); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + fs::write( + plan.operations[0].target.join("new-user-turn"), + "new content", + ) + .unwrap(); + assert!(rollback(&f.task, || false, |_, _, _| {}).is_err()); + assert!(!f.source.exists()); + assert!(plan.operations[0].target.join("new-user-turn").exists()); + } +} diff --git a/src/crates/services/services-core/src/session_recovery/identity.rs b/src/crates/services/services-core/src/session_recovery/identity.rs new file mode 100644 index 0000000000..0ca1512852 --- /dev/null +++ b/src/crates/services/services-core/src/session_recovery/identity.rs @@ -0,0 +1,523 @@ +use super::*; +use crate::workspace_identity as wi; + +pub(super) type Records = BTreeMap; +pub(super) type Aliases = BTreeMap; +pub(super) struct Owner { + pub id: String, + pub root: String, + pub metadata: crate::session::SessionMetadata, + pub documents: BTreeMap, + snapshot_id: Option, + remote: Option<(String, String, Option)>, +} +pub(super) fn registry(user_root: &Path) -> Result<(Records, String)> { + let path = user_root.join("data/workspace_data.json"); + let raw = json(&path)?; + ensure!( + raw["format_version"] == crate::workspace_persistence::WORKSPACE_PERSISTENCE_FORMAT_VERSION + && raw["product_id"] == openbitfun_core_types::product_identity::product_id(), + "Unsupported workspace registry format" + ); + let records = raw["workspaces"] + .as_object() + .context("Workspace registry has no workspaces map")?; + for (id, record) in records { + ensure!( + record.is_object() && record.get("id").is_none_or(|v| v == id), + "Invalid workspace registry identity: {id}" + ); + } + Ok(( + records + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + file_hash(&path)?, + )) +} +fn string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) +} +fn remote_path(value: &str) -> Result { + ensure!( + value.starts_with('/') && !value.contains('\0'), + "Remote workspace path must be absolute POSIX" + ); + let normalized = wi::normalize_remote_workspace_path(value); + ensure!( + !normalized.split('/').any(|p| p == "." || p == ".."), + "Ambiguous remote workspace path" + ); + Ok(normalized) +} +fn local_path(value: &str) -> Result { + ensure!( + Path::new(value).is_absolute(), + "Local workspace path must be absolute" + ); + let value = value.replace('\\', "/").trim_end_matches('/').to_string(); + #[cfg(windows)] + let value = value.to_lowercase(); + Ok(value) +} +fn equal_path(a: &str, b: &str, remote: bool) -> Result { + Ok(if remote { + remote_path(a)? == remote_path(b)? + } else { + local_path(a)? == local_path(b)? + }) +} +fn record_root(record: &Value) -> Result { + Ok(string(record, "rootPath") + .context("Workspace registry has no root path")? + .into()) +} +fn host(record: &Value) -> Result { + Ok(string(&record["metadata"], "sshHost") + .context("Remote registry record has no SSH host")? + .trim() + .to_lowercase()) +} +pub(super) fn runtime_key(record: &Value) -> Result { + let root = record_root(record)?; + if string(record, "workspaceKind") == Some("remote") { + Ok(wi::remote_workspace_runtime_key( + &host(record)?, + &remote_path(&root)?, + )) + } else { + ensure!( + Path::new(&root).is_dir(), + "Local workspace directory is unavailable: {root}" + ); + Ok(wi::local_workspace_runtime_key( + &wi::normalize_local_workspace_root_for_stable_id(Path::new(&root)) + .map_err(anyhow::Error::msg)?, + )) + } +} +pub(super) fn aliases(home: &Path, records: &Records) -> Result { + let mut result = BTreeMap::new(); + if home.file_name().and_then(|s| s.to_str()) != Some(".openbitfun") { + return Ok(result); + } + for (id, record) in records { + if string(record, "workspaceKind") != Some("assistant") { + continue; + } + let root = record_root(record)?; + let Ok(relative) = Path::new(&root).strip_prefix(home.join("personal_assistant")) else { + continue; + }; + // Never guess the old default assistant into a named assistant. + if relative.components().count() != 1 || !filename(relative)?.starts_with("workspace-") { + continue; + } + let old = home + .with_file_name(".bitfun") + .join("personal_assistant") + .join(relative) + .to_str() + .context("Non-UTF8 assistant path")? + .to_string(); + let alias = wi::local_workspace_stable_storage_id(&old.replace('\\', "/")); + if records.contains_key(&alias) { + continue; + } + ensure!( + result.insert(alias, (id.clone(), old, root)).is_none(), + "Ambiguous legacy assistant identity" + ); + } + Ok(result) +} +pub(super) fn owner( + directory: &Path, + mut documents: BTreeMap, + records: &Records, + aliases: &Aliases, + cached_metadata: Option<&super::CatalogEntry>, +) -> Result { + ensure!( + documents["metadata.json"].is_object(), + "Metadata must be a JSON object" + ); + if let Some(state) = documents.get("state.json") { + ensure!( + state.is_object() + && state + .get("schema_version") + .is_none_or(|value| value.as_u64() == Some(2)), + "Unsupported session state" + ); + ensure!( + state.get("config").is_none_or(Value::is_object), + "Session config must be an object" + ); + } + for (alias, (id, old, new)) in aliases { + for (name, document) in &mut documents { + let container = if name == "state.json" { + document.get_mut("config") + } else { + Some(document) + }; + let Some(container) = container else { continue }; + for key in [ + "workspaceId", + "workspace_id", + "projectWorkspaceId", + "project_workspace_id", + ] { + if string(container, key) == Some(alias) { + container[key] = Value::String(id.clone()); + } + } + for key in [ + "workspacePath", + "workspace_path", + "projectWorkspacePath", + "project_workspace_path", + ] { + if string(container, key) + .is_some_and(|p| equal_path(p, old, false).unwrap_or(false)) + { + container[key] = Value::String(new.clone()); + } + } + for key in ["executionTarget", "execution_target"] { + if let Some(target) = container.get_mut(key).filter(|v| v.is_object()) { + for field in ["rootPath", "root_path"] { + if string(target, field) + .is_some_and(|p| equal_path(p, old, false).unwrap_or(false)) + { + target[field] = Value::String(new.clone()); + } + } + } + } + } + } + let metadata = match cached_metadata.filter(|entry| entry.value == documents["metadata.json"]) { + Some(entry) => entry.metadata.clone(), + None => metadata(directory, documents["metadata.json"].clone())?, + }; + let meta = serde_json::to_value(&metadata)?; + let state = documents.get("state.json").cloned().unwrap_or(Value::Null); + let config = &state["config"]; + let remote = string(config, "workspace_kind") == Some("remote") + || metadata + .workspace_id + .as_ref() + .is_some_and(|id| id.starts_with("remote_")) + || string(config, "workspace_id").is_some_and(|id| id.starts_with("remote_")) + || string(&meta, "workspaceHostname").is_some_and(|h| h != "localhost") + || string(config, "remote_ssh_host").is_some_and(|h| h != "localhost"); + let mut values = BTreeMap::new(); + for (key, state_key) in [ + ("workspaceId", "workspace_id"), + ("projectWorkspaceId", "project_workspace_id"), + ("workspacePath", "workspace_path"), + ("projectWorkspacePath", "project_workspace_path"), + ("workspaceKind", "workspace_kind"), + ("workspaceHostname", "remote_ssh_host"), + ("executionTarget", "execution_target"), + ] { + // WorkspaceKind is not part of metadata DTO; retain its legacy evidence. + let left = if key == "workspaceKind" { + &documents["metadata.json"][key] + } else { + &meta[key] + }; + let right = &config[state_key]; + if !left.is_null() && !right.is_null() { + let same = if key.ends_with("Path") { + equal_path( + left.as_str().context("Invalid workspace path")?, + right.as_str().context("Invalid workspace path")?, + remote, + )? + } else { + left == right + }; + ensure!(same, "Metadata/state disagree on {key}"); + } + values.insert( + key, + if !left.is_null() { + left.clone() + } else { + right.clone() + }, + ); + } + let field = |key: &str| { + values + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + }; + ensure!( + matches!( + field("workspaceKind"), + None | Some("normal" | "assistant" | "remote") + ), + "Worktree session requires manual repair" + ); + if let (Some(a), Some(b)) = (field("workspaceId"), field("projectWorkspaceId")) { + ensure!(a == b, "Linked worktree session requires manual repair"); + } + if let (Some(a), Some(b)) = (field("workspacePath"), field("projectWorkspacePath")) { + ensure!( + equal_path(a, b, remote)?, + "Execution/project workspace paths differ" + ); + } + let path = field("projectWorkspacePath").or_else(|| field("workspacePath")); + let id = if let Some(id) = field("projectWorkspaceId").or_else(|| field("workspaceId")) { + ensure!(records.contains_key(id), "Unknown workspace ID: {id}"); + id.to_string() + } else { + let mut matches = Vec::new(); + for (id, record) in records { + if (string(record, "workspaceKind") == Some("remote")) != remote { + continue; + } + if let Some(path) = path { + if equal_path(path, &record_root(record)?, remote)? + && (!remote + || Some(host(record)?.as_str()) + == field("workspaceHostname") + .map(str::trim) + .map(str::to_lowercase) + .as_deref()) + { + matches.push(id.clone()); + } + } + } + ensure!( + matches.len() == 1, + "Legacy path does not identify exactly one workspace" + ); + matches.remove(0) + }; + let record = &records[&id]; + ensure!( + matches!( + string(record, "workspaceKind"), + None | Some("normal" | "assistant" | "remote") + ), + "Worktree registry record requires manual repair" + ); + ensure!( + (string(record, "workspaceKind") == Some("remote")) == remote, + "Session and registry disagree on local/remote identity" + ); + let root = record_root(record)?; + if let Some(path) = path { + ensure!( + equal_path(path, &root, remote)?, + "Session identity disagrees with registry path" + ); + } + let target = &values["executionTarget"]; + if !target.is_null() { + ensure!( + target.is_object() && string(target, "kind") == Some("local"), + "Unsupported execution target" + ); + ensure!( + equal_path( + string(target, "rootPath") + .or_else(|| string(target, "root_path")) + .context("Execution target has no root")?, + &root, + remote + )?, + "Execution target disagrees with registry" + ); + } + let remote = if remote { + let host = host(record)?; + ensure!( + field("workspaceHostname") + .map(str::trim) + .map(str::to_lowercase) + .as_deref() + == Some(&host), + "Session and registry SSH hosts disagree" + ); + let session_connection = + string(config, "remote_connection_id").map(wi::canonical_ssh_connection_id); + let registered_connection = + string(&record["metadata"], "connectionId").map(wi::canonical_ssh_connection_id); + if let (Some(a), Some(b)) = (&session_connection, ®istered_connection) { + ensure!(a == b, "Session and registry SSH connections disagree"); + } + Some(( + host, + remote_path(&root)?, + session_connection.or(registered_connection), + )) + } else { + None + }; + let mut snapshots = BTreeSet::new(); + for value in [ + metadata.snapshot_session_id.as_deref(), + string(&state, "snapshot_session_id"), + ] { + if let Some(value) = value { + ensure!(safe_id(value), "Invalid snapshot session ID"); + snapshots.insert(value.to_string()); + } + } + ensure!( + snapshots.len() <= 1, + "Metadata/state snapshot session IDs disagree" + ); + Ok(Owner { + id, + root, + metadata, + documents, + remote, + snapshot_id: snapshots.into_iter().next(), + }) +} +fn references(value: &Value, ids: &mut BTreeSet) -> Result<()> { + match value { + Value::Object(fields) => { + for (key, child) in fields { + if [ + "snapshot_id", + "snapshotId", + "before_snapshot_id", + "after_snapshot_id", + ] + .contains(&key.as_str()) + && !child.is_null() + && child != "" + { + let id = child.as_str().context("Invalid snapshot reference")?; + ensure!(safe_id(id), "Invalid snapshot reference"); + ids.insert(id.into()); + } else { + references(child, ids)?; + } + } + } + Value::Array(children) => { + for child in children { + references(child, ids)?; + } + } + _ => {} + } + Ok(()) +} +pub(super) fn snapshot_assets(source: &Path, target: &Path, owner: &Owner) -> Result> { + let source_runtime = source.parent().unwrap().parent().unwrap(); + let target_runtime = target.parent().unwrap().parent().unwrap(); + let evidence = if source.exists() { source } else { target }; + let revert = evidence.join("session-revert.json"); + let mut ids = BTreeSet::new(); + if revert.exists() { + references(&json(&revert)?, &mut ids)?; + } + let relative = if let Some(remote) = &owner.remote { + if remote.2.is_none() { + ensure!( + !source_runtime + .join("snapshots/workspace-identities") + .exists() + && ids.is_empty(), + "Remote snapshot dependencies require an SSH connection ID" + ); + return Ok(vec![]); + } + PathBuf::from("snapshots/workspace-identities").join(hash(&serde_json::to_vec(remote)?)) + } else { + PathBuf::from("snapshots") + }; + let snapshots = source_runtime.join(relative); + let mut assets = BTreeMap::new(); + let mut add = |path: PathBuf| -> Result { + safe(&path, source_runtime)?; + let sha256 = file_hash(&path)?; + let dest = target_runtime.join(path.strip_prefix(source_runtime)?); + safe(&dest, target_runtime)?; + let old = optional_hash(&dest)?; + ensure!( + old.as_ref().is_none_or(|s| s == &sha256), + "Snapshot destination conflict: {}", + dest.display() + ); + assets.insert( + dest.clone(), + Asset { + source: path.clone(), + target: dest, + sha256, + existed: old.is_some(), + }, + ); + if path.extension().is_some_and(|s| s == "json") { + json(&path) + } else { + Ok(Value::Null) + } + }; + let sessions = BTreeSet::from([ + filename(source)?, + owner.snapshot_id.clone().unwrap_or(filename(source)?), + ]); + for sid in sessions { + let history = snapshots.join("operations").join(format!("{sid}.json")); + if history.exists() { + let raw = add(history)?; + ensure!( + raw["session_id"] == sid, + "Snapshot history session ID mismatch" + ); + references(&raw, &mut ids)?; + } + } + for sid in ids { + let category = if sid.starts_with("baseline_") { + "baselines" + } else { + "metadata" + }; + let value = add(snapshots.join(category).join(format!("{sid}.json")))?; + ensure!(value["snapshot_id"] == sid, "Snapshot metadata ID mismatch"); + let content_hash = + string(&value, "content_hash").context("Snapshot content hash is missing")?; + ensure!( + content_hash.len() == 32 + && content_hash + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "Unsupported snapshot content hash" + ); + let blob = snapshots + .join("by_hash") + .join(format!("{content_hash}.snap")); + if blob.exists() { + add(blob)?; + } else { + ensure!( + value + .get("compressed_content") + .is_some_and(|v| !v.is_null() && v != "" && v != &serde_json::json!([])), + "Missing snapshot content: {sid}" + ); + } + } + Ok(assets.into_values().collect()) +} diff --git a/src/crates/services/services-core/src/session_recovery/mod.rs b/src/crates/services/services-core/src/session_recovery/mod.rs new file mode 100644 index 0000000000..efd17aa3e4 --- /dev/null +++ b/src/crates/services/services-core/src/session_recovery/mod.rs @@ -0,0 +1,641 @@ +//! Offline, conservative recovery of sessions damaged by directory/brand migrations. +//! No runtime, SSH connection, permission policy or application host is started here. +mod engine; +mod identity; +#[cfg(test)] +mod tests; + +use anyhow::{ensure, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +pub use engine::{apply, rollback, task_phase}; +pub const FORMAT_VERSION: u32 = 1; +const FINGERPRINT_KIND: &str = "directory-layout-v1"; +const CONTROLS: &[&str] = &[ + "index.json", + "product-search-v1.sqlite", + "product-search-v1.sqlite-wal", + "product-search-v1.sqlite-shm", +]; +type Catalog = BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepairPlan { + pub version: u32, + pub fingerprint_kind: String, + pub home: PathBuf, + pub user_root: PathBuf, + pub registry_sha256: String, + pub operations: Vec, + pub diagnostics: Vec, + pub catalogs: BTreeMap, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepairOperation { + pub source: PathBuf, + pub target: PathBuf, + pub session_id: String, + pub session_name: String, + pub workspace_id: String, + pub workspace_path: String, + pub sha256: String, + pub repaired_sha256: String, + pub updates: BTreeMap, + pub identity_changes: Vec, + pub assets: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileUpdate { + pub before: Vec, + pub after: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityChange { + pub file: String, + pub field: String, + pub before: Value, + pub after: Value, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Asset { + pub source: PathBuf, + pub target: PathBuf, + pub sha256: String, + pub existed: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepairDiagnostic { + pub source: PathBuf, + pub status: String, + pub reason: Option, +} + +pub fn read_plan(task: &Path) -> Result { + safe(task, task)?; + let plan: RepairPlan = serde_json::from_slice(&read(&task.join("plan.json"))?)?; + ensure!( + plan.version == FORMAT_VERSION && plan.fingerprint_kind == FINGERPRINT_KIND, + "Unsupported session repair plan version" + ); + Ok(plan) +} + +/// Scan is read-only. Malformed or ambiguous identities become diagnostics. +pub fn scan(home: &Path, user_root: &Path, cancelled: impl Fn() -> bool) -> Result { + scan_with_progress(home, user_root, cancelled, |_, _, _, _| {}) +} + +pub fn scan_with_progress( + home: &Path, + user_root: &Path, + cancelled: impl Fn() -> bool, + progress: impl Fn(usize, usize, &str, &Path), +) -> Result { + safe(home, home)?; + safe(user_root, user_root)?; + let (records, registry_sha256) = identity::registry(user_root)?; + let aliases = identity::aliases(home, &records)?; + let mut plan = RepairPlan { + version: FORMAT_VERSION, + fingerprint_kind: FINGERPRINT_KIND.into(), + home: home.into(), + user_root: user_root.into(), + registry_sha256, + operations: vec![], + diagnostics: vec![], + catalogs: BTreeMap::new(), + }; + let mut roots = Vec::new(); + let mut catalog_cache: BTreeMap = BTreeMap::new(); + for runtime in children(&home.join("projects"))? { + if runtime.is_dir() { + roots.push(runtime.join("sessions")); + } + } + collect_remote_roots(&home.join("remote_ssh"), &mut roots)?; + let total_sessions = roots + .iter() + .map(|root| { + children(root).map(|entries| entries.iter().filter(|path| path.is_dir()).count()) + }) + .sum::>()?; + let mut completed_sessions = 0; + for root in roots { + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + if !root.is_dir() { + continue; + } + let session_dirs = children(&root)?; + let result = (|| -> Result<()> { + safe(&root, home)?; + let source_catalog = cached_catalog(&root, &mut catalog_cache)?; + plan.catalogs + .insert(root.clone(), source_catalog.catalog.clone()); + for source in session_dirs { + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + if !source.is_dir() { + continue; + } + let operation = (|| -> Result> { + safe(&source, home)?; + let sid = filename(&source)?; + ensure!(safe_id(&sid), "Invalid session directory name"); + let cached_metadata = source_catalog + .entries + .get(&sid) + .context("Session metadata is absent from catalog")?; + let documents = documents(&source, Some(cached_metadata))?; + let owner = identity::owner( + &source, + documents.clone(), + &records, + &aliases, + Some(cached_metadata), + )?; + let target = home + .join("projects") + .join(identity::runtime_key(&records[&owner.id])?) + .join("sessions") + .join(&sid); + safe(&target, home)?; + let mut updates = BTreeMap::new(); + let mut identity_changes = vec![]; + for (name, repaired) in &owner.documents { + if documents[name] != *repaired { + updates.insert( + name.clone(), + FileUpdate { + before: if name == "metadata.json" { + cached_metadata.raw.clone() + } else { + read(&source.join(name))? + }, + after: encode(repaired)?, + }, + ); + changes(name, "", &documents[name], repaired, &mut identity_changes); + } + } + if target == source && updates.is_empty() { + return Ok(None); + } + ensure!( + source == target || !target.exists(), + "Destination session already exists: {}", + target.display() + ); + let target_catalog = + cached_catalog(target.parent().unwrap(), &mut catalog_cache)?; + plan.catalogs.insert( + target.parent().unwrap().into(), + target_catalog.catalog.clone(), + ); + let (sha256, repaired_sha256) = directory_fingerprint_pair( + &source, + &BTreeMap::new(), + &update_side(&updates, false), + )?; + let assets = identity::snapshot_assets(&source, &target, &owner)?; + Ok(Some(RepairOperation { + source: source.clone(), + target, + session_id: sid, + session_name: owner.metadata.session_name, + workspace_id: owner.id, + workspace_path: owner.root, + sha256, + repaired_sha256, + updates, + identity_changes, + assets, + })) + })(); + let progress_path = source.clone(); + match operation { + Ok(Some(operation)) => plan.operations.push(operation), + Ok(None) => plan.diagnostics.push(RepairDiagnostic { + source, + status: "correct".into(), + reason: None, + }), + Err(error) => plan.diagnostics.push(RepairDiagnostic { + source, + status: "skipped".into(), + reason: Some(error.to_string()), + }), + } + completed_sessions += 1; + progress( + completed_sessions, + total_sessions, + "scanning_sessions", + &progress_path, + ); + } + Ok(()) + })(); + if let Err(error) = result { + plan.diagnostics.push(RepairDiagnostic { + source: root, + status: "skipped".into(), + reason: Some(error.to_string()), + }); + } + } + ensure!(!cancelled(), "Repair cancelled at a safe boundary"); + let mut targets: BTreeMap = BTreeMap::new(); + let mut assets: BTreeMap> = BTreeMap::new(); + for operation in &plan.operations { + *targets.entry(operation.target.clone()).or_default() += 1; + for asset in &operation.assets { + assets + .entry(asset.target.clone()) + .or_default() + .insert(asset.sha256.clone()); + } + } + plan.operations.retain(|operation| { + if targets[&operation.target] > 1 + || operation.assets.iter().any(|a| assets[&a.target].len() > 1) + { + plan.diagnostics.push(RepairDiagnostic { + source: operation.source.clone(), + status: "skipped".into(), + reason: Some( + "Multiple sessions claim conflicting destinations or snapshots".into(), + ), + }); + false + } else { + true + } + }); + let affected: BTreeSet = plan + .operations + .iter() + .flat_map(|o| { + [ + o.source.parent().unwrap().into(), + o.target.parent().unwrap().into(), + ] + }) + .collect(); + plan.catalogs.retain(|root, _| affected.contains(root)); + Ok(plan) +} + +pub fn save_plan(task: &Path, plan: &RepairPlan) -> Result<()> { + outside_data(task, plan)?; + ensure!(!task.exists(), "Repair task already exists"); + fs::create_dir_all(task)?; + write(&task.join("plan.json"), &encode(plan)?) +} + +fn collect_remote_roots(path: &Path, roots: &mut Vec) -> Result<()> { + safe(path, path)?; + if !path.is_dir() { + return Ok(()); + } + if filename(path)? == "sessions" { + roots.push(path.into()); + return Ok(()); + } + for child in children(path)? { + if child.is_dir() { + collect_remote_roots(&child, roots)?; + } + } + Ok(()) +} +fn changes(file: &str, field: &str, before: &Value, after: &Value, out: &mut Vec) { + if let (Some(left), Some(right)) = (before.as_object(), after.as_object()) { + let keys: BTreeSet<_> = left.keys().chain(right.keys()).collect(); + for key in keys { + changes( + file, + &if field.is_empty() { + key.clone() + } else { + format!("{field}.{key}") + }, + &before[key], + &after[key], + out, + ); + } + } else if before != after { + out.push(IdentityChange { + file: file.into(), + field: field.into(), + before: before.clone(), + after: after.clone(), + }); + } +} +#[derive(Debug, Clone)] +struct CatalogEntry { + raw: Vec, + value: Value, + metadata: crate::session::SessionMetadata, +} + +#[derive(Debug, Clone)] +struct CatalogSnapshot { + catalog: Catalog, + entries: BTreeMap, +} + +fn documents(path: &Path, metadata: Option<&CatalogEntry>) -> Result> { + let metadata = match metadata { + Some(entry) => entry.value.clone(), + None => json(&path.join("metadata.json"))?, + }; + let mut values = BTreeMap::from([("metadata.json".into(), metadata)]); + if path.join("state.json").exists() { + values.insert("state.json".into(), json(&path.join("state.json"))?); + } + Ok(values) +} +fn metadata(path: &Path, raw: Value) -> Result { + ensure!( + raw.get("schema_version") + .is_none_or(|value| value.as_u64() == Some(2)), + "Unsupported metadata schema" + ); + let metadata: crate::session::SessionMetadata = + serde_json::from_value(raw).context("Invalid session metadata")?; + ensure!( + metadata.session_id == filename(path)?, + "Metadata session ID does not match its directory" + ); + Ok(metadata) +} +fn catalog(path: &Path) -> Result { + Ok(catalog_snapshot(path)?.catalog) +} +fn catalog_snapshot(path: &Path) -> Result { + let mut catalog = BTreeMap::new(); + let mut entries = BTreeMap::new(); + for child in children(path)? { + if child.is_dir() && child.join("metadata.json").exists() { + let sid = filename(&child)?; + ensure!(safe_id(&sid), "Invalid session ID"); + let raw = read(&child.join("metadata.json"))?; + let value: Value = + serde_json::from_slice(raw.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&raw))?; + let parsed = metadata(&child, value.clone())?; + let sha256 = hash(&raw); + catalog.insert(sid.clone(), sha256.clone()); + entries.insert( + sid, + CatalogEntry { + raw, + value, + metadata: parsed, + }, + ); + } + } + Ok(CatalogSnapshot { catalog, entries }) +} +fn cached_catalog( + path: &Path, + cache: &mut BTreeMap, +) -> Result { + if let Some(snapshot) = cache.get(path) { + return Ok(snapshot.clone()); + } + let snapshot = catalog_snapshot(path)?; + cache.insert(path.into(), snapshot.clone()); + Ok(snapshot) +} +fn index_bytes(path: &Path) -> Result> { + let mut entries = Vec::new(); + for entry in catalog_snapshot(path)?.entries.values() { + entries.push(entry.metadata.clone()); + } + // Use the product owner for visibility, aliases, counters and schema. + encode(&crate::session::build_session_index_snapshot(entries, now_ms()).0) +} +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} +fn safe_id(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} +fn filename(path: &Path) -> Result { + Ok(path + .file_name() + .and_then(|s| s.to_str()) + .context("Non-UTF8 storage path")? + .into()) +} +fn safe(path: &Path, root: &Path) -> Result<()> { + ensure!( + path.is_absolute() && root.is_absolute() && path.starts_with(root), + "Storage path escapes its root: {}", + path.display() + ); + ensure!( + !path + .components() + .any(|p| matches!(p, Component::ParentDir | Component::CurDir)), + "Ambiguous storage path" + ); + for ancestor in path.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(info) => { + #[cfg(windows)] + let linked = { + use std::os::windows::fs::MetadataExt; + info.file_type().is_symlink() || info.file_attributes() & 0x400 != 0 + }; + #[cfg(not(windows))] + let linked = info.file_type().is_symlink(); + ensure!( + !linked, + "Linked/reparse paths are unsupported: {}", + ancestor.display() + ); + ensure!( + info.is_dir() || info.is_file(), + "Special files are unsupported" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) +} +fn outside_data(path: &Path, plan: &RepairPlan) -> Result<()> { + safe(path, path)?; + for root in [&plan.home, &plan.user_root] { + ensure!( + !path.starts_with(root) && !root.starts_with(path), + "Repair backup must be outside product data" + ); + } + Ok(()) +} +fn children(path: &Path) -> Result> { + safe(path, path)?; + if !path.exists() { + return Ok(vec![]); + } + let mut result = fs::read_dir(path)? + .map(|r| r.map(|e| e.path())) + .collect::>>()?; + result.sort(); + for child in &result { + safe(child, path)?; + } + Ok(result) +} +fn read(path: &Path) -> Result> { + safe(path, path)?; + ensure!(path.is_file(), "Missing regular file: {}", path.display()); + Ok(fs::read(path)?) +} +fn json(path: &Path) -> Result { + let raw = read(path)?; + Ok( + serde_json::from_slice(raw.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&raw)) + .with_context(|| format!("Invalid JSON: {}", path.display()))?, + ) +} +fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn file_hash(path: &Path) -> Result { + use std::io::Read; + safe(path, path)?; + ensure!(path.is_file(), "Missing regular file: {}", path.display()); + let mut file = fs::File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + digest.update(&buffer[..n]); + } + Ok(format!("{:x}", digest.finalize())) +} +fn optional_hash(path: &Path) -> Result> { + safe(path, path)?; + if path.exists() { + Ok(Some(file_hash(path)?)) + } else { + Ok(None) + } +} +fn encode(value: &impl Serialize) -> Result> { + let mut bytes = serde_json::to_vec_pretty(value)?; + bytes.push(b'\n'); + Ok(bytes) +} +fn write(path: &Path, bytes: &[u8]) -> Result<()> { + safe(path, path)?; + // Reuse the owner strict atomic replacement, including Windows sharing semantics. + static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + let runtime = RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("session recovery runtime initialization") + }); + runtime.block_on( + crate::json_store::JsonFileStore.write_bytes_atomic_strict(path, bytes.to_vec()), + )?; + Ok(()) +} +fn copy_file(source: &Path, target: &Path) -> Result<()> { + safe(source, source)?; + safe(target, target)?; + static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + let runtime = RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("session recovery runtime initialization") + }); + runtime.block_on(crate::json_store::JsonFileStore.copy_file_atomic_strict(source, target))?; + Ok(()) +} +fn directory_fingerprint_pair( + root: &Path, + before_overrides: &BTreeMap>, + after_overrides: &BTreeMap>, +) -> Result<(String, String)> { + fn visit(root: &Path, path: &Path, out: &mut BTreeMap>) -> Result<()> { + for child in children(path)? { + let relative = child + .strip_prefix(root)? + .to_str() + .context("Non-UTF8 path")? + .replace('\\', "/"); + if child.is_dir() { + out.insert(relative, None); + visit(root, &child, out)?; + } else { + out.insert(relative, Some(fs::metadata(&child)?.len())); + } + } + Ok(()) + } + safe(root, root)?; + ensure!(root.is_dir(), "Session directory is unavailable"); + let mut entries = BTreeMap::new(); + visit(root, root, &mut entries)?; + let fingerprint = |overrides: &BTreeMap>| -> Result { + let entries = entries + .iter() + .map(|(path, size)| { + ( + path.clone(), + size.map(|value| { + overrides + .get(path) + .map_or(value, |bytes| bytes.len() as u64) + }), + ) + }) + .collect::>(); + Ok(hash(&encode(&entries)?)) + }; + Ok(( + fingerprint(before_overrides)?, + fingerprint(after_overrides)?, + )) +} +fn tree_hash(root: &Path, overrides: &BTreeMap>) -> Result { + directory_fingerprint_pair(root, overrides, &BTreeMap::new()).map(|pair| pair.0) +} +fn update_side(updates: &BTreeMap, before: bool) -> BTreeMap> { + updates + .iter() + .map(|(name, value)| { + ( + name.clone(), + if before { + value.before.clone() + } else { + value.after.clone() + }, + ) + }) + .collect() +} diff --git a/src/crates/services/services-core/src/session_recovery/tests.rs b/src/crates/services/services-core/src/session_recovery/tests.rs new file mode 100644 index 0000000000..920f2066f0 --- /dev/null +++ b/src/crates/services/services-core/src/session_recovery/tests.rs @@ -0,0 +1,406 @@ +use super::*; +use serde_json::json; + +pub(super) struct Fixture { + _temp: tempfile::TempDir, + pub home: PathBuf, + pub user: PathBuf, + pub workspace: PathBuf, + pub source: PathBuf, + pub task: PathBuf, +} +impl Fixture { + pub(super) fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join(".openbitfun"); + let user = temp.path().join("config"); + let workspace = temp.path().join("project"); + fs::create_dir_all(&workspace).unwrap(); + let source = home.join("projects/legacy-collision/sessions/session-one"); + fs::create_dir_all(&source).unwrap(); + let task = temp.path().join("repair/task"); + let this = Self { + _temp: temp, + home, + user, + workspace, + source, + task, + }; + this.registry(json!({"id":"w1","rootPath":this.workspace,"workspaceKind":"normal"})); + let meta = crate::session::SessionMetadata::new( + "session-one".into(), + "Example".into(), + "agent".into(), + "model".into(), + ); + let mut meta = serde_json::to_value(meta).unwrap(); + meta["workspaceId"] = json!("w1"); + meta["workspacePath"] = json!(this.workspace); + this.put(&this.source.join("metadata.json"), meta); + this.put(&this.source.join("state.json"), json!({"schema_version":2,"config":{"workspace_id":"w1","workspace_path":this.workspace,"permission_mode":"full_access","future_field":{"x":1}},"context":"preserve"})); + this.put( + &this.source.parent().unwrap().join("index.json"), + json!({"old":"index"}), + ); + fs::write(this.source.join("turn.bin"), [0, 1, 255, 10]).unwrap(); + fs::write( + this.source + .parent() + .unwrap() + .join("product-search-v1.sqlite"), + b"rebuildable search cache", + ) + .unwrap(); + this + } + fn put(&self, path: &Path, value: Value) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, encode(&value).unwrap()).unwrap(); + } + fn registry(&self, record: Value) { + self.put(&self.user.join("data/workspace_data.json"),json!({"format_version":1,"product_id":"openbitfun","workspaces":{record["id"].as_str().unwrap():record}})); + } + fn plan(&self) -> RepairPlan { + scan(&self.home, &self.user, || false).unwrap() + } + pub(super) fn prepare(&self) -> RepairPlan { + let plan = self.plan(); + save_plan(&self.task, &plan).unwrap(); + plan + } +} +#[test] +fn misplaced_session_round_trip_keeps_every_byte_and_rebuilds_owner_index() { + let f = Fixture::new(); + let before = tree_hash(&f.source, &BTreeMap::new()).unwrap(); + let old_index = read(&f.source.parent().unwrap().join("index.json")).unwrap(); + let plan = f.prepare(); + assert_eq!(plan.operations.len(), 1); + assert!(f.source.exists()); + assert!(!plan.operations[0].target.exists()); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + assert_eq!(task_phase(&f.task).unwrap(), "completed"); + let target = &plan.operations[0].target; + assert!(!f + .source + .parent() + .unwrap() + .join("product-search-v1.sqlite") + .exists()); + assert_eq!(tree_hash(target, &BTreeMap::new()).unwrap(), before); + let index: crate::session::StoredSessionIndexFile = + serde_json::from_slice(&read(&target.parent().unwrap().join("index.json")).unwrap()) + .unwrap(); + assert_eq!(index.metadata_file_count, 1); + assert_eq!(index.sessions[0].session_id, "session-one"); + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + assert_eq!(tree_hash(&f.source, &BTreeMap::new()).unwrap(), before); + assert!(!target.exists()); + assert_eq!( + read(&f.source.parent().unwrap().join("index.json")).unwrap(), + old_index + ); + assert_eq!( + read(&f.source.parent().unwrap().join("product-search-v1.sqlite")).unwrap(), + b"rebuildable search cache" + ); +} +#[test] +fn assistant_alias_repairs_only_existing_fields_and_preserves_permissions() { + let f = Fixture::new(); + let root = f.home.join("personal_assistant/workspace-named"); + fs::create_dir_all(&root).unwrap(); + let old = f + .home + .with_file_name(".bitfun") + .join("personal_assistant/workspace-named"); + let old_id = crate::workspace_identity::local_workspace_stable_storage_id( + &old.to_str().unwrap().replace('\\', "/"), + ); + f.registry(json!({"id":"new-assistant","rootPath":root,"workspaceKind":"assistant"})); + let mut meta = json(&f.source.join("metadata.json")).unwrap(); + meta.as_object_mut().unwrap().remove("workspaceId"); + meta["workspacePath"] = json!(old); + f.put(&f.source.join("metadata.json"), meta); + let state = json!({"config":{"workspace_id":old_id,"workspace_path":old,"permission_mode":"full_access","future":{"a":2}}}); + f.put(&f.source.join("state.json"), state.clone()); + let plan = f.prepare(); + assert_eq!(plan.operations.len(), 1); + assert_eq!(plan.operations[0].identity_changes.len(), 3); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + let target = &plan.operations[0].target; + let repaired = json(&target.join("state.json")).unwrap(); + assert_eq!(repaired["config"]["workspace_id"], "new-assistant"); + assert_eq!(repaired["config"]["permission_mode"], "full_access"); + assert_eq!(repaired["config"]["future"], state["config"]["future"]); + assert!(json(&target.join("metadata.json")) + .unwrap() + .get("workspaceId") + .is_none()); + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + assert_eq!(json(&f.source.join("state.json")).unwrap(), state); +} +#[test] +fn remote_mirror_repairs_offline_without_local_posix_directory() { + let mut f = Fixture::new(); + let legacy_mirror = f + .home + .join("remote_ssh/host.example/home/user/project/sessions/session-one"); + fs::create_dir_all(legacy_mirror.parent().unwrap()).unwrap(); + fs::rename(&f.source, &legacy_mirror).unwrap(); + f.source = legacy_mirror; + f.registry(json!({"id":"remote_test","rootPath":"/home/user/project","workspaceKind":"remote","metadata":{"sshHost":"host.example","connectionId":"ssh-user@host.example"}})); + let mut meta = json(&f.source.join("metadata.json")).unwrap(); + meta["workspaceId"] = json!("remote_test"); + meta["workspacePath"] = json!("/home/user/project"); + meta["workspaceHostname"] = json!("host.example"); + f.put(&f.source.join("metadata.json"), meta); + f.put(&f.source.join("state.json"),json!({"config":{"workspace_id":"remote_test","workspace_path":"/home/user/project","workspace_kind":"remote","remote_ssh_host":"host.example","remote_connection_id":"ssh-user@host.example:22"}})); + let plan = f.prepare(); + assert_eq!(plan.operations.len(), 1); + assert_eq!( + plan.operations[0] + .target + .parent() + .unwrap() + .parent() + .unwrap() + .file_name() + .unwrap(), + crate::workspace_identity::remote_workspace_runtime_key( + "host.example", + "/home/user/project" + ) + .as_str() + ); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + rollback(&f.task, || false, |_, _, _| {}).unwrap(); +} +#[test] +fn missing_ids_use_unique_path_but_are_never_materialized() { + let f = Fixture::new(); + for name in ["metadata.json", "state.json"] { + let mut raw = json(&f.source.join(name)).unwrap(); + if name == "metadata.json" { + raw.as_object_mut().unwrap().remove("workspaceId"); + } else { + raw["config"] + .as_object_mut() + .unwrap() + .remove("workspace_id"); + } + f.put(&f.source.join(name), raw); + } + let plan = f.prepare(); + assert_eq!(plan.operations.len(), 1); + assert!(plan.operations[0].updates.is_empty()); + apply(&f.task, || false, |_, _, _| {}).unwrap(); +} +#[test] +fn stale_session_registry_catalog_and_backup_refuse_mutation() { + for what in ["session", "registry", "catalog"] { + let f = Fixture::new(); + let plan = f.prepare(); + match what { + "session" => fs::write(f.source.join("turn.bin"), "changed").unwrap(), + "registry" => f.registry(json!({"id":"other","rootPath":f.workspace})), + _ => { + let neighbor = f.source.with_file_name("neighbor"); + fs::create_dir_all(&neighbor).unwrap(); + fs::write(neighbor.join("metadata.json"), "{}").unwrap(); + } + } + assert!(apply(&f.task, || false, |_, _, _| {}).is_err()); + assert!(f.source.exists()); + assert!(!plan.operations[0].target.exists()); + } + let f = Fixture::new(); + let plan = f.prepare(); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + fs::write(f.task.join("sessions/0/turn.bin"), "tamper").unwrap(); + assert!(rollback(&f.task, || false, |_, _, _| {}).is_err()); + assert!(plan.operations[0].target.exists()); +} +#[test] +fn interrupted_move_resumes_or_rolls_back() { + for resume in [true, false] { + let f = Fixture::new(); + let plan = f.prepare(); + // Stop after the whole-session rename but before index publication. + let cancel = std::cell::Cell::new(false); + let result = apply( + &f.task, + || cancel.get(), + |_, _, phase| { + if phase == "repairing_sessions" { + cancel.set(true); + } + }, + ); + assert!(result.is_err()); + assert!(plan.operations[0].target.exists()); + assert_eq!(task_phase(&f.task).unwrap(), "prepared"); + if resume { + apply(&f.task, || false, |_, _, _| {}).unwrap(); + } + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + assert!(f.source.exists()); + } +} +#[test] +fn snapshots_shared_history_revert_blobs_copy_and_rollback_only_created_assets() { + let f = Fixture::new(); + let snapshots = f + .source + .parent() + .unwrap() + .parent() + .unwrap() + .join("snapshots"); + f.put( + &snapshots.join("operations/session-one.json"), + json!({"session_id":"session-one","operations":[{"snapshot_id":"snap-one"}]}), + ); + f.put( + &snapshots.join("metadata/snap-one.json"), + json!({"snapshot_id":"snap-one","content_hash":"0123456789abcdef0123456789abcdef"}), + ); + fs::create_dir_all(snapshots.join("by_hash")).unwrap(); + fs::write( + snapshots.join("by_hash/0123456789abcdef0123456789abcdef.snap"), + "blob", + ) + .unwrap(); + f.put( + &f.source.join("session-revert.json"), + json!({"workspaceCheckpoint":[{"snapshotId":"snap-one"}]}), + ); + let plan = f.prepare(); + assert_eq!(plan.operations[0].assets.len(), 3); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + for asset in &plan.operations[0].assets { + assert!(asset.source.exists() && asset.target.exists()); + } + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + for asset in &plan.operations[0].assets { + assert!(asset.source.exists() && !asset.target.exists()); + } +} +#[test] +fn ambiguous_unknown_conflicting_and_missing_snapshot_sessions_are_diagnostics() { + for reason in ["unknown", "conflicting", "snapshot", "neighbor"] { + let f = Fixture::new(); + match reason { + "unknown" => { + let mut value = json(&f.source.join("state.json")).unwrap(); + value["config"]["workspace_id"] = json!("unknown"); + f.put(&f.source.join("state.json"), value); + } + "conflicting" => { + let target = f + .home + .join("projects") + .join(crate::workspace_identity::local_workspace_runtime_key( + &crate::workspace_identity::normalize_local_workspace_root_for_stable_id( + &f.workspace, + ) + .unwrap(), + )) + .join("sessions/session-one"); + fs::create_dir_all(target).unwrap(); + } + "snapshot" => f.put( + &f.source.join("session-revert.json"), + json!({"snapshotId":"missing"}), + ), + _ => { + let neighbor = f.source.with_file_name("bad"); + f.put(&neighbor.join("metadata.json"), json!({})); + } + } + let plan = f.plan(); + assert!(plan.operations.is_empty()); + assert!(plan.diagnostics.iter().any(|d| d.status == "skipped")); + } +} + +#[test] +fn remote_scoped_snapshots_use_canonical_connection_and_leave_legacy_scope_untouched() { + let f = Fixture::new(); + f.registry(json!({"id":"remote_test","rootPath":"/home/user/project","workspaceKind":"remote","metadata":{"sshHost":"host.example","connectionId":"ssh-user@host.example"}})); + let mut meta = json(&f.source.join("metadata.json")).unwrap(); + meta["workspaceId"] = json!("remote_test"); + meta["workspacePath"] = json!("/home/user/project"); + meta["workspaceHostname"] = json!("host.example"); + f.put(&f.source.join("metadata.json"), meta); + f.put(&f.source.join("state.json"),json!({"config":{"workspace_id":"remote_test","workspace_path":"/home/user/project","workspace_kind":"remote","remote_ssh_host":"host.example","remote_connection_id":"ssh-user@host.example:22"}})); + let runtime = f.source.parent().unwrap().parent().unwrap(); + let scope = hash( + &serde_json::to_vec(&( + "host.example", + "/home/user/project", + Some("ssh-user@host.example"), + )) + .unwrap(), + ); + let snapshots = runtime.join("snapshots/workspace-identities").join(&scope); + f.put( + &snapshots.join("operations/session-one.json"), + json!({"session_id":"session-one","snapshot_id":"snap-one"}), + ); + f.put(&snapshots.join("metadata/snap-one.json"),json!({"snapshot_id":"snap-one","content_hash":"0123456789abcdef0123456789abcdef","compressed_content":[1,2,3]})); + f.put( + &runtime.join("snapshots/operations/session-one.json"), + json!({"legacy":"do not copy"}), + ); + let plan = f.prepare(); + assert_eq!(plan.operations.len(), 1); + assert_eq!(plan.operations[0].assets.len(), 2); + assert!(plan.operations[0] + .assets + .iter() + .all(|a| a.source.to_string_lossy().contains(&scope))); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + assert_eq!( + json(&runtime.join("snapshots/operations/session-one.json")).unwrap(), + json!({"legacy":"do not copy"}) + ); +} + +#[test] +fn legacy_metadata_aliases_hide_subagents_and_reject_unknown_schema_versions() { + let f = Fixture::new(); + let mut meta = json(&f.source.join("metadata.json")).unwrap(); + for (camel, snake) in [ + ("sessionId", "session_id"), + ("sessionName", "session_name"), + ("workspacePath", "workspace_path"), + ("workspaceId", "workspace_id"), + ] { + let value = meta.as_object_mut().unwrap().remove(camel).unwrap(); + meta[snake] = value; + } + meta["sessionKind"] = json!("subagent"); + f.put(&f.source.join("metadata.json"), meta); + let plan = f.prepare(); + assert_eq!(plan.operations.len(), 1); + apply(&f.task, || false, |_, _, _| {}).unwrap(); + let target = &plan.operations[0].target; + let index = json(&target.parent().unwrap().join("index.json")).unwrap(); + assert_eq!(index["metadata_file_count"], 1); + assert_eq!(index["sessions"], json!([])); + rollback(&f.task, || false, |_, _, _| {}).unwrap(); + let mut state = json(&f.source.join("state.json")).unwrap(); + state["schema_version"] = json!("unknown"); + f.put(&f.source.join("state.json"), state); + let plan = f.plan(); + assert!(plan.operations.is_empty()); + assert!(plan + .diagnostics + .iter() + .any(|d| d.reason.as_deref() == Some("Unsupported session state"))); +}