diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 46687d59..b7582b52 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,7 +23,7 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - uses: dtolnay/rust-toolchain@1.88.0 - - run: cargo check --locked --manifest-path src-tauri/Cargo.toml --all-targets + - run: cargo check --locked --workspace --manifest-path src-tauri/Cargo.toml --all-targets frontend: runs-on: ubuntu-22.04 @@ -63,13 +63,13 @@ jobs: uses: docker://rhysd/actionlint:1.7.12 shellcheck: - name: ShellCheck bundled WSL assets + name: ShellCheck WSL bootstrap runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v7 with: ref: ${{ inputs.target_sha }} - - name: Check bundled WSL operation scripts + - name: Check WSL bootstrap script run: >- docker run --rm --volume "$PWD:/work" @@ -92,7 +92,7 @@ jobs: shell: bash run: | mkdir -p ci-diagnostics - cargo fmt --manifest-path src-tauri/Cargo.toml -- --check 2>&1 | tee ci-diagnostics/fmt.log + cargo fmt --all --manifest-path src-tauri/Cargo.toml -- --check 2>&1 | tee ci-diagnostics/fmt.log - name: Upload Rust formatting diagnostics if: ${{ always() }} uses: actions/upload-artifact@v4 @@ -127,6 +127,13 @@ jobs: - uses: swatinem/rust-cache@v2 with: workspaces: './src-tauri -> target' + - name: Prepare Windows Cargo resource placeholders + if: runner.os == 'Windows' + shell: bash + run: | + mkdir -p src-tauri/target/wsl-worker/current + printf 'quality-check-placeholder\n' > src-tauri/target/wsl-worker/current/worker + printf '{}\n' > src-tauri/target/wsl-worker/current/manifest.json - name: Capture runner diagnostics shell: bash run: | @@ -143,11 +150,11 @@ jobs: } | tee ci-diagnostics/toolchain.txt - name: Check all Rust targets shell: bash - run: cargo check --locked --manifest-path src-tauri/Cargo.toml --all-targets 2>&1 | tee ci-diagnostics/check.log + run: cargo check --locked --workspace --manifest-path src-tauri/Cargo.toml --all-targets 2>&1 | tee ci-diagnostics/check.log - name: Lint all Rust targets if: ${{ always() }} shell: bash - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings 2>&1 | tee ci-diagnostics/clippy.log + run: cargo clippy --locked --workspace --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings 2>&1 | tee ci-diagnostics/clippy.log - name: Upload Rust static diagnostics if: ${{ always() }} uses: actions/upload-artifact@v4 @@ -171,6 +178,12 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ inputs.target_sha }} + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: pnpm + - run: pnpm install --frozen-lockfile - name: Install Tauri system dependencies if: runner.os == 'Linux' run: | @@ -180,6 +193,13 @@ jobs: - uses: swatinem/rust-cache@v2 with: workspaces: './src-tauri -> target' + - name: Prepare Windows Cargo resource placeholders + if: runner.os == 'Windows' + shell: bash + run: | + mkdir -p src-tauri/target/wsl-worker/current + printf 'quality-check-placeholder\n' > src-tauri/target/wsl-worker/current/worker + printf '{}\n' > src-tauri/target/wsl-worker/current/manifest.json - name: Capture runner diagnostics shell: bash run: | @@ -196,7 +216,17 @@ jobs: } | tee ci-diagnostics/toolchain.txt - name: Run Rust tests shell: bash - run: cargo test --locked --manifest-path src-tauri/Cargo.toml 2>&1 | tee ci-diagnostics/test.log + run: cargo test --locked --workspace --manifest-path src-tauri/Cargo.toml 2>&1 | tee ci-diagnostics/test.log + - name: Verify CLI private installation updates + shell: bash + run: | + cargo test --locked --workspace --manifest-path src-tauri/Cargo.toml native_update_private_only_cli_installation_in_place -- --ignored --nocapture 2>&1 | tee ci-diagnostics/cli-private-update.log + grep -Eq 'test native_workflow_integration_support::update_lifecycle::native_update_private_only_cli_installation_in_place \.\.\. ok' ci-diagnostics/cli-private-update.log + - name: Verify CLI Eve directory updates + shell: bash + run: | + cargo test --locked --workspace --manifest-path src-tauri/Cargo.toml native_eve_update_restores_recorded_missing_installation -- --ignored --nocapture 2>&1 | tee ci-diagnostics/cli-eve-update.log + grep -Eq 'test native_workflow_integration_support::update_lifecycle::native_eve_update_restores_recorded_missing_installation \.\.\. ok' ci-diagnostics/cli-eve-update.log - name: Upload Rust test diagnostics if: ${{ always() }} uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54ada48e..eb0d9689 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,34 @@ jobs: with: target_sha: ${{ needs.validate.outputs.commit_sha }} + build-wsl-worker: + needs: [validate, quality] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + - uses: actions/setup-node@v7 + with: + node-version: 22 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + - uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + - name: Build WSL Worker artifact + run: node scripts/prepare-wsl-worker.mjs + - name: Verify WSL Worker artifact + run: node scripts/prepare-wsl-worker.mjs --verify src-tauri/target/wsl-worker/current + - name: Upload WSL Worker artifact + uses: actions/upload-artifact@v4 + with: + name: wsl-worker-${{ needs.validate.outputs.commit_sha }} + path: src-tauri/target/wsl-worker/current + if-no-files-found: error + retention-days: 7 + prepare-release: needs: [validate, quality] runs-on: ubuntu-22.04 @@ -184,7 +212,7 @@ jobs: echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" build-release: - needs: [validate, quality, prepare-release] + needs: [validate, quality, prepare-release, build-wsl-worker] permissions: contents: write strategy: @@ -229,6 +257,15 @@ jobs: with: workspaces: "./src-tauri -> target" - run: pnpm install --frozen-lockfile + - name: Download WSL Worker artifact + if: ${{ runner.os == 'Windows' }} + uses: actions/download-artifact@v4 + with: + name: wsl-worker-${{ needs.validate.outputs.commit_sha }} + path: src-tauri/target/wsl-worker/current + - name: Verify WSL Worker artifact + if: ${{ runner.os == 'Windows' }} + run: pnpm verify:wsl-worker - name: Build and upload release bundles uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 env: @@ -244,6 +281,12 @@ jobs: retryAttempts: 3 releaseAssetNamePattern: skill-deck_[version]_${{ matrix.asset_platform }}_[arch][setup][ext] args: ${{ needs.validate.outputs.prerelease == 'true' && matrix.prerelease_args || matrix.args }} + - name: Verify NSIS WSL Worker resource + if: ${{ runner.os == 'Windows' }} + run: pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind nsis + - name: Verify MSI WSL Worker resource + if: ${{ runner.os == 'Windows' && needs.validate.outputs.prerelease != 'true' }} + run: pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind msi verify-release: needs: [validate, prepare-release, build-release] diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b746e6..5c76ef15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ## [Unreleased] +### Changed + +- **简化 Skill 维护操作** — 来源信息不完整或上游已删除时,用户可以保留或移除当前 Skill;需要重新使用时,通过普通安装流程重新安装,不再提供独立的来源修复和重新安装操作。 + ## [1.7.1] - 2026-08-20 ### Added diff --git a/README.md b/README.md index 06584705..a9e2c962 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ## ✨ Core capabilities - **Discover and install** — Browse available Skills, review their sources, documentation, and security information, or install from GitHub, Git, local directories, Well-known URLs, raw `SKILL.md` files, and ZIP/tar archives -- **Browse and maintain** — Read installed Skill content, check and apply updates, and select a new source when the saved source no longer works; private GitHub sources can reuse local Git credentials for update checks +- **Browse and maintain** — Read installed Skill content and check or apply updates; keep Skills whose sources are unavailable, or remove them before installing from another source; private GitHub sources can reuse local Git credentials for update checks - **Projects and agents** — View global and project Skills together, filter by agent, manage which agents can read a Skill, and copy Project Skills to other projects; use built-in agent definitions or add your own - **Cross-platform management** — Manage Skills on Windows, macOS, and Linux; Windows users can also switch to installed WSL distributions @@ -102,7 +102,7 @@ Download the latest release from [GitHub Releases](https://github.com/hccake/ski ### 1. Choose an install entry point -Choose a Skill from the Discover page, or open the install entry in Global Skills or a target project. When installing a Project Skill, add or select the project in the sidebar first. +Choose a Skill from the Discover page to open the installation wizard in the current environment. The wizard defaults to Global Skills; you can select an added project to install there. You can also open the install entry directly from Global Skills or a project on the Skills page. ### 2. Provide a Skill source @@ -121,7 +121,7 @@ npx skills add vercel-labs/agent-skills --skill frontend-design -a claude-code ### 3. Confirm and install -Select the Skills, target agents, and installation mode, review the change preview, and run the installation. Cross-host download redirects require explicit confirmation. Direct downloads are one-time, new-install sources and do not provide update, reinstall, or source-repair actions afterward. After installation, the selected agents can read the Skill; you can continue reading and managing agents from the Skills workspace. +Select the Skills, target agents, and installation mode, review the change preview, and run the installation. Cross-host download redirects require explicit confirmation. Direct downloads are one-time, new-install sources and do not provide updates; remove the installed Skill before installing it again. After installation, the selected agents can read the Skill; you can continue reading and managing agents from the Skills workspace. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index e94dfd6b..c1bb3a7d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,7 +37,7 @@ ## ✨ 核心能力 - **发现与安装**:从在线目录发现 Skill,查看来源、说明和安全信息,或者从 GitHub、Git、本地目录、约定地址(Well-known 地址)、原始 `SKILL.md` 文件和 ZIP、tar 归档安装 -- **浏览与维护**:阅读已安装 Skill 的完整内容,检查并执行更新,在来源失效时重新选择来源;私有 GitHub 来源可以复用本机 Git 凭据检查更新 +- **浏览与维护**:阅读已安装 Skill 的完整内容,检查并执行更新;来源失效时可以保留当前 Skill,或移除后从其他来源重新安装;私有 GitHub 来源可以复用本机 Git 凭据检查更新 - **项目与 Agent 管理**:统一查看全局与项目 Skill,按 Agent 筛选,调整 Skill 可供哪些 Agent 使用,并在项目之间复制 Skill;既可以使用随应用提供的 Agent 信息,也可以添加自己的 Agent 信息 - **跨平台管理**:在 Windows、macOS 和 Linux 上管理 Skill;Windows 用户还可以切换到已安装的 WSL 发行版 @@ -101,7 +101,7 @@ ### 1. 选择安装入口 -可以从“发现”页面选择 Skill,也可以在“全局 Skill”或目标项目中打开安装入口。安装项目 Skill 时,先在侧栏添加或选择对应项目。 +从“发现”页面选择 Skill 后,安装向导使用当前环境并默认选中全局,用户可以改选已添加的项目。也可以直接从 Skills 页的“全局 Skill”或项目区域打开安装入口。 ### 2. 提供 Skill 来源 @@ -120,7 +120,7 @@ npx skills add vercel-labs/agent-skills --skill frontend-design -a claude-code ### 3. 确认并安装 -选择需要安装的 Skill、目标 Agent 和安装方式,检查变更预览,然后执行安装。下载地址发生跨主机重定向时,需要明确确认最终下载主机。直接下载属于一次性全新安装来源,安装后不提供更新、重新安装或来源修复操作。安装完成后,关联的 Agent 即可读取该 Skill;你也可以在 Skills 工作台中继续阅读和管理 Agent。 +选择需要安装的 Skill、目标 Agent 和安装方式,检查变更预览,然后执行安装。下载地址发生跨主机重定向时,需要明确确认最终下载主机。直接下载属于一次性全新安装来源,安装后不提供更新;再次安装前需要先移除现有 Skill。安装完成后,关联的 Agent 即可读取该 Skill;你也可以在 Skills 工作台中继续阅读和管理 Agent。 --- diff --git a/docs/README.md b/docs/README.md index 2d9cd814..c9b5cd11 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Skill Deck 的长期文档说明当前产品、领域规则和系统约束。每 | [产品行为与交互](./product.md) | 用户看到的核心概念、页面能力、用户工作流、反馈规则、Agent 筛选交互和产品限制 | 用户可见能力、交互语义或产品限制变化 | | [Agent 模型](./agent-model.md) | Agent 注册表、Skill 读取位置、Agent 检测位置、关联 Agent、筛选候选、选择分组和安装初始选择 | Agent 模型、解析规则或选择语义变化 | | [Environment、Skill 位置与项目管理](./environments-and-projects.md) | 应用所在系统、Windows 与 WSL 切换、全局 Skill、项目 Skill、已添加项目、路径解析和跨文件系统访问 | Windows/WSL 切换、全局或项目行为、已添加项目、路径或文件访问规则变化 | -| [Skill 生命周期](./skill-lifecycle.md) | 来源、发现、安装、读取、更新、来源修复、管理 Agent、复制和移除 | Skill 从来源进入本地后的业务流程变化 | +| [Skill 生命周期](./skill-lifecycle.md) | 来源、发现、安装、读取、更新、管理 Agent、复制和移除 | Skill 从来源进入本地后的业务流程变化 | | [更新检查](./update-checking.md) | 远端版本比较、缓存、重试、限流、凭据和状态文件 | 远端版本比较或检查策略变化 | | [系统架构](./architecture.md) | 系统边界、应用内部结构、进程与窗口、IPC 契约、平台适配、数据归属和安全保证 | 顶层模块、典型调用方向、窗口、传输接口、平台实现或数据归属变化 | | [测试与验证规范](./testing.md) | 测试类型、覆盖范围、测试夹具、跨平台规则和断言原则 | 测试架构、平台测试能力、测试夹具或验证策略变化 | @@ -28,7 +28,7 @@ Skill Deck 的长期文档说明当前产品、领域规则和系统约束。每 | 修改用户可见功能、页面交互或 Agent 筛选 | [产品行为与交互](./product.md),再按涉及概念进入对应主文档 | | 修改 Skill 读取位置、Agent 检测位置、关联 Agent、筛选候选、选择分组或安装初始选择 | [Agent 模型](./agent-model.md) | | 修改应用所在系统、Windows/WSL 切换、全局或项目行为、已添加项目、路径解析或跨文件系统访问 | [Environment、Skill 位置与项目管理](./environments-and-projects.md) | -| 修改来源、发现、安装、更新、来源修复、管理 Agent、复制或移除 | [Skill 生命周期](./skill-lifecycle.md);涉及远端检查时增加[更新检查](./update-checking.md),涉及写入机制时增加[执行与恢复](./execution-and-recovery.md) | +| 修改来源、发现、安装、更新、管理 Agent、复制或移除 | [Skill 生命周期](./skill-lifecycle.md);涉及远端检查时增加[更新检查](./update-checking.md),涉及写入机制时增加[执行与恢复](./execution-and-recovery.md) | | 修改路由、跨窗口通信、Tauri 命令、WSL 传输或顶层依赖 | [系统架构](./architecture.md) | | 新增或修改测试、测试夹具、平台适配器验证或 E2E | [测试与验证规范](./testing.md);修改 CI 命令时增加[贡献指南](../CONTRIBUTING.md) | | 跟踪 `skills` CLI 版本变化 | [skills CLI 参考与兼容](./skills-cli-reference.md),再按变化类型进入受影响的领域主文档 | diff --git a/docs/adr/0010-use-single-writer-library-persistence.md b/docs/adr/0010-use-single-writer-library-persistence.md new file mode 100644 index 00000000..fb13825a --- /dev/null +++ b/docs/adr/0010-use-single-writer-library-persistence.md @@ -0,0 +1,11 @@ +--- +status: accepted +--- + +# Skill Library 采用单写者持久化模型 + +Skill Library catalog 和成员目录只由当前 Skill Deck 进程写入。Tauri 单实例机制限制正常运行时的应用实例数量,`RuntimeAdmissionCoordinator` 统一控制持久化写入许可,Runtime Library Adapter 再按 Environment 串行化 catalog、成员内容和内部恢复 I/O。其他进程、命令行工具和用户直接修改 catalog 不属于受支持的写入方式。 + +这项取舍删除了 Native 操作系统文件锁和 WSL `flock` 守卫协议,同时保留成员条件提交、catalog 原子写入、目录备份和崩溃恢复。全局和项目 Skill 的兼容 lock 仍可能被 `skills` CLI 或用户工具修改,因此继续在提交前重新读取并检查修订。 + +正式支持多个 Skill Deck 实例、增加独立后台写入进程、允许其他产品写入 Library,或者让多个设备共享同一 Library 数据目录时,必须重新讨论跨进程协调协议。当前行为由[系统架构](../architecture.md#应用内部结构)、[执行与恢复](../execution-and-recovery.md#原子写入与-lock-提交)和[测试与验证规范](../testing.md#http时间与外部进程)负责说明。 diff --git a/docs/adr/0011-centralize-scope-skill-version-election.md b/docs/adr/0011-centralize-scope-skill-version-election.md new file mode 100644 index 00000000..d1b478d9 --- /dev/null +++ b/docs/adr/0011-centralize-scope-skill-version-election.md @@ -0,0 +1,23 @@ +--- +status: accepted +--- + +# Scope 内 Skill 版本选举采用统一规划 Module + +一个 Scope 同时包含通用 Skill 目录和多个 Agent 专用 Skill 目录。同名 Skill 可能具有直接安装版本,也可能来自多个按顺序应用的库。通用目录、各 Agent 专用目录以及多个 Agent 共用的物理目录需要分别确定当前使用的版本。 + +Skill Deck 先通过统一的 Agent 目录表解析通用 Skill 目录和 Agent 安装选项目录,再为具体 Skill 追加安全目录名。库候选 Source 独立读取库应用记录、catalog 和真实成员定位信息,并把预览证据与纯候选集合分开。安装、复制、移除、管理 Agent 和管理库应用通过 `application/scope_skill_planning` 规划 Scope 内同名 Skill;低层物理目录选举只作为该 Module 的私有实现。 + +选举 Module 按 `PhysicalTargetKey` 合并指向同一物理目录的位置。任一位置需要直接版本时,直接版本胜出;否则只选择第一个适用的有序库候选。后续候选不会参与胜出判断,只用于识别当前链接是否来自较低优先级或先前应用的库。当前版本中,同一 Scope 应用的所有库共用一组选定 Agent;Agent ID 只说明目录读取者和错误归属,不参与版本胜出判断。 + +通用 Skill 目录优先使用 Scope 中的直接安装版本,没有直接版本时按库顺序使用第一个同名版本。Agent 专用 Skill 目录只在直接 Skill 关联该物理目录时优先使用直接版本;否则使用关联到该目录且顺序最靠前的库版本。两类版本都不适用时,规划结果不保留该 Skill。通用目录存在直接版本,不会改变其他 Agent 专用目录的选举结果。 + +统一规划 Module 负责把直接安装 Skill 的 Agent 关联变化和 Skill 库应用关系转换成物理目录计划。链接、复制和转换内容是直接安装版本的写入方式,不改变其选举优先级。调用方继续负责内容取得、lock 或库应用记录、预览凭据、执行和未完成操作。所有相关写入流程都使用非可选的库候选 Source;没有可用库版本时,Source 返回带证据的空候选快照,调用方仍然经过统一选举。每个计划目录都进入执行期复核;无需写入的目录使用只读 `Keep`,旧预览不能授权已经变化的目录写入或应用状态提交。 + +当前版本没有为历史 Agent 安装项新增独立的所有权记录。规划 Module 能够明确识别已知库链接;为了兼容旧版 Skill Deck 和其他兼容工具已经创建的安装项,有效目录、有效符号链接或 junction 作为该物理目录中的直接版本保留。失效链接可以在存在目标版本时替换;文件等不能作为 Skill 目录处理的条目保持不变,并返回冲突。这项兼容规则不能证明有效条目的创建者,只保证当前操作不会把它误改为库版本。 + +这项决策替代了安装、复制、移除、管理 Agent 和库应用各自解释优先级的做法。分散处理会让通用目录中的直接版本错误影响其他 Agent 目录,也会在取消直接关联后遗漏库版本恢复。调用方向选举 Module 提交完整的有序候选,由选举 Module 统一取第一项;这让同一套目录识别和版本选择同时服务写入计划、管理 Agent 状态和库管理预览。 + +正式支持给正在应用的库增删成员并向多个 Scope 传播变化、需要精确区分 Skill Deck 与其他工具创建的 Agent 安装项,或者引入新的 Agent 内容转换与目录形态时,必须重新讨论版本选举输入、所有权证据和跨 Scope 的执行与恢复协议。当前版本继续禁止修改正在应用的库成员。 + +当前行为由 [领域词汇](../../CONTEXT.md#skill-位置与目录)、[Agent 模型](../agent-model.md#agent-选择与安装位置分组)、[Skill 生命周期](../skill-lifecycle.md#安装)、[系统架构](../architecture.md#应用内部结构)、[执行与恢复](../execution-and-recovery.md#skill-变更如何执行) 和 [测试与验证规范](../testing.md#后端测试) 负责说明。 diff --git a/docs/adr/0012-environment-local-documents-omit-runtime-environment.md b/docs/adr/0012-environment-local-documents-omit-runtime-environment.md new file mode 100644 index 00000000..2f74baaf --- /dev/null +++ b/docs/adr/0012-environment-local-documents-omit-runtime-environment.md @@ -0,0 +1,11 @@ +--- +status: accepted +--- + +# Environment 本地文档不保存运行时 Environment 身份 + +`EnvironmentRef` 表示当前 Host 如何路由 Native 或 WSL 操作,同一 Linux 用户空间由 Linux Native 和 Windows WSL 访问时会得到不同的运行时表示。Environment 本地文档的归属已经由 Store、Adapter 和存储路径确定,因此只保存业务状态和 Environment 内部键;Repository Interface 显式接收运行时 Context,并在加载后绑定当前 Environment。Host 本地配置需要恢复用户选择或跨 Environment 路由时仍可保存 `EnvironmentRef`。 + +库应用记录在首次发布前按该规则删除 `target`,旧开发记录中的多余字段由当前 reader 忽略。Recovery marker 仍包含运行时 Environment 和 `ResourceLocator`,在迭代 5 迁移 Recovery 时按同一规则拆分磁盘状态与运行时索引,不在本次兼容修复中提前改写恢复协议。 + +同一 Environment 本地存储仍采用单写者模型。Linux Native Skill Deck 与 Windows Skill Deck 可以顺序访问同一个 WSL 用户空间,但不能同时写入;正式支持多个跨 Host 写入进程时需要重新讨论 ADR-0010 的协调协议。 diff --git a/docs/agent-model.md b/docs/agent-model.md index 3599fd45..aea809ac 100644 --- a/docs/agent-model.md +++ b/docs/agent-model.md @@ -119,6 +119,8 @@ Agent 在全局和 Project 中分别声明支持情况和扫描方式,`Both` 每个安装选项对应一个可以独立选择和写入的 Agent 专用 Skill 目录。多个 Agent 的专用目录指向同一文件系统位置时,后端将它们合并为一个安装选项。界面只显示一个 Checkbox,但保留组内所有 Agent ID;预览和执行阶段也只写入一次。 +选择界面统一处理选项展示、当前勾选、展开状态和快照过期后的重新确认。各业务分别提供打开界面时的选中项:安装使用入口意图、检测结果和最近选择;管理 Agent 使用当前直接安装关系;复制使用源 Skill 的直接安装关系;库应用使用当前 Scope 已保存的 Agent 关联。只有普通安装会读取和更新最近选择。 + 如果一个合并选项同时包含“只读取专用目录”和“两类目录都读取”的 Agent,该选项只显示在“选择后可使用”区域。其中读取通用 Skill 目录的 Agent 仍然显示在自动可用信息区。 Agent 专用 Skill 目录与当前通用 Skill 目录解析到同一文件系统身份时,后端将该 Agent 视为可直接使用,不再生成单独安装选项或专用目录写入选项。该规则依据实际文件系统身份生效,不依赖保存信息中的路径写法。 @@ -127,7 +129,7 @@ Agent 专用 Skill 目录与当前通用 Skill 目录解析到同一文件系统 安装请求可以携带注册表中尚不存在的 Agent ID。应用保留原始 ID,并在选择内容之前说明本次无法处理这些 Agent。未知 Agent 不显示 Checkbox,也不参与检测分组、安装位置合并和安装方式计算;用户无需在当前流程中手动移除它们,它们不会阻止当前 Skill 的安装,其他有效 Agent 安装项也可以继续执行。 -复制与安装使用同一套 Agent 信息、安装选项和选择请求。用户为一个目标 Environment 选择一次 Agent,这套选择会应用到其中的全部目标项目。Backend 根据勾选的安装选项解析目录,并在各项目中生成实际写入路径。检测状态只用于界面提示,不决定复制时是否向 Agent 专用 Skill 目录写入。 +复制与安装使用同一套 Agent 信息、安装选项和选择请求。复制界面按照源 Skill 当前的直接安装关系预先选择 Agent,再将用户确认的关联映射到同一目标 Environment 中的全部目标项目。目标项目已有的其他 Agent 关联继续保留。跨 Environment 复制时,选择界面不展示来自源 Environment 的 Agent 检测状态和目录;后端仍根据每个目标项目的实际目录生成写入计划。 物理身份和写入安全见[执行与恢复](./execution-and-recovery.md);管理 Agent 的业务结果见[Skill 生命周期](./skill-lifecycle.md)。 @@ -140,7 +142,7 @@ Agent 专用 Skill 目录与当前通用 Skill 目录解析到同一文件系统 3. 检测到多个 Agent 时,从当前 Environment 的 `lastSelectedAgents` 恢复仍然有效的 Agent;没有历史时不预选需要写入专用目录的 Agent; 4. 没有检测到 Agent 时,在当前有效 Agent 中采用 `claude-code`、`opencode` 和 `codex`,与参考版本的 `skills` CLI 保持一致。 -读取通用 Skill 目录的 Agent 自动可用,不需要用户勾选,也不会因为自动可用而勾选其可选的专用目录。用户确认本次选择后,应用把当前 `skills` CLI 能够识别的内置 Agent 写入当前 Environment 的全局 `lastSelectedAgents`。全局安装和项目安装共用这份最近选择;用户添加的 Agent 和 Eve 专用目标不写入该字段。 +读取通用 Skill 目录的 Agent 自动可用,不需要用户勾选,也不会因为自动可用而勾选其可选的专用目录。用户在 Agent 选择页确认并进入安装确认步骤时,应用把当前 `skills` CLI 能够识别的内置 Agent 写入当前 Environment 的全局 `lastSelectedAgents`。该字段记录最近确认的安装选择,不表示安装已经成功。全局安装和项目安装共用这份最近选择;用户添加的 Agent、Eve 专用目标、额外写入 Agent 自己目录的可选项和安装方式不写入该字段。 应用会忽略历史中已经无效或无法识别的 Agent ID,但不会为了清理历史而单独改写 lock。读取历史失败时,向导仍允许用户选择目标;保存历史失败时,本次安装仍可继续。Agent 注册表或目录状态变化导致选择 revision 过期时,用户需要根据最新选项重新确认。 diff --git a/docs/architecture.md b/docs/architecture.md index 9712da4e..cdbe5553 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,7 @@ Skill Deck 是一款运行在用户电脑上的跨平台桌面应用,支持 Wi - 外部 AI Agent 独立读取约定位置中的 Skill;第三方 `skills` CLI 和 Skill Deck 使用相同的通用 Skill 存放位置和兼容的 lock 数据,各自独立管理 Skill; - 主窗口和安装向导共享 Rust 运行时,但只获得各自工作需要的命令权限; - React 与 Rust 使用同一份生成的进程间通信(IPC)契约; -- 安装、更新、来源修复、复制、移除和管理 Agent 复用同一套预览、执行与恢复接口; +- 安装、更新、复制、移除和管理 Agent 复用同一套预览、执行与恢复接口; - 文件写入、凭据和外部进程分别经过适合自身风险的安全接口。 ## 系统边界 @@ -90,7 +90,7 @@ flowchart LR `RuntimeServiceGraph` 是 Rust 后端的组合根,也是 Tauri Managed State。Tauri 在启动阶段创建该组合根,命令处理函数从中取得长生命周期状态、共享业务流程和平台能力。只服务于单个命令的流程可以保留在对应命令模块中;需要跨入口复用、长期持有状态或隔离平台差异时,再提取到相应模块。`RuntimeAdmissionCoordinator` 由组合根持有,统一协调安装向导会话、Skill 写操作、设置变更、应用生命周期和应用更新之间的运行许可。 -`application/mutation` 统一提供预览凭据签发与校验、变更计划组装和计划执行 Interface(调用方依赖的执行接口)。安装、更新、来源修复、复制、移除和调整 Agent 关联等应用用例负责各自的业务策略,把已经决定的写入内容交给规划模块,并调用该 Interface。`RuntimePlanExecutor` 作为运行时 Adapter 协调变更任务,具体 Environment Adapter 负责目标文件系统上的读取与写入。写入的一致性和恢复协议见[执行与恢复](./execution-and-recovery.md)。 +`application/mutation` 统一提供预览凭据签发与校验、变更计划组装和计划执行 Interface(调用方依赖的执行接口)。安装、更新、复制、移除和调整 Agent 关联等应用用例负责各自的业务策略,把已经决定的写入内容交给规划模块,并调用该 Interface。`RuntimePlanExecutor` 作为运行时 Adapter 协调变更任务,具体 Environment Adapter 负责目标文件系统上的读取与写入。写入的一致性和恢复协议见[执行与恢复](./execution-and-recovery.md)。 ## IPC 契约 @@ -106,7 +106,13 @@ Tauri 的 capability 和 permission 配置采用默认拒绝策略。每个窗 外部网络能力由 Skill 目录、Skill 来源、来源证据、连接测试和应用更新等具体产品 Module 提供。Command 只进入这些 Module 的 Interface;Application 不依赖通用 HTTP 请求、网络用途枚举、代理路由计划、reqwest、Git 进程、WSL session 或 Tauri Updater 类型。产品 Module 隐藏具体外部实现;只有平台实现或外部能力确实存在变化时才建立 seam,单一具体流程直接由 Runtime Module 实现。 -Rust 运行时持有当前已经验证的代理设置,设置保存成功后才原子替换。HTTP 请求、Git 命令和 Updater 操作在实际执行前读取当前设置;底层客户端或进程已经开始的单次执行继续使用创建时的配置,后续执行使用新设置。连接测试使用页面传入的设置草稿,不替换运行时设置。 +`ConfigStore` 管理应用配置的原始文档快照、校验、保存和生效值,由 `RuntimeServiceGraph` 持有。设置查询、Git 超时和 `ProxySettingsStore` 使用同一份内存配置。启动与用户主动重新读取时访问配置文件,普通设置查询和网络操作读取内存快照。 + +代理校验按 HTTP、本机 Git 和各 WSL 发行版分别记录故障。重新读取时,有效部分使用新值,失败部分保留上一次有效值;没有有效值的部分返回配置错误。WSL 跟随本机 Git 时同时检查这两项配置。未知的代理字段使整个代理结构处于不支持状态。首次加载及故障状态变化时记录配置位置、故障范围和处理方式。 + +配置保存只替换明确修改的字段,保留其他原始 JSON 字段。代理修复直接校验新设置,允许替换无法解析的旧代理片段。写入根据读取时的文件快照进行条件原子替换,并检查已存在配置文件的物理位置;外部变更导致保存冲突。写入结果不明确时,应用重新核对文件,保留原生效值并返回需要检查的状态。 + +设置成功持久化后才替换当前生效值。请求和进程在实际执行前读取设置,已经开始的执行继续使用原配置。连接测试使用独立的页面草稿,不替换运行时设置。WSL 的启用、停用和运行许可继续由 `WslRuntime` 管理。 Discover 页面通过受限的 Tauri 命令访问 `www.skills.sh`。当前 Discovery 数据契约使用 `/api/search`;排行榜、详情及前端补全的站内地址使用相同的规范站点。本次主机名统一不改变 API 版本或响应模型。官方发布者信息在搜索或榜单首次实际使用时由后台加载,核心内容只读取当时已经完成的进程内缓存,不等待这项辅助请求。加载失败不会写入缓存,后续实际请求可以再次尝试。Discover 返回的安全审计信息只随 Discover 内容展示;应用不会把已安装 Skill 或用户手动输入的来源自动发送给第三方审计服务。 @@ -154,7 +160,7 @@ React 工作流 -> 结构化结果和最新快照 ``` -安装、更新、来源修复、复制、移除和管理 Agent 共用这条主链。执行用例会先取得运行许可,再重新读取当前目录、lock、Agent 选择和运行状态,确认预览仍然有效。预览与执行之间的校验、原子写入、取消和恢复由[执行与恢复](./execution-and-recovery.md)维护;来源获取与安装规则见[Skill 生命周期](./skill-lifecycle.md),远端版本比较与缓存规则见[更新检查](./update-checking.md)。 +安装、更新、复制、移除和管理 Agent 共用这条主链。执行用例会先取得运行许可,再重新读取当前目录、lock、Agent 选择和运行状态,确认预览仍然有效。预览与执行之间的校验、原子写入、取消和恢复由[执行与恢复](./execution-and-recovery.md)维护;来源获取与安装规则见[Skill 生命周期](./skill-lifecycle.md),远端版本比较与缓存规则见[更新检查](./update-checking.md)。 ## 平台实现 diff --git a/docs/environments-and-projects.md b/docs/environments-and-projects.md index b461e15b..6d383085 100644 --- a/docs/environments-and-projects.md +++ b/docs/environments-and-projects.md @@ -89,7 +89,7 @@ Agent 的路径声明和实际路径也分开处理:相对于 Home 或 ConfigH ## 把 Skill 复制到其他项目 -普通安装、更新和来源修复都在能够直接管理目标文件的 Windows、macOS、Linux 或 WSL 用户空间中完成。把 Skill 复制到其他项目时,来源 Environment 先保存完整的 Skill 内容快照,目标 Environment 再根据项目路径和访问状态执行受保护写入。 +普通安装和更新都在能够直接管理目标文件的 Windows、macOS、Linux 或 WSL 用户空间中完成。把 Skill 复制到其他项目时,来源 Environment 先保存完整的 Skill 内容快照,目标 Environment 再根据项目路径和访问状态执行受保护写入。 目标项目必须能够由用户选择的 Windows 用户空间或 WSL 发行版直接管理。目标项目处于跨文件系统状态时,复制流程会要求用户切换到项目文件所在的 Environment,而不会把当前 Environment 自动视为文件系统归属方。 diff --git a/docs/execution-and-recovery.md b/docs/execution-and-recovery.md index 50e6962d..1238c35c 100644 --- a/docs/execution-and-recovery.md +++ b/docs/execution-and-recovery.md @@ -1,6 +1,6 @@ # 执行与恢复 -安装、更新、来源修复、复制、移除和调整 Agent 关联都会修改 Skill 目录或相关 lock 文件。Skill Deck 在写入前重新检查目录、lock、Agent 选择和当前运行状态,并把每个 Skill 或目标项目作为可以独立完成的写入任务。写入失败后,如果系统无法确认 Skill 目录与 lock 仍然一致,应用会保留恢复记录,供用户检查和清理。 +安装、更新、复制、移除和调整 Agent 关联都会修改 Skill 目录或相关 lock 文件。Skill Deck 在写入前重新检查目录、lock、Agent 选择和当前运行状态,并把每个 Skill 或目标项目作为可以独立完成的写入任务。写入失败后,如果系统无法确认 Skill 目录与 lock 仍然一致,应用会保留恢复记录,供用户检查和清理。 受保护写入是指会修改最终 Skill 目录、Agent 专用 Skill 目录或相关 lock 记录,并需要在失败后保持或恢复一致性的操作。lock 文件保存 Skill 的来源及相关安装信息,提交时还要保留其他程序写入的无关记录和未知字段。 @@ -35,10 +35,16 @@ flowchart TD - 目标位置已有的符号链接、Windows 目录联接或重解析点作为目标目录项处理,不跟随它们读取其他位置; - 临时内容由负责获取内容的系统或 WSL 发行版管理,路径采用该 Environment 可以直接解析的格式。 -安装、来源修复以及在 Windows 与 WSL 之间复制时,会在生成预览前固定内容。更新先根据当前本地状态生成预览,用户确认后再取得新版本内容;取得内容后,后续检查和写入始终使用同一份快照。 +安装以及在 Windows 与 WSL 之间复制时,会在生成预览前固定内容。更新先根据当前本地状态生成预览,用户确认后再取得新版本内容;取得内容后,后续检查和写入始终使用同一份快照。 获取来源或创建快照失败时,写入尚未开始,应用返回相应的来源或预览错误;已经固定的快照过期时返回“结果已过期”。这些情况不会创建恢复记录。在 Windows 与 WSL 之间复制时,来源 Environment 只负责固定内容,目标 Environment 负责实际写入、lock 提交和恢复数据。复制的来源保留规则见[Skill 生命周期](./skill-lifecycle.md#复制到项目)。 +同一发现会话中的同一个 Skill 共用正在进行的内容准备任务,后续请求等待同一结果。不同 Skill 可以分别准备;不同发现会话、Environment 或 WSL Worker 代次之间保持隔离。已有快照的复用继续校验来源证据,安装预览仍根据本次目标和选择重新生成。 + +调用方离开后,已经启动的内容准备和校验任务继续负责 I/O 与必要清理,其他调用方仍可等待共享结果。临时占用在实际 I/O 完成后释放,内容通过清单和容量检查后才发布。准备失败时清理未发布内容,再允许重试;清理失败时保留会话的收尾责任,并拒绝复用该会话。 + +安装准备遇到发现会话过期或内容失效时,需要重新获取来源和 Skill 列表,再核对用户选择。目标状态变化时可以保留有效内容,重新生成预览并要求确认。 + ## 预览与执行 预览根据当前目录、lock、Agent 选择和目标能力列出将要执行的操作、覆盖内容和风险。用户确认后,应用先取得应用内写入许可,再重新读取当前状态。预览只用于帮助用户确认操作,不能代替执行前的权限和路径检查。 @@ -54,7 +60,7 @@ flowchart TD 应用返回“结果已过期”后,界面重新生成预览,让用户再次确认目标、覆盖项和风险。失败、取消或部分完成时,当前工作流保留未完成项目及可用的后续操作;已经成功的项目不再进入下一次重试。 -安装、来源修复、复制、移除和调整 Agent 关联在执行前要求预览凭据与当前状态完整一致。更新在取得新版本内容前也执行完整校验;内容取得后继续校验操作位置和运行时修订,并逐个 Skill 比较本地内容与来源记录。只有发生变化的 Skill 会被标记为结果过期,其余 Skill 仍可继续更新。 +安装、复制、移除和调整 Agent 关联在执行前要求预览凭据与当前状态完整一致。更新在取得新版本内容前也执行完整校验;内容取得后继续校验操作位置和运行时修订,并逐个 Skill 比较本地内容与来源记录。只有发生变化的 Skill 会被标记为结果过期,其余 Skill 仍可继续更新。 批量操作按目标项目或 Skill 分别读取状态、生成计划并返回结果。某个目标的路径、lock 或存储状态无法使用时,其他互不冲突的目标仍可继续。Agent 检测结果只影响提示和推荐,实际写入位置由用户确认的 Agent 选择决定,详细规则见[Agent 模型](./agent-model.md)和[Skill 生命周期](./skill-lifecycle.md)。 @@ -142,6 +148,8 @@ Windows、macOS、Linux 和 WSL 使用相同的预览、检查、提交和恢复 ## 恢复记录与恢复数据 +应用继续识别旧版本来源修复操作留下的恢复记录,允许用户查看和清理对应数据;当前版本不再创建新的来源修复操作。 + 恢复标记在任务进入受保护写入前保存,用于记录本次操作负责的目标、备份和执行阶段。任务正常完成或成功还原后,系统删除标记和备份;系统无法确认目录与 lock 一致时,标记和必要备份会作为恢复数据保留,并在界面中形成恢复记录。 恢复记录只处理已经进入受保护写入的任务。来源获取、生成预览、普通配置写入和写入前检查失败时,最终 Skill 目录尚未改变,原工作流直接显示错误或警告,不创建恢复记录。 diff --git a/docs/product.md b/docs/product.md index 7a2160f6..69eaa9f2 100644 --- a/docs/product.md +++ b/docs/product.md @@ -77,7 +77,7 @@ Environment 切换、全局 Skill 和已添加项目的完整规则见[Environme - 卡片用一个注意图标和持续可见的短文案集中展示来源异常、更新检测异常和重复安装关系。来源信息不完整时只显示“来源信息不完整”,不会重复显示由此导致的更新检测异常;来源中的原 Skill 已不存在时显示“来源中已找不到此 Skill”。全局和当前项目都存在同名 Skill 时显示“全局和项目中重复安装”;Agent 可以读取通用目录、但其专用目录仍保留安装项时显示“通用目录和 Agent 专用目录中均已安装”。这两项只说明实际安装关系,不推断内容是否同步。 - Skill 卡片和详情只列出当前已检测到的关联 Agent,并允许标签自然换行。没有检测到的 Agent 不在界面中逐一列出;完整关联规则见[Agent 模型](./agent-model.md#关联-agent)。 - 选中 Skill 后,界面切换为列表与详情分栏。详情先按当前目标、上游来源、本地安装、使用关系、安全与维护分组展示信息,再展示 `SKILL.md` 内容。列表没有单独的详情按钮,用户可以点击 Skill 名称或卡片进入详情。 -- 可用操作包括检查更新、执行更新、管理 Agent、复制到项目、修复来源和移除。卡片尾部直接显示当前可用操作,用户可以通过一次点击发起对应操作;需要确认的操作继续使用确认界面。 +- 可用操作包括检查更新、执行更新、管理 Agent、复制到项目和移除。卡片尾部直接显示当前可用操作,用户可以通过一次点击发起对应操作;需要确认的操作继续使用确认界面。 - Skill 内容读取失败时,列表和详情中的其他信息继续显示,内容区域允许用户重试。 - 写操作进行时,界面禁用与其冲突的操作,一次只执行一个写操作。 @@ -100,7 +100,8 @@ Discover 用于浏览远端可安装 Skill。 - 用户可以查看热门、趋势和精选列表,也可以按关键词搜索。 - 详情区展示简介、README 或 `SKILL.md` 内容、来源信息、安装位置,以及 Discover 来源已经提供的安全审计信息。 - 已安装到全局或某个项目的 Skill 会显示对应位置。 -- 点击安装会打开独立向导,固定主窗口当前使用的 Environment,并预填来源和 Skill 名称。向导使用 `Windows`、`macOS`、`Linux` 或 `WSL · 发行版名称` 显示这个固定目标。用户先选择该系统中的全局或项目 Skill 位置;进入来源步骤后,向导自动读取预填来源并预选该 Skill。用户仍可以返回来源步骤,改用在线搜索或手动输入。 +- 点击安装会打开独立向导,固定主窗口当前使用的 Environment,并预填来源和 Skill 名称。向导使用 `Windows`、`macOS`、`Linux` 或 `WSL · 发行版名称` 显示这个固定目标。位置选择默认选中全局,用户可以改选该 Environment 中已添加的项目;进入来源步骤后,向导自动读取预填来源并预选该 Skill。用户仍可以返回来源步骤,改用在线搜索或手动输入。 +- 项目列表加载期间显示加载提示,读取失败时提供重试,两种情况下都可以继续全局安装。当前 Environment 尚未添加项目时,向导提示用户前往 `Skills` 页面添加。 - 安全审计属于 Discover 提供的辅助信号。Skill Deck 不会把已安装 Skill 或用户手动输入的来源自动发送给其他审计服务;没有审计信息时,用户仍可查看来源并决定是否安装。 ### 统一安装流程 @@ -131,28 +132,30 @@ flowchart LR 5. 确认页按 Skill 展示来源风险、安装目标、同名冲突和实际执行范围,并在后台准备安装内容。受保护来源和跨主机下载需要用户明确确认;直接下载来源存在同名目标时禁止执行。准备成功后开放安装按钮;准备失败时停留在当前页面并说明失败阶段。准备结果过期后需要重新准备和确认。 6. 完成页按 Skill 和目标项目展示成功、失败、取消、跳过、未运行或“操作未完成,需要检查相关文件”。每项同时显示目标 Environment 及全局或项目位置;失败项可以展开错误详情,可重试项保留之前的选择并重新执行。 +确认页遇到临时获取失败或目标变化时,可以重新准备。来源信息过期、Skill 内容失效或所在环境发生变化时,向导提供“重新获取 Skill 列表”,保留来源输入和安装位置,并返回来源步骤自动获取新列表。此前选择的 Skill 只有仍在新列表中时才会保留,用户再次确认后才能安装。 + 安装执行期间不显示操作栏。安装完成或失败后,结果区域可以独立滚动,完成、重试、返回来源和关闭等操作固定显示在向导底部。 安装得到结果后,主窗口立即刷新目标位置的 Skill 列表。批量安装部分完成时,已经成功的 Skill 会及时出现在列表中,失败项继续显示在结果页;用户继续查看结果或关闭安装向导都不会延迟这次刷新。 -直接下载来源只支持无同名冲突的全新安装,不保存用于更新或重新获取的来源信息。安装完成后,用户仍可管理 Agent、复制或移除该 Skill;界面不提供更新、重新安装或来源修复入口。 +直接下载来源只支持无同名冲突的全新安装,不保存用于更新或重新获取的来源信息。安装完成后,用户仍可管理 Agent、复制或移除该 Skill;界面不提供更新入口,再次安装前需要先移除现有 Skill。 安装的技术语义见[Skill 生命周期](./skill-lifecycle.md),预览、一致性和恢复规则见[执行与恢复](./execution-and-recovery.md)。 ## 维护已安装 Skill -### 检查更新与修复来源 +### 检查更新 - 用户可以检查单个 Skill,也可以检查当前 Skill 位置中支持检查的全部 Skill。界面分别显示该 Skill 是否支持检查、本次检查状态,以及上次成功检查的结果。 -- 首次版本比较完成前,摘要保持未知。检查失败时,界面保留上次成功结果,并将本次状态显示为“更新检查未完成”;卡片和详情提供失败原因、可重试时间以及重试、配置 GitHub Token、打开来源或修复来源等下一步。 +- 首次版本比较完成前,摘要保持未知。检查失败时,界面保留上次成功结果,并将本次状态显示为“更新检查未完成”;卡片和详情提供失败原因、可重试时间以及重试、配置 GitHub Token、打开来源等下一步。 - Git 设置允许验证并保存 GitHub Token。已经保存的 Token 不回显;私有 GitHub 来源还可以使用本机 Git 已有的凭据检查更新。凭据不可用时,界面说明可使用的其他凭据来源。 - 点击更新后打开确认界面。用户确认后,应用获取新的安装内容;批量更新在同一流程中展示本次需要处理的 Skill。 - 确认界面展示通用 Skill 目录中的 Skill,以及本次将为哪些 Agent 创建链接、创建副本或同步已有副本。已经被用户修改的副本默认保留,只有用户明确选择后才覆盖。 - 计划和结果阶段支持取消或关闭。执行中展示当前阶段和进度;停止完成后仍进入结果页,如实列出已经完成和尚未执行的项目。 - 完成页先展示整体摘要,再列出失败、部分完成、警告和未完成操作。失败项仍显示可以执行的后续操作。 -- 来源记录不完整或普通检查无法继续时,界面提供适用的修复来源或移除操作。上游已经删除当前 Skill 时,本地内容默认保留,界面显示“来源中已找不到此 Skill”,并提供修复来源和移除;来源修复在独立弹窗中重新选择来源并生成预览。 +- 来源记录不完整或来源中已不存在当前 Skill 时,本地内容默认保留,界面说明当前状态并提供移除操作。用户需要从其他来源重新安装时,先移除现有 Skill,再使用普通安装流程。 -远端比较的触发、缓存、重试、限流和凭据规则见[更新检查](./update-checking.md)。执行更新与来源修复的业务规则见[Skill 生命周期](./skill-lifecycle.md#更新与来源修复),取消和写入结果见[执行与恢复](./execution-and-recovery.md)。 +远端比较的触发、缓存、重试、限流和凭据规则见[更新检查](./update-checking.md)。执行更新的业务规则见[Skill 生命周期](./skill-lifecycle.md#更新),取消和写入结果见[执行与恢复](./execution-and-recovery.md)。 ### 让 Agent 使用 Skill @@ -164,6 +167,7 @@ flowchart LR - 管理界面明确标识当前 Skill,并将链接或复制的安装方式作为独立选择。状态过期和保存失败时保留用户选择并说明下一步。 - 只要当前流程中存在可以选择安装方式的 Agent,用户就可以先选择安装方式,再选择 Agent。尚未用于任何已选 Agent 的安装方式不算待保存修改;固定使用复制方式的 Agent 不受该控件影响。 - 界面分别展示已经能够使用当前 Skill 的 Agent、选择后才可使用的 Agent,以及可以选择是否在自己目录中保留链接或副本的 Agent。汇总项可以展开查看完整成员及其可用条件。 +- “选择后可使用”区域在标题下持续汇总该组已经选中的 Agent,候选行仍在原分组和位置中保留勾选状态。Eve 按具体目标分别汇总;多个 Agent 共用一个目录时显示一个汇总项,并允许查看全部成员。用户可以从汇总项或候选行取消选择。“同时安装到 Agent 自己的 Skill 目录”继续在自身区域显示已选数量,不进入这项汇总。 - 使用同一个 Skill 目录的 Agent 合并为一个选项,用户可以展开查看成员、写入位置和可用条件。界面分开展示当前状态与待执行操作,并以一致的方式说明检测结果、暂时无法判断和链接冲突等情况。具体分组规则见[Agent 模型](./agent-model.md#agent-选择与安装位置分组)。 - 保存成功后关闭弹窗;保存失败时保留当前选择。准备结果过期后,界面要求用户重新确认。 - 安装向导不能添加新的 Agent 信息。Skill Deck 尚未记录的 Agent 通过只读提示说明本次无法处理,并引导用户关闭向导后前往 `Settings > Agents` 添加 Skill 读取位置和 Agent 检测位置。 @@ -172,7 +176,8 @@ flowchart LR - 复制弹窗分别提供目标项目和 Agent 选择区域,用户可以按任意顺序配置,并在两个区域中独立滚动。目标 Environment 只有一个可选项时隐藏该字段,存在多个可用 Environment 时才显示。 - 用户在一个目标 Environment 中选择一个或多个项目。每个项目都显示名称和完整路径。目标 Agent、安装方式和展开状态由全部目标项目共用;项目只决定实际写入路径,不会各自保存一套 Agent 选择。 -- Agent 选择默认沿用来源 Skill 当前的安装选择。用户可以增删目标 Agent,并提前选择链接或复制;即使尚未选择项目,Agent 选择仍保持可用。固定使用复制的 Agent 不受安装方式控件影响。 +- Agent 选择按照源 Skill 当前的直接安装关系预先勾选。用户可以增删本次需要复制的 Agent 关联,并提前选择链接或复制;目标项目已有的其他 Agent 关联继续保留。即使尚未选择项目,Agent 选择仍保持可用。固定使用复制的 Agent 不受安装方式控件影响。 +- 跨 Environment 复制时,Agent 选择区域保留源 Skill 的 Agent 名称和勾选状态,不展示来自源 Environment 的检测状态和目录。实际写入位置由后端分别根据各目标项目计算。 - 连接目标 Environment 失败时,界面提示用户重新连接;连接成功但项目目录读取失败时,界面提示用户重新读取项目。两类失败都保留 Agent 选择,并允许用户重试当前步骤。 - 用户点击复制后,应用检查目标、Agent 选择和覆盖风险。复制弹窗不增加独立预览页;需要用户处理的风险和状态变化直接留在当前弹窗中说明。 - Agent 选择状态发生变化时,弹窗载入最新状态,保留仍然有效的用户选择和项目选择,并要求用户确认后继续。 @@ -235,6 +240,10 @@ Git 代理只通过本次进程的 `http.proxy` 参数生效,不读取、修 页面中的修改形成设置草稿。只有保存成功后,后续发起的 HTTP 请求和 Git 操作才使用新设置;已经开始的操作继续使用启动时的设置,在线图片不受此次保存影响。 +代理配置无法读取或校验失败时,页面展示受影响的连接方式和配置文件位置。应用分别处理 HTTP、本机 Git 和各 WSL 发行版中的 Git 配置;已有有效设置的连接方式继续使用上一次有效值,尚无有效设置的连接方式暂停相关网络操作。跟随本机 Git 的 WSL 连接同时受本机 Git 配置状态影响。 + +用户可以重新读取配置文件,页面会保留尚未保存的草稿。对于能够读取的配置文档,用户检查页面中的设置并保存后,应用会替换原代理配置,同时保留文档中的其他设置。整个文件损坏或无法读取时,用户需要先修复文件,再重新读取。文件在保存前被其他操作修改时,应用提示重新读取;写入结果无法确认时,应用保留原连接方式并提示检查保存结果。 + 用户可以在保存前测试当前页面中的设置。连接测试始终通过 `https://www.skills.sh/api/search` 的轻量查询检查在线 Skill 搜索,并通过 `https://github.com/hccake/skill-deck.git` 检查应用所在系统和当前页面中所有 WSL 发行版的 Git 连接。每项检查最多等待 10 秒,测试结果不会保存当前设置,也不表示任意 Well-known 地址、更新服务器或在线图片都可以访问。 ### Projects diff --git a/docs/skill-lifecycle.md b/docs/skill-lifecycle.md index 07ef17ad..91c0ccd1 100644 --- a/docs/skill-lifecycle.md +++ b/docs/skill-lifecycle.md @@ -15,13 +15,12 @@ flowchart LR Install --> Installed["已安装 Skill"] Installed -->|"来源可以重新获取"| Update["更新"] - Installed -->|"来源无法继续使用"| Repair["修复来源"] Installed --> Manage["管理 Agent"] Installed -->|"项目 Skill"| Copy["复制到其他项目"] Installed --> Remove["移除"] ``` -安装、更新、来源修复、管理 Agent、复制和移除都需要先确认本次变更。执行前,Skill Deck 会根据最新目录、来源记录和 Agent 选择重新检查操作条件。内容快照、变更计划、写入一致性和恢复规则见[执行与恢复](./execution-and-recovery.md)。 +安装、更新、管理 Agent、复制和移除都需要先确认本次变更。执行前,Skill Deck 会根据最新目录、来源记录和 Agent 选择重新检查操作条件。内容快照、变更计划、写入一致性和恢复规则见[执行与恢复](./execution-and-recovery.md)。 ## 从来源发现 Skill @@ -50,7 +49,7 @@ Skill Deck 先解析并获取来源,再按照兼容的发现规则列出其中 Git、本地目录、直接下载和 Well-known 来源使用同一套 internal Skill 可见性规则。普通发现和 wildcard 只形成公开 Skill catalog;不含 wildcard 的精确名称选择会在公开 Skill 之外放行同名 internal Skill,且不会扩大到其他 internal Skill。列表、发现会话和后续内容获取使用同一投影结果,因此未显示的路径不能作为有效 payload 取得内容。 -直接下载只支持目标中不存在同名 Skill 的全新安装,不创建来源 lock,也不提供更新检查、重新获取或来源修复能力。同名目标的覆盖能力由后续生命周期阶段提供。 +直接下载只支持没有同名冲突的全新安装。目标已经存在同名 Skill 时,应用停止安装并提示冲突;安装后不创建来源 lock,也不提供更新入口;再次安装前需要先移除现有 Skill。用户仍可管理 Agent、复制或移除本地内容。 Git 来源获取与远端引用查询共用 Git 获取超时设置,Windows 中面向 WSL Environment 执行的 Git 来源获取也使用同一设置。计时从应用确定本次 Git 连接方式时开始,并覆盖 Git 子进程运行;共享获取队列中的等待时间不计入其中。Git 子进程运行期间持续读取标准输出和标准错误,避免输出管道阻塞进程;应用只保留有界的末尾诊断内容。诊断保留在本机,应用不为 Git 输出维护通用 URL、查询参数或关键字脱敏规则,也不会主动把自身持有的认证 Header、密码或 Token 写入 Git 命令和日志。 @@ -80,7 +79,7 @@ Eve 项目还可以选择根 Agent 或已经发现的子 Agent。Skill Deck 根 ## 已安装状态与读取 -已安装 Skill 由所在的 Environment、Skill 位置和 Skill 名称标识。来源不是已安装 Skill 身份的一部分;它可以缺失,也可以在来源修复后发生变化。 +已安装 Skill 由所在的 Environment、Skill 位置和 Skill 名称标识。来源不是已安装 Skill 身份的一部分;它可以缺失。 `SKILL.md` 中的原始名称用于标识 Skill,经过安全化的名称用于实际安装目录。Skill Deck 先按原始名称查找来源记录,再兼容与安装目录同名的旧记录,并在下一次正常受保护写入中完成迁移。其他名称即使安全化后相同,也不会被猜测为当前 Skill 的来源记录。 @@ -88,7 +87,7 @@ Skill 目录提供当前内容,保存的来源信息提供重新获取和更 读取 `SKILL.md` 或其他资源时,应用根据已安装 Skill 的标识重新解析实际目录,并在读取前完成路径校验。资源读取边界见[系统架构](./architecture.md),Skill 卡片和详情的展示规则见[产品行为与交互](./product.md#浏览已安装-skill)。 -## 更新与来源修复 +## 更新 文档需要区分更新能力、更新检查和执行更新: @@ -99,15 +98,13 @@ Skill 目录提供当前内容,保存的来源信息提供重新获取和更 | 检查结果 | 上次成功完成版本比较后得出的“有更新”或“没有更新”;检查失败和信息不足单独显示 | | 执行更新 | 按保存的来源取得新内容,并重新安装到原 Skill 位置 | -本地来源不参与远端更新检查。远端来源信息完整时可以重新获取;缺少比较版本所需的信息时,用户可能仍可主动重新安装 Skill,但 Skill Deck 无法判断“有更新”或“没有更新”。远端版本信息的缓存、失败重试、限流和凭据规则见[更新检查](./update-checking.md)。 +本地来源不参与远端更新检查。远端来源可以重新获取、但缺少版本基线时,应用无法判断是否有更新。比较方式、缓存、失败重试和凭据规则见[更新检查](./update-checking.md)。 更新会重新安装完整 Skill 目录。指向通用 Skill 目录中该 Skill 的链接会随其一起更新;已经发生本地修改的副本默认保留,只有用户明确选择后才会覆盖。更新后继续保留已经确认的 Agent 安装位置。 生成更新目标时,Agent 专用 Skill 根目录缺失表示该 Agent 当前没有专用安装项,不纳入本次更新。专用根目录存在断链、非目录冲突或不安全路径时,应用返回对应的路径错误;暂时无法确认目录状态时,应用返回 Environment 不可用错误,不会把这些路径交给后续目标投影。 -来源修复用于处理本地 Skill 仍然存在,但保存的来源无法可靠定位原 Skill 的情况,例如来源信息不完整、上游目录移动或删除,或者来源已经失效。用户重新选择来源和对应 Skill 后,Skill Deck 更新来源记录并重新安装内容。上游内容已经删除时,本地 Skill 继续保留,由用户选择修复来源、保留现状或移除。 - -来源修复不会改变已经确认的 Agent 安装关系。只读取 Agent 专用 Skill 目录的 Agent,以及已经在专用目录中安装当前 Skill 的 Agent,修复后仍然使用原有位置。 +来源记录不完整、来源失效或来源中已经不存在当前 Skill 时,本地内容继续保留。用户可以保留或移除当前 Skill;需要从其他来源重新安装时,先移除当前 Skill,再使用普通安装流程。 ## 管理 Agent @@ -148,7 +145,6 @@ Agent 检测结果用于提示、排序、默认推荐和界面展示。最终 2. Skill Deck 先从来源发现 Skill,用户完成选择后再固定需要安装的完整内容。 3. Skill 目录和保存的来源信息分别提供当前内容与重新获取依据,其中一项缺失不会被解释成另一项也不存在。 4. 更新检查成功后才能得出“有更新”或“没有更新”;检查失败时,保留上次成功检查的结果。 -5. 来源修复更新来源和内容,但不改变已安装 Skill 的身份,并保持已经确认的 Agent 安装关系。 -6. Agent 检测结果与用户选择分别保存;最终写入目标由用户本次确认的安装选项决定。 -7. 复制使用当前已安装的完整 Skill 内容,来源信息可以随内容复制,也可以缺失。 -8. 受保护写入无法确认结果一致时,应用保留恢复记录和恢复数据,由用户检查后续状态。 +5. Agent 检测结果与用户选择分别保存;最终写入目标由用户本次确认的安装选项决定。 +6. 复制使用当前已安装的完整 Skill 内容,来源信息可以随内容复制,也可以缺失。 +7. 受保护写入无法确认结果一致时,应用保留恢复记录和恢复数据,由用户检查后续状态。 diff --git a/docs/skills-cli-reference.md b/docs/skills-cli-reference.md index 72925270..7169e971 100644 --- a/docs/skills-cli-reference.md +++ b/docs/skills-cli-reference.md @@ -71,7 +71,7 @@ Skill Deck 的运行状态、预览信息、内容快照、恢复记录和用户 | `lastSelectedAgents` | `skills` CLI | CLI 最近一次选择的 Agent ID | | `defaultTargetAgents` | 旧版 Skill Deck | 当前版本不读取、不写入且不清理;重写全局 lock 时原样保留 | -Skill Deck 在用户确认安装目标后写入 `lastSelectedAgents`,内容只包含当前参考版本能够识别的内置 Agent ID。全局安装和项目安装共用当前 Environment 的全局字段;明确指定 Agent 的安装入口不会更新该字段。初始选择和失败处理规则见[Agent 模型](./agent-model.md#安装初始选择与最近选择)。 +Skill Deck 在用户确认 Agent 选择并进入安装确认步骤时写入 `lastSelectedAgents`,内容只包含当前参考版本能够识别的内置 Agent ID。该字段记录最近确认的安装选择,不表示安装已经成功。全局安装和项目安装共用当前 Environment 的全局字段;明确指定 Agent 的安装入口不会更新该字段。初始选择和失败处理规则见[Agent 模型](./agent-model.md#安装初始选择与最近选择)。 ## 项目 lock 与 Eve 安装位置 diff --git a/docs/testing.md b/docs/testing.md index 2f8fe125..4c385e65 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,6 +74,8 @@ WSL 测试分工如下: ### HTTP、时间与外部进程 +配置读取与修复测试覆盖故障隔离、上一次有效值的保留、未修改字段的保留、修复保存、外部修改冲突和写入结果未确认的处理,并区分内存快照与磁盘变更。共享 HTTP 传输测试确认配置不可用时停止请求。 + HTTP 测试使用成熟的本地服务器库,Git 测试使用可控仓库或可替换的传输实现。测试夹具应能稳定产生认证失败、远端引用变化、响应不完整、限流、网络中断、克隆失败和超时等结果。具体重试时间、缓存范围和限流规则由[更新检查](./update-checking.md)及其测试维护。 产品 Module 测试覆盖自定义代理和直接连接。共享 HTTP Transport 使用本地服务器验证 Direct 请求、Custom Proxy 只配置一个代理、设置变更后新请求使用当前设置、客户端默认重定向、总时限、取消和响应读取上限;Discovery URL 契约测试验证 `www.skills.sh` 规范站点及现有公开 `/api/search` 路径。测试不通过修改进程环境重测 reqwest 的 `no_proxy` 行为,也不建立 DNS 公私网分类、地址固定、host allowlist 或请求用途策略。具体响应大小和 Content-Type 由消费内容的产品 Module 测试。这些测试只覆盖应用主动发起的业务请求,不覆盖 WebView 加载的在线图片;连接测试结果也不能证明 WebView 子资源或其他未测试地址可访问。 diff --git a/docs/update-checking.md b/docs/update-checking.md index b6709617..ecccaf94 100644 --- a/docs/update-checking.md +++ b/docs/update-checking.md @@ -2,7 +2,7 @@ 更新检查用于确认远端来源中的 Skill 是否已经出现新版本。检查时只读取比较版本所需的远端信息,不会把新版本写入 Skill 目录;用户确认更新后,Skill Deck 才会按照已保存的来源获取完整内容并执行安装。 -Skill 能否更新、如何重新安装以及如何修复来源,见[Skill 生命周期](./skill-lifecycle.md)。lock 文件字段、内容哈希和参考版本规则见[skills CLI 参考与兼容](./skills-cli-reference.md)。 +Skill 能否更新以及来源失效后的处理方式,见[Skill 生命周期](./skill-lifecycle.md)。lock 文件字段、内容哈希和参考版本规则见[skills CLI 参考与兼容](./skills-cli-reference.md)。 ## 检查状态与结果 @@ -10,12 +10,18 @@ Skill 能否更新、如何重新安装以及如何修复来源,见[Skill 生 | 信息 | 含义 | |---|---| -| 能否检查 | Skill 来源和本地安装记录是否足以获取远端版本信息并完成比较 | +| 能否检查 | Skill 来源和保存的来源记录是否足以获取远端版本信息并完成比较 | | 上次成功检查的结果 | 上次完成比较后得出的“有更新”或“没有更新” | | 本次检查状态 | 本次检查是否成功、失败、正在等待重试或因远端服务限流而暂停 | 本次检查失败时,界面会保留上次成功检查的结果,同时提示本次检查未能确认远端状态。只有完成一次版本比较后,才能得出“没有更新”;尚未检查或检查失败都不能表示当前已经是最新版本。 +全局、项目和 Skill 库使用相同的版本比较规则。 + +全局和项目检查开始时,先固定能够比较远端版本的 Skill 名称列表,再逐项读取对应的来源记录。没有来源记录的本地 Skill 和来源类型为本地目录的 Skill 不纳入检查,也不计入来源异常、检查异常或待维护数量。已保存的来源记录存在但字段不完整或无法解释时,应用显示“来源信息不完整”。 + +Skill 库只提供整库检查,按照当前成员保存的索引地址、仓库信息和版本基线逐项比较。本地目录来源的成员没有可比较的远端版本,整库检查会将其记为无法检查,并计入检查异常摘要。检查结果仅用于判断更新状态,不作为后续写入的内容凭据。 + ## 如何获取远端版本信息 Skill Deck 根据 Skill 来源选择获取方式。在 Windows 上,用户选择 Windows 还是 WSL Environment,也会影响由哪个 Environment 执行检查: @@ -23,14 +29,16 @@ Skill Deck 根据 Skill 来源选择获取方式。在 Windows 上,用户选 - 在桌面应用所在的 Windows、macOS 或 Linux 中,GitHub 来源优先通过 GitHub Trees API 获取完整目录清单和可用于比较的目录树对象 ID(tree object ID)。API 因认证、未授权或仓库不存在、响应信息不完整或来源暂时不可用而无法提供证据时,Skill Deck 改用本机 Git 获取相同的版本信息; - Windows 用户切换到某个 WSL 发行版后,GitHub 来源改由该发行版中的 Git 获取相同的版本信息; - GitLab 和普通 Git 来源使用桌面应用所在操作系统或当前 WSL 发行版中的 Git; -- 本地来源没有远端版本可供比较,因此不参与更新检查; +- 本地来源没有远端版本可供比较,不发起远端请求; - 通过约定地址(Well-known)发现的来源使用索引摘要比较版本。v2 索引可以直接返回摘要;旧版索引只获取当前请求检查的已安装 Skill 内容并计算兼容摘要,不下载目录中的其他 Skill; -- Well-known 记录同时包含索引地址和安装时摘要时可以检查并重新安装,只有索引地址时可以重新安装但不能得出版本比较结果,只有历史制品地址或来源主机时需要修复来源;保存的 scoped 地址始终只使用对应路径下的 catalog,保存的精确 `index.json` 地址始终只请求原索引,历史制品地址不会被解释为直接下载来源; -- Well-known 索引不再包含当前已安装 Skill 时显示“上游已移除”。自动检查和批量更新保留本地内容,由用户选择修复来源或移除。 +- Well-known 记录同时包含索引地址和安装时摘要时可以检查更新;只有索引地址时不能得出版本比较结果,只有历史制品地址或来源主机时需要先移除当前 Skill,再通过普通安装流程重新添加。 +- Well-known 索引不再包含本次检查的 Skill 时,应用显示来源中已不存在的状态,并保留本地内容。直接安装 Skill 可以保留或移除,库成员可以移除后从新来源添加。 + +保存的 scoped 地址始终只使用对应路径下的 catalog,保存的精确 `index.json` 地址始终只请求原索引,历史制品地址不会被解释为直接下载来源。 GitHub Trees API 已确认限流或请求发生普通网络错误时,更新检查继续使用相应的限流等待或来源失败退避,不启动 Git 回退。API 的其他非权威失败进入 Git 回退后,本次结果以 Git 的认证、仓库、`ref`、网络、超时或执行结果为准。 -这些远端信息只用于判断版本是否发生变化。安装、来源修复和复制需要使用经过校验的完整 Skill 内容。缓存失效后,Skill Deck 会在桌面应用所在操作系统中重新获取版本信息;Windows 用户切换到 WSL 后,则会由当前发行版重新获取。 +这些远端信息只用于判断版本是否发生变化。安装和复制需要使用经过校验的完整 Skill 内容。缓存失效后,Skill Deck 会在桌面应用所在操作系统中重新获取版本信息;Windows 用户切换到 WSL 后,则会由当前发行版重新获取。 ## 网络代理 @@ -51,7 +59,7 @@ GitHub Trees API 已确认限流或请求发生普通网络错误时,更新检 在应用本次运行期间,每个全局或项目位置只会触发一次初始自动检查。重新打开页面、窗口重新获得焦点,或者在已经访问过的位置之间切换,都不会再次触发这项检查。 -本地 Skill 状态发生变化后,自动检查只处理新增的可检查 Skill,以及来源、分支或标签等 `ref`、来源仓库内的 Skill 路径(`skillPath`)发生变化的 Skill。Skill Deck 完成安装、更新、来源修复、复制、管理 Agent 或移除后,会同步本次操作涉及的 Skill 状态,避免再次检查所有 Skill。 +本地 Skill 状态发生变化后,自动检查只处理新增的可检查 Skill,以及来源、分支或标签等 `ref`、来源仓库内的 Skill 路径(`skillPath`)发生变化的 Skill。Skill Deck 完成安装、更新、复制、管理 Agent 或移除后,会同步本次操作涉及的 Skill 状态,避免再次检查所有 Skill。 ## 缓存、重试与限流 @@ -66,6 +74,8 @@ GitHub Trees API 已确认限流或请求发生普通网络错误时,更新检 缓存按照远端来源区分,不按照 Skill 名称区分。名称相同但来源不同的 Skill 不会共用远端版本信息。 +界面中的单个 Skill 检查结果按操作位置保存。安装或移除成功后,应用清除本次涉及 Skill 的旧检查结果,并使该位置在变更前启动的检查失效。其他 Skill 已保存的检查结果继续保留。 + 检查失败后的处理分为三类: 1. 认证失败、仓库不存在、分支或标签等 `ref` 不存在,以及无法区分的“不存在或未授权”,都需要用户更新凭据或来源信息。在此之前,自动检查不会重复访问该来源。 @@ -77,7 +87,7 @@ GitHub Trees API 已确认限流或请求发生普通网络错误时,更新检 以下情况会清除相关失败记录或限流等待时间,使后续检查可以重新进行: - 检查成功后,清除对应来源的失败次数和等待时间; -- 来源信息发生变化或来源修复成功后,清除原来源的失败记录; +- 普通安装改变来源信息后,清除原来源的失败记录; - GitHub Token 成功保存或清除后,清除桌面应用所在操作系统中 GitHub 的认证失败记录和限流等待时间; - 本次安装涉及的所有 Skill 都写入成功后,清除相应来源的失败记录。 diff --git a/package.json b/package.json index ba051dbe..18fcca62 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test:scripts": "node --test scripts/__tests__/*.test.mjs", "test:watch": "vitest", "preview": "vite preview", + "prepare:wsl-worker": "node scripts/prepare-wsl-worker.mjs", + "verify:wsl-worker": "node scripts/prepare-wsl-worker.mjs --verify src-tauri/target/wsl-worker/current", "sync-version": "node scripts/sync-version.mjs", "prepare": "husky" }, diff --git a/scripts/__tests__/check-docs.test.mjs b/scripts/__tests__/check-docs.test.mjs index 03682a8d..eee5519a 100644 --- a/scripts/__tests__/check-docs.test.mjs +++ b/scripts/__tests__/check-docs.test.mjs @@ -46,7 +46,7 @@ test("reports missing local files and anchors with their source locations", asyn }, async (root) => { const problems = await checkDocumentation(root, ["README.md", "docs/guide.md"]); assert.equal(problems.length, 2); - assert.match(problems[0], /README\.md:3: missing file: docs\/missing\.md/); - assert.match(problems[1], /README\.md:4: missing anchor: docs\/guide\.md#not-present/); + assert.match(problems[0], /README\.md:3: missing file: docs[\\/]missing\.md/); + assert.match(problems[1], /README\.md:4: missing anchor: docs[\\/]guide\.md#not-present/); }); }); diff --git a/scripts/__tests__/prepare-wsl-worker.test.mjs b/scripts/__tests__/prepare-wsl-worker.test.mjs new file mode 100644 index 00000000..8145f073 --- /dev/null +++ b/scripts/__tests__/prepare-wsl-worker.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + buildWorkerManifest, + parsePrepareArgs, + verifyWorkerArtifact, + workerBuildInvocation, +} from "../prepare-wsl-worker.mjs"; + +test("Linux builds the fixed musl worker target directly", () => { + const args = parsePrepareArgs([], "linux"); + + assert.deepEqual(args, { mode: "build", distro: undefined }); + assert.deepEqual( + workerBuildInvocation({ platform: "linux", distro: args.distro }), + { + command: "cargo", + args: [ + "build", + "--locked", + "--manifest-path", + "src-tauri/Cargo.toml", + "-p", + "wsl-environment-worker", + "--target", + "x86_64-unknown-linux-musl", + "--release", + ], + }, + ); +}); + +test("Windows builds through the selected WSL distribution", () => { + const args = parsePrepareArgs( + ["--", "--distro", "Ubuntu-24.04"], + "win32", + ); + assert.deepEqual(args, { mode: "build", distro: "Ubuntu-24.04" }); + + assert.deepEqual( + workerBuildInvocation({ + platform: "win32", + distro: args.distro, + linuxRepositoryRoot: "/mnt/c/code/skill-deck", + }), + { + command: "wsl.exe", + args: [ + "--distribution", + "Ubuntu-24.04", + "--cd", + "/mnt/c/code/skill-deck", + "--exec", + "/bin/sh", + "-lc", + 'exec "$@"', + "--", + "cargo", + "build", + "--locked", + "--manifest-path", + "src-tauri/Cargo.toml", + "-p", + "wsl-environment-worker", + "--target", + "x86_64-unknown-linux-musl", + "--release", + ], + }, + ); + assert.throws( + () => parsePrepareArgs([], "win32"), + /--distro is required on Windows/, + ); +}); + +test("worker manifest uses one SHA-256 value as its build identity", () => { + assert.deepEqual( + buildWorkerManifest("ab".repeat(32)), + { + buildId: `sha256:${"ab".repeat(32)}`, + sha256: `sha256:${"ab".repeat(32)}`, + target: "x86_64-unknown-linux-musl", + }, + ); +}); + +test("worker artifact verification rejects every mismatched contract field", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "skill-deck-worker-")); + const directory = path.join(root, "current"); + await mkdir(directory); + const digest = "87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e"; + const manifest = buildWorkerManifest(digest); + try { + await writeFile(path.join(directory, "worker"), "worker"); + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify(manifest)}\n`, + ); + assert.deepEqual(await verifyWorkerArtifact(directory), manifest); + + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify({ ...manifest, target: "x86_64-unknown-linux-gnu" })}\n`, + ); + await assert.rejects(() => verifyWorkerArtifact(directory), /target/); + + await writeFile(path.join(directory, "manifest.json"), "not-json"); + await assert.rejects(() => verifyWorkerArtifact(directory), /JSON/); + + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify({ ...manifest, buildId: `sha256:${"a".repeat(64)}` })}\n`, + ); + await assert.rejects(() => verifyWorkerArtifact(directory), /buildId/); + + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify(manifest)}\n`, + ); + await writeFile(path.join(directory, "worker"), "damaged"); + await assert.rejects(() => verifyWorkerArtifact(directory), /bytes/); + await rm(path.join(directory, "worker")); + await assert.rejects(() => verifyWorkerArtifact(directory)); + await writeFile(path.join(directory, "worker"), "worker"); + await rm(path.join(directory, "manifest.json")); + await assert.rejects(() => verifyWorkerArtifact(directory)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("verify mode is independent from the Windows build distro", () => { + assert.deepEqual( + parsePrepareArgs(["--verify", "C:\\artifact"], "win32"), + { mode: "verify", directory: "C:\\artifact" }, + ); +}); diff --git a/scripts/__tests__/release-policy.test.mjs b/scripts/__tests__/release-policy.test.mjs index fb463ec3..3c9a61ab 100644 --- a/scripts/__tests__/release-policy.test.mjs +++ b/scripts/__tests__/release-policy.test.mjs @@ -19,6 +19,10 @@ const qualityWorkflowUrl = new URL( "../../.github/workflows/quality.yml", import.meta.url, ); +const windowsTauriConfigUrl = new URL( + "../../src-tauri/tauri.windows.conf.json", + import.meta.url, +); const releaseVerifierUrl = new URL("../verify-release-assets.mjs", import.meta.url); const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -71,6 +75,7 @@ test("CI and Release run the same quality workflow for an exact commit", async ( "validate", "quality", "prepare-release", + "build-wsl-worker", ]); for (const [jobName, job] of Object.entries(quality.jobs)) { @@ -109,21 +114,56 @@ test("quality workflow separates portable formatting, static checks, and tests", assert.equal(workflow.jobs["rust-static"].strategy["fail-fast"], false); assert.equal(workflow.jobs["rust-test"].strategy["fail-fast"], false); + const msrvCommands = workflow.jobs.msrv.steps + .map((step) => step.run ?? "") + .join("\n"); + const formatCommands = workflow.jobs["rust-format"].steps + .map((step) => step.run ?? "") + .join("\n"); const staticCommands = workflow.jobs["rust-static"].steps .map((step) => step.run ?? "") .join("\n"); const testCommands = workflow.jobs["rust-test"].steps .map((step) => step.run ?? "") .join("\n"); - assert.match(staticCommands, /cargo check[^\n]*--locked[^\n]*--all-targets/); + assert.match(msrvCommands, /cargo check[^\n]*--locked[^\n]*--workspace[^\n]*--all-targets/); + assert.match(formatCommands, /cargo fmt[^\n]*--all[^\n]*-- --check/); + assert.match(staticCommands, /cargo check[^\n]*--locked[^\n]*--workspace[^\n]*--all-targets/); assert.match( staticCommands, - /cargo clippy[^\n]*--locked[^\n]*--all-targets[^\n]*-- -D warnings/, + /cargo clippy[^\n]*--locked[^\n]*--workspace[^\n]*--all-targets[^\n]*-- -D warnings/, ); assert.doesNotMatch(staticCommands, /cargo test/); - assert.match(testCommands, /cargo test[^\n]*--locked/); + assert.match(testCommands, /cargo test[^\n]*--locked[^\n]*--workspace/); assert.doesNotMatch(testCommands, /cargo (?:check|clippy)/); + const rustTestSteps = workflow.jobs["rust-test"].steps; + const pnpmSetup = rustTestSteps.findIndex((step) => step.uses?.startsWith("pnpm/action-setup@")); + const nodeSetup = rustTestSteps.findIndex((step) => step.uses?.startsWith("actions/setup-node@")); + const dependencyInstall = rustTestSteps.findIndex((step) => step.run === "pnpm install --frozen-lockfile"); + const rustTests = rustTestSteps.findIndex((step) => (step.run ?? "").includes("cargo test")); + assert.ok(pnpmSetup >= 0, "Rust CLI interoperability tests require pnpm"); + assert.ok(nodeSetup > pnpmSetup, "prepare Node and pnpm before installing the pinned CLI"); + assert.ok(dependencyInstall > nodeSetup && dependencyInstall < rustTests); + assert.match(testCommands, /native_update_private_only_cli_installation_in_place[^\n]*--ignored/); + assert.match(testCommands, /grep[^\n]*native_update_private_only_cli_installation_in_place/); + assert.match(testCommands, /native_eve_update_restores_recorded_missing_installation[^\n]*--ignored/); + assert.match(testCommands, /grep[^\n]*native_eve_update_restores_recorded_missing_installation/); + + for (const jobName of ["rust-static", "rust-test"]) { + const steps = workflow.jobs[jobName].steps; + const resources = steps.findIndex( + (step) => step.name === "Prepare Windows Cargo resource placeholders", + ); + const firstCargo = steps.findIndex((step) => + /cargo (?:check|clippy|test)/.test(step.run ?? ""), + ); + assert.ok(resources >= 0 && resources < firstCargo); + assert.equal(steps[resources].if, "runner.os == 'Windows'"); + assert.match(steps[resources].run, /target\/wsl-worker\/current\/worker/); + assert.match(steps[resources].run, /target\/wsl-worker\/current\/manifest\.json/); + } + const clippyStep = workflow.jobs["rust-static"].steps.find((step) => (step.run ?? "").includes("cargo clippy"), ); @@ -183,7 +223,12 @@ test("release workflow prepares one draft and lets tauri-action upload each plat assert.ok(workflow.jobs["prepare-release"]); assert.ok(workflow.jobs["verify-release"]); assert.equal(workflow.jobs["verify-release"].permissions.contents, "write"); - assert.deepEqual(build.needs, ["validate", "quality", "prepare-release"]); + assert.deepEqual(build.needs, [ + "validate", + "quality", + "prepare-release", + "build-wsl-worker", + ]); assert.equal(build.permissions.contents, "write"); assert.doesNotMatch( commands, @@ -214,6 +259,67 @@ test("release workflow prepares one draft and lets tauri-action upload each plat assert.equal(workflow.jobs.aggregate, undefined); }); +test("Windows bundles the exact Linux Worker artifact as Tauri resources", async () => { + const [workflow, windowsConfig] = await Promise.all([ + readWorkflow(workflowUrl), + readFile(windowsTauriConfigUrl, "utf8").then(JSON.parse), + ]); + assert.deepEqual(windowsConfig.bundle.resources, { + "target/wsl-worker/current/worker": "wsl-worker/current/worker", + "target/wsl-worker/current/manifest.json": "wsl-worker/current/manifest.json", + }); + assert.equal(windowsConfig.bundle.externalBin, undefined); + + const worker = workflow.jobs["build-wsl-worker"]; + assert.deepEqual(worker.needs, ["validate", "quality"]); + assert.equal(worker["runs-on"], "ubuntu-22.04"); + const checkout = worker.steps.find((step) => + step.uses?.startsWith("actions/checkout@") + ); + assert.equal(checkout.with.ref, "${{ needs.validate.outputs.commit_sha }}"); + const upload = worker.steps.find( + (step) => step.uses === "actions/upload-artifact@v4", + ); + assert.equal( + upload.with.name, + "wsl-worker-${{ needs.validate.outputs.commit_sha }}", + ); + assert.equal(upload.with.path, "src-tauri/target/wsl-worker/current"); + + const build = workflow.jobs["build-release"]; + const download = build.steps.find( + (step) => step.uses === "actions/download-artifact@v4", + ); + assert.equal(download.if, "${{ runner.os == 'Windows' }}"); + assert.equal( + download.with.name, + "wsl-worker-${{ needs.validate.outputs.commit_sha }}", + ); + assert.equal(download.with.path, "src-tauri/target/wsl-worker/current"); + const verify = build.steps.find((step) => step.run === "pnpm verify:wsl-worker"); + assert.equal(verify.if, "${{ runner.os == 'Windows' }}"); + + const actionIndex = build.steps.findIndex((step) => + step.uses?.startsWith("tauri-apps/tauri-action@") + ); + const nsisIndex = build.steps.findIndex( + (step) => step.run === "pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind nsis", + ); + const msiIndex = build.steps.findIndex( + (step) => step.run === "pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind msi", + ); + assert.ok(nsisIndex > actionIndex); + assert.ok(msiIndex > actionIndex); + assert.equal( + build.steps[nsisIndex].if, + "${{ runner.os == 'Windows' }}", + ); + assert.equal( + build.steps[msiIndex].if, + "${{ runner.os == 'Windows' && needs.validate.outputs.prerelease != 'true' }}", + ); +}); + test("release workflow omits MSI for prereleases and retains it for stable releases", async () => { const workflow = await readWorkflow(workflowUrl); const build = workflow.jobs["build-release"]; diff --git a/scripts/__tests__/skills-cli-eve-interop.test.mjs b/scripts/__tests__/skills-cli-eve-interop.test.mjs index 800b014e..d52b14ae 100644 --- a/scripts/__tests__/skills-cli-eve-interop.test.mjs +++ b/scripts/__tests__/skills-cli-eve-interop.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { EOL, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; @@ -10,8 +10,9 @@ const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); const skillsBinary = join( repoRoot, "node_modules", - ".bin", - process.platform === "win32" ? "skills.cmd" : "skills", + "skills", + "bin", + "cli.mjs", ); const skillName = "interop-skill"; @@ -20,7 +21,7 @@ function run(command, args, options = {}) { cwd: options.cwd, encoding: "utf8", env: options.env ?? process.env, - shell: process.platform === "win32" && command.endsWith(".cmd"), + timeout: 30_000, }); assert.equal( @@ -96,7 +97,7 @@ function cliEnvironment(home) { } function runSkills(fixture, args) { - return run(skillsBinary, args, { + return run(process.execPath, [skillsBinary, ...args], { cwd: fixture.project, env: cliEnvironment(fixture.home), }); @@ -129,7 +130,7 @@ function eveSkillPath(project, subagent) { } test("uses the pinned Vercel Skills CLI", () => { - const result = run(skillsBinary, ["--version"], { + const result = run(process.execPath, [skillsBinary, "--version"], { cwd: repoRoot, env: cliEnvironment(join(repoRoot, ".tmp-skills-cli-home")), }); @@ -137,6 +138,34 @@ test("uses the pinned Vercel Skills CLI", () => { assert.equal(result.stdout.trim(), "1.5.23"); }); +test("defaults to a private copy for one explicit Agent without creating a canonical installation", async (t) => { + const fixture = await createFixture(t); + runSkills(fixture, [ + "add", fixture.source, "--skill", skillName, "--agent", "claude-code", "--yes", + ]); + + assert.equal( + await pathExists(join(fixture.project, ".claude", "skills", skillName, "SKILL.md")), + true, + ); + assert.equal(await pathExists(join(fixture.project, ".agents", "skills", skillName)), false); + assert.ok((await readProjectLock(fixture.project)).skills[skillName]); +}); + +test("copies into two private-only Agent directories without creating a canonical installation", async (t) => { + const fixture = await createFixture(t); + installFrom(fixture, fixture.source, { agents: ["codebuddy", "minimax-code"] }); + + for (const agentRoot of [".codebuddy", ".minimax"]) { + assert.equal( + await pathExists(join(fixture.project, agentRoot, "skills", skillName, "SKILL.md")), + true, + ); + } + assert.equal(await pathExists(join(fixture.project, ".agents", "skills", skillName)), false); + assert.ok((await readProjectLock(fixture.project)).skills[skillName]); +}); + test("installs Eve root placement without writing redundant metadata", async (t) => { const fixture = await createFixture(t); @@ -145,6 +174,41 @@ test("installs Eve root placement without writing redundant metadata", async (t) const lock = await readProjectLock(fixture.project); assert.equal(await pathExists(join(eveSkillPath(fixture.project), "SKILL.md")), true); assert.equal("subagents" in lock.skills[skillName], false); + const markdown = await readFile(join(eveSkillPath(fixture.project), "SKILL.md"), "utf8"); + assert.doesNotMatch(markdown, /^name:/m); + assert.match(markdown, /^description:/m); +}); + +test("writes an Eve single file from a blob snapshot without a packaged SKILL.md", async (t) => { + const fixture = await createFixture(t); + const content = await readFile(join(fixture.source, skillName, "SKILL.md"), "utf8"); + const responses = { + tree: { sha: "tree-1", tree: [{ path: `skills/${skillName}/SKILL.md`, type: "blob", sha: "blob-1" }] }, + content, + snapshot: { files: [{ path: `${skillName}.md`, contents: content }], hash: "1".repeat(64) }, + }; + const fetchFixture = join(fixture.root, "cli-fetch-fixture.mjs"); + await writeFile(fetchFixture, ` +const fixture = ${JSON.stringify(responses)}; +globalThis.fetch = async (input) => { + const url = String(input); + if (url.includes("/git/trees/")) return Response.json(fixture.tree); + if (url.startsWith("https://raw.githubusercontent.com/")) return new Response(fixture.content); + if (url.includes("/snapshot.json") || url.includes("/api/download/")) return Response.json(fixture.snapshot); + return Response.json({ private: false }); +}; +`, "utf8"); + run(process.execPath, [ + "--import", pathToFileURL(fetchFixture).href, + skillsBinary, "add", "zapier/connectors", + "--skill", skillName, "--agent", "eve", "--subagent", "root", "--yes", + ], { cwd: fixture.project, env: cliEnvironment(fixture.home) }); + + const markdown = await readFile(join(fixture.project, "agent", "skills", `${skillName}.md`), "utf8"); + assert.doesNotMatch(markdown, /^name:/m); + assert.match(markdown, /^description:/m); + assert.equal(await pathExists(eveSkillPath(fixture.project)), false); + assert.ok((await readProjectLock(fixture.project)).skills[skillName]); }); test("records and installs a named Eve subagent", async (t) => { @@ -216,7 +280,7 @@ test("replays Eve placement when updating from an offline Git source", async (t) for (const target of [undefined, "builder"]) { assert.equal( await readFile(join(eveSkillPath(fixture.project, target), "payload.txt"), "utf8"), - "version two\n", + `version two${EOL}`, ); } }); diff --git a/scripts/prepare-wsl-worker.mjs b/scripts/prepare-wsl-worker.mjs new file mode 100644 index 00000000..d87813aa --- /dev/null +++ b/scripts/prepare-wsl-worker.mjs @@ -0,0 +1,170 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const WORKER_TARGET = "x86_64-unknown-linux-musl"; +const WORKER_PACKAGE = "wsl-environment-worker"; +const BUILD_ARGS = [ + "build", + "--locked", + "--manifest-path", + "src-tauri/Cargo.toml", + "-p", + WORKER_PACKAGE, + "--target", + WORKER_TARGET, + "--release", +]; + +export function parsePrepareArgs(argv, platform = process.platform) { + if (argv[0] === "--") argv = argv.slice(1); + if (argv[0] === "--verify" && argv.length === 2) { + return { mode: "verify", directory: argv[1] }; + } + let distro; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== "--distro" || !argv[index + 1]) { + throw new Error(`unknown or incomplete argument: ${argv[index]}`); + } + distro = argv[index + 1]; + index += 1; + } + if (platform === "win32" && !distro) { + throw new Error("--distro is required on Windows"); + } + return { mode: "build", distro }; +} + +export function workerBuildInvocation({ + platform, + distro, + linuxRepositoryRoot, +}) { + if (platform === "linux") { + return { command: "cargo", args: BUILD_ARGS }; + } + if (platform === "win32" && distro && linuxRepositoryRoot) { + return { + command: "wsl.exe", + args: [ + "--distribution", + distro, + "--cd", + linuxRepositoryRoot, + "--exec", + "/bin/sh", + "-lc", + 'exec "$@"', + "--", + "cargo", + ...BUILD_ARGS, + ], + }; + } + throw new Error(`unsupported worker build platform: ${platform}`); +} + +export function buildWorkerManifest(hexDigest) { + const sha256 = `sha256:${hexDigest}`; + return { buildId: sha256, sha256, target: WORKER_TARGET }; +} + +export async function verifyWorkerArtifact(directory) { + const worker = await readFile(path.join(directory, "worker")); + const manifest = JSON.parse( + await readFile(path.join(directory, "manifest.json"), "utf8"), + ); + if (manifest.target !== WORKER_TARGET) { + throw new Error(`unsupported WSL worker target: ${manifest.target}`); + } + if (manifest.buildId !== manifest.sha256) { + throw new Error("WSL worker buildId does not match sha256"); + } + const actual = `sha256:${createHash("sha256").update(worker).digest("hex")}`; + if (manifest.sha256 !== actual) { + throw new Error("WSL worker bytes do not match manifest sha256"); + } + return manifest; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: options.encoding, + stdio: options.encoding ? ["ignore", "pipe", "inherit"] : "inherit", + cwd: options.cwd, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with status ${result.status}`); + } + return result.stdout?.trim(); +} + +async function prepare() { + const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); + const args = parsePrepareArgs(process.argv.slice(2)); + if (args.mode === "verify") { + await verifyWorkerArtifact(path.resolve(repositoryRoot, args.directory)); + return; + } + const { distro } = args; + const linuxRepositoryRoot = + process.platform === "win32" + ? run( + "wsl.exe", + [ + "--distribution", + distro, + "--exec", + "wslpath", + "-a", + "-u", + repositoryRoot, + ], + { encoding: "utf8" }, + ) + : undefined; + const invocation = workerBuildInvocation({ + platform: process.platform, + distro, + linuxRepositoryRoot, + }); + run(invocation.command, invocation.args, { cwd: repositoryRoot }); + + const builtWorker = path.join( + repositoryRoot, + "src-tauri", + "target", + WORKER_TARGET, + "release", + WORKER_PACKAGE, + ); + const outputDirectory = path.join( + repositoryRoot, + "src-tauri", + "target", + "wsl-worker", + "current", + ); + const outputWorker = path.join(outputDirectory, "worker"); + await mkdir(outputDirectory, { recursive: true }); + await copyFile(builtWorker, outputWorker); + await chmod(outputWorker, 0o755); + const digest = createHash("sha256") + .update(await readFile(outputWorker)) + .digest("hex"); + await writeFile( + path.join(outputDirectory, "manifest.json"), + `${JSON.stringify(buildWorkerManifest(digest), null, 2)}\n`, + ); + await verifyWorkerArtifact(outputDirectory); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + await prepare(); +} diff --git a/scripts/verify-windows-worker-bundle.ps1 b/scripts/verify-windows-worker-bundle.ps1 new file mode 100644 index 00000000..a30769db --- /dev/null +++ b/scripts/verify-windows-worker-bundle.ps1 @@ -0,0 +1,70 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('nsis', 'msi')] + [string]$BundleKind +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$bundleDirectory = Join-Path $repositoryRoot "src-tauri\target\release\bundle\$BundleKind" +$pattern = if ($BundleKind -eq 'nsis') { '*.exe' } else { '*.msi' } +$packages = @(Get-ChildItem -LiteralPath $bundleDirectory -Filter $pattern -File) +if ($packages.Count -ne 1) { + throw "Expected one $BundleKind package in $bundleDirectory, found $($packages.Count)" +} + +function Assert-WorkerArtifact { + param([Parameter(Mandatory = $true)][string]$Root) + + $directories = @(Get-ChildItem -LiteralPath $Root -Filter manifest.json -File -Recurse | + Where-Object { + $_.Directory.Name -eq 'current' -and + $_.Directory.Parent.Name -eq 'wsl-worker' -and + (Test-Path -LiteralPath (Join-Path $_.Directory.FullName 'worker') -PathType Leaf) + } | + ForEach-Object { $_.Directory.FullName }) + if ($directories.Count -ne 1) { + throw "Expected one bundled WSL Worker resource, found $($directories.Count)" + } + $global:LASTEXITCODE = 0 + & node (Join-Path $repositoryRoot 'scripts\prepare-wsl-worker.mjs') --verify $directories[0] + if ($LASTEXITCODE -ne 0) { + throw 'Bundled WSL Worker artifact verification failed' + } +} + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "skill-deck-worker-bundle-$([Guid]::NewGuid().ToString('N'))" +try { + if ($BundleKind -eq 'msi') { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + $msiArguments = @( + '/a', + "`"$($packages[0].FullName)`"", + '/qn', + "TARGETDIR=`"$temporaryRoot`"" + ) + $process = Start-Process -FilePath msiexec.exe -ArgumentList $msiArguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "MSI administrative extraction failed with exit code $($process.ExitCode)" + } + Assert-WorkerArtifact -Root $temporaryRoot + } + else { + $process = Start-Process -FilePath $packages[0].FullName -ArgumentList @('/S', "/D=$temporaryRoot") -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "NSIS installation failed with exit code $($process.ExitCode)" + } + Assert-WorkerArtifact -Root $temporaryRoot + } +} +finally { + if ($BundleKind -eq 'nsis') { + $uninstaller = Join-Path $temporaryRoot 'uninstall.exe' + if (Test-Path -LiteralPath $uninstaller -PathType Leaf) { + Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait | Out-Null + } + } + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a9eedb51..8f21a455 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -99,13 +99,14 @@ dependencies = [ "chrono", "dirs", "embed-resource", + "environment-engine", + "environment-protocol", "flate2", + "futures-util", "junction", "keyring", "libc", "log", - "once_cell", - "pathdiff", "regex", "reqwest", "scraper", @@ -129,6 +130,7 @@ dependencies = [ "thiserror 2.0.18", "tiny_http", "tokio", + "tokio-util", "unicode-normalization", "url", "urlencoding", @@ -324,6 +326,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -718,6 +729,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "combine" version = "4.6.7" @@ -817,6 +837,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1144,6 +1170,18 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "endi" version = "1.1.1" @@ -1181,6 +1219,32 @@ dependencies = [ "regex", ] +[[package]] +name = "environment-engine" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "tempfile", + "unicode-normalization", +] + +[[package]] +name = "environment-protocol" +version = "0.0.0" +dependencies = [ + "bytes", + "futures-util", + "postcard", + "serde", + "serde_bytes", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1754,6 +1818,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1769,6 +1842,20 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3364,6 +3451,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4222,6 +4322,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4556,6 +4666,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6537,6 +6656,22 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wsl-environment-worker" +version = "0.0.0" +dependencies = [ + "environment-engine", + "environment-protocol", + "futures-util", + "libc", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-util", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 94289651..05982da9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,10 @@ serde_yaml = "0.9" thiserror = "2" dirs = "6" keyring = "4.1.5" +environment-protocol = { path = "crates/environment-protocol" } +environment-engine = { path = "crates/environment-engine" } +futures-util = "0.3" +tokio-util = { version = "0.7", features = ["codec"] } # 临时目录 tempfile = "3" @@ -45,9 +49,6 @@ tempfile = "3" # HTTP 请求 reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } -# 计算相对路径 -pathdiff = "0.2" - # 递归目录遍历 walkdir = "2" @@ -64,8 +65,6 @@ urlencoding = "2" # 时间处理 chrono = { version = "0.4", features = ["serde"] } -# 延迟初始化单例 -once_cell = "1" unicode-normalization = "0.1" sha2 = "0.10.9" tauri-plugin-opener = "2.5.3" @@ -87,3 +86,11 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Secur tempfile = "3" tauri = { version = "2.10.0", features = ["test"] } tiny_http = "0.12" + +[workspace] +members = [ + "crates/environment-engine", + "crates/environment-protocol", + "crates/wsl-environment-worker", +] +resolver = "2" diff --git a/src-tauri/app_commands.rs b/src-tauri/app_commands.rs index 7217c417..f07ab702 100644 --- a/src-tauri/app_commands.rs +++ b/src-tauri/app_commands.rs @@ -16,10 +16,12 @@ macro_rules! with_app_commands { list_skill_libraries => [commands::skill_libraries::list_skill_libraries], preview_library_application => [commands::skill_libraries::preview_library_application], preview_add_library_skills => [commands::skill_libraries::preview_add_library_skills], - preview_library_skill_updates => [commands::skill_libraries::preview_library_skill_updates], + preview_remove_library_skill => [commands::skill_libraries::preview_remove_library_skill], + prepare_library_skill_updates => [commands::skill_libraries::prepare_library_skill_updates], read_library_skill_content => [commands::skill_libraries::read_library_skill_content], rename_skill_library => [commands::skill_libraries::rename_skill_library], remove_library_skill => [commands::skill_libraries::remove_library_skill], + resume_library_membership => [commands::skill_libraries::resume_library_membership], retry_library_application => [commands::skill_libraries::retry_library_application], update_library_skills => [commands::skill_libraries::update_library_skills], list_agents => [commands::agents::list_agents], @@ -56,9 +58,9 @@ macro_rules! with_app_commands { open_skill_resource => [commands::resources::open_skill_resource], open_config_resource => [commands::resources::open_config_resource], check_updates => [commands::update::check_updates], - preview_update => [commands::update::preview_update], - update_skill => [commands::update::update_skill], - update_skills_batch => [commands::update::update_skills_batch], + prepare_update => [commands::update::prepare_update], + execute_update => [commands::update::execute_update], + cancel_update_preparation => [commands::update::cancel_update_preparation], open_install_wizard => [commands::wizard::open_install_wizard], get_install_wizard_session => [commands::wizard::get_install_wizard_session], focus_install_wizard => [commands::wizard::focus_install_wizard], diff --git a/src-tauri/crates/environment-engine/Cargo.toml b/src-tauri/crates/environment-engine/Cargo.toml new file mode 100644 index 00000000..415baaef --- /dev/null +++ b/src-tauri/crates/environment-engine/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "environment-engine" +version = "0.0.0" +edition = "2021" +rust-version = "1.88.0" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10.9" +unicode-normalization = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/crates/environment-engine/src/atomic_document.rs b/src-tauri/crates/environment-engine/src/atomic_document.rs new file mode 100644 index 00000000..25177110 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/atomic_document.rs @@ -0,0 +1,467 @@ +//! Small synchronous document-publication mechanics. +//! +//! The caller owns path authorization, schema validation, parent creation and +//! read/modify/write serialization. This module never deletes a `.bak`, follows +//! a recovery policy, or treats an I/O error after publication as a rollback. +//! Snapshot comparison is optimistic: it is not a lock for cooperating or +//! uncooperative external processes. Directory transactions remain separate. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublicationState { + NotPublished, + PublishedUnconfirmed, + OutcomeUnknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WritePhase { + Preparing, + Writing, + BeforePublish, + Publishing, + Confirming, +} + +#[derive(Debug)] +pub struct AtomicWriteError { + pub publication: PublicationState, + pub phase: WritePhase, + source: io::Error, + conflict: bool, +} + +impl AtomicWriteError { + pub fn is_conflict(&self) -> bool { + self.conflict + } + + fn io(phase: WritePhase, publication: PublicationState, source: io::Error) -> Self { + Self { + publication, + phase, + source, + conflict: false, + } + } + + fn conflict() -> Self { + Self { + publication: PublicationState::NotPublished, + phase: WritePhase::BeforePublish, + source: io::Error::other("document changed since its snapshot was read"), + conflict: true, + } + } +} + +impl std::fmt::Display for AtomicWriteError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "document {:?} failed ({:?}): {}", + self.phase, self.publication, self.source + ) + } +} + +impl std::error::Error for AtomicWriteError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +#[derive(Clone, Copy)] +enum Expectation<'a> { + Any, + Snapshot(Option<&'a [u8]>), +} + +/// Publish complete bytes in an existing, authorized parent directory. +/// A successful return includes file sync and, on Unix, parent-directory sync. +/// Other platforms retain their existing directory-sync limitations. +pub fn replace(path: &Path, bytes: &[u8]) -> Result<(), AtomicWriteError> { + replace_with_hook(path, Expectation::Any, bytes, |_| Ok(())) +} + +/// The expected bytes are the bytes used to compute this update, not a new +/// snapshot acquired inside a save wrapper. `None` means expected missing. +pub fn replace_if_unchanged( + path: &Path, + expected: Option<&[u8]>, + bytes: &[u8], +) -> Result<(), AtomicWriteError> { + replace_with_hook(path, Expectation::Snapshot(expected), bytes, |_| Ok(())) +} + +/// Remove a document only while it still matches the observed bytes. A +/// successful return includes parent-directory sync where the platform +/// supports it. `None` means the caller observed the document as missing. +pub fn remove_if_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<(), AtomicWriteError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .filter(|_| path.file_name().is_some()) + .ok_or_else(|| { + AtomicWriteError::io( + WritePhase::Preparing, + PublicationState::NotPublished, + io::Error::new( + io::ErrorKind::InvalidInput, + "document needs an explicit parent and file name", + ), + ) + })?; + check_expected(path, Expectation::Snapshot(expected))?; + if expected.is_none() { + return Ok(()); + } + check_expected(path, Expectation::Snapshot(expected))?; + fs::remove_file(path).map_err(|error| { + AtomicWriteError::io( + WritePhase::Publishing, + PublicationState::OutcomeUnknown, + error, + ) + })?; + sync_directory(parent).map_err(|error| { + AtomicWriteError::io( + WritePhase::Confirming, + PublicationState::PublishedUnconfirmed, + error, + ) + }) +} + +fn replace_with_hook( + path: &Path, + expected: Expectation<'_>, + bytes: &[u8], + mut hook: impl FnMut(WritePhase) -> io::Result<()>, +) -> Result<(), AtomicWriteError> { + let before = |phase, error| AtomicWriteError::io(phase, PublicationState::NotPublished, error); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .filter(|_| path.file_name().is_some()) + .ok_or_else(|| { + before( + WritePhase::Preparing, + io::Error::new( + io::ErrorKind::InvalidInput, + "document needs an explicit parent and file name", + ), + ) + })?; + check_expected(path, expected)?; + let mut temporary = + TemporaryDocument::create(parent).map_err(|error| before(WritePhase::Preparing, error))?; + hook(WritePhase::Writing).map_err(|error| before(WritePhase::Writing, error))?; + let file = temporary + .file + .as_mut() + .expect("new temporary has its file handle"); + file.write_all(bytes) + .map_err(|error| before(WritePhase::Writing, error))?; + file.sync_all() + .map_err(|error| before(WritePhase::Writing, error))?; + // Closing before rename also works on platforms with restrictive sharing. + drop(temporary.file.take()); + hook(WritePhase::BeforePublish).map_err(|error| before(WritePhase::BeforePublish, error))?; + check_expected(path, expected)?; + let temporary_path = temporary + .path + .as_ref() + .expect("unpublished temporary has a path"); + fs::rename(temporary_path, path).map_err(|error| { + AtomicWriteError::io( + WritePhase::Publishing, + // A failed namespace operation on some storage may have taken effect. + // Do not promise rollback solely from its error return. + PublicationState::OutcomeUnknown, + error, + ) + })?; + temporary.path = None; + hook(WritePhase::Confirming) + .and_then(|()| sync_directory(parent)) + .map_err(|error| { + AtomicWriteError::io( + WritePhase::Confirming, + PublicationState::PublishedUnconfirmed, + error, + ) + }) +} + +fn check_expected(path: &Path, expected: Expectation<'_>) -> Result<(), AtomicWriteError> { + let Expectation::Snapshot(expected) = expected else { + return Ok(()); + }; + let limit = expected.map_or(0, <[u8]>::len); + match read_optional_bounded(path, limit) { + Ok(current) if current.as_deref() == expected => Ok(()), + Ok(_) => Err(AtomicWriteError::conflict()), + // The observed file grew beyond the old snapshot, so it cannot match. + Err(error) if error.kind() == io::ErrorKind::InvalidData => { + Err(AtomicWriteError::conflict()) + } + Err(error) => Err(AtomicWriteError::io( + WritePhase::BeforePublish, + PublicationState::NotPublished, + error, + )), + } +} + +/// No truncated bytes are returned as a valid document snapshot. The caller +/// chooses a resource-specific limit; zero permits only an empty file. +pub fn read_optional_bounded(path: &Path, limit: usize) -> io::Result>> { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !file.metadata()?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "document is not a regular file", + )); + } + let bound = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); + let mut bytes = Vec::new(); + file.take(bound).read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "document exceeds its read limit", + )); + } + Ok(Some(bytes)) +} + +#[cfg(unix)] +pub fn sync_directory(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +pub fn sync_directory(_parent: &Path) -> io::Result<()> { + // Do not claim Unix directory durability on Windows or other platforms. + Ok(()) +} + +struct TemporaryDocument { + path: Option, + file: Option, +} + +impl TemporaryDocument { + fn create(parent: &Path) -> io::Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(io::Error::other)? + .as_nanos(); + for _ in 0..64 { + let sequence = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!( + ".skill-deck-document-{}-{nonce:x}-{sequence:x}", + std::process::id(), + )); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(file) => { + return Ok(Self { + path: Some(path), + file: Some(file), + }) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "cannot allocate a unique document temporary", + )) + } +} + +impl Drop for TemporaryDocument { + fn drop(&mut self) { + drop(self.file.take()); + if let Some(path) = &self.path { + let _ = fs::remove_file(path); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn complete_document_replacement_preserves_unrelated_backup() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + let backup = root.path().join("document.json.bak"); + fs::write(&target, b"old").unwrap(); + fs::write(&backup, b"last-known-good").unwrap(); + replace(&target, b"new").unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert_eq!(fs::read(&backup).unwrap(), b"last-known-good"); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2); + } + + #[test] + fn prepublication_failure_keeps_old_bytes_and_removes_only_own_temporary() { + for phase in [WritePhase::Writing, WritePhase::BeforePublish] { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"old").unwrap(); + let error = replace_with_hook(&target, Expectation::Any, b"new", |at| { + if at == phase { + Err(io::Error::other("injected")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(error.publication, PublicationState::NotPublished); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1); + } + } + + #[test] + fn failure_after_publish_is_not_reported_as_unchanged() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"old").unwrap(); + let error = replace_with_hook(&target, Expectation::Any, b"new", |phase| { + if phase == WritePhase::Confirming { + Err(io::Error::other("injected directory sync failure")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(error.publication, PublicationState::PublishedUnconfirmed); + assert_eq!(error.phase, WritePhase::Confirming); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1); + } + + #[test] + fn conditional_replace_rechecks_the_original_bytes_at_publish() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"old").unwrap(); + let error = replace_with_hook( + &target, + Expectation::Snapshot(Some(b"old")), + b"new", + |phase| { + if phase == WritePhase::BeforePublish { + fs::write(&target, b"external")?; + } + Ok(()) + }, + ) + .unwrap_err(); + assert!(error.is_conflict()); + assert_eq!(error.publication, PublicationState::NotPublished); + assert_eq!(fs::read(&target).unwrap(), b"external"); + } + + #[test] + fn missing_and_empty_are_different_snapshots() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + assert_eq!(read_optional_bounded(&target, 0).unwrap(), None); + replace_if_unchanged(&target, None, b"").unwrap(); + assert_eq!(read_optional_bounded(&target, 0).unwrap(), Some(Vec::new())); + assert!(replace_if_unchanged(&target, None, b"unexpected") + .unwrap_err() + .is_conflict()); + replace_if_unchanged(&target, Some(b""), b"next").unwrap(); + } + + #[test] + fn conditional_remove_preserves_a_document_changed_after_observation() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"external").unwrap(); + + let error = remove_if_unchanged(&target, Some(b"observed")).unwrap_err(); + + assert!(error.is_conflict()); + assert_eq!(error.publication, PublicationState::NotPublished); + assert_eq!(fs::read(&target).unwrap(), b"external"); + } + + #[test] + fn conditional_remove_distinguishes_missing_from_present() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + remove_if_unchanged(&target, None).unwrap(); + fs::write(&target, b"created-later").unwrap(); + + assert!(remove_if_unchanged(&target, None) + .unwrap_err() + .is_conflict()); + remove_if_unchanged(&target, Some(b"created-later")).unwrap(); + assert!(!target.exists()); + } + + #[test] + fn bounded_read_rejects_oversize_instead_of_returning_truncated_snapshot() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"12345").unwrap(); + assert_eq!( + read_optional_bounded(&target, 5).unwrap(), + Some(b"12345".to_vec()) + ); + assert_eq!( + read_optional_bounded(&target, 4).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + assert!(read_optional_bounded(root.path(), 100).is_err()); + } + + #[test] + fn primitive_does_not_create_missing_parent_directories() { + let root = tempdir().unwrap(); + let parent = root.path().join("not-created"); + let error = replace(&parent.join("document.json"), b"new").unwrap_err(); + assert_eq!(error.publication, PublicationState::NotPublished); + assert!(!parent.exists()); + } + + #[cfg(unix)] + #[test] + fn private_temporary_does_not_publish_world_readable_data() { + use std::os::unix::fs::PermissionsExt; + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + replace(&target, b"private").unwrap(); + assert_eq!( + fs::metadata(target).unwrap().permissions().mode() & 0o077, + 0 + ); + } +} diff --git a/src-tauri/crates/environment-engine/src/directory.rs b/src-tauri/crates/environment-engine/src/directory.rs new file mode 100644 index 00000000..4bcfe8a4 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/directory.rs @@ -0,0 +1,220 @@ +use std::fmt; +use std::path::PathBuf; + +pub fn plain_descendant_directory( + root: &std::path::Path, + relative: &std::path::Path, +) -> std::io::Result { + if !root.is_absolute() + || relative + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + { + return Err(std::io::ErrorKind::InvalidInput.into()); + } + let mut path = root.to_path_buf(); + for component in std::iter::once(None).chain(relative.components().map(Some)) { + if let Some(component) = component { + path.push(component); + } + let metadata = std::fs::symlink_metadata(&path)?; + #[cfg(windows)] + let is_link = { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 + }; + #[cfg(not(windows))] + let is_link = metadata.file_type().is_symlink(); + if is_link { + return Err(std::io::ErrorKind::Unsupported.into()); + } + if !metadata.is_dir() { + return Err(std::io::ErrorKind::NotADirectory.into()); + } + } + Ok(path) +} + +pub fn size_no_follow(root: &std::path::Path) -> std::io::Result { + let mut pending = vec![root.to_path_buf()]; + let mut total = 0_u64; + while let Some(path) = pending.pop() { + let metadata = std::fs::symlink_metadata(&path)?; + #[cfg(windows)] + let is_link = { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 + }; + #[cfg(not(windows))] + let is_link = metadata.file_type().is_symlink(); + total = total.saturating_add(metadata.len()); + if metadata.is_dir() && !is_link { + for entry in std::fs::read_dir(path)? { + pending.push(entry?.path()); + } + } + } + Ok(total) +} + +#[cfg(target_os = "linux")] +use std::fs; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCountRequest { + pub paths: Vec, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCountFact { + pub path: PathBuf, + pub observed_count: Option, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCountResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryListRequest { + pub path: PathBuf, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryListResponse { + pub names: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectoryCountError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for DirectoryCountError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => { + formatter.write_str("Linux directory count is unavailable") + } + Self::InvalidRequest => formatter.write_str("invalid bounded directory count request"), + Self::Cancelled => formatter.write_str("directory count was cancelled"), + } + } +} + +impl std::error::Error for DirectoryCountError {} + +pub fn count_entries( + request: &DirectoryCountRequest, +) -> Result { + count_entries_with_cancel(request, || false) +} + +pub fn list_child_directories( + request: &DirectoryListRequest, +) -> Result { + list_child_directories_with_cancel(request, || false) +} + +pub fn list_child_directories_with_cancel( + request: &DirectoryListRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if !request.path.is_absolute() || request.limit == 0 { + return Err(DirectoryCountError::InvalidRequest); + } + list_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn list_platform( + _request: &DirectoryListRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(DirectoryCountError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn list_platform( + request: &DirectoryListRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut names = match fs::read_dir(&request.path) { + Ok(entries) => entries + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .map(|entry| PathBuf::from(entry.file_name())) + .collect::>(), + Err(_) => Vec::new(), + }; + if is_cancelled() { + return Err(DirectoryCountError::Cancelled); + } + names.sort(); + let truncated = names.len() > request.limit as usize; + names.truncate(request.limit as usize); + Ok(DirectoryListResponse { names, truncated }) +} + +pub fn count_entries_with_cancel( + request: &DirectoryCountRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.paths.is_empty() + || request.limit == 0 + || request.paths.iter().any(|path| !path.is_absolute()) + { + return Err(DirectoryCountError::InvalidRequest); + } + count_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn count_platform( + _request: &DirectoryCountRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(DirectoryCountError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn count_platform( + request: &DirectoryCountRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut facts = Vec::with_capacity(request.paths.len()); + for path in &request.paths { + if is_cancelled() { + return Err(DirectoryCountError::Cancelled); + } + let observed = fs::read_dir(path).ok().and_then(|entries| { + let mut count = 0u32; + for entry in entries.take(request.limit as usize + 1) { + if is_cancelled() || entry.is_err() { + return None; + } + count += 1; + } + Some((count.min(request.limit), count > request.limit)) + }); + facts.push(DirectoryCountFact { + path: path.clone(), + observed_count: observed.map(|(count, _)| count), + truncated: observed.is_some_and(|(_, truncated)| truncated), + }); + } + Ok(DirectoryCountResponse { facts }) +} diff --git a/src-tauri/crates/environment-engine/src/document.rs b/src-tauri/crates/environment-engine/src/document.rs new file mode 100644 index 00000000..0cfa0875 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/document.rs @@ -0,0 +1,320 @@ +use std::fmt; +use std::path::PathBuf; + +use crate::atomic_document::{PublicationState, WritePhase}; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentRequest { + pub queries: Vec, + pub aggregate_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentQuery { + pub path: PathBuf, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DocumentState { + Missing, + NotFile, + Unreadable, + Bytes(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentFact { + pub path: PathBuf, + pub state: DocumentState, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for DocumentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => formatter.write_str("Linux document read is unavailable"), + Self::InvalidRequest => formatter.write_str("invalid bounded document read request"), + Self::Cancelled => formatter.write_str("document read was cancelled"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentWriteError { + UnsupportedPlatform, + Io { + phase: WritePhase, + publication: PublicationState, + }, + Conflict, + InvalidTarget, +} + +impl fmt::Display for DocumentWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => formatter.write_str("Linux document write is unavailable"), + Self::Io { phase, publication } => { + write!( + formatter, + "document write failed ({phase:?}, {publication:?})" + ) + } + Self::Conflict => formatter.write_str("document changed since it was read"), + Self::InvalidTarget => formatter.write_str("document target is not a regular file"), + } + } +} + +pub fn write_document_atomic( + path: &std::path::Path, + expected_revision: Option<&str>, + bytes: &[u8], + max_current_bytes: usize, +) -> Result { + write_document_platform(path, expected_revision, bytes, max_current_bytes) +} + +pub fn remove_document_if_revision( + path: &std::path::Path, + expected_revision: Option<&str>, + max_current_bytes: usize, +) -> Result<(), DocumentWriteError> { + remove_document_platform(path, expected_revision, max_current_bytes) +} + +#[cfg(not(target_os = "linux"))] +fn write_document_platform( + _path: &std::path::Path, + _expected_revision: Option<&str>, + _bytes: &[u8], + _max_current_bytes: usize, +) -> Result { + Err(DocumentWriteError::UnsupportedPlatform) +} + +#[cfg(not(target_os = "linux"))] +fn remove_document_platform( + _path: &std::path::Path, + _expected_revision: Option<&str>, + _max_current_bytes: usize, +) -> Result<(), DocumentWriteError> { + Err(DocumentWriteError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn write_document_platform( + path: &std::path::Path, + expected_revision: Option<&str>, + bytes: &[u8], + max_current_bytes: usize, +) -> Result { + if !path.is_absolute() || path.file_name().is_none() { + return Err(DocumentWriteError::InvalidTarget); + } + let current = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(DocumentWriteError::InvalidTarget) + } + Ok(_) => crate::atomic_document::read_optional_bounded(path, max_current_bytes).map_err( + |_| DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }, + )?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => { + return Err(DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }) + } + }; + let current_revision = current.as_deref().map(document_revision); + if current_revision.as_deref() != expected_revision { + return Err(DocumentWriteError::Conflict); + } + let parent = path.parent().ok_or(DocumentWriteError::InvalidTarget)?; + fs::create_dir_all(parent).map_err(|_| DocumentWriteError::Io { + phase: WritePhase::Preparing, + publication: PublicationState::NotPublished, + })?; + crate::atomic_document::replace_if_unchanged(path, current.as_deref(), bytes).map_err( + |error| { + if error.is_conflict() { + DocumentWriteError::Conflict + } else { + DocumentWriteError::Io { + phase: error.phase, + publication: error.publication, + } + } + }, + )?; + Ok(document_revision(bytes)) +} + +#[cfg(target_os = "linux")] +fn remove_document_platform( + path: &std::path::Path, + expected_revision: Option<&str>, + max_current_bytes: usize, +) -> Result<(), DocumentWriteError> { + if !path.is_absolute() || path.file_name().is_none() { + return Err(DocumentWriteError::InvalidTarget); + } + let current = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(DocumentWriteError::InvalidTarget) + } + Ok(_) => crate::atomic_document::read_optional_bounded(path, max_current_bytes).map_err( + |_| DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }, + )?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => { + return Err(DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }) + } + }; + if current.as_deref().map(document_revision).as_deref() != expected_revision { + return Err(DocumentWriteError::Conflict); + } + if current.is_none() { + return Ok(()); + } + crate::atomic_document::remove_if_unchanged(path, current.as_deref()).map_err(|error| { + if error.is_conflict() { + DocumentWriteError::Conflict + } else { + DocumentWriteError::Io { + phase: error.phase, + publication: error.publication, + } + } + }) +} + +#[cfg(target_os = "linux")] +fn document_revision(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +impl std::error::Error for DocumentError {} +impl std::error::Error for DocumentWriteError {} + +pub fn read_documents(request: &DocumentRequest) -> Result { + read_documents_with_cancel(request, || false) +} + +pub fn read_documents_with_cancel( + request: &DocumentRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.queries.is_empty() + || request.aggregate_limit == 0 + || request + .queries + .iter() + .any(|query| !query.path.is_absolute() || query.limit == 0) + { + return Err(DocumentError::InvalidRequest); + } + read_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn read_platform( + _request: &DocumentRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(DocumentError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn read_platform( + request: &DocumentRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut total_content_bytes = 0usize; + let mut facts = Vec::with_capacity(request.queries.len()); + for query in &request.queries { + if is_cancelled() { + return Err(DocumentError::Cancelled); + } + let metadata = match fs::metadata(&query.path) { + Ok(metadata) if metadata.is_file() => metadata, + Ok(_) => { + facts.push(fact(query.path.clone(), DocumentState::NotFile, false)); + continue; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + facts.push(fact(query.path.clone(), DocumentState::Missing, false)); + continue; + } + Err(_) => { + facts.push(fact(query.path.clone(), DocumentState::Unreadable, false)); + continue; + } + }; + let remaining = (request.aggregate_limit as usize).saturating_sub(total_content_bytes); + let limit = remaining.min(query.limit as usize); + let mut bytes = Vec::new(); + let state = match fs::File::open(&query.path) { + Ok(file) => { + if file.take(limit as u64).read_to_end(&mut bytes).is_ok() { + total_content_bytes += bytes.len(); + DocumentState::Bytes(bytes) + } else { + DocumentState::Unreadable + } + } + Err(_) => DocumentState::Unreadable, + }; + facts.push(fact( + query.path.clone(), + state, + metadata.len() > limit as u64, + )); + } + Ok(DocumentResponse { + facts, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn fact(path: PathBuf, state: DocumentState, truncated: bool) -> DocumentFact { + DocumentFact { + path, + state, + truncated, + } +} diff --git a/src-tauri/crates/environment-engine/src/entry.rs b/src-tauri/crates/environment-engine/src/entry.rs new file mode 100644 index 00000000..97e068d4 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/entry.rs @@ -0,0 +1,139 @@ +use std::fmt; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryRequest { + pub paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + Missing, + File, + Directory, + Symlink, + BrokenLink, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryMetadata { + pub device: u64, + pub inode: u64, + pub mode: u32, + pub size: u64, + pub mtime_seconds: i64, + pub mtime_nanos: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryFact { + pub kind: EntryKind, + pub metadata: Option, + pub link_target: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryError { + UnsupportedPlatform, + InvalidRequest, + Unavailable, + Cancelled, +} + +impl fmt::Display for EntryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for EntryError {} + +pub fn inspect_entries(request: &EntryRequest) -> Result { + inspect_entries_with_cancel(request, || false) +} + +pub fn inspect_entries_with_cancel( + request: &EntryRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.paths.is_empty() || request.paths.iter().any(|path| !path.is_absolute()) { + return Err(EntryError::InvalidRequest); + } + inspect_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn inspect_platform( + _request: &EntryRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(EntryError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn inspect_platform( + request: &EntryRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + use std::os::unix::fs::MetadataExt; + + let mut facts = Vec::with_capacity(request.paths.len()); + for path in &request.paths { + if is_cancelled() { + return Err(EntryError::Cancelled); + } + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + facts.push(EntryFact { + kind: EntryKind::Missing, + metadata: None, + link_target: None, + }); + continue; + } + Err(_) => return Err(EntryError::Unavailable), + }; + let file_type = metadata.file_type(); + let (kind, link_target) = if file_type.is_symlink() { + let target = fs::read_link(path).map_err(|_| EntryError::Unavailable)?; + let kind = match fs::metadata(path) { + Ok(_) => EntryKind::Symlink, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => EntryKind::BrokenLink, + Err(_) => return Err(EntryError::Unavailable), + }; + (kind, Some(target)) + } else if metadata.is_dir() { + (EntryKind::Directory, None) + } else if metadata.is_file() { + (EntryKind::File, None) + } else { + (EntryKind::Other, None) + }; + facts.push(EntryFact { + kind, + metadata: Some(EntryMetadata { + device: metadata.dev(), + inode: metadata.ino(), + mode: metadata.mode(), + size: metadata.size(), + mtime_seconds: metadata.mtime(), + mtime_nanos: metadata.mtime_nsec(), + }), + link_target, + }); + } + Ok(EntryResponse { facts }) +} diff --git a/src-tauri/crates/environment-engine/src/git_ref.rs b/src-tauri/crates/environment-engine/src/git_ref.rs new file mode 100644 index 00000000..519807e1 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/git_ref.rs @@ -0,0 +1,70 @@ +/// Ordered Git ref names, shared by Native and Worker probes. +pub fn remote_ref_patterns(reference: Option<&str>) -> Vec { + match reference.filter(|value| !value.is_empty()) { + None => vec!["HEAD".into()], + Some(value) if value.starts_with("refs/tags/") => { + vec![format!("{value}^{{}}"), value.into()] + } + Some(value) if value.starts_with("refs/") => vec![value.into()], + Some(value) => vec![ + format!("refs/heads/{value}"), + format!("refs/tags/{value}^{{}}"), + format!("refs/tags/{value}"), + ], + } +} + +pub fn resolve_remote_revision(output: &str, reference: Option<&str>) -> Option { + let records = output + .lines() + .filter_map(|line| line.split_once('\t')) + .collect::>(); + remote_ref_patterns(reference).iter().find_map(|candidate| { + let (revision, _) = records.iter().find(|(_, name)| *name == candidate)?; + (matches!(revision.len(), 40 | 64) && revision.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| revision.to_ascii_lowercase()) + }) +} + +pub fn clone_branch(reference: &str) -> &str { + reference + .strip_prefix("refs/heads/") + .or_else(|| reference.strip_prefix("refs/tags/")) + .unwrap_or(reference) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_names_prefer_branches_while_explicit_tags_peel_to_commits() { + let branch = "a".repeat(40); + let object = "b".repeat(40); + let commit = "c".repeat(40); + let output = format!("{object}\trefs/tags/release\n{commit}\trefs/tags/release^{{}}\n{branch}\trefs/heads/release\n"); + assert_eq!( + resolve_remote_revision(&output, Some("release")), + Some(branch.clone()) + ); + assert_eq!( + resolve_remote_revision(&output, Some("refs/heads/release")), + Some(branch) + ); + assert_eq!( + resolve_remote_revision(&output, Some("refs/tags/release")), + Some(commit) + ); + assert_eq!(resolve_remote_revision(&output, Some("missing")), None); + assert_eq!(resolve_remote_revision(&output, None), None); + } + + #[test] + fn default_ref_and_lightweight_tags_are_exact_and_validate_object_ids() { + let commit = "d".repeat(64); + let output = format!("{commit}\tHEAD\n{commit}\trefs/tags/v1\ninvalid\trefs/heads/bad\n"); + assert_eq!(resolve_remote_revision(&output, None), Some(commit.clone())); + assert_eq!(resolve_remote_revision(&output, Some("v1")), Some(commit)); + assert_eq!(resolve_remote_revision(&output, Some("bad")), None); + } +} diff --git a/src-tauri/crates/environment-engine/src/inspection.rs b/src-tauri/crates/environment-engine/src/inspection.rs new file mode 100644 index 00000000..2fb1d0e7 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/inspection.rs @@ -0,0 +1,296 @@ +use std::fmt; +#[cfg(target_os = "linux")] +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; +#[cfg(target_os = "linux")] +use std::{fs, io::Read, path::Path}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectionRequest { + pub roots: Vec, + pub read_content: bool, + pub per_file_limit: u32, + pub aggregate_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectionRoot { + pub path: PathBuf, + pub stat_only: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorCode { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathFact { + pub root_index: u32, + pub relative_path: PathBuf, + pub kind: EntryKind, + pub resolved_target: Option, + pub fingerprint: Option, + pub content_bytes: Vec, + pub truncated: bool, + pub error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectionSnapshot { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InspectionError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for InspectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => formatter.write_str("Linux inspection is unavailable"), + Self::InvalidRequest => formatter.write_str("invalid bounded inspection request"), + Self::Cancelled => formatter.write_str("inspection was cancelled"), + } + } +} + +impl std::error::Error for InspectionError {} + +pub fn inspect(request: &InspectionRequest) -> Result { + inspect_with_cancel(request, || false) +} + +pub fn inspect_with_cancel( + request: &InspectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate(request)?; + inspect_platform(request, &is_cancelled) +} + +fn validate(request: &InspectionRequest) -> Result<(), InspectionError> { + if request.roots.is_empty() + || request.per_file_limit == 0 + || request.aggregate_limit == 0 + || request.per_file_limit > request.aggregate_limit + || request.roots.len() > u32::MAX as usize + { + return Err(InspectionError::InvalidRequest); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn inspect_platform( + _request: &InspectionRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(InspectionError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn inspect_platform( + request: &InspectionRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut facts = Vec::new(); + let mut total_content_bytes = 0usize; + + for (root_index, root) in request.roots.iter().enumerate() { + ensure_not_cancelled(is_cancelled)?; + let root_fact = inspect_path( + &root.path, + root_index as u32, + PathBuf::new(), + false, + request, + &mut total_content_bytes, + ); + let root_is_directory = root_fact.kind == EntryKind::Directory; + facts.push(root_fact); + + if root.stat_only || !root_is_directory { + continue; + } + + let entries = match fs::read_dir(&root.path) { + Ok(entries) => entries, + Err(_) => { + if let Some(root_fact) = facts.last_mut() { + root_fact.error_code = Some(ErrorCode::PathUnavailable); + } + continue; + } + }; + let mut children = entries.filter_map(Result::ok).collect::>(); + children.sort_by_key(fs::DirEntry::file_name); + + for child in children { + ensure_not_cancelled(is_cancelled)?; + let relative_path = PathBuf::from(child.file_name()); + let child_path = child.path(); + let child_fact = inspect_path( + &child_path, + root_index as u32, + relative_path.clone(), + false, + request, + &mut total_content_bytes, + ); + let can_contain_skill = + matches!(child_fact.kind, EntryKind::Directory | EntryKind::Symlink); + facts.push(child_fact); + + if can_contain_skill { + let skill_path = child_path.join("SKILL.md"); + if fs::symlink_metadata(&skill_path).is_ok() { + facts.push(inspect_path( + &skill_path, + root_index as u32, + relative_path.join("SKILL.md"), + request.read_content, + request, + &mut total_content_bytes, + )); + } + } + } + } + + Ok(InspectionSnapshot { + facts, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn ensure_not_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), InspectionError> { + if is_cancelled() { + Err(InspectionError::Cancelled) + } else { + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn inspect_path( + path: &Path, + root_index: u32, + relative_path: PathBuf, + read_content: bool, + request: &InspectionRequest, + total_content_bytes: &mut usize, +) -> PathFact { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return empty_fact(root_index, relative_path, EntryKind::Missing, None); + } + Err(_) => { + return empty_fact( + root_index, + relative_path, + EntryKind::Other, + Some(ErrorCode::PathUnavailable), + ); + } + }; + let file_type = metadata.file_type(); + let kind = if file_type.is_symlink() { + EntryKind::Symlink + } else if file_type.is_file() { + EntryKind::File + } else if file_type.is_dir() { + EntryKind::Directory + } else { + EntryKind::Other + }; + let (resolved_target, mut error_code) = if kind == EntryKind::Symlink { + match fs::read_link(path) { + Ok(target) => (Some(target), None), + Err(_) => (None, Some(ErrorCode::ReadLinkFailed)), + } + } else { + (None, None) + }; + let mut content_bytes = Vec::new(); + let mut truncated = false; + + if kind == EntryKind::File && read_content { + let remaining = (request.aggregate_limit as usize).saturating_sub(*total_content_bytes); + let limit = remaining.min(request.per_file_limit as usize); + match fs::File::open(path) { + Ok(file) => { + if file + .take(limit as u64) + .read_to_end(&mut content_bytes) + .is_err() + { + content_bytes.clear(); + error_code = Some(ErrorCode::ReadFailed); + } else { + truncated = metadata.len() > content_bytes.len() as u64; + *total_content_bytes += content_bytes.len(); + } + } + Err(_) => error_code = Some(ErrorCode::ReadFailed), + } + } + + PathFact { + root_index, + relative_path, + kind, + resolved_target, + fingerprint: Some(format!( + "entry-v1:{}:{}:{}:{}:{}:{}", + metadata.dev(), + metadata.ino(), + metadata.mode(), + metadata.size(), + metadata.mtime(), + metadata.mtime_nsec() + )), + content_bytes, + truncated, + error_code, + } +} + +#[cfg(target_os = "linux")] +fn empty_fact( + root_index: u32, + relative_path: PathBuf, + kind: EntryKind, + error_code: Option, +) -> PathFact { + PathFact { + root_index, + relative_path, + kind, + resolved_target: None, + fingerprint: None, + content_bytes: Vec::new(), + truncated: false, + error_code, + } +} diff --git a/src-tauri/crates/environment-engine/src/lib.rs b/src-tauri/crates/environment-engine/src/lib.rs new file mode 100644 index 00000000..e52e5ca5 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/lib.rs @@ -0,0 +1,18 @@ +#![forbid(unsafe_code)] + +//! Shared filesystem mechanics, with platform-specific Linux operations for the WSL Worker. + +pub mod atomic_document; +pub mod directory; +pub mod document; +pub mod entry; +pub mod git_ref; +pub mod inspection; +pub mod library; +pub mod linux_mutation; +pub mod lock; +pub mod manifest; +pub mod path; +pub mod payload; +pub mod projection; +pub mod source_inventory; diff --git a/src-tauri/crates/environment-engine/src/library.rs b/src-tauri/crates/environment-engine/src/library.rs new file mode 100644 index 00000000..f7e3ed96 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/library.rs @@ -0,0 +1,519 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +use crate::linux_mutation::ParentIdentity; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogSnapshot { + pub bytes: Option>, + pub revision: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogWrite { + pub expected_revision: Option, + pub bytes: Vec, + pub max_current_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetExpectation { + pub parent: ParentIdentity, + pub fingerprint: String, + pub content_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContentAction { + Upsert { payload_root: PathBuf }, + Delete, + DeleteIfPresent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryCommit { + pub root: PathBuf, + pub operation_id: String, + pub destination: PathBuf, + pub expected_target: TargetExpectation, + pub content: ContentAction, + pub catalog: CatalogWrite, +} + +#[derive(Debug)] +pub enum LibraryError { + UnsupportedPlatform, + InvalidRequest, + StaleTarget, + InvalidPayload, + RecoveryIncomplete, + Io(std::io::Error), +} + +impl fmt::Display for LibraryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for LibraryError {} + +impl From for LibraryError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +pub fn read_catalog(root: &Path) -> Result { + read_catalog_platform(root) +} + +pub fn commit(request: LibraryCommit) -> Result<(), LibraryError> { + commit_platform(request) +} + +pub fn write_catalog( + root: &Path, + library_ids: &[String], + catalog: CatalogWrite, +) -> Result { + write_catalog_platform(root, library_ids, catalog) +} + +#[cfg(not(target_os = "linux"))] +fn read_catalog_platform(_root: &Path) -> Result { + Err(LibraryError::UnsupportedPlatform) +} + +#[cfg(not(target_os = "linux"))] +fn commit_platform(_request: LibraryCommit) -> Result<(), LibraryError> { + Err(LibraryError::UnsupportedPlatform) +} + +#[cfg(not(target_os = "linux"))] +fn write_catalog_platform( + _root: &Path, + _library_ids: &[String], + _catalog: CatalogWrite, +) -> Result { + Err(LibraryError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn read_catalog_platform(root: &Path) -> Result { + let catalog = read_optional(&root.join("catalog.json"))?; + let current_revision = catalog.as_deref().map(revision); + recover(root, current_revision.as_deref())?; + let bytes = read_optional(&root.join("catalog.json"))?; + Ok(CatalogSnapshot { + revision: bytes.as_deref().map(revision), + bytes, + }) +} + +#[cfg(target_os = "linux")] +fn commit_platform(request: LibraryCommit) -> Result<(), LibraryError> { + validate_request(&request)?; + let current = read_catalog_platform(&request.root)?; + if current.revision != request.catalog.expected_revision { + return Err(LibraryError::StaleTarget); + } + validate_target(&request.destination, &request.expected_target)?; + + let transaction = request + .root + .join(".transactions") + .join(&request.operation_id); + if std::fs::symlink_metadata(&transaction).is_ok() { + return Err(LibraryError::StaleTarget); + } + std::fs::create_dir_all(&transaction)?; + let stage = transaction.join("stage"); + let backup = transaction.join("backup"); + write_transaction( + &transaction, + &request.destination, + matches!(request.content, ContentAction::Upsert { .. }), + "preparing", + None, + )?; + + let new_revision = revision(&request.catalog.bytes); + let result = (|| { + match &request.content { + ContentAction::Upsert { payload_root } => { + materialize_payload(payload_root, &stage)?; + verify_materialized(payload_root, &stage)?; + write_state(&transaction.join("phase"), "staged")?; + } + ContentAction::Delete => { + if !request.destination.is_dir() { + return Err(LibraryError::StaleTarget); + } + } + ContentAction::DeleteIfPresent => { + if std::fs::symlink_metadata(&request.destination) + .is_ok_and(|metadata| !metadata.is_dir() || metadata.file_type().is_symlink()) + { + return Err(LibraryError::StaleTarget); + } + } + } + validate_target(&request.destination, &request.expected_target)?; + if std::fs::symlink_metadata(&request.destination).is_ok() { + write_state(&transaction.join("phase"), "backedUp")?; + std::fs::rename(&request.destination, &backup)?; + } + write_state(&transaction.join("phase"), "activated")?; + if matches!(request.content, ContentAction::Upsert { .. }) { + std::fs::create_dir_all( + request + .destination + .parent() + .ok_or(LibraryError::InvalidRequest)?, + )?; + std::fs::rename(&stage, &request.destination)?; + if let ContentAction::Upsert { payload_root } = &request.content { + verify_materialized(payload_root, &request.destination)?; + } + } + write_state(&transaction.join("expected-catalog-hash"), &new_revision)?; + write_state(&transaction.join("phase"), "catalogPrepared")?; + write_catalog_document( + &request.root.join("catalog.json"), + request.catalog.expected_revision.as_deref(), + &request.catalog.bytes, + request.catalog.max_current_bytes, + )?; + write_state(&transaction.join("phase"), "catalogCommitted")?; + remove_any(&backup)?; + std::fs::remove_dir_all(&transaction)?; + Ok(()) + })(); + if let Err(error) = result { + let current = read_optional(&request.root.join("catalog.json"))?; + let current_revision = current.as_deref().map(revision); + recover(&request.root, current_revision.as_deref())?; + if current_revision.as_deref() == Some(new_revision.as_str()) { + return Ok(()); + } + return Err(error); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn validate_target(destination: &Path, expected: &TargetExpectation) -> Result<(), LibraryError> { + let target = crate::projection::project_targets(&crate::projection::ProjectionRequest { + destinations: vec![destination.to_path_buf()], + }) + .map_err(|_| LibraryError::StaleTarget)? + .targets + .pop() + .ok_or(LibraryError::StaleTarget)?; + if target.anchor_device != expected.parent.device + || target.anchor_inode != expected.parent.inode + || crate::linux_mutation::fingerprint_path(&target.physical_destination).as_deref() + != Ok(expected.fingerprint.as_str()) + { + return Err(LibraryError::StaleTarget); + } + if let Some(expected_hash) = &expected.content_hash { + if crate::linux_mutation::content_hash_path(&target.physical_destination).as_deref() + != Ok(expected_hash.as_str()) + { + return Err(LibraryError::StaleTarget); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn write_catalog_platform( + root: &Path, + library_ids: &[String], + catalog: CatalogWrite, +) -> Result { + if !root.is_absolute() + || catalog.bytes.is_empty() + || library_ids.iter().any(|id| !valid_component(id)) + { + return Err(LibraryError::InvalidRequest); + } + let current = read_catalog_platform(root)?; + if current.revision != catalog.expected_revision { + return Err(LibraryError::StaleTarget); + } + let mut created = Vec::new(); + for library_id in library_ids { + let library = root.join("libraries").join(library_id); + if std::fs::symlink_metadata(&library).is_err() { + std::fs::create_dir_all(library.join("skills"))?; + created.push(library); + } + } + let result = write_catalog_document( + &root.join("catalog.json"), + catalog.expected_revision.as_deref(), + &catalog.bytes, + catalog.max_current_bytes, + ); + if result.is_err() { + for library in created { + let _ = std::fs::remove_dir_all(library); + } + } + result?; + Ok(revision(&catalog.bytes)) +} + +#[cfg(target_os = "linux")] +fn validate_request(request: &LibraryCommit) -> Result<(), LibraryError> { + let managed = request.root.join("libraries"); + if !request.root.is_absolute() + || !request.destination.starts_with(&managed) + || request.operation_id.is_empty() + || request.operation_id.contains(['/', '\\', '\0']) + || request.catalog.bytes.is_empty() + { + return Err(LibraryError::InvalidRequest); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn valid_component(value: &str) -> bool { + !value.is_empty() && !matches!(value, "." | "..") && !value.contains(['/', '\\', '\0']) +} + +#[cfg(target_os = "linux")] +fn write_transaction( + transaction: &Path, + destination: &Path, + desired_presence: bool, + phase: &str, + expected_catalog_hash: Option<&str>, +) -> Result<(), LibraryError> { + write_state( + &transaction.join("destination"), + destination.to_str().ok_or(LibraryError::InvalidRequest)?, + )?; + write_state( + &transaction.join("desired-presence"), + if desired_presence { "1" } else { "0" }, + )?; + if let Some(expected) = expected_catalog_hash { + write_state(&transaction.join("expected-catalog-hash"), expected)?; + } + write_state(&transaction.join("phase"), phase) +} + +#[cfg(target_os = "linux")] +fn write_state(path: &Path, value: &str) -> Result<(), LibraryError> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let parent = path.parent().ok_or(LibraryError::InvalidRequest)?; + std::fs::create_dir_all(parent)?; + let temporary = path.with_extension(format!("tmp.{}", std::process::id())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))?; + let result = (|| { + file.write_all(value.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + std::fs::File::open(parent)?.sync_all()?; + Ok::<_, std::io::Error>(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result.map_err(LibraryError::from) +} + +#[cfg(target_os = "linux")] +fn write_catalog_document( + path: &Path, + expected_revision: Option<&str>, + bytes: &[u8], + max_current_bytes: usize, +) -> Result<(), LibraryError> { + crate::document::write_document_atomic(path, expected_revision, bytes, max_current_bytes) + .map(|_| ()) + .map_err(|error| match error { + crate::document::DocumentWriteError::Conflict => LibraryError::StaleTarget, + crate::document::DocumentWriteError::InvalidTarget => LibraryError::InvalidRequest, + crate::document::DocumentWriteError::UnsupportedPlatform => { + LibraryError::UnsupportedPlatform + } + crate::document::DocumentWriteError::Io { .. } => { + LibraryError::Io(std::io::Error::other("failed to write Library catalog")) + } + }) +} + +#[cfg(target_os = "linux")] +fn materialize_payload(payload_root: &Path, destination: &Path) -> Result<(), LibraryError> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let manifest = + crate::payload::verify_payload(payload_root).map_err(|_| LibraryError::InvalidPayload)?; + std::fs::create_dir(destination)?; + for entry in manifest.entries { + let target = destination.join(&entry.relative_path); + match entry.kind { + crate::payload::PayloadEntryKind::Directory => std::fs::create_dir_all(&target)?, + crate::payload::PayloadEntryKind::File => { + std::fs::create_dir_all(target.parent().ok_or(LibraryError::InvalidPayload)?)?; + let blob_id = entry.blob_id.ok_or(LibraryError::InvalidPayload)?; + let mut input = crate::payload::read_blob(payload_root, &blob_id) + .map_err(|_| LibraryError::InvalidPayload)? + .ok_or(LibraryError::InvalidPayload)?; + let mut output = std::fs::File::create(&target)?; + std::io::copy(&mut input, &mut output)?; + output.flush()?; + std::fs::set_permissions( + &target, + std::fs::Permissions::from_mode(if entry.executable { 0o755 } else { 0o644 }), + )?; + } + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn verify_materialized(payload_root: &Path, destination: &Path) -> Result<(), LibraryError> { + let manifest = + crate::payload::verify_payload(payload_root).map_err(|_| LibraryError::InvalidPayload)?; + for entry in manifest.entries { + let target = destination.join(&entry.relative_path); + let valid = match entry.kind { + crate::payload::PayloadEntryKind::Directory => target.is_dir(), + crate::payload::PayloadEntryKind::File => { + target.is_file() + && entry.content_hash.as_deref().is_some_and(|expected| { + std::fs::read(&target) + .ok() + .is_some_and(|bytes| revision_raw(&bytes) == expected) + }) + } + }; + if !valid { + return Err(LibraryError::InvalidPayload); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn recover(root: &Path, catalog_revision: Option<&str>) -> Result<(), LibraryError> { + let transactions = root.join(".transactions"); + let Ok(entries) = std::fs::read_dir(&transactions) else { + return Ok(()); + }; + for entry in entries { + let transaction = entry?.path(); + if !transaction.is_dir() { + continue; + } + let destination = read_required_text(&transaction.join("destination"))?; + let destination = PathBuf::from(destination); + if !destination.starts_with(root.join("libraries")) { + return Err(LibraryError::RecoveryIncomplete); + } + let phase = read_required_text(&transaction.join("phase"))?; + let desired_presence = + match read_required_text(&transaction.join("desired-presence"))?.as_str() { + "1" => true, + "0" => false, + _ => return Err(LibraryError::RecoveryIncomplete), + }; + let stage = transaction.join("stage"); + let backup = transaction.join("backup"); + if phase == "catalogCommitted" { + // Keep the commit evidence until all retained content is removed. + if remove_any(&stage).is_ok() && remove_any(&backup).is_ok() { + let _ = std::fs::remove_dir_all(&transaction); + } + continue; + } + match phase.as_str() { + "preparing" | "staged" => {} + "backedUp" if !destination.exists() && backup.is_dir() && stage.exists() => { + std::fs::rename(&backup, &destination)?; + } + "backedUp" if destination.exists() && !backup.exists() => {} + "backedUp" if destination.exists() && backup.is_dir() && !stage.exists() => {} + "activated" => rollback(&destination, &backup)?, + "catalogPrepared" if destination.exists() == desired_presence => { + let expected = read_required_text(&transaction.join("expected-catalog-hash"))?; + if !same_revision(catalog_revision, &expected) { + rollback(&destination, &backup)?; + } + } + _ => return Err(LibraryError::RecoveryIncomplete), + } + remove_any(&stage)?; + remove_any(&backup)?; + std::fs::remove_dir_all(&transaction)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn rollback(destination: &Path, backup: &Path) -> Result<(), LibraryError> { + remove_any(destination)?; + if backup.exists() { + std::fs::rename(backup, destination)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn remove_any(path: &Path) -> Result<(), LibraryError> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(path)? + } + Ok(_) => std::fs::remove_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_required_text(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|_| LibraryError::RecoveryIncomplete) +} + +#[cfg(target_os = "linux")] +fn read_optional(path: &Path) -> Result>, LibraryError> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +#[cfg(target_os = "linux")] +fn revision(bytes: &[u8]) -> String { + format!("sha256:{}", revision_raw(bytes)) +} + +#[cfg(target_os = "linux")] +fn revision_raw(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(target_os = "linux")] +fn same_revision(actual: Option<&str>, expected: &str) -> bool { + actual.map(|value| value.strip_prefix("sha256:").unwrap_or(value)) + == Some(expected.strip_prefix("sha256:").unwrap_or(expected)) +} diff --git a/src-tauri/crates/environment-engine/src/linux_mutation.rs b/src-tauri/crates/environment-engine/src/linux_mutation.rs new file mode 100644 index 00000000..01454db8 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/linux_mutation.rs @@ -0,0 +1,777 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +#[cfg(target_os = "linux")] +use std::sync::atomic::{AtomicU64, Ordering}; + +use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; + +use crate::entry::{inspect_entries, EntryKind, EntryRequest}; +use crate::manifest::{build_manifest, ManifestKind, ManifestRequest, ManifestResponse}; +#[cfg(target_os = "linux")] +use crate::payload::read_blob; +use crate::payload::{verify_payload, PayloadEntryKind, PayloadManifest}; +#[cfg(target_os = "linux")] +use crate::projection::{project_targets, ProjectionRequest}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParentIdentity { + pub device: u64, + pub inode: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryAction { + Keep, + Materialize { payload_root: PathBuf }, + Symlink { target: PathBuf }, + Remove, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryIntent { + pub destination: PathBuf, + pub expected_parent: ParentIdentity, + pub expected_fingerprint: String, + pub expected_content_hash: Option, + pub action: EntryAction, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryEntry { + pub destination: PathBuf, + pub backup: PathBuf, + pub expected_present: bool, + pub original_fingerprint: String, +} + +#[derive(Debug)] +struct StagedEntry { + intent: EntryIntent, + parent_identity: Option, + stage: Option, + backup: PathBuf, + backup_created: bool, + installed: bool, +} + +#[derive(Debug)] +pub struct StagedMutation { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MutationError { + UnsupportedPlatform, + InvalidRequest, + StaleTarget, + InvalidPayload, + VerificationFailed, + Cancelled, + RestoreFailed { message: String }, + Io { message: String }, +} + +impl fmt::Display for MutationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for MutationError {} + +impl From for MutationError { + fn from(error: std::io::Error) -> Self { + Self::Io { + message: error.to_string(), + } + } +} + +pub fn parent_identity(path: &Path) -> Result { + parent_identity_platform(path) +} + +pub fn fingerprint_path(path: &Path) -> Result { + let fact = inspect_entries(&EntryRequest { + paths: vec![path.to_path_buf()], + }) + .map_err(|_| MutationError::Io { + message: format!("failed to inspect {}", path.display()), + })? + .facts + .into_iter() + .next() + .ok_or(MutationError::InvalidRequest)?; + if fact.kind == EntryKind::Missing { + return Ok("entry-v1-missing".to_string()); + } + let metadata = fact.metadata.ok_or(MutationError::InvalidRequest)?; + let values = [ + metadata.device.to_string(), + metadata.inode.to_string(), + format!("{:x}", metadata.mode), + metadata.size.to_string(), + metadata.mtime_seconds.to_string(), + format!("{:09}", metadata.mtime_nanos), + ]; + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-wsl-entry-v1\0"); + for value in values { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + if let Some(target) = fact.link_target { + hasher.update(target.as_os_str().as_encoded_bytes()); + } + Ok(format!("entry-v1-{:x}", hasher.finalize())) +} + +pub fn preflight_write_targets( + destinations: &[PathBuf], + is_cancelled: F, +) -> Result<(), MutationError> +where + F: Fn() -> bool, +{ + preflight_write_targets_platform(destinations, is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn preflight_write_targets_platform( + _destinations: &[PathBuf], + _is_cancelled: impl Fn() -> bool, +) -> Result<(), MutationError> { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn preflight_write_targets_platform( + destinations: &[PathBuf], + is_cancelled: impl Fn() -> bool, +) -> Result<(), MutationError> { + static NEXT_PROBE: AtomicU64 = AtomicU64::new(1); + + if destinations.is_empty() || destinations.iter().any(|path| !path.is_absolute()) { + return Err(MutationError::InvalidRequest); + } + for destination in destinations { + if is_cancelled() { + return Err(MutationError::Cancelled); + } + let mut parent = destination.parent().ok_or(MutationError::InvalidRequest)?; + loop { + match fs::symlink_metadata(parent) { + Ok(_) => break, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + parent = parent.parent().ok_or(MutationError::InvalidRequest)?; + } + Err(error) => return Err(error.into()), + } + } + if !fs::metadata(parent).is_ok_and(|metadata| metadata.is_dir()) { + return Err(MutationError::InvalidRequest); + } + let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed); + let probe = parent.join(format!( + ".skill-deck-preflight-{}-{sequence}", + std::process::id() + )); + let renamed = probe.with_extension("renamed"); + fs::create_dir(&probe)?; + if let Err(error) = fs::rename(&probe, &renamed) { + let _ = fs::remove_dir(&probe); + return Err(error.into()); + } + if let Err(error) = fs::remove_dir(&renamed) { + let _ = fs::remove_dir(&renamed); + return Err(error.into()); + } + } + Ok(()) +} + +pub fn validate_intents(intents: &[EntryIntent], is_cancelled: F) -> Result<(), MutationError> +where + F: Fn() -> bool, +{ + validate_intents_platform(intents, &is_cancelled) +} + +impl StagedMutation { + pub fn stage( + operation_id: &str, + intents: Vec, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + stage_platform(operation_id, intents, &is_cancelled) + } + + pub fn recheck(&self, is_cancelled: F) -> Result<(), MutationError> + where + F: Fn() -> bool, + { + for entry in &self.entries { + check_cancelled(&is_cancelled)?; + recheck_entry(entry)?; + } + Ok(()) + } + + pub fn swap(&mut self, is_cancelled: F) -> Result<(), MutationError> + where + F: Fn() -> bool, + { + self.recheck(&is_cancelled)?; + for index in 0..self.entries.len() { + if let Err(primary) = swap_one(&mut self.entries[index]) { + if let Err(restore) = self.restore() { + return Err(MutationError::RestoreFailed { + message: format!("{primary}; {restore}"), + }); + } + return Err(primary); + } + } + Ok(()) + } + + pub fn verify(&self, is_cancelled: F) -> Result<(), MutationError> + where + F: Fn() -> bool, + { + for entry in &self.entries { + check_cancelled(&is_cancelled)?; + let valid = match &entry.intent.action { + EntryAction::Keep => { + fingerprint_path(&entry.intent.destination)? + == entry.intent.expected_fingerprint + && entry + .intent + .expected_content_hash + .as_ref() + .is_none_or(|expected| { + content_hash_path(&entry.intent.destination).as_ref() + == Ok(expected) + }) + } + EntryAction::Materialize { payload_root } => { + verify_materialized(payload_root, &entry.intent.destination).is_ok() + } + EntryAction::Symlink { target } => { + fs::symlink_metadata(&entry.intent.destination) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + && fs::read_link(&entry.intent.destination) + .is_ok_and(|actual| &actual == target) + } + EntryAction::Remove => { + fingerprint_path(&entry.intent.destination).as_deref() == Ok("entry-v1-missing") + } + }; + if !valid { + return Err(MutationError::VerificationFailed); + } + } + Ok(()) + } + + pub fn restore(&mut self) -> Result<(), MutationError> { + for entry in self.entries.iter_mut().rev() { + if entry.installed { + remove_no_follow(&entry.intent.destination)?; + entry.installed = false; + } + if entry.backup_created { + fs::rename(&entry.backup, &entry.intent.destination)?; + entry.backup_created = false; + } + } + Ok(()) + } + + pub fn recovery_entries(&self) -> Vec { + self.entries + .iter() + .filter(|entry| !matches!(entry.intent.action, EntryAction::Keep)) + .map(|entry| RecoveryEntry { + destination: entry.intent.destination.clone(), + backup: entry.backup.clone(), + expected_present: !matches!(entry.intent.action, EntryAction::Remove), + original_fingerprint: entry.intent.expected_fingerprint.clone(), + }) + .collect() + } + + pub fn cleanup_stages(&mut self) -> Vec { + let mut warnings = Vec::new(); + for entry in &mut self.entries { + if let Some(path) = entry.stage.clone() { + if let Err(error) = remove_no_follow(&path) { + warnings.push(format!("{}: {error}", path.display())); + } else { + entry.stage = None; + } + } + } + warnings + } + + pub fn cleanup(mut self) -> Result, MutationError> { + let mut warnings = self.cleanup_stages(); + for entry in self.entries { + if entry.backup_created { + if let Err(error) = remove_no_follow(&entry.backup) { + warnings.push(format!("{}: {error}", entry.backup.display())); + } + } + } + Ok(warnings) + } +} + +#[cfg(not(target_os = "linux"))] +fn parent_identity_platform(_path: &Path) -> Result { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn parent_identity_platform(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let metadata = fs::metadata(path)?; + if !metadata.is_dir() { + return Err(MutationError::InvalidRequest); + } + Ok(ParentIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(not(target_os = "linux"))] +fn stage_platform( + _operation_id: &str, + _intents: Vec, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn stage_platform( + operation_id: &str, + intents: Vec, + is_cancelled: &impl Fn() -> bool, +) -> Result { + if intents.is_empty() + || operation_id.is_empty() + || !operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(MutationError::InvalidRequest); + } + validate_intents_platform(&intents, is_cancelled)?; + let mut entries = Vec::with_capacity(intents.len()); + for (index, intent) in intents.into_iter().enumerate() { + let mut current_stage = None; + let staged = (|| { + check_cancelled(is_cancelled)?; + let parent = intent + .destination + .parent() + .ok_or(MutationError::InvalidRequest)?; + let parent_identity = match &intent.action { + EntryAction::Keep | EntryAction::Remove => None, + EntryAction::Materialize { .. } | EntryAction::Symlink { .. } => { + fs::create_dir_all(parent)?; + Some(parent_identity(parent)?) + } + }; + let stage = sibling(&intent.destination, "stage", operation_id, index)?; + let backup = sibling(&intent.destination, "backup", operation_id, index)?; + if fs::symlink_metadata(&stage).is_ok() || fs::symlink_metadata(&backup).is_ok() { + return Err(MutationError::StaleTarget); + } + current_stage = Some(stage.clone()); + let staged_path = match &intent.action { + EntryAction::Keep | EntryAction::Remove => None, + EntryAction::Materialize { payload_root } => { + materialize(payload_root, &stage)?; + Some(stage) + } + EntryAction::Symlink { target } => { + use std::os::unix::fs::symlink; + symlink(target, &stage)?; + Some(stage) + } + }; + let staged = StagedEntry { + intent, + parent_identity, + stage: staged_path, + backup, + backup_created: false, + installed: false, + }; + verify_stage(&staged)?; + Ok(staged) + })(); + match staged { + Ok(staged) => entries.push(staged), + Err(error) => { + if let Some(stage) = current_stage { + let _ = remove_no_follow(&stage); + } + cleanup_partial(&mut entries); + return Err(error); + } + } + } + Ok(StagedMutation { entries }) +} + +#[cfg(not(target_os = "linux"))] +fn validate_intents_platform( + _intents: &[EntryIntent], + _is_cancelled: &impl Fn() -> bool, +) -> Result<(), MutationError> { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn validate_intents_platform( + intents: &[EntryIntent], + is_cancelled: &impl Fn() -> bool, +) -> Result<(), MutationError> { + if intents.is_empty() { + return Err(MutationError::InvalidRequest); + } + let mut destinations = BTreeSet::new(); + for intent in intents { + check_cancelled(is_cancelled)?; + if !intent.destination.is_absolute() || !destinations.insert(&intent.destination) { + return Err(MutationError::InvalidRequest); + } + validate_intent_platform(intent)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn validate_intent_platform(intent: &EntryIntent) -> Result<(), MutationError> { + let projection = project_targets(&ProjectionRequest { + destinations: vec![intent.destination.clone()], + }) + .map_err(|_| MutationError::StaleTarget)? + .targets + .into_iter() + .next() + .ok_or(MutationError::StaleTarget)?; + if projection.anchor_device != intent.expected_parent.device + || projection.anchor_inode != intent.expected_parent.inode + || projection.physical_destination != intent.destination + || fingerprint_path(&intent.destination)? != intent.expected_fingerprint + { + return Err(MutationError::StaleTarget); + } + if let Some(expected) = &intent.expected_content_hash { + if content_hash_path(&intent.destination).as_ref() != Ok(expected) { + return Err(MutationError::StaleTarget); + } + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn validate_intent_platform(_intent: &EntryIntent) -> Result<(), MutationError> { + Err(MutationError::UnsupportedPlatform) +} + +fn recheck_entry(entry: &StagedEntry) -> Result<(), MutationError> { + if entry.parent_identity.is_none() { + validate_intent_platform(&entry.intent)?; + return verify_stage(entry); + } + let parent = entry + .intent + .destination + .parent() + .ok_or(MutationError::InvalidRequest)?; + if Some(parent_identity(parent)?) != entry.parent_identity + || fingerprint_path(&entry.intent.destination)? != entry.intent.expected_fingerprint + || fs::symlink_metadata(&entry.backup).is_ok() + { + return Err(MutationError::StaleTarget); + } + if let Some(expected) = &entry.intent.expected_content_hash { + if content_hash_path(&entry.intent.destination).as_ref() != Ok(expected) { + return Err(MutationError::StaleTarget); + } + } + verify_stage(entry) +} + +fn verify_stage(entry: &StagedEntry) -> Result<(), MutationError> { + match (&entry.intent.action, &entry.stage) { + (EntryAction::Materialize { payload_root }, Some(stage)) => { + verify_materialized(payload_root, stage) + } + (EntryAction::Symlink { target }, Some(stage)) + if fs::symlink_metadata(stage).is_ok_and(|value| value.file_type().is_symlink()) + && fs::read_link(stage).is_ok_and(|actual| &actual == target) => + { + Ok(()) + } + (EntryAction::Keep | EntryAction::Remove, None) => Ok(()), + _ => Err(MutationError::VerificationFailed), + } +} + +fn swap_one(entry: &mut StagedEntry) -> Result<(), MutationError> { + if matches!(entry.intent.action, EntryAction::Keep) { + return Ok(()); + } + if fingerprint_path(&entry.intent.destination)?.as_str() != "entry-v1-missing" { + fs::rename(&entry.intent.destination, &entry.backup)?; + entry.backup_created = true; + } + if let Some(stage) = entry.stage.take() { + fs::rename(stage, &entry.intent.destination)?; + entry.installed = true; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn sibling( + destination: &Path, + kind: &str, + operation_id: &str, + index: usize, +) -> Result { + let parent = destination.parent().ok_or(MutationError::InvalidRequest)?; + Ok(parent.join(format!(".skill-deck-{kind}-{operation_id}-{index:06}"))) +} + +#[cfg(target_os = "linux")] +fn materialize(payload_root: &Path, destination: &Path) -> Result<(), MutationError> { + let manifest = verify_payload(payload_root).map_err(|_| MutationError::InvalidPayload)?; + fs::create_dir(destination)?; + for entry in &manifest.entries { + let path = destination.join(&entry.relative_path); + match entry.kind { + PayloadEntryKind::Directory => fs::create_dir_all(path)?, + PayloadEntryKind::File => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let blob_id = entry + .blob_id + .as_deref() + .ok_or(MutationError::InvalidPayload)?; + let mut input = read_blob(payload_root, blob_id) + .map_err(|_| MutationError::InvalidPayload)? + .ok_or(MutationError::InvalidPayload)?; + let mut output = fs::File::create(&path)?; + std::io::copy(&mut input, &mut output)?; + set_executable(&path, entry.executable)?; + } + } + } + Ok(()) +} + +fn verify_materialized(payload_root: &Path, destination: &Path) -> Result<(), MutationError> { + let manifest = verify_payload(payload_root).map_err(|_| MutationError::InvalidPayload)?; + verify_manifest_tree(&manifest, payload_root, destination) +} + +fn verify_manifest_tree( + manifest: &PayloadManifest, + payload_root: &Path, + destination: &Path, +) -> Result<(), MutationError> { + let expected = manifest + .entries + .iter() + .map(|entry| (PathBuf::from(&entry.relative_path), entry)) + .collect::>(); + let mut actual = BTreeSet::new(); + let mut pending = vec![destination.to_path_buf()]; + while let Some(directory) = pending.pop() { + for child in fs::read_dir(directory)? { + let path = child?.path(); + let relative = path + .strip_prefix(destination) + .map_err(|_| MutationError::VerificationFailed)? + .to_path_buf(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() || !actual.insert(relative.clone()) { + return Err(MutationError::VerificationFailed); + } + let entry = expected + .get(&relative) + .ok_or(MutationError::VerificationFailed)?; + match entry.kind { + PayloadEntryKind::Directory if metadata.is_dir() => pending.push(path), + PayloadEntryKind::File if metadata.is_file() => { + let blob_id = entry + .blob_id + .as_deref() + .ok_or(MutationError::InvalidPayload)?; + let blob = payload_root.join("blobs").join(blob_id); + if metadata.len() != entry.size + || digest_file(&path)? != blob_id + || digest_file(&blob)? != blob_id + || is_executable(&path)? != entry.executable + { + return Err(MutationError::VerificationFailed); + } + } + _ => return Err(MutationError::VerificationFailed), + } + } + } + if actual.len() == expected.len() { + Ok(()) + } else { + Err(MutationError::VerificationFailed) + } +} + +pub fn content_hash_path(root: &Path) -> Result { + let response = build_manifest(&ManifestRequest { + root: root.to_path_buf(), + }) + .map_err(|_| MutationError::StaleTarget)?; + aggregate_manifest_hash(&response) +} + +fn aggregate_manifest_hash(manifest: &ManifestResponse) -> Result { + let mut records = manifest + .records + .iter() + .map(|record| { + let relative = record + .relative_path + .as_os_str() + .to_str() + .ok_or(MutationError::StaleTarget)? + .nfc() + .collect::(); + let target = record + .symlink_target + .as_ref() + .map(|target| { + target + .as_os_str() + .to_str() + .ok_or(MutationError::StaleTarget) + .map(|target| target.nfc().collect::()) + }) + .transpose()?; + Ok((relative, target, record)) + }) + .collect::, MutationError>>()?; + records.sort_by(|left, right| left.0.cmp(&right.0)); + if records.windows(2).any(|pair| pair[0].0 == pair[1].0) { + return Err(MutationError::StaleTarget); + } + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-content-manifest"); + hasher.update([1]); + for (relative, target, record) in records { + let tag = match record.kind { + ManifestKind::Directory => b'd', + ManifestKind::File => b'f', + ManifestKind::Symlink => b'l', + }; + hasher.update([tag, u8::from(record.executable)]); + hash_field(&mut hasher, relative.as_bytes()); + hash_field( + &mut hasher, + record.digest.as_deref().unwrap_or("").as_bytes(), + ); + hash_field(&mut hasher, target.as_deref().unwrap_or("").as_bytes()); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +fn digest_file(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + return Ok(format!("{:x}", hasher.finalize())); + } + hasher.update(&buffer[..read]); + } +} + +#[cfg(target_os = "linux")] +fn cleanup_partial(entries: &mut Vec) { + while let Some(entry) = entries.pop() { + if let Some(stage) = entry.stage { + let _ = remove_no_follow(&stage); + } + } +} + +fn remove_no_follow(path: &Path) -> Result<(), MutationError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + fs::remove_dir_all(path)? + } + Ok(_) => fs::remove_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +fn check_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), MutationError> { + if is_cancelled() { + Err(MutationError::Cancelled) + } else { + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn set_executable(path: &Path, executable: bool) -> Result<(), MutationError> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path)?.permissions(); + let mut mode = permissions.mode(); + if executable { + mode |= 0o111; + } else { + mode &= !0o111; + } + permissions.set_mode(mode); + fs::set_permissions(path, permissions)?; + Ok(()) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + Ok(fs::metadata(path)?.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable(_path: &Path) -> Result { + Err(MutationError::UnsupportedPlatform) +} diff --git a/src-tauri/crates/environment-engine/src/lock.rs b/src-tauri/crates/environment-engine/src/lock.rs new file mode 100644 index 00000000..4ec2b121 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/lock.rs @@ -0,0 +1,356 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LockSchema { + Global, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntryMutation { + Replace { + key: String, + replacement: Value, + }, + Remove { + key: String, + }, + MoveAndReplace { + from: String, + to: String, + replacement: Value, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockMutation { + pub schema: LockSchema, + pub entry: EntryMutation, + pub root_replacements: BTreeMap, + pub expected_entries: BTreeMap>, + pub expected_roots: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockReceipt { + pub entries: BTreeMap>, + pub roots: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppliedLock { + pub bytes: Vec, + pub receipt: LockReceipt, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockError { + InvalidDocument { message: String }, + UnsupportedSchema { version: u64, supported: u64 }, + MissingExpectedEntry { key: String }, + MissingExpectedRoot { field: String }, + EntryConflict { key: String }, + RootConflict { field: String }, +} + +impl fmt::Display for LockError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for LockError {} + +pub fn apply( + current: Option<&[u8]>, + legacy: Option<&[u8]>, + mutation: &LockMutation, +) -> Result { + let mut document = load_document(current, legacy, mutation.schema)?; + for key in affected_keys(&mutation.entry) { + let expected = + mutation + .expected_entries + .get(key) + .ok_or_else(|| LockError::MissingExpectedEntry { + key: key.to_string(), + })?; + document.validate_entry(key, expected)?; + } + match &mutation.entry { + EntryMutation::Replace { key, replacement } => { + document.replace_entry(mutation.schema, key, replacement.clone()) + } + EntryMutation::Remove { key } => document.remove_entry(key), + EntryMutation::MoveAndReplace { + from, + to, + replacement, + } => document.move_and_replace(mutation.schema, from, to, replacement.clone()), + } + for (field, replacement) in &mutation.root_replacements { + let expected = + mutation + .expected_roots + .get(field) + .ok_or_else(|| LockError::MissingExpectedRoot { + field: field.clone(), + })?; + document.replace_root(field, expected, replacement.clone())?; + } + let receipt = LockReceipt { + entries: mutation + .expected_entries + .keys() + .map(|key| (key.clone(), document.entry(key).cloned())) + .collect(), + roots: mutation + .expected_roots + .keys() + .map(|field| (field.clone(), document.root.get(field).cloned())) + .collect(), + }; + let mut bytes = serde_json::to_vec_pretty(&document.root).map_err(json_error)?; + bytes.push(b'\n'); + Ok(AppliedLock { bytes, receipt }) +} + +fn affected_keys(entry: &EntryMutation) -> Vec<&str> { + match entry { + EntryMutation::Replace { key, .. } | EntryMutation::Remove { key } => vec![key], + EntryMutation::MoveAndReplace { from, to, .. } => vec![from, to], + } +} + +fn load_document( + current: Option<&[u8]>, + legacy: Option<&[u8]>, + schema: LockSchema, +) -> Result { + if let Some(bytes) = current { + ensure_supported_schema(bytes, schema)?; + return Document::parse(bytes); + } + let Some(bytes) = legacy else { + return Ok(Document::empty(schema)); + }; + let document = Document::parse(bytes)?; + match schema { + LockSchema::Global => Ok(document), + LockSchema::Project => convert_legacy_project(document), + } +} + +fn ensure_supported_schema(bytes: &[u8], schema: LockSchema) -> Result<(), LockError> { + let value: Value = serde_json::from_slice(bytes).map_err(json_error)?; + let version = value + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| LockError::InvalidDocument { + message: "lock version is missing".to_string(), + })?; + let supported = schema_version(schema); + if version > supported { + Err(LockError::UnsupportedSchema { version, supported }) + } else { + Ok(()) + } +} + +struct Document { + root: Value, +} + +impl Document { + fn parse(bytes: &[u8]) -> Result { + let root: Value = serde_json::from_slice(bytes).map_err(json_error)?; + let object = root.as_object().ok_or_else(|| LockError::InvalidDocument { + message: "lock root must be a JSON object".to_string(), + })?; + if !object.get("skills").is_some_and(Value::is_object) { + return Err(LockError::InvalidDocument { + message: "lock skills must be a JSON object".to_string(), + }); + } + Ok(Self { root }) + } + + fn empty(schema: LockSchema) -> Self { + Self { + root: serde_json::json!({ "version": schema_version(schema), "skills": {} }), + } + } + + fn skills(&self) -> &Map { + self.root["skills"] + .as_object() + .expect("validated lock skills") + } + + fn skills_mut(&mut self) -> &mut Map { + self.root["skills"] + .as_object_mut() + .expect("validated lock skills") + } + + fn entry(&self, key: &str) -> Option<&Value> { + self.skills().get(key) + } + + fn validate_entry(&self, key: &str, expected: &Option) -> Result<(), LockError> { + if self.entry(key) == expected.as_ref() { + Ok(()) + } else { + Err(LockError::EntryConflict { + key: key.to_string(), + }) + } + } + + fn replace_entry(&mut self, schema: LockSchema, key: &str, replacement: Value) { + let replacement = merge_entry_fields(schema, self.entry(key), replacement); + self.skills_mut().insert(key.to_string(), replacement); + } + + fn remove_entry(&mut self, key: &str) { + self.skills_mut().remove(key); + } + + fn move_and_replace(&mut self, schema: LockSchema, from: &str, to: &str, replacement: Value) { + let replacement = merge_entry_fields(schema, self.entry(from), replacement); + self.skills_mut().remove(from); + self.skills_mut().insert(to.to_string(), replacement); + } + + fn replace_root( + &mut self, + field: &str, + expected: &Option, + replacement: Value, + ) -> Result<(), LockError> { + if self.root.get(field) != expected.as_ref() { + return Err(LockError::RootConflict { + field: field.to_string(), + }); + } + self.root + .as_object_mut() + .expect("validated lock root") + .insert(field.to_string(), replacement); + Ok(()) + } +} + +fn schema_version(schema: LockSchema) -> u64 { + match schema { + LockSchema::Global => 3, + LockSchema::Project => 1, + } +} + +fn merge_entry_fields(schema: LockSchema, current: Option<&Value>, replacement: Value) -> Value { + let known_fields: &[&str] = match schema { + LockSchema::Global => &[ + "source", + "sourceType", + "sourceUrl", + "ref", + "skillPath", + "skillFolderHash", + "installedAt", + "updatedAt", + "pluginName", + "sourceBaseUrl", + "wellKnownDigest", + ], + LockSchema::Project => &[ + "source", + "ref", + "sourceType", + "sourceUrl", + "computedHash", + "remoteHash", + "skillPath", + "subagents", + "pluginName", + "wellKnownDigest", + ], + }; + match (current.and_then(Value::as_object), replacement) { + (current, Value::Object(replacement)) => { + let mut merged = current.cloned().unwrap_or_default(); + for field in known_fields { + merged.remove(*field); + } + merged.extend(replacement); + Value::Object(merged) + } + (_, replacement) => replacement, + } +} + +fn convert_legacy_project(document: Document) -> Result { + let mut root = + document + .root + .as_object() + .cloned() + .ok_or_else(|| LockError::InvalidDocument { + message: "lock root must be a JSON object".to_string(), + })?; + root.insert("version".to_string(), Value::from(1)); + for field in ["dismissed", "lastSelectedAgents", "defaultTargetAgents"] { + root.remove(field); + } + let skills = root + .get_mut("skills") + .and_then(Value::as_object_mut) + .expect("validated lock skills"); + for entry in skills.values_mut() { + let Some(current) = entry.as_object().cloned() else { + continue; + }; + let mut replacement = Map::new(); + for field in [ + "source", + "ref", + "sourceType", + "sourceUrl", + "skillPath", + "pluginName", + ] { + if let Some(value) = current.get(field) { + replacement.insert(field.to_string(), value.clone()); + } + } + replacement.insert("computedHash".to_string(), Value::String(String::new())); + if let Some(remote_hash) = current + .get("skillFolderHash") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + replacement.insert( + "remoteHash".to_string(), + Value::String(remote_hash.to_string()), + ); + } + *entry = merge_entry_fields( + LockSchema::Global, + Some(&Value::Object(current)), + Value::Object(replacement), + ); + } + Ok(Document { + root: Value::Object(root), + }) +} + +fn json_error(error: serde_json::Error) -> LockError { + LockError::InvalidDocument { + message: error.to_string(), + } +} diff --git a/src-tauri/crates/environment-engine/src/manifest.rs b/src-tauri/crates/environment-engine/src/manifest.rs new file mode 100644 index 00000000..b9e9558a --- /dev/null +++ b/src-tauri/crates/environment-engine/src/manifest.rs @@ -0,0 +1,163 @@ +use std::fmt; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[cfg(target_os = "linux")] +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestRequest { + pub root: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManifestKind { + Directory, + File, + Symlink, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestRecord { + pub relative_path: PathBuf, + pub kind: ManifestKind, + pub digest: Option, + pub executable: bool, + pub symlink_target: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestResponse { + pub records: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManifestError { + UnsupportedPlatform, + InvalidRequest, + Unavailable, + UnsupportedEntry, + Cancelled, +} + +impl fmt::Display for ManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ManifestError {} + +pub fn build_manifest(request: &ManifestRequest) -> Result { + build_manifest_with_cancel(request, || false) +} + +pub fn build_manifest_with_cancel( + request: &ManifestRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if !request.root.is_absolute() { + return Err(ManifestError::InvalidRequest); + } + build_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn build_platform( + _request: &ManifestRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(ManifestError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn build_platform( + request: &ManifestRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let metadata = fs::symlink_metadata(&request.root).map_err(|_| ManifestError::Unavailable)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(ManifestError::InvalidRequest); + } + let mut records = Vec::new(); + visit(&request.root, Path::new(""), is_cancelled, &mut records)?; + Ok(ManifestResponse { records }) +} + +#[cfg(target_os = "linux")] +fn visit( + directory: &Path, + relative_parent: &Path, + is_cancelled: &impl Fn() -> bool, + records: &mut Vec, +) -> Result<(), ManifestError> { + let mut entries = fs::read_dir(directory) + .map_err(|_| ManifestError::Unavailable)? + .collect::, _>>() + .map_err(|_| ManifestError::Unavailable)?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + if is_cancelled() { + return Err(ManifestError::Cancelled); + } + let relative_path = relative_parent.join(entry.file_name()); + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| ManifestError::Unavailable)?; + if metadata.file_type().is_symlink() { + records.push(ManifestRecord { + relative_path, + kind: ManifestKind::Symlink, + digest: None, + executable: false, + symlink_target: Some(fs::read_link(path).map_err(|_| ManifestError::Unavailable)?), + }); + } else if metadata.is_dir() { + records.push(ManifestRecord { + relative_path: relative_path.clone(), + kind: ManifestKind::Directory, + digest: None, + executable: false, + symlink_target: None, + }); + visit(&path, &relative_path, is_cancelled, records)?; + } else if metadata.is_file() { + use std::os::unix::fs::PermissionsExt; + records.push(ManifestRecord { + relative_path, + kind: ManifestKind::File, + digest: Some(digest_file(&path)?), + executable: metadata.permissions().mode() & 0o111 != 0, + symlink_target: None, + }); + } else { + return Err(ManifestError::UnsupportedEntry); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn digest_file(path: &Path) -> Result { + let mut file = fs::File::open(path).map_err(|_| ManifestError::Unavailable)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|_| ManifestError::Unavailable)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src-tauri/crates/environment-engine/src/path.rs b/src-tauri/crates/environment-engine/src/path.rs new file mode 100644 index 00000000..eb485bec --- /dev/null +++ b/src-tauri/crates/environment-engine/src/path.rs @@ -0,0 +1,185 @@ +use std::fmt; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathRequest { + pub queries: Vec, + pub aggregate_content_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathQuery { + pub path: PathBuf, + pub content_limit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + Missing, + Directory, + SymlinkDirectory, + SymlinkOther, + Other, + BrokenLink, + Inaccessible, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContentState { + NotRequested, + Empty, + Unreadable, + Bytes(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathFact { + pub path: PathBuf, + pub kind: PathKind, + pub content: ContentState, + pub content_truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for PathError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => { + formatter.write_str("Linux path inspection is unavailable") + } + Self::InvalidRequest => formatter.write_str("invalid bounded path inspection request"), + Self::Cancelled => formatter.write_str("path inspection was cancelled"), + } + } +} + +impl std::error::Error for PathError {} + +pub fn inspect_paths(request: &PathRequest) -> Result { + inspect_paths_with_cancel(request, || false) +} + +pub fn inspect_paths_with_cancel( + request: &PathRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.queries.is_empty() + || request.aggregate_content_limit == 0 + || request.queries.iter().any(|query| { + !query.path.is_absolute() || query.content_limit.is_some_and(|limit| limit == 0) + }) + { + return Err(PathError::InvalidRequest); + } + inspect_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn inspect_platform( + _request: &PathRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(PathError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn inspect_platform( + request: &PathRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut total_content_bytes = 0usize; + let mut facts = Vec::with_capacity(request.queries.len()); + for query in &request.queries { + if is_cancelled() { + return Err(PathError::Cancelled); + } + facts.push(inspect_one( + query, + request.aggregate_content_limit as usize, + &mut total_content_bytes, + )); + } + Ok(PathResponse { + facts, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn inspect_one(query: &PathQuery, aggregate_limit: usize, total: &mut usize) -> PathFact { + let kind = match fs::symlink_metadata(&query.path) { + Ok(metadata) if metadata.file_type().is_symlink() => match fs::metadata(&query.path) { + Ok(target) if target.is_dir() => PathKind::SymlinkDirectory, + Ok(_) => PathKind::SymlinkOther, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathKind::BrokenLink, + Err(_) => PathKind::Inaccessible, + }, + Ok(metadata) if metadata.is_dir() => PathKind::Directory, + Ok(_) => PathKind::Other, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathKind::Missing, + Err(_) => PathKind::Inaccessible, + }; + let Some(per_file_limit) = query.content_limit else { + return PathFact { + path: query.path.clone(), + kind, + content: ContentState::NotRequested, + content_truncated: false, + }; + }; + let metadata = match fs::metadata(&query.path) { + Ok(metadata) if metadata.is_file() => metadata, + _ => { + return PathFact { + path: query.path.clone(), + kind, + content: ContentState::NotRequested, + content_truncated: false, + }; + } + }; + let remaining = aggregate_limit.saturating_sub(*total); + let limit = remaining.min(per_file_limit as usize); + let mut bytes = Vec::new(); + let content = match fs::File::open(&query.path) { + Ok(file) => { + if file.take(limit as u64).read_to_end(&mut bytes).is_ok() { + *total += bytes.len(); + if metadata.len() == 0 { + ContentState::Empty + } else { + ContentState::Bytes(bytes) + } + } else { + ContentState::Unreadable + } + } + _ => ContentState::Unreadable, + }; + PathFact { + path: query.path.clone(), + kind, + content, + content_truncated: metadata.len() > limit as u64, + } +} diff --git a/src-tauri/crates/environment-engine/src/payload.rs b/src-tauri/crates/environment-engine/src/payload.rs new file mode 100644 index 00000000..81375b11 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/payload.rs @@ -0,0 +1,647 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io::{BufReader, Read, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const EXCLUDED_PAYLOAD_FILES: &[&str] = &["metadata.json"]; +const EXCLUDED_PAYLOAD_DIRS: &[&str] = &[".git", "__pycache__", "__pypackages__"]; +const EXCLUDED_CLI_DIRS: &[&str] = &[".git", "node_modules"]; +const MANIFEST_FILE: &str = "manifest.json"; +const BLOB_LIST_FILE: &str = "blob-list"; +const BLOBS_DIRECTORY: &str = "blobs"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PayloadEntryKind { + File, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadEntry { + pub relative_path: String, + pub kind: PayloadEntryKind, + pub blob_id: Option, + pub content_hash: Option, + pub size: u64, + pub executable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadManifest { + pub entries: Vec, + pub payload_root_hash: String, + pub payload_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltPayload { + pub manifest: PayloadManifest, + pub total_bytes: u64, + pub computed_hash: String, +} + +#[derive(Debug)] +pub enum PayloadError { + Io(std::io::Error), + Json(serde_json::Error), + InvalidSource, + DestinationExists, + UnsafeSourceLink { path: PathBuf }, + InvalidPayload, + UnsupportedPlatform, + Cancelled, +} + +impl fmt::Display for PayloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for PayloadError {} + +impl From for PayloadError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for PayloadError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +pub fn build_payload( + source_root: &Path, + payload_root: &Path, +) -> Result { + build_payload_with_cancel(source_root, payload_root, || false) +} + +pub fn build_payload_with_cancel( + source_root: &Path, + payload_root: &Path, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + let physical_root = fs::canonicalize(source_root).map_err(|_| PayloadError::InvalidSource)?; + if !physical_root.is_dir() { + return Err(PayloadError::InvalidSource); + } + if fs::symlink_metadata(payload_root).is_ok() { + return Err(PayloadError::DestinationExists); + } + fs::create_dir(payload_root)?; + let result = build_payload_in_root(&physical_root, payload_root, &is_cancelled); + if result.is_err() { + let _ = fs::remove_dir_all(payload_root); + } + result +} + +pub fn verify_payload(payload_root: &Path) -> Result { + ensure_payload_root(payload_root)?; + let manifest_path = payload_root.join(MANIFEST_FILE); + ensure_regular_file(&manifest_path)?; + let manifest: PayloadManifest = serde_json::from_reader(fs::File::open(manifest_path)?)?; + verify_manifest(&manifest)?; + + let blob_ids = manifest_blob_ids(&manifest)?; + let blob_list_path = payload_root.join(BLOB_LIST_FILE); + ensure_regular_file(&blob_list_path)?; + let blob_list = fs::read_to_string(blob_list_path)?; + let listed = blob_list + .lines() + .map(str::to_owned) + .collect::>(); + if listed != blob_ids || blob_list.lines().count() != blob_ids.len() { + return Err(PayloadError::InvalidPayload); + } + for blob_id in blob_ids { + let blob = payload_root.join(BLOBS_DIRECTORY).join(&blob_id); + ensure_regular_file(&blob)?; + if file_sha256(&blob)? != blob_id { + return Err(PayloadError::InvalidPayload); + } + } + Ok(manifest) +} + +pub fn read_blob(payload_root: &Path, blob_id: &str) -> Result, PayloadError> { + if !valid_blob_id(blob_id) { + return Err(PayloadError::InvalidPayload); + } + ensure_payload_root(payload_root)?; + let path = payload_root.join(BLOBS_DIRECTORY).join(blob_id); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + if file_sha256(&path)? != blob_id { + return Err(PayloadError::InvalidPayload); + } + Ok(Some(fs::File::open(path)?)) + } + Ok(_) => Err(PayloadError::InvalidPayload), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +pub fn source_metadata_fingerprint(source_root: &Path) -> Result { + source_metadata_fingerprint_platform(source_root) +} + +#[cfg(not(target_os = "linux"))] +fn source_metadata_fingerprint_platform(_source_root: &Path) -> Result { + Err(PayloadError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn source_metadata_fingerprint_platform(source_root: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + + let physical_root = fs::canonicalize(source_root).map_err(|_| PayloadError::InvalidSource)?; + if !physical_root.is_dir() { + return Err(PayloadError::InvalidSource); + } + let mut paths = Vec::new(); + collect_fingerprint_paths(&physical_root, &physical_root, Path::new(""), &mut paths)?; + paths.sort_by(|left, right| left.1.cmp(&right.1)); + + let mut hasher = Sha256::new(); + for (path, relative) in paths { + let link_metadata = fs::symlink_metadata(&path)?; + let (kind, target, metadata) = if link_metadata.file_type().is_symlink() { + let _ = safe_internal_link_target(&physical_root, &path)?; + ( + b"link".as_slice(), + fs::read_link(&path)?.into_os_string().as_bytes().to_vec(), + fs::metadata(&path).map_err(|_| unsafe_link(&path))?, + ) + } else if link_metadata.is_dir() { + (b"directory".as_slice(), Vec::new(), link_metadata) + } else if link_metadata.is_file() { + (b"file".as_slice(), Vec::new(), link_metadata) + } else { + (b"other".as_slice(), Vec::new(), link_metadata) + }; + hasher.update(relative.as_os_str().as_bytes()); + hasher.update([0]); + hasher.update(kind); + hasher.update([0]); + hasher.update(metadata.len().to_string().as_bytes()); + hasher.update([0]); + hasher.update(metadata.mtime().to_string().as_bytes()); + hasher.update([0]); + hasher.update(format!("{:o}", metadata.mode() & 0o7777).as_bytes()); + hasher.update([0]); + hasher.update(target); + hasher.update([0]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(target_os = "linux")] +fn collect_fingerprint_paths( + physical_root: &Path, + current: &Path, + relative_root: &Path, + paths: &mut Vec<(PathBuf, PathBuf)>, +) -> Result<(), PayloadError> { + for entry in sorted_directory_entries(current)? { + let name = entry.file_name(); + let name_text = name.to_string_lossy(); + let path = entry.path(); + let relative = relative_root.join(&name); + let metadata = fs::symlink_metadata(&path)?; + if metadata.is_dir() && EXCLUDED_PAYLOAD_DIRS.contains(&name_text.as_ref()) { + continue; + } + if metadata.is_file() && EXCLUDED_PAYLOAD_FILES.contains(&name_text.as_ref()) { + continue; + } + if metadata.file_type().is_symlink() { + let _ = safe_internal_link_target(physical_root, &path)?; + paths.push((path, relative)); + } else if metadata.is_dir() { + paths.push((path.clone(), relative.clone())); + collect_fingerprint_paths(physical_root, &path, &relative, paths)?; + } else { + paths.push((path, relative)); + } + } + Ok(()) +} + +fn build_payload_in_root( + physical_root: &Path, + payload_root: &Path, + is_cancelled: &impl Fn() -> bool, +) -> Result { + ensure_not_cancelled(is_cancelled)?; + let blobs_root = payload_root.join(BLOBS_DIRECTORY); + fs::create_dir(&blobs_root)?; + let mut entries = Vec::new(); + let mut ancestors = vec![physical_root.to_path_buf()]; + collect_payload_entries( + physical_root, + physical_root, + Path::new(""), + &blobs_root, + &mut entries, + &mut ancestors, + is_cancelled, + )?; + entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let payload_root_hash = compute_payload_root_hash(&entries); + let manifest = PayloadManifest { + entries, + payload_id: payload_root_hash.clone(), + payload_root_hash, + }; + verify_manifest(&manifest)?; + let blob_ids = manifest_blob_ids(&manifest)?; + write_blob_list(&payload_root.join(BLOB_LIST_FILE), &blob_ids)?; + serde_json::to_writer( + fs::File::create(payload_root.join(MANIFEST_FILE))?, + &manifest, + )?; + let total_bytes = manifest + .entries + .iter() + .filter(|entry| entry.kind == PayloadEntryKind::File) + .map(|entry| (entry.blob_id.as_deref().unwrap(), entry.size)) + .collect::>() + .values() + .sum(); + let computed_hash = compute_cli_hash(&manifest, &blobs_root)?; + Ok(BuiltPayload { + manifest, + total_bytes, + computed_hash, + }) +} + +fn collect_payload_entries( + physical_root: &Path, + current: &Path, + relative_root: &Path, + blobs_root: &Path, + entries: &mut Vec, + ancestors: &mut Vec, + is_cancelled: &impl Fn() -> bool, +) -> Result<(), PayloadError> { + for entry in sorted_directory_entries(current)? { + ensure_not_cancelled(is_cancelled)?; + let name = entry.file_name(); + let name_text = name.to_string_lossy(); + if EXCLUDED_PAYLOAD_DIRS.contains(&name_text.as_ref()) { + continue; + } + let path = entry.path(); + let relative = relative_root.join(&name); + let relative_path = normalized_relative_path(&relative)?; + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + let target = safe_internal_link_target(physical_root, &path)?; + let target_metadata = fs::metadata(&target).map_err(|_| unsafe_link(&path))?; + if target_metadata.is_dir() { + if ancestors.contains(&target) { + return Err(unsafe_link(&path)); + } + entries.push(directory_entry(relative_path)); + ancestors.push(target.clone()); + let result = collect_payload_entries( + physical_root, + &target, + &relative, + blobs_root, + entries, + ancestors, + is_cancelled, + ); + ancestors.pop(); + result?; + } else if target_metadata.is_file() { + add_payload_file( + &target, + relative_path, + &target_metadata, + blobs_root, + entries, + is_cancelled, + )?; + } else { + return Err(unsafe_link(&path)); + } + } else if metadata.is_dir() { + entries.push(directory_entry(relative_path)); + let canonical = fs::canonicalize(&path).map_err(|_| unsafe_link(&path))?; + ancestors.push(canonical); + let result = collect_payload_entries( + physical_root, + &path, + &relative, + blobs_root, + entries, + ancestors, + is_cancelled, + ); + ancestors.pop(); + result?; + } else if metadata.is_file() && !EXCLUDED_PAYLOAD_FILES.contains(&name_text.as_ref()) { + add_payload_file( + &path, + relative_path, + &metadata, + blobs_root, + entries, + is_cancelled, + )?; + } + } + Ok(()) +} + +fn add_payload_file( + source: &Path, + relative_path: String, + metadata: &fs::Metadata, + blobs_root: &Path, + entries: &mut Vec, + is_cancelled: &impl Fn() -> bool, +) -> Result<(), PayloadError> { + let temporary = blobs_root.join(format!(".copy-{}", entries.len())); + let mut input = fs::File::open(source)?; + let mut output = fs::File::create(&temporary)?; + let mut buffer = [0_u8; 64 * 1024]; + loop { + ensure_not_cancelled(is_cancelled)?; + let read = input.read(&mut buffer)?; + if read == 0 { + break; + } + output.write_all(&buffer[..read])?; + } + output.flush()?; + let blob_id = file_sha256(&temporary)?; + let blob = blobs_root.join(&blob_id); + if fs::symlink_metadata(&blob).is_ok() { + fs::remove_file(&temporary)?; + } else { + fs::rename(&temporary, &blob)?; + } + set_private_permissions(&blob)?; + let copied_size = fs::metadata(&blob)?.len(); + entries.push(PayloadEntry { + relative_path, + kind: PayloadEntryKind::File, + blob_id: Some(blob_id.clone()), + content_hash: Some(blob_id), + size: copied_size, + executable: is_executable(metadata), + }); + Ok(()) +} + +fn verify_manifest(manifest: &PayloadManifest) -> Result<(), PayloadError> { + if manifest.payload_id != manifest.payload_root_hash + || compute_payload_root_hash(&manifest.entries) != manifest.payload_root_hash + { + return Err(PayloadError::InvalidPayload); + } + let mut previous_path = None; + for entry in &manifest.entries { + if entry.relative_path.is_empty() + || entry.relative_path.starts_with('/') + || entry.relative_path.contains('\\') + || entry + .relative_path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + || previous_path.is_some_and(|previous| previous >= entry.relative_path.as_str()) + { + return Err(PayloadError::InvalidPayload); + } + previous_path = Some(entry.relative_path.as_str()); + match entry.kind { + PayloadEntryKind::Directory + if entry.blob_id.is_some() + || entry.content_hash.is_some() + || entry.size != 0 + || entry.executable => + { + return Err(PayloadError::InvalidPayload); + } + PayloadEntryKind::File + if entry.blob_id.is_none() + || entry.blob_id.as_deref() != entry.content_hash.as_deref() + || !entry.blob_id.as_deref().is_some_and(valid_blob_id) => + { + return Err(PayloadError::InvalidPayload); + } + _ => {} + } + } + Ok(()) +} + +fn manifest_blob_ids(manifest: &PayloadManifest) -> Result, PayloadError> { + verify_manifest(manifest)?; + Ok(manifest + .entries + .iter() + .filter_map(|entry| entry.blob_id.clone()) + .collect()) +} + +fn compute_payload_root_hash(entries: &[PayloadEntry]) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-payload-v1\0"); + for entry in entries { + hasher.update(entry.relative_path.as_bytes()); + hasher.update([0]); + hasher.update([match entry.kind { + PayloadEntryKind::File => 1, + PayloadEntryKind::Directory => 2, + }]); + hasher.update(entry.size.to_le_bytes()); + hasher.update([u8::from(entry.executable)]); + if let Some(content_hash) = &entry.content_hash { + hasher.update(content_hash.as_bytes()); + } + hasher.update([0]); + } + format!("{:x}", hasher.finalize()) +} + +fn compute_cli_hash(manifest: &PayloadManifest, blobs_root: &Path) -> Result { + let mut files = manifest + .entries + .iter() + .filter(|entry| entry.kind == PayloadEntryKind::File) + .filter(|entry| { + !entry + .relative_path + .split('/') + .any(|component| EXCLUDED_CLI_DIRS.contains(&component)) + }) + .collect::>(); + files.sort_by(|left, right| { + left.relative_path + .to_ascii_lowercase() + .cmp(&right.relative_path.to_ascii_lowercase()) + .then_with(|| left.relative_path.cmp(&right.relative_path)) + }); + let mut hasher = Sha256::new(); + for entry in files { + hasher.update(entry.relative_path.as_bytes()); + let blob_id = entry + .blob_id + .as_deref() + .ok_or(PayloadError::InvalidPayload)?; + hash_file_into(&blobs_root.join(blob_id), &mut hasher)?; + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn file_sha256(path: &Path) -> Result { + let mut hasher = Sha256::new(); + hash_file_into(path, &mut hasher)?; + Ok(format!("{:x}", hasher.finalize())) +} + +fn hash_file_into(path: &Path, hasher: &mut Sha256) -> Result<(), PayloadError> { + let mut reader = BufReader::new(fs::File::open(path)?); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + return Ok(()); + } + hasher.update(&buffer[..read]); + } +} + +fn write_blob_list(path: &Path, blob_ids: &BTreeSet) -> Result<(), PayloadError> { + let mut file = fs::File::create(path)?; + for blob_id in blob_ids { + writeln!(file, "{blob_id}")?; + } + Ok(()) +} + +fn ensure_payload_root(path: &Path) -> Result<(), PayloadError> { + let metadata = fs::symlink_metadata(path).map_err(|_| PayloadError::InvalidPayload)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(PayloadError::InvalidPayload); + } + let blobs = path.join(BLOBS_DIRECTORY); + let metadata = fs::symlink_metadata(blobs).map_err(|_| PayloadError::InvalidPayload)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(PayloadError::InvalidPayload); + } + Ok(()) +} + +fn ensure_regular_file(path: &Path) -> Result<(), PayloadError> { + let metadata = fs::symlink_metadata(path).map_err(|_| PayloadError::InvalidPayload)?; + if metadata.is_file() && !metadata.file_type().is_symlink() { + Ok(()) + } else { + Err(PayloadError::InvalidPayload) + } +} + +fn safe_internal_link_target(physical_root: &Path, link: &Path) -> Result { + let target = fs::canonicalize(link).map_err(|_| unsafe_link(link))?; + if target.starts_with(physical_root) { + Ok(target) + } else { + Err(unsafe_link(link)) + } +} + +fn unsafe_link(path: &Path) -> PayloadError { + PayloadError::UnsafeSourceLink { + path: path.to_path_buf(), + } +} + +fn directory_entry(relative_path: String) -> PayloadEntry { + PayloadEntry { + relative_path, + kind: PayloadEntryKind::Directory, + blob_id: None, + content_hash: None, + size: 0, + executable: false, + } +} + +fn sorted_directory_entries(path: &Path) -> Result, PayloadError> { + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + Ok(entries) +} + +fn normalized_relative_path(path: &Path) -> Result { + path.components() + .map(|component| { + component + .as_os_str() + .to_str() + .map(str::to_string) + .ok_or(PayloadError::InvalidSource) + }) + .collect::, _>>() + .map(|components| components.join("/")) +} + +fn valid_blob_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn ensure_not_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), PayloadError> { + if is_cancelled() { + Err(PayloadError::Cancelled) + } else { + Ok(()) + } +} + +#[cfg(unix)] +fn is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path) -> Result<(), PayloadError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path) -> Result<(), PayloadError> { + Ok(()) +} diff --git a/src-tauri/crates/environment-engine/src/projection.rs b/src-tauri/crates/environment-engine/src/projection.rs new file mode 100644 index 00000000..e5714859 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/projection.rs @@ -0,0 +1,134 @@ +use std::fmt; +#[cfg(target_os = "linux")] +use std::path::Component; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectionRequest { + pub destinations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectedTarget { + pub anchor_device: u64, + pub anchor_inode: u64, + pub physical_anchor: PathBuf, + pub physical_destination: PathBuf, + pub relative_components: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectionResponse { + pub targets: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectionError { + UnsupportedPlatform, + InvalidRequest, + Unavailable, + Cancelled, +} + +impl fmt::Display for ProjectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ProjectionError {} + +pub fn project_targets(request: &ProjectionRequest) -> Result { + project_targets_with_cancel(request, || false) +} + +pub fn project_targets_with_cancel( + request: &ProjectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.destinations.is_empty() + || request.destinations.iter().any(|path| !path.is_absolute()) + { + return Err(ProjectionError::InvalidRequest); + } + project_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn project_platform( + _request: &ProjectionRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(ProjectionError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn project_platform( + request: &ProjectionRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + use std::os::unix::fs::MetadataExt; + + let mut targets = Vec::with_capacity(request.destinations.len()); + for destination in &request.destinations { + if is_cancelled() { + return Err(ProjectionError::Cancelled); + } + let mut parent = destination + .parent() + .ok_or(ProjectionError::InvalidRequest)? + .to_path_buf(); + let name = destination + .file_name() + .filter(|name| !name.is_empty()) + .ok_or(ProjectionError::InvalidRequest)?; + let mut components = vec![PathBuf::from(name)]; + while fs::symlink_metadata(&parent).is_err() { + let name = parent + .file_name() + .filter(|name| !name.is_empty()) + .ok_or(ProjectionError::Unavailable)?; + components.push(PathBuf::from(name)); + parent = parent + .parent() + .ok_or(ProjectionError::Unavailable)? + .to_path_buf(); + } + if !fs::metadata(&parent) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + return Err(ProjectionError::Unavailable); + } + components.reverse(); + if components.iter().any(|component| { + component + .components() + .any(|part| !matches!(part, Component::Normal(_))) + }) { + return Err(ProjectionError::InvalidRequest); + } + let physical_anchor = + fs::canonicalize(&parent).map_err(|_| ProjectionError::Unavailable)?; + let metadata = fs::metadata(&physical_anchor).map_err(|_| ProjectionError::Unavailable)?; + let physical_destination = components + .iter() + .fold(physical_anchor.clone(), |path, component| { + path.join(component) + }); + targets.push(ProjectedTarget { + anchor_device: metadata.dev(), + anchor_inode: metadata.ino(), + physical_anchor, + physical_destination, + relative_components: components, + }); + } + Ok(ProjectionResponse { targets }) +} diff --git a/src-tauri/crates/environment-engine/src/source_inventory.rs b/src-tauri/crates/environment-engine/src/source_inventory.rs new file mode 100644 index 00000000..bacb5f00 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/source_inventory.rs @@ -0,0 +1,456 @@ +use std::fmt; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[cfg(target_os = "linux")] +const MAX_RECURSIVE_DEPTH: usize = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceInventoryRequest { + pub roots: Vec, + pub mode: SourceScanMode, + pub per_file_limit: u32, + pub aggregate_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceRoot { + pub path: PathBuf, + pub stat_only: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceScanMode { + Recursive, + PriorityDirectories, + SkillMetadata, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceEntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceEntryError { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceEntry { + pub root_index: u32, + pub relative_path: PathBuf, + pub kind: SourceEntryKind, + pub link_target: Option, + pub content_bytes: Vec, + pub truncated: bool, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceInventoryResponse { + pub entries: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceInventoryError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for SourceInventoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for SourceInventoryError {} + +pub fn scan_source( + request: &SourceInventoryRequest, +) -> Result { + scan_source_with_cancel(request, || false) +} + +pub fn scan_source_with_cancel( + request: &SourceInventoryRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.roots.is_empty() + || request.roots.len() > u32::MAX as usize + || request.per_file_limit == 0 + || request.aggregate_limit == 0 + || request.per_file_limit > request.aggregate_limit + || request.roots.iter().any(|root| !root.path.is_absolute()) + { + return Err(SourceInventoryError::InvalidRequest); + } + scan_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn scan_platform( + _request: &SourceInventoryRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(SourceInventoryError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn scan_platform( + request: &SourceInventoryRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut entries = Vec::new(); + let mut total_content_bytes = 0usize; + for (root_index, root) in request.roots.iter().enumerate() { + ensure_not_cancelled(is_cancelled)?; + let read_root_content = root + .path + .file_name() + .is_some_and(|name| name == "skills-lock.json"); + let root_entry = inspect_entry( + &root.path, + root_index as u32, + PathBuf::new(), + read_root_content, + request, + &mut total_content_bytes, + ); + let root_is_directory = root_entry.kind == SourceEntryKind::Directory; + entries.push(root_entry); + if root.stat_only || !root_is_directory { + continue; + } + let visit_result = match request.mode { + SourceScanMode::Recursive => visit_recursive( + &root.path, + Path::new(""), + 1, + root_index as u32, + request, + is_cancelled, + &mut total_content_bytes, + &mut entries, + ), + SourceScanMode::PriorityDirectories => visit_priority_directories( + &root.path, + root_index as u32, + request, + is_cancelled, + &mut total_content_bytes, + &mut entries, + ), + SourceScanMode::SkillMetadata => { + let mut children = read_directory(&root.path)?; + children.sort_by_key(|entry| (entry.file_name() != "SKILL.md", entry.file_name())); + if let Some(file) = children.into_iter().find(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) + }) { + let safe = fs::canonicalize(file.path()) + .ok() + .zip(fs::canonicalize(&root.path).ok()) + .is_some_and(|(file, root)| file.starts_with(root)); + let mut entry = inspect_entry( + &file.path(), + root_index as u32, + PathBuf::from(file.file_name()), + safe, + request, + &mut total_content_bytes, + ); + if !safe { + entry.error = Some(SourceEntryError::PathUnavailable); + } + entries.push(entry); + } + Ok(()) + } + }; + if let Err(error) = visit_result { + if error == SourceInventoryError::Cancelled { + return Err(error); + } + if let Some(root_entry) = entries.iter_mut().find(|entry| { + entry.root_index == root_index as u32 && entry.relative_path.as_os_str().is_empty() + }) { + root_entry.error = Some(SourceEntryError::PathUnavailable); + } + } + } + Ok(SourceInventoryResponse { + entries, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn visit_priority_directories( + root: &Path, + root_index: u32, + request: &SourceInventoryRequest, + is_cancelled: &impl Fn() -> bool, + total_content_bytes: &mut usize, + entries: &mut Vec, +) -> Result<(), SourceInventoryError> { + let mut directories = read_directory(root)?; + directories.sort_by_key(fs::DirEntry::file_name); + for directory in directories { + ensure_not_cancelled(is_cancelled)?; + let metadata = match fs::symlink_metadata(directory.path()) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + continue; + } + let mut children = read_directory(&directory.path())?; + children.sort_by_key(fs::DirEntry::file_name); + for child in children { + ensure_not_cancelled(is_cancelled)?; + let file_name = child.file_name(); + if !file_name + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) + { + continue; + } + let relative_path = PathBuf::from(directory.file_name()).join(file_name); + let metadata = match fs::symlink_metadata(child.path()) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.is_file() || metadata.file_type().is_symlink() { + entries.push(inspect_entry( + &child.path(), + root_index, + relative_path, + true, + request, + total_content_bytes, + )); + } + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_directory(directory: &Path) -> Result, SourceInventoryError> { + fs::read_dir(directory) + .map_err(|_| SourceInventoryError::InvalidRequest)? + .collect::, _>>() + .map_err(|_| SourceInventoryError::InvalidRequest) +} + +#[cfg(target_os = "linux")] +#[allow( + clippy::too_many_arguments, + reason = "recursive source scan carries one bounded accumulator" +)] +fn visit_recursive( + directory: &Path, + relative_parent: &Path, + depth: usize, + root_index: u32, + request: &SourceInventoryRequest, + is_cancelled: &impl Fn() -> bool, + total_content_bytes: &mut usize, + entries: &mut Vec, +) -> Result<(), SourceInventoryError> { + if depth > MAX_RECURSIVE_DEPTH { + return Ok(()); + } + let mut children = fs::read_dir(directory) + .map_err(|_| SourceInventoryError::InvalidRequest)? + .collect::, _>>() + .map_err(|_| SourceInventoryError::InvalidRequest)?; + children.sort_by_key(fs::DirEntry::file_name); + for child in children { + ensure_not_cancelled(is_cancelled)?; + let path = child.path(); + let relative_path = relative_parent.join(child.file_name()); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + if pruned_directory(child.file_name().as_os_str()) { + continue; + } + visit_recursive( + &path, + &relative_path, + depth + 1, + root_index, + request, + is_cancelled, + total_content_bytes, + entries, + )?; + continue; + } + if relevant_document(&relative_path) { + entries.push(inspect_entry( + &path, + root_index, + relative_path, + true, + request, + total_content_bytes, + )); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn relevant_document(relative_path: &Path) -> bool { + if relative_path == Path::new(".claude-plugin/marketplace.json") + || relative_path == Path::new(".claude-plugin/plugin.json") + || relative_path == Path::new("skills-lock.json") + { + return true; + } + relative_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) +} + +#[cfg(target_os = "linux")] +fn pruned_directory(name: &std::ffi::OsStr) -> bool { + matches!( + name.to_str(), + Some(".git" | "node_modules" | "dist" | "build" | "__pycache__" | "__pypackages__") + ) +} + +#[cfg(target_os = "linux")] +fn inspect_entry( + path: &Path, + root_index: u32, + relative_path: PathBuf, + read_content: bool, + request: &SourceInventoryRequest, + total_content_bytes: &mut usize, +) -> SourceEntry { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return empty_entry(root_index, relative_path, SourceEntryKind::Missing, None); + } + Err(_) => { + return empty_entry( + root_index, + relative_path, + SourceEntryKind::Other, + Some(SourceEntryError::PathUnavailable), + ); + } + }; + let file_type = metadata.file_type(); + let kind = if file_type.is_symlink() { + SourceEntryKind::Symlink + } else if metadata.is_file() { + SourceEntryKind::File + } else if metadata.is_dir() { + SourceEntryKind::Directory + } else { + SourceEntryKind::Other + }; + let (link_target, mut error) = if kind == SourceEntryKind::Symlink { + match fs::read_link(path) { + Ok(target) => (Some(target), None), + Err(_) => (None, Some(SourceEntryError::ReadLinkFailed)), + } + } else { + (None, None) + }; + let mut content_bytes = Vec::new(); + let mut truncated = false; + let content_length = match kind { + SourceEntryKind::File => Some(metadata.len()), + SourceEntryKind::Symlink => fs::metadata(path) + .ok() + .filter(fs::Metadata::is_file) + .map(|target| target.len()), + _ => None, + }; + if let Some(content_length) = content_length.filter(|_| read_content) { + let remaining = (request.aggregate_limit as usize).saturating_sub(*total_content_bytes); + let limit = remaining.min(request.per_file_limit as usize); + match fs::File::open(path) { + Ok(file) => { + if file + .take(limit as u64) + .read_to_end(&mut content_bytes) + .is_ok() + { + *total_content_bytes += content_bytes.len(); + truncated = content_length > content_bytes.len() as u64; + } else { + content_bytes.clear(); + error = Some(SourceEntryError::ReadFailed); + } + } + Err(_) => error = Some(SourceEntryError::ReadFailed), + } + } + SourceEntry { + root_index, + relative_path, + kind, + link_target, + content_bytes, + truncated, + error, + } +} + +#[cfg(target_os = "linux")] +fn empty_entry( + root_index: u32, + relative_path: PathBuf, + kind: SourceEntryKind, + error: Option, +) -> SourceEntry { + SourceEntry { + root_index, + relative_path, + kind, + link_target: None, + content_bytes: Vec::new(), + truncated: false, + error, + } +} + +#[cfg(target_os = "linux")] +fn ensure_not_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), SourceInventoryError> { + if is_cancelled() { + Err(SourceInventoryError::Cancelled) + } else { + Ok(()) + } +} diff --git a/src-tauri/crates/environment-engine/tests/document.rs b/src-tauri/crates/environment-engine/tests/document.rs new file mode 100644 index 00000000..ea4411dc --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/document.rs @@ -0,0 +1,143 @@ +#![cfg(target_os = "linux")] + +use environment_engine::atomic_document::PublicationState; +use environment_engine::document::{ + read_documents, remove_document_if_revision, write_document_atomic, DocumentQuery, + DocumentRequest, DocumentState, DocumentWriteError, +}; +use sha2::Digest; + +#[test] +fn conditional_document_write_replaces_a_file_and_returns_its_revision() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/projects.json"); + + let bytes = br#"{"projects":[]}"#; + let revision = write_document_atomic(&path, None, bytes, 1024).unwrap(); + let expected = format!("sha256:{:x}", sha2::Sha256::digest(bytes)); + assert_eq!(revision, expected); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + let replacement = br#"{"projects":["demo"]}"#; + let replacement_revision = + write_document_atomic(&path, Some(&revision), replacement, 1024).unwrap(); + assert_eq!( + replacement_revision, + format!("sha256:{:x}", sha2::Sha256::digest(replacement)) + ); + assert_eq!(std::fs::read(&path).unwrap(), replacement); +} + +#[test] +fn conditional_document_remove_preserves_a_changed_target() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("applications/project.json"); + let revision = write_document_atomic(&path, None, b"current", 1024).unwrap(); + + assert_eq!( + remove_document_if_revision(&path, Some("sha256:wrong"), 1024).unwrap_err(), + DocumentWriteError::Conflict + ); + assert_eq!(std::fs::read(&path).unwrap(), b"current"); + + remove_document_if_revision(&path, Some(&revision), 1024).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn conditional_document_write_rejects_a_changed_file_without_overwriting_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/projects.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"old").unwrap(); + + assert_eq!( + write_document_atomic(&path, Some("sha256:wrong"), b"new", 1024).unwrap_err(), + DocumentWriteError::Conflict + ); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); +} + +#[test] +fn conditional_document_write_rejects_directory_and_symlink_targets() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("state"); + std::fs::create_dir(&directory).unwrap(); + assert_eq!( + write_document_atomic(&directory, None, b"new", 1024).unwrap_err(), + DocumentWriteError::InvalidTarget + ); + let target = temp.path().join("target"); + std::fs::write(&target, b"old").unwrap(); + let link = temp.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert_eq!( + write_document_atomic(&link, None, b"new", 1024).unwrap_err(), + DocumentWriteError::InvalidTarget + ); +} + +#[test] +fn optional_documents_are_bounded_and_isolated() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("document"); + let directory = temp.path().join("directory"); + std::fs::write(&file, b"abcdef").unwrap(); + std::fs::create_dir(&directory).unwrap(); + + let response = read_documents(&DocumentRequest { + queries: vec![ + DocumentQuery { + path: file, + limit: 4, + }, + DocumentQuery { + path: temp.path().join("missing"), + limit: 4, + }, + DocumentQuery { + path: directory, + limit: 4, + }, + ], + aggregate_limit: 8, + }) + .unwrap(); + + assert_eq!( + response.facts[0].state, + DocumentState::Bytes(b"abcd".to_vec()) + ); + assert!(response.facts[0].truncated); + assert_eq!(response.facts[1].state, DocumentState::Missing); + assert_eq!(response.facts[2].state, DocumentState::NotFile); + assert_eq!(response.total_content_bytes, 4); +} + +#[test] +fn conditional_writes_and_removals_reject_an_oversized_current_document() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.json"); + let file = std::fs::File::create(&path).unwrap(); + file.set_len(32).unwrap(); + + assert_eq!( + write_document_atomic(&path, None, b"new", 16).unwrap_err(), + DocumentWriteError::Io { + phase: environment_engine::atomic_document::WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + } + ); + assert_eq!( + remove_document_if_revision(&path, None, 16).unwrap_err(), + DocumentWriteError::Io { + phase: environment_engine::atomic_document::WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + } + ); + assert_eq!(std::fs::metadata(path).unwrap().len(), 32); +} diff --git a/src-tauri/crates/environment-engine/tests/inspection.rs b/src-tauri/crates/environment-engine/tests/inspection.rs new file mode 100644 index 00000000..2cc6e943 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/inspection.rs @@ -0,0 +1,225 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::os::unix::fs::symlink; + +use environment_engine::inspection::{ + inspect, inspect_with_cancel, EntryKind, ErrorCode, InspectionError, InspectionRequest, + InspectionRoot, +}; + +#[test] +fn inspects_direct_children_and_bounded_skill_documents() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("skills"); + std::fs::create_dir_all(root.join("Alpha")).expect("Alpha directory"); + std::fs::create_dir_all(root.join("beta")).expect("beta directory"); + std::fs::write(root.join("Alpha/SKILL.md"), b"123456").expect("Alpha document"); + std::fs::write(root.join("beta/SKILL.md"), b"abcdef").expect("beta document"); + + let snapshot = inspect(&InspectionRequest { + read_content: true, + roots: vec![InspectionRoot { + path: root, + stat_only: false, + }], + per_file_limit: 4, + aggregate_limit: 6, + }) + .expect("inspection"); + + assert_eq!(snapshot.total_content_bytes, 6); + let alpha = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_path() == std::path::Path::new("Alpha/SKILL.md")) + .expect("Alpha fact"); + assert_eq!(alpha.kind, EntryKind::File); + assert_eq!(alpha.content_bytes, b"1234"); + assert!(alpha.truncated); + + let beta = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_path() == std::path::Path::new("beta/SKILL.md")) + .expect("beta fact"); + assert_eq!(beta.content_bytes, b"ab"); + assert!(beta.truncated); +} + +#[test] +fn inventory_reports_skill_documents_without_reading_content() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("skills"); + std::fs::create_dir_all(root.join("demo")).expect("demo directory"); + std::fs::write(root.join("demo/SKILL.md"), b"frontmatter bytes").expect("document"); + + let snapshot = inspect(&InspectionRequest { + roots: vec![InspectionRoot { + path: root, + stat_only: false, + }], + read_content: false, + per_file_limit: 16, + aggregate_limit: 16, + }) + .expect("inventory"); + + let document = snapshot + .facts + .iter() + .find(|fact| fact.relative_path == std::path::Path::new("demo/SKILL.md")) + .expect("document locator"); + assert_eq!(document.kind, EntryKind::File); + assert!(document.content_bytes.is_empty()); + assert!(!document.truncated); + assert_eq!(snapshot.total_content_bytes, 0); +} + +#[test] +fn stat_only_root_does_not_enumerate_or_consume_content_budget() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join("toolkit")).expect("toolkit directory"); + std::fs::write(temp.path().join("toolkit/SKILL.md"), b"document").expect("document"); + + let snapshot = inspect(&InspectionRequest { + read_content: true, + roots: vec![InspectionRoot { + path: temp.path().to_path_buf(), + stat_only: true, + }], + per_file_limit: 16, + aggregate_limit: 16, + }) + .expect("inspection"); + + assert_eq!(snapshot.facts.len(), 1); + assert_eq!(snapshot.facts[0].kind, EntryKind::Directory); + assert_eq!(snapshot.total_content_bytes, 0); +} + +#[test] +fn follows_a_child_directory_symlink_only_for_its_skill_document() { + let temp = tempfile::tempdir().expect("tempdir"); + let canonical = temp.path().join("canonical/toolkit"); + let root = temp.path().join("skills"); + std::fs::create_dir_all(&canonical).expect("canonical directory"); + std::fs::create_dir_all(&root).expect("skills directory"); + std::fs::write(canonical.join("SKILL.md"), b"document").expect("document"); + symlink(&canonical, root.join("toolkit")).expect("directory symlink"); + symlink(temp.path().join("missing"), root.join("broken")).expect("broken symlink"); + + let snapshot = inspect(&InspectionRequest { + read_content: true, + roots: vec![InspectionRoot { + path: root, + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + }) + .expect("inspection"); + + let linked = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_path() == std::path::Path::new("toolkit")) + .expect("linked directory"); + assert_eq!(linked.kind, EntryKind::Symlink); + assert_eq!(linked.resolved_target.as_deref(), Some(canonical.as_path())); + assert!(snapshot + .facts + .iter() + .any(|fact| fact.relative_path.as_path() == std::path::Path::new("toolkit/SKILL.md"))); + assert!(!snapshot + .facts + .iter() + .any(|fact| fact.relative_path.as_path() == std::path::Path::new("broken/SKILL.md"))); +} + +#[test] +fn preserves_non_utf8_relative_paths_without_lossy_conversion() { + let temp = tempfile::tempdir().expect("tempdir"); + let raw_name = std::ffi::OsString::from_vec(vec![b's', b'k', 0x80]); + std::fs::write(temp.path().join(&raw_name), b"payload").expect("non-UTF-8 entry"); + + let snapshot = inspect(&InspectionRequest { + read_content: true, + roots: vec![InspectionRoot { + path: temp.path().to_path_buf(), + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + }) + .expect("inspection"); + + let fact = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_os_str().as_bytes() == raw_name.as_bytes()) + .expect("raw path fact"); + assert_eq!(fact.kind, EntryKind::File); +} + +#[test] +fn isolates_missing_and_unreadable_roots() { + let temp = tempfile::tempdir().expect("tempdir"); + let missing = temp.path().join("missing"); + let file = temp.path().join("file"); + std::fs::write(&file, b"content").expect("file"); + + let snapshot = inspect(&InspectionRequest { + read_content: true, + roots: vec![ + InspectionRoot { + path: missing, + stat_only: false, + }, + InspectionRoot { + path: file, + stat_only: false, + }, + ], + per_file_limit: 16, + aggregate_limit: 32, + }) + .expect("inspection"); + + assert_eq!(snapshot.facts[0].kind, EntryKind::Missing); + assert_eq!(snapshot.facts[0].error_code, None); + assert_eq!(snapshot.facts[1].kind, EntryKind::File); + assert_ne!( + snapshot.facts[1].error_code, + Some(ErrorCode::PathUnavailable) + ); +} + +#[test] +fn cooperative_cancellation_stops_between_filesystem_entries() { + let temp = tempfile::tempdir().expect("tempdir"); + for index in 0..32 { + std::fs::create_dir_all(temp.path().join(format!("skill-{index}"))) + .expect("skill directory"); + } + let checks = std::cell::Cell::new(0usize); + + let error = inspect_with_cancel( + &InspectionRequest { + read_content: true, + roots: vec![InspectionRoot { + path: temp.path().to_path_buf(), + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + }, + || { + checks.set(checks.get() + 1); + checks.get() > 2 + }, + ) + .unwrap_err(); + + assert_eq!(error, InspectionError::Cancelled); +} diff --git a/src-tauri/crates/environment-engine/tests/library.rs b/src-tauri/crates/environment-engine/tests/library.rs new file mode 100644 index 00000000..cb5a4a79 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/library.rs @@ -0,0 +1,306 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use environment_engine::library::{ + commit, read_catalog, write_catalog, CatalogWrite, ContentAction, LibraryCommit, LibraryError, + TargetExpectation, +}; +use environment_engine::linux_mutation::fingerprint_path; +use environment_engine::projection::{project_targets, ProjectionRequest}; + +#[test] +fn member_upsert_commits_content_and_catalog_as_one_intent() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + let payload = payload_fixture(temp.path(), b"new content"); + let catalog = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + + commit(LibraryCommit { + root: root.clone(), + operation_id: "operation-1".to_string(), + destination: destination.clone(), + expected_target: target_expectation(&destination), + content: ContentAction::Upsert { + payload_root: payload, + }, + catalog: CatalogWrite { + expected_revision: None, + bytes: catalog.clone(), + max_current_bytes: 1024, + }, + }) + .unwrap(); + + assert_eq!( + std::fs::read(destination.join("SKILL.md")).unwrap(), + b"new content" + ); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(catalog)); + assert!(!root.join(".transactions/operation-1").exists()); +} + +#[test] +fn member_commit_accepts_a_managed_root_reached_through_a_symlinked_parent() { + let temp = tempfile::tempdir().unwrap(); + let physical_home = temp.path().join("physical-home"); + std::fs::create_dir(&physical_home).unwrap(); + let alias_home = temp.path().join("home"); + std::os::unix::fs::symlink(&physical_home, &alias_home).unwrap(); + let root = alias_home.join(".skill-deck/skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + let payload = payload_fixture(temp.path(), b"linked home"); + + commit(LibraryCommit { + root: root.clone(), + operation_id: "linked-home".to_string(), + destination: destination.clone(), + expected_target: target_expectation(&destination), + content: ContentAction::Upsert { + payload_root: payload, + }, + catalog: CatalogWrite { + expected_revision: None, + bytes: br#"{"schemaVersion":1}"#.to_vec(), + max_current_bytes: 1024, + }, + }) + .unwrap(); + + assert_eq!( + std::fs::read(destination.join("SKILL.md")).unwrap(), + b"linked home" + ); +} + +#[test] +fn committed_cleanup_ignores_a_later_destination_state() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let transaction = root.join(".transactions/committed-cleanup"); + let destination = root.join("libraries/lib-one/skills/demo"); + std::fs::create_dir_all(transaction.join("backup")).unwrap(); + std::fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"1").unwrap(); + std::fs::write(transaction.join("expected-catalog-hash"), b"committed").unwrap(); + std::fs::write(transaction.join("phase"), b"catalogCommitted").unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("catalog.json"), br#"{"schemaVersion":1}"#).unwrap(); + + let loaded = read_catalog(&root); + + assert!(loaded.is_ok()); + assert!(!transaction.exists()); +} + +#[test] +fn catalog_write_is_conditional_and_creates_declared_library_roots() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let first = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + let revision = write_catalog( + &root, + &[], + CatalogWrite { + expected_revision: None, + bytes: first, + max_current_bytes: 1024, + }, + ) + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + + write_catalog( + &root, + &["lib-1".to_string()], + CatalogWrite { + expected_revision: Some(revision), + bytes: second.clone(), + max_current_bytes: 1024, + }, + ) + .unwrap(); + assert!(root.join("libraries/lib-1/skills").is_dir()); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(second.clone())); + + assert!(matches!( + write_catalog( + &root, + &[], + CatalogWrite { + expected_revision: Some("sha256:wrong".to_string()), + bytes: b"stale".to_vec(), + max_current_bytes: 1024, + }, + ), + Err(LibraryError::StaleTarget) + )); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(second)); +} + +#[test] +fn committed_cleanup_failure_remains_retryable_across_catalog_reads() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let transaction = root.join(".transactions/committed-cleanup"); + let destination = root.join("libraries/lib-1"); + std::fs::create_dir_all(&transaction).unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"0").unwrap(); + std::fs::write(transaction.join("expected-catalog-hash"), b"committed").unwrap(); + std::fs::write(transaction.join("phase"), b"catalogCommitted").unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + std::fs::write(root.join("catalog.json"), &catalog).unwrap(); + let locked = transaction.join("backup/locked"); + std::fs::create_dir_all(&locked).unwrap(); + std::fs::write(locked.join("SKILL.md"), b"remaining backup").unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let first = read_catalog(&root); + let second = read_catalog(&root); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); + + assert_eq!(first.unwrap().bytes, Some(catalog.clone())); + assert_eq!(second.unwrap().bytes, Some(catalog.clone())); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(catalog)); + assert!(!transaction.exists()); +} + +#[test] +fn delete_commit_removes_the_destination_and_updates_catalog() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let first = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":["demo"]}]}"#.to_vec(); + let revision = write_catalog( + &root, + &["lib-1".to_string()], + CatalogWrite { + expected_revision: None, + bytes: first, + max_current_bytes: 1024, + }, + ) + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":[]}]}"#.to_vec(); + + commit(LibraryCommit { + root: root.clone(), + operation_id: "delete-1".to_string(), + destination: destination.clone(), + expected_target: target_expectation(&destination), + content: ContentAction::Delete, + catalog: CatalogWrite { + expected_revision: Some(revision), + bytes: second.clone(), + max_current_bytes: 1024, + }, + }) + .unwrap(); + + assert!(!destination.exists()); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(second)); + assert!(!root.join(".transactions/delete-1").exists()); +} + +#[test] +fn catalog_read_recovers_an_activated_legacy_wsl_transaction() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + let transaction = root.join(".transactions/interrupted"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"new").unwrap(); + std::fs::create_dir_all(transaction.join("backup")).unwrap(); + std::fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_encoded_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"1").unwrap(); + std::fs::write(transaction.join("phase"), b"activated").unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + std::fs::write(root.join("catalog.json"), &catalog).unwrap(); + + assert_eq!(read_catalog(&root).unwrap().bytes, Some(catalog)); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + assert!(!transaction.exists()); +} + +#[test] +fn catalog_read_keeps_legacy_prepared_content_when_catalog_hash_matches() { + use sha2::{Digest, Sha256}; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + let transaction = root.join(".transactions/committed"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"new").unwrap(); + std::fs::create_dir_all(transaction.join("backup")).unwrap(); + std::fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_encoded_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"1").unwrap(); + std::fs::write(transaction.join("phase"), b"catalogPrepared").unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + std::fs::write( + transaction.join("expected-catalog-hash"), + format!("{:x}", Sha256::digest(&catalog)), + ) + .unwrap(); + std::fs::write(root.join("catalog.json"), &catalog).unwrap(); + + read_catalog(&root).unwrap(); + + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + assert!(!transaction.exists()); +} + +fn target_expectation(destination: &Path) -> TargetExpectation { + let target = project_targets(&ProjectionRequest { + destinations: vec![destination.to_path_buf()], + }) + .unwrap() + .targets + .pop() + .unwrap(); + TargetExpectation { + parent: environment_engine::linux_mutation::ParentIdentity { + device: target.anchor_device, + inode: target.anchor_inode, + }, + fingerprint: fingerprint_path(destination).unwrap(), + content_hash: None, + } +} + +fn payload_fixture(parent: &Path, content: &[u8]) -> std::path::PathBuf { + let source = parent.join("source"); + let payload = parent.join("payload"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), content).unwrap(); + environment_engine::payload::build_payload(&source, &payload).unwrap(); + payload +} diff --git a/src-tauri/crates/environment-engine/tests/linux_mutation.rs b/src-tauri/crates/environment-engine/tests/linux_mutation.rs new file mode 100644 index 00000000..8ca9735d --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/linux_mutation.rs @@ -0,0 +1,395 @@ +#![cfg(target_os = "linux")] + +use environment_engine::linux_mutation::{ + content_hash_path, fingerprint_path, parent_identity, preflight_write_targets, EntryAction, + EntryIntent, MutationError, StagedMutation, +}; + +#[test] +fn write_preflight_rejects_an_already_read_only_parent_without_leaving_files() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("read-only"); + let destination = parent.join("demo"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let result = preflight_write_targets(std::slice::from_ref(&destination), || false); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); + assert_eq!(std::fs::read_dir(parent).unwrap().count(), 0); +} +use environment_engine::payload::build_payload; + +#[test] +fn replace_rechecks_swaps_verifies_and_restores_one_linux_entry_set() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + + let mut staged = StagedMutation::stage("operation-1", vec![intent], || false).unwrap(); + staged.recheck(|| false).unwrap(); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + staged.restore().unwrap(); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + staged.cleanup().unwrap(); +} + +#[test] +fn materialize_keeps_an_unselected_agent_with_a_missing_root_absent() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let canonical = scope.join(".agents/skills/demo"); + let unselected = scope.join(".opencode/skills/demo"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let anchor = parent_identity(&scope).unwrap(); + let intents = vec![ + EntryIntent { + destination: canonical.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&canonical).unwrap(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }, + EntryIntent { + destination: unselected.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&unselected).unwrap(), + expected_content_hash: None, + action: EntryAction::Keep, + }, + ]; + + let mut staged = StagedMutation::stage("operation-keep", intents, || false).unwrap(); + + assert!(!unselected.parent().unwrap().exists()); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert_eq!(std::fs::read(canonical.join("SKILL.md")).unwrap(), b"new"); + assert!(!unselected.parent().unwrap().exists()); + staged.restore().unwrap(); + staged.cleanup().unwrap(); +} + +#[test] +fn materialize_creates_a_selected_agent_root_and_installs_its_symlink() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let canonical = scope.join(".agents/skills/demo"); + let selected = scope.join(".claude/skills/demo"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let anchor = parent_identity(&scope).unwrap(); + let intents = vec![ + EntryIntent { + destination: canonical.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&canonical).unwrap(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }, + EntryIntent { + destination: selected.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&selected).unwrap(), + expected_content_hash: None, + action: EntryAction::Symlink { + target: std::path::PathBuf::from("../../.agents/skills/demo"), + }, + }, + ]; + + let mut staged = StagedMutation::stage("operation-symlink", intents, || false).unwrap(); + + assert!(selected.parent().unwrap().is_dir()); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert_eq!( + std::fs::read_link(&selected).unwrap(), + std::path::Path::new("../../.agents/skills/demo") + ); + staged.restore().unwrap(); + staged.cleanup().unwrap(); +} + +#[test] +fn keep_rejects_a_missing_target_created_after_stage() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let destination = scope.join(".opencode/skills/demo"); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(&scope).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Keep, + }; + let mut staged = StagedMutation::stage("operation-stale-keep", vec![intent], || false).unwrap(); + std::fs::create_dir_all(&destination).unwrap(); + + assert_eq!( + staged.swap(|| false).unwrap_err(), + MutationError::StaleTarget + ); + staged.cleanup().unwrap(); +} + +#[test] +fn a_later_stage_failure_cleans_every_prepared_entry() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("targets"); + std::fs::create_dir(&parent).unwrap(); + let first = parent.join("first"); + let second = parent.join("second"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let anchor = parent_identity(&parent).unwrap(); + let intents = vec![ + EntryIntent { + destination: first, + expected_parent: anchor, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }, + EntryIntent { + destination: second, + expected_parent: anchor, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: temp.path().join("missing-payload"), + }, + }, + ]; + + assert_eq!( + StagedMutation::stage("operation-cleanup", intents, || false).unwrap_err(), + MutationError::InvalidPayload + ); + assert!(!parent + .join(".skill-deck-stage-operation-cleanup-000000") + .exists()); + assert!(!parent + .join(".skill-deck-stage-operation-cleanup-000001") + .exists()); +} + +#[test] +fn staging_never_cleans_a_preexisting_operation_path() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("targets"); + std::fs::create_dir(&parent).unwrap(); + let destination = parent.join("demo"); + let existing_stage = parent.join(".skill-deck-stage-operation-existing-000000"); + std::fs::create_dir(&existing_stage).unwrap(); + std::fs::write(existing_stage.join("evidence"), b"keep").unwrap(); + let intent = EntryIntent { + destination, + expected_parent: parent_identity(&parent).unwrap(), + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Remove, + }; + + assert_eq!( + StagedMutation::stage("operation-existing", vec![intent], || false).unwrap_err(), + MutationError::StaleTarget + ); + assert_eq!( + std::fs::read(existing_stage.join("evidence")).unwrap(), + b"keep" + ); +} + +#[test] +fn stage_only_cleanup_preserves_backup_evidence() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("targets"); + std::fs::create_dir(&parent).unwrap(); + let destination = parent.join("demo"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination, + expected_parent: parent_identity(&parent).unwrap(), + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + let mut staged = StagedMutation::stage("operation-stage-only", vec![intent], || false).unwrap(); + let stage = parent.join(".skill-deck-stage-operation-stage-only-000000"); + let backup = parent.join(".skill-deck-backup-operation-stage-only-000000"); + std::fs::create_dir(&backup).unwrap(); + std::fs::write(backup.join("evidence"), b"old").unwrap(); + + assert!(staged.cleanup_stages().is_empty()); + + assert!(!stage.exists()); + assert_eq!(std::fs::read(backup.join("evidence")).unwrap(), b"old"); +} + +#[test] +fn remove_keeps_a_missing_target_parent_absent() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let destination = scope.join(".opencode/skills/demo"); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(&scope).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Remove, + }; + + let mut staged = StagedMutation::stage("operation-remove", vec![intent], || false).unwrap(); + + assert!(!destination.parent().unwrap().exists()); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert!(!destination.parent().unwrap().exists()); + staged.restore().unwrap(); + staged.cleanup().unwrap(); +} + +#[test] +fn replace_rejects_a_target_changed_after_stage() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + let mut staged = StagedMutation::stage("operation-2", vec![intent], || false).unwrap(); + std::fs::write(destination.join("external"), b"changed").unwrap(); + + assert_eq!( + staged.swap(|| false).unwrap_err(), + MutationError::StaleTarget + ); + staged.cleanup().unwrap(); +} + +#[test] +fn replace_rejects_existing_file_content_changed_after_stage() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + let mut staged = StagedMutation::stage("operation-3", vec![intent], || false).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"locally changed").unwrap(); + + assert_eq!( + staged.swap(|| false).unwrap_err(), + MutationError::StaleTarget + ); + staged.cleanup().unwrap(); +} + +#[test] +fn cancellation_is_delayed_after_the_entry_set_starts_swapping() { + let temp = tempfile::tempdir().unwrap(); + let destinations = [ + temp.path().join("targets/one"), + temp.path().join("targets/two"), + ]; + let intents = destinations + .iter() + .map(|destination| { + std::fs::create_dir_all(destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Remove, + } + }) + .collect(); + let mut staged = StagedMutation::stage("operation-4", intents, || false).unwrap(); + let checks = std::cell::Cell::new(0_u32); + + staged + .swap(|| { + checks.set(checks.get() + 1); + checks.get() >= 4 + }) + .unwrap(); + + assert!(destinations.iter().all(|destination| !destination.exists())); + staged.verify(|| false).unwrap(); + staged.restore().unwrap(); + assert!(destinations.iter().all(|destination| destination.is_dir())); + staged.cleanup().unwrap(); +} diff --git a/src-tauri/crates/environment-engine/tests/lock.rs b/src-tauri/crates/environment-engine/tests/lock.rs new file mode 100644 index 00000000..3a7deed8 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/lock.rs @@ -0,0 +1,81 @@ +use std::collections::BTreeMap; + +use environment_engine::lock::{apply, EntryMutation, LockError, LockMutation, LockSchema}; +use serde_json::json; + +#[test] +fn conditional_lock_apply_preserves_unknown_fields_and_returns_new_evidence() { + let current = serde_json::to_vec(&json!({ + "version": 3, + "skills": { + "demo": { + "source": "old", + "skillFolderHash": "old-hash", + "futureEntry": { "keep": true } + }, + "other": { "source": "untouched" } + }, + "futureRoot": [1, 2, 3] + })) + .unwrap(); + let mutation = LockMutation { + schema: LockSchema::Global, + entry: EntryMutation::Replace { + key: "demo".to_string(), + replacement: json!({ + "source": "new", + "skillFolderHash": "new-hash" + }), + }, + root_replacements: BTreeMap::new(), + expected_entries: BTreeMap::from([( + "demo".to_string(), + Some(json!({ + "source": "old", + "skillFolderHash": "old-hash", + "futureEntry": { "keep": true } + })), + )]), + expected_roots: BTreeMap::new(), + }; + + let applied = apply(Some(¤t), None, &mutation).unwrap(); + let value: serde_json::Value = serde_json::from_slice(&applied.bytes).unwrap(); + + assert_eq!(value["skills"]["demo"]["source"], "new"); + assert_eq!(value["skills"]["demo"]["futureEntry"]["keep"], true); + assert_eq!(value["skills"]["other"]["source"], "untouched"); + assert_eq!(value["futureRoot"], json!([1, 2, 3])); + assert_eq!( + applied.receipt.entries["demo"], + Some(value["skills"]["demo"].clone()) + ); +} + +#[test] +fn conditional_lock_apply_rejects_changed_selected_entry() { + let current = serde_json::to_vec(&json!({ + "version": 1, + "skills": { "demo": { "source": "changed" } } + })) + .unwrap(); + let mutation = LockMutation { + schema: LockSchema::Project, + entry: EntryMutation::Remove { + key: "demo".to_string(), + }, + root_replacements: BTreeMap::new(), + expected_entries: BTreeMap::from([( + "demo".to_string(), + Some(json!({ "source": "expected" })), + )]), + expected_roots: BTreeMap::new(), + }; + + assert_eq!( + apply(Some(¤t), None, &mutation).unwrap_err(), + LockError::EntryConflict { + key: "demo".to_string() + } + ); +} diff --git a/src-tauri/crates/environment-engine/tests/path_and_directory.rs b/src-tauri/crates/environment-engine/tests/path_and_directory.rs new file mode 100644 index 00000000..daf288b5 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/path_and_directory.rs @@ -0,0 +1,91 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::fs::symlink; + +use environment_engine::directory::{ + count_entries, list_child_directories, DirectoryCountRequest, DirectoryListRequest, +}; +use environment_engine::path::{inspect_paths, ContentState, PathKind, PathQuery, PathRequest}; + +#[test] +fn path_metadata_classifies_links_and_reads_bounded_content() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("directory"); + let file = temp.path().join("package.json"); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(&file, b"abcdef").unwrap(); + symlink(&directory, temp.path().join("directory-link")).unwrap(); + symlink(&file, temp.path().join("file-link")).unwrap(); + symlink(temp.path().join("missing"), temp.path().join("broken")).unwrap(); + + let response = inspect_paths(&PathRequest { + queries: vec![ + PathQuery { + path: directory, + content_limit: None, + }, + PathQuery { + path: temp.path().join("directory-link"), + content_limit: None, + }, + PathQuery { + path: temp.path().join("file-link"), + content_limit: Some(4), + }, + PathQuery { + path: temp.path().join("broken"), + content_limit: None, + }, + ], + aggregate_content_limit: 8, + }) + .unwrap(); + + assert_eq!(response.facts[0].kind, PathKind::Directory); + assert_eq!(response.facts[1].kind, PathKind::SymlinkDirectory); + assert_eq!(response.facts[2].kind, PathKind::SymlinkOther); + assert_eq!( + response.facts[2].content, + ContentState::Bytes(b"abcd".to_vec()) + ); + assert!(response.facts[2].content_truncated); + assert_eq!(response.facts[3].kind, PathKind::BrokenLink); +} + +#[test] +fn directory_count_isolates_missing_paths_and_caps_entries() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("skills"); + std::fs::create_dir(&directory).unwrap(); + for index in 0..4 { + std::fs::write(directory.join(format!("skill-{index}")), b"skill").unwrap(); + } + + let response = count_entries(&DirectoryCountRequest { + paths: vec![directory, temp.path().join("missing")], + limit: 3, + }) + .unwrap(); + + assert_eq!(response.facts[0].observed_count, Some(3)); + assert!(response.facts[0].truncated); + assert_eq!(response.facts[1].observed_count, None); + assert!(!response.facts[1].truncated); +} + +#[test] +fn child_directory_listing_is_sorted_and_bounded() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("beta")).unwrap(); + std::fs::create_dir(temp.path().join("Alpha")).unwrap(); + std::fs::write(temp.path().join("file"), b"file").unwrap(); + + let response = list_child_directories(&DirectoryListRequest { + path: temp.path().to_path_buf(), + limit: 1, + }) + .unwrap(); + + assert_eq!(response.names, vec![std::path::PathBuf::from("Alpha")]); + assert!(response.truncated); +} diff --git a/src-tauri/crates/environment-engine/tests/payload.rs b/src-tauri/crates/environment-engine/tests/payload.rs new file mode 100644 index 00000000..0fc1a1f5 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/payload.rs @@ -0,0 +1,125 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::fs::{symlink, PermissionsExt}; + +use environment_engine::payload::{ + build_payload, source_metadata_fingerprint, verify_payload, PayloadEntryKind, PayloadError, +}; + +#[test] +fn payload_build_preserves_linux_content_semantics_without_loading_duplicate_blobs() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let payload_root = temp.path().join("payload"); + std::fs::create_dir_all(source.join("scripts")).unwrap(); + std::fs::create_dir_all(source.join("assets")).unwrap(); + std::fs::create_dir_all(source.join(".git")).unwrap(); + std::fs::write(source.join("SKILL.md"), b"skill").unwrap(); + std::fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); + std::fs::write(source.join("assets/copy.txt"), b"skill").unwrap(); + std::fs::write(source.join("metadata.json"), b"excluded").unwrap(); + std::fs::write(source.join(".git/config"), b"excluded").unwrap(); + std::fs::set_permissions( + source.join("scripts/run.sh"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + + let built = build_payload(&source, &payload_root).unwrap(); + + assert_eq!(built.total_bytes, 15); + assert_eq!(built.manifest.payload_id, built.manifest.payload_root_hash); + assert_eq!( + std::fs::read_dir(payload_root.join("blobs")) + .unwrap() + .count(), + 2 + ); + assert!(built.manifest.entries.iter().any(|entry| { + entry.relative_path == "scripts/run.sh" + && entry.kind == PayloadEntryKind::File + && entry.executable + })); + assert!(!built.manifest.entries.iter().any( + |entry| entry.relative_path.contains(".git") || entry.relative_path == "metadata.json" + )); + assert_eq!(verify_payload(&payload_root).unwrap(), built.manifest); +} + +#[test] +fn payload_build_rejects_links_outside_the_selected_source() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(temp.path().join("outside"), b"outside").unwrap(); + symlink(temp.path().join("outside"), source.join("linked")).unwrap(); + + assert!(matches!( + build_payload(&source, &temp.path().join("payload")), + Err(PayloadError::UnsafeSourceLink { .. }) + )); +} + +#[test] +fn source_fingerprint_tracks_linux_metadata_and_rejects_external_links() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let before = source_metadata_fingerprint(&source).unwrap(); + + let mut permissions = std::fs::metadata(source.join("SKILL.md")) + .unwrap() + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(source.join("SKILL.md"), permissions).unwrap(); + let after = source_metadata_fingerprint(&source).unwrap(); + assert_ne!(before, after); + + std::fs::write(temp.path().join("outside"), b"outside").unwrap(); + symlink(temp.path().join("outside"), source.join("external")).unwrap(); + assert!(matches!( + source_metadata_fingerprint(&source), + Err(PayloadError::UnsafeSourceLink { .. }) + )); +} + +#[test] +fn cancelled_payload_build_removes_its_partial_destination() { + use environment_engine::payload::build_payload_with_cancel; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let payload = temp.path().join("payload"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), vec![7_u8; 128 * 1024]).unwrap(); + + let checks = std::cell::Cell::new(0_u32); + let result = build_payload_with_cancel(&source, &payload, || { + checks.set(checks.get() + 1); + checks.get() > 2 + }); + + assert!(matches!(result, Err(PayloadError::Cancelled))); + assert!(!payload.exists()); +} + +#[test] +fn payload_build_rejects_non_utf8_manifest_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write( + source.join(OsString::from_vec(vec![b's', 0xff])), + b"content", + ) + .unwrap(); + + assert!(matches!( + build_payload(&source, &temp.path().join("payload")), + Err(PayloadError::InvalidSource) + )); +} diff --git a/src-tauri/crates/environment-engine/tests/planning.rs b/src-tauri/crates/environment-engine/tests/planning.rs new file mode 100644 index 00000000..c570852d --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/planning.rs @@ -0,0 +1,81 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::fs::{symlink, PermissionsExt}; + +use environment_engine::entry::{inspect_entries, EntryKind, EntryRequest}; +use environment_engine::manifest::{build_manifest, ManifestKind, ManifestRequest}; +use environment_engine::projection::{project_targets, ProjectionRequest}; + +#[test] +fn entry_facts_do_not_follow_the_final_symlink() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + let link = temp.path().join("link"); + std::fs::write(&target, b"first").unwrap(); + symlink(&target, &link).unwrap(); + + let before = inspect_entries(&EntryRequest { + paths: vec![link.clone()], + }) + .unwrap(); + std::fs::write(&target, b"changed target").unwrap(); + let after = inspect_entries(&EntryRequest { paths: vec![link] }).unwrap(); + + assert_eq!(before.facts[0].kind, EntryKind::Symlink); + assert_eq!(before.facts[0], after.facts[0]); +} + +#[test] +fn projection_resolves_existing_ancestors_before_appending_components() { + let temp = tempfile::tempdir().unwrap(); + let physical = temp.path().join("physical"); + let logical = temp.path().join("logical"); + std::fs::create_dir(&physical).unwrap(); + symlink(&physical, &logical).unwrap(); + let destination = logical.join("skills/demo"); + + let response = project_targets(&ProjectionRequest { + destinations: vec![destination.clone()], + }) + .unwrap(); + + assert_eq!( + response.targets[0].physical_destination, + physical.join("skills/demo") + ); + assert_eq!( + response.targets[0].relative_components, + vec![ + std::path::PathBuf::from("skills"), + std::path::PathBuf::from("demo"), + ] + ); + assert!(!destination.exists()); +} + +#[test] +fn manifest_captures_digest_executable_directories_and_symlinks() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill"); + std::fs::create_dir_all(root.join("empty")).unwrap(); + std::fs::write(root.join("run.sh"), b"#!/bin/sh\n").unwrap(); + let mut permissions = std::fs::metadata(root.join("run.sh")) + .unwrap() + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(root.join("run.sh"), permissions).unwrap(); + symlink("run.sh", root.join("current")).unwrap(); + + let response = build_manifest(&ManifestRequest { root }).unwrap(); + + assert!(response.records.iter().any(|record| { + record.relative_path.as_path() == std::path::Path::new("run.sh") + && record.kind == ManifestKind::File + && record.executable + })); + assert!(response.records.iter().any(|record| { + record.relative_path.as_path() == std::path::Path::new("current") + && record.kind == ManifestKind::Symlink + && record.symlink_target.as_deref() == Some(std::path::Path::new("run.sh")) + })); +} diff --git a/src-tauri/crates/environment-engine/tests/source_inventory.rs b/src-tauri/crates/environment-engine/tests/source_inventory.rs new file mode 100644 index 00000000..7b78a515 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/source_inventory.rs @@ -0,0 +1,139 @@ +#![cfg(target_os = "linux")] + +use environment_engine::source_inventory::{ + scan_source, SourceEntryKind, SourceInventoryRequest, SourceRoot, SourceScanMode, +}; + +#[test] +fn saved_skill_metadata_reads_only_its_root_and_prefers_the_exact_name() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("one/two/three/four/five/six/seven"); + std::fs::create_dir_all(root.join("nested")).unwrap(); + std::fs::write(root.join("skill.md"), b"lower").unwrap(); + std::fs::write(root.join("nested/SKILL.md"), b"nested must not be read").unwrap(); + let request = SourceInventoryRequest { + roots: vec![SourceRoot { + path: root.clone(), + stat_only: false, + }], + mode: SourceScanMode::SkillMetadata, + per_file_limit: 16, + aggregate_limit: 16, + }; + let first = scan_source(&request).unwrap(); + assert_eq!(first.entries.len(), 2); + assert_eq!(first.entries[1].content_bytes, b"lower"); + std::fs::write(root.join("SKILL.md"), b"exact").unwrap(); + assert_eq!( + scan_source(&request).unwrap().entries[1].content_bytes, + b"exact" + ); +} + +#[test] +fn saved_skill_metadata_does_not_read_an_external_link() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill"); + std::fs::create_dir(&root).unwrap(); + let external = temp.path().join("external"); + std::fs::write(&external, b"outside data").unwrap(); + std::os::unix::fs::symlink(&external, root.join("SKILL.md")).unwrap(); + let response = scan_source(&SourceInventoryRequest { + roots: vec![SourceRoot { + path: root, + stat_only: false, + }], + mode: SourceScanMode::SkillMetadata, + per_file_limit: 16, + aggregate_limit: 16, + }) + .unwrap(); + assert!(response.entries[1].content_bytes.is_empty()); + assert!(response.entries[1].error.is_some()); +} + +#[test] +fn recursive_inventory_reads_only_source_documents_and_prunes_dependency_trees() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("repo"); + std::fs::create_dir_all(root.join("skills/demo")).unwrap(); + std::fs::create_dir_all(root.join("skills/linked")).unwrap(); + std::fs::create_dir_all(root.join("node_modules/ignored")).unwrap(); + std::fs::create_dir_all(root.join(".claude-plugin")).unwrap(); + std::fs::write(root.join("skills/demo/SKILL.md"), b"demo").unwrap(); + std::fs::write(root.join("linked-document"), b"linked-demo").unwrap(); + std::os::unix::fs::symlink( + root.join("linked-document"), + root.join("skills/linked/SKILL.md"), + ) + .unwrap(); + std::fs::write(root.join("node_modules/ignored/SKILL.md"), b"ignored").unwrap(); + std::fs::write(root.join(".claude-plugin/plugin.json"), b"plugin").unwrap(); + std::fs::write(root.join("skills-lock.json"), b"lock").unwrap(); + std::fs::write(root.join("ordinary.txt"), b"ordinary").unwrap(); + + let response = scan_source(&SourceInventoryRequest { + roots: vec![SourceRoot { + path: root, + stat_only: false, + }], + mode: SourceScanMode::Recursive, + per_file_limit: 16, + aggregate_limit: 64, + }) + .unwrap(); + + let documents = response + .entries + .iter() + .filter(|entry| !entry.relative_path.as_os_str().is_empty()) + .map(|entry| { + ( + entry.relative_path.to_string_lossy().into_owned(), + entry.content_bytes.clone(), + ) + }) + .collect::>(); + assert_eq!(documents["skills/demo/SKILL.md"], b"demo"); + assert_eq!(documents["skills/linked/SKILL.md"], b"linked-demo"); + assert_eq!(documents[".claude-plugin/plugin.json"], b"plugin"); + assert_eq!(documents["skills-lock.json"], b"lock"); + assert!(!documents.contains_key("node_modules/ignored/SKILL.md")); + assert!(!documents.contains_key("ordinary.txt")); + assert_eq!(response.entries[0].kind, SourceEntryKind::Directory); +} + +#[test] +fn priority_inventory_reads_only_direct_child_skill_documents() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("catalog"); + let linked_source = temp.path().join("linked-source"); + std::fs::create_dir_all(root.join("direct/scripts")).unwrap(); + std::fs::create_dir_all(root.join("category/nested")).unwrap(); + std::fs::create_dir_all(&linked_source).unwrap(); + std::fs::write(root.join("direct/skill.MD"), b"direct").unwrap(); + std::fs::write(root.join("direct/scripts/SKILL.md"), b"too-deep").unwrap(); + std::fs::write(root.join("category/nested/SKILL.md"), b"nested").unwrap(); + std::fs::write(linked_source.join("SKILL.md"), b"linked").unwrap(); + symlink(linked_source, root.join("linked")).unwrap(); + + let response = scan_source(&SourceInventoryRequest { + roots: vec![SourceRoot { + path: root, + stat_only: false, + }], + mode: SourceScanMode::PriorityDirectories, + per_file_limit: 16, + aggregate_limit: 64, + }) + .unwrap(); + + let relative_paths = response + .entries + .iter() + .map(|entry| entry.relative_path.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!(relative_paths, vec!["", "direct/skill.MD"]); +} diff --git a/src-tauri/crates/environment-protocol/Cargo.toml b/src-tauri/crates/environment-protocol/Cargo.toml new file mode 100644 index 00000000..370a95e6 --- /dev/null +++ b/src-tauri/crates/environment-protocol/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "environment-protocol" +version = "0.0.0" +edition = "2021" +rust-version = "1.88.0" +publish = false + +[dependencies] +bytes = "1" +futures-util = { version = "0.3", features = ["sink"] } +postcard = { version = "1.1.3", features = ["use-std"] } +serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" +sha2 = "0.10.9" +thiserror = "2" +tokio = { version = "1", features = ["io-util", "macros", "rt", "sync"] } +tokio-util = { version = "0.7", features = ["codec"] } + +[dev-dependencies] +tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread", "time"] } diff --git a/src-tauri/crates/environment-protocol/src/lib.rs b/src-tauri/crates/environment-protocol/src/lib.rs new file mode 100644 index 00000000..0c58a1a1 --- /dev/null +++ b/src-tauri/crates/environment-protocol/src/lib.rs @@ -0,0 +1,1186 @@ +use std::io; +use std::sync::Arc; + +use bytes::Bytes; +use futures_util::SinkExt; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::task::JoinHandle; +use tokio_util::codec::{FramedWrite, LengthDelimitedCodec}; + +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; +pub const MAX_PAYLOAD_CHUNK_BYTES: usize = 256 * 1024; +pub const MAX_RESPONSE_TRANSFER_BYTES: usize = 36 * 1024 * 1024; +pub const MAX_PAYLOAD_TRANSFER_BYTES: usize = 256 * 1024 * 1024; +pub const MAX_MUTATION_TRANSFER_BYTES: usize = 36 * 1024 * 1024; +pub const MAX_CONCURRENT_READ_REQUESTS: usize = 8; +pub const MAX_PENDING_READ_REQUESTS: usize = 64; +pub const MAX_INSPECTION_ROOTS: usize = 256; +pub const MAX_INSPECTION_FACTS: usize = 65_536; +pub const MAX_INSPECTION_CONTENT_BYTES: u32 = 8 * 1024 * 1024; +pub const MAX_PATH_CONTENT_BYTES_PER_FILE: u32 = 1024 * 1024; +pub const MAX_DIRECTORY_COUNT_LIMIT: u32 = 10_000; +pub const MAX_DOCUMENT_BYTES: u32 = 16 * 1024 * 1024; +pub const MAX_MANIFEST_RECORDS: usize = 262_144; +pub const MAX_REQUEST_DEADLINE_MILLIS: u64 = 120_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum WireRecord { + Control(Envelope), + PayloadChunk { + transfer_id: u64, + #[serde(with = "serde_bytes")] + bytes: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Envelope { + pub request_id: u64, + pub message: Message, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PathKind { + Missing, + File, + Directory, + SymlinkDirectory, + SymlinkOther, + BrokenLink, + Other, + Inaccessible, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionRequest { + pub roots: Vec, + pub read_content: bool, + pub per_file_limit: u32, + pub aggregate_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionRoot { + pub path: String, + pub stat_only: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InspectionEntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InspectionErrorCode { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionFact { + pub root_index: u32, + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub kind: InspectionEntryKind, + pub resolved_target: Option>, + pub fingerprint: Option, + #[serde(with = "serde_bytes")] + pub content_bytes: Vec, + pub truncated: bool, + pub error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataRequest { + pub queries: Vec, + pub aggregate_content_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataQuery { + pub path: String, + pub content_limit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PathMetadataKind { + Missing, + Directory, + SymlinkDirectory, + SymlinkOther, + Other, + BrokenLink, + Inaccessible, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PathMetadataContent { + NotRequested, + Empty, + Unreadable, + Bytes(#[serde(with = "serde_bytes")] Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataFact { + pub path: String, + pub kind: PathMetadataKind, + pub content: PathMetadataContent, + pub content_truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryCountRequest { + pub paths: Vec, + pub limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryCountFact { + pub path: String, + pub observed_count: Option, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryCountResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadRequest { + pub queries: Vec, + pub aggregate_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadQuery { + pub path: String, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum DocumentReadState { + Missing, + NotFile, + Unreadable, + Bytes(#[serde(with = "serde_bytes")] Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadFact { + pub path: String, + pub state: DocumentReadState, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentWritePreparation { + pub path: String, + pub expected_revision: Option, + pub total_bytes: u64, + pub sha256: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentRemoveRequest { + pub path: String, + pub expected_revision: Option, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryCatalogResponse { + #[serde(with = "serde_bytes")] + pub bytes: Vec, + pub present: bool, + pub revision: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryApplicationIndex { + pub project_ids: Vec, + pub problem_keys: Vec, + pub complete: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryOperationPreparation { + pub total_bytes: u64, + pub sha256: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryOperationRequest { + pub operation_id: String, + pub expected_catalog_revision: Option, + #[serde(with = "serde_bytes")] + pub catalog_bytes: Vec, + pub action: LibraryOperationAction, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum LibraryOperationAction { + SaveCatalog { + library_ids: Vec, + }, + CommitMember { + library_id: String, + skill_name: String, + expected_anchor_device: u64, + expected_anchor_inode: u64, + expected_fingerprint: String, + expected_content_hash: Option, + mutation: LibraryMemberAction, + }, + DeleteLibrary { + library_id: String, + expected_anchor_device: u64, + expected_anchor_inode: u64, + expected_fingerprint: String, + expected_content_hash: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum LibraryMemberAction { + Upsert { payload_id: u64 }, + Delete, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryListRequest { + pub path: String, + pub limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryListResponse { + pub names: Vec>, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapWindowsPathsRequest { + pub paths: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapWindowsPathsResponse { + pub mapped: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapHostPathsRequest { + pub paths: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapHostPathsResponse { + pub mapped: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFactsRequest { + pub paths: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntryFactKind { + Missing, + File, + Directory, + Symlink, + BrokenLink, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryMetadata { + pub device: u64, + pub inode: u64, + pub mode: u32, + pub size: u64, + pub mtime_seconds: i64, + pub mtime_nanos: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFact { + pub kind: EntryFactKind, + pub metadata: Option, + pub link_target: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFactsResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectionRequest { + pub destinations: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectedTarget { + pub anchor_device: u64, + pub anchor_inode: u64, + pub physical_destination: Vec, + pub relative_components: Vec>, + pub storage_projection: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectionResponse { + pub targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WriteProbeRequest { + pub destinations: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WriteProbeResponse { + pub checked_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestRequest { + pub root: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ManifestRecordKind { + Directory, + File, + Symlink, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestRecord { + pub relative_path: Vec, + pub kind: ManifestRecordKind, + pub digest: Option, + pub executable: bool, + pub symlink_target: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestResponse { + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitSourceRequest { + pub url: String, + pub git_ref: Option, + pub proxy: Option, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpenLocalSourceRequest { + pub path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceScanMode { + Recursive, + PriorityDirectories, + SkillMetadata, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceScanRoot { + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub stat_only: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceScanRequest { + pub source_id: u64, + pub roots: Vec, + pub mode: SourceScanMode, + pub per_file_limit: u32, + pub aggregate_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceEntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceEntryErrorCode { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceEntry { + pub root_index: u32, + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub kind: SourceEntryKind, + pub link_target: Option>, + #[serde(with = "serde_bytes")] + pub content_bytes: Vec, + pub truncated: bool, + pub error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceScanResponse { + pub entries: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PayloadEntryKind { + File, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadEntry { + pub relative_path: String, + pub kind: PayloadEntryKind, + pub blob_id: Option, + pub content_hash: Option, + pub size: u64, + pub executable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadManifest { + pub entries: Vec, + pub payload_root_hash: String, + pub payload_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AcquirePayloadFromSourceRequest { + pub source_id: u64, + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub session_id: String, + pub payload_name: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifyPayloadRequest { + pub session_id: String, + pub payload_name: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayloadReadyResponse { + pub payload_id: u64, + pub manifest: PayloadManifest, + pub total_bytes: u64, + pub computed_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayloadCleanupResponse { + pub removed_sessions: u32, + pub protected_sessions: u32, + pub retained_external_bytes: u64, + pub cleanup_blocked: bool, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayloadCleanupWarning { + pub code: String, + pub candidate_name: Option, + pub technical_details: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationUnitRequest { + pub resource_id: String, + pub operation_id: String, + pub unit_id: String, + #[serde(with = "serde_bytes")] + pub initial_marker_json: Vec, + pub entries: Vec, + pub lock: Option, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationEntry { + pub destination: String, + pub expected_anchor_device: u64, + pub expected_anchor_inode: u64, + pub expected_fingerprint: String, + pub expected_content_hash: Option, + pub action: MutationEntryAction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationEntryAction { + Keep, + Materialize { payload_id: u64 }, + Symlink { target: String }, + Remove, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationLockSchema { + Global, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationLockEntry { + Replace { + key: String, + #[serde(with = "serde_bytes")] + replacement_json: Vec, + }, + Remove { + key: String, + }, + MoveAndReplace { + from: String, + to: String, + #[serde(with = "serde_bytes")] + replacement_json: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationLock { + pub target: String, + pub legacy_target: Option, + pub schema: MutationLockSchema, + pub entry: MutationLockEntry, + pub root_replacements_json: std::collections::BTreeMap>, + pub expected_entries_json: std::collections::BTreeMap>>, + pub expected_roots_json: std::collections::BTreeMap>>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationLockReceipt { + pub entries_json: std::collections::BTreeMap>>, + pub roots_json: std::collections::BTreeMap>>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationUnitOutcome { + Succeeded { + lock: Option, + cleanup: Option, + }, + Failed { + code: String, + phase: String, + parameters: Vec<(String, String)>, + message: String, + }, + Cancelled, + RecoveryRequired { + resource_id: String, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationCleanupToken { + pub resource_id: String, + pub marker_sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationRecoveryState { + Present, + Unreadable, + Unsafe, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationRecoveryRecord { + pub resource_id: String, + pub managed_root: String, + pub state: MutationRecoveryState, + #[serde(with = "serde_bytes")] + pub marker_bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationRecoveryList { + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Message { + Handshake { + build_id: String, + }, + HandshakeResult { + build_id: String, + distro: String, + user: String, + uid: u32, + home: String, + }, + Progress { + current: u32, + total: u32, + }, + Cancel { + target_request_id: u64, + }, + BeginTransfer { + transfer_id: u64, + total_bytes: u64, + sha256: String, + owner_request_id: u64, + }, + TransferCompleted { + transfer_id: u64, + total_bytes: u64, + sha256: String, + }, + Error { + code: String, + phase: String, + parameters: Vec<(String, String)>, + }, + Shutdown, + ObservePath { + path: String, + }, + PathObserved { + kind: PathKind, + }, + InspectFilesystem { + request: InspectionRequest, + }, + InspectPaths { + request: PathMetadataRequest, + }, + CountDirectoryEntries { + request: DirectoryCountRequest, + }, + ReadDocuments { + request: DocumentReadRequest, + }, + PrepareDocumentWrite { + request: DocumentWritePreparation, + }, + DocumentWritten { + revision: String, + }, + RemoveDocument { + request: DocumentRemoveRequest, + }, + DocumentRemoved, + ReadLibraryCatalog { + deadline_millis: u64, + }, + ListLibraryApplications { + deadline_millis: u64, + }, + PrepareLibraryOperation { + request: LibraryOperationPreparation, + }, + LibraryOperationCompleted { + catalog_revision: String, + }, + ListChildDirectories { + request: DirectoryListRequest, + }, + MapPathsToWindows { + request: MapWindowsPathsRequest, + }, + InspectEntries { + request: EntryFactsRequest, + }, + ProjectTargets { + request: ProjectionRequest, + }, + ProbeWriteTargets { + request: WriteProbeRequest, + }, + BuildManifest { + request: ManifestRequest, + }, + AcquireGitSource { + request: GitSourceRequest, + }, + OpenLocalSource { + request: OpenLocalSourceRequest, + }, + SourceOpened { + source_id: u64, + root: String, + revision: Option, + managed_bytes: u64, + }, + ReleaseSource { + source_id: u64, + }, + SourceReleased { + source_id: u64, + }, + ScanSource { + request: SourceScanRequest, + }, + SourceFingerprint { + source_id: u64, + #[serde(with = "serde_bytes")] + relative_path: Vec, + deadline_millis: u64, + }, + SourceFingerprintResult { + fingerprint: String, + }, + SourceRevision { + source_id: u64, + #[serde(with = "serde_bytes")] + relative_path: Vec, + deadline_millis: u64, + }, + SourceRevisionResult { + revision: String, + }, + ProbeGit { + request: GitSourceRequest, + }, + GitProbed { + revision: String, + }, + AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest, + }, + VerifyPayload { + request: VerifyPayloadRequest, + }, + ReadPayloadBlob { + payload_id: u64, + blob_id: String, + deadline_millis: u64, + }, + RemovePayload { + session_id: String, + payload_name: String, + }, + PayloadRemoved { + session_id: String, + payload_name: String, + }, + RemovePayloadSession { + session_id: String, + }, + PayloadSessionRemoved { + session_id: String, + }, + SweepPayloadOrphans { + protected_session_ids: Vec, + }, + BeginPayloadUpload { + session_id: String, + payload_name: String, + }, + PayloadUploadBegun { + upload_id: u64, + }, + UploadPayloadBlob { + upload_id: u64, + blob_id: String, + total_bytes: u64, + sha256: String, + }, + FinalizePayloadUpload { + upload_id: u64, + total_bytes: u64, + sha256: String, + }, + TransferReady { + transfer_id: u64, + }, + PayloadBlobUploaded { + upload_id: u64, + blob_id: String, + }, + PayloadUploadFinalized { + payload_id: u64, + }, + PrepareMutationUnit { + resource_id: String, + total_bytes: u64, + sha256: String, + }, + MutationAccepted { + resource_id: String, + }, + AcknowledgeMutationUnit { + cleanup: MutationCleanupToken, + }, + MutationAcknowledged { + resource_id: String, + }, + ListMutationRecovery, + CleanupMutationRecovery { + resource_id: String, + #[serde(with = "serde_bytes")] + expected_marker_json: Vec, + backups: Vec, + }, + MutationRecoveryCleaned { + resource_id: String, + }, + MapHostPaths { + request: MapHostPathsRequest, + }, +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + #[error("invalid postcard record: {0}")] + Postcard(#[from] postcard::Error), + #[error("wire record contains {count} trailing bytes")] + TrailingBytes { count: usize }, +} + +pub fn encode(record: &WireRecord) -> Result, postcard::Error> { + postcard::to_stdvec(record) +} + +pub fn decode(bytes: &[u8]) -> Result { + let (record, remaining) = postcard::take_from_bytes(bytes)?; + if !remaining.is_empty() { + return Err(DecodeError::TrailingBytes { + count: remaining.len(), + }); + } + Ok(record) +} + +pub fn encode_inspection_response( + response: &InspectionResponse, +) -> Result, postcard::Error> { + postcard::to_stdvec(response) +} + +pub fn encode_payload(value: &T) -> Result, postcard::Error> +where + T: Serialize, +{ + postcard::to_stdvec(value) +} + +pub fn decode_payload(bytes: &[u8]) -> Result +where + T: DeserializeOwned, +{ + let (value, remaining) = postcard::take_from_bytes(bytes)?; + if !remaining.is_empty() { + return Err(DecodeError::TrailingBytes { + count: remaining.len(), + }); + } + Ok(value) +} + +pub fn decode_inspection_response(bytes: &[u8]) -> Result { + decode_payload(bytes) +} + +pub fn codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_BYTES) + .new_codec() +} + +#[derive(Debug, thiserror::Error)] +pub enum WriterError { + #[error("protocol writer is closed")] + Closed, + #[error("failed to encode protocol record: {0}")] + Encode(#[from] postcard::Error), + #[error("failed to write protocol record: {0}")] + Io(#[from] io::Error), + #[error("binary transfer exceeds its boundary")] + TransferTooLarge, + #[error("binary transfer content does not match its declaration")] + TransferMismatch, +} + +#[derive(Clone)] +pub struct ProtocolWriter { + control: mpsc::Sender, + binary: mpsc::Sender, + transfer_gate: Arc>, +} + +enum BinaryRecord { + Record(WireRecord), + Barrier(WireRecord, oneshot::Sender<()>), +} + +impl ProtocolWriter { + pub async fn send_control(&self, record: WireRecord) -> Result<(), WriterError> { + self.control + .send(record) + .await + .map_err(|_| WriterError::Closed) + } + + pub fn try_send_control(&self, record: WireRecord) -> bool { + self.control.try_send(record).is_ok() + } + + pub async fn send_binary(&self, record: WireRecord) -> Result<(), WriterError> { + self.binary + .send(BinaryRecord::Record(record)) + .await + .map_err(|_| WriterError::Closed) + } + + pub async fn send_binary_barrier(&self, record: WireRecord) -> Result<(), WriterError> { + let (written_tx, written_rx) = oneshot::channel(); + self.binary + .send(BinaryRecord::Barrier(record, written_tx)) + .await + .map_err(|_| WriterError::Closed)?; + written_rx.await.map_err(|_| WriterError::Closed) + } + + pub async fn send_transfer( + &self, + owner_request_id: u64, + transfer_id: u64, + payload: &[u8], + ) -> Result<(), WriterError> { + self.send_transfer_with_limit( + owner_request_id, + transfer_id, + payload, + MAX_RESPONSE_TRANSFER_BYTES, + ) + .await + } + + pub async fn send_transfer_with_limit( + &self, + owner_request_id: u64, + transfer_id: u64, + payload: &[u8], + transfer_limit: usize, + ) -> Result<(), WriterError> { + if payload.len() > transfer_limit || payload.len() > MAX_PAYLOAD_TRANSFER_BYTES { + return Err(WriterError::TransferTooLarge); + } + let _guard = self.transfer_gate.lock().await; + let total_bytes = payload.len() as u64; + let sha256 = format!("sha256:{:x}", Sha256::digest(payload)); + self.send_binary(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::BeginTransfer { + transfer_id, + total_bytes, + sha256: sha256.clone(), + owner_request_id, + }, + })) + .await?; + for chunk in payload.chunks(MAX_PAYLOAD_CHUNK_BYTES) { + self.send_binary(WireRecord::PayloadChunk { + transfer_id, + bytes: chunk.to_vec(), + }) + .await?; + } + self.send_binary_barrier(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + }, + })) + .await + } + + pub async fn send_reader_transfer_with_limit( + &self, + owner_request_id: u64, + transfer_id: u64, + mut reader: R, + total_bytes: u64, + sha256: String, + transfer_limit: usize, + ) -> Result<(), WriterError> + where + R: AsyncRead + Unpin, + { + if total_bytes > transfer_limit as u64 || total_bytes > MAX_PAYLOAD_TRANSFER_BYTES as u64 { + return Err(WriterError::TransferTooLarge); + } + let _guard = self.transfer_gate.lock().await; + self.send_binary(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::BeginTransfer { + transfer_id, + total_bytes, + sha256: sha256.clone(), + owner_request_id, + }, + })) + .await?; + let mut hasher = Sha256::new(); + let mut received = 0_u64; + let mut buffer = vec![0_u8; MAX_PAYLOAD_CHUNK_BYTES]; + while received < total_bytes { + let remaining = usize::try_from(total_bytes - received) + .unwrap_or(usize::MAX) + .min(buffer.len()); + let read = reader.read(&mut buffer[..remaining]).await?; + if read == 0 { + return Err(WriterError::TransferMismatch); + } + received += read as u64; + hasher.update(&buffer[..read]); + self.send_binary(WireRecord::PayloadChunk { + transfer_id, + bytes: buffer[..read].to_vec(), + }) + .await?; + } + let mut extra = [0_u8; 1]; + if reader.read(&mut extra).await? != 0 + || format!("sha256:{:x}", hasher.finalize()) != sha256 + { + return Err(WriterError::TransferMismatch); + } + self.send_binary_barrier(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + }, + })) + .await + } +} + +pub fn spawn_writer(output: W) -> (ProtocolWriter, JoinHandle>) +where + W: AsyncWrite + Unpin + Send + 'static, +{ + let (control_tx, mut control_rx) = mpsc::channel(64); + let (binary_tx, mut binary_rx) = mpsc::channel(8); + let task = tokio::spawn(async move { + let mut sink = FramedWrite::new(output, codec()); + let mut control_open = true; + let mut binary_open = true; + let mut control_first = true; + while control_open || binary_open { + let selected = if control_first { + tokio::select! { + biased; + value = control_rx.recv(), if control_open => match value { + Some(record) => Some((record, None)), + None => { control_open = false; None } + }, + value = binary_rx.recv(), if binary_open => match value { + Some(BinaryRecord::Record(record)) => Some((record, None)), + Some(BinaryRecord::Barrier(record, written)) => Some((record, Some(written))), + None => { binary_open = false; None } + }, + } + } else { + tokio::select! { + biased; + value = binary_rx.recv(), if binary_open => match value { + Some(BinaryRecord::Record(record)) => Some((record, None)), + Some(BinaryRecord::Barrier(record, written)) => Some((record, Some(written))), + None => { binary_open = false; None } + }, + value = control_rx.recv(), if control_open => match value { + Some(record) => Some((record, None)), + None => { control_open = false; None } + }, + } + }; + let Some((record, written)) = selected else { + continue; + }; + control_first = !control_first; + sink.send(Bytes::from(encode(&record)?)).await?; + if let Some(written) = written { + let _ = written.send(()); + } + } + sink.close().await?; + Ok(()) + }); + ( + ProtocolWriter { + control: control_tx, + binary: binary_tx, + transfer_gate: Arc::new(Mutex::new(())), + }, + task, + ) +} diff --git a/src-tauri/crates/environment-protocol/tests/protocol.rs b/src-tauri/crates/environment-protocol/tests/protocol.rs new file mode 100644 index 00000000..0bb70d6f --- /dev/null +++ b/src-tauri/crates/environment-protocol/tests/protocol.rs @@ -0,0 +1,559 @@ +use std::time::Duration; + +use bytes::{Bytes, BytesMut}; +use environment_protocol::{ + codec, decode, decode_inspection_response, encode, encode_inspection_response, spawn_writer, + AcquirePayloadFromSourceRequest, DecodeError, DocumentWritePreparation, Envelope, + InspectionEntryKind, InspectionErrorCode, InspectionFact, InspectionResponse, + MapHostPathsRequest, MapHostPathsResponse, Message, OpenLocalSourceRequest, PathKind, + SourceScanMode, SourceScanRequest, SourceScanRoot, WireRecord, MAX_FRAME_BYTES, + MAX_PAYLOAD_CHUNK_BYTES, +}; +use futures_util::StreamExt; +use sha2::Digest; +use tokio_util::codec::{Decoder, Encoder, FramedRead}; + +#[test] +fn strict_postcard_wire_format_round_trips_known_literals() { + let shutdown = WireRecord::Control(Envelope { + request_id: 7, + message: Message::Shutdown, + }); + assert_eq!(encode(&shutdown).unwrap(), [0, 7, 7]); + assert_eq!(decode(&[0, 7, 7]).unwrap(), shutdown); + + let chunk = WireRecord::PayloadChunk { + transfer_id: 42, + bytes: vec![0, 255, 1], + }; + assert_eq!(encode(&chunk).unwrap(), [1, 42, 3, 0, 255, 1]); + assert_eq!(decode(&[1, 42, 3, 0, 255, 1]).unwrap(), chunk); +} + +#[test] +fn strict_decoder_rejects_truncated_trailing_and_oversized_frames() { + let encoded = encode(&WireRecord::Control(Envelope { + request_id: 1, + message: Message::Shutdown, + })) + .unwrap(); + + assert!(matches!( + decode(&encoded[..encoded.len() - 1]), + Err(DecodeError::Postcard(_)) + )); + + let mut trailing = encoded; + trailing.push(0); + assert_eq!( + decode(&trailing), + Err(DecodeError::TrailingBytes { count: 1 }) + ); + + let mut framed = BytesMut::new(); + framed.extend_from_slice(&((MAX_FRAME_BYTES + 1) as u32).to_be_bytes()); + framed.resize(MAX_FRAME_BYTES + 5, 0); + assert!(codec().decode(&mut framed).is_err()); +} + +#[test] +fn path_observation_messages_round_trip_without_changing_existing_literals() { + let request = WireRecord::Control(Envelope { + request_id: 8, + message: Message::ObservePath { + path: "/home/alice".to_string(), + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let response = WireRecord::Control(Envelope { + request_id: 8, + message: Message::PathObserved { + kind: PathKind::Directory, + }, + }); + assert_eq!(decode(&encode(&response).unwrap()).unwrap(), response); + + assert_eq!( + encode(&WireRecord::Control(Envelope { + request_id: 7, + message: Message::Shutdown, + })) + .unwrap(), + [0, 7, 7] + ); +} + +#[test] +fn host_path_mapping_messages_round_trip_in_input_order() { + let request = WireRecord::Control(Envelope { + request_id: 9, + message: Message::MapHostPaths { + request: MapHostPathsRequest { + paths: vec![ + r"C:\Code\Skill Deck".to_string(), + r"\\server\share\项目".to_string(), + ], + deadline_millis: 10_000, + }, + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let response = MapHostPathsResponse { + mapped: vec![ + "/mnt/c/Code/Skill Deck".to_string(), + "/mnt/server/share/项目".to_string(), + ], + }; + let encoded = environment_protocol::encode_payload(&response).unwrap(); + assert_eq!( + environment_protocol::decode_payload::(&encoded).unwrap(), + response + ); +} + +#[test] +fn inspection_payload_round_trips_raw_posix_path_bytes() { + let response = InspectionResponse { + facts: vec![InspectionFact { + root_index: 2, + relative_path: vec![b's', b'k', 0x80], + kind: InspectionEntryKind::Symlink, + resolved_target: Some(vec![b'.', b'.', b'/', 0xff]), + fingerprint: Some("entry-v1-test".to_string()), + content_bytes: vec![0, 255, 1], + truncated: true, + error_code: Some(InspectionErrorCode::ReadLinkFailed), + }], + total_content_bytes: 3, + }; + + let encoded = encode_inspection_response(&response).unwrap(); + assert_eq!(decode_inspection_response(&encoded).unwrap(), response); + + let mut trailing = encoded; + trailing.push(0); + assert!(matches!( + decode_inspection_response(&trailing), + Err(DecodeError::TrailingBytes { count: 1 }) + )); +} + +#[test] +fn source_requests_keep_paths_relative_to_the_owning_handle() { + let open = WireRecord::Control(Envelope { + request_id: 51, + message: Message::OpenLocalSource { + request: OpenLocalSourceRequest { + path: "/home/alice/skills".to_string(), + }, + }, + }); + assert_eq!(decode(&encode(&open).unwrap()).unwrap(), open); + + let scan = WireRecord::Control(Envelope { + request_id: 52, + message: Message::ScanSource { + request: SourceScanRequest { + source_id: 9, + roots: vec![SourceScanRoot { + relative_path: vec![b's', b'k', 0x80], + stat_only: false, + }], + mode: SourceScanMode::Recursive, + per_file_limit: 1024, + aggregate_limit: 4096, + deadline_millis: 30_000, + }, + }, + }); + assert_eq!(decode(&encode(&scan).unwrap()).unwrap(), scan); +} + +#[test] +fn payload_requests_bind_storage_to_a_source_handle_and_managed_key() { + let request = WireRecord::Control(Envelope { + request_id: 61, + message: Message::AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest { + source_id: 9, + relative_path: b"skills/demo".to_vec(), + session_id: "session-1".to_string(), + payload_name: "payload-demo".to_string(), + deadline_millis: 60_000, + }, + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let upload = WireRecord::Control(Envelope { + request_id: 62, + message: Message::UploadPayloadBlob { + upload_id: 4, + blob_id: "a".repeat(64), + total_bytes: 128, + sha256: format!("sha256:{}", "a".repeat(64)), + }, + }); + assert_eq!(decode(&encode(&upload).unwrap()).unwrap(), upload); +} + +#[test] +fn document_write_messages_round_trip_revision_and_transfer_evidence() { + let request = WireRecord::Control(Envelope { + request_id: 63, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: "/home/alice/.skill-deck/projects.json".to_string(), + expected_revision: Some(format!("sha256:{}", "a".repeat(64))), + total_bytes: 128, + sha256: format!("sha256:{}", "b".repeat(64)), + deadline_millis: 30_000, + }, + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let response = WireRecord::Control(Envelope { + request_id: 63, + message: Message::DocumentWritten { + revision: format!("sha256:{}", "b".repeat(64)), + }, + }); + assert_eq!(decode(&encode(&response).unwrap()).unwrap(), response); + + let remove = WireRecord::Control(Envelope { + request_id: 64, + message: Message::RemoveDocument { + request: environment_protocol::DocumentRemoveRequest { + path: "/home/alice/.skill-deck/application.json".to_string(), + expected_revision: Some(format!("sha256:{}", "b".repeat(64))), + deadline_millis: 30_000, + }, + }, + }); + assert_eq!(decode(&encode(&remove).unwrap()).unwrap(), remove); +} + +#[test] +fn mutation_request_round_trips_one_unit_with_payload_handle_and_lock_evidence() { + let request = environment_protocol::MutationUnitRequest { + resource_id: "a".repeat(64), + operation_id: "operation-1".to_string(), + unit_id: "unit-1".to_string(), + initial_marker_json: br#"{"kind":"inProgress"}"#.to_vec(), + entries: vec![environment_protocol::MutationEntry { + destination: "/home/alice/.agents/skills/demo".to_string(), + expected_anchor_device: 1, + expected_anchor_inode: 2, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: environment_protocol::MutationEntryAction::Materialize { payload_id: 9 }, + }], + lock: Some(environment_protocol::MutationLock { + target: "/home/alice/.agents/skills-lock.json".to_string(), + legacy_target: None, + schema: environment_protocol::MutationLockSchema::Global, + entry: environment_protocol::MutationLockEntry::Remove { + key: "demo".to_string(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"old"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + + let encoded = environment_protocol::encode_payload(&request).unwrap(); + assert_eq!( + environment_protocol::decode_payload::(&encoded) + .unwrap(), + request + ); +} + +#[test] +fn library_operation_round_trips_opaque_catalog_and_payload_handle() { + let request = environment_protocol::LibraryOperationRequest { + operation_id: "library-operation-1".to_string(), + expected_catalog_revision: Some(format!("sha256:{}", "a".repeat(64))), + catalog_bytes: br#"{"schemaVersion":1}"#.to_vec(), + action: environment_protocol::LibraryOperationAction::CommitMember { + library_id: "library-1".to_string(), + skill_name: "demo".to_string(), + expected_anchor_device: 11, + expected_anchor_inode: 22, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + mutation: environment_protocol::LibraryMemberAction::Upsert { payload_id: 9 }, + }, + deadline_millis: 60_000, + }; + let encoded = environment_protocol::encode_payload(&request).unwrap(); + assert_eq!( + environment_protocol::decode_payload::( + &encoded, + ) + .unwrap(), + request + ); + + let read = WireRecord::Control(Envelope { + request_id: 70, + message: Message::ReadLibraryCatalog { + deadline_millis: 30_000, + }, + }); + assert_eq!(decode(&encode(&read).unwrap()).unwrap(), read); +} + +#[tokio::test] +async fn one_writer_preserves_binary_order_and_acks_written_barriers() { + let (writer_side, reader_side) = tokio::io::duplex(128); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 9, + bytes: vec![1; 1024], + }) + .await + .unwrap(); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Progress { + current: 1, + total: 2, + }, + })) + .await + .unwrap(); + + let barrier_writer = writer.clone(); + let barrier = tokio::spawn(async move { + barrier_writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id: 9, + message: Message::TransferCompleted { + transfer_id: 9, + total_bytes: 1024, + sha256: "digest".to_string(), + }, + })) + .await + }); + + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(!barrier.is_finished()); + + let mut records = Vec::new(); + while records.len() < 3 { + let frame = reader.next().await.unwrap().unwrap(); + records.push(decode(&frame).unwrap()); + } + barrier.await.unwrap().unwrap(); + + let chunk_index = records + .iter() + .position(|record| matches!(record, WireRecord::PayloadChunk { transfer_id: 9, .. })) + .unwrap(); + let completion_index = records + .iter() + .position(|record| { + matches!( + record, + WireRecord::Control(Envelope { + message: Message::TransferCompleted { transfer_id: 9, .. }, + .. + }) + ) + }) + .unwrap(); + assert!(chunk_index < completion_index); + drop(writer); + writer_task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn one_transfer_is_chunked_and_bound_to_its_owner_request() { + let (writer_side, reader_side) = tokio::io::duplex(MAX_PAYLOAD_CHUNK_BYTES * 3); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + let payload = vec![0x5a; MAX_PAYLOAD_CHUNK_BYTES * 2 + 7]; + + writer.send_transfer(41, 73, &payload).await.unwrap(); + drop(writer); + + let mut records = Vec::new(); + while let Some(frame) = reader.next().await { + records.push(decode(&frame.unwrap()).unwrap()); + } + writer_task.await.unwrap().unwrap(); + + assert!(matches!( + &records[0], + WireRecord::Control(Envelope { + request_id: 41, + message: Message::BeginTransfer { + transfer_id: 73, + owner_request_id: 41, + total_bytes, + .. + }, + }) if *total_bytes == payload.len() as u64 + )); + let chunks = records[1..records.len() - 1] + .iter() + .map(|record| match record { + WireRecord::PayloadChunk { + transfer_id: 73, + bytes, + } => bytes.as_slice(), + other => panic!("unexpected transfer record: {other:?}"), + }) + .collect::>(); + assert_eq!(chunks.len(), 3); + assert!(chunks + .iter() + .all(|chunk| chunk.len() <= MAX_PAYLOAD_CHUNK_BYTES)); + assert_eq!(chunks.concat(), payload); + assert!(matches!( + records.last().unwrap(), + WireRecord::Control(Envelope { + request_id: 41, + message: Message::TransferCompleted { + transfer_id: 73, + total_bytes, + .. + }, + }) if *total_bytes == payload.len() as u64 + )); +} + +#[tokio::test] +async fn transfer_writer_enforces_the_callers_boundary() { + let (writer_side, _reader_side) = tokio::io::duplex(128); + let (writer, writer_task) = spawn_writer(writer_side); + + let error = writer + .send_transfer_with_limit(1, 1, &[0; 16], 8) + .await + .unwrap_err(); + assert!(matches!( + error, + environment_protocol::WriterError::TransferTooLarge + )); + drop(writer); + writer_task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn reader_transfer_streams_a_declared_blob_without_changing_the_wire_shape() { + let (writer_side, reader_side) = tokio::io::duplex(4096); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + let content = b"streamed-blob"; + let digest = format!("sha256:{:x}", sha2::Sha256::digest(content)); + + writer + .send_reader_transfer_with_limit( + 81, + 82, + std::io::Cursor::new(content), + content.len() as u64, + digest, + 1024, + ) + .await + .unwrap(); + drop(writer); + + let mut records = Vec::new(); + while let Some(frame) = reader.next().await { + records.push(decode(&frame.unwrap()).unwrap()); + } + writer_task.await.unwrap().unwrap(); + assert!(matches!( + records.as_slice(), + [ + WireRecord::Control(Envelope { + message: Message::BeginTransfer { transfer_id: 82, .. }, + .. + }), + WireRecord::PayloadChunk { transfer_id: 82, bytes }, + WireRecord::Control(Envelope { + message: Message::TransferCompleted { transfer_id: 82, .. }, + .. + }) + ] if bytes == content + )); +} + +#[tokio::test] +async fn continuous_control_messages_do_not_starve_binary_records() { + let (writer_side, reader_side) = tokio::io::duplex(4); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Progress { + current: 1, + total: 10, + }, + })) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + for request_id in 2..=10 { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Progress { + current: request_id as u32, + total: 10, + }, + })) + .await + .unwrap(); + } + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 99, + bytes: vec![9], + }) + .await + .unwrap(); + + let first = decode(&reader.next().await.unwrap().unwrap()).unwrap(); + let second = decode(&reader.next().await.unwrap().unwrap()).unwrap(); + assert!(matches!(first, WireRecord::Control(_))); + assert!(matches!( + second, + WireRecord::PayloadChunk { + transfer_id: 99, + .. + } + )); + + drop(writer); + while reader.next().await.is_some() {} + writer_task.await.unwrap().unwrap(); +} + +#[test] +fn frame_encoder_accepts_the_largest_valid_record() { + let mut codec = codec(); + let mut output = BytesMut::new(); + codec + .encode(Bytes::from(vec![0; MAX_FRAME_BYTES]), &mut output) + .unwrap(); + assert_eq!(output.len(), MAX_FRAME_BYTES + 4); +} diff --git a/src-tauri/crates/wsl-environment-worker/Cargo.toml b/src-tauri/crates/wsl-environment-worker/Cargo.toml new file mode 100644 index 00000000..64c3c554 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "wsl-environment-worker" +version = "0.0.0" +edition = "2021" +rust-version = "1.88.0" +publish = false + +[dependencies] +environment-engine = { path = "../environment-engine" } +environment-protocol = { path = "../environment-protocol" } +futures-util = "0.3" +libc = "0.2" +serde = "1" +serde_json = "1" +sha2 = "0.10.9" +tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"] } +tokio-util = { version = "0.7", features = ["codec"] } + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/crates/wsl-environment-worker/src/inbound_transfer.rs b/src-tauri/crates/wsl-environment-worker/src/inbound_transfer.rs new file mode 100644 index 00000000..ca90a8e5 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/inbound_transfer.rs @@ -0,0 +1,130 @@ +use std::fmt; + +use environment_protocol::MAX_PAYLOAD_CHUNK_BYTES; +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferDeclaration { + pub owner_request_id: u64, + pub transfer_id: u64, + pub total_bytes: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferCompletion { + pub owner_request_id: u64, + pub transfer_id: u64, + pub total_bytes: u64, + pub sha256: String, +} + +pub struct CompletedInboundTransfer { + pub declaration: TransferDeclaration, + pub file: tokio::fs::File, +} + +pub struct InboundTransfer { + declaration: TransferDeclaration, + received_bytes: u64, + hasher: Sha256, + file: tokio::fs::File, +} + +#[derive(Debug)] +pub enum InboundTransferError { + InvalidDeclaration, + InvalidChunk, + InvalidCompletion, + Io(std::io::Error), +} + +impl fmt::Display for InboundTransferError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidDeclaration => formatter.write_str("invalid inbound transfer declaration"), + Self::InvalidChunk => formatter.write_str("invalid inbound transfer chunk"), + Self::InvalidCompletion => formatter.write_str("invalid inbound transfer completion"), + Self::Io(error) => write!(formatter, "inbound transfer I/O failed: {error}"), + } + } +} + +impl std::error::Error for InboundTransferError {} + +impl From for InboundTransferError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl InboundTransfer { + pub fn begin( + declaration: TransferDeclaration, + transfer_limit: u64, + file: tokio::fs::File, + ) -> Result { + if declaration.owner_request_id == 0 + || declaration.transfer_id == 0 + || declaration.total_bytes > transfer_limit + || !valid_sha256(&declaration.sha256) + { + return Err(InboundTransferError::InvalidDeclaration); + } + Ok(Self { + declaration, + received_bytes: 0, + hasher: Sha256::new(), + file, + }) + } + + pub async fn write_chunk( + &mut self, + transfer_id: u64, + bytes: &[u8], + ) -> Result<(), InboundTransferError> { + if transfer_id != self.declaration.transfer_id + || bytes.is_empty() + || bytes.len() > MAX_PAYLOAD_CHUNK_BYTES + || self.received_bytes.saturating_add(bytes.len() as u64) > self.declaration.total_bytes + { + return Err(InboundTransferError::InvalidChunk); + } + self.file.write_all(bytes).await?; + self.hasher.update(bytes); + self.received_bytes += bytes.len() as u64; + Ok(()) + } + + pub async fn complete( + mut self, + completion: TransferCompletion, + ) -> Result { + let actual_sha256 = format!("sha256:{:x}", self.hasher.finalize()); + if completion.owner_request_id != self.declaration.owner_request_id + || completion.transfer_id != self.declaration.transfer_id + || completion.total_bytes != self.declaration.total_bytes + || completion.sha256 != self.declaration.sha256 + || self.received_bytes != self.declaration.total_bytes + || actual_sha256 != self.declaration.sha256 + { + return Err(InboundTransferError::InvalidCompletion); + } + self.file.flush().await?; + Ok(CompletedInboundTransfer { + declaration: self.declaration, + file: self.file, + }) + } +} + +fn valid_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) +} diff --git a/src-tauri/crates/wsl-environment-worker/src/lib.rs b/src-tauri/crates/wsl-environment-worker/src/lib.rs new file mode 100644 index 00000000..3b7a958f --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/lib.rs @@ -0,0 +1,922 @@ +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; + +#[cfg(target_os = "linux")] +use environment_engine::inspection::{ + self as engine_inspection, EntryKind as EngineEntryKind, ErrorCode as EngineErrorCode, + InspectionError as EngineInspectionError, +}; +#[cfg(target_os = "linux")] +use environment_engine::{ + directory as engine_directory, document as engine_document, entry as engine_entry, + linux_mutation as engine_linux_mutation, manifest as engine_manifest, path as engine_path, + projection as engine_projection, +}; +#[cfg(target_os = "linux")] +use environment_protocol::{ + DirectoryCountFact, DocumentReadFact, DocumentReadState, EntryFact, EntryFactKind, + EntryMetadata, InspectionEntryKind, InspectionErrorCode, InspectionFact, ManifestRecord, + ManifestRecordKind, PathMetadataContent, PathMetadataFact, PathMetadataKind, ProjectedTarget, + MAX_DIRECTORY_COUNT_LIMIT, MAX_DOCUMENT_BYTES, MAX_INSPECTION_CONTENT_BYTES, + MAX_INSPECTION_FACTS, MAX_MANIFEST_RECORDS, MAX_PATH_CONTENT_BYTES_PER_FILE, + MAX_REQUEST_DEADLINE_MILLIS, +}; +use environment_protocol::{ + DirectoryCountRequest, DirectoryCountResponse, DirectoryListRequest, DirectoryListResponse, + DocumentReadRequest, DocumentReadResponse, EntryFactsRequest, EntryFactsResponse, + InspectionRequest, InspectionResponse, ManifestRequest, ManifestResponse, + MapWindowsPathsRequest, MapWindowsPathsResponse, Message, PathKind, PathMetadataRequest, + PathMetadataResponse, ProjectionRequest, ProjectionResponse, WriteProbeRequest, + WriteProbeResponse, MAX_INSPECTION_ROOTS, +}; +use sha2::{Digest, Sha256}; + +pub mod inbound_transfer; +pub mod library; +pub mod mutation; +pub mod payload; +pub mod source; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerIdentity { + pub distro: String, + pub user: String, + pub uid: u32, + pub home: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Dispatch { + pub response: Option, + pub close: bool, +} + +pub struct WorkerRuntime { + build_id: String, + identity: WorkerIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestError { + pub code: &'static str, + pub phase: &'static str, +} + +impl WorkerRuntime { + pub fn new(build_id: String, identity: WorkerIdentity) -> Self { + Self { build_id, identity } + } + + pub fn dispatch(&self, message: Message) -> Dispatch { + match message { + Message::Handshake { build_id } if build_id == self.build_id => Dispatch { + response: Some(Message::HandshakeResult { + build_id: self.build_id.clone(), + distro: self.identity.distro.clone(), + user: self.identity.user.clone(), + uid: self.identity.uid, + home: self.identity.home.clone(), + }), + close: false, + }, + Message::Handshake { .. } => Dispatch { + response: Some(error("buildMismatch", "handshake")), + close: true, + }, + Message::ObservePath { path } => match execute_path_observation(&path) { + Ok(kind) => Dispatch { + response: Some(Message::PathObserved { kind }), + close: false, + }, + Err(error) => Dispatch { + response: Some(error_message(error)), + close: false, + }, + }, + Message::Shutdown => Dispatch { + response: None, + close: true, + }, + _ => Dispatch { + response: Some(error("unexpectedMessage", "request")), + close: true, + }, + } + } +} + +pub fn file_sha256(path: &Path) -> io::Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +pub fn execute_path_observation(path: &str) -> Result { + let path = Path::new(path); + if !path.is_absolute() { + return Err(RequestError { + code: "invalidPath", + phase: "request", + }); + } + Ok(observe_path(path)) +} + +pub fn error_message(request_error: RequestError) -> Message { + error(request_error.code, request_error.phase) +} + +#[cfg(target_os = "linux")] +pub fn execute_inspection( + request: InspectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + validate_inspection_request(&request)?; + let snapshot = engine_inspection::inspect_with_cancel( + &engine_inspection::InspectionRequest { + roots: request + .roots + .into_iter() + .map(|root| engine_inspection::InspectionRoot { + path: root.path.into(), + stat_only: root.stat_only, + }) + .collect(), + read_content: request.read_content, + per_file_limit: request.per_file_limit, + aggregate_limit: request.aggregate_limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + EngineInspectionError::Cancelled => "cancelled", + EngineInspectionError::InvalidRequest => "invalidRequest", + EngineInspectionError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "inspection", + })?; + if snapshot.facts.len() > MAX_INSPECTION_FACTS { + return Err(RequestError { + code: "resultTooLarge", + phase: "inspection", + }); + } + + Ok(InspectionResponse { + facts: snapshot + .facts + .into_iter() + .map(|fact| InspectionFact { + root_index: fact.root_index, + relative_path: fact.relative_path.as_os_str().as_bytes().to_vec(), + kind: match fact.kind { + EngineEntryKind::Missing => InspectionEntryKind::Missing, + EngineEntryKind::File => InspectionEntryKind::File, + EngineEntryKind::Directory => InspectionEntryKind::Directory, + EngineEntryKind::Symlink => InspectionEntryKind::Symlink, + EngineEntryKind::Other => InspectionEntryKind::Other, + }, + resolved_target: fact + .resolved_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + fingerprint: fact.fingerprint, + content_bytes: fact.content_bytes, + truncated: fact.truncated, + error_code: fact.error_code.map(|code| match code { + EngineErrorCode::PathUnavailable => InspectionErrorCode::PathUnavailable, + EngineErrorCode::ReadFailed => InspectionErrorCode::ReadFailed, + EngineErrorCode::ReadLinkFailed => InspectionErrorCode::ReadLinkFailed, + }), + }) + .collect(), + total_content_bytes: snapshot.total_content_bytes, + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_path_metadata( + request: PathMetadataRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate_batch_size(request.queries.len(), "pathMetadata")?; + if request.queries.is_empty() + || request + .queries + .iter() + .any(|query| !Path::new(&query.path).is_absolute()) + || request.queries.iter().any(|query| { + query + .content_limit + .is_some_and(|limit| limit == 0 || limit > MAX_PATH_CONTENT_BYTES_PER_FILE) + }) + || request.aggregate_content_limit == 0 + || request.aggregate_content_limit > MAX_INSPECTION_CONTENT_BYTES + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "pathMetadata", + }); + } + let response = engine_path::inspect_paths_with_cancel( + &engine_path::PathRequest { + queries: request + .queries + .into_iter() + .map(|query| engine_path::PathQuery { + path: query.path.into(), + content_limit: query.content_limit, + }) + .collect(), + aggregate_content_limit: request.aggregate_content_limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_path::PathError::Cancelled => "cancelled", + engine_path::PathError::InvalidRequest => "invalidRequest", + engine_path::PathError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "pathMetadata", + })?; + Ok(PathMetadataResponse { + facts: response + .facts + .into_iter() + .map(|fact| PathMetadataFact { + path: fact.path.to_string_lossy().into_owned(), + kind: match fact.kind { + engine_path::PathKind::Missing => PathMetadataKind::Missing, + engine_path::PathKind::Directory => PathMetadataKind::Directory, + engine_path::PathKind::SymlinkDirectory => PathMetadataKind::SymlinkDirectory, + engine_path::PathKind::SymlinkOther => PathMetadataKind::SymlinkOther, + engine_path::PathKind::Other => PathMetadataKind::Other, + engine_path::PathKind::BrokenLink => PathMetadataKind::BrokenLink, + engine_path::PathKind::Inaccessible => PathMetadataKind::Inaccessible, + }, + content: match fact.content { + engine_path::ContentState::NotRequested => PathMetadataContent::NotRequested, + engine_path::ContentState::Empty => PathMetadataContent::Empty, + engine_path::ContentState::Unreadable => PathMetadataContent::Unreadable, + engine_path::ContentState::Bytes(bytes) => PathMetadataContent::Bytes(bytes), + }, + content_truncated: fact.content_truncated, + }) + .collect(), + total_content_bytes: response.total_content_bytes, + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_path_metadata( + _request: PathMetadataRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "pathMetadata", + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_directory_count( + request: DirectoryCountRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate_batch_size(request.paths.len(), "directoryCount")?; + if request.paths.is_empty() + || request + .paths + .iter() + .any(|path| !Path::new(path).is_absolute()) + || request.limit == 0 + || request.limit > MAX_DIRECTORY_COUNT_LIMIT + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "directoryCount", + }); + } + let response = engine_directory::count_entries_with_cancel( + &engine_directory::DirectoryCountRequest { + paths: request.paths.into_iter().map(Into::into).collect(), + limit: request.limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_directory::DirectoryCountError::Cancelled => "cancelled", + engine_directory::DirectoryCountError::InvalidRequest => "invalidRequest", + engine_directory::DirectoryCountError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "directoryCount", + })?; + Ok(DirectoryCountResponse { + facts: response + .facts + .into_iter() + .map(|fact| DirectoryCountFact { + path: fact.path.to_string_lossy().into_owned(), + observed_count: fact.observed_count, + truncated: fact.truncated, + }) + .collect(), + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_document_read( + request: DocumentReadRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate_batch_size(request.queries.len(), "documentRead")?; + if request.queries.is_empty() + || request.queries.iter().any(|query| { + !Path::new(&query.path).is_absolute() + || query.limit == 0 + || query.limit > MAX_DOCUMENT_BYTES + }) + || request.aggregate_limit == 0 + || request.aggregate_limit > MAX_DOCUMENT_BYTES + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "documentRead", + }); + } + let response = engine_document::read_documents_with_cancel( + &engine_document::DocumentRequest { + queries: request + .queries + .into_iter() + .map(|query| engine_document::DocumentQuery { + path: query.path.into(), + limit: query.limit, + }) + .collect(), + aggregate_limit: request.aggregate_limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_document::DocumentError::Cancelled => "cancelled", + engine_document::DocumentError::InvalidRequest => "invalidRequest", + engine_document::DocumentError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "documentRead", + })?; + Ok(DocumentReadResponse { + facts: response + .facts + .into_iter() + .map(|fact| DocumentReadFact { + path: fact.path.to_string_lossy().into_owned(), + state: match fact.state { + engine_document::DocumentState::Missing => DocumentReadState::Missing, + engine_document::DocumentState::NotFile => DocumentReadState::NotFile, + engine_document::DocumentState::Unreadable => DocumentReadState::Unreadable, + engine_document::DocumentState::Bytes(bytes) => DocumentReadState::Bytes(bytes), + }, + truncated: fact.truncated, + }) + .collect(), + total_content_bytes: response.total_content_bytes, + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_directory_list( + request: DirectoryListRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + if !Path::new(&request.path).is_absolute() + || request.limit == 0 + || request.limit > MAX_DIRECTORY_COUNT_LIMIT + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "directoryList", + }); + } + let response = engine_directory::list_child_directories_with_cancel( + &engine_directory::DirectoryListRequest { + path: request.path.into(), + limit: request.limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_directory::DirectoryCountError::Cancelled => "cancelled", + engine_directory::DirectoryCountError::InvalidRequest => "invalidRequest", + engine_directory::DirectoryCountError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "directoryList", + })?; + Ok(DirectoryListResponse { + names: response + .names + .into_iter() + .map(|name| name.as_os_str().as_bytes().to_vec()) + .collect(), + truncated: response.truncated, + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_map_windows_paths( + request: MapWindowsPathsRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate_batch_size(request.paths.len(), "pathMapping")?; + if request.paths.is_empty() + || request + .paths + .iter() + .any(|path| !Path::new(path).is_absolute()) + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "pathMapping", + }); + } + let mut mapped = Vec::with_capacity(request.paths.len()); + for path in request.paths { + if is_cancelled() { + return Err(RequestError { + code: "cancelled", + phase: "pathMapping", + }); + } + mapped.push(map_path_to_windows(Path::new(&path))); + } + Ok(MapWindowsPathsResponse { mapped }) +} + +#[cfg(target_os = "linux")] +pub fn execute_entry_facts( + request: EntryFactsRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + validate_paths(&request.paths, request.deadline_millis, "entryFacts")?; + let response = engine_entry::inspect_entries_with_cancel( + &engine_entry::EntryRequest { + paths: request.paths.into_iter().map(Into::into).collect(), + }, + is_cancelled, + ) + .map_err(|error| planning_error(entry_error_code(error), "entryFacts"))?; + Ok(EntryFactsResponse { + facts: response + .facts + .into_iter() + .map(|fact| EntryFact { + kind: match fact.kind { + engine_entry::EntryKind::Missing => EntryFactKind::Missing, + engine_entry::EntryKind::File => EntryFactKind::File, + engine_entry::EntryKind::Directory => EntryFactKind::Directory, + engine_entry::EntryKind::Symlink => EntryFactKind::Symlink, + engine_entry::EntryKind::BrokenLink => EntryFactKind::BrokenLink, + engine_entry::EntryKind::Other => EntryFactKind::Other, + }, + metadata: fact.metadata.map(|metadata| EntryMetadata { + device: metadata.device, + inode: metadata.inode, + mode: metadata.mode, + size: metadata.size, + mtime_seconds: metadata.mtime_seconds, + mtime_nanos: metadata.mtime_nanos, + }), + link_target: fact + .link_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_projection( + request: ProjectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + validate_paths(&request.destinations, request.deadline_millis, "projection")?; + let response = engine_projection::project_targets_with_cancel( + &engine_projection::ProjectionRequest { + destinations: request.destinations.into_iter().map(Into::into).collect(), + }, + &is_cancelled, + ) + .map_err(|error| planning_error(projection_error_code(error), "projection"))?; + let mut targets = Vec::with_capacity(response.targets.len()); + let mut mapped_anchors = std::collections::BTreeMap::::new(); + for target in response.targets { + if is_cancelled() { + return Err(planning_error("cancelled", "projection")); + } + let storage_projection = match mapped_anchors.get(&target.physical_anchor) { + Some(mapped) => mapped.clone(), + None => { + let mapped = map_path_to_windows(&target.physical_anchor) + .ok_or_else(|| planning_error("pathMappingFailed", "projection"))?; + mapped_anchors.insert(target.physical_anchor.clone(), mapped.clone()); + mapped + } + }; + targets.push(ProjectedTarget { + anchor_device: target.anchor_device, + anchor_inode: target.anchor_inode, + physical_destination: target.physical_destination.as_os_str().as_bytes().to_vec(), + relative_components: target + .relative_components + .into_iter() + .map(|component| component.as_os_str().as_bytes().to_vec()) + .collect(), + storage_projection, + }); + } + Ok(ProjectionResponse { targets }) +} + +#[cfg(target_os = "linux")] +pub fn execute_write_probe( + request: WriteProbeRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate_paths( + &request.destinations, + request.deadline_millis, + "writePreflight", + )?; + let checked_count = u32::try_from(request.destinations.len()) + .map_err(|_| planning_error("invalidRequest", "writePreflight"))?; + engine_linux_mutation::preflight_write_targets( + &request + .destinations + .into_iter() + .map(Into::into) + .collect::>(), + is_cancelled, + ) + .map_err(|error| { + let code = match error { + engine_linux_mutation::MutationError::Cancelled => "cancelled", + engine_linux_mutation::MutationError::InvalidRequest => "invalidRequest", + _ => "writeProbeFailed", + }; + planning_error(code, "writePreflight") + })?; + Ok(WriteProbeResponse { checked_count }) +} + +#[cfg(target_os = "linux")] +pub fn execute_manifest( + request: ManifestRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + validate_paths( + std::slice::from_ref(&request.root), + request.deadline_millis, + "manifest", + )?; + let response = engine_manifest::build_manifest_with_cancel( + &engine_manifest::ManifestRequest { + root: request.root.into(), + }, + is_cancelled, + ) + .map_err(|error| planning_error(manifest_error_code(error), "manifest"))?; + if response.records.len() > MAX_MANIFEST_RECORDS { + return Err(planning_error("resultTooLarge", "manifest")); + } + Ok(ManifestResponse { + records: response + .records + .into_iter() + .map(|record| ManifestRecord { + relative_path: record.relative_path.as_os_str().as_bytes().to_vec(), + kind: match record.kind { + engine_manifest::ManifestKind::Directory => ManifestRecordKind::Directory, + engine_manifest::ManifestKind::File => ManifestRecordKind::File, + engine_manifest::ManifestKind::Symlink => ManifestRecordKind::Symlink, + }, + digest: record.digest, + executable: record.executable, + symlink_target: record + .symlink_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) +} + +#[cfg(target_os = "linux")] +fn validate_paths( + paths: &[String], + deadline: u64, + phase: &'static str, +) -> Result<(), RequestError> { + validate_batch_size(paths.len(), phase)?; + if paths.is_empty() + || paths.iter().any(|path| !Path::new(path).is_absolute()) + || deadline == 0 + || deadline > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(planning_error("invalidRequest", phase)); + } + Ok(()) +} + +fn validate_batch_size(len: usize, phase: &'static str) -> Result<(), RequestError> { + if len > MAX_INSPECTION_ROOTS { + return Err(planning_error("requestTooLarge", phase)); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn entry_error_code(error: engine_entry::EntryError) -> &'static str { + match error { + engine_entry::EntryError::UnsupportedPlatform => "unsupportedPlatform", + engine_entry::EntryError::InvalidRequest => "invalidRequest", + engine_entry::EntryError::Unavailable => "pathUnavailable", + engine_entry::EntryError::Cancelled => "cancelled", + } +} + +#[cfg(target_os = "linux")] +fn projection_error_code(error: engine_projection::ProjectionError) -> &'static str { + match error { + engine_projection::ProjectionError::UnsupportedPlatform => "unsupportedPlatform", + engine_projection::ProjectionError::InvalidRequest => "invalidRequest", + engine_projection::ProjectionError::Unavailable => "pathUnavailable", + engine_projection::ProjectionError::Cancelled => "cancelled", + } +} + +#[cfg(target_os = "linux")] +fn manifest_error_code(error: engine_manifest::ManifestError) -> &'static str { + match error { + engine_manifest::ManifestError::UnsupportedPlatform => "unsupportedPlatform", + engine_manifest::ManifestError::InvalidRequest => "invalidRequest", + engine_manifest::ManifestError::Unavailable => "pathUnavailable", + engine_manifest::ManifestError::UnsupportedEntry => "unsupportedEntry", + engine_manifest::ManifestError::Cancelled => "cancelled", + } +} + +fn planning_error(code: &'static str, phase: &'static str) -> RequestError { + RequestError { code, phase } +} + +#[cfg(target_os = "linux")] +#[allow( + clippy::disallowed_methods, + reason = "Environment Worker 是独立 crate,必须直接构造受控的 wslpath 子进程" +)] +fn map_path_to_windows(path: &Path) -> Option { + use std::process::{Command, Stdio}; + + let output = Command::new("wslpath") + .arg("-w") + .arg("--") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() || output.stdout.len() > 16 * 1024 { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim_end_matches(['\r', '\n']).to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_entry_facts( + _request: EntryFactsRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(planning_error("unsupportedPlatform", "entryFacts")) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_projection( + request: ProjectionRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate_batch_size(request.destinations.len(), "projection")?; + Err(planning_error("unsupportedPlatform", "projection")) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_write_probe( + _request: WriteProbeRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(planning_error("unsupportedPlatform", "writePreflight")) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_manifest( + _request: ManifestRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(planning_error("unsupportedPlatform", "manifest")) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_map_windows_paths( + _request: MapWindowsPathsRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "pathMapping", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_directory_list( + _request: DirectoryListRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "directoryList", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_document_read( + _request: DocumentReadRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "documentRead", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_directory_count( + _request: DirectoryCountRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "directoryCount", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_inspection( + _request: InspectionRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "inspection", + }) +} + +#[cfg(target_os = "linux")] +fn validate_inspection_request(request: &InspectionRequest) -> Result<(), RequestError> { + validate_batch_size(request.roots.len(), "inspection")?; + if request.roots.is_empty() + || request + .roots + .iter() + .any(|root| !Path::new(&root.path).is_absolute()) + || request.per_file_limit == 0 + || request.aggregate_limit == 0 + || request.per_file_limit > request.aggregate_limit + || request.aggregate_limit > MAX_INSPECTION_CONTENT_BYTES + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "inspection", + }); + } + Ok(()) +} + +fn observe_path(path: &Path) -> PathKind { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => match std::fs::metadata(path) { + Ok(target) if target.is_dir() => PathKind::SymlinkDirectory, + Ok(_) => PathKind::SymlinkOther, + Err(error) if error.kind() == io::ErrorKind::NotFound => PathKind::BrokenLink, + Err(_) => PathKind::Inaccessible, + }, + Ok(metadata) if metadata.is_dir() => PathKind::Directory, + Ok(metadata) if metadata.is_file() => PathKind::File, + Ok(_) => PathKind::Other, + Err(error) if error.kind() == io::ErrorKind::NotFound => PathKind::Missing, + Err(_) => PathKind::Inaccessible, + } +} + +fn error(code: &str, phase: &str) -> Message { + Message::Error { + code: code.to_string(), + phase: phase.to_string(), + parameters: Vec::new(), + } +} diff --git a/src-tauri/crates/wsl-environment-worker/src/library.rs b/src-tauri/crates/wsl-environment-worker/src/library.rs new file mode 100644 index 00000000..070d5494 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/library.rs @@ -0,0 +1,288 @@ +use std::fmt; +use std::path::PathBuf; + +use environment_engine::library::{ + self as engine, CatalogWrite, ContentAction, LibraryCommit, TargetExpectation, +}; +use environment_engine::linux_mutation::ParentIdentity; +use environment_protocol::{ + LibraryApplicationIndex, LibraryCatalogResponse, LibraryMemberAction, LibraryOperationAction, + LibraryOperationRequest, MAX_DIRECTORY_COUNT_LIMIT, MAX_REQUEST_DEADLINE_MILLIS, +}; + +use crate::payload::{PayloadError, PayloadManager}; + +pub struct LibraryManager { + root: PathBuf, +} + +#[derive(Debug)] +pub enum LibraryError { + InvalidRequest, + StaleTarget, + StalePayload, + RecoveryIncomplete, + Io, +} + +impl fmt::Display for LibraryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for LibraryError {} + +impl LibraryManager { + pub fn new(home: PathBuf) -> Self { + Self { + root: home.join(".skill-deck/skill-libraries"), + } + } + + pub fn read_catalog(&self) -> Result { + let snapshot = engine::read_catalog(&self.root).map_err(map_engine_error)?; + Ok(LibraryCatalogResponse { + present: snapshot.bytes.is_some(), + bytes: snapshot.bytes.unwrap_or_default(), + revision: snapshot.revision, + }) + } + + pub fn list_applications(&self) -> Result { + let applications = self.root.join("applications"); + let mut project_ids = Vec::new(); + let mut problem_keys = Vec::new(); + match std::fs::read_dir(&applications) { + Ok(entries) => { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + problem_keys.push("applications/".to_string()); + continue; + } + }; + let name = entry.file_name().to_string_lossy().into_owned(); + if name != "global.json" && name != "projects" { + problem_keys.push(name); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LibraryApplicationIndex { + project_ids, + problem_keys, + complete: true, + }); + } + Err(_) => { + problem_keys.push("applications".to_string()); + return Ok(LibraryApplicationIndex { + project_ids, + problem_keys, + complete: false, + }); + } + } + let projects = applications.join("projects"); + match std::fs::read_dir(projects) { + Ok(entries) => { + for (index, entry) in entries.enumerate() { + if index == MAX_DIRECTORY_COUNT_LIMIT as usize { + problem_keys.push("projects/".to_string()); + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + problem_keys.push("projects/".to_string()); + continue; + } + }; + let name = entry.file_name(); + let display = name.to_string_lossy().into_owned(); + let project_id = std::path::Path::new(&name) + .file_stem() + .and_then(|value| value.to_str()) + .filter(|_| { + std::path::Path::new(&name) + .extension() + .is_some_and(|value| value == "json") + }); + let valid = entry.file_type().is_ok_and(|kind| kind.is_file()) + && project_id.is_some_and(valid_component); + if let Some(project_id) = project_id.filter(|_| valid) { + project_ids.push(project_id.to_string()); + } else { + problem_keys.push(format!("projects/{display}")); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => problem_keys.push("projects".to_string()), + } + project_ids.sort(); + project_ids.dedup(); + problem_keys.sort(); + problem_keys.dedup(); + Ok(LibraryApplicationIndex { + complete: problem_keys.is_empty(), + project_ids, + problem_keys, + }) + } + + pub fn execute( + &self, + request: LibraryOperationRequest, + payloads: &PayloadManager, + ) -> Result { + if request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + || !valid_component(&request.operation_id) + || request.catalog_bytes.is_empty() + || request.catalog_bytes.len() > environment_protocol::MAX_DOCUMENT_BYTES as usize + { + return Err(LibraryError::InvalidRequest); + } + let catalog = CatalogWrite { + expected_revision: request.expected_catalog_revision, + bytes: request.catalog_bytes, + max_current_bytes: environment_protocol::MAX_DOCUMENT_BYTES as usize, + }; + match request.action { + LibraryOperationAction::SaveCatalog { library_ids } => { + engine::write_catalog(&self.root, &library_ids, catalog).map_err(map_engine_error) + } + LibraryOperationAction::CommitMember { + library_id, + skill_name, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + mutation, + } => { + if !valid_component(&library_id) || !valid_component(&skill_name) { + return Err(LibraryError::InvalidRequest); + } + let content = match mutation { + LibraryMemberAction::Upsert { payload_id } => ContentAction::Upsert { + payload_root: payloads + .payload_root(payload_id) + .map_err(map_payload_error)?, + }, + LibraryMemberAction::Delete => ContentAction::Delete, + }; + engine::commit(LibraryCommit { + root: self.root.clone(), + operation_id: request.operation_id, + destination: self + .root + .join("libraries") + .join(library_id) + .join("skills") + .join(skill_name), + expected_target: expectation( + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + ), + content, + catalog, + }) + .map_err(map_engine_error)?; + Ok(catalog_revision(&self.root)?) + } + LibraryOperationAction::DeleteLibrary { + library_id, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + } => { + if !valid_component(&library_id) { + return Err(LibraryError::InvalidRequest); + } + engine::commit(LibraryCommit { + root: self.root.clone(), + operation_id: request.operation_id, + destination: self.root.join("libraries").join(library_id), + expected_target: expectation( + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + ), + content: ContentAction::DeleteIfPresent, + catalog, + }) + .map_err(map_engine_error)?; + Ok(catalog_revision(&self.root)?) + } + } + } +} + +fn expectation( + device: u64, + inode: u64, + fingerprint: String, + content_hash: Option, +) -> TargetExpectation { + TargetExpectation { + parent: ParentIdentity { device, inode }, + fingerprint, + content_hash, + } +} + +fn catalog_revision(root: &std::path::Path) -> Result { + engine::read_catalog(root) + .map_err(map_engine_error)? + .revision + .ok_or(LibraryError::Io) +} + +fn valid_component(value: &str) -> bool { + !value.is_empty() && !matches!(value, "." | "..") && !value.contains(['/', '\\', '\0']) +} + +fn map_engine_error(error: engine::LibraryError) -> LibraryError { + match error { + engine::LibraryError::InvalidRequest | engine::LibraryError::UnsupportedPlatform => { + LibraryError::InvalidRequest + } + engine::LibraryError::StaleTarget => LibraryError::StaleTarget, + engine::LibraryError::InvalidPayload => LibraryError::StalePayload, + engine::LibraryError::RecoveryIncomplete => LibraryError::RecoveryIncomplete, + engine::LibraryError::Io(_) => LibraryError::Io, + } +} + +fn map_payload_error(_error: PayloadError) -> LibraryError { + LibraryError::StalePayload +} + +#[cfg(test)] +mod tests { + use super::LibraryManager; + + #[test] + fn inaccessible_project_namespace_returns_an_incomplete_inventory() { + let home = tempfile::tempdir().unwrap(); + let applications = home.path().join(".skill-deck/skill-libraries/applications"); + std::fs::create_dir_all(&applications).unwrap(); + std::fs::write(applications.join("projects"), b"not a directory").unwrap(); + + let inventory = LibraryManager::new(home.path().to_path_buf()) + .list_applications() + .unwrap(); + + assert!(inventory.project_ids.is_empty()); + assert_eq!(inventory.problem_keys, vec!["projects"]); + assert!(!inventory.complete); + } +} diff --git a/src-tauri/crates/wsl-environment-worker/src/main.rs b/src-tauri/crates/wsl-environment-worker/src/main.rs new file mode 100644 index 00000000..45bffe39 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/main.rs @@ -0,0 +1,2448 @@ +use std::collections::hash_map::Entry; +use std::collections::{HashMap, VecDeque}; +use std::error::Error; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use environment_protocol::{ + codec, decode, decode_payload, encode_inspection_response, encode_payload, spawn_writer, + Envelope, MapHostPathsRequest, MapHostPathsResponse, Message, ProtocolWriter, WireRecord, + MAX_CONCURRENT_READ_REQUESTS, MAX_DOCUMENT_BYTES, MAX_INSPECTION_ROOTS, + MAX_MUTATION_TRANSFER_BYTES, MAX_PAYLOAD_TRANSFER_BYTES, MAX_PENDING_READ_REQUESTS, + MAX_REQUEST_DEADLINE_MILLIS, +}; +use futures_util::StreamExt; +use sha2::Digest; +use tokio::task::JoinSet; +use tokio_util::codec::FramedRead; +use wsl_environment_worker::inbound_transfer::{ + InboundTransfer, TransferCompletion, TransferDeclaration, +}; +use wsl_environment_worker::library::{LibraryError, LibraryManager}; +use wsl_environment_worker::mutation::MutationManager; +use wsl_environment_worker::payload::{PayloadError, PayloadManager, PreparedPayloadFile}; +use wsl_environment_worker::source::{ + probe_git, scan_source, GitSourceOptions, SourceError, SourceManager, +}; +use wsl_environment_worker::{ + error_message, execute_directory_count, execute_directory_list, execute_document_read, + execute_entry_facts, execute_inspection, execute_manifest, execute_map_windows_paths, + execute_path_metadata, execute_path_observation, execute_projection, execute_write_probe, + file_sha256, RequestError, WorkerIdentity, WorkerRuntime, +}; + +struct QueuedRequest { + request_id: u64, + message: Message, + cancelled: Arc, +} + +enum InboundAction { + Blob { + upload_id: u64, + blob_id: String, + }, + Manifest { + upload_id: u64, + }, + Mutation { + resource_id: String, + }, + Document { + request: environment_protocol::DocumentWritePreparation, + started: Instant, + }, + Library { + deadline_millis: u64, + started: Instant, + }, +} + +struct PreparedInbound { + owner_request_id: u64, + deadline: Instant, + transfer_id: u64, + total_bytes: u64, + sha256: String, + transfer_limit: u64, + path: PathBuf, + file: std::fs::File, + action: InboundAction, +} + +struct ActiveInbound { + owner_request_id: u64, + deadline: Instant, + path: PathBuf, + action: InboundAction, + transfer: InboundTransfer, +} + +struct LibraryExecution { + deadline_millis: u64, + started: Instant, + path: PathBuf, +} + +const MAX_MANIFEST_TRANSFER_BYTES: u64 = 8 * 1024 * 1024; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let executable = std::env::current_exe()?; + let home = std::env::var("HOME").unwrap_or_default(); + let runtime = WorkerRuntime::new( + file_sha256(&executable)?, + WorkerIdentity { + distro: std::env::var("WSL_DISTRO_NAME").unwrap_or_default(), + user: std::env::var("USER").unwrap_or_default(), + uid: effective_user_id(), + home: home.clone(), + }, + ); + let mut reader = FramedRead::new(tokio::io::stdin(), codec()); + let (writer, writer_task) = spawn_writer(tokio::io::stdout()); + let mut queue = VecDeque::new(); + let mut active = HashMap::>::new(); + let mut tasks = JoinSet::new(); + let sources = Arc::new(tokio::sync::Mutex::new(SourceManager::new(PathBuf::from( + "/tmp", + ))?)); + let payloads = Arc::new(tokio::sync::Mutex::new(PayloadManager::new( + PathBuf::from("/tmp"), + )?)); + let mutations = Arc::new(tokio::sync::Mutex::new(MutationManager::new( + PathBuf::from("/tmp"), + )?)); + let libraries = Arc::new(tokio::sync::Mutex::new(LibraryManager::new(PathBuf::from( + home, + )))); + let documents = Arc::new(tokio::sync::Mutex::new(())); + let mut shutting_down = false; + let mut prepared_inbound: Option = None; + let mut active_inbound: Option = None; + let mut next_inbound_transfer_id = 1_u64; + + loop { + while tasks.len() < MAX_CONCURRENT_READ_REQUESTS { + let Some(request) = queue.pop_front() else { + break; + }; + let task_writer = writer.clone(); + tasks.spawn(execute_business_request( + request, + task_writer, + Arc::clone(&sources), + Arc::clone(&payloads), + Arc::clone(&mutations), + Arc::clone(&libraries), + )); + } + + if shutting_down && tasks.is_empty() { + break; + } + + let inbound_deadline = prepared_inbound + .as_ref() + .map(|inbound| inbound.deadline) + .or_else(|| active_inbound.as_ref().map(|inbound| inbound.deadline)); + let inbound_deadline_sleep = + tokio::time::sleep_until(inbound_deadline.unwrap_or_else(Instant::now).into()); + tokio::pin!(inbound_deadline_sleep); + + tokio::select! { + _ = &mut inbound_deadline_sleep, if inbound_deadline.is_some() => { + if let Some(inbound) = prepared_inbound.take() { + drop(inbound.file); + finish_inbound_with_error( + &writer, + inbound.owner_request_id, + "deadlineExceeded", + &payloads, + inbound.path, + inbound.action, + ).await?; + } else if let Some(inbound) = active_inbound.take() { + finish_inbound_with_error( + &writer, + inbound.owner_request_id, + "deadlineExceeded", + &payloads, + inbound.path, + inbound.action, + ).await?; + } + } + completed = tasks.join_next(), if !tasks.is_empty() => { + match completed { + Some(Ok(Ok(request_id))) => { + active.remove(&request_id); + } + Some(Ok(Err(error))) => return Err(error.into()), + Some(Err(error)) => return Err(error.into()), + None => {} + } + } + frame = reader.next(), if !shutting_down => { + let Some(frame) = frame else { + cancel_all(&active); + queue.clear(); + discard_inbound(&mut prepared_inbound, &mut active_inbound).await; + shutting_down = true; + continue; + }; + let record = decode(&frame?)?; + if let WireRecord::PayloadChunk { transfer_id, bytes } = record { + let inbound = active_inbound + .as_mut() + .ok_or("worker received a payload without an owning request")?; + inbound.transfer.write_chunk(transfer_id, &bytes).await?; + continue; + } + let WireRecord::Control(envelope) = record else { unreachable!() }; + let request_id = envelope.request_id; + match envelope.message { + message @ (Message::ObservePath { .. } + | Message::InspectFilesystem { .. } + | Message::InspectPaths { .. } + | Message::CountDirectoryEntries { .. } + | Message::ReadDocuments { .. } + | Message::ReadLibraryCatalog { .. } + | Message::ListLibraryApplications { .. } + | Message::ListChildDirectories { .. } + | Message::MapPathsToWindows { .. } + | Message::MapHostPaths { .. } + | Message::InspectEntries { .. } + | Message::ProjectTargets { .. } + | Message::ProbeWriteTargets { .. } + | Message::BuildManifest { .. } + | Message::AcquireGitSource { .. } + | Message::OpenLocalSource { .. } + | Message::ReleaseSource { .. } + | Message::ScanSource { .. } + | Message::SourceFingerprint { .. } + | Message::SourceRevision { .. } + | Message::ProbeGit { .. } + | Message::AcquirePayloadFromSource { .. } + | Message::VerifyPayload { .. } + | Message::ReadPayloadBlob { .. } + | Message::RemovePayload { .. } + | Message::RemovePayloadSession { .. } + | Message::SweepPayloadOrphans { .. } + | Message::BeginPayloadUpload { .. } + | Message::AcknowledgeMutationUnit { .. } + | Message::ListMutationRecovery + | Message::CleanupMutationRecovery { .. }) => { + match active.entry(request_id) { + Entry::Occupied(_) => { + send_error(&writer, request_id, "duplicateRequest", "request").await?; + } + Entry::Vacant(_) if queue.len() >= MAX_PENDING_READ_REQUESTS => { + send_error(&writer, request_id, "workerBusy", "admission").await?; + } + Entry::Vacant(entry) => { + let cancelled = Arc::new(AtomicBool::new(false)); + entry.insert(Arc::clone(&cancelled)); + queue.push_back(QueuedRequest { + request_id, + message, + cancelled, + }); + } + } + } + Message::PrepareDocumentWrite { request: preparation } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if !preparation.path.starts_with('/') + || preparation.path.ends_with('/') + || preparation.total_bytes == 0 + || preparation.total_bytes > MAX_DOCUMENT_BYTES as u64 + || !valid_transfer_sha256(&preparation.sha256) + || preparation + .expected_revision + .as_deref() + .is_some_and(|revision| !valid_transfer_sha256(revision)) + || preparation.deadline_millis == 0 + || preparation.deadline_millis + > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + send_error(&writer, request_id, "invalidRequest", "documentWrite").await?; + continue; + } + let path = PathBuf::from(format!( + "/tmp/.skill-deck-document-request-{}-{}", + std::process::id(), + request_id + )); + let file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(_) => { + send_error(&writer, request_id, "workerBusy", "documentWrite").await?; + continue; + } + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(0o600), + ) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + send_error(&writer, request_id, "workerIoFailed", "documentWrite").await?; + continue; + } + } + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(preparation.deadline_millis), + transfer_id, + total_bytes: preparation.total_bytes, + sha256: preparation.sha256.clone(), + transfer_limit: MAX_DOCUMENT_BYTES as u64, + path, + file, + action: InboundAction::Document { + request: preparation, + started: Instant::now(), + }, + }); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })) + .await?; + } + Message::RemoveDocument { request } => { + if !request.path.starts_with('/') + || request.path.ends_with('/') + || request + .expected_revision + .as_deref() + .is_some_and(|revision| !valid_transfer_sha256(revision)) + || request.deadline_millis == 0 + || request.deadline_millis + > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + send_error(&writer, request_id, "invalidRequest", "documentRemove").await?; + continue; + } + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + send_error(&writer, request_id, "duplicateRequest", "documentRemove").await?; + continue; + } + tasks.spawn(execute_document_remove( + request_id, + request, + Instant::now(), + writer.clone(), + Arc::clone(&documents), + cancelled, + )); + } + Message::PrepareLibraryOperation { request: preparation } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if preparation.total_bytes == 0 + || preparation.total_bytes > MAX_MUTATION_TRANSFER_BYTES as u64 + || !valid_transfer_sha256(&preparation.sha256) + || preparation.deadline_millis == 0 + || preparation.deadline_millis + > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + send_error(&writer, request_id, "invalidRequest", "library").await?; + continue; + } + let path = PathBuf::from(format!( + "/tmp/.skill-deck-library-request-{}-{}", + std::process::id(), + request_id + )); + let file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(_) => { + send_error(&writer, request_id, "workerBusy", "library").await?; + continue; + } + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(0o600), + ) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + send_error(&writer, request_id, "workerIoFailed", "library").await?; + continue; + } + } + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(preparation.deadline_millis), + transfer_id, + total_bytes: preparation.total_bytes, + sha256: preparation.sha256, + transfer_limit: MAX_MUTATION_TRANSFER_BYTES as u64, + path, + file, + action: InboundAction::Library { + deadline_millis: preparation.deadline_millis, + started: Instant::now(), + }, + }); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })) + .await?; + } + Message::UploadPayloadBlob { + upload_id, + blob_id, + total_bytes, + sha256, + } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if total_bytes > MAX_PAYLOAD_TRANSFER_BYTES as u64 + || sha256 != format!("sha256:{blob_id}") + { + send_error(&writer, request_id, "invalidTransfer", "payloadUpload").await?; + continue; + } + match payloads.lock().await.prepare_blob(upload_id, &blob_id) { + Ok(PreparedPayloadFile { path, file }) => { + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(MAX_REQUEST_DEADLINE_MILLIS), + transfer_id, + total_bytes, + sha256, + transfer_limit: MAX_PAYLOAD_TRANSFER_BYTES as u64, + path, + file, + action: InboundAction::Blob { upload_id, blob_id }, + }); + writer.send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })).await?; + } + Err(error) => { + send_payload_error( + &writer, + request_id, + SourcePayloadError::Payload(error), + ).await?; + } + } + } + Message::FinalizePayloadUpload { + upload_id, + total_bytes, + sha256, + } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if total_bytes == 0 || total_bytes > MAX_MANIFEST_TRANSFER_BYTES { + send_error(&writer, request_id, "invalidTransfer", "payloadUpload").await?; + continue; + } + match payloads.lock().await.prepare_manifest(upload_id) { + Ok(PreparedPayloadFile { path, file }) => { + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(MAX_REQUEST_DEADLINE_MILLIS), + transfer_id, + total_bytes, + sha256, + transfer_limit: MAX_MANIFEST_TRANSFER_BYTES, + path, + file, + action: InboundAction::Manifest { upload_id }, + }); + writer.send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })).await?; + } + Err(error) => { + send_payload_error( + &writer, + request_id, + SourcePayloadError::Payload(error), + ).await?; + } + } + } + Message::PrepareMutationUnit { + resource_id, + total_bytes, + sha256, + } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if total_bytes == 0 + || total_bytes > MAX_MUTATION_TRANSFER_BYTES as u64 + || !valid_transfer_sha256(&sha256) + || resource_id.is_empty() + || !resource_id.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') + }) + { + send_error(&writer, request_id, "invalidTransfer", "mutation").await?; + continue; + } + let path = PathBuf::from(format!( + "/tmp/.skill-deck-mutation-request-{resource_id}-{}", + std::process::id() + )); + let file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(_) => { + send_error(&writer, request_id, "workerBusy", "mutation").await?; + continue; + } + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(0o600), + ) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + send_error( + &writer, + request_id, + "workerIoFailed", + "mutation", + ) + .await?; + continue; + } + } + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(MAX_REQUEST_DEADLINE_MILLIS), + transfer_id, + total_bytes, + sha256, + transfer_limit: MAX_MUTATION_TRANSFER_BYTES as u64, + path, + file, + action: InboundAction::Mutation { resource_id }, + }); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })) + .await?; + } + Message::BeginTransfer { + transfer_id, + total_bytes, + sha256, + owner_request_id, + } => { + let prepared = prepared_inbound + .take() + .ok_or("worker received an unprepared inbound transfer")?; + if request_id != owner_request_id + || transfer_id != prepared.transfer_id + || total_bytes != prepared.total_bytes + || sha256 != prepared.sha256 + { + return Err("inbound transfer does not match its preparation".into()); + } + let transfer = InboundTransfer::begin( + TransferDeclaration { + owner_request_id, + transfer_id, + total_bytes, + sha256, + }, + prepared.transfer_limit, + tokio::fs::File::from_std(prepared.file), + )?; + active_inbound = Some(ActiveInbound { + owner_request_id, + deadline: prepared.deadline, + path: prepared.path, + action: prepared.action, + transfer, + }); + } + Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + } => { + let inbound = active_inbound + .take() + .ok_or("worker completed an inbound transfer that is not active")?; + let completed = inbound.transfer.complete(TransferCompletion { + owner_request_id: request_id, + transfer_id, + total_bytes, + sha256, + }).await?; + drop(completed.file); + if let InboundAction::Mutation { resource_id } = inbound.action { + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + return Err("duplicate mutation request".into()); + } + tasks.spawn(execute_mutation_request( + request_id, + resource_id, + inbound.path, + writer.clone(), + Arc::clone(&payloads), + Arc::clone(&mutations), + cancelled, + )); + } else if let InboundAction::Document { request, started } = inbound.action { + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + return Err("duplicate document request".into()); + } + tasks.spawn(execute_document_write( + request_id, + request, + started, + inbound.path, + writer.clone(), + Arc::clone(&documents), + cancelled, + )); + } else if let InboundAction::Library { + deadline_millis, + started, + } = inbound.action + { + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + return Err("duplicate Library request".into()); + } + tasks.spawn(execute_library_operation( + request_id, + LibraryExecution { + deadline_millis, + started, + path: inbound.path, + }, + writer.clone(), + Arc::clone(&payloads), + Arc::clone(&libraries), + cancelled, + )); + } else { + complete_inbound_action( + &writer, + request_id, + Arc::clone(&payloads), + inbound.path, + inbound.action, + ).await?; + } + } + Message::Cancel { target_request_id } => { + if let Some(cancelled) = active.get(&target_request_id) { + cancelled.store(true, Ordering::Release); + } + if prepared_inbound + .as_ref() + .is_some_and(|inbound| inbound.owner_request_id == target_request_id) + { + let inbound = prepared_inbound.take().unwrap(); + drop(inbound.file); + finish_inbound_with_error( + &writer, + target_request_id, + "cancelled", + &payloads, + inbound.path, + inbound.action, + ) + .await?; + } + if active_inbound + .as_ref() + .is_some_and(|inbound| inbound.owner_request_id == target_request_id) + { + let inbound = active_inbound.take().unwrap(); + finish_inbound_with_error( + &writer, + target_request_id, + "cancelled", + &payloads, + inbound.path, + inbound.action, + ) + .await?; + } + } + Message::Shutdown => { + cancel_all(&active); + queue.clear(); + discard_inbound(&mut prepared_inbound, &mut active_inbound).await; + shutting_down = true; + } + message => { + let dispatch = runtime.dispatch(message); + if let Some(message) = dispatch.response { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await?; + } + if dispatch.close { + cancel_all(&active); + queue.clear(); + shutting_down = true; + } + } + } + } + } + } + + drop(writer); + writer_task.await??; + Ok(()) +} + +async fn execute_business_request( + request: QueuedRequest, + writer: ProtocolWriter, + sources: Arc>, + payloads: Arc>, + mutations: Arc>, + libraries: Arc>, +) -> Result { + let request_id = request.request_id; + match request.message { + Message::ObservePath { path } => { + let result = tokio::task::spawn_blocking(move || execute_path_observation(&path)) + .await + .map_err(|error| error.to_string())?; + if !request.cancelled.load(Ordering::Acquire) { + let message = result + .map(|kind| Message::PathObserved { kind }) + .unwrap_or_else(error_message); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await + .map_err(|error| error.to_string())?; + } + } + Message::InspectFilesystem { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let inspection_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_inspection(intent, || inspection_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "inspection", + }) + } + }; + let externally_cancelled = request.cancelled.load(Ordering::Acquire) + && !matches!( + result, + Err(RequestError { + code: "deadlineExceeded", + .. + }) + ); + if externally_cancelled { + return Ok(request_id); + } + match result { + Ok(response) => { + let payload = + encode_inspection_response(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: error_message(error), + })) + .await + .map_err(|error| error.to_string())?; + } + } + } + Message::InspectPaths { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_path_metadata(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "pathMetadata", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::CountDirectoryEntries { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_directory_count(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "directoryCount", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::ReadDocuments { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_document_read(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "documentRead", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::ListChildDirectories { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_directory_list(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "directoryList", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::MapPathsToWindows { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_map_windows_paths(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "pathMapping", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::MapHostPaths { request: intent } => { + let result = execute_map_host_paths(intent, Arc::clone(&request.cancelled)).await; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::InspectEntries { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_entry_facts(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = timeout_result(deadline, task, &cancelled, "entryFacts").await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::ProjectTargets { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_projection(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = timeout_result(deadline, task, &cancelled, "projection").await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::ProbeWriteTargets { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_write_probe(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = finish_write_probe(deadline, task, &cancelled).await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::BuildManifest { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_manifest(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = timeout_result(deadline, task, &cancelled, "manifest").await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::AcquireGitSource { request: intent } => { + let result = sources + .lock() + .await + .acquire_git( + GitSourceOptions { + url: intent.url, + git_ref: intent.git_ref, + proxy: intent.proxy, + deadline: Duration::from_millis(intent.deadline_millis), + }, + Arc::clone(&request.cancelled), + ) + .await; + if request.cancelled.load(Ordering::Acquire) { + return Ok(request_id); + } + send_source_result( + &writer, + request_id, + result.map(|source| Message::SourceOpened { + source_id: source.id, + root: source.root.to_string_lossy().into_owned(), + revision: source.revision, + managed_bytes: source.managed_bytes, + }), + ) + .await?; + } + Message::OpenLocalSource { request: intent } => { + let result = sources.lock().await.open_local(&intent.path); + send_source_result( + &writer, + request_id, + result.map(|source| Message::SourceOpened { + source_id: source.id, + root: source.root.to_string_lossy().into_owned(), + revision: source.revision, + managed_bytes: source.managed_bytes, + }), + ) + .await?; + } + Message::ReleaseSource { source_id } => { + let result = sources + .lock() + .await + .release(source_id) + .map(|()| Message::SourceReleased { source_id }); + send_source_result(&writer, request_id, result).await?; + } + Message::ScanSource { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let scan_cancelled = Arc::clone(&cancelled); + let source_manager = Arc::clone(&sources); + let task = tokio::task::spawn_blocking(move || { + let manager = source_manager.blocking_lock(); + scan_source(&manager, intent, || scan_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(SourceError::DeadlineExceeded) + } + }; + if request.cancelled.load(Ordering::Acquire) + && !matches!(result, Err(SourceError::DeadlineExceeded)) + { + return Ok(request_id); + } + match result { + Ok(response) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => send_source_error(&writer, request_id, error).await?, + } + } + Message::SourceFingerprint { + source_id, + relative_path, + deadline_millis, + } => { + let deadline = Duration::from_millis(deadline_millis); + let source_manager = Arc::clone(&sources); + let relative_path = posix_path(relative_path); + let task = tokio::task::spawn_blocking(move || { + source_manager + .blocking_lock() + .fingerprint(source_id, &relative_path) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => Err(SourceError::DeadlineExceeded), + }; + send_source_result( + &writer, + request_id, + result.map(|fingerprint| Message::SourceFingerprintResult { fingerprint }), + ) + .await?; + } + Message::SourceRevision { + source_id, + relative_path, + deadline_millis, + } => { + let result = sources + .lock() + .await + .tree_revision( + source_id, + &posix_path(relative_path), + Duration::from_millis(deadline_millis), + Arc::clone(&request.cancelled), + ) + .await; + if request.cancelled.load(Ordering::Acquire) { + return Ok(request_id); + } + send_source_result( + &writer, + request_id, + result.map(|revision| Message::SourceRevisionResult { revision }), + ) + .await?; + } + Message::ProbeGit { request: intent } => { + let result = probe_git( + GitSourceOptions { + url: intent.url, + git_ref: intent.git_ref, + proxy: intent.proxy, + deadline: Duration::from_millis(intent.deadline_millis), + }, + Arc::clone(&request.cancelled), + ) + .await; + if request.cancelled.load(Ordering::Acquire) { + return Ok(request_id); + } + send_source_result( + &writer, + request_id, + result.map(|revision| Message::GitProbed { revision }), + ) + .await?; + } + Message::AcquirePayloadFromSource { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let build_cancelled = Arc::clone(&cancelled); + let source_manager = Arc::clone(&sources); + let payload_manager = Arc::clone(&payloads); + let relative_path = posix_path(intent.relative_path); + let mut task = tokio::task::spawn_blocking(move || { + let sources = source_manager.blocking_lock(); + let source_root = sources.resolve(intent.source_id, &relative_path)?; + payload_manager + .blocking_lock() + .acquire_from_source_with_cancel( + &intent.session_id, + &intent.payload_name, + &source_root, + || build_cancelled.load(Ordering::Acquire), + ) + .map_err(SourcePayloadError::Payload) + }); + let result = match tokio::time::timeout(deadline, &mut task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + let _ = task.await.map_err(|error| error.to_string())?; + Err(SourcePayloadError::DeadlineExceeded) + } + }; + if request.cancelled.load(Ordering::Acquire) + && !matches!(result, Err(SourcePayloadError::DeadlineExceeded)) + { + return Ok(request_id); + } + match result { + Ok(payload) => { + let encoded = encode_payload(&payload.into_response()) + .map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &encoded) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => send_payload_error(&writer, request_id, error).await?, + } + } + Message::VerifyPayload { request: intent } => { + let payload_manager = Arc::clone(&payloads); + let task = tokio::task::spawn_blocking(move || { + payload_manager + .blocking_lock() + .verify(&intent.session_id, &intent.payload_name) + }); + let result = + match tokio::time::timeout(Duration::from_millis(intent.deadline_millis), task) + .await + { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => Err(PayloadError::StalePayload), + }; + match result { + Ok(payload) => { + let encoded = encode_payload(&payload.map(|payload| payload.into_response())) + .map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &encoded) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_payload_error(&writer, request_id, SourcePayloadError::Payload(error)) + .await? + } + } + } + Message::ReadPayloadBlob { + payload_id, + blob_id, + deadline_millis: _, + } => { + let file = payloads + .lock() + .await + .read_blob(payload_id, &blob_id) + .map_err(SourcePayloadError::Payload); + match file { + Ok(Some(file)) => { + let total_bytes = file.metadata().map_err(|error| error.to_string())?.len(); + writer + .send_reader_transfer_with_limit( + request_id, + request_id, + tokio::fs::File::from_std(file), + total_bytes, + format!("sha256:{blob_id}"), + MAX_PAYLOAD_TRANSFER_BYTES, + ) + .await + .map_err(|error| error.to_string())?; + } + Ok(None) => { + send_payload_error(&writer, request_id, SourcePayloadError::MissingPayload) + .await? + } + Err(error) => send_payload_error(&writer, request_id, error).await?, + } + } + Message::ReadLibraryCatalog { deadline_millis } => { + let result = if deadline_millis == 0 + || deadline_millis > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + Err(LibraryError::InvalidRequest) + } else { + let task = + tokio::task::spawn_blocking(move || libraries.blocking_lock().read_catalog()); + match tokio::time::timeout(Duration::from_millis(deadline_millis), task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + request.cancelled.store(true, Ordering::Release); + send_error(&writer, request_id, "deadlineExceeded", "libraryRead") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + } + }; + match result { + Ok(response) if !request.cancelled.load(Ordering::Acquire) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Ok(_) => {} + Err(error) => send_library_error(&writer, request_id, error, "libraryRead").await?, + } + } + Message::ListLibraryApplications { deadline_millis } => { + let result = if deadline_millis == 0 + || deadline_millis > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + Err(LibraryError::InvalidRequest) + } else { + let task = tokio::task::spawn_blocking(move || { + libraries.blocking_lock().list_applications() + }); + match tokio::time::timeout(Duration::from_millis(deadline_millis), task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + request.cancelled.store(true, Ordering::Release); + send_error(&writer, request_id, "deadlineExceeded", "applicationList") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + } + }; + match result { + Ok(response) if !request.cancelled.load(Ordering::Acquire) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Ok(_) => {} + Err(error) => { + send_library_error(&writer, request_id, error, "applicationList").await? + } + } + } + Message::RemovePayload { + session_id, + payload_name, + } => { + let result = payloads + .lock() + .await + .remove(&session_id, &payload_name) + .map(|()| Message::PayloadRemoved { + session_id, + payload_name, + }) + .map_err(SourcePayloadError::Payload); + send_payload_control_result(&writer, request_id, result).await?; + } + Message::RemovePayloadSession { session_id } => { + let result = payloads + .lock() + .await + .remove_session(&session_id) + .map(|()| Message::PayloadSessionRemoved { session_id }) + .map_err(SourcePayloadError::Payload); + send_payload_control_result(&writer, request_id, result).await?; + } + Message::SweepPayloadOrphans { + protected_session_ids, + } => { + let result = payloads.lock().await.sweep_orphans(&protected_session_ids); + match result { + Ok(report) => { + let encoded = encode_payload(&report).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &encoded) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_payload_error(&writer, request_id, SourcePayloadError::Payload(error)) + .await? + } + } + } + Message::BeginPayloadUpload { + session_id, + payload_name, + } => { + let result = payloads + .lock() + .await + .begin_upload(&session_id, &payload_name) + .map(|upload_id| Message::PayloadUploadBegun { upload_id }) + .map_err(SourcePayloadError::Payload); + send_payload_control_result(&writer, request_id, result).await?; + } + Message::AcknowledgeMutationUnit { cleanup } => { + let manager = mutations.lock_owned().await; + let resource_id = cleanup.resource_id.clone(); + let result = tokio::task::spawn_blocking(move || manager.acknowledge(&cleanup)) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::MutationAcknowledged { resource_id }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: "staleRecovery".to_string(), + phase: "mutationAck".to_string(), + parameters: vec![("message".to_string(), error.to_string())], + }, + })) + .await + .map_err(|error| error.to_string())?; + } + } + } + Message::ListMutationRecovery => { + let manager = mutations.lock_owned().await; + let result = tokio::task::spawn_blocking(move || manager.recovery_store().list()) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(records) => { + let response = environment_protocol::MutationRecoveryList { + records: records + .into_iter() + .map(|record| environment_protocol::MutationRecoveryRecord { + resource_id: record.resource_id, + managed_root: record.managed_root.to_string_lossy().into_owned(), + state: if record.unsafe_root { + environment_protocol::MutationRecoveryState::Unsafe + } else if record.marker_bytes.is_some() { + environment_protocol::MutationRecoveryState::Present + } else { + environment_protocol::MutationRecoveryState::Unreadable + }, + marker_bytes: record.marker_bytes.unwrap_or_default(), + }) + .collect(), + }; + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_error( + &writer, + request_id, + "recoveryUnavailable", + "mutationRecovery", + ) + .await + .map_err(|error| error.to_string())?; + let _ = error; + } + } + } + Message::CleanupMutationRecovery { + resource_id, + expected_marker_json, + backups, + } => { + let manager = mutations.lock_owned().await; + let result = tokio::task::spawn_blocking({ + let resource_id = resource_id.clone(); + move || { + manager.recovery_store().cleanup( + &resource_id, + &expected_marker_json, + &backups.into_iter().map(PathBuf::from).collect::>(), + ) + } + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::MutationRecoveryCleaned { resource_id }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: "staleRecovery".to_string(), + phase: "mutationRecovery".to_string(), + parameters: vec![("message".to_string(), error.to_string())], + }, + })) + .await + .map_err(|error| error.to_string())?; + } + } + } + _ => return Err("non-business message entered the worker queue".to_string()), + } + Ok(request_id) +} + +async fn execute_map_host_paths( + request: MapHostPathsRequest, + cancelled: Arc, +) -> Result { + if request.paths.is_empty() + || request.paths.len() > MAX_INSPECTION_ROOTS + || request + .paths + .iter() + .any(|path| path.is_empty() || path.contains('\0')) + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "pathMapping", + }); + } + + let mapping = async { + let mut mapped = Vec::with_capacity(request.paths.len()); + for path in request.paths { + if cancelled.load(Ordering::Acquire) { + return Err(RequestError { + code: "cancelled", + phase: "pathMapping", + }); + } + mapped.push(map_host_path(&path, Arc::clone(&cancelled)).await?); + } + Ok(MapHostPathsResponse { mapped }) + }; + match tokio::time::timeout(Duration::from_millis(request.deadline_millis), mapping).await { + Ok(result) => result, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "pathMapping", + }) + } + } +} + +#[allow( + clippy::disallowed_methods, + reason = "Environment Worker 是独立 crate,必须直接构造受控的 wslpath 子进程" +)] +async fn map_host_path(path: &str, cancelled: Arc) -> Result { + let mut command = tokio::process::Command::new("wslpath"); + command + .args(["-u", "--", path]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let output = tokio::select! { + output = command.output() => output, + _ = wait_until_cancelled(cancelled) => { + return Err(RequestError { + code: "cancelled", + phase: "pathMapping", + }); + } + } + .map_err(|_| RequestError { + code: "pathUnavailable", + phase: "pathMapping", + })?; + if !output.status.success() || output.stdout.len() > 16 * 1024 { + return Err(RequestError { + code: "pathUnavailable", + phase: "pathMapping", + }); + } + String::from_utf8(output.stdout) + .ok() + .map(|path| path.trim_end_matches(['\r', '\n']).to_string()) + .filter(|path| path.starts_with('/') && !path.contains('\0')) + .ok_or(RequestError { + code: "pathUnavailable", + phase: "pathMapping", + }) +} + +async fn wait_until_cancelled(cancelled: Arc) { + while !cancelled.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +enum SourcePayloadError { + Source(SourceError), + Payload(PayloadError), + MissingPayload, + DeadlineExceeded, +} + +async fn complete_inbound_action( + writer: &ProtocolWriter, + request_id: u64, + payloads: Arc>, + path: PathBuf, + action: InboundAction, +) -> Result<(), String> { + match action { + InboundAction::Blob { upload_id, blob_id } => { + let payload_manager = Arc::clone(&payloads); + let committed_blob_id = blob_id.clone(); + let result = tokio::task::spawn_blocking(move || { + payload_manager + .blocking_lock() + .commit_blob(upload_id, &committed_blob_id, path) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::PayloadBlobUploaded { upload_id, blob_id }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + payloads.lock().await.abort_upload(upload_id); + send_payload_error(writer, request_id, SourcePayloadError::Payload(error)) + .await?; + } + } + } + InboundAction::Manifest { upload_id } => { + let payload_manager = Arc::clone(&payloads); + let result = tokio::task::spawn_blocking(move || { + payload_manager + .blocking_lock() + .finalize_upload(upload_id, path) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(payload) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::PayloadUploadFinalized { + payload_id: payload.id, + }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_payload_error(writer, request_id, SourcePayloadError::Payload(error)) + .await?; + } + } + } + InboundAction::Mutation { .. } => { + return Err("mutation transfer entered payload completion".to_string()); + } + InboundAction::Document { .. } => { + return Err("document transfer entered payload completion".to_string()); + } + InboundAction::Library { .. } => { + return Err("Library transfer entered payload completion".to_string()); + } + } + Ok(()) +} + +async fn execute_mutation_request( + request_id: u64, + resource_id: String, + path: PathBuf, + writer: ProtocolWriter, + payloads: Arc>, + mutations: Arc>, + cancelled: Arc, +) -> Result { + let bytes = tokio::fs::read(&path) + .await + .map_err(|error| error.to_string())?; + let _ = tokio::fs::remove_file(path).await; + let request: environment_protocol::MutationUnitRequest = + environment_protocol::decode_payload(&bytes).map_err(|error| error.to_string())?; + if request.resource_id != resource_id { + return Err("mutation transfer resource does not match its request".to_string()); + } + if cancelled.load(Ordering::Acquire) { + send_mutation_outcome( + &writer, + request_id, + &environment_protocol::MutationUnitOutcome::Cancelled, + ) + .await?; + return Ok(request_id); + } + let started = tokio::time::Instant::now(); + let deadline = Duration::from_millis(request.deadline_millis); + let mutation_guard = tokio::select! { + guard = mutations.lock_owned() => guard, + _ = tokio::time::sleep(deadline) => { + send_mutation_outcome(&writer, request_id, &mutation_deadline_outcome()).await?; + return Ok(request_id); + } + }; + let deadline_elapsed = Arc::new(AtomicBool::new(false)); + let deadline_signal = Arc::clone(&deadline_elapsed); + let deadline_task = tokio::spawn(async move { + tokio::time::sleep(deadline.saturating_sub(started.elapsed())).await; + deadline_signal.store(true, Ordering::Release); + }); + let payload_manager = Arc::clone(&payloads); + let accept_cancelled = Arc::clone(&cancelled); + let accept_deadline = Arc::clone(&deadline_elapsed); + let accepted = tokio::task::spawn_blocking(move || { + let payloads = payload_manager.blocking_lock(); + match mutation_guard.accept(request, &payloads, || { + accept_cancelled.load(Ordering::Acquire) || accept_deadline.load(Ordering::Acquire) + }) { + Ok(accepted) => Ok((mutation_guard, accepted)), + Err(error) => Err((mutation_guard, error)), + } + }) + .await + .map_err(|error| error.to_string())?; + let (mutation_guard, accepted) = match accepted { + Ok(value) => value, + Err((_guard, error)) => { + deadline_task.abort(); + let outcome = if deadline_elapsed.load(Ordering::Acquire) { + mutation_deadline_outcome() + } else { + mutation_accept_error(error, cancelled.load(Ordering::Acquire)) + }; + send_mutation_outcome(&writer, request_id, &outcome).await?; + return Ok(request_id); + } + }; + if MutationManager::requires_acceptance(&accepted) { + writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id, + message: Message::MutationAccepted { + resource_id: resource_id.clone(), + }, + })) + .await + .map_err(|error| error.to_string())?; + } + let execute_cancelled = Arc::clone(&cancelled); + let execute_deadline = Arc::clone(&deadline_elapsed); + let outcome = tokio::task::spawn_blocking(move || { + mutation_guard.execute(accepted, || { + execute_cancelled.load(Ordering::Acquire) || execute_deadline.load(Ordering::Acquire) + }) + }) + .await + .map_err(|error| error.to_string())? + .unwrap_or_else( + |error| environment_protocol::MutationUnitOutcome::RecoveryRequired { + resource_id, + message: error.to_string(), + }, + ); + deadline_task.abort(); + let outcome = if deadline_elapsed.load(Ordering::Acquire) + && matches!( + outcome, + environment_protocol::MutationUnitOutcome::Cancelled + ) { + mutation_deadline_outcome() + } else { + outcome + }; + send_mutation_outcome(&writer, request_id, &outcome).await?; + Ok(request_id) +} + +async fn execute_document_write( + request_id: u64, + preparation: environment_protocol::DocumentWritePreparation, + started: Instant, + path: PathBuf, + writer: ProtocolWriter, + gate: Arc>, + cancelled: Arc, +) -> Result { + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(_) => { + let _ = tokio::fs::remove_file(path).await; + send_error(&writer, request_id, "documentWriteFailed", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + let _ = tokio::fs::remove_file(path).await; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let Some(remaining) = + Duration::from_millis(preparation.deadline_millis).checked_sub(started.elapsed()) + else { + send_error(&writer, request_id, "deadlineExceeded", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + }; + let _guard = match tokio::time::timeout(remaining, gate.lock()).await { + Ok(guard) => guard, + Err(_) => { + send_error(&writer, request_id, "deadlineExceeded", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let expected_digest = format!("sha256:{:x}", sha2::Sha256::digest(&bytes)); + if expected_digest != preparation.sha256 { + send_error(&writer, request_id, "invalidTransfer", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let result = tokio::task::spawn_blocking(move || { + environment_engine::document::write_document_atomic( + PathBuf::from(preparation.path).as_path(), + preparation.expected_revision.as_deref(), + &bytes, + environment_protocol::MAX_DOCUMENT_BYTES as usize, + ) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(revision) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::DocumentWritten { revision }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(environment_engine::document::DocumentWriteError::Conflict) => { + send_error(&writer, request_id, "documentConflict", "documentWrite") + .await + .map_err(|error| error.to_string())?; + } + Err(environment_engine::document::DocumentWriteError::InvalidTarget) => { + send_error(&writer, request_id, "invalidTarget", "documentWrite") + .await + .map_err(|error| error.to_string())?; + } + Err(environment_engine::document::DocumentWriteError::Io { phase, publication }) => { + let code = document_write_error_code(publication); + send_error(&writer, request_id, code, document_write_phase(phase)) + .await + .map_err(|error| error.to_string())?; + } + Err(environment_engine::document::DocumentWriteError::UnsupportedPlatform) => { + send_error(&writer, request_id, "documentWriteFailed", "documentWrite") + .await + .map_err(|error| error.to_string())?; + } + } + Ok(request_id) +} + +async fn execute_library_operation( + request_id: u64, + execution: LibraryExecution, + writer: ProtocolWriter, + payloads: Arc>, + libraries: Arc>, + cancelled: Arc, +) -> Result { + let bytes = match tokio::fs::read(&execution.path).await { + Ok(bytes) => bytes, + Err(_) => { + let _ = tokio::fs::remove_file(execution.path).await; + send_error(&writer, request_id, "libraryIo", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + let _ = tokio::fs::remove_file(execution.path).await; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let request = match decode_payload::(&bytes) { + Ok(request) if request.deadline_millis == execution.deadline_millis => request, + _ => { + send_error(&writer, request_id, "invalidRequest", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + let Some(remaining) = + Duration::from_millis(execution.deadline_millis).checked_sub(execution.started.elapsed()) + else { + send_error(&writer, request_id, "deadlineExceeded", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + }; + let manager = match tokio::time::timeout(remaining, libraries.lock_owned()).await { + Ok(manager) => manager, + Err(_) => { + send_error(&writer, request_id, "deadlineExceeded", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let result = tokio::task::spawn_blocking(move || { + let payloads = payloads.blocking_lock(); + manager.execute(request, &payloads) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(catalog_revision) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::LibraryOperationCompleted { catalog_revision }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => send_library_error(&writer, request_id, error, "library").await?, + } + Ok(request_id) +} + +async fn execute_document_remove( + request_id: u64, + request: environment_protocol::DocumentRemoveRequest, + started: Instant, + writer: ProtocolWriter, + gate: Arc>, + cancelled: Arc, +) -> Result { + let Some(remaining) = + Duration::from_millis(request.deadline_millis).checked_sub(started.elapsed()) + else { + send_error(&writer, request_id, "deadlineExceeded", "documentRemove") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + }; + let _guard = match tokio::time::timeout(remaining, gate.lock()).await { + Ok(guard) => guard, + Err(_) => { + send_error(&writer, request_id, "deadlineExceeded", "documentRemove") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "documentRemove") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let result = tokio::task::spawn_blocking(move || { + environment_engine::document::remove_document_if_revision( + PathBuf::from(request.path).as_path(), + request.expected_revision.as_deref(), + environment_protocol::MAX_DOCUMENT_BYTES as usize, + ) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::DocumentRemoved, + })) + .await + .map_err(|error| error.to_string())?, + Err(environment_engine::document::DocumentWriteError::Conflict) => { + send_error(&writer, request_id, "documentConflict", "documentRemove") + .await + .map_err(|error| error.to_string())? + } + Err(environment_engine::document::DocumentWriteError::InvalidTarget) => { + send_error(&writer, request_id, "invalidTarget", "documentRemove") + .await + .map_err(|error| error.to_string())? + } + Err(environment_engine::document::DocumentWriteError::Io { phase, publication }) => { + send_error( + &writer, + request_id, + document_write_error_code(publication), + document_write_phase(phase), + ) + .await + .map_err(|error| error.to_string())? + } + Err(environment_engine::document::DocumentWriteError::UnsupportedPlatform) => { + send_error(&writer, request_id, "documentWriteFailed", "documentRemove") + .await + .map_err(|error| error.to_string())? + } + } + Ok(request_id) +} + +fn document_write_error_code( + publication: environment_engine::atomic_document::PublicationState, +) -> &'static str { + match publication { + environment_engine::atomic_document::PublicationState::NotPublished => { + "documentWriteFailed" + } + environment_engine::atomic_document::PublicationState::PublishedUnconfirmed => { + "documentPublishedUnconfirmed" + } + environment_engine::atomic_document::PublicationState::OutcomeUnknown => { + "documentOutcomeUnknown" + } + } +} + +fn document_write_phase(phase: environment_engine::atomic_document::WritePhase) -> &'static str { + match phase { + environment_engine::atomic_document::WritePhase::Preparing => "preparing", + environment_engine::atomic_document::WritePhase::Writing => "writing", + environment_engine::atomic_document::WritePhase::BeforePublish => "beforePublish", + environment_engine::atomic_document::WritePhase::Publishing => "publishing", + environment_engine::atomic_document::WritePhase::Confirming => "confirming", + } +} + +async fn send_library_error( + writer: &ProtocolWriter, + request_id: u64, + error: LibraryError, + phase: &'static str, +) -> Result<(), String> { + let code = match error { + LibraryError::InvalidRequest => "invalidRequest", + LibraryError::StaleTarget => "staleTarget", + LibraryError::StalePayload => "stalePayload", + LibraryError::RecoveryIncomplete => "libraryRecoveryIncomplete", + LibraryError::Io => "libraryIo", + }; + send_error(writer, request_id, code, phase) + .await + .map_err(|error| error.to_string()) +} + +fn mutation_deadline_outcome() -> environment_protocol::MutationUnitOutcome { + environment_protocol::MutationUnitOutcome::Failed { + code: "deadlineExceeded".to_string(), + phase: "mutation".to_string(), + parameters: Vec::new(), + message: "WSL mutation deadline exceeded".to_string(), + } +} + +async fn send_mutation_outcome( + writer: &ProtocolWriter, + request_id: u64, + outcome: &environment_protocol::MutationUnitOutcome, +) -> Result<(), String> { + let payload = encode_payload(outcome).map_err(|error| error.to_string())?; + writer + .send_transfer_with_limit( + request_id, + request_id, + &payload, + MAX_MUTATION_TRANSFER_BYTES, + ) + .await + .map_err(|error| error.to_string()) +} + +fn valid_transfer_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) +} + +fn mutation_accept_error( + error: wsl_environment_worker::mutation::WorkerMutationError, + cancelled: bool, +) -> environment_protocol::MutationUnitOutcome { + use environment_engine::linux_mutation::MutationError as EngineError; + use wsl_environment_worker::mutation::WorkerMutationError; + + if cancelled || matches!(error, WorkerMutationError::Engine(EngineError::Cancelled)) { + return environment_protocol::MutationUnitOutcome::Cancelled; + } + let code = match error { + WorkerMutationError::Engine(EngineError::StaleTarget) => "staleTarget", + WorkerMutationError::Payload => "stalePayload", + _ => "invalidMutation", + }; + environment_protocol::MutationUnitOutcome::Failed { + code: code.to_string(), + phase: "accept".to_string(), + parameters: Vec::new(), + message: error.to_string(), + } +} + +impl From for SourcePayloadError { + fn from(error: SourceError) -> Self { + Self::Source(error) + } +} + +async fn send_payload_control_result( + writer: &ProtocolWriter, + request_id: u64, + result: Result, +) -> Result<(), String> { + match result { + Ok(message) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await + .map_err(|error| error.to_string()), + Err(error) => send_payload_error(writer, request_id, error).await, + } +} + +async fn send_payload_error( + writer: &ProtocolWriter, + request_id: u64, + error: SourcePayloadError, +) -> Result<(), String> { + let (code, phase) = match error { + SourcePayloadError::Source(SourceError::MissingSource) => ("staleSource", "payload"), + SourcePayloadError::Source(_) => ("invalidSource", "payload"), + SourcePayloadError::Payload(PayloadError::MissingPayload) + | SourcePayloadError::MissingPayload => ("missingPayload", "payload"), + SourcePayloadError::Payload(PayloadError::Engine( + environment_engine::payload::PayloadError::Cancelled, + )) => ("cancelled", "payload"), + SourcePayloadError::DeadlineExceeded => ("deadlineExceeded", "payload"), + SourcePayloadError::Payload(_) => ("stalePayload", "payload"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: code.to_string(), + phase: phase.to_string(), + parameters: Vec::new(), + }, + })) + .await + .map_err(|error| error.to_string()) +} + +async fn send_source_result( + writer: &ProtocolWriter, + request_id: u64, + result: Result, +) -> Result<(), String> { + match result { + Ok(message) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await + .map_err(|error| error.to_string()), + Err(error) => send_source_error(writer, request_id, error).await, + } +} + +async fn send_source_error( + writer: &ProtocolWriter, + request_id: u64, + error: SourceError, +) -> Result<(), String> { + let (code, phase, parameters) = match error { + SourceError::GitUnavailable { message } => ( + "gitUnavailable", + "git", + vec![("message".to_string(), message)], + ), + SourceError::GitFailed { exit_code, stderr } => ( + "gitFailed", + "git", + vec![ + ( + "exitCode".to_string(), + exit_code.map(|code| code.to_string()).unwrap_or_default(), + ), + ("stderr".to_string(), stderr), + ], + ), + SourceError::DeadlineExceeded => ("deadlineExceeded", "source", Vec::new()), + SourceError::Cancelled => ("cancelled", "source", Vec::new()), + SourceError::MissingSource => ("staleSource", "source", Vec::new()), + SourceError::DirectoryLinksUnsupported => ("sourceDirectoryLinks", "source", Vec::new()), + SourceError::InvalidLocalSource + | SourceError::InvalidRelativePath + | SourceError::InvalidInventory => ("invalidSource", "source", Vec::new()), + SourceError::InvalidManagedBase | SourceError::Io(_) => ("sourceIo", "source", Vec::new()), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: code.to_string(), + phase: phase.to_string(), + parameters, + }, + })) + .await + .map_err(|error| error.to_string()) +} + +#[cfg(unix)] +fn posix_path(bytes: Vec) -> PathBuf { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + PathBuf::from(OsString::from_vec(bytes)) +} + +#[cfg(not(unix))] +fn posix_path(bytes: Vec) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(&bytes).into_owned()) +} + +async fn timeout_result( + deadline: Duration, + task: tokio::task::JoinHandle>, + cancelled: &Arc, + phase: &'static str, +) -> Result, String> { + match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string()), + Err(_) => { + cancelled.store(true, Ordering::Release); + Ok(Err(RequestError { + code: "deadlineExceeded", + phase, + })) + } + } +} + +async fn finish_write_probe( + deadline: Duration, + mut task: tokio::task::JoinHandle< + Result, + >, + cancelled: &Arc, +) -> Result, String> { + match tokio::time::timeout(deadline, &mut task).await { + Ok(joined) => joined.map_err(|error| error.to_string()), + Err(_) => { + cancelled.store(true, Ordering::Release); + // A terminal response must follow the last filesystem side effect. + let _ = task.await.map_err(|error| error.to_string())?; + Ok(Err(RequestError { + code: "deadlineExceeded", + phase: "writePreflight", + })) + } + } +} + +async fn send_payload_result( + request_id: u64, + result: Result, + cancelled: Arc, + writer: ProtocolWriter, +) -> Result<(), String> +where + T: serde::Serialize, +{ + let externally_cancelled = cancelled.load(Ordering::Acquire) + && !matches!( + result, + Err(RequestError { + code: "deadlineExceeded", + .. + }) + ); + if externally_cancelled { + return Ok(()); + } + match result { + Ok(response) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string()) + } + Err(error) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: error_message(error), + })) + .await + .map_err(|error| error.to_string()), + } +} + +async fn send_error( + writer: &ProtocolWriter, + request_id: u64, + code: &'static str, + phase: &'static str, +) -> Result<(), environment_protocol::WriterError> { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: error_message(RequestError { code, phase }), + })) + .await +} + +async fn discard_inbound( + prepared: &mut Option, + active: &mut Option, +) { + if let Some(prepared) = prepared.take() { + drop(prepared.file); + let _ = tokio::fs::remove_file(prepared.path).await; + } + if let Some(active) = active.take() { + let _ = tokio::fs::remove_file(active.path).await; + } +} + +async fn finish_inbound_with_error( + writer: &ProtocolWriter, + request_id: u64, + code: &'static str, + payloads: &Arc>, + path: PathBuf, + action: InboundAction, +) -> Result<(), environment_protocol::WriterError> { + let _ = tokio::fs::remove_file(path).await; + let phase = match action { + InboundAction::Blob { upload_id, .. } | InboundAction::Manifest { upload_id } => { + payloads.lock().await.abort_upload(upload_id); + "payloadUpload" + } + InboundAction::Mutation { .. } => "mutation", + InboundAction::Document { .. } => "documentWrite", + InboundAction::Library { .. } => "library", + }; + send_error(writer, request_id, code, phase).await +} + +fn cancel_all(active: &HashMap>) { + for cancelled in active.values() { + cancelled.store(true, Ordering::Release); + } +} + +#[cfg(unix)] +fn effective_user_id() -> u32 { + unsafe extern "C" { + fn geteuid() -> u32; + } + unsafe { geteuid() } +} + +#[cfg(not(unix))] +fn effective_user_id() -> u32 { + 0 +} + +#[cfg(test)] +mod write_probe_tests { + use super::*; + + #[tokio::test] + async fn write_probe_timeout_waits_for_inflight_filesystem_work() { + let cancelled = Arc::new(AtomicBool::new(false)); + let observed_cancelled = cancelled.clone(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, released) = std::sync::mpsc::channel(); + let task = tokio::task::spawn_blocking(move || { + started.send(()).unwrap(); + released.recv_timeout(Duration::from_secs(2)).unwrap(); + Ok(environment_protocol::WriteProbeResponse { checked_count: 1 }) + }); + ready.await.unwrap(); + let result = + tokio::spawn(async move { finish_write_probe(Duration::ZERO, task, &cancelled).await }); + tokio::time::timeout(Duration::from_secs(1), async { + while !observed_cancelled.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let finished_before_work = result.is_finished(); + release.send(()).unwrap(); + let outcome = result.await.unwrap().unwrap(); + + assert!( + !finished_before_work, + "a write probe cannot acknowledge timeout while filesystem work is still running" + ); + assert!(matches!( + outcome, + Err(RequestError { + code: "deadlineExceeded", + .. + }) + )); + } +} diff --git a/src-tauri/crates/wsl-environment-worker/src/mutation.rs b/src-tauri/crates/wsl-environment-worker/src/mutation.rs new file mode 100644 index 00000000..8c569dae --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/mutation.rs @@ -0,0 +1,949 @@ +use std::fmt; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use environment_engine::linux_mutation::{ + validate_intents, EntryAction, EntryIntent, MutationError as EngineMutationError, + ParentIdentity, StagedMutation, +}; +use environment_engine::lock::{ + self as engine_lock, EntryMutation as EngineLockEntry, LockMutation as EngineLockMutation, + LockSchema as EngineLockSchema, +}; +use environment_protocol::{ + MutationCleanupToken, MutationEntryAction, MutationLock, MutationLockEntry, + MutationLockReceipt, MutationLockSchema, MutationUnitOutcome, MutationUnitRequest, + MAX_REQUEST_DEADLINE_MILLIS, +}; + +use crate::payload::PayloadManager; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryFile { + pub resource_id: String, + pub managed_root: PathBuf, + pub marker_bytes: Option>, + pub unsafe_root: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MutationRecoveryError { + InvalidBase, + InvalidResource, + UnsafeRoot, + StaleMarker, + Io { message: String }, +} + +impl fmt::Display for MutationRecoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for MutationRecoveryError {} + +impl From for MutationRecoveryError { + fn from(error: std::io::Error) -> Self { + Self::Io { + message: error.to_string(), + } + } +} + +pub struct MutationRecoveryStore { + namespace: PathBuf, +} + +pub struct MutationManager { + recovery: MutationRecoveryStore, +} + +pub struct AcceptedMutation { + request: MutationUnitRequest, + intents: Vec, + marker: Option, +} + +#[derive(Debug)] +pub enum WorkerMutationError { + InvalidRequest, + Payload, + Engine(EngineMutationError), + Lock(engine_lock::LockError), + Recovery(MutationRecoveryError), + LockCommitUncertain { message: String }, + Io(std::io::Error), + Json(serde_json::Error), +} + +impl fmt::Display for WorkerMutationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for WorkerMutationError {} + +impl From for WorkerMutationError { + fn from(error: MutationRecoveryError) -> Self { + Self::Recovery(error) + } +} + +impl From for WorkerMutationError { + fn from(error: EngineMutationError) -> Self { + Self::Engine(error) + } +} + +impl From for WorkerMutationError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for WorkerMutationError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +impl MutationManager { + pub fn new(namespace: PathBuf) -> Result { + Ok(Self { + recovery: MutationRecoveryStore::new(namespace)?, + }) + } + + pub fn accept( + &self, + request: MutationUnitRequest, + payloads: &PayloadManager, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + if request.entries.is_empty() + || request.resource_id.is_empty() + || request.operation_id.is_empty() + || request.unit_id.is_empty() + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(WorkerMutationError::InvalidRequest); + } + let intents = request + .entries + .iter() + .map(|entry| { + let action = match &entry.action { + MutationEntryAction::Keep => EntryAction::Keep, + MutationEntryAction::Materialize { payload_id } => EntryAction::Materialize { + payload_root: payloads + .payload_root(*payload_id) + .map_err(|_| WorkerMutationError::Payload)?, + }, + MutationEntryAction::Symlink { target } => { + let target = PathBuf::from(target); + if target.is_absolute() || target.as_os_str().is_empty() { + return Err(WorkerMutationError::InvalidRequest); + } + EntryAction::Symlink { target } + } + MutationEntryAction::Remove => EntryAction::Remove, + }; + Ok(EntryIntent { + destination: PathBuf::from(&entry.destination), + expected_parent: ParentIdentity { + device: entry.expected_anchor_device, + inode: entry.expected_anchor_inode, + }, + expected_fingerprint: entry.expected_fingerprint.clone(), + expected_content_hash: entry.expected_content_hash.clone(), + action, + }) + }) + .collect::, WorkerMutationError>>()?; + validate_intents(&intents, is_cancelled).map_err(WorkerMutationError::Engine)?; + let requires_recovery = request.lock.is_some() + || request + .entries + .iter() + .any(|entry| !matches!(entry.action, MutationEntryAction::Keep)); + let marker = if requires_recovery { + let marker: serde_json::Value = serde_json::from_slice(&request.initial_marker_json)?; + validate_initial_marker(&request, &marker)?; + self.recovery + .create(&request.resource_id, &request.initial_marker_json)?; + Some(marker) + } else { + None + }; + Ok(AcceptedMutation { + request, + intents, + marker, + }) + } + + pub fn execute( + &self, + mut accepted: AcceptedMutation, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + let intents = std::mem::take(&mut accepted.intents); + let mut staged = + match StagedMutation::stage(&accepted.request.resource_id, intents, &is_cancelled) { + Ok(staged) => staged, + Err(error) => { + if accepted.marker.is_some() { + self.remove_recovery(&accepted.request.resource_id)?; + } + return Ok(engine_failure("stage", error)); + } + }; + let mut lock_committed = false; + let transaction = (|| { + staged.swap(&is_cancelled)?; + update_optional_marker_phase(&mut accepted.marker, "inProgress", Some("swapped")); + self.write_marker(&accepted)?; + staged.verify(|| false)?; + update_optional_marker_phase(&mut accepted.marker, "inProgress", Some("verified")); + self.write_marker(&accepted)?; + let lock = match accepted.request.lock.as_ref().map(apply_lock).transpose() { + Ok(lock) => { + lock_committed = lock.is_some(); + lock + } + Err(error @ WorkerMutationError::LockCommitUncertain { .. }) => { + lock_committed = true; + return Err(error); + } + Err(error) => return Err(error), + }; + update_optional_marker_phase(&mut accepted.marker, "inProgress", Some("lockCommitted")); + self.write_marker(&accepted)?; + Ok::<_, WorkerMutationError>(lock) + })(); + let lock = match transaction { + Ok(lock) => lock, + Err(error) if lock_committed => { + let stage_cleanup = staged.cleanup_stages(); + update_optional_marker_phase( + &mut accepted.marker, + "recoveryRequired", + Some("lockCommitted"), + ); + let marker_error = self.write_marker(&accepted).err(); + let mut message = marker_error.map_or_else( + || error.to_string(), + |marker_error| format!("{error}; {marker_error}"), + ); + if !stage_cleanup.is_empty() { + message.push_str(&format!( + "; stage cleanup failed: {}", + stage_cleanup.join("; ") + )); + } + return Ok(MutationUnitOutcome::RecoveryRequired { + resource_id: accepted.request.resource_id, + message, + }); + } + Err(error) => { + let primary = error.to_string(); + let (code, parameters) = transaction_error_fields(&error); + return match staged.restore() { + Ok(()) => { + let _ = staged.cleanup(); + if accepted.marker.is_some() { + self.remove_recovery(&accepted.request.resource_id)?; + } + Ok(MutationUnitOutcome::Failed { + code, + phase: "commit".to_string(), + parameters, + message: primary, + }) + } + Err(restore) => { + let stage_cleanup = staged.cleanup_stages(); + update_optional_marker_phase( + &mut accepted.marker, + "recoveryRequired", + Some("restoreFailed"), + ); + if accepted.marker.is_some() { + self.write_marker(&accepted)?; + let mut message = format!("{primary}; {restore}"); + if !stage_cleanup.is_empty() { + message.push_str(&format!( + "; stage cleanup failed: {}", + stage_cleanup.join("; ") + )); + } + Ok(MutationUnitOutcome::RecoveryRequired { + resource_id: accepted.request.resource_id, + message, + }) + } else { + Ok(MutationUnitOutcome::Failed { + code: "restoreFailed".to_string(), + phase: "restore".to_string(), + parameters: Vec::new(), + message: format!("{primary}; {restore}"), + }) + } + } + }; + } + }; + update_optional_marker_phase(&mut accepted.marker, "cleanupOnly", None); + self.write_marker(&accepted)?; + let cleanup = if accepted.marker.is_some() { + Some(MutationCleanupToken { + resource_id: accepted.request.resource_id.clone(), + marker_sha256: self.recovery.marker_digest(&accepted.request.resource_id)?, + }) + } else { + None + }; + Ok(MutationUnitOutcome::Succeeded { lock, cleanup }) + } + + pub fn acknowledge(&self, cleanup: &MutationCleanupToken) -> Result<(), WorkerMutationError> { + self.recovery + .acknowledge(&cleanup.resource_id, &cleanup.marker_sha256)?; + Ok(()) + } + + pub fn requires_acceptance(accepted: &AcceptedMutation) -> bool { + accepted.marker.is_some() + } + + pub fn recovery_store(&self) -> &MutationRecoveryStore { + &self.recovery + } + + fn write_marker(&self, accepted: &AcceptedMutation) -> Result<(), WorkerMutationError> { + let Some(marker) = &accepted.marker else { + return Ok(()); + }; + self.recovery.update( + &accepted.request.resource_id, + &serde_json::to_vec_pretty(marker)?, + )?; + Ok(()) + } + + fn remove_recovery(&self, resource_id: &str) -> Result<(), WorkerMutationError> { + self.recovery.remove(resource_id)?; + Ok(()) + } +} + +impl MutationRecoveryStore { + pub fn new(namespace: PathBuf) -> Result { + if !namespace.is_absolute() { + return Err(MutationRecoveryError::InvalidBase); + } + fs::create_dir_all(&namespace)?; + let namespace = fs::canonicalize(namespace)?; + if !namespace.is_dir() { + return Err(MutationRecoveryError::InvalidBase); + } + Ok(Self { namespace }) + } + + pub fn list(&self) -> Result, MutationRecoveryError> { + let mut entries = fs::read_dir(&self.namespace)? + .collect::, _>>()? + .into_iter() + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("skill-deck-operation-") + }) + .collect::>(); + entries.sort_by_key(fs::DirEntry::file_name); + Ok(entries + .into_iter() + .map(|entry| { + let root = entry.path(); + let resource_id = entry + .file_name() + .to_string_lossy() + .trim_start_matches("skill-deck-operation-") + .to_string(); + let safe = self.validate_root(&root, &resource_id).is_ok(); + let marker_bytes = safe + .then(|| fs::read(root.join("recovery.json"))) + .transpose() + .ok() + .flatten(); + RecoveryFile { + resource_id, + managed_root: root, + marker_bytes, + unsafe_root: !safe, + } + }) + .collect()) + } + + pub fn create( + &self, + resource_id: &str, + marker_bytes: &[u8], + ) -> Result { + validate_resource_id(resource_id)?; + if marker_bytes.is_empty() { + return Err(MutationRecoveryError::StaleMarker); + } + let root = self.root(resource_id); + if fs::symlink_metadata(&root).is_ok() { + return Err(MutationRecoveryError::StaleMarker); + } + fs::create_dir(&root)?; + set_private_directory(&root)?; + let result = (|| { + fs::write( + root.join(".skill-deck-owner"), + format!("1\n{resource_id}\n"), + )?; + write_atomic(&root.join("recovery.json"), marker_bytes) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&root); + } + result?; + Ok(root) + } + + pub fn update( + &self, + resource_id: &str, + marker_bytes: &[u8], + ) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + if marker_bytes.is_empty() { + return Err(MutationRecoveryError::StaleMarker); + } + write_atomic(&root.join("recovery.json"), marker_bytes) + } + + pub fn cleanup( + &self, + resource_id: &str, + expected_marker: &[u8], + backups: &[PathBuf], + ) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + let marker = fs::read(root.join("recovery.json"))?; + if marker != expected_marker { + return Err(MutationRecoveryError::StaleMarker); + } + let expected_backups = marker_backups(&marker)?; + if expected_backups + .iter() + .map(|(_, backup)| backup) + .ne(backups.iter()) + { + return Err(MutationRecoveryError::StaleMarker); + } + for (destination, backup) in &expected_backups { + validate_backup(resource_id, destination, backup, &root)?; + } + for (_, backup) in &expected_backups { + remove_no_follow(&stage_path_for_backup(resource_id, backup)?)?; + } + for (_, backup) in expected_backups { + remove_no_follow(&backup)?; + } + fs::remove_dir_all(root)?; + Ok(()) + } + + pub fn remove(&self, resource_id: &str) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + fs::remove_dir_all(root)?; + Ok(()) + } + + pub fn marker_digest(&self, resource_id: &str) -> Result { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + Ok(format!( + "sha256:{:x}", + Sha256::digest(fs::read(root.join("recovery.json"))?) + )) + } + + pub fn acknowledge( + &self, + resource_id: &str, + expected_digest: &str, + ) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + let marker = fs::read(root.join("recovery.json"))?; + let actual = format!("sha256:{:x}", Sha256::digest(&marker)); + if actual != expected_digest { + return Err(MutationRecoveryError::StaleMarker); + } + let backups = marker_backups(&marker)? + .into_iter() + .map(|(_, backup)| backup) + .collect::>(); + self.cleanup(resource_id, &marker, &backups) + } + + fn root(&self, resource_id: &str) -> PathBuf { + self.namespace + .join(format!("skill-deck-operation-{resource_id}")) + } + + fn validate_root(&self, root: &Path, resource_id: &str) -> Result<(), MutationRecoveryError> { + validate_resource_id(resource_id)?; + if root != self.root(resource_id) { + return Err(MutationRecoveryError::UnsafeRoot); + } + let metadata = fs::symlink_metadata(root)?; + let owner = root.join(".skill-deck-owner"); + let owner_metadata = fs::symlink_metadata(&owner)?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || !owner_metadata.is_file() + || owner_metadata.file_type().is_symlink() + || fs::read_to_string(owner)? != format!("1\n{resource_id}\n") + { + return Err(MutationRecoveryError::UnsafeRoot); + } + Ok(()) + } +} + +fn validate_initial_marker( + request: &MutationUnitRequest, + marker: &serde_json::Value, +) -> Result<(), WorkerMutationError> { + if marker.get("resourceId").and_then(serde_json::Value::as_str) != Some(&request.resource_id) + || marker + .get("operationId") + .and_then(serde_json::Value::as_str) + != Some(&request.operation_id) + || marker.get("unitId").and_then(serde_json::Value::as_str) != Some(&request.unit_id) + || marker.get("kind").and_then(serde_json::Value::as_str) != Some("inProgress") + { + return Err(WorkerMutationError::InvalidRequest); + } + let marker_entries = marker + .get("entries") + .and_then(serde_json::Value::as_array) + .ok_or(WorkerMutationError::InvalidRequest)?; + let changed = request + .entries + .iter() + .enumerate() + .filter(|(_, entry)| !matches!(entry.action, MutationEntryAction::Keep)) + .collect::>(); + let evidenced = if changed.is_empty() && request.lock.is_some() { + request.entries.iter().enumerate().take(1).collect() + } else { + changed + }; + if marker_entries.len() != evidenced.len() { + return Err(WorkerMutationError::InvalidRequest); + } + for (marker_entry, (index, request_entry)) in marker_entries.iter().zip(evidenced) { + let destination = marker_entry + .get("destination") + .and_then(|value| value.get("nativePath")) + .and_then(serde_json::Value::as_str); + let backup = marker_entry + .get("backup") + .and_then(|value| value.get("nativePath")) + .and_then(serde_json::Value::as_str); + let destination_path = Path::new(&request_entry.destination); + let expected_backup = if matches!(request_entry.action, MutationEntryAction::Keep) { + None + } else { + destination_path.parent().map(|parent| { + parent.join(format!( + ".skill-deck-backup-{}-{index:06}", + request.resource_id + )) + }) + }; + if destination != Some(request_entry.destination.as_str()) + || backup.map(Path::new) != expected_backup.as_deref() + { + return Err(WorkerMutationError::InvalidRequest); + } + } + Ok(()) +} + +fn update_marker_phase(marker: &mut serde_json::Value, kind: &str, phase: Option<&str>) { + marker["kind"] = serde_json::Value::String(kind.to_string()); + if let (Some(phase), Some(entries)) = ( + phase, + marker + .get_mut("entries") + .and_then(serde_json::Value::as_array_mut), + ) { + for entry in entries { + entry["phase"] = serde_json::Value::String(phase.to_string()); + } + } +} + +fn update_optional_marker_phase( + marker: &mut Option, + kind: &str, + phase: Option<&str>, +) { + if let Some(marker) = marker { + update_marker_phase(marker, kind, phase); + } +} + +fn apply_lock(lock: &MutationLock) -> Result { + let target = Path::new(&lock.target); + if !target.is_absolute() + || lock + .legacy_target + .as_deref() + .is_some_and(|path| !Path::new(path).is_absolute()) + { + return Err(WorkerMutationError::InvalidRequest); + } + let current = read_optional(target)?; + let legacy = match (¤t, &lock.legacy_target) { + (None, Some(path)) => read_optional(Path::new(path))?, + _ => None, + }; + let mutation = engine_lock_mutation(lock)?; + let applied = engine_lock::apply(current.as_deref(), legacy.as_deref(), &mutation) + .map_err(WorkerMutationError::Lock)?; + write_document_atomic(target, &applied.bytes)?; + Ok(MutationLockReceipt { + entries_json: applied + .receipt + .entries + .into_iter() + .map(|(key, value)| { + value + .map(|value| serde_json::to_vec(&value)) + .transpose() + .map(|value| (key, value)) + }) + .collect::>()?, + roots_json: applied + .receipt + .roots + .into_iter() + .map(|(field, value)| { + value + .map(|value| serde_json::to_vec(&value)) + .transpose() + .map(|value| (field, value)) + }) + .collect::>()?, + }) +} + +fn engine_lock_mutation(lock: &MutationLock) -> Result { + let parse = |bytes: &[u8]| serde_json::from_slice(bytes).map_err(WorkerMutationError::from); + Ok(EngineLockMutation { + schema: match lock.schema { + MutationLockSchema::Global => EngineLockSchema::Global, + MutationLockSchema::Project => EngineLockSchema::Project, + }, + entry: match &lock.entry { + MutationLockEntry::Replace { + key, + replacement_json, + } => EngineLockEntry::Replace { + key: key.clone(), + replacement: parse(replacement_json)?, + }, + MutationLockEntry::Remove { key } => EngineLockEntry::Remove { key: key.clone() }, + MutationLockEntry::MoveAndReplace { + from, + to, + replacement_json, + } => EngineLockEntry::MoveAndReplace { + from: from.clone(), + to: to.clone(), + replacement: parse(replacement_json)?, + }, + }, + root_replacements: lock + .root_replacements_json + .iter() + .map(|(field, bytes)| parse(bytes).map(|value| (field.clone(), value))) + .collect::>()?, + expected_entries: lock + .expected_entries_json + .iter() + .map(|(key, bytes)| { + bytes + .as_deref() + .map(parse) + .transpose() + .map(|value| (key.clone(), value)) + }) + .collect::>()?, + expected_roots: lock + .expected_roots_json + .iter() + .map(|(field, bytes)| { + bytes + .as_deref() + .map(parse) + .transpose() + .map(|value| (field.clone(), value)) + }) + .collect::>()?, + }) +} + +fn read_optional(path: &Path) -> Result>, WorkerMutationError> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn write_document_atomic(path: &Path, bytes: &[u8]) -> Result<(), WorkerMutationError> { + let parent = path.parent().ok_or(WorkerMutationError::InvalidRequest)?; + fs::create_dir_all(parent)?; + let legacy_backup = PathBuf::from(format!("{}.bak", path.display())); + match fs::remove_file(&legacy_backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let temporary = parent.join(format!(".skill-deck-document.{}", std::process::id())); + if fs::symlink_metadata(&temporary).is_ok() { + return Err(WorkerMutationError::InvalidRequest); + } + let mut committed = false; + let result = (|| { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + set_private_file(&temporary)?; + use std::io::Write; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + committed = true; + fs::File::open(parent)?.sync_all()?; + Ok::<_, std::io::Error>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + match result { + Ok(()) => Ok(()), + Err(error) if committed => Err(WorkerMutationError::LockCommitUncertain { + message: error.to_string(), + }), + Err(error) => Err(error.into()), + } +} + +fn engine_failure(phase: &str, error: EngineMutationError) -> MutationUnitOutcome { + if error == EngineMutationError::Cancelled { + MutationUnitOutcome::Cancelled + } else { + MutationUnitOutcome::Failed { + code: match error { + EngineMutationError::StaleTarget => "staleTarget", + EngineMutationError::InvalidPayload => "stalePayload", + _ => "executionFailed", + } + .to_string(), + phase: phase.to_string(), + parameters: Vec::new(), + message: error.to_string(), + } + } +} + +fn transaction_error_fields(error: &WorkerMutationError) -> (String, Vec<(String, String)>) { + match error { + WorkerMutationError::Lock(engine_lock::LockError::EntryConflict { key }) => ( + "lockConflictSkill".to_string(), + vec![("skillName".to_string(), key.clone())], + ), + WorkerMutationError::Lock(engine_lock::LockError::RootConflict { field }) => ( + "lockConflictRoot".to_string(), + vec![("field".to_string(), field.clone())], + ), + _ => ("transactionFailed".to_string(), Vec::new()), + } +} + +fn validate_resource_id(resource_id: &str) -> Result<(), MutationRecoveryError> { + if resource_id.is_empty() + || resource_id.len() > 128 + || !resource_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + Err(MutationRecoveryError::InvalidResource) + } else { + Ok(()) + } +} + +fn validate_backup( + resource_id: &str, + destination: &Path, + backup: &Path, + root: &Path, +) -> Result<(), MutationRecoveryError> { + let name = backup + .file_name() + .and_then(|name| name.to_str()) + .ok_or(MutationRecoveryError::UnsafeRoot)?; + if !backup.is_absolute() + || !destination.is_absolute() + || unsafe_lexical_path(backup) + || unsafe_lexical_path(destination) + || backup == root + || backup == destination + || backup.parent() != destination.parent() + || !name.starts_with(&format!(".skill-deck-backup-{resource_id}-")) + { + return Err(MutationRecoveryError::UnsafeRoot); + } + Ok(()) +} + +fn marker_backups(marker: &[u8]) -> Result, MutationRecoveryError> { + let value: serde_json::Value = + serde_json::from_slice(marker).map_err(|_| MutationRecoveryError::StaleMarker)?; + value + .get("entries") + .and_then(serde_json::Value::as_array) + .ok_or(MutationRecoveryError::StaleMarker)? + .iter() + .filter_map(|entry| { + let backup = entry.get("backup")?; + (!backup.is_null()).then_some((entry, backup)) + }) + .map(|(entry, backup)| { + let destination = entry + .get("destination") + .and_then(|value| value.get("nativePath")) + .and_then(serde_json::Value::as_str) + .ok_or(MutationRecoveryError::StaleMarker)?; + let backup = backup + .get("nativePath") + .and_then(serde_json::Value::as_str) + .ok_or(MutationRecoveryError::StaleMarker)?; + Ok((PathBuf::from(destination), PathBuf::from(backup))) + }) + .collect() +} + +fn stage_path_for_backup( + resource_id: &str, + backup: &Path, +) -> Result { + let name = backup + .file_name() + .and_then(|name| name.to_str()) + .ok_or(MutationRecoveryError::UnsafeRoot)?; + let suffix = name + .strip_prefix(&format!(".skill-deck-backup-{resource_id}-")) + .filter(|suffix| !suffix.is_empty()) + .ok_or(MutationRecoveryError::UnsafeRoot)?; + Ok(backup.with_file_name(format!(".skill-deck-stage-{resource_id}-{suffix}"))) +} + +fn unsafe_lexical_path(path: &Path) -> bool { + path.components().any(|component| { + matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + ) + }) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), MutationRecoveryError> { + let parent = path.parent().ok_or(MutationRecoveryError::UnsafeRoot)?; + let temporary = parent.join(format!(".recovery-{}", std::process::id())); + if fs::symlink_metadata(&temporary).is_ok() { + return Err(MutationRecoveryError::StaleMarker); + } + let result = (|| { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + set_private_file(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + fs::File::open(parent)?.sync_all()?; + Ok::<_, std::io::Error>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result.map_err(Into::into) +} + +fn remove_no_follow(path: &Path) -> Result<(), MutationRecoveryError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + fs::remove_dir_all(path)? + } + Ok(_) => fs::remove_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +#[cfg(unix)] +fn set_private_directory(path: &Path) -> Result<(), MutationRecoveryError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_directory(_path: &Path) -> Result<(), MutationRecoveryError> { + Ok(()) +} + +#[cfg(unix)] +fn set_private_file(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_private_file(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} diff --git a/src-tauri/crates/wsl-environment-worker/src/payload.rs b/src-tauri/crates/wsl-environment-worker/src/payload.rs new file mode 100644 index 00000000..542e5ca1 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/payload.rs @@ -0,0 +1,657 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use environment_engine::payload::{ + build_payload_with_cancel, read_blob, verify_payload, BuiltPayload, + PayloadEntryKind as EngineEntryKind, PayloadError as EngineError, + PayloadManifest as EngineManifest, +}; +use environment_protocol::{ + PayloadCleanupResponse, PayloadCleanupWarning, PayloadEntry, PayloadEntryKind, PayloadManifest, + PayloadReadyResponse, +}; + +const OWNER_FILE: &str = ".skill-deck-owner"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedPayload { + pub id: u64, + pub root: PathBuf, + pub manifest: PayloadManifest, + pub total_bytes: u64, + pub computed_hash: Option, +} + +impl ManagedPayload { + pub fn into_response(self) -> PayloadReadyResponse { + PayloadReadyResponse { + payload_id: self.id, + manifest: self.manifest, + total_bytes: self.total_bytes, + computed_hash: self.computed_hash, + } + } +} + +#[derive(Debug)] +pub enum PayloadError { + InvalidBase, + InvalidSession, + InvalidPayloadName, + MissingPayload, + StalePayload, + Engine(EngineError), + Json(serde_json::Error), + Io(std::io::Error), +} + +impl fmt::Display for PayloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for PayloadError {} + +impl From for PayloadError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for PayloadError { + fn from(error: EngineError) -> Self { + Self::Engine(error) + } +} + +impl From for PayloadError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +struct PayloadRecord { + root: PathBuf, +} + +pub struct PayloadManager { + base: PathBuf, + next_id: u64, + payloads: HashMap, + uploads: HashMap, +} + +struct UploadRecord { + stage: PathBuf, + final_root: PathBuf, +} + +pub struct PreparedPayloadFile { + pub path: PathBuf, + pub file: fs::File, +} + +impl PayloadManager { + pub fn new(base: PathBuf) -> Result { + if !base.is_absolute() { + return Err(PayloadError::InvalidBase); + } + fs::create_dir_all(&base)?; + let base = fs::canonicalize(base)?; + if !base.is_dir() { + return Err(PayloadError::InvalidBase); + } + Ok(Self { + base, + next_id: 1, + payloads: HashMap::new(), + uploads: HashMap::new(), + }) + } + + pub fn acquire_from_source( + &mut self, + session_id: &str, + payload_name: &str, + source_root: &Path, + ) -> Result { + self.acquire_from_source_with_cancel(session_id, payload_name, source_root, || false) + } + + pub fn acquire_from_source_with_cancel( + &mut self, + session_id: &str, + payload_name: &str, + source_root: &Path, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + let session_root = self.ensure_session(session_id)?; + validate_payload_name(payload_name)?; + let payload_root = session_root.join(payload_name); + if fs::symlink_metadata(&payload_root).is_ok() { + return Err(PayloadError::StalePayload); + } + let id = self.allocate_id()?; + let stage = session_root.join(format!(".stage-{payload_name}-{id}")); + if fs::symlink_metadata(&stage).is_ok() { + return Err(PayloadError::StalePayload); + } + let built = build_payload_with_cancel(source_root, &stage, is_cancelled)?; + if let Err(error) = fs::rename(&stage, &payload_root) { + let _ = fs::remove_dir_all(&stage); + return Err(error.into()); + } + Ok(self.register_built(id, payload_root, built)) + } + + pub fn verify( + &mut self, + session_id: &str, + payload_name: &str, + ) -> Result, PayloadError> { + let session_root = self.session_root(session_id)?; + validate_payload_name(payload_name)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + let payload_root = session_root.join(payload_name); + match fs::symlink_metadata(&payload_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + Ok(_) => {} + } + let manifest = verify_payload(&payload_root)?; + let total_bytes = unique_total_bytes(&manifest); + let id = self.allocate_id()?; + self.payloads.insert( + id, + PayloadRecord { + root: payload_root.clone(), + }, + ); + Ok(Some(ManagedPayload { + id, + root: payload_root, + manifest: map_manifest(manifest), + total_bytes, + computed_hash: None, + })) + } + + pub fn begin_upload( + &mut self, + session_id: &str, + payload_name: &str, + ) -> Result { + let session_root = self.ensure_session(session_id)?; + validate_payload_name(payload_name)?; + let final_root = session_root.join(payload_name); + let stage = session_root.join(format!("{payload_name}.upload")); + if fs::symlink_metadata(&final_root).is_ok() || fs::symlink_metadata(&stage).is_ok() { + return Err(PayloadError::StalePayload); + } + fs::create_dir(&stage)?; + set_private_directory(&stage)?; + fs::create_dir(stage.join("blobs"))?; + let upload_id = self.allocate_id()?; + self.uploads + .insert(upload_id, UploadRecord { stage, final_root }); + Ok(upload_id) + } + + pub fn prepare_blob( + &self, + upload_id: u64, + blob_id: &str, + ) -> Result { + if !valid_blob_id(blob_id) { + return Err(PayloadError::StalePayload); + } + let upload = self + .uploads + .get(&upload_id) + .ok_or(PayloadError::StalePayload)?; + let path = upload.stage.join(format!(".incoming-blob-{blob_id}")); + prepare_file(path) + } + + pub fn commit_blob( + &self, + upload_id: u64, + blob_id: &str, + incoming: PathBuf, + ) -> Result<(), PayloadError> { + let upload = self + .uploads + .get(&upload_id) + .ok_or(PayloadError::StalePayload)?; + let expected = upload.stage.join(format!(".incoming-blob-{blob_id}")); + if incoming != expected || file_sha256(&incoming)? != blob_id { + return Err(PayloadError::StalePayload); + } + let destination = upload.stage.join("blobs").join(blob_id); + if fs::symlink_metadata(&destination).is_ok() { + return Err(PayloadError::StalePayload); + } + fs::rename(incoming, &destination)?; + set_private_file(&destination)?; + Ok(()) + } + + pub fn prepare_manifest(&self, upload_id: u64) -> Result { + let upload = self + .uploads + .get(&upload_id) + .ok_or(PayloadError::StalePayload)?; + prepare_file(upload.stage.join(".incoming-manifest")) + } + + pub fn finalize_upload( + &mut self, + upload_id: u64, + incoming_manifest: PathBuf, + ) -> Result { + let upload = self + .uploads + .remove(&upload_id) + .ok_or(PayloadError::StalePayload)?; + let result = finalize_upload_record(&upload, incoming_manifest).and_then(|manifest| { + fs::rename(&upload.stage, &upload.final_root)?; + let id = self.allocate_id()?; + let total_bytes = unique_total_bytes(&manifest); + self.payloads.insert( + id, + PayloadRecord { + root: upload.final_root.clone(), + }, + ); + Ok(ManagedPayload { + id, + root: upload.final_root.clone(), + manifest: map_manifest(manifest), + total_bytes, + computed_hash: None, + }) + }); + if result.is_err() { + let _ = fs::remove_dir_all(&upload.stage); + } + result + } + + pub fn abort_upload(&mut self, upload_id: u64) { + if let Some(upload) = self.uploads.remove(&upload_id) { + let _ = fs::remove_dir_all(upload.stage); + } + } + + pub fn read_blob( + &self, + payload_id: u64, + blob_id: &str, + ) -> Result, PayloadError> { + let payload = self + .payloads + .get(&payload_id) + .ok_or(PayloadError::MissingPayload)?; + read_blob(&payload.root, blob_id).map_err(Into::into) + } + + pub fn payload_root(&self, payload_id: u64) -> Result { + self.payloads + .get(&payload_id) + .map(|payload| payload.root.clone()) + .ok_or(PayloadError::MissingPayload) + } + + pub fn remove(&mut self, session_id: &str, payload_name: &str) -> Result<(), PayloadError> { + let session_root = self.session_root(session_id)?; + validate_payload_name(payload_name)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + let payload_root = session_root.join(payload_name); + let upload_ids = self + .uploads + .iter() + .filter_map(|(upload_id, upload)| { + (upload.final_root == payload_root).then_some(*upload_id) + }) + .collect::>(); + for upload_id in upload_ids { + self.abort_upload(upload_id); + } + match fs::symlink_metadata(&payload_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + fs::remove_dir_all(&payload_root)?; + } + Ok(_) => return Err(PayloadError::StalePayload), + } + self.payloads + .retain(|_, record| record.root != payload_root); + Ok(()) + } + + pub fn remove_session(&mut self, session_id: &str) -> Result<(), PayloadError> { + let session_root = self.session_root(session_id)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + fs::remove_dir_all(&session_root)?; + self.payloads + .retain(|_, record| !record.root.starts_with(&session_root)); + self.uploads + .retain(|_, upload| !upload.final_root.starts_with(&session_root)); + Ok(()) + } + + pub fn sweep_orphans( + &mut self, + protected_session_ids: &[String], + ) -> Result { + let protected = protected_session_ids.iter().collect::>(); + let mut report = PayloadCleanupResponse { + removed_sessions: 0, + protected_sessions: 0, + retained_external_bytes: 0, + cleanup_blocked: false, + warnings: Vec::new(), + }; + for entry in fs::read_dir(&self.base)?.collect::, _>>()? { + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(session_id) = name.strip_prefix("skill-deck-source-") else { + continue; + }; + let path = entry.path(); + if self.validate_session(&path, session_id).is_err() { + retain_external(&mut report, &path, &name, "invalidMarker"); + continue; + } + if protected.contains(&session_id.to_string()) { + report.protected_sessions = report.protected_sessions.saturating_add(1); + continue; + } + match fs::remove_dir_all(&path) { + Ok(()) => { + report.removed_sessions = report.removed_sessions.saturating_add(1); + self.payloads + .retain(|_, record| !record.root.starts_with(&path)); + } + Err(error) => retain_external_with_details( + &mut report, + &path, + &name, + "deleteFailed", + Some(error.to_string()), + ), + } + } + Ok(report) + } + + fn ensure_session(&self, session_id: &str) -> Result { + let session_root = self.session_root(session_id)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(&session_root)?; + set_private_directory(&session_root)?; + fs::write(session_root.join(OWNER_FILE), format!("1\n{session_id}\n"))?; + } + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + Ok(session_root) + } + + fn session_root(&self, session_id: &str) -> Result { + if session_id.is_empty() + || session_id.len() > 128 + || !session_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(PayloadError::InvalidSession); + } + Ok(self.base.join(format!("skill-deck-source-{session_id}"))) + } + + fn validate_session(&self, root: &Path, session_id: &str) -> Result<(), PayloadError> { + let metadata = fs::symlink_metadata(root)?; + let marker = root.join(OWNER_FILE); + let marker_metadata = fs::symlink_metadata(&marker)?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || !marker_metadata.is_file() + || marker_metadata.file_type().is_symlink() + || fs::read_to_string(marker)? != format!("1\n{session_id}\n") + { + return Err(PayloadError::StalePayload); + } + Ok(()) + } + + fn allocate_id(&mut self) -> Result { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .ok_or(PayloadError::StalePayload)?; + Ok(id) + } + + fn register_built(&mut self, id: u64, root: PathBuf, built: BuiltPayload) -> ManagedPayload { + self.payloads + .insert(id, PayloadRecord { root: root.clone() }); + ManagedPayload { + id, + root, + manifest: map_manifest(built.manifest), + total_bytes: built.total_bytes, + computed_hash: Some(built.computed_hash), + } + } +} + +impl Drop for PayloadManager { + fn drop(&mut self) { + for upload in self.uploads.values() { + let _ = fs::remove_dir_all(&upload.stage); + } + } +} + +fn prepare_file(path: PathBuf) -> Result { + if fs::symlink_metadata(&path).is_ok() { + return Err(PayloadError::StalePayload); + } + let file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + set_private_file(&path)?; + Ok(PreparedPayloadFile { path, file }) +} + +fn finalize_upload_record( + upload: &UploadRecord, + incoming_manifest: PathBuf, +) -> Result { + if incoming_manifest != upload.stage.join(".incoming-manifest") { + return Err(PayloadError::StalePayload); + } + let manifest: EngineManifest = serde_json::from_reader(fs::File::open(&incoming_manifest)?)?; + let expected_blobs = manifest + .entries + .iter() + .filter_map(|entry| entry.blob_id.clone()) + .collect::>(); + let actual_blobs = fs::read_dir(upload.stage.join("blobs"))? + .map(|entry| { + let entry = entry?; + let metadata = entry.metadata()?; + if !metadata.is_file() || entry.file_type()?.is_symlink() { + return Err(std::io::Error::other("payload blob is not a regular file")); + } + Ok(entry.file_name().to_string_lossy().into_owned()) + }) + .collect::, std::io::Error>>()?; + if actual_blobs != expected_blobs { + return Err(PayloadError::StalePayload); + } + let mut blob_list = fs::File::create(upload.stage.join("blob-list"))?; + use std::io::Write; + for blob_id in &expected_blobs { + writeln!(blob_list, "{blob_id}")?; + } + fs::rename(incoming_manifest, upload.stage.join("manifest.json"))?; + let verified = verify_payload(&upload.stage)?; + if verified != manifest { + return Err(PayloadError::StalePayload); + } + Ok(manifest) +} + +fn validate_payload_name(payload_name: &str) -> Result<(), PayloadError> { + if !payload_name.starts_with("payload-") + || payload_name.len() <= "payload-".len() + || !payload_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(PayloadError::InvalidPayloadName); + } + Ok(()) +} + +fn valid_blob_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn file_sha256(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + use std::io::Read; + + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + return Ok(format!("{:x}", hasher.finalize())); + } + hasher.update(&buffer[..read]); + } +} + +fn map_manifest(manifest: EngineManifest) -> PayloadManifest { + PayloadManifest { + entries: manifest + .entries + .into_iter() + .map(|entry| PayloadEntry { + relative_path: entry.relative_path, + kind: match entry.kind { + EngineEntryKind::File => PayloadEntryKind::File, + EngineEntryKind::Directory => PayloadEntryKind::Directory, + }, + blob_id: entry.blob_id, + content_hash: entry.content_hash, + size: entry.size, + executable: entry.executable, + }) + .collect(), + payload_root_hash: manifest.payload_root_hash, + payload_id: manifest.payload_id, + } +} + +fn unique_total_bytes(manifest: &EngineManifest) -> u64 { + manifest + .entries + .iter() + .filter_map(|entry| entry.blob_id.as_ref().map(|blob| (blob, entry.size))) + .collect::>() + .values() + .copied() + .sum() +} + +fn retain_external(report: &mut PayloadCleanupResponse, path: &Path, name: &str, code: &str) { + retain_external_with_details(report, path, name, code, None); +} + +fn retain_external_with_details( + report: &mut PayloadCleanupResponse, + path: &Path, + name: &str, + code: &str, + technical_details: Option, +) { + report.cleanup_blocked = true; + match directory_size(path) { + Ok(size) => { + report.retained_external_bytes = report.retained_external_bytes.saturating_add(size) + } + Err(_) => report.warnings.push(PayloadCleanupWarning { + code: "sizeUnavailable".to_string(), + candidate_name: Some(name.to_string()), + technical_details: None, + }), + } + report.warnings.push(PayloadCleanupWarning { + code: code.to_string(), + candidate_name: Some(name.to_string()), + technical_details, + }); +} + +fn directory_size(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || metadata.is_file() { + return Ok(metadata.len()); + } + let mut size = metadata.len(); + for entry in fs::read_dir(path)? { + size = size.saturating_add(directory_size(&entry?.path())?); + } + Ok(size) +} + +#[cfg(unix)] +fn set_private_directory(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) +} + +#[cfg(unix)] +fn set_private_file(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_private_directory(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_file(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} diff --git a/src-tauri/crates/wsl-environment-worker/src/source.rs b/src-tauri/crates/wsl-environment-worker/src/source.rs new file mode 100644 index 00000000..bc06ecfb --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/source.rs @@ -0,0 +1,663 @@ +use std::collections::HashMap; +use std::fmt; +use std::path::{Component, Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +use environment_engine::payload::source_metadata_fingerprint; +#[cfg(target_os = "linux")] +use environment_engine::source_inventory::{ + scan_source_with_cancel, SourceEntryError as EngineEntryError, + SourceEntryKind as EngineEntryKind, SourceInventoryRequest, SourceRoot, + SourceScanMode as EngineScanMode, +}; +#[cfg(target_os = "linux")] +use environment_protocol::{SourceEntry, SourceEntryErrorCode, SourceEntryKind}; +use environment_protocol::{SourceScanRequest, SourceScanResponse}; + +const GIT_STDERR_LIMIT: usize = 256 * 1024; +const GIT_STDOUT_LIMIT: usize = 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitSourceOptions { + pub url: String, + pub git_ref: Option, + pub proxy: Option, + pub deadline: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenedSource { + pub id: u64, + pub root: PathBuf, + pub revision: Option, + pub managed_bytes: u64, +} + +#[derive(Debug)] +pub enum SourceError { + InvalidManagedBase, + InvalidLocalSource, + InvalidRelativePath, + DirectoryLinksUnsupported, + InvalidInventory, + MissingSource, + GitUnavailable { + message: String, + }, + GitFailed { + exit_code: Option, + stderr: String, + }, + DeadlineExceeded, + Cancelled, + Io(std::io::Error), +} + +#[cfg(target_os = "linux")] +pub fn scan_source( + manager: &SourceManager, + request: SourceScanRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::ffi::OsString; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let roots = request + .roots + .into_iter() + .map(|root| { + let relative = PathBuf::from(OsString::from_vec(root.relative_path)); + let path = manager.resolve(request.source_id, &relative)?; + if request.mode == environment_protocol::SourceScanMode::SkillMetadata { + match environment_engine::directory::plain_descendant_directory( + manager.root(request.source_id)?, + &relative, + ) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::Unsupported => { + return Err(SourceError::DirectoryLinksUnsupported) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(SourceRoot { + path, + stat_only: root.stat_only, + }) + }) + .collect::, _>>()?; + let response = scan_source_with_cancel( + &SourceInventoryRequest { + roots, + mode: match request.mode { + environment_protocol::SourceScanMode::Recursive => EngineScanMode::Recursive, + environment_protocol::SourceScanMode::PriorityDirectories => { + EngineScanMode::PriorityDirectories + } + environment_protocol::SourceScanMode::SkillMetadata => { + EngineScanMode::SkillMetadata + } + }, + per_file_limit: request.per_file_limit, + aggregate_limit: request.aggregate_limit, + }, + is_cancelled, + ) + .map_err(|_| SourceError::InvalidInventory)?; + Ok(SourceScanResponse { + entries: response + .entries + .into_iter() + .map(|entry| SourceEntry { + root_index: entry.root_index, + relative_path: entry.relative_path.as_os_str().as_bytes().to_vec(), + kind: match entry.kind { + EngineEntryKind::Missing => SourceEntryKind::Missing, + EngineEntryKind::File => SourceEntryKind::File, + EngineEntryKind::Directory => SourceEntryKind::Directory, + EngineEntryKind::Symlink => SourceEntryKind::Symlink, + EngineEntryKind::Other => SourceEntryKind::Other, + }, + link_target: entry + .link_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + content_bytes: entry.content_bytes, + truncated: entry.truncated, + error_code: entry.error.map(|error| match error { + EngineEntryError::PathUnavailable => SourceEntryErrorCode::PathUnavailable, + EngineEntryError::ReadFailed => SourceEntryErrorCode::ReadFailed, + EngineEntryError::ReadLinkFailed => SourceEntryErrorCode::ReadLinkFailed, + }), + }) + .collect(), + total_content_bytes: response.total_content_bytes, + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn scan_source( + _manager: &SourceManager, + _request: SourceScanRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(SourceError::InvalidInventory) +} + +impl fmt::Display for SourceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for SourceError {} + +impl From for SourceError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +struct SourceRecord { + root: PathBuf, + cleanup_root: Option, + revision: Option, +} + +pub struct SourceManager { + managed_base: PathBuf, + next_id: u64, + sources: HashMap, +} + +impl SourceManager { + pub fn new(managed_base: PathBuf) -> Result { + if !managed_base.is_absolute() { + return Err(SourceError::InvalidManagedBase); + } + std::fs::create_dir_all(&managed_base)?; + let managed_base = std::fs::canonicalize(managed_base)?; + if !managed_base.is_dir() { + return Err(SourceError::InvalidManagedBase); + } + Ok(Self { + managed_base, + next_id: 1, + sources: HashMap::new(), + }) + } + + pub fn open_local(&mut self, path: &str) -> Result { + let path = Path::new(path); + if !path.is_absolute() { + return Err(SourceError::InvalidLocalSource); + } + let root = std::fs::canonicalize(path).map_err(|_| SourceError::InvalidLocalSource)?; + if !root.is_dir() { + return Err(SourceError::InvalidLocalSource); + } + Ok(self.insert(root, None, None, 0)) + } + + pub async fn acquire_git( + &mut self, + options: GitSourceOptions, + cancelled: Arc, + ) -> Result { + if options.url.is_empty() || options.deadline.is_zero() { + return Err(SourceError::GitFailed { + exit_code: None, + stderr: "invalid Git source request".to_string(), + }); + } + if cancelled.load(Ordering::Acquire) { + return Err(SourceError::Cancelled); + } + let id = self.allocate_id()?; + let managed_root = self.managed_base.join(format!( + "skill-deck-discovery-worker-{}-{id}", + std::process::id() + )); + if std::fs::symlink_metadata(&managed_root).is_ok() { + return Err(SourceError::InvalidManagedBase); + } + std::fs::create_dir(&managed_root)?; + std::fs::write(managed_root.join(".skill-deck-owner"), b"1\n")?; + let repository = managed_root.join("repo"); + let clone_result = run_git( + git_clone_arguments(&options, &repository), + options.proxy.as_deref(), + options.deadline, + Arc::clone(&cancelled), + ) + .await; + if let Err(error) = clone_result { + let _ = std::fs::remove_dir_all(&managed_root); + return Err(error); + } + if let Some(reference) = options + .git_ref + .as_deref() + .filter(|reference| reference.starts_with("refs/")) + { + for arguments in [ + vec!["fetch", "--depth", "1", "origin", reference], + vec!["checkout", "--detach", "FETCH_HEAD"], + ] { + let arguments = [ + vec!["-C".to_string(), repository.to_string_lossy().into_owned()], + arguments.into_iter().map(str::to_string).collect(), + ] + .concat(); + if let Err(error) = run_git( + arguments, + options.proxy.as_deref(), + options.deadline, + cancelled.clone(), + ) + .await + { + let _ = std::fs::remove_dir_all(&managed_root); + return Err(error); + } + } + } + let revision = run_git( + vec![ + "-C".to_string(), + repository.to_string_lossy().into_owned(), + "rev-parse".to_string(), + "--verify".to_string(), + "HEAD".to_string(), + ], + None, + options.deadline, + cancelled, + ) + .await + .and_then(|output| parse_revision(&output.stdout)); + let revision = match revision { + Ok(revision) => revision, + Err(error) => { + let _ = std::fs::remove_dir_all(&managed_root); + return Err(error); + } + }; + let opened = OpenedSource { + id, + root: repository.clone(), + revision: Some(revision.clone()), + managed_bytes: match environment_engine::directory::size_no_follow(&managed_root) { + Ok(bytes) => bytes, + Err(error) => { + let _ = std::fs::remove_dir_all(&managed_root); + return Err(error.into()); + } + }, + }; + self.sources.insert( + id, + SourceRecord { + root: repository, + cleanup_root: Some(managed_root), + revision: Some(revision), + }, + ); + Ok(opened) + } + + pub fn root(&self, source_id: u64) -> Result<&Path, SourceError> { + self.sources + .get(&source_id) + .map(|source| source.root.as_path()) + .ok_or(SourceError::MissingSource) + } + + pub fn revision(&self, source_id: u64) -> Result, SourceError> { + self.sources + .get(&source_id) + .map(|source| source.revision.as_deref()) + .ok_or(SourceError::MissingSource) + } + + pub fn fingerprint(&self, source_id: u64, relative_path: &Path) -> Result { + let path = self.resolve(source_id, relative_path)?; + source_metadata_fingerprint(&path).map_err(|_| SourceError::InvalidLocalSource) + } + + pub async fn tree_revision( + &self, + source_id: u64, + relative_path: &Path, + deadline: Duration, + cancelled: Arc, + ) -> Result { + let root = self.root(source_id)?; + let relative = normalized_git_path(relative_path)?; + let revision_spec = if relative.is_empty() { + "HEAD^{tree}".to_string() + } else { + format!("HEAD:{relative}") + }; + let output = run_git( + vec![ + "-C".to_string(), + root.to_string_lossy().into_owned(), + "rev-parse".to_string(), + "--verify".to_string(), + revision_spec, + ], + None, + deadline, + cancelled, + ) + .await?; + parse_revision(&output.stdout) + } + + pub fn resolve(&self, source_id: u64, relative_path: &Path) -> Result { + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir)) + { + return Err(SourceError::InvalidRelativePath); + } + Ok(self.root(source_id)?.join(relative_path)) + } + + pub fn release(&mut self, source_id: u64) -> Result<(), SourceError> { + let source = self + .sources + .get(&source_id) + .ok_or(SourceError::MissingSource)?; + if let Some(cleanup_root) = &source.cleanup_root { + match std::fs::remove_dir_all(cleanup_root) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + self.sources.remove(&source_id); + Ok(()) + } + + fn insert( + &mut self, + root: PathBuf, + cleanup_root: Option, + revision: Option, + managed_bytes: u64, + ) -> OpenedSource { + let id = self.allocate_id().expect("source handle space exhausted"); + self.sources.insert( + id, + SourceRecord { + root: root.clone(), + cleanup_root, + revision: revision.clone(), + }, + ); + OpenedSource { + id, + root, + revision, + managed_bytes, + } + } + + fn allocate_id(&mut self) -> Result { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .ok_or(SourceError::InvalidManagedBase)?; + Ok(id) + } +} + +pub async fn probe_git( + options: GitSourceOptions, + cancelled: Arc, +) -> Result { + if options.url.is_empty() || options.deadline.is_zero() { + return Err(SourceError::GitFailed { + exit_code: None, + stderr: "invalid Git probe request".to_string(), + }); + } + let mut arguments = vec![ + "ls-remote".to_string(), + "--exit-code".to_string(), + "--".to_string(), + options.url.clone(), + ]; + arguments.extend(environment_engine::git_ref::remote_ref_patterns( + options.git_ref.as_deref(), + )); + let output = run_git( + arguments, + options.proxy.as_deref(), + options.deadline, + cancelled, + ) + .await?; + environment_engine::git_ref::resolve_remote_revision( + &String::from_utf8_lossy(&output.stdout), + options.git_ref.as_deref(), + ) + .ok_or(SourceError::GitFailed { + exit_code: Some(2), + stderr: "requested ref not found".into(), + }) +} + +impl Drop for SourceManager { + fn drop(&mut self) { + for source in self.sources.values_mut() { + if let Some(cleanup_root) = source.cleanup_root.take() { + let _ = std::fs::remove_dir_all(cleanup_root); + } + } + } +} + +struct GitOutput { + stdout: Vec, +} + +fn git_clone_arguments(options: &GitSourceOptions, repository: &Path) -> Vec { + let mut arguments = vec![ + "clone".to_string(), + "--depth".to_string(), + "1".to_string(), + "--progress".to_string(), + ]; + if let Some(git_ref) = &options.git_ref { + arguments.extend([ + "--branch".to_string(), + environment_engine::git_ref::clone_branch(git_ref).to_string(), + ]); + } + arguments.extend([ + "--".to_string(), + options.url.clone(), + repository.to_string_lossy().into_owned(), + ]); + arguments +} + +fn normalized_git_path(path: &Path) -> Result { + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir)) + { + return Err(SourceError::InvalidRelativePath); + } + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Normal(value) => { + components.push(value.to_str().ok_or(SourceError::InvalidRelativePath)?) + } + Component::CurDir => {} + _ => return Err(SourceError::InvalidRelativePath), + } + } + Ok(components.join("/")) +} + +#[allow( + clippy::disallowed_methods, + reason = "Worker crate 独立管理 Linux Git 进程组,不依赖 app crate 的进程 helper" +)] +async fn run_git( + arguments: Vec, + proxy: Option<&str>, + deadline: Duration, + cancelled: Arc, +) -> Result { + let mut command = Command::new("git"); + command + .env("LC_ALL", "C") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("GIT_ALLOW_PROTOCOL", "https:http:ssh:git:file") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(proxy) = proxy { + command.args(["-c", &format!("http.proxy={proxy}")]); + } + command.args(arguments); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + let mut child = command + .spawn() + .map_err(|error| SourceError::GitUnavailable { + message: error.to_string(), + })?; + let process_group_id = child.id().and_then(|id| i32::try_from(id).ok()); + let stdout = child.stdout.take().ok_or_else(|| SourceError::GitFailed { + exit_code: None, + stderr: "Git stdout is unavailable".to_string(), + })?; + let stderr = child.stderr.take().ok_or_else(|| SourceError::GitFailed { + exit_code: None, + stderr: "Git stderr is unavailable".to_string(), + })?; + let stdout_task = tokio::spawn(read_prefix(stdout, GIT_STDOUT_LIMIT)); + let stderr_task = tokio::spawn(read_tail(stderr, GIT_STDERR_LIMIT)); + let mut wait = Box::pin(child.wait()); + let mut deadline_sleep = Box::pin(tokio::time::sleep(deadline)); + let status = loop { + tokio::select! { + result = &mut wait => break result?, + _ = &mut deadline_sleep => { + kill_process_group(process_group_id); + let _ = wait.await; + return Err(SourceError::DeadlineExceeded); + } + _ = tokio::time::sleep(Duration::from_millis(25)) => { + if cancelled.load(Ordering::Acquire) { + kill_process_group(process_group_id); + let _ = wait.await; + return Err(SourceError::Cancelled); + } + } + } + }; + let stdout = stdout_task.await.map_err(|_| SourceError::GitFailed { + exit_code: status.code(), + stderr: "failed to capture Git stdout".to_string(), + })??; + let stderr = stderr_task.await.map_err(|_| SourceError::GitFailed { + exit_code: status.code(), + stderr: "failed to capture Git stderr".to_string(), + })??; + if !status.success() { + return Err(SourceError::GitFailed { + exit_code: status.code(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + }); + } + Ok(GitOutput { stdout }) +} + +async fn read_prefix(mut reader: R, limit: usize) -> Result, std::io::Error> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + return Ok(output); + } + let remaining = limit.saturating_sub(output.len()); + output.extend_from_slice(&buffer[..read.min(remaining)]); + } +} + +async fn read_tail(mut reader: R, limit: usize) -> Result, std::io::Error> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + return Ok(output); + } + output.extend_from_slice(&buffer[..read]); + if output.len() > limit { + output.drain(..output.len() - limit); + } + } +} + +fn parse_revision(bytes: &[u8]) -> Result { + let revision = std::str::from_utf8(bytes) + .map_err(|_| SourceError::GitFailed { + exit_code: None, + stderr: "Git revision is not UTF-8".to_string(), + })? + .trim(); + if matches!(revision.len(), 40 | 64) && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(revision.to_ascii_lowercase()) + } else { + Err(SourceError::GitFailed { + exit_code: None, + stderr: "Git returned an invalid revision".to_string(), + }) + } +} + +#[cfg(unix)] +fn kill_process_group(process_group_id: Option) { + if let Some(process_group_id) = process_group_id { + let _ = unsafe { libc::kill(-process_group_id, libc::SIGKILL) }; + } +} + +#[cfg(not(unix))] +fn kill_process_group(_process_group_id: Option) {} diff --git a/src-tauri/crates/wsl-environment-worker/tests/inbound_transfer.rs b/src-tauri/crates/wsl-environment-worker/tests/inbound_transfer.rs new file mode 100644 index 00000000..a7455941 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/inbound_transfer.rs @@ -0,0 +1,38 @@ +use sha2::{Digest, Sha256}; +use wsl_environment_worker::inbound_transfer::{ + InboundTransfer, TransferCompletion, TransferDeclaration, +}; + +#[tokio::test] +async fn transfer_streams_to_disk_and_verifies_its_completion() { + let temp = tempfile::NamedTempFile::new().unwrap(); + let file = tokio::fs::File::from_std(temp.reopen().unwrap()); + let payload = b"payload-bytes"; + let sha256 = format!("sha256:{:x}", Sha256::digest(payload)); + let mut transfer = InboundTransfer::begin( + TransferDeclaration { + owner_request_id: 7, + transfer_id: 11, + total_bytes: payload.len() as u64, + sha256: sha256.clone(), + }, + 1024, + file, + ) + .unwrap(); + + transfer.write_chunk(11, &payload[..4]).await.unwrap(); + transfer.write_chunk(11, &payload[4..]).await.unwrap(); + let completed = transfer + .complete(TransferCompletion { + owner_request_id: 7, + transfer_id: 11, + total_bytes: payload.len() as u64, + sha256, + }) + .await + .unwrap(); + drop(completed.file); + + assert_eq!(std::fs::read(temp.path()).unwrap(), payload); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/library.rs b/src-tauri/crates/wsl-environment-worker/tests/library.rs new file mode 100644 index 00000000..16890824 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/library.rs @@ -0,0 +1,164 @@ +#![cfg(target_os = "linux")] + +use environment_engine::linux_mutation::fingerprint_path; +use environment_engine::projection::{project_targets, ProjectionRequest}; +use environment_protocol::LibraryMemberAction; +use environment_protocol::{LibraryOperationAction, LibraryOperationRequest}; +use wsl_environment_worker::library::LibraryManager; +use wsl_environment_worker::payload::PayloadManager; + +#[test] +fn manager_saves_and_reads_the_catalog_under_its_home() { + let home = tempfile::tempdir().unwrap(); + let manager = LibraryManager::new(home.path().to_path_buf()); + let payloads = PayloadManager::new(home.path().join("payloads")).unwrap(); + let bytes = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + + let revision = manager + .execute( + LibraryOperationRequest { + operation_id: "save-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: bytes.clone(), + action: LibraryOperationAction::SaveCatalog { + library_ids: Vec::new(), + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + let catalog = manager.read_catalog().unwrap(); + + assert_eq!(catalog.bytes, bytes); + assert!(catalog.present); + assert_eq!(catalog.revision, Some(revision)); + assert!(home + .path() + .join(".skill-deck/skill-libraries/catalog.json") + .is_file()); +} + +#[test] +fn manager_commits_a_member_from_an_existing_payload_handle() { + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"member").unwrap(); + let manager = LibraryManager::new(home.path().to_path_buf()); + let mut payloads = PayloadManager::new(home.path().join("payloads")).unwrap(); + let payload = payloads + .acquire_from_source("library", "payload-demo", &source) + .unwrap(); + let first = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":[]}]}"#.to_vec(); + let catalog_revision = manager + .execute( + LibraryOperationRequest { + operation_id: "save-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: first, + action: LibraryOperationAction::SaveCatalog { + library_ids: vec!["lib-1".to_string()], + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + let destination = home + .path() + .join(".skill-deck/skill-libraries/libraries/lib-1/skills/demo"); + let target = project_targets(&ProjectionRequest { + destinations: vec![destination.clone()], + }) + .unwrap() + .targets + .pop() + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":["demo"]}]}"#.to_vec(); + + manager + .execute( + LibraryOperationRequest { + operation_id: "member-1".to_string(), + expected_catalog_revision: Some(catalog_revision), + catalog_bytes: second.clone(), + action: LibraryOperationAction::CommitMember { + library_id: "lib-1".to_string(), + skill_name: "demo".to_string(), + expected_anchor_device: target.anchor_device, + expected_anchor_inode: target.anchor_inode, + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + mutation: LibraryMemberAction::Upsert { + payload_id: payload.id, + }, + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + + assert_eq!( + std::fs::read(destination.join("SKILL.md")).unwrap(), + b"member" + ); + assert_eq!(manager.read_catalog().unwrap().bytes, second); +} + +#[test] +fn manager_deletes_a_library_directory_and_catalog_as_one_intent() { + let home = tempfile::tempdir().unwrap(); + let manager = LibraryManager::new(home.path().to_path_buf()); + let payloads = PayloadManager::new(home.path().join("payloads")).unwrap(); + let first = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + let catalog_revision = manager + .execute( + LibraryOperationRequest { + operation_id: "save-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: first, + action: LibraryOperationAction::SaveCatalog { + library_ids: vec!["lib-1".to_string()], + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + let destination = home + .path() + .join(".skill-deck/skill-libraries/libraries/lib-1"); + std::fs::write(destination.join("skills/SKILL.md"), b"member").unwrap(); + let target = project_targets(&ProjectionRequest { + destinations: vec![destination.clone()], + }) + .unwrap() + .targets + .pop() + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + + manager + .execute( + LibraryOperationRequest { + operation_id: "delete-library-1".to_string(), + expected_catalog_revision: Some(catalog_revision), + catalog_bytes: second.clone(), + action: LibraryOperationAction::DeleteLibrary { + library_id: "lib-1".to_string(), + expected_anchor_device: target.anchor_device, + expected_anchor_inode: target.anchor_inode, + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + + assert!(!destination.exists()); + assert_eq!(manager.read_catalog().unwrap().bytes, second); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/mutation_recovery.rs b/src-tauri/crates/wsl-environment-worker/tests/mutation_recovery.rs new file mode 100644 index 00000000..e43f032d --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/mutation_recovery.rs @@ -0,0 +1,96 @@ +#![cfg(target_os = "linux")] + +use wsl_environment_worker::mutation::{MutationRecoveryError, MutationRecoveryStore}; + +#[test] +fn recovery_store_lists_old_markers_and_cleans_only_an_exact_marker() { + let temp = tempfile::tempdir().unwrap(); + let store = MutationRecoveryStore::new(temp.path().to_path_buf()).unwrap(); + let resource_id = "a".repeat(64); + let root = temp + .path() + .join(format!("skill-deck-operation-{resource_id}")); + let backup = temp + .path() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let stage = temp + .path() + .join(format!(".skill-deck-stage-{resource_id}-000000")); + std::fs::create_dir(&root).unwrap(); + std::fs::write( + root.join(".skill-deck-owner"), + format!("1\n{resource_id}\n"), + ) + .unwrap(); + let destination = temp.path().join("demo"); + let marker = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "kind": "cleanupOnly", + "entries": [{ + "destination": { "nativePath": destination }, + "backup": { "nativePath": backup } + }] + })) + .unwrap(); + std::fs::write(root.join("recovery.json"), &marker).unwrap(); + std::fs::create_dir(&backup).unwrap(); + std::fs::create_dir(&stage).unwrap(); + + let listed = store.list().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].resource_id, resource_id); + assert_eq!(listed[0].marker_bytes.as_deref(), Some(marker.as_slice())); + + assert_eq!( + store + .cleanup(&resource_id, b"different", std::slice::from_ref(&backup)) + .unwrap_err(), + MutationRecoveryError::StaleMarker + ); + assert!(root.is_dir()); + assert!(backup.is_dir()); + assert!(stage.is_dir()); + + store + .cleanup(&resource_id, &marker, std::slice::from_ref(&backup)) + .unwrap(); + assert!(!root.exists()); + assert!(!backup.exists()); + assert!(!stage.exists()); +} + +#[test] +fn recovery_store_rejects_a_backup_outside_its_target_parent() { + let temp = tempfile::tempdir().unwrap(); + let store = MutationRecoveryStore::new(temp.path().to_path_buf()).unwrap(); + let resource_id = "b".repeat(64); + let root = temp + .path() + .join(format!("skill-deck-operation-{resource_id}")); + let destination = temp.path().join("targets/demo"); + let backup = temp + .path() + .join(format!("elsewhere/.skill-deck-backup-{resource_id}-000000")); + std::fs::create_dir(&root).unwrap(); + std::fs::write( + root.join(".skill-deck-owner"), + format!("1\n{resource_id}\n"), + ) + .unwrap(); + let marker = serde_json::to_vec(&serde_json::json!({ + "entries": [{ + "destination": { "nativePath": destination }, + "backup": { "nativePath": backup } + }] + })) + .unwrap(); + std::fs::write(root.join("recovery.json"), &marker).unwrap(); + + assert_eq!( + store + .cleanup(&resource_id, &marker, std::slice::from_ref(&backup)) + .unwrap_err(), + MutationRecoveryError::UnsafeRoot + ); + assert!(root.is_dir()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/mutation_transaction.rs b/src-tauri/crates/wsl-environment-worker/tests/mutation_transaction.rs new file mode 100644 index 00000000..7e1a0e5b --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/mutation_transaction.rs @@ -0,0 +1,374 @@ +#![cfg(target_os = "linux")] + +use environment_engine::linux_mutation::{ + content_hash_path, fingerprint_path, parent_identity, MutationError, +}; +use environment_protocol::{ + MutationEntry, MutationEntryAction, MutationLock, MutationLockEntry, MutationLockSchema, + MutationUnitOutcome, MutationUnitRequest, +}; +use wsl_environment_worker::mutation::MutationManager; +use wsl_environment_worker::mutation::WorkerMutationError; +use wsl_environment_worker::payload::PayloadManager; + +#[test] +fn accepted_transaction_commits_directory_and_lock_before_exact_ack_cleanup() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let mut payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let payload = payloads + .acquire_from_source("mutation", "payload-demo", &source) + .unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let lock_path = temp.path().join("skills-lock.json"); + std::fs::write( + &lock_path, + br#"{"version":3,"skills":{"demo":{"source":"old"}}}"#, + ) + .unwrap(); + let resource_id = "b".repeat(64); + let backup = destination + .parent() + .unwrap() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let marker = serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "operation-1", + "unitId": "unit-1", + "subject": { + "operationKind": "install", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "target-1", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": backup + }, + "expectedState": "present", + "originalFingerprint": fingerprint_path(&destination).unwrap(), + "phase": "staged" + }] + })) + .unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "operation-1".to_string(), + unit_id: "unit-1".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: parent_identity(destination.parent().unwrap()) + .unwrap() + .device, + expected_anchor_inode: parent_identity(destination.parent().unwrap()) + .unwrap() + .inode, + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: MutationEntryAction::Materialize { + payload_id: payload.id, + }, + }], + lock: Some(MutationLock { + target: lock_path.to_string_lossy().into_owned(), + legacy_target: None, + schema: MutationLockSchema::Global, + entry: MutationLockEntry::Replace { + key: "demo".to_string(), + replacement_json: br#"{"source":"new"}"#.to_vec(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"old"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + let accepted = manager.accept(request, &payloads, || false).unwrap(); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + assert!(temp + .path() + .join(format!("skill-deck-operation-{resource_id}/recovery.json")) + .is_file()); + + let outcome = manager.execute(accepted, || false).unwrap(); + let cleanup = match outcome { + MutationUnitOutcome::Succeeded { + cleanup: Some(cleanup), + .. + } => cleanup, + outcome => panic!("unexpected transaction outcome: {outcome:?}"), + }; + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + let lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap(); + assert_eq!(lock["skills"]["demo"]["source"], "new"); + assert!(backup.is_dir()); + + manager.acknowledge(&cleanup).unwrap(); + assert!(!backup.exists()); + assert!(!temp + .path() + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); +} + +#[test] +fn lock_conflict_restores_the_directory_and_keeps_typed_conflict_evidence() { + let temp = tempfile::tempdir().unwrap(); + let payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let lock_path = temp.path().join("skills-lock.json"); + std::fs::write( + &lock_path, + br#"{"version":3,"skills":{"demo":{"source":"changed"}}}"#, + ) + .unwrap(); + let resource_id = "c".repeat(64); + let backup = destination + .parent() + .unwrap() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let fingerprint = fingerprint_path(&destination).unwrap(); + let marker = serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "operation-2", + "unitId": "unit-2", + "subject": { + "operationKind": "remove", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "target-1", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": backup + }, + "expectedState": "missing", + "originalFingerprint": fingerprint, + "phase": "staged" + }] + })) + .unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "operation-2".to_string(), + unit_id: "unit-2".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: fingerprint, + expected_content_hash: None, + action: MutationEntryAction::Remove, + }], + lock: Some(MutationLock { + target: lock_path.to_string_lossy().into_owned(), + legacy_target: None, + schema: MutationLockSchema::Global, + entry: MutationLockEntry::Remove { + key: "demo".to_string(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"expected"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + let accepted = manager.accept(request, &payloads, || false).unwrap(); + let outcome = manager.execute(accepted, || false).unwrap(); + + assert!(matches!( + outcome, + MutationUnitOutcome::Failed { + ref code, + ref parameters, + .. + } if code == "lockConflictSkill" + && parameters == &vec![("skillName".to_string(), "demo".to_string())] + )); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + assert!(!backup.exists()); + assert!(!temp + .path() + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); +} + +#[test] +fn authoritative_accept_preserves_stale_target_classification() { + let temp = tempfile::tempdir().unwrap(); + let payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let expected = fingerprint_path(&destination).unwrap(); + let expected_content = content_hash_path(&destination).unwrap(); + std::fs::write(destination.join("changed"), b"changed").unwrap(); + let request = MutationUnitRequest { + resource_id: "d".repeat(64), + operation_id: "operation-3".to_string(), + unit_id: "unit-3".to_string(), + initial_marker_json: Vec::new(), + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: expected, + expected_content_hash: Some(expected_content), + action: MutationEntryAction::Keep, + }], + lock: None, + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + assert!(matches!( + manager.accept(request, &payloads, || false), + Err(WorkerMutationError::Engine(MutationError::StaleTarget)) + )); +} + +#[test] +fn lock_only_transaction_is_accepted_and_keeps_recovery_evidence_until_ack() { + let temp = tempfile::tempdir().unwrap(); + let payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"same").unwrap(); + let lock_path = temp.path().join("skills-lock.json"); + std::fs::write( + &lock_path, + br#"{"version":3,"skills":{"demo":{"source":"old"}}}"#, + ) + .unwrap(); + let resource_id = "e".repeat(64); + let fingerprint = fingerprint_path(&destination).unwrap(); + let marker = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "operation-4", + "unitId": "unit-4", + "subject": { + "operationKind": "install", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "target-1", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": null, + "expectedState": "present", + "originalFingerprint": fingerprint, + "phase": "staged" + }] + })) + .unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "operation-4".to_string(), + unit_id: "unit-4".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: fingerprint, + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: MutationEntryAction::Keep, + }], + lock: Some(MutationLock { + target: lock_path.to_string_lossy().into_owned(), + legacy_target: None, + schema: MutationLockSchema::Global, + entry: MutationLockEntry::Replace { + key: "demo".to_string(), + replacement_json: br#"{"source":"new"}"#.to_vec(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"old"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + let accepted = manager.accept(request, &payloads, || false).unwrap(); + assert!(MutationManager::requires_acceptance(&accepted)); + let outcome = manager.execute(accepted, || false).unwrap(); + let cleanup = match outcome { + MutationUnitOutcome::Succeeded { + cleanup: Some(cleanup), + .. + } => cleanup, + outcome => panic!("unexpected transaction outcome: {outcome:?}"), + }; + let lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap(); + assert_eq!(lock["skills"]["demo"]["source"], "new"); + assert!(temp + .path() + .join(format!("skill-deck-operation-{resource_id}/recovery.json")) + .is_file()); + + manager.acknowledge(&cleanup).unwrap(); + assert!(!temp + .path() + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/payload.rs b/src-tauri/crates/wsl-environment-worker/tests/payload.rs new file mode 100644 index 00000000..db9df73b --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/payload.rs @@ -0,0 +1,103 @@ +#![cfg(target_os = "linux")] + +use std::io::Read; + +use wsl_environment_worker::payload::PayloadManager; + +#[test] +fn payload_manager_builds_verifies_reads_and_removes_worker_owned_payloads() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir_all(source.join("scripts")).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + std::fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); + let mut manager = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + + let acquired = manager + .acquire_from_source("session-1", "payload-demo", &source) + .unwrap(); + assert!(acquired.root.join("manifest.json").is_file()); + let verified = manager + .verify("session-1", "payload-demo") + .unwrap() + .unwrap(); + assert_ne!(verified.id, acquired.id); + assert_eq!(verified.manifest, acquired.manifest); + + let blob_id = acquired + .manifest + .entries + .iter() + .find_map(|entry| entry.blob_id.as_deref()) + .unwrap(); + let mut blob = manager.read_blob(acquired.id, blob_id).unwrap().unwrap(); + let mut content = Vec::new(); + blob.read_to_end(&mut content).unwrap(); + assert!(content == b"demo" || content == b"#!/bin/sh\n"); + + let root = acquired.root; + manager.remove("session-1", "payload-demo").unwrap(); + assert!(!root.exists()); + assert!(manager.read_blob(acquired.id, blob_id).is_err()); +} + +#[test] +fn payload_manager_only_sweeps_valid_unprotected_owned_sessions() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let mut manager = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + manager + .acquire_from_source("protected", "payload-demo", &source) + .unwrap(); + manager + .acquire_from_source("orphan", "payload-demo", &source) + .unwrap(); + std::fs::create_dir(temp.path().join("skill-deck-source-foreign")).unwrap(); + + let report = manager.sweep_orphans(&["protected".to_string()]).unwrap(); + + assert_eq!(report.removed_sessions, 1); + assert_eq!(report.protected_sessions, 1); + assert!(report.cleanup_blocked); + assert!(temp.path().join("skill-deck-source-protected").is_dir()); + assert!(!temp.path().join("skill-deck-source-orphan").exists()); + assert!(temp.path().join("skill-deck-source-foreign").is_dir()); +} + +#[test] +fn payload_upload_is_staged_and_published_only_after_exact_manifest_validation() { + use sha2::{Digest, Sha256}; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let mut manager = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let source_payload = manager + .acquire_from_source("source", "payload-demo", &source) + .unwrap(); + let manifest = source_payload.manifest.clone(); + manager.remove_session("source").unwrap(); + + let upload_id = manager.begin_upload("uploaded", "payload-demo").unwrap(); + let blob_id = format!("{:x}", Sha256::digest(b"demo")); + let prepared = manager.prepare_blob(upload_id, &blob_id).unwrap(); + std::fs::write(&prepared.path, b"demo").unwrap(); + manager + .commit_blob(upload_id, &blob_id, prepared.path) + .unwrap(); + let manifest_file = manager.prepare_manifest(upload_id).unwrap(); + std::fs::write(&manifest_file.path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + let uploaded = manager + .finalize_upload(upload_id, manifest_file.path) + .unwrap(); + + assert_eq!(uploaded.manifest, manifest); + assert!(uploaded.root.join("manifest.json").is_file()); + + let abandoned = manager.begin_upload("abandoned", "payload-demo").unwrap(); + manager.remove_session("abandoned").unwrap(); + assert!(manager.prepare_manifest(abandoned).is_err()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/projection.rs b/src-tauri/crates/wsl-environment-worker/tests/projection.rs new file mode 100644 index 00000000..16b794f6 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/projection.rs @@ -0,0 +1,18 @@ +use environment_protocol::{ProjectionRequest, MAX_INSPECTION_ROOTS}; + +#[test] +fn oversized_projection_reports_the_request_limit() { + let error = wsl_environment_worker::execute_projection( + ProjectionRequest { + destinations: (0..=MAX_INSPECTION_ROOTS) + .map(|index| format!("/tmp/skill-deck-projection-{index}")) + .collect(), + deadline_millis: 10_000, + }, + || false, + ) + .unwrap_err(); + + assert_eq!(error.phase, "projection"); + assert_eq!(error.code, "requestTooLarge"); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/runtime.rs b/src-tauri/crates/wsl-environment-worker/tests/runtime.rs new file mode 100644 index 00000000..490074fc --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/runtime.rs @@ -0,0 +1,160 @@ +use std::io::Write; + +#[cfg(target_os = "linux")] +use environment_protocol::{ + InspectionEntryKind, InspectionRequest, InspectionRoot, WriteProbeRequest, +}; +use environment_protocol::{Message, PathKind}; +#[cfg(target_os = "linux")] +use wsl_environment_worker::{execute_inspection, execute_write_probe}; +use wsl_environment_worker::{file_sha256, Dispatch, WorkerIdentity, WorkerRuntime}; + +fn identity(home: &str) -> WorkerIdentity { + WorkerIdentity { + distro: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: home.to_string(), + } +} + +#[test] +fn handshake_accepts_only_the_running_binary_build() { + let temp = tempfile::tempdir().unwrap(); + let runtime = WorkerRuntime::new( + "sha256:worker-v1".to_string(), + identity(temp.path().to_str().unwrap()), + ); + + assert_eq!( + runtime.dispatch(Message::Handshake { + build_id: "sha256:worker-v1".to_string(), + }), + Dispatch { + response: Some(Message::HandshakeResult { + build_id: "sha256:worker-v1".to_string(), + distro: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: temp.path().to_string_lossy().into_owned(), + }), + close: false, + } + ); + + assert_eq!( + runtime.dispatch(Message::Handshake { + build_id: "sha256:other".to_string(), + }), + Dispatch { + response: Some(Message::Error { + code: "buildMismatch".to_string(), + phase: "handshake".to_string(), + parameters: Vec::new(), + }), + close: true, + } + ); +} + +#[test] +fn observe_path_reports_directory_file_missing_and_symlink() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("file"); + std::fs::write(&file, b"content").unwrap(); + let runtime = WorkerRuntime::new("build".to_string(), identity(temp.path().to_str().unwrap())); + + for (path, expected) in [ + (temp.path().to_path_buf(), PathKind::Directory), + (file, PathKind::File), + (temp.path().join("missing"), PathKind::Missing), + ] { + assert_eq!( + runtime.dispatch(Message::ObservePath { + path: path.to_string_lossy().into_owned(), + }), + Dispatch { + response: Some(Message::PathObserved { kind: expected }), + close: false, + } + ); + } + + #[cfg(unix)] + { + let link = temp.path().join("link"); + std::os::unix::fs::symlink(temp.path(), &link).unwrap(); + assert_eq!( + runtime.dispatch(Message::ObservePath { + path: link.to_string_lossy().into_owned(), + }), + Dispatch { + response: Some(Message::PathObserved { + kind: PathKind::SymlinkDirectory, + }), + close: false, + } + ); + } +} + +#[test] +fn self_hash_is_the_sha256_of_the_exact_binary_bytes() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"worker-bytes").unwrap(); + assert_eq!( + file_sha256(file.path()).unwrap(), + "sha256:11b87fcc63c88aff5a8568038519a02d7db3551e1432836b8d60bbf6eb6a7b38" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn inspection_adapter_executes_the_shared_engine_and_returns_raw_path_bytes() { + use std::os::unix::ffi::OsStringExt; + + let temp = tempfile::tempdir().unwrap(); + let raw_name = std::ffi::OsString::from_vec(vec![b's', b'k', 0x80]); + std::fs::write(temp.path().join(&raw_name), b"payload").unwrap(); + + let response = execute_inspection( + InspectionRequest { + read_content: true, + roots: vec![InspectionRoot { + path: temp.path().to_string_lossy().into_owned(), + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + deadline_millis: 1_000, + }, + || false, + ) + .unwrap(); + + assert!(response.facts.iter().any(|fact| { + fact.relative_path == vec![b's', b'k', 0x80] && fact.kind == InspectionEntryKind::File + })); +} + +#[cfg(target_os = "linux")] +#[test] +fn write_probe_adapter_rejects_an_already_read_only_parent() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("read-only"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let result = execute_write_probe( + WriteProbeRequest { + destinations: vec![parent.join("demo").to_string_lossy().into_owned()], + deadline_millis: 1_000, + }, + || false, + ); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/source.rs b/src-tauri/crates/wsl-environment-worker/tests/source.rs new file mode 100644 index 00000000..9e878f84 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/source.rs @@ -0,0 +1,236 @@ +#![cfg(target_os = "linux")] +#![allow( + clippy::disallowed_methods, + reason = "Worker Git 集成测试需要直接创建受控的本地 Git fixture" +)] + +use std::process::Command; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Duration; + +use environment_protocol::{SourceScanMode, SourceScanRequest, SourceScanRoot}; +use wsl_environment_worker::source::scan_source; +use wsl_environment_worker::source::{GitSourceOptions, SourceManager}; + +fn git(cwd: &std::path::Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +#[tokio::test] +async fn saved_ref_probe_and_checkout_agree_for_branch_tag_name_collisions() { + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + std::fs::create_dir(&repository).unwrap(); + git(&repository, &["init", "-b", "main"]); + git(&repository, &["config", "user.email", "test@example.com"]); + git(&repository, &["config", "user.name", "Skill Deck Test"]); + git(&repository, &["commit", "--allow-empty", "-m", "tag"]); + let tag = git(&repository, &["rev-parse", "HEAD"]); + git(&repository, &["tag", "-a", "release", "-m", "release"]); + git(&repository, &["commit", "--allow-empty", "-m", "branch"]); + let branch = git(&repository, &["rev-parse", "HEAD"]); + git(&repository, &["branch", "release"]); + let mut manager = SourceManager::new(temp.path().join("managed")).unwrap(); + for (reference, expected) in [ + (None, &branch), + (Some("release"), &branch), + (Some("refs/heads/release"), &branch), + (Some("refs/tags/release"), &tag), + ] { + let options = || GitSourceOptions { + url: repository.to_string_lossy().into_owned(), + git_ref: reference.map(str::to_string), + proxy: None, + deadline: Duration::from_secs(30), + }; + let probed = + wsl_environment_worker::source::probe_git(options(), Arc::new(AtomicBool::new(false))) + .await + .unwrap(); + let acquired = manager + .acquire_git(options(), Arc::new(AtomicBool::new(false))) + .await + .unwrap(); + assert_eq!(&probed, expected, "{reference:?}"); + assert_eq!(acquired.revision.as_ref(), Some(expected), "{reference:?}"); + manager.release(acquired.id).unwrap(); + } +} + +#[tokio::test] +async fn source_manager_owns_git_clones_but_not_opened_local_directories() { + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + std::fs::create_dir(&repository).unwrap(); + git(&repository, &["init", "-b", "main"]); + git(&repository, &["config", "user.email", "test@example.com"]); + git(&repository, &["config", "user.name", "Skill Deck Test"]); + std::fs::write(repository.join("SKILL.md"), b"demo").unwrap(); + std::fs::create_dir_all(repository.join("skills/demo")).unwrap(); + std::fs::write(repository.join("skills/demo/SKILL.md"), b"nested").unwrap(); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "fixture"]); + let expected_revision = git(&repository, &["rev-parse", "HEAD"]); + let expected_skill_revision = git(&repository, &["rev-parse", "HEAD:skills/demo"]); + + let managed_base = temp.path().join("managed"); + std::fs::create_dir(&managed_base).unwrap(); + let mut manager = SourceManager::new(managed_base).unwrap(); + let local = manager.open_local(repository.to_str().unwrap()).unwrap(); + let git_source = manager + .acquire_git( + GitSourceOptions { + url: repository.to_string_lossy().into_owned(), + git_ref: None, + proxy: None, + deadline: Duration::from_secs(30), + }, + Arc::new(AtomicBool::new(false)), + ) + .await + .unwrap(); + + assert_eq!( + git_source.revision.as_deref(), + Some(expected_revision.as_str()) + ); + let tree_revision = manager + .tree_revision( + git_source.id, + std::path::Path::new(""), + Duration::from_secs(30), + Arc::new(AtomicBool::new(false)), + ) + .await + .unwrap(); + assert_eq!(tree_revision.len(), expected_revision.len()); + let skill_revision = manager + .tree_revision( + git_source.id, + std::path::Path::new("skills/demo"), + Duration::from_secs(30), + Arc::new(AtomicBool::new(false)), + ) + .await + .unwrap(); + assert_eq!(skill_revision, expected_skill_revision); + assert!(manager + .root(git_source.id) + .unwrap() + .join("SKILL.md") + .is_file()); + let managed_root = manager.root(git_source.id).unwrap().to_path_buf(); + let cleanup_root = managed_root.parent().unwrap().to_path_buf(); + let held = cleanup_root.with_file_name("held-source"); + std::fs::rename(&cleanup_root, &held).unwrap(); + std::fs::write(&cleanup_root, b"occupied").unwrap(); + let failed_release = manager.release(git_source.id); + std::fs::remove_file(&cleanup_root).unwrap(); + std::fs::rename(&held, &cleanup_root).unwrap(); + assert!(failed_release.is_err()); + assert!(manager.root(git_source.id).is_ok()); + manager.release(git_source.id).unwrap(); + assert!(!managed_root.exists()); + manager.release(local.id).unwrap(); + assert!(repository.join("SKILL.md").is_file()); +} + +#[test] +fn source_relative_paths_cannot_escape_the_opened_root() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("source"); + std::fs::create_dir(&root).unwrap(); + let mut manager = SourceManager::new(temp.path().join("managed")).unwrap(); + let source = manager.open_local(root.to_str().unwrap()).unwrap(); + + assert!(manager + .resolve(source.id, std::path::Path::new("skills/demo")) + .is_ok()); + assert!(manager + .resolve(source.id, std::path::Path::new("../outside")) + .is_err()); + assert!(manager + .resolve(source.id, std::path::Path::new("/absolute")) + .is_err()); +} + +#[test] +fn source_scan_projects_relative_wire_paths_through_the_handle() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("source"); + std::fs::create_dir_all(root.join("skills/demo")).unwrap(); + std::fs::write(root.join("skills/demo/SKILL.md"), b"demo").unwrap(); + let mut manager = SourceManager::new(temp.path().join("managed")).unwrap(); + let source = manager.open_local(root.to_str().unwrap()).unwrap(); + + let response = scan_source( + &manager, + SourceScanRequest { + source_id: source.id, + roots: vec![SourceScanRoot { + relative_path: b"skills".to_vec(), + stat_only: false, + }], + mode: SourceScanMode::Recursive, + per_file_limit: 1024, + aggregate_limit: 4096, + deadline_millis: 30_000, + }, + || false, + ) + .unwrap(); + + assert!(response + .entries + .iter() + .any(|entry| entry.relative_path == b"demo/SKILL.md")); +} + +#[test] +fn saved_skill_scan_rejects_directory_links_before_reading_content() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("source"); + let outside = temp.path().join("outside"); + for parent in [&root, &outside] { + std::fs::create_dir_all(parent.join("real/demo")).unwrap(); + std::fs::write( + parent.join("real/demo/SKILL.md"), + b"must not follow directory links", + ) + .unwrap(); + } + std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap(); + std::os::unix::fs::symlink(outside.join("real"), root.join("escape")).unwrap(); + std::os::unix::fs::symlink(root.join("real/demo"), root.join("leaf")).unwrap(); + let mut manager = SourceManager::new(temp.path().join("managed")).unwrap(); + let source = manager.open_local(root.to_str().unwrap()).unwrap(); + for relative in ["alias/demo", "escape/demo", "leaf"] { + let result = scan_source( + &manager, + SourceScanRequest { + source_id: source.id, + roots: vec![SourceScanRoot { + relative_path: relative.as_bytes().to_vec(), + stat_only: false, + }], + mode: SourceScanMode::SkillMetadata, + per_file_limit: 1024, + aggregate_limit: 4096, + deadline_millis: 30_000, + }, + || false, + ); + assert!(result.is_err(), "{relative}: {result:?}"); + } +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/stdio.rs b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs new file mode 100644 index 00000000..f607dc81 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs @@ -0,0 +1,1458 @@ +#![cfg(target_os = "linux")] +#![allow( + clippy::disallowed_methods, + reason = "Worker 集成测试需要直接启动当前 crate 构建的受控二进制" +)] + +use std::process::Stdio; +use std::time::Duration; + +use environment_protocol::{codec, decode, Envelope, Message, PathKind, WireRecord}; +#[cfg(target_os = "linux")] +use environment_protocol::{ + decode_inspection_response, decode_payload, AcquirePayloadFromSourceRequest, + DocumentWritePreparation, InspectionRequest, InspectionRoot, MapHostPathsRequest, + MapHostPathsResponse, MutationEntry, MutationEntryAction, MutationUnitOutcome, + MutationUnitRequest, OpenLocalSourceRequest, PayloadReadyResponse, VerifyPayloadRequest, +}; +use futures_util::StreamExt; +use tokio::process::Command; +use tokio::time::timeout; +use tokio_util::codec::FramedRead; +use wsl_environment_worker::file_sha256; + +#[tokio::test] +async fn worker_binary_handshakes_observes_home_and_shuts_down() { + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { + build_id: build_id.clone(), + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await, + Envelope { + request_id: 1, + message: Message::HandshakeResult { + build_id: actual, + distro, + user, + home: actual_home, + .. + }, + } if actual == build_id + && distro == "Ubuntu" + && user == "alice" + && actual_home == home.path().to_string_lossy() + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::ObservePath { + path: home.path().to_string_lossy().into_owned(), + }, + })) + .await + .unwrap(); + assert_eq!( + next_message(&mut reader).await, + Envelope { + request_id: 2, + message: Message::PathObserved { + kind: PathKind::Directory, + }, + } + ); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[tokio::test] +async fn worker_probe_dispatch_preserves_explicit_git_ref() { + let home = tempfile::tempdir().unwrap(); + let repository = home.path().join("repository"); + std::fs::create_dir(&repository).unwrap(); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .current_dir(&repository) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + }; + git(&["init", "-b", "main"]); + git(&["config", "user.name", "Skill Deck Test"]); + git(&["config", "user.email", "test@example.com"]); + git(&["commit", "--allow-empty", "-m", "tag"]); + git(&["tag", "-a", "release", "-m", "release"]); + let tag = git(&["rev-parse", "HEAD"]); + git(&["commit", "--allow-empty", "-m", "branch"]); + git(&["branch", "release"]); + let branch = git(&["rev-parse", "HEAD"]); + assert_ne!(tag, branch); + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(child.stdin.take().unwrap()); + let mut reader = FramedRead::new(child.stdout.take().unwrap(), codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { + build_id: file_sha256(std::path::Path::new(binary)).unwrap(), + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + for (index, (reference, expected)) in [("release", branch), ("refs/tags/release", tag)] + .into_iter() + .enumerate() + { + writer + .send_control(WireRecord::Control(Envelope { + request_id: index as u64 + 2, + message: Message::ProbeGit { + request: environment_protocol::GitSourceRequest { + url: repository.to_string_lossy().into_owned(), + git_ref: Some(reference.into()), + proxy: None, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + assert_eq!( + next_message(&mut reader).await.message, + Message::GitProbed { revision: expected } + ); + } + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[tokio::test] +async fn worker_maps_host_paths_with_structured_wslpath_arguments() { + use std::os::unix::fs::PermissionsExt; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let tools = tempfile::tempdir().unwrap(); + let wslpath = tools.path().join("wslpath"); + std::fs::write( + &wslpath, + r#"#!/bin/sh +[ "$1" = "-u" ] && [ "$2" = "--" ] || exit 64 +case "$3" in + 'C:\Code\Skill Deck') printf '%s\n' '/mnt/c/Code/Skill Deck' ;; + '\\server\share\项目') printf '%s\n' '/mnt/server/share/项目' ;; + *) exit 65 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&wslpath, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .env("PATH", tools.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::MapHostPaths { + request: MapHostPathsRequest { + paths: vec![ + r"C:\Code\Skill Deck".to_string(), + r"\\server\share\项目".to_string(), + ], + deadline_millis: 5_000, + }, + }, + })) + .await + .unwrap(); + let response: MapHostPathsResponse = + decode_payload(&next_transfer(&mut reader, 2).await).unwrap(); + assert_eq!( + response.mapped, + ["/mnt/c/Code/Skill Deck", "/mnt/server/share/项目"] + ); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[tokio::test] +async fn host_path_mapping_deadline_reaps_wslpath() { + use std::os::unix::fs::PermissionsExt; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let tools = tempfile::tempdir().unwrap(); + let pid_file = tools.path().join("wslpath.pid"); + let wslpath = tools.path().join("wslpath"); + std::fs::write( + &wslpath, + "#!/bin/sh\nprintf '%s\\n' \"$$\" > \"$SKILL_DECK_TEST_PID\"\nexec /bin/sleep 30\n", + ) + .unwrap(); + std::fs::set_permissions(&wslpath, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .env("PATH", tools.path()) + .env("SKILL_DECK_TEST_PID", &pid_file) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::MapHostPaths { + request: MapHostPathsRequest { + paths: vec![r"C:\Code\slow".to_string()], + deadline_millis: 50, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await, + Envelope { + request_id: 2, + message: Message::Error { ref code, ref phase, .. }, + } if code == "deadlineExceeded" && phase == "pathMapping" + )); + + let pid = std::fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse::() + .unwrap(); + for _ in 0..100 { + if !std::path::Path::new(&format!("/proc/{pid}")).exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(!std::path::Path::new(&format!("/proc/{pid}")).exists()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_streams_inspection_without_starving_control() { + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let root = home.path().join("skills"); + for index in 0..128 { + let skill = root.join(format!("skill-{index}")); + std::fs::create_dir_all(&skill).unwrap(); + std::fs::write(skill.join("SKILL.md"), vec![b'x'; 32 * 1024]).unwrap(); + } + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { + build_id: build_id.clone(), + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::InspectFilesystem { + request: InspectionRequest { + read_content: true, + roots: vec![InspectionRoot { + path: root.to_string_lossy().into_owned(), + stat_only: false, + }], + per_file_limit: 256 * 1024, + aggregate_limit: 8 * 1024 * 1024, + deadline_millis: 5_000, + }, + }, + })) + .await + .unwrap(); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::ObservePath { + path: home.path().to_string_lossy().into_owned(), + }, + })) + .await + .unwrap(); + + let mut payload = Vec::new(); + let mut record_index = 0usize; + let mut observed_index = None; + let mut completed_index = None; + while observed_index.is_none() || completed_index.is_none() { + let record = decode(&reader.next().await.unwrap().unwrap()).unwrap(); + match record { + WireRecord::Control(Envelope { + request_id: 3, + message: Message::PathObserved { kind }, + }) => { + assert_eq!(kind, PathKind::Directory); + observed_index = Some(record_index); + } + WireRecord::Control(Envelope { + request_id: 2, + message: Message::BeginTransfer { .. }, + }) => {} + WireRecord::PayloadChunk { + transfer_id: 2, + bytes, + } => payload.extend_from_slice(&bytes), + WireRecord::Control(Envelope { + request_id: 2, + message: Message::TransferCompleted { .. }, + }) => completed_index = Some(record_index), + other => panic!("unexpected worker record: {other:?}"), + } + record_index += 1; + } + + assert!(observed_index.unwrap() < completed_index.unwrap()); + let response = decode_inspection_response(&payload).unwrap(); + assert_eq!(response.total_content_bytes, 4 * 1024 * 1024); + assert_eq!(response.facts.len(), 257); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_completes_source_payload_and_host_upload_lifecycles_over_stdio() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let session_id = format!("stdio-{}", std::process::id()); + let uploaded_session_id = format!("stdio-upload-{}", std::process::id()); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::OpenLocalSource { + request: OpenLocalSourceRequest { + path: source.to_string_lossy().into_owned(), + }, + }, + })) + .await + .unwrap(); + let source_id = match next_message(&mut reader).await.message { + Message::SourceOpened { source_id, .. } => source_id, + message => panic!("unexpected source response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest { + source_id, + relative_path: Vec::new(), + session_id: session_id.clone(), + payload_name: "payload-stdio".to_string(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let acquired: PayloadReadyResponse = + decode_payload(&next_transfer(&mut reader, 3).await).unwrap(); + let blob_id = acquired + .manifest + .entries + .iter() + .find_map(|entry| entry.blob_id.clone()) + .unwrap(); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::ReadPayloadBlob { + payload_id: acquired.payload_id, + blob_id: blob_id.clone(), + deadline_millis: 30_000, + }, + })) + .await + .unwrap(); + assert_eq!(next_transfer(&mut reader, 4).await, b"demo"); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 5, + message: Message::BeginPayloadUpload { + session_id: uploaded_session_id.clone(), + payload_name: "payload-stdio".to_string(), + }, + })) + .await + .unwrap(); + let upload_id = match next_message(&mut reader).await.message { + Message::PayloadUploadBegun { upload_id } => upload_id, + message => panic!("unexpected upload response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 6, + message: Message::UploadPayloadBlob { + upload_id, + blob_id: blob_id.clone(), + total_bytes: 4, + sha256: format!("sha256:{blob_id}"), + }, + })) + .await + .unwrap(); + let blob_transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected transfer response: {message:?}"), + }; + writer + .send_transfer(7, blob_transfer_id, b"demo") + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadBlobUploaded { .. } + )); + let manifest = serde_json::to_vec(&acquired.manifest).unwrap(); + let manifest_sha = format!("sha256:{:x}", Sha256::digest(&manifest)); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 8, + message: Message::FinalizePayloadUpload { + upload_id, + total_bytes: manifest.len() as u64, + sha256: manifest_sha, + }, + })) + .await + .unwrap(); + let manifest_transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected manifest response: {message:?}"), + }; + writer + .send_transfer(9, manifest_transfer_id, &manifest) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadUploadFinalized { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 10, + message: Message::VerifyPayload { + request: VerifyPayloadRequest { + session_id: uploaded_session_id.clone(), + payload_name: "payload-stdio".to_string(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let verified: Option = + decode_payload(&next_transfer(&mut reader, 10).await).unwrap(); + assert_eq!(verified.unwrap().manifest, acquired.manifest); + + for (request_id, cleanup_session) in [(11, session_id), (12, uploaded_session_id)] { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::RemovePayloadSession { + session_id: cleanup_session, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadSessionRemoved { .. } + )); + } + writer + .send_control(WireRecord::Control(Envelope { + request_id: 13, + message: Message::ReleaseSource { source_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::SourceReleased { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 14, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_executes_one_accepted_mutation_and_acknowledges_cleanup_over_stdio() { + use environment_engine::linux_mutation::{ + content_hash_path, fingerprint_path, parent_identity, + }; + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let destination = home.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let resource_id = format!( + "{:x}", + Sha256::digest(destination.as_os_str().as_encoded_bytes()) + ); + let backup = destination + .parent() + .unwrap() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let session_id = format!("stdio-mutation-{}", std::process::id()); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::OpenLocalSource { + request: OpenLocalSourceRequest { + path: source.to_string_lossy().into_owned(), + }, + }, + })) + .await + .unwrap(); + let source_id = match next_message(&mut reader).await.message { + Message::SourceOpened { source_id, .. } => source_id, + message => panic!("unexpected source response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest { + source_id, + relative_path: Vec::new(), + session_id: session_id.clone(), + payload_name: "payload-mutation".to_string(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let payload: PayloadReadyResponse = + decode_payload(&next_transfer(&mut reader, 3).await).unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let fingerprint = fingerprint_path(&destination).unwrap(); + let marker = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "stdio-operation", + "unitId": "stdio-unit", + "subject": { + "operationKind": "install", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "stdio-target", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": backup + }, + "expectedState": "present", + "originalFingerprint": fingerprint, + "phase": "staged" + }] + })) + .unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "stdio-operation".to_string(), + unit_id: "stdio-unit".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: fingerprint, + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: MutationEntryAction::Materialize { + payload_id: payload.payload_id, + }, + }], + lock: None, + deadline_millis: 30_000, + }; + let request_bytes = environment_protocol::encode_payload(&request).unwrap(); + let request_sha = format!("sha256:{:x}", Sha256::digest(&request_bytes)); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::PrepareMutationUnit { + resource_id: resource_id.clone(), + total_bytes: request_bytes.len() as u64, + sha256: request_sha, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected mutation preparation response: {message:?}"), + }; + writer + .send_transfer(4, transfer_id, &request_bytes) + .await + .unwrap(); + assert_eq!( + next_message(&mut reader).await, + Envelope { + request_id: 4, + message: Message::MutationAccepted { + resource_id: resource_id.clone(), + }, + } + ); + let outcome: MutationUnitOutcome = + decode_payload(&next_transfer(&mut reader, 4).await).unwrap(); + let cleanup = match outcome { + MutationUnitOutcome::Succeeded { + cleanup: Some(cleanup), + .. + } => cleanup, + outcome => panic!("unexpected mutation outcome: {outcome:?}"), + }; + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + assert!(backup.is_dir()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 5, + message: Message::AcknowledgeMutationUnit { + cleanup: cleanup.clone(), + }, + })) + .await + .unwrap(); + assert_eq!( + next_message(&mut reader).await, + Envelope { + request_id: 5, + message: Message::MutationAcknowledged { + resource_id: resource_id.clone(), + }, + } + ); + assert!(!backup.exists()); + assert!(!std::path::Path::new("/tmp") + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 6, + message: Message::RemovePayloadSession { session_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadSessionRemoved { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 7, + message: Message::ReleaseSource { source_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::SourceReleased { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 8, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_writes_one_document_atomically_over_stdio() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".skill-deck/projects.json"); + let bytes = br#"{"projects":[]}"#; + let digest = format!("sha256:{:x}", Sha256::digest(bytes)); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let worker_pid = child.id().expect("worker process ID"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest.clone(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected document preparation response: {message:?}"), + }; + writer.send_transfer(2, transfer_id, bytes).await.unwrap(); + let revision = match next_message(&mut reader).await.message { + Message::DocumentWritten { revision } => revision, + message => panic!("unexpected document write response: {message:?}"), + }; + assert_eq!(revision, digest); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + std::fs::write(&path, b"external").unwrap(); + let replacement = br#"{"projects":["demo"]}"#; + let replacement_digest = format!("sha256:{:x}", Sha256::digest(replacement)); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: Some(revision), + total_bytes: replacement.len() as u64, + sha256: replacement_digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected document preparation response: {message:?}"), + }; + writer + .send_transfer(3, transfer_id, replacement) + .await + .unwrap(); + match next_message(&mut reader).await.message { + Message::Error { code, phase, .. } => { + assert_eq!(code, "documentConflict"); + assert_eq!(phase, "documentWrite"); + } + message => panic!("unexpected document conflict response: {message:?}"), + } + assert_eq!(std::fs::read(&path).unwrap(), b"external"); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::RemoveDocument { + request: environment_protocol::DocumentRemoveRequest { + path: path.to_string_lossy().into_owned(), + expected_revision: Some(format!("sha256:{:x}", Sha256::digest(b"external"))), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::DocumentRemoved + )); + assert!(!path.exists()); + + let abandoned_path = home.path().join(".skill-deck/abandoned.json"); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 5, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: abandoned_path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 6, + message: Message::Cancel { + target_request_id: 5, + }, + })) + .await + .unwrap(); + let replacement_path = home.path().join(".skill-deck/replacement.json"); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 7, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: replacement_path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: format!("sha256:{:x}", Sha256::digest(bytes)), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let cancelled = next_message(&mut reader).await; + assert_eq!(cancelled.request_id, 5); + assert!(matches!( + cancelled.message, + Message::Error { ref code, .. } if code == "cancelled" + )); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 8, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); + assert!(!abandoned_path.exists()); + for request_id in [2_u64, 3, 5, 7] { + assert!(!std::path::PathBuf::from(format!( + "/tmp/.skill-deck-document-request-{worker_pid}-{request_id}" + )) + .exists()); + } +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn prepared_document_expires_without_follow_up_traffic() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".skill-deck/expired.json"); + let bytes = br#"{"value":1}"#; + let digest = format!("sha256:{:x}", Sha256::digest(bytes)); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest.clone(), + deadline_millis: 25, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + + let expired = timeout(Duration::from_secs(2), next_message(&mut reader)) + .await + .expect("prepared document must expire without another request"); + assert_eq!(expired.request_id, 2); + assert!(matches!( + expired.message, + Message::Error { ref code, .. } if code == "deadlineExceeded" + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); + assert!(!path.exists()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_executes_one_library_catalog_operation_over_stdio() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + let request = environment_protocol::LibraryOperationRequest { + operation_id: "save-catalog-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: catalog.clone(), + action: environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: Vec::new(), + }, + deadline_millis: 30_000, + }; + let payload = environment_protocol::encode_payload(&request).unwrap(); + let digest = format!("sha256:{:x}", Sha256::digest(&payload)); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::PrepareLibraryOperation { + request: environment_protocol::LibraryOperationPreparation { + total_bytes: payload.len() as u64, + sha256: digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected Library preparation response: {message:?}"), + }; + writer + .send_transfer(2, transfer_id, &payload) + .await + .unwrap(); + let revision = match next_message(&mut reader).await.message { + Message::LibraryOperationCompleted { catalog_revision } => catalog_revision, + message => panic!("unexpected Library operation response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::ReadLibraryCatalog { + deadline_millis: 30_000, + }, + })) + .await + .unwrap(); + let response: environment_protocol::LibraryCatalogResponse = + environment_protocol::decode_payload(&next_transfer(&mut reader, 3).await).unwrap(); + assert!(response.present); + assert_eq!(response.bytes, catalog); + assert_eq!(response.revision, Some(revision)); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[tokio::test] +async fn worker_lists_only_managed_library_application_keys() { + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let applications = home.path().join(".skill-deck/skill-libraries/applications"); + let projects = applications.join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::write(projects.join("registered.json"), b"{}").unwrap(); + std::fs::write(projects.join("orphan.json"), b"{}").unwrap(); + std::fs::create_dir(projects.join("not-a-record.json")).unwrap(); + std::fs::write(applications.join("unexpected.json"), b"{}").unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + let _ = next_message(&mut reader).await; + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::ListLibraryApplications { + deadline_millis: 30_000, + }, + })) + .await + .unwrap(); + let response: environment_protocol::LibraryApplicationIndex = + environment_protocol::decode_payload(&next_transfer(&mut reader, 2).await).unwrap(); + + assert_eq!(response.project_ids, vec!["orphan", "registered"]); + assert!(!response.complete); + assert_eq!(response.problem_keys.len(), 2); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +async fn next_transfer( + reader: &mut FramedRead, + owner_request_id: u64, +) -> Vec +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut payload = Vec::new(); + loop { + match decode(&reader.next().await.unwrap().unwrap()).unwrap() { + WireRecord::Control(Envelope { + request_id, + message: Message::BeginTransfer { .. }, + }) => assert_eq!(request_id, owner_request_id), + WireRecord::PayloadChunk { bytes, .. } => payload.extend(bytes), + WireRecord::Control(Envelope { + request_id, + message: Message::TransferCompleted { .. }, + }) => { + assert_eq!(request_id, owner_request_id); + return payload; + } + record => panic!("unexpected transfer record: {record:?}"), + } + } +} + +async fn next_message( + reader: &mut FramedRead, +) -> Envelope +where + R: tokio::io::AsyncRead + Unpin, +{ + match decode(&reader.next().await.unwrap().unwrap()).unwrap() { + WireRecord::Control(envelope) => envelope, + WireRecord::PayloadChunk { .. } => panic!("unexpected payload chunk"), + } +} diff --git a/src-tauri/permissions/window-command-sets.toml b/src-tauri/permissions/window-command-sets.toml index bd462ab5..82a4b6b5 100644 --- a/src-tauri/permissions/window-command-sets.toml +++ b/src-tauri/permissions/window-command-sets.toml @@ -7,9 +7,7 @@ permissions = [ "allow-execute-lifecycle-action", "allow-get-active-mutation", "allow-get-install-agent-selection", - "allow-install-skills", "allow-list-environment-projects", - "allow-preview-install", "allow-request-cancel-active-mutation", "allow-search-discover-skills" ] @@ -62,17 +60,20 @@ permissions = [ "allow-open-recovery-resource", "allow-open-skill-resource", "allow-preview-add-library-skills", + "allow-preview-remove-library-skill", "allow-preview-copy-skill-to-projects", "allow-preview-custom-agent-delete", "allow-preview-library-application", - "allow-preview-library-skill-updates", + "allow-prepare-library-skill-updates", "allow-preview-manage-skill-agents", "allow-preview-remove", - "allow-preview-update", + "allow-prepare-update", + "allow-cancel-update-preparation", "allow-read-library-skill-content", "allow-read-skill-content", "allow-remove-environment-project", "allow-remove-library-skill", + "allow-resume-library-membership", "allow-remove-skill", "allow-rename-skill-library", "allow-retry-library-application", @@ -85,8 +86,7 @@ permissions = [ "allow-set-wsl-integration-enabled", "allow-test-proxy-connection", "allow-update-library-skills", - "allow-update-skill", - "allow-update-skills-batch", + "allow-execute-update", "allow-validate-custom-agent-draft" ] @@ -95,5 +95,7 @@ identifier = "install-wizard-commands" description = "Application commands available to the install wizard" permissions = [ "shared-business-commands", - "allow-confirm-install-agent-selection" + "allow-confirm-install-agent-selection", + "allow-install-skills", + "allow-preview-install" ] diff --git a/src-tauri/src/application/agent_selection.rs b/src-tauri/src/application/agent_selection.rs index 8e1e8e67..2ed3fc28 100644 --- a/src-tauri/src/application/agent_selection.rs +++ b/src-tauri/src/application/agent_selection.rs @@ -137,6 +137,7 @@ pub struct AgentSelectionGroup { #[specta(rename_all = "camelCase")] pub enum UnavailableAgentSelectionReason { DefinitionMissing, + OptionUnavailable, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -154,7 +155,7 @@ pub struct AgentSelectionSnapshot { pub agents: Vec, pub install_options: Vec, pub groups: Vec, - pub initial_selected_option_ids: Vec, + pub baseline_selected_option_ids: Vec, pub unavailable_explicit_agents: Vec, pub user_mode_option_ids: Vec, pub revision: AgentSelectionRevision, @@ -609,7 +610,7 @@ pub(crate) fn apply_initial_agent_selection( .iter() .map(|agent| (&agent.id, agent.directory_access)) .collect::>(); - snapshot.initial_selected_option_ids = catalog + snapshot.baseline_selected_option_ids = catalog .snapshot() .install_options .iter() @@ -926,7 +927,7 @@ pub(crate) async fn build_agent_selection_catalog( agents, install_options, groups, - initial_selected_option_ids: Vec::new(), + baseline_selected_option_ids: Vec::new(), unavailable_explicit_agents: Vec::new(), user_mode_option_ids, revision, @@ -978,7 +979,7 @@ pub(crate) async fn test_submission_for_agents( ); AgentSelectionSubmission { revision: snapshot.revision, - selected_option_ids: snapshot.initial_selected_option_ids, + selected_option_ids: snapshot.baseline_selected_option_ids, requested_mode, } } @@ -1007,7 +1008,7 @@ pub(crate) async fn test_submission_for_agents_and_own_directories>(), ); - snapshot.initial_selected_option_ids = catalog + snapshot.baseline_selected_option_ids = catalog .snapshot() .install_options .iter() @@ -1021,7 +1022,7 @@ pub(crate) async fn test_submission_for_agents_and_own_directories { let distro_name = distro_name.clone(); - wsl.with_session_retry(&distro_name, move |session| { + let workspace = wsl.workspace(&distro_name)?; + wsl.with_session_read_retry(&distro_name, move |session| { let context = context.clone(); + let workspace = workspace.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; read_last_selected_agents_with_io( - EnvironmentLockIo::ActiveWsl(session), + EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }, &global_lock_target(resolved.lock), ) .await @@ -63,13 +69,19 @@ pub async fn set_last_selected_agents( EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let selected_agent_ids = selected_agent_ids.to_vec(); - wsl.with_session_retry(&distro_name, move |session| { + let workspace = wsl.workspace(&distro_name)?; + wsl.with_session(&distro_name, move |session| { let context = context.clone(); let selected_agent_ids = selected_agent_ids.clone(); + let workspace = workspace.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; write_last_selected_agents_with_io( - EnvironmentLockIo::ActiveWsl(session), + EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }, global_lock_target(resolved.lock), &selected_agent_ids, ) @@ -144,7 +156,7 @@ mod tests { "/tmp/skill-deck-agent-history-test-{}", uuid::Uuid::new_v4() ); - let wsl = WslRuntime::new_with_support(true, true); + let wsl = WslRuntime::for_wsl_test(); let outcome = wsl_history_round_trip(&wsl, &distro_name, &root).await; let cleanup = cleanup_wsl_history_test_root(&distro_name, &root).await; @@ -168,22 +180,25 @@ mod tests { distro_name: &str, root: &str, ) -> Result<(Option>, serde_json::Value), AppError> { - let mut session = wsl.connect(distro_name).await?; - session.home = root.to_string(); - session.xdg_state_home = None; - session.config_home = format!("{root}/.config"); - wsl.insert(session.clone()); + let session = wsl.connect(distro_name).await?; let environment = EnvironmentRef::Wsl { distro_name: distro_name.to_string(), }; - let context = SkillLocationRef { - environment: environment.clone(), - scope: SkillLocation::Global, + let target = LockTarget { + primary: ResourceLocator { + environment: environment.clone(), + native_path: format!("{root}/skills-lock.json"), + }, + legacy: None, + schema: LockSchema::Global, + }; + let workspace = wsl.workspace(distro_name)?; + let io = EnvironmentLockIo::ActiveWsl { + session: Box::new(session.clone()), + workspace: workspace.clone(), }; - let resolved = ContextResolver::resolve_wsl(context, &session).await?; - let io = EnvironmentLockIo::ActiveWsl(session); io.write_atomic( - &resolved.lock, + &target.primary, serde_json::to_vec(&serde_json::json!({ "version": 3, "skills": {}, @@ -194,9 +209,21 @@ mod tests { ) .await?; - let initial = get_last_selected_agents(&environment, wsl).await?; - set_last_selected_agents(&environment, wsl, &["claude-code".to_string()]).await?; - let value = serde_json::from_slice(&io.read(&resolved.lock).await?)?; + let initial = read_last_selected_agents_with_io(io, &target).await?; + let io = EnvironmentLockIo::ActiveWsl { + session: Box::new(session.clone()), + workspace: workspace.clone(), + }; + write_last_selected_agents_with_io(io, target, &["claude-code".to_string()]).await?; + let io = EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }; + let locator = ResourceLocator { + environment, + native_path: format!("{root}/skills-lock.json"), + }; + let value = serde_json::from_slice(&io.read(&locator).await?)?; Ok((initial, value)) } diff --git a/src-tauri/src/application/agents.rs b/src-tauri/src/application/agents.rs index 05709cee..983c7fe8 100644 --- a/src-tauri/src/application/agents.rs +++ b/src-tauri/src/application/agents.rs @@ -14,19 +14,18 @@ use crate::core::agent_registry::{AgentRegistry, AgentRegistrySnapshot}; use crate::core::agent_settings::{AgentSettingsSnapshot, AgentStorageIssue, CustomAgentRecord}; use crate::core::custom_agent_repository::CustomAgentRepository; use crate::core::mutation::MutationKind; -use crate::core::paths::PATHS; use crate::environment::agent_environment::{ - AgentEnvironmentResolver, AgentRuntimeSnapshot, DetectionReason, DirectoryPresenceState, - EnvironmentContext, ResolvedAgent, ResolvedAgentScope, + native_environment_context, wsl_environment_context, AgentEnvironmentResolver, + AgentRuntimeSnapshot, DetectionReason, DirectoryPresenceState, EnvironmentContext, + ResolvedAgent, ResolvedAgentScope, }; -use crate::environment::context_resolver::{ContextResolver, ResolvedContext}; +use crate::environment::context_resolver::ContextResolver; use crate::environment::directory_inspection::{inspect_native, inspect_wsl, DirectoryInspection}; -use crate::environment::types::{EnvironmentRef, EnvironmentStatus, SkillLocationRef}; -use crate::environment::wsl::{WslRuntime, WslSession}; +use crate::environment::types::{EnvironmentRef, SkillLocationRef}; +use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::models::Scope; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use specta::Type; const CUSTOM_AGENT_STORAGE_UNAVAILABLE_CODE: &str = "customAgentStorageUnavailable"; @@ -466,22 +465,27 @@ pub async fn list_agents( let retry_context = context.clone(); let service = agent_registry; environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let workspace = workspace.clone(); let context = retry_context.clone(); async move { let resolved = - ContextResolver::resolve_wsl(context.clone(), &session).await?; + ContextResolver::resolve_wsl(context.clone(), &session, &workspace) + .await?; let project_path = resolved .project .as_ref() .map(|project| project.native_path.clone()); + let metadata_workspace = workspace.clone(); let environment = wsl_environment_context(&resolved, session.clone(), workspace); let snapshot = service.registry_snapshot(true); - AgentEnvironmentResolver::from_active_wsl_session(environment, session) - .resolve_registry(&snapshot, project_path.as_deref()) - .await + AgentEnvironmentResolver::from_active_wsl_workspace( + environment, + metadata_workspace, + ) + .resolve_registry(&snapshot, project_path.as_deref()) + .await } }) .await @@ -557,23 +561,25 @@ pub async fn validate_custom_agent_draft( let workspace = environment_registry.workspace(&distro_name)?; let retry_context = context.clone(); environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let workspace = workspace.clone(); let context = retry_context.clone(); let preview_snapshot = preview_snapshot.clone(); let draft_id = draft_id.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; let project_path = resolved .project .as_ref() .map(|project| project.native_path.clone()); + let metadata_workspace = workspace.clone(); resolve_custom_agent_preview_with_resolver( &preview_snapshot, &draft_id, - AgentEnvironmentResolver::from_active_wsl_session( + AgentEnvironmentResolver::from_active_wsl_workspace( wsl_environment_context(&resolved, session.clone(), workspace), - session, + metadata_workspace, ), project_path.as_deref(), ) @@ -690,7 +696,7 @@ pub async fn preview_custom_agent_delete( let distro_name = distro_name.clone(); let workspace = environment_registry.workspace(&distro_name)?; environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let workspace = workspace.clone(); let context = context.clone(); let definition = definition.clone(); @@ -698,19 +704,21 @@ pub async fn preview_custom_agent_delete( async move { let agent_id = definition.id.clone(); let display_name = definition.display_name.clone(); - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; let project_path = resolved .project .as_ref() .map(|project| project.native_path.clone()); - let runtime = AgentEnvironmentResolver::from_active_wsl_session( - wsl_environment_context(&resolved, session.clone(), workspace), - session.clone(), + let metadata_workspace = workspace.clone(); + let runtime = AgentEnvironmentResolver::from_active_wsl_workspace( + wsl_environment_context(&resolved, session.clone(), workspace.clone()), + metadata_workspace, ) .resolve_registry(&snapshot, project_path.as_deref()) .await?; let paths = delete_impact_resolved_paths(&runtime, &agent_id); - let inspections = inspect_wsl(&session, &paths).await?; + let inspections = inspect_wsl(&workspace, &paths).await?; Ok(build_delete_impact( &runtime, agent_id, @@ -1077,52 +1085,6 @@ fn delete_preview_snapshot( }) } -fn native_environment_context(resolved: &ResolvedContext) -> EnvironmentContext { - let environment_variables = std::env::vars().collect::>(); - let home = resolved.home.native_path.clone(); - let config_home = PATHS.config_home.to_string_lossy().to_string(); - let revision = environment_revision( - "native", - &(home.clone(), config_home.clone(), &environment_variables), - ); - EnvironmentContext { - environment: EnvironmentRef::Native, - home, - config_home, - environment_variables, - availability: EnvironmentStatus::Available, - revision, - wsl_workspace: None, - } -} - -fn wsl_environment_context( - resolved: &ResolvedContext, - session: WslSession, - workspace: crate::environment::wsl::WslWorkspace, -) -> EnvironmentContext { - let revision = environment_revision("wsl", &session); - EnvironmentContext { - environment: resolved.context.environment.clone(), - home: session.home.clone(), - config_home: session.config_home.clone(), - environment_variables: session.environment.clone(), - availability: EnvironmentStatus::Available, - revision, - wsl_workspace: Some(workspace), - } -} - -fn environment_revision(value_kind: &str, value: &impl Serialize) -> String { - let mut hasher = Sha256::new(); - hasher.update(value_kind.as_bytes()); - hasher.update( - serde_json::to_vec(value) - .expect("environment revision inputs must serialize deterministically"), - ); - format!("{:x}", hasher.finalize()) -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -1140,7 +1102,9 @@ mod tests { use crate::environment::agent_environment::{ AgentEnvironmentResolver, DetectionState, EnvironmentContext, }; + use crate::environment::context_resolver::ResolvedContext; use crate::environment::types::{EnvironmentStatus, ResourceLocator, SkillLocation}; + use crate::environment::wsl::WslSession; use serde_json::json; #[test] diff --git a/src-tauri/src/application/collection_records.rs b/src-tauri/src/application/collection_records.rs index 911558fe..424ece79 100644 --- a/src-tauri/src/application/collection_records.rs +++ b/src-tauri/src/application/collection_records.rs @@ -218,7 +218,7 @@ impl CollectionRecordReader for LibraryCatalogRecordReader<'_> { } } -fn library_update_metadata( +pub(crate) fn library_update_metadata( record: &LibrarySkillRecord, ) -> Result { let source: crate::application::skill_libraries::LibrarySkillSourceRecord = @@ -621,7 +621,8 @@ mod tests { "description": "Broken", "sourceRecord": { "sourceType": 42 }, "contentManifestHash": "manifest-broken" - }] + }], + "retiredSkills": [] }] })) .expect("catalog envelope remains readable"); @@ -650,6 +651,7 @@ mod tests { id: library_id.clone(), name: "Library".to_string(), skills, + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), diff --git a/src-tauri/src/application/copy.rs b/src-tauri/src/application/copy.rs index 31095554..fea0303e 100644 --- a/src-tauri/src/application/copy.rs +++ b/src-tauri/src/application/copy.rs @@ -243,17 +243,18 @@ where .entry_snapshot(&source.resolved.lock_key) .value() .cloned(); + let candidates = loaded.plan.direct_content_candidates(&loaded.catalog); + let (source_identity, source_hash) = self + .acquirer + .select_source(&request.source, &request.skill_name, &candidates) + .await?; let handle = self .acquirer - .acquire( - &request.source, - &request.skill_name, - loaded - .plan - .standard_fact() - .map_err(|error| error.into_app_error())?, - ) + .acquire(&request.source, &request.skill_name, &source_identity) .await?; + if handle.manifest_hash != source_hash { + return Err(AppError::StalePayload); + } let payload = self.payloads.pin_verified(&handle).await?; let source_snapshot = CopySourceSnapshot { source_context: request.source.clone(), @@ -261,11 +262,7 @@ where revisions: loaded.facts.revisions.clone(), lock_entry: source_lock_entry, project_identity: self.comparator.capture_source(&loaded.facts).await?, - standard_identity: loaded - .plan - .standard_fact() - .map_err(|error| error.into_app_error())? - .clone(), + standard_identity: source_identity, agent_intents: source_selection.intents().to_vec(), }; self.payloads @@ -356,6 +353,26 @@ where AgentSelectionResolution::Ready(selection) => selection, AgentSelectionResolution::Stale => return Err(AppError::StaleContext), }; + let mut source_facts = self + .targets + .resolve( + &request.source, + std::slice::from_ref(&snapshot.standard_identity.destination), + None, + ) + .await?; + if source_facts.len() != 1 { + return Err(AppError::StaleTarget); + } + let source_identity = source_facts.remove(0); + if !loaded + .plan + .direct_content_candidates(&loaded.catalog) + .iter() + .any(|candidate| candidate.key == source_identity.key) + { + return Err(AppError::StaleTarget); + } let current = CopySourceSnapshot { source_context: request.source.clone(), skill_name: request.skill_name.clone(), @@ -367,11 +384,7 @@ where .value() .cloned(), project_identity: self.comparator.capture_source(&loaded.facts).await?, - standard_identity: loaded - .plan - .standard_fact() - .map_err(|error| error.into_app_error())? - .clone(), + standard_identity: source_identity, agent_intents: selection.intents().to_vec(), }; @@ -853,7 +866,7 @@ fn copy_observed_selection( states: &[CopyObservedOptionState], library_candidates: &LibraryCandidateSnapshot, ) -> Result { - let initial_selected_option_ids = states + let baseline_selected_option_ids = states .iter() .filter(|state| state.initial_selected) .map(|state| state.option_id.clone()) @@ -875,7 +888,7 @@ fn copy_observed_selection( .collect::>(), ))?); let mut snapshot = catalog.snapshot().clone(); - snapshot.initial_selected_option_ids = initial_selected_option_ids; + snapshot.baseline_selected_option_ids = baseline_selected_option_ids; snapshot.revision = revision; Ok(snapshot) } @@ -1261,29 +1274,77 @@ pub(crate) fn compare_resolved_projects( left: &crate::environment::planning::ResolvedTargetFact, right: &crate::environment::planning::ResolvedTargetFact, ) -> Result { + if left.entry_kind != TargetEntryKind::Directory + || right.entry_kind != TargetEntryKind::Directory + { + return Err(AppError::StaleTarget); + } if left.key.backend != right.key.backend || !crate::environment::types::same_environment_identity( &left.destination.environment, &right.destination.environment, ) { - return Ok(PhysicalIdentityComparison::Unknown); + // 项目事实已按实际存储归属解析,不同归属使用独立的身份命名空间。 + return Ok(PhysicalIdentityComparison::Different); } if left.key == right.key { return Ok(PhysicalIdentityComparison::Same); } - let case_sensitive = !matches!(left.key.backend, ExecutionBackend::NativeWindows); - if crate::environment::runtime::physical_paths_overlap( - &left.destination, - &right.destination, - case_sensitive, - )? { + let left = copy_project_path_components(left)?; + let right = copy_project_path_components(right)?; + if left.starts_with(&right) || right.starts_with(&left) { Ok(PhysicalIdentityComparison::Same) } else { Ok(PhysicalIdentityComparison::Different) } } +fn copy_project_path_components(fact: &ResolvedTargetFact) -> Result, AppError> { + let path = &fact.destination.native_path; + let mut components = match &fact.destination.environment { + EnvironmentRef::Native => { + let path = std::path::Path::new(path); + if !path.is_absolute() { + return Err(AppError::StaleTarget); + } + path.components() + .map(|component| { + if matches!(component, std::path::Component::ParentDir) { + return Err(AppError::StaleTarget); + } + component + .as_os_str() + .to_str() + .map(str::to_string) + .ok_or(AppError::StaleTarget) + }) + .collect::, _>>()? + } + EnvironmentRef::Wsl { .. } => { + if !path.starts_with('/') { + return Err(AppError::StaleTarget); + } + path.split('/') + .filter(|part| !part.is_empty()) + .map(|part| { + if matches!(part, "." | "..") { + Err(AppError::StaleTarget) + } else { + Ok(part.to_string()) + } + }) + .collect::, _>>()? + } + }; + if matches!(fact.key.backend, ExecutionBackend::NativeWindows) { + for component in &mut components { + *component = component.to_lowercase(); + } + } + Ok(components) +} + fn normalize_copy_metadata(source: Option<&Value>) -> Option { let source = source?; if !source.is_object() { @@ -1597,7 +1658,16 @@ mod tests { fn project_fact(path: &str, inode: u64, child: &str) -> ResolvedTargetFact { ResolvedTargetFact { key: PhysicalTargetKey { + #[cfg(windows)] + backend: ExecutionBackend::NativeWindows, + #[cfg(windows)] + physical_parent: PhysicalParentIdentity::Windows { + volume_serial: 1, + file_id: inode.into(), + }, + #[cfg(not(windows))] backend: ExecutionBackend::NativeUnix, + #[cfg(not(windows))] physical_parent: PhysicalParentIdentity::Unix { device: 1, inode }, normalized_final_child_name: child.to_string(), }, @@ -1615,10 +1685,19 @@ mod tests { #[test] fn physical_project_comparison_blocks_equal_and_nested_but_allows_similar_paths() { - let source = project_fact("/work/app", 1, "app"); - let same = project_fact("/work/app", 1, "app"); - let nested = project_fact("/work/app/examples/demo", 1, "app/examples/demo"); - let similar = project_fact("/work/application", 1, "application"); + let root = tempdir().unwrap(); + let fact = |relative: &str, parent| { + let path = root.path().join(relative); + project_fact( + path.to_str().unwrap(), + parent, + path.file_name().unwrap().to_str().unwrap(), + ) + }; + let source = fact("app", 1); + let same = fact("app", 1); + let nested = fact("app/examples/demo", 2); + let similar = fact("application", 1); assert_eq!( compare_resolved_projects(&source, &same).unwrap(), @@ -1634,6 +1713,58 @@ mod tests { ); } + #[test] + fn physical_project_comparison_keeps_posix_names_and_storage_owners_distinct() { + let wsl_fact = |path: &str, inode, distro: &str| { + let mut fact = project_fact(path, inode, path.rsplit('/').next().unwrap()); + fact.key.backend = ExecutionBackend::WslPosix { + distro_name: distro.into(), + }; + fact.key.physical_parent = PhysicalParentIdentity::Wsl { + distro_name: distro.into(), + device: 1, + inode, + }; + fact.destination.environment = EnvironmentRef::Wsl { + distro_name: distro.into(), + }; + fact + }; + let source = wsl_fact("/work/app", 1, "ubuntu"); + for (candidate, expected) in [ + ( + wsl_fact("/work/app/examples", 2, "ubuntu"), + PhysicalIdentityComparison::Same, + ), + ( + wsl_fact("/work", 3, "ubuntu"), + PhysicalIdentityComparison::Same, + ), + ( + wsl_fact("/work/app\\examples", 1, "ubuntu"), + PhysicalIdentityComparison::Different, + ), + ( + wsl_fact("/work/App", 1, "ubuntu"), + PhysicalIdentityComparison::Different, + ), + ( + wsl_fact("/work/app", 1, "debian"), + PhysicalIdentityComparison::Different, + ), + ( + project_fact(std::env::temp_dir().join("app").to_str().unwrap(), 1, "app"), + PhysicalIdentityComparison::Different, + ), + ] { + assert_eq!( + compare_resolved_projects(&source, &candidate).unwrap(), + expected, + "{candidate:?}" + ); + } + } + #[test] fn target_lock_projects_source_metadata_without_inheriting_adapter_state() { let source = serde_json::json!({ @@ -2103,7 +2234,11 @@ mod tests { let target_root = temp.path().join("target"); let source_skill = source_root.join(".agents/skills/demo"); fs::create_dir_all(&source_skill).unwrap(); - fs::write(source_skill.join("SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); + fs::write( + source_skill.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nbody", + ) + .unwrap(); fs::create_dir_all(&target_root).unwrap(); let source = context(EnvironmentRef::Native, "source"); @@ -2148,7 +2283,7 @@ mod tests { target_project_ids, agent_selection: AgentSelectionSubmission { revision: agent_selection.revision, - selected_option_ids: agent_selection.initial_selected_option_ids, + selected_option_ids: agent_selection.baseline_selected_option_ids, requested_mode: InstallMode::Copy, }, }; @@ -2163,7 +2298,11 @@ mod tests { let target_root = temp.path().join("target"); let source_skill = source_root.join(".agents/skills/demo"); fs::create_dir_all(&source_skill).unwrap(); - fs::write(source_skill.join("SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); + fs::write( + source_skill.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nbody", + ) + .unwrap(); let target_skill_root = target_root.join(".agents/skills"); fs::create_dir_all(&target_skill_root).unwrap(); let external_target = target_skill_root.join("demo"); @@ -2207,7 +2346,7 @@ mod tests { target_project_ids: vec!["target".to_string()], agent_selection: AgentSelectionSubmission { revision: agent_selection.revision, - selected_option_ids: agent_selection.initial_selected_option_ids, + selected_option_ids: agent_selection.baseline_selected_option_ids, requested_mode: InstallMode::Copy, }, }; @@ -2273,7 +2412,7 @@ mod tests { fs::create_dir_all(&canonical_path).unwrap(); fs::write( canonical_path.join("SKILL.md"), - b"---\nname: demo\n---\nbody", + b"---\nname: demo\ndescription: Demo\n---\nbody", ) .unwrap(); fs::create_dir_all(&agent_root).unwrap(); @@ -2314,16 +2453,27 @@ mod tests { environment: EnvironmentRef::Native, native_path: second_library.to_string_lossy().into_owned(), }; + let library_facts = targets + .resolve_environment( + &EnvironmentRef::Native, + &[first_locator.clone(), second_locator.clone()], + None, + ) + .await + .unwrap(); + let first_physical_locator = library_facts[0].destination.clone(); let candidates = vec![ LibraryVersionCandidate::new( crate::application::skill_libraries::LibraryId::parse("first"), "demo", - first_locator.clone(), + first_physical_locator.clone(), + library_facts[0].key.clone(), ), LibraryVersionCandidate::new( crate::application::skill_libraries::LibraryId::parse("second"), "demo", - second_locator, + library_facts[1].destination.clone(), + library_facts[1].key.clone(), ), ]; let candidate_set = LibraryCandidateSet::new(candidates.clone(), candidates).unwrap(); @@ -2372,7 +2522,7 @@ mod tests { .find(|option| option.agent_ids.contains(&agent_id)) .unwrap(); - assert!(!selection.initial_selected_option_ids.contains(&option.id)); + assert!(!selection.baseline_selected_option_ids.contains(&option.id)); calls.lock().unwrap().clear(); let request = CopyRequest { @@ -2382,7 +2532,7 @@ mod tests { target_project_ids: vec!["target".to_string()], agent_selection: AgentSelectionSubmission { revision: selection.revision, - selected_option_ids: selection.initial_selected_option_ids, + selected_option_ids: selection.baseline_selected_option_ids, requested_mode: InstallMode::Copy, }, }; @@ -2416,7 +2566,7 @@ mod tests { plan.agent_actions.as_slice(), [actions] if matches!(actions.as_slice(), [PreparedEntryAction::Link { target }] - if target == &first_locator) + if target == &first_physical_locator) )); } @@ -2510,7 +2660,11 @@ mod tests { let broken_root = temp.path().join("broken"); let source_skill = source_root.join(".agents/skills/demo"); fs::create_dir_all(&source_skill).unwrap(); - fs::write(source_skill.join("SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); + fs::write( + source_skill.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nbody", + ) + .unwrap(); fs::create_dir_all(&healthy_root).unwrap(); fs::create_dir_all(&broken_root).unwrap(); @@ -2565,7 +2719,7 @@ mod tests { target_project_ids: vec!["healthy".to_string(), "broken".to_string()], agent_selection: AgentSelectionSubmission { revision: selection.revision, - selected_option_ids: selection.initial_selected_option_ids, + selected_option_ids: selection.baseline_selected_option_ids, requested_mode: InstallMode::Copy, }, }; @@ -2617,7 +2771,11 @@ mod tests { let second_root = temp.path().join("second"); let source_skill = source_root.join(".agents/skills/demo"); fs::create_dir_all(source_skill.join("scripts")).unwrap(); - fs::write(source_skill.join("SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); + fs::write( + source_skill.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nbody", + ) + .unwrap(); fs::write(source_skill.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); fs::create_dir_all(&first_root).unwrap(); fs::create_dir_all(&second_root).unwrap(); @@ -2666,7 +2824,7 @@ mod tests { target_project_ids: vec!["first".to_string(), "second".to_string()], agent_selection: AgentSelectionSubmission { revision: source_selection.revision, - selected_option_ids: source_selection.initial_selected_option_ids, + selected_option_ids: source_selection.baseline_selected_option_ids, requested_mode: InstallMode::Symlink, }, }; @@ -2707,7 +2865,7 @@ mod tests { fs::write( source_skill.join("SKILL.md"), - b"---\nname: demo\n---\nchanged", + b"---\nname: demo\ndescription: Demo\n---\nchanged", ) .unwrap(); let stale_payload_error = service @@ -2722,7 +2880,11 @@ mod tests { .await .unwrap_err(); assert!(matches!(stale_payload_error, AppError::StalePayload)); - fs::write(source_skill.join("SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); + fs::write( + source_skill.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nbody", + ) + .unwrap(); let original_source_lock = facts .lock() @@ -2849,7 +3011,7 @@ mod tests { fs::create_dir_all(source_root.join(".agents/skills/demo")).unwrap(); fs::write( source_root.join(".agents/skills/demo/SKILL.md"), - b"---\nname: demo\n---\nbody", + b"---\nname: demo\ndescription: Demo\n---\nbody", ) .unwrap(); fs::create_dir_all(&healthy_root).unwrap(); @@ -2957,7 +3119,11 @@ mod tests { let target_root = temp.path().join("target"); let source_skill = source_root.join(".agents/skills/demo"); fs::create_dir_all(&source_skill).unwrap(); - fs::write(source_skill.join("SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); + fs::write( + source_skill.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nbody", + ) + .unwrap(); fs::create_dir_all(&target_root).unwrap(); let source = context(EnvironmentRef::Native, "source"); @@ -2999,7 +3165,7 @@ mod tests { target_project_ids: vec!["target".to_string()], agent_selection: AgentSelectionSubmission { revision: selection.revision, - selected_option_ids: selection.initial_selected_option_ids, + selected_option_ids: selection.baseline_selected_option_ids, requested_mode: InstallMode::Copy, }, }; diff --git a/src-tauri/src/application/environment_projects.rs b/src-tauri/src/application/environment_projects.rs index 0b4c85dc..1a29ea4a 100644 --- a/src-tauri/src/application/environment_projects.rs +++ b/src-tauri/src/application/environment_projects.rs @@ -309,6 +309,11 @@ mod tests { .into_iter() .collect(), pending, + sync_state: if pending { + crate::application::library_application::LibraryApplicationSyncState::Pending + } else { + crate::application::library_application::LibraryApplicationSyncState::Synced + }, } } diff --git a/src-tauri/src/application/environment_settings.rs b/src-tauri/src/application/environment_settings.rs index 251fc32f..a760547b 100644 --- a/src-tauri/src/application/environment_settings.rs +++ b/src-tauri/src/application/environment_settings.rs @@ -1,6 +1,6 @@ use crate::application::payload_session::PayloadSessionManager; use crate::application::runtime_admission::{AdmissionDenied, RuntimeAdmissionCoordinator}; -use crate::core::update_config; +use crate::core::app_config::ConfigStore; use crate::environment::project_service::{self, EnvironmentDiscoverySnapshot}; use crate::environment::wsl::WslRuntime; use crate::error::{AppError, WslIntegrationBusyReason}; @@ -83,7 +83,6 @@ where }); } persist(false)?; - transition.flush_deferred_source_cleanups().await; transition.commit_disabled(); } else { apply_wsl_integration_setting_with(enabled, environments, quiescence_timeout, persist) @@ -113,17 +112,16 @@ fn map_admission_error(error: AdmissionDenied) -> AppError { } } -fn merge_config_preserving_wsl_setting( - mut config: SkillDeckConfig, - persisted: &mut SkillDeckConfig, -) { - config.wsl_integration_enabled = persisted.wsl_integration_enabled; - config.network_proxy = persisted.network_proxy.clone(); - *persisted = config; +fn merge_config_preserving_wsl_setting(config: SkillDeckConfig, persisted: &mut SkillDeckConfig) { + // 通用配置表单只编辑 Git 超时,其余字段由各自的设置入口维护。 + persisted.git_clone_timeout_secs = config.git_clone_timeout_secs; } -pub fn save_config_preserving_wsl_setting(config: SkillDeckConfig) -> Result<(), AppError> { - update_config(move |persisted| merge_config_preserving_wsl_setting(config, persisted))?; +pub(crate) fn save_config_preserving_wsl_setting( + config: SkillDeckConfig, + store: &ConfigStore, +) -> Result<(), AppError> { + store.update(move |persisted| merge_config_preserving_wsl_setting(config, persisted))?; Ok(()) } @@ -131,18 +129,21 @@ pub struct WslIntegrationSettings<'a> { runtime: &'a WslRuntime, admission: &'a RuntimeAdmissionCoordinator, payloads: &'a PayloadSessionManager, + config: &'a ConfigStore, } impl<'a> WslIntegrationSettings<'a> { - pub fn new( + pub(crate) fn new( runtime: &'a WslRuntime, admission: &'a RuntimeAdmissionCoordinator, payloads: &'a PayloadSessionManager, + config: &'a ConfigStore, ) -> Self { Self { runtime, admission, payloads, + config, } } @@ -162,7 +163,8 @@ impl<'a> WslIntegrationSettings<'a> { self.admission, WSL_QUIESCENCE_TIMEOUT, |enabled| { - update_config(|config| config.wsl_integration_enabled = enabled)?; + self.config + .update(|config| config.wsl_integration_enabled = enabled)?; Ok(()) }, || { @@ -285,6 +287,28 @@ mod tests { assert_eq!(persisted.network_proxy, expected); } + #[test] + fn saving_git_timeout_preserves_other_fields_from_an_older_form_snapshot() { + let incoming = SkillDeckConfig { + git_clone_timeout_secs: 60, + ..Default::default() + }; + let mut persisted = SkillDeckConfig { + projects: vec!["/saved-project".into()], + hidden_wsl_distros: vec!["Ubuntu".into()], + ..Default::default() + }; + let mut expected = persisted.clone(); + expected.git_clone_timeout_secs = 60; + + merge_config_preserving_wsl_setting(incoming, &mut persisted); + + assert_eq!( + serde_json::to_value(persisted).unwrap(), + serde_json::to_value(expected).unwrap() + ); + } + #[test] fn admission_errors_have_stable_public_error_mappings() { let cases = [ diff --git a/src-tauri/src/application/install.rs b/src-tauri/src/application/install.rs index bb053051..12e67ee7 100644 --- a/src-tauri/src/application/install.rs +++ b/src-tauri/src/application/install.rs @@ -83,32 +83,15 @@ pub struct InstallResponse { pub type InstallFuture<'a, T> = Pin + Send + 'a>>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InstallOperation { - Install, - Repair, -} - -impl InstallOperation { - pub fn mutation_kind(self) -> crate::core::mutation::MutationKind { - match self { - Self::Install => crate::core::mutation::MutationKind::Install, - Self::Repair => crate::core::mutation::MutationKind::Repair, - } - } -} - pub trait InstallPlanner: Send + Sync { fn preview<'a>( &'a self, - operation: InstallOperation, request: &'a InstallRequest, payloads: Vec, ) -> InstallFuture<'a, Result>; fn rebuild<'a>( &'a self, - operation: InstallOperation, request: &'a InstallRequest, payloads: Vec, ) -> InstallFuture<'a, Result<(PreviewToken, MutationPlan), AppError>>; @@ -148,17 +131,15 @@ where pub async fn preview( &self, - operation: InstallOperation, request: &InstallRequest, ) -> Result { validate_install_request(request)?; let payloads = self.pin_request_payloads(request, None).await?; - self.planner.preview(operation, request, payloads).await + self.planner.preview(request, payloads).await } pub async fn execute( &self, - operation: InstallOperation, request: &InstallRequest, expected_token: PreviewToken, cancellation: CancellationSignal, @@ -167,7 +148,7 @@ where let payloads = self .pin_request_payloads(request, Some(cancellation.clone())) .await?; - let (actual_token, plan) = self.planner.rebuild(operation, request, payloads).await?; + let (actual_token, plan) = self.planner.rebuild(request, payloads).await?; validate_exact_preview(&expected_token, &actual_token)?; let mut response = InstallResponse { units: self.executor.execute(plan, cancellation).await, @@ -379,7 +360,6 @@ mod tests { impl InstallPlanner for Planner { fn preview<'a>( &'a self, - _operation: InstallOperation, _request: &'a InstallRequest, _payloads: Vec, ) -> InstallFuture<'a, Result> { @@ -392,7 +372,6 @@ mod tests { fn rebuild<'a>( &'a self, - operation: InstallOperation, _request: &'a InstallRequest, payloads: Vec, ) -> InstallFuture<'a, Result<(PreviewToken, MutationPlan), AppError>> { @@ -401,7 +380,7 @@ mod tests { Ok(( self.preview.token.clone(), MutationPlan { - kind: operation.mutation_kind(), + kind: crate::core::mutation::MutationKind::Install, operation_id: "operation-1".to_string(), payloads: payloads .into_iter() @@ -533,21 +512,13 @@ mod tests { }) })); - let InstallPreviewOutcome::Ready { preview } = service - .preview(InstallOperation::Install, &request) - .await - .unwrap() + let InstallPreviewOutcome::Ready { preview } = service.preview(&request).await.unwrap() else { panic!("expected ready preview"); }; assert_eq!(preview.token, token); let response = service - .execute( - InstallOperation::Install, - &request, - token, - CancellationSignal::default(), - ) + .execute(&request, token, CancellationSignal::default()) .await .unwrap(); diff --git a/src-tauri/src/application/install_agent_selection.rs b/src-tauri/src/application/install_agent_selection.rs index 6fe86c7c..c1d41381 100644 --- a/src-tauri/src/application/install_agent_selection.rs +++ b/src-tauri/src/application/install_agent_selection.rs @@ -375,7 +375,7 @@ mod tests { disabled_reason: None, }], groups: Vec::new(), - initial_selected_option_ids: Vec::new(), + baseline_selected_option_ids: Vec::new(), unavailable_explicit_agents: Vec::new(), user_mode_option_ids: vec![option_id.clone()], revision: AgentSelectionRevision("latest".to_string()), @@ -393,7 +393,7 @@ mod tests { ); assert_eq!( - snapshot.selection.initial_selected_option_ids, + snapshot.selection.baseline_selected_option_ids, vec![option_id] ); assert!(snapshot.selection.unavailable_explicit_agents.is_empty()); diff --git a/src-tauri/src/application/install_planner.rs b/src-tauri/src/application/install_planner.rs index b27296d4..8931cc89 100644 --- a/src-tauri/src/application/install_planner.rs +++ b/src-tauri/src/application/install_planner.rs @@ -7,8 +7,8 @@ use crate::application::agent_selection::{ InstallAgentSelectionSnapshot, }; use crate::application::install::{ - InstallFuture, InstallOperation, InstallPlanner, InstallPreview, InstallPreviewOutcome, - InstallRequest, InstallSkillPreview, + InstallFuture, InstallPlanner, InstallPreview, InstallPreviewOutcome, InstallRequest, + InstallSkillPreview, }; use crate::application::installed_skill_resolver::SkillDirectoryName; #[cfg(test)] @@ -75,12 +75,11 @@ where { fn preview<'a>( &'a self, - operation: InstallOperation, request: &'a InstallRequest, payloads: Vec, ) -> InstallFuture<'a, Result> { Box::pin(async move { - match self.build(operation, request, payloads, false).await? { + match self.build(request, payloads, false).await? { BuiltInstallOutcome::Ready(built) => Ok(InstallPreviewOutcome::Ready { preview: built.preview, }), @@ -93,13 +92,11 @@ where fn rebuild<'a>( &'a self, - operation: InstallOperation, request: &'a InstallRequest, payloads: Vec, ) -> InstallFuture<'a, Result<(PreviewToken, MutationPlan), AppError>> { Box::pin(async move { - let BuiltInstallOutcome::Ready(built) = - self.build(operation, request, payloads, true).await? + let BuiltInstallOutcome::Ready(built) = self.build(request, payloads, true).await? else { return Err(AppError::StaleTarget); }; @@ -152,7 +149,6 @@ where { async fn build( &self, - operation: InstallOperation, request: &InstallRequest, payloads: Vec, include_plan: bool, @@ -174,9 +170,6 @@ where return Err(AppError::DirectDownloadRedirectConfirmationRequired { host }); } } - if operation != InstallOperation::Install && uses_direct_download { - return Err(AppError::DirectDownloadUnsupportedOperation); - } let catalog = build_agent_selection_catalog( &request.context, &facts.agent_runtime, @@ -347,7 +340,7 @@ where candidate_digests, ))?; let token = issue_preview_token(PreviewTokenDraft { - kind: operation.mutation_kind(), + kind: crate::core::mutation::MutationKind::Install, request, revisions: facts.revisions.clone(), observed_state_digest, @@ -413,7 +406,7 @@ where }; let plan = include_plan.then(|| { assemble_plan(MutationPlanDraft { - kind: operation.mutation_kind(), + kind: crate::core::mutation::MutationKind::Install, payloads: payloads .into_iter() .map(ValidatedSkillPayload::into_lease) @@ -1116,54 +1109,19 @@ mod tests { let fixture = MultiSkillInstallFixture::new().await; let preview = fixture .planner - .preview( - InstallOperation::Install, - &fixture.request, - fixture.leases().await, - ) + .preview(&fixture.request, fixture.leases().await) .await .unwrap(); let (token, plan) = fixture .planner - .rebuild( - InstallOperation::Install, - &fixture.request, - fixture.leases().await, - ) + .rebuild(&fixture.request, fixture.leases().await) .await .unwrap(); - let InstallPreviewOutcome::Ready { - preview: repair_preview, - } = fixture - .planner - .preview( - InstallOperation::Repair, - &fixture.request, - fixture.leases().await, - ) - .await - .unwrap() - else { - panic!("expected ready repair preview"); - }; - let (repair_token, repair_plan) = fixture - .planner - .rebuild( - InstallOperation::Repair, - &fixture.request, - fixture.leases().await, - ) - .await - .unwrap(); - let InstallPreviewOutcome::Ready { preview } = preview else { panic!("expected ready preview"); }; assert_eq!(preview.token, token); assert_eq!(plan.kind, MutationKind::Install); - assert_eq!(repair_preview.token, repair_token); - assert_eq!(repair_plan.kind, MutationKind::Repair); - assert_ne!(preview.token, repair_preview.token); assert_eq!(preview.skills.len(), 2); assert_eq!(plan.payloads.len(), 2); assert_eq!(plan.units.len(), 2); @@ -1201,11 +1159,7 @@ mod tests { fixture .planner - .preview( - InstallOperation::Install, - &fixture.request, - fixture.leases().await, - ) + .preview(&fixture.request, fixture.leases().await) .await .unwrap(); @@ -1321,7 +1275,6 @@ mod tests { assert!(matches!( planner .preview( - InstallOperation::Install, &request, vec![manager.pin_verified(&handle).await.unwrap()], ) @@ -1334,11 +1287,7 @@ mod tests { )); let result = planner - .rebuild( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .rebuild(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await; let error = match result { Err(error) => error, @@ -1480,11 +1429,7 @@ mod tests { }; let unacknowledged = planner - .rebuild( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .rebuild(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await; assert!(matches!( unacknowledged, @@ -1497,34 +1442,14 @@ mod tests { }; let (_, plan) = planner - .rebuild( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .rebuild(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await .unwrap(); assert!(plan.units[0].lock_mutation.is_none()); - let repair = planner - .preview( - InstallOperation::Repair, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) - .await; - assert!(matches!( - repair, - Err(AppError::DirectDownloadUnsupportedOperation) - )); - fs::create_dir_all(canonical_root.join("demo")).unwrap(); let conflict_preview = planner - .preview( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .preview(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await .unwrap(); let InstallPreviewOutcome::Ready { preview } = conflict_preview else { @@ -1537,11 +1462,7 @@ mod tests { ); let conflict = planner - .rebuild( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .rebuild(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await; assert!(matches!( conflict, @@ -1663,19 +1584,11 @@ mod tests { }; let preview = planner - .preview( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .preview(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await .unwrap(); let (token, plan) = planner - .rebuild( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .rebuild(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await .unwrap(); @@ -1885,6 +1798,12 @@ mod tests { crate::application::skill_libraries::LibraryId::parse("lib-1"), "demo", locator(&library_target), + target_resolver + .resolve_environment(&EnvironmentRef::Native, &[locator(&library_target)], None) + .await + .unwrap() + .remove(0) + .key, ); let library_candidates = crate::application::library_candidates::LibraryCandidateSnapshot::new( @@ -1915,11 +1834,7 @@ mod tests { }; let (_, plan) = planner - .rebuild( - InstallOperation::Install, - &request, - vec![manager.pin_verified(&handle).await.unwrap()], - ) + .rebuild(&request, vec![manager.pin_verified(&handle).await.unwrap()]) .await .unwrap(); diff --git a/src-tauri/src/application/installed_skill_payload.rs b/src-tauri/src/application/installed_skill_payload.rs index daccec8a..83f2bebb 100644 --- a/src-tauri/src/application/installed_skill_payload.rs +++ b/src-tauri/src/application/installed_skill_payload.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use std::path::Path; use std::sync::Arc; @@ -5,12 +6,11 @@ use crate::application::installed_skill_resolver::InstalledSkillResolver; use crate::application::mutation::plan::stable_digest; use crate::application::payload_session::{ AcquiredPayloadHandle, DiscoverySourceDescriptor, DiscoverySourceLocation, - PayloadPlanningMetadata, PayloadSessionManager, PayloadSessionStorage, PayloadStorageKey, - RetainedDiscoverySource, + PayloadPlanningMetadata, PayloadSessionManager, PayloadStorageKey, RetainedDiscoverySource, + StoredPayload, }; use crate::environment::planning::{ResolvedTargetFact, TargetEntryKind}; use crate::environment::types::{same_environment_identity, EnvironmentRef, SkillLocationRef}; -use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; use crate::environment::wsl::WslRuntime; use crate::error::AppError; @@ -27,6 +27,32 @@ impl InstalledSkillPayloadAcquirer { } } + pub(crate) async fn select_source( + &self, + context: &SkillLocationRef, + skill_name: &str, + candidates: &[ResolvedTargetFact], + ) -> Result<(ResolvedTargetFact, String), AppError> { + let source = candidates.first().ok_or(AppError::StaleTarget)?; + let hash = self + .current_manifest_hash(context, skill_name, source) + .await?; + for candidate in &candidates[1..] { + if self + .current_manifest_hash(context, skill_name, candidate) + .await? + != hash + { + return Err( + crate::application::scope_skill_placements::ambiguous_installed_source( + skill_name, + ), + ); + } + } + Ok((source.clone(), hash)) + } + pub async fn acquire( &self, context: &SkillLocationRef, @@ -40,6 +66,11 @@ impl InstalledSkillPayloadAcquirer { let payload = crate::core::skill_payload::build_skill_payload(Path::new( &standard.destination.native_path, ))?; + validate_format( + skill_name, + crate::application::skill_changes::payload_frontmatter(&payload), + &standard.destination.native_path, + )?; let computed_hash = crate::core::skill_payload::compute_cli_project_hash_from_payload(&payload)?; let discovery = self @@ -59,7 +90,7 @@ impl InstalledSkillPayloadAcquirer { let workspace = self.environments.workspace(distro_name)?; let standard_path = standard.destination.native_path.clone(); let skill_name = skill_name.to_string(); - let storage = Arc::new(WslPayloadSessionStorage::new(workspace)); + let storage = workspace.payload_storage(); let retained = RetainedDiscoverySource::new( DiscoverySourceLocation::WslNative { distro_name: distro_name.clone(), @@ -85,17 +116,46 @@ impl InstalledSkillPayloadAcquirer { retained, ) .await?; - let key = PayloadStorageKey::new(&discovery.session_id, skill_name.clone()); - let acquired = storage - .acquire_from_path(&key, &standard_path, None) - .await?; self.payloads - .register_existing_payload_with_metadata( + .prepare_payload( &discovery, skill_name.clone(), - acquired.manifest, - acquired.total_bytes, - installed_metadata(&skill_name, acquired.computed_hash)?, + move |storage, key| async move { + let acquired = storage + .acquire_from_path(&key, &standard_path, None) + .await?; + let entry = acquired + .manifest + .entries + .iter() + .find(|entry| { + entry.kind == crate::core::skill_payload::PayloadEntryKind::File + && entry.relative_path.eq_ignore_ascii_case("SKILL.md") + }) + .ok_or_else(|| format_unavailable(&standard_path))?; + let blob_id = entry.blob_id.as_deref().ok_or(AppError::StalePayload)?; + let bytes = storage + .read_blob(&key, blob_id) + .await? + .ok_or(AppError::StalePayload)?; + if format!("{:x}", Sha256::digest(&bytes)) != blob_id { + return Err(AppError::StalePayload); + } + let frontmatter = std::str::from_utf8(&bytes) + .map_err(|error| AppError::InvalidSkillMd { + message: error.to_string(), + }) + .and_then(crate::core::skill::parse_skill_md_content); + validate_format(&skill_name, frontmatter, &standard_path)?; + let planning_metadata = + installed_metadata(&skill_name, acquired.computed_hash)?; + planning_metadata.validate()?; + Ok(StoredPayload { + manifest: acquired.manifest, + total_bytes: acquired.total_bytes, + planning_metadata, + }) + }, ) .await } @@ -117,7 +177,7 @@ impl InstalledSkillPayloadAcquirer { .payload_root_hash), EnvironmentRef::Wsl { distro_name } => { let workspace = self.environments.workspace(distro_name)?; - let storage = Arc::new(WslPayloadSessionStorage::new(workspace)); + let storage = workspace.payload_storage(); let session_id = format!("copy-source-check-{}", uuid::Uuid::new_v4().simple()); let key = PayloadStorageKey::new(&session_id, skill_name); let acquired = storage @@ -133,6 +193,25 @@ impl InstalledSkillPayloadAcquirer { } } +fn format_unavailable(path: &str) -> AppError { + AppError::CapabilityUnavailable { + capability: "installedSkillFormat".into(), + path: Some(path.into()), + } +} + +fn validate_format( + name: &str, + frontmatter: Result, + path: &str, +) -> Result<(), AppError> { + if frontmatter.is_ok_and(|metadata| metadata.name == name) { + Ok(()) + } else { + Err(format_unavailable(path)) + } +} + fn validate_standard( context: &SkillLocationRef, standard: &ResolvedTargetFact, @@ -168,6 +247,48 @@ fn installed_metadata( mod tests { use super::*; + #[tokio::test] + async fn installed_payload_rejects_eve_content_without_ordinary_metadata() { + use crate::environment::planning::{RuntimeTargetFactResolver, TargetFactResolver}; + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("SKILL.md"), + b"---\ndescription: Eve content\n---\nbody", + ) + .unwrap(); + let environments = Arc::new(WslRuntime::default()); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: crate::environment::types::SkillLocation::Global, + }; + let fact = RuntimeTargetFactResolver::new(environments.clone()) + .resolve( + &context, + &[crate::environment::types::ResourceLocator { + environment: EnvironmentRef::Native, + native_path: root.path().to_string_lossy().into_owned(), + }], + None, + ) + .await + .unwrap() + .remove(0); + let manager = Arc::new(PayloadSessionManager::in_memory( + crate::application::payload_session::PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 1_000_000, + }, + || 1_000, + )); + let acquirer = InstalledSkillPayloadAcquirer::new(manager, environments); + assert!(acquirer.acquire(&context, "demo", &fact).await.is_err()); + assert_eq!( + std::fs::read(root.path().join("SKILL.md")).unwrap(), + b"---\ndescription: Eve content\n---\nbody" + ); + } + #[test] fn installed_payload_metadata_uses_the_resolved_install_directory() { let metadata = installed_metadata("ce:review", "computed".to_string()).unwrap(); diff --git a/src-tauri/src/application/library_agent_placements.rs b/src-tauri/src/application/library_agent_placements.rs index e7d1628c..9193814a 100644 --- a/src-tauri/src/application/library_agent_placements.rs +++ b/src-tauri/src/application/library_agent_placements.rs @@ -28,6 +28,7 @@ impl LibraryAgentPlacement { pub(crate) struct LibraryAgentPlacementMap { selection: AgentSelectionSnapshot, placements: BTreeMap, + saved_placements: BTreeMap>, } impl LibraryAgentPlacementMap { @@ -41,15 +42,23 @@ impl LibraryAgentPlacementMap { .collect::>(); let mut projected_options = Vec::new(); let mut placements = BTreeMap::new(); + let mut saved_placements = BTreeMap::new(); for option in catalog.options() { if option.public.kind != AgentInstallOptionKind::StandardDirectory - || option.public.mode_constraint != AgentSelectionModeConstraint::UserSelectable - || !option.public.selectable || option.placement.storage_access != StorageAccess::Native || option.placement.content != DirectoryContentKind::Original { continue; } + if !option.public.agent_ids.is_empty() { + saved_placements + .insert(option.placement.id.clone(), option.public.agent_ids.clone()); + } + if option.public.mode_constraint != AgentSelectionModeConstraint::UserSelectable + || !option.public.selectable + { + continue; + } let selection_agent_ids = option .public .agent_ids @@ -80,12 +89,13 @@ impl LibraryAgentPlacementMap { .retain(|agent| available_agents.contains(&agent.id)); selection.install_options = projected_options; selection.groups.clear(); - selection.initial_selected_option_ids.clear(); + selection.baseline_selected_option_ids.clear(); selection.unavailable_explicit_agents.clear(); selection.user_mode_option_ids.clear(); Self { selection, placements, + saved_placements, } } @@ -132,6 +142,54 @@ impl LibraryAgentPlacementMap { } Ok(result) } + + pub(crate) fn placements_for_saved( + &self, + selected_agent_ids: &[AgentId], + ) -> BTreeSet { + let selected = selected_agent_ids.iter().collect::>(); + self.saved_placements + .iter() + .filter(|(_, agent_ids)| agent_ids.iter().any(|agent_id| selected.contains(agent_id))) + .map(|(id, _)| id.clone()) + .collect() + } + + pub(crate) fn validate_selection_with_persisted( + &self, + requested_agent_ids: &[AgentId], + persisted_agent_ids: &BTreeSet, + ) -> Result<(), LibraryAgentPlacementError> { + let requested = requested_agent_ids.iter().collect::>(); + let available = self + .placements + .values() + .flat_map(|placement| placement.selection_agent_ids.iter()) + .collect::>(); + if let Some(agent_id) = requested.iter().find(|agent_id| { + !available.contains(**agent_id) && !persisted_agent_ids.contains(**agent_id) + }) { + return Err(LibraryAgentPlacementError::UnknownAgent( + (*agent_id).clone(), + )); + } + for (id, placement) in &self.placements { + let selected = placement + .selection_agent_ids + .iter() + .filter(|agent_id| requested.contains(agent_id)) + .collect::>(); + if !selected.is_empty() + && selected.len() != placement.selection_agent_ids.len() + && selected + .iter() + .any(|agent_id| !persisted_agent_ids.contains(*agent_id)) + { + return Err(LibraryAgentPlacementError::PartialSelection(id.clone())); + } + } + Ok(()) + } } #[cfg(test)] @@ -233,7 +291,7 @@ mod tests { agents: vec![private_one, private_two, both], install_options: vec![public.clone()], groups: Vec::new(), - initial_selected_option_ids: Vec::new(), + baseline_selected_option_ids: Vec::new(), unavailable_explicit_agents: Vec::new(), user_mode_option_ids: Vec::new(), revision: AgentSelectionRevision("revision".to_string()), @@ -282,5 +340,22 @@ mod tests { ]) .unwrap() .contains(&placement_id)); + assert!(map + .placements_for_saved(&[AgentId::parse("both").unwrap()]) + .contains(&placement_id)); + let persisted = BTreeSet::from([AgentId::parse("private-one").unwrap()]); + assert!(map + .validate_selection_with_persisted( + &[AgentId::parse("private-one").unwrap()], + &persisted, + ) + .is_ok()); + assert!(matches!( + map.validate_selection_with_persisted( + &[AgentId::parse("private-two").unwrap()], + &BTreeSet::new(), + ), + Err(LibraryAgentPlacementError::PartialSelection(id)) if id == placement_id + )); } } diff --git a/src-tauri/src/application/library_application.rs b/src-tauri/src/application/library_application.rs index 9eda9cda..7c04311f 100644 --- a/src-tauri/src/application/library_application.rs +++ b/src-tauri/src/application/library_application.rs @@ -8,7 +8,8 @@ use specta::Type; use crate::application::agent_selection::{ build_agent_selection_catalog, AgentSelectionAgentKind, AgentSelectionCatalog, - AgentSelectionSnapshot, DirectoryPlacementId, + AgentSelectionSnapshot, DirectoryPlacementId, UnavailableAgentSelection, + UnavailableAgentSelectionReason, }; use crate::application::installed_skill_resolver::SkillDirectoryName; use crate::application::library_agent_placements::LibraryAgentPlacementMap; @@ -17,7 +18,7 @@ use crate::application::library_candidates::{ ResolvedLibraryCandidateIndex, }; use crate::application::mutation::executor::MutationPlanExecutor; -use crate::application::mutation::plan::PreviewToken; +use crate::application::mutation::plan::{PreparedEntryAction, PreviewToken}; use crate::application::mutation::planning::{ assemble_plan, issue_preview_token, validate_exact_preview, MutationPlanDraft, MutationUnitDraft, PreviewTokenDraft, @@ -43,6 +44,7 @@ use crate::environment::planning::TargetFactResolver; use crate::environment::runtime::{EntryFingerprint, PhysicalTargetKey}; use crate::environment::types::{ResourceLocator, SkillLocationRef, StorageAccess}; use crate::error::AppError; +use crate::storage::atomic_document::DocumentSnapshot; pub const LIBRARY_APPLICATION_SCHEMA_VERSION: u32 = 1; pub type LibraryApplicationFuture<'a, T> = Pin + Send + 'a>>; @@ -55,35 +57,233 @@ pub struct LibraryApplicationState { pub selected_agent_ids: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryMemberIdentity { + pub(crate) library_id: LibraryId, + pub(crate) member_name: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReconciliationCheckpoint { + pub members: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReconciliationReason { + ApplicationChanged, + MembershipChanged, + ReapplyRequested, + VerificationRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReconciliationAttention { + Pending, + Unverified, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct PendingLibraryApplication { - pub operation_id: String, +pub struct PendingReconciliation { + pub reconciliation_id: String, + pub attention: ReconciliationAttention, + pub reasons: Vec, pub before_application: LibraryApplicationState, pub target_application: LibraryApplicationState, - pub preview_fingerprint: String, + pub recognized_members: Vec, + pub affected_members: Vec, + pub target_members: Vec, +} + +impl PendingReconciliation { + fn merged_with( + mut self, + reasons: Vec, + target_application: LibraryApplicationState, + recognized_members: Vec, + target_members: Vec, + ) -> Self { + self.affected_members = self + .affected_members + .into_iter() + .chain(changed_member_identities( + &self.target_members, + &target_members, + )) + .collect::>() + .into_iter() + .collect(); + self.reasons = self + .reasons + .into_iter() + .chain(reasons) + .collect::>() + .into_iter() + .collect(); + self.recognized_members = self + .recognized_members + .into_iter() + .chain(recognized_members) + .chain(target_members.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + self.target_application = target_application; + self.target_members = target_members; + self + } +} + +fn reconciliation_members( + checkpoint: &ReconciliationCheckpoint, + pending: Option<&PendingReconciliation>, + desired: &[LibraryMemberIdentity], +) -> Vec { + checkpoint + .members + .iter() + .chain( + pending + .into_iter() + .flat_map(|pending| pending.recognized_members.iter()), + ) + .chain(desired) + .cloned() + .collect::>() + .into_iter() + .collect() } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LibraryApplicationRecord { pub schema_version: u32, - pub target: SkillLocationRef, pub current: LibraryApplicationState, - pub pending_operation: Option, + pub checkpoint: ReconciliationCheckpoint, + pub pending: Option, } impl LibraryApplicationRecord { - pub fn empty(target: SkillLocationRef) -> Self { + pub fn empty() -> Self { Self { schema_version: LIBRARY_APPLICATION_SCHEMA_VERSION, - target, current: LibraryApplicationState::default(), - pending_operation: None, + checkpoint: ReconciliationCheckpoint::default(), + pending: None, + } + } +} + +pub(crate) fn validate_application_record( + record: &LibraryApplicationRecord, +) -> Result<(), AppError> { + let invalid = || AppError::ConfigurationCorrupted { + message: "invalid Skill Library application record".to_string(), + }; + if record.schema_version != LIBRARY_APPLICATION_SCHEMA_VERSION + || has_duplicates(&record.current.ordered_library_ids) + || has_duplicates(&record.current.selected_agent_ids) + || !is_canonical_members(&record.checkpoint.members) + { + return Err(invalid()); + } + let Some(pending) = &record.pending else { + return Ok(()); + }; + if pending.reconciliation_id.is_empty() + || pending.reconciliation_id.contains(['/', '\\', '\0']) + || pending.reasons.is_empty() + || !is_strictly_sorted(&pending.reasons) + || pending.before_application != record.current + || has_duplicates(&pending.target_application.ordered_library_ids) + || has_duplicates(&pending.target_application.selected_agent_ids) + || !is_canonical_members(&pending.recognized_members) + || !is_canonical_members(&pending.affected_members) + || !is_canonical_members(&pending.target_members) + { + return Err(invalid()); + } + let recognized = pending.recognized_members.iter().collect::>(); + if record + .checkpoint + .members + .iter() + .chain(&pending.target_members) + .chain(&pending.affected_members) + .any(|member| !recognized.contains(member)) + { + return Err(invalid()); + } + Ok(()) +} + +fn has_duplicates(values: &[T]) -> bool { + values.iter().collect::>().len() != values.len() +} + +fn is_strictly_sorted(values: &[T]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn is_canonical_members(values: &[LibraryMemberIdentity]) -> bool { + values + .iter() + .all(|member| !member.library_id.as_str().is_empty() && !member.member_name.is_empty()) + && is_strictly_sorted(values) +} + +#[derive(Debug, Clone)] +pub struct VersionedApplicationRecord { + pub context: SkillLocationRef, + pub record: LibraryApplicationRecord, + pub(crate) target: ResourceLocator, + pub(crate) snapshot: DocumentSnapshot, +} + +#[derive(Debug)] +pub struct ApplicationInventoryProblem { + pub storage_key: String, + pub error: AppError, +} + +#[derive(Debug)] +pub struct ApplicationInventory { + pub records: Vec, + pub problems: Vec, + pub complete: bool, +} + +impl VersionedApplicationRecord { + #[cfg(test)] + pub(crate) fn in_memory(context: SkillLocationRef, record: LibraryApplicationRecord) -> Self { + let bytes = serde_json::to_vec_pretty(&record).expect("in-memory application record"); + Self { + target: ResourceLocator { + environment: context.environment.clone(), + native_path: format!("memory://{:?}", context.scope), + }, + snapshot: DocumentSnapshot { + bytes: Some(bytes), + generation: None, + }, + context, + record, } } } +impl std::ops::Deref for VersionedApplicationRecord { + type Target = LibraryApplicationRecord; + + fn deref(&self) -> &Self::Target { + &self.record + } +} + /// 判断某个库在这一份应用记录中的使用状态。 /// /// `None` 表示该位置既没有确认使用该库,也没有未完成操作引用它。已确认生效优先于 @@ -95,7 +295,7 @@ pub fn library_usage_state( if record.current.ordered_library_ids.contains(library_id) { return Some(LibraryUsageState::Confirmed); } - let pending = record.pending_operation.as_ref().is_some_and(|pending| { + let pending = record.pending.as_ref().is_some_and(|pending| { pending .before_application .ordered_library_ids @@ -124,7 +324,7 @@ impl LibraryUsageAccumulator { confirmed.insert(id); } let mut pending: BTreeSet<&LibraryId> = BTreeSet::new(); - if let Some(operation) = record.pending_operation.as_ref() { + if let Some(operation) = record.pending.as_ref() { let referenced = operation .before_application .ordered_library_ids @@ -165,6 +365,65 @@ pub struct LibraryApplicationSummary { pub ordered_libraries: Vec, pub selected_agent_ids: Vec, pub pending: bool, + pub sync_state: LibraryApplicationSyncState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum LibraryApplicationSyncState { + Synced, + Pending, + Unverified, + RecoveryRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScopeRecoveryState { + Clear, + Unverified, + Required, +} + +pub(crate) trait LibraryApplicationRecoveryStatus: Send + Sync { + fn status<'a>( + &'a self, + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result>; +} + +#[cfg(test)] +struct ClearRecoveryStatus; + +#[cfg(test)] +impl LibraryApplicationRecoveryStatus for ClearRecoveryStatus { + fn status<'a>( + &'a self, + _context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async { Ok(ScopeRecoveryState::Clear) }) + } +} + +fn application_sync_state( + record: &LibraryApplicationRecord, + target_members: &[LibraryMemberIdentity], + recovery: ScopeRecoveryState, +) -> LibraryApplicationSyncState { + if recovery == ScopeRecoveryState::Required { + LibraryApplicationSyncState::RecoveryRequired + } else if recovery == ScopeRecoveryState::Unverified + || record + .pending + .as_ref() + .is_some_and(|pending| pending.attention == ReconciliationAttention::Unverified) + { + LibraryApplicationSyncState::Unverified + } else if record.pending.is_some() || record.checkpoint.members != target_members { + LibraryApplicationSyncState::Pending + } else { + LibraryApplicationSyncState::Synced + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -225,6 +484,13 @@ pub struct LibraryApplicationResponse { pub units: Vec, } +#[derive(Debug, Clone)] +pub(crate) struct LibraryApplicationScopePlan { + pub context: SkillLocationRef, + pub entries: Vec<(PhysicalTargetKey, PreparedEntryAction)>, + pub preview: Option, +} + /// Project 删除流程解除库应用关系时使用的操作集合。 /// /// 该 Interface 只覆盖解除流程实际调用的五个操作,使用例可以在不构造 @@ -259,17 +525,25 @@ pub trait ProjectLibraryDetachment: Send + Sync { ) -> LibraryApplicationFuture<'a, Result<(), AppError>>; } -pub trait LibraryApplicationRepository: Send + Sync { +pub trait ApplicationRegistry: Send + Sync { fn load_application<'a>( &'a self, context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result>; + ) -> LibraryApplicationFuture<'a, Result>; - fn save_application<'a>( + fn save_application_if<'a>( &'a self, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>>; + ) -> LibraryApplicationFuture<'a, Result>; + + fn enumerate<'a>( + &'a self, + environment: &'a crate::environment::types::EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result>; +} +pub(crate) trait LibraryApplicationResources: Send + Sync { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -282,17 +556,28 @@ pub trait LibraryApplicationRepository: Send + Sync { context: &'a SkillLocationRef, ) -> LibraryApplicationFuture<'a, Result>; - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>>; } +pub(crate) trait LibraryApplicationBackend: + ApplicationRegistry + LibraryApplicationResources +{ +} + +impl LibraryApplicationBackend for T where + T: ApplicationRegistry + LibraryApplicationResources + ?Sized +{ +} + pub struct LibraryApplicationModule { - repository: Arc, + repository: Arc, facts: F, targets: T, executor: E, + recovery: Arc, } impl LibraryApplicationModule @@ -301,17 +586,35 @@ where T: TargetFactResolver, E: MutationPlanExecutor, { + #[cfg(test)] pub fn new( - repository: Arc, + repository: Arc, + facts: F, + targets: T, + executor: E, + ) -> Self { + Self::with_recovery_status( + repository, + facts, + targets, + executor, + Arc::new(ClearRecoveryStatus), + ) + } + + pub fn with_recovery_status( + repository: Arc, facts: F, targets: T, executor: E, + recovery: Arc, ) -> Self { Self { repository, facts, targets, executor, + recovery, } } @@ -321,15 +624,27 @@ where ) -> Result { let record = self.repository.load_application(&context).await?; let catalog = self.repository.load_catalog(&context).await?; - summary(&record, &catalog) + let recovery = self + .recovery + .status(&context) + .await + .unwrap_or(ScopeRecoveryState::Unverified); + summary(&record, &catalog, recovery) } pub async fn agent_options( &self, context: SkillLocationRef, ) -> Result { + let record = self.repository.load_application(&context).await?; let facts = self.facts.snapshot(&context).await?; - let resolved = resolve_library_agent_options(&context, &facts, &self.targets).await?; + let resolved = resolve_library_agent_options( + &context, + &facts, + &self.targets, + &persisted_agent_ids(&record.record), + ) + .await?; Ok(LibraryAgentOptions { selection: resolved.selection, migrations: resolved.migrations, @@ -337,82 +652,11 @@ where }) } - pub async fn managed_skill_names( - &self, - context: SkillLocationRef, - ) -> Result, AppError> { - let record = self.repository.load_application(&context).await?; - let catalog = self.repository.load_catalog(&context).await?; - let member_index = - LibraryCatalogMemberIndex::build(&catalog).map_err(library_member_index_error)?; - let current = member_index - .members_for(&record.current.ordered_library_ids) - .map_err(library_member_index_error)?; - let pending = record - .pending_operation - .as_ref() - .map(|pending| { - member_index - .members_for(&pending.target_application.ordered_library_ids) - .map_err(library_member_index_error) - }) - .transpose()? - .unwrap_or_default(); - let groups = merge_library_skill_groups(current, pending); - if groups.is_empty() { - return Ok(BTreeSet::new()); - } - let facts = self.facts.snapshot(&context).await?; - let destinations = groups - .iter() - .map(|group| { - facts - .resolved_context - .skill_root - .join_child(group.directory_name.as_ref()) - }) - .collect::>(); - let target_facts = self.targets.resolve(&context, &destinations, None).await?; - if target_facts.len() != groups.len() { - return Err(AppError::StaleTarget); - } - ensure_library_link_targets_supported(target_facts.iter())?; - let candidate_members = library_group_members(&groups); - let candidate_index = ResolvedLibraryCandidateIndex::load( - self.repository.as_ref(), - &self.targets, - &context, - &candidate_members, - ) - .await?; - let mut managed = BTreeSet::new(); - for (group, fact) in groups.into_iter().zip(target_facts) { - let members = group - .current_members - .iter() - .chain(group.target_members.iter()) - .cloned() - .collect::>(); - let candidates = candidate_index.candidates_for(&members)?; - for candidate in candidates { - if fact - .link_target_identity - .as_ref() - .is_some_and(|identity| identity.matches(candidate.locator())) - { - managed.insert(candidate.member_name().to_string()); - break; - } - } - } - Ok(managed) - } - pub async fn preview( &self, draft: LibraryApplicationDraft, ) -> Result { - Ok(self.build(&draft, false, false).await?.preview) + Ok(self.build(&draft, false, false, None, None).await?.preview) } pub async fn apply( @@ -420,17 +664,46 @@ where request: ApplyLibraryApplicationRequest, cancellation: CancellationSignal, ) -> Result { - let built = self.build(&request.draft, true, false).await?; + let built = self.build(&request.draft, true, false, None, None).await?; validate_exact_preview(&request.expected_token, &built.preview.token)?; + let mut reasons = reconciliation_reasons( + &built.record.current, + &built.preview.target, + &built.record.checkpoint, + &built.target_members, + ); + if reasons.is_empty() && built.plan.units.is_empty() { + return Ok(LibraryApplicationResponse { + application: self.read(built.context).await?, + units: Vec::new(), + }); + } + if built.record.current == built.preview.target && !built.plan.units.is_empty() { + reasons.push(ReconciliationReason::ReapplyRequested); + } let mut pending_record = built.record.clone(); - pending_record.pending_operation = Some(PendingLibraryApplication { - operation_id: built.plan.operation_id.clone(), + pending_record.pending = Some(PendingReconciliation { + reconciliation_id: uuid::Uuid::new_v4().simple().to_string(), + attention: ReconciliationAttention::Pending, + reasons, before_application: built.record.current.clone(), target_application: built.preview.target.clone(), - preview_fingerprint: built.preview.token.generation.clone(), + recognized_members: reconciliation_members( + &built.record.checkpoint, + built.record.pending.as_ref(), + &built.target_members, + ), + affected_members: changed_member_identities( + &built.record.checkpoint.members, + &built.target_members, + ), + target_members: built.target_members.clone(), }); - self.repository.save_application(&pending_record).await?; - self.execute(built, pending_record, cancellation).await + let pending = self + .repository + .save_application_if(&built.observed, &pending_record) + .await?; + self.execute(built, pending, cancellation).await } pub async fn retry_pending( @@ -438,22 +711,243 @@ where context: SkillLocationRef, cancellation: CancellationSignal, ) -> Result { - let record = self.repository.load_application(&context).await?; - let pending = record - .pending_operation - .clone() - .ok_or_else(|| AppError::Validation { + let observed = self.repository.load_application(&context).await?; + if observed.pending.is_none() { + return Err(AppError::Validation { field: Some("context".to_string()), message: "the Scope has no pending Skill Library operation".to_string(), - })?; + }); + } + self.resume_observed(context, observed, cancellation).await + } + + pub async fn resume( + &self, + context: SkillLocationRef, + cancellation: CancellationSignal, + ) -> Result { + let observed = self.repository.load_application(&context).await?; + self.resume_observed(context, observed, cancellation).await + } + + pub(crate) async fn plan_resume( + &self, + context: SkillLocationRef, + ) -> Result { + let observed = self.repository.load_application(&context).await?; + let catalog = self.repository.load_catalog(&context).await?; + if !reconciliation_required(&observed.record, &catalog)? { + return Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }); + } + let target_application = observed + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.current.clone()); + let draft = LibraryApplicationDraft { + context: context.clone(), + ordered_library_ids: target_application.ordered_library_ids, + selected_agent_ids: target_application.selected_agent_ids, + }; + let built = self.build(&draft, true, true, Some(observed), None).await?; + let entries = built + .plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .map(|entry| (entry.key.clone(), entry.action.clone())) + .collect(); + Ok(LibraryApplicationScopePlan { + context, + entries, + preview: Some(built.preview), + }) + } + + pub(crate) async fn plan_resume_with_catalog( + &self, + context: SkillLocationRef, + catalog: LibraryCatalog, + ) -> Result { + let observed = self.repository.load_application(&context).await?; + if !reconciliation_required(&observed.record, &catalog)? { + return Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }); + } + let target_application = observed + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.current.clone()); + let draft = LibraryApplicationDraft { + context: context.clone(), + ordered_library_ids: target_application.ordered_library_ids, + selected_agent_ids: target_application.selected_agent_ids, + }; + let built = self + .build(&draft, true, true, Some(observed), Some(catalog)) + .await?; + let entries = built + .plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .map(|entry| (entry.key.clone(), entry.action.clone())) + .collect(); + Ok(LibraryApplicationScopePlan { + context, + entries, + preview: Some(built.preview), + }) + } + + pub(crate) async fn record_reconciliation_attention( + &self, + context: SkillLocationRef, + attention: ReconciliationAttention, + ) -> Result<(), AppError> { + let observed = self.repository.load_application(&context).await?; + let catalog = self.repository.load_catalog(&context).await?; + let member_index = + LibraryCatalogMemberIndex::build(&catalog).map_err(library_member_index_error)?; + let target_application = observed + .record + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.record.current.clone()); + let target_members = member_index + .members_for(&target_application.ordered_library_ids) + .map_err(library_member_index_error)? + .into_values() + .flatten() + .collect::>() + .into_iter() + .collect::>(); + let reasons = reconciliation_reasons( + &observed.record.current, + &target_application, + &observed.record.checkpoint, + &target_members, + ); + if reasons.is_empty() && observed.record.pending.is_none() { + return Ok(()); + } + let recognized = reconciliation_members( + &observed.record.checkpoint, + observed.record.pending.as_ref(), + &target_members, + ); + let mut pending = match observed.record.pending.clone() { + Some(pending) => { + pending.merged_with(reasons, target_application, recognized, target_members) + } + None => PendingReconciliation { + reconciliation_id: uuid::Uuid::new_v4().simple().to_string(), + attention: ReconciliationAttention::Pending, + reasons, + before_application: observed.record.current.clone(), + target_application, + recognized_members: recognized, + affected_members: changed_member_identities( + &observed.record.checkpoint.members, + &target_members, + ), + target_members, + }, + }; + pending.attention = pending.attention.max(attention); + let mut record = observed.record.clone(); + record.pending = Some(pending); + self.repository + .save_application_if(&observed, &record) + .await?; + Ok(()) + } + + async fn resume_observed( + &self, + context: SkillLocationRef, + observed: VersionedApplicationRecord, + cancellation: CancellationSignal, + ) -> Result { + let catalog = self.repository.load_catalog(&context).await?; + if !reconciliation_required(&observed.record, &catalog)? { + if observed.pending.is_some() { + let mut verified = observed.record.clone(); + verified.pending = None; + self.repository + .save_application_if(&observed, &verified) + .await?; + } + return Ok(LibraryApplicationResponse { + application: self.read(context).await?, + units: Vec::new(), + }); + } + let target_application = observed + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.current.clone()); let draft = LibraryApplicationDraft { context, - ordered_library_ids: pending.target_application.ordered_library_ids.clone(), - selected_agent_ids: pending.target_application.selected_agent_ids.clone(), + ordered_library_ids: target_application.ordered_library_ids.clone(), + selected_agent_ids: target_application.selected_agent_ids.clone(), + }; + let built = self.build(&draft, true, true, Some(observed), None).await?; + let reasons = reconciliation_reasons( + &built.record.current, + &built.preview.target, + &built.record.checkpoint, + &built.target_members, + ); + if reasons.is_empty() && built.record.pending.is_none() { + return Ok(LibraryApplicationResponse { + application: self.read(built.context).await?, + units: Vec::new(), + }); + } + let recognized = reconciliation_members( + &built.record.checkpoint, + built.record.pending.as_ref(), + &built.target_members, + ); + let pending = match built.record.pending.clone() { + Some(pending) => pending.merged_with( + reasons, + built.preview.target.clone(), + recognized, + built.target_members.clone(), + ), + None => PendingReconciliation { + reconciliation_id: uuid::Uuid::new_v4().simple().to_string(), + attention: ReconciliationAttention::Pending, + reasons, + before_application: built.record.current.clone(), + target_application: built.preview.target.clone(), + recognized_members: recognized, + affected_members: changed_member_identities( + &built.record.checkpoint.members, + &built.target_members, + ), + target_members: built.target_members.clone(), + }, }; - let mut built = self.build(&draft, true, true).await?; - built.plan.operation_id = pending.operation_id; - self.execute(built, record, cancellation).await + let mut pending_record = built.record.clone(); + pending_record.pending = Some(pending); + let pending = self + .repository + .save_application_if(&built.observed, &pending_record) + .await?; + self.execute(built, pending, cancellation).await } pub async fn forget_project(&self, context: SkillLocationRef) -> Result<(), AppError> { @@ -467,38 +961,53 @@ where }); } let record = self.repository.load_application(&context).await?; - if record.pending_operation.is_some() + if record.pending.is_some() || !record.current.ordered_library_ids.is_empty() || !record.current.selected_agent_ids.is_empty() + || !record.checkpoint.members.is_empty() { return Err(AppError::MutationBusy); } - self.repository.remove_application(&context).await + self.repository.remove_application_if(&record).await } async fn execute( &self, built: BuiltLibraryApplication, - pending_record: LibraryApplicationRecord, + pending: VersionedApplicationRecord, cancellation: CancellationSignal, ) -> Result { - let context = built.record.target.clone(); + let context = built.context.clone(); let expected_unit_count = built.plan.units.len(); let units = self.executor.execute(built.plan, cancellation).await; let completed = library_execution_completed(expected_unit_count, &units); let final_record = if completed { LibraryApplicationRecord { current: built.preview.target, - pending_operation: None, - ..pending_record + checkpoint: ReconciliationCheckpoint { + members: built.target_members.clone(), + }, + pending: None, + ..pending.record.clone() } } else { - pending_record + let mut record = pending.record.clone(); + if let Some(pending) = &mut record.pending { + pending.attention = execution_attention(&units); + } + record }; - self.repository.save_application(&final_record).await?; + self.repository + .save_application_if(&pending, &final_record) + .await?; let catalog = self.repository.load_catalog(&context).await?; + let recovery = self + .recovery + .status(&context) + .await + .unwrap_or(ScopeRecoveryState::Unverified); Ok(LibraryApplicationResponse { - application: summary(&final_record, &catalog)?, + application: summary(&final_record, &catalog, recovery)?, units, }) } @@ -508,17 +1017,32 @@ where draft: &LibraryApplicationDraft, include_plan: bool, allow_pending: bool, + observed: Option, + catalog: Option, ) -> Result { - let record = self.repository.load_application(&draft.context).await?; - if record.pending_operation.is_some() && !allow_pending { + let observed = match observed { + Some(observed) => observed, + None => self.repository.load_application(&draft.context).await?, + }; + let record = observed.record.clone(); + if record.pending.is_some() && !allow_pending { return Err(AppError::MutationBusy); } - let catalog = self.repository.load_catalog(&draft.context).await?; - let member_index = + let observed_catalog = self.repository.load_catalog(&draft.context).await?; + let catalog = catalog.unwrap_or_else(|| observed_catalog.clone()); + let current_member_index = LibraryCatalogMemberIndex::build(&observed_catalog) + .map_err(library_member_index_error)?; + let target_member_index = LibraryCatalogMemberIndex::build(&catalog).map_err(library_member_index_error)?; let facts = self.facts.snapshot(&draft.context).await?; - let agent_options = - resolve_library_agent_options(&draft.context, &facts, &self.targets).await?; + let persisted_agent_ids = persisted_agent_ids(&record); + let agent_options = resolve_library_agent_options( + &draft.context, + &facts, + &self.targets, + &persisted_agent_ids, + ) + .await?; let ordered_library_ids = validated_library_ids(&catalog, &draft.ordered_library_ids)?; if ordered_library_ids.is_empty() && !draft.selected_agent_ids.is_empty() { return Err(AppError::Validation { @@ -531,25 +1055,83 @@ where selected_agent_ids: validated_agent_ids( &agent_options.placement_map, &draft.selected_agent_ids, + &persisted_agent_ids, )?, }; - let current_agent_ids = validated_agent_ids( - &agent_options.placement_map, - &record.current.selected_agent_ids, - )?; - if let Some(pending) = &record.pending_operation { + let current_agent_ids = record.current.selected_agent_ids.clone(); + if let Some(pending) = &record.pending { if pending.target_application != target { return Err(AppError::StaleContext); } } - let groups = merge_library_skill_groups( - member_index - .members_for(&record.current.ordered_library_ids) + let current_members = current_member_index + .members_for(&record.current.ordered_library_ids) + .map_err(library_member_index_error)?; + let current_member_identities = current_members + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + let target_members_by_directory = target_member_index + .members_for(&target.ordered_library_ids) + .map_err(library_member_index_error)?; + let target_members = target_members_by_directory + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + let recognized_members = + reconciliation_members(&record.checkpoint, record.pending.as_ref(), &target_members); + let recognized = merge_catalog_member_maps( + current_member_index + .recognized_members( + &recognized_members + .iter() + .filter(|member| current_member_index.contains_identity(member)) + .cloned() + .collect::>(), + ) .map_err(library_member_index_error)?, - member_index - .members_for(&target.ordered_library_ids) + target_member_index + .recognized_members( + &recognized_members + .iter() + .filter(|member| target_member_index.contains_identity(member)) + .cloned() + .collect::>(), + ) .map_err(library_member_index_error)?, ); + let before_recognized = current_member_index + .recognized_members(&reconciliation_members( + &record.checkpoint, + record.pending.as_ref(), + ¤t_member_identities, + )) + .map_err(library_member_index_error)?; + let mut groups = merge_library_skill_groups( + current_members, + target_members_by_directory, + recognized, + before_recognized, + ); + let confirmed_reapply = record.pending.as_ref().is_some_and(|pending| { + pending + .reasons + .contains(&ReconciliationReason::ReapplyRequested) + }); + if allow_pending && !confirmed_reapply && record.current == target { + let changed = membership_changed_skill_directories( + &record.checkpoint, + record.pending.as_ref(), + &target_members, + )?; + groups.retain(|group| changed.contains(&group.directory_name)); + } let candidate_members = library_group_members(&groups); let candidate_index = ResolvedLibraryCandidateIndex::load( self.repository.as_ref(), @@ -569,17 +1151,22 @@ where for group in groups { let current_candidates = candidate_index.candidates_for(&group.current_members)?; let target_candidates = candidate_index.candidates_for(&group.target_members)?; + let recognized_candidates = + candidate_index.candidates_for(&group.recognized_members)?; + let before_recognized_candidates = + candidate_index.candidates_for(&group.before_recognized_members)?; + let before_applied = !before_recognized_candidates.is_empty(); let current_candidate_set = LibraryCandidateSet::for_skill( &draft.context.environment, &group.directory_name, - current_candidates.clone(), + before_recognized_candidates, current_candidates, ) .map_err(|_| AppError::StaleContext)?; let target_candidate_set = LibraryCandidateSet::for_skill( &draft.context.environment, &group.directory_name, - target_candidates.clone(), + recognized_candidates, target_candidates, ) .map_err(|_| AppError::StaleContext)?; @@ -632,6 +1219,7 @@ where group, current_candidate_set, target_candidate_set, + before_applied, logical_targets, )); } @@ -653,7 +1241,8 @@ where let mut removed_skill_names = BTreeSet::new(); let mut switched_skill_names = BTreeSet::new(); let mut observed_targets = Vec::new(); - for (group, current_candidate_set, target_candidate_set, logical_targets) in prepared_groups + for (group, current_candidate_set, target_candidate_set, before_applied, logical_targets) in + prepared_groups { let group_facts = target_facts .by_ref() @@ -691,6 +1280,7 @@ where candidates: &target_candidate_set, selected_agent_ids: &target.selected_agent_ids, }, + before_applied, legacy, }) .map_err(|error| error.into_app_error())?; @@ -770,6 +1360,7 @@ where units: if include_plan { units } else { Vec::new() }, }); Ok(BuiltLibraryApplication { + context: draft.context.clone(), preview: LibraryApplicationPreview { token, current: record.current.clone(), @@ -781,7 +1372,9 @@ where overridden_by_direct_skill_names: direct_skill_names.into_iter().collect(), }, record, + observed, plan, + target_members, }) } } @@ -796,11 +1389,27 @@ fn library_execution_completed(expected: usize, units: &[MutationUnitResult]) -> }) } -fn library_application_observed_digest( - record: &LibraryApplicationRecord, - target: &LibraryApplicationState, - catalog: &LibraryCatalog, - observed_targets: &[(PhysicalTargetKey, EntryFingerprint)], +fn execution_attention(units: &[MutationUnitResult]) -> ReconciliationAttention { + if units.iter().any(|unit| { + unit.error.as_ref().is_some_and(|error| { + matches!( + error.code, + crate::application::mutation::result::OperationErrorCode::EnvironmentUnavailable + | crate::application::mutation::result::OperationErrorCode::StorageUnsupported + ) + }) + }) { + ReconciliationAttention::Unverified + } else { + ReconciliationAttention::Pending + } +} + +fn library_application_observed_digest( + record: &LibraryApplicationRecord, + target: &LibraryApplicationState, + catalog: &LibraryCatalog, + observed_targets: &[(PhysicalTargetKey, EntryFingerprint)], ) -> Result { crate::application::mutation::plan::stable_digest(&(record, target, catalog, observed_targets)) } @@ -823,9 +1432,7 @@ where context: SkillLocationRef, cancellation: CancellationSignal, ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { - LibraryApplicationModule::retry_pending(self, context, cancellation).await - }) + Box::pin(async move { LibraryApplicationModule::resume(self, context, cancellation).await }) } fn preview<'a>( @@ -872,6 +1479,8 @@ struct LibrarySkillGroup { directory_name: SkillDirectoryName, current_members: Vec, target_members: Vec, + recognized_members: Vec, + before_recognized_members: Vec, } #[derive(Clone)] @@ -890,6 +1499,7 @@ async fn resolve_library_agent_options( context: &SkillLocationRef, facts: &ScopePlanningSnapshot, targets: &T, + saved_agent_ids: &BTreeSet, ) -> Result { let catalog = build_agent_selection_catalog( context, @@ -900,12 +1510,47 @@ async fn resolve_library_agent_options( ) .await?; let placement_map = LibraryAgentPlacementMap::from_catalog(&catalog); - let selection = placement_map.selection_snapshot().clone(); - let private_agent_ids = selection + let mut selection = placement_map.selection_snapshot().clone(); + selection.baseline_selected_option_ids = selection + .install_options + .iter() + .filter(|option| { + option + .agent_ids + .iter() + .all(|agent_id| saved_agent_ids.contains(agent_id)) + }) + .map(|option| option.id.clone()) + .collect(); + let represented_saved_agent_ids = selection + .install_options + .iter() + .filter(|option| { + option + .agent_ids + .iter() + .all(|agent_id| saved_agent_ids.contains(agent_id)) + }) + .flat_map(|option| option.agent_ids.iter().cloned()) + .collect::>(); + let known_agent_ids = catalog + .snapshot() .agents .iter() .map(|agent| agent.id.clone()) .collect::>(); + selection.unavailable_explicit_agents = saved_agent_ids + .iter() + .filter(|agent_id| !represented_saved_agent_ids.contains(*agent_id)) + .map(|agent_id| UnavailableAgentSelection { + agent_id: agent_id.as_str().to_string(), + reason: if known_agent_ids.contains(agent_id) { + UnavailableAgentSelectionReason::OptionUnavailable + } else { + UnavailableAgentSelectionReason::DefinitionMissing + }, + }) + .collect(); let mut options = placement_map .placements() .filter_map(|(placement_id, placement)| match placement_id { @@ -951,11 +1596,8 @@ async fn resolve_library_agent_options( }; let mut legacy_candidates = Vec::new(); let mut migrations = Vec::new(); - for agent in &selection.agents { - if !private_agent_ids.contains(&agent.id) { - continue; - } - let Some(resolved) = facts.agent_runtime.agents.get(&agent.id) else { + for agent_id in saved_agent_ids { + let Some(resolved) = facts.agent_runtime.agents.get(agent_id) else { continue; }; let resolved_scope = if legacy_scope == LegacyPathScope::Global { @@ -978,7 +1620,11 @@ async fn resolve_library_agent_options( environment: context.environment.clone(), native_path: path.clone(), }; - legacy_candidates.push((root, agent.id.clone(), agent.display_name.clone())); + legacy_candidates.push(( + root, + agent_id.clone(), + resolved.definition.display_name.clone(), + )); } } } @@ -1037,9 +1683,10 @@ async fn resolve_library_agent_options( fn validated_agent_ids( placements: &LibraryAgentPlacementMap, requested: &[AgentId], + persisted: &BTreeSet, ) -> Result, AppError> { placements - .placements_for(requested) + .validate_selection_with_persisted(requested, persisted) .map_err(|error| match error { crate::application::library_agent_placements::LibraryAgentPlacementError::UnknownAgent( agent, @@ -1060,6 +1707,22 @@ fn validated_agent_ids( .collect()) } +fn persisted_agent_ids(record: &LibraryApplicationRecord) -> BTreeSet { + record + .current + .selected_agent_ids + .iter() + .chain(record.pending.iter().flat_map(|pending| { + pending + .before_application + .selected_agent_ids + .iter() + .chain(&pending.target_application.selected_agent_ids) + })) + .cloned() + .collect() +} + fn ensure_library_link_targets_supported<'a>( targets: impl IntoIterator, ) -> Result<(), AppError> { @@ -1076,18 +1739,99 @@ fn ensure_library_link_targets_supported<'a>( } struct BuiltLibraryApplication { + context: SkillLocationRef, preview: LibraryApplicationPreview, record: LibraryApplicationRecord, + observed: VersionedApplicationRecord, plan: crate::application::mutation::plan::MutationPlan, + target_members: Vec, +} + +fn reconciliation_reasons( + current: &LibraryApplicationState, + target: &LibraryApplicationState, + checkpoint: &ReconciliationCheckpoint, + target_members: &[LibraryMemberIdentity], +) -> Vec { + let mut reasons = Vec::new(); + if current != target { + reasons.push(ReconciliationReason::ApplicationChanged); + } + if checkpoint.members != target_members { + reasons.push(ReconciliationReason::MembershipChanged); + } + reasons +} + +fn reconciliation_required( + record: &LibraryApplicationRecord, + catalog: &LibraryCatalog, +) -> Result { + if record.pending.as_ref().is_some_and(|pending| { + pending.target_application != record.current + || pending + .reasons + .iter() + .any(|reason| *reason != ReconciliationReason::VerificationRequired) + }) { + return Ok(true); + } + let target = record + .pending + .as_ref() + .map(|pending| &pending.target_application) + .unwrap_or(&record.current); + let target_members = LibraryCatalogMemberIndex::build(catalog) + .map_err(library_member_index_error)? + .members_for(&target.ordered_library_ids) + .map_err(library_member_index_error)? + .into_values() + .flatten() + .collect::>() + .into_iter() + .collect::>(); + Ok(record.checkpoint.members != target_members) +} + +fn membership_changed_skill_directories( + checkpoint: &ReconciliationCheckpoint, + pending: Option<&PendingReconciliation>, + target_members: &[LibraryMemberIdentity], +) -> Result, AppError> { + let mut affected = changed_member_identities(&checkpoint.members, target_members); + if let Some(pending) = pending { + affected.extend(pending.affected_members.iter().cloned()); + affected.extend(changed_member_identities( + &pending.target_members, + target_members, + )); + } + affected + .into_iter() + .map(|member| SkillDirectoryName::try_from(member.member_name.as_str())) + .collect() +} + +fn changed_member_identities( + before: &[LibraryMemberIdentity], + after: &[LibraryMemberIdentity], +) -> Vec { + let before = before.iter().cloned().collect::>(); + let after = after.iter().cloned().collect::>(); + before.symmetric_difference(&after).cloned().collect() } fn merge_library_skill_groups( current: BTreeMap>, target: BTreeMap>, + recognized: BTreeMap>, + before_recognized: BTreeMap>, ) -> Vec { let directory_names = current .keys() .chain(target.keys()) + .chain(recognized.keys()) + .chain(before_recognized.keys()) .cloned() .collect::>(); directory_names @@ -1095,20 +1839,33 @@ fn merge_library_skill_groups( .map(|directory_name| LibrarySkillGroup { current_members: current.get(&directory_name).cloned().unwrap_or_default(), target_members: target.get(&directory_name).cloned().unwrap_or_default(), + recognized_members: recognized.get(&directory_name).cloned().unwrap_or_default(), + before_recognized_members: before_recognized + .get(&directory_name) + .cloned() + .unwrap_or_default(), directory_name, }) .collect() } +fn merge_catalog_member_maps( + mut left: BTreeMap>, + right: BTreeMap>, +) -> BTreeMap> { + for (directory, members) in right { + let combined = left.entry(directory).or_default(); + combined.extend(members); + combined.sort(); + combined.dedup(); + } + left +} + fn library_group_members(groups: &[LibrarySkillGroup]) -> Vec { groups .iter() - .flat_map(|group| { - group - .current_members - .iter() - .chain(group.target_members.iter()) - }) + .flat_map(|group| group.recognized_members.iter()) .cloned() .collect() } @@ -1133,12 +1890,7 @@ fn validated_library_ids( .ok_or_else(|| AppError::PathNotFound { path: id.as_str().to_string(), })?; - if library.skills.is_empty() { - return Err(AppError::Validation { - field: Some("orderedLibraryIds".to_string()), - message: "empty Skill Library cannot be applied".to_string(), - }); - } + let _ = library; result.push(id.clone()); } Ok(result) @@ -1153,7 +1905,23 @@ fn library_member_index_error(error: impl std::fmt::Debug) -> AppError { fn summary( record: &LibraryApplicationRecord, catalog: &LibraryCatalog, + recovery: ScopeRecoveryState, ) -> Result { + let member_index = + LibraryCatalogMemberIndex::build(catalog).map_err(library_member_index_error)?; + let target_ids = record + .pending + .as_ref() + .map(|pending| pending.target_application.ordered_library_ids.as_slice()) + .unwrap_or(&record.current.ordered_library_ids); + let target_members = member_index + .members_for(target_ids) + .map_err(library_member_index_error)? + .into_values() + .flatten() + .collect::>() + .into_iter() + .collect::>(); let mut ordered_libraries = Vec::new(); for id in &record.current.ordered_library_ids { let library = catalog @@ -1169,32 +1937,36 @@ fn summary( skill_count: library.skills.len() as u32, }); } + let sync_state = application_sync_state(record, &target_members, recovery); Ok(LibraryApplicationSummary { ordered_libraries, selected_agent_ids: record.current.selected_agent_ids.clone(), - pending: record.pending_operation.is_some(), + pending: sync_state != LibraryApplicationSyncState::Synced, + sync_state, }) } #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use crate::application::install::InstallFuture; use crate::application::mutation::executor::MutationFuture; use crate::application::mutation::plan::{MutationPlan, PreparedEntryAction, RuntimeRevisions}; use crate::application::skill_libraries::{ - LibrarySkillRecord, LibrarySkillSourceRecord, SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, + LibrarySkillRecord, LibrarySkillSourceRecord, RetiredLibrarySkillRecord, + SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, }; use crate::core::agent_definition::{ - AgentAdapter, AgentDefinition, AgentSource, DetectionSpec, PathSpec, ScopeDefinition, + AgentAdapter, AgentDefinition, AgentSource, DetectionSpec, LegacyMigrationTarget, + LegacyPath, PathSpec, ScopeDefinition, }; use crate::core::lossless_lock::{LockSchema, LosslessLockDocument}; use crate::environment::agent_environment::{ AgentRuntimeSnapshot, DetectionState, DirectoryPresenceState, ResolvedAgent, - ResolvedAgentScope, + ResolvedAgentScope, ResolvedPathPresence, }; use crate::environment::context_resolver::ResolvedContext; use crate::environment::planning::{TargetFactFuture, TargetFactResolver}; @@ -1208,11 +1980,23 @@ mod tests { const TEST_SKILL_ROOT: &str = "/scope/.agents/skills"; const TEST_AGENT_ROOT: &str = "/agents/private/skills"; + const TEST_LEGACY_ROOT: &str = "/agents/legacy/skills"; const TEST_LIBRARY_ROOT: &str = "/libraries/lib-one/skills"; #[derive(Clone)] struct FixedFacts(ScopePlanningSnapshot); + struct FixedRecoveryStatus(ScopeRecoveryState); + + impl LibraryApplicationRecoveryStatus for FixedRecoveryStatus { + fn status<'a>( + &'a self, + _context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { Ok(self.0) }) + } + } + impl ScopePlanningSnapshotSource for FixedFacts { fn snapshot<'a>( &'a self, @@ -1225,26 +2009,65 @@ mod tests { struct MemoryApplicationRepository { record: Mutex, catalog: Mutex, + fail_next_checkpoint: AtomicBool, } - impl LibraryApplicationRepository for MemoryApplicationRepository { + impl ApplicationRegistry for MemoryApplicationRepository { fn load_application<'a>( &'a self, - _context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { Ok(self.record.lock().unwrap().clone()) }) + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + context.clone(), + self.record.lock().unwrap().clone(), + )) + }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { + if record.pending.is_none() + && self.fail_next_checkpoint.swap(false, Ordering::SeqCst) + { + return Err(AppError::Io { + message: "injected checkpoint commit failure".to_string(), + }); + } *self.record.lock().unwrap() = record.clone(); - Ok(()) + Ok(VersionedApplicationRecord::in_memory( + observed.context.clone(), + record.clone(), + )) + }) + } + + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + let context = SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }; + Ok(ApplicationInventory { + records: vec![VersionedApplicationRecord::in_memory( + context, + self.record.lock().unwrap().clone(), + )], + problems: Vec::new(), + complete: true, + }) }) } + } + impl LibraryApplicationResources for MemoryApplicationRepository { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -1266,9 +2089,9 @@ mod tests { Box::pin(async move { Ok(self.catalog.lock().unwrap().clone()) }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, + _observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { Ok(()) }) } @@ -1314,19 +2137,41 @@ mod tests { Ok(logical_destinations .iter() .map(|destination| { - let agent_skill = locator(TEST_AGENT_ROOT).join_child("demo"); - let (name, entry_kind, link_target) = - if destination.native_path == TEST_AGENT_ROOT { - ("agent-root", TargetEntryKind::Directory, None) - } else if destination.native_path == agent_skill.native_path { - ( - "agent-skill", - self.agent_entry_kind, - self.agent_link_target.clone(), - ) - } else { - ("canonical-skill", self.primary_entry_kind, None) - }; + let path = std::path::Path::new(&destination.native_path); + let skill_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("target"); + let parent = path.parent(); + let (name, entry_kind, link_target) = if path + .starts_with(std::path::Path::new("/libraries")) + { + ( + format!( + "library-{}", + crate::application::mutation::plan::stable_digest(destination) + .unwrap() + ), + TargetEntryKind::Directory, + None, + ) + } else if destination.native_path == TEST_AGENT_ROOT { + ("agent-root".to_string(), TargetEntryKind::Directory, None) + } else if parent == Some(std::path::Path::new(TEST_AGENT_ROOT)) + || parent == Some(std::path::Path::new(TEST_LEGACY_ROOT)) + { + ( + format!("agent-skill-{skill_name}"), + self.agent_entry_kind, + self.agent_link_target.clone(), + ) + } else { + ( + format!("canonical-skill-{skill_name}"), + self.primary_entry_kind, + None, + ) + }; let link_target_identity = link_target.as_deref().and_then(|raw| { crate::environment::planning::resolve_link_target_identity( destination, @@ -1334,7 +2179,7 @@ mod tests { ) }); ResolvedTargetFact { - key: physical_key(name), + key: physical_key(&name), destination: destination.clone(), storage_access: StorageAccess::Native, fingerprint: EntryFingerprint(format!("entry-v1-{name}")), @@ -1383,6 +2228,37 @@ mod tests { } } + #[derive(Clone)] + struct RecoveryExecutor; + + impl MutationPlanExecutor for RecoveryExecutor { + fn execute<'a>( + &'a self, + plan: MutationPlan, + _cancellation: CancellationSignal, + ) -> MutationFuture<'a, Vec> { + Box::pin(async move { + plan.units + .iter() + .map(|unit| { + MutationUnitResult::recovery_required( + unit.id.clone(), + unit.skill_name.clone(), + unit.target.clone(), + crate::application::mutation::result::ErrorReport::recovery_required( + crate::application::mutation::result::RecoveryResourceId::parse( + "recovery-1", + ) + .unwrap(), + "restore required", + ), + ) + }) + .collect() + }) + } + } + fn application_fixture( agent_entry_kind: TargetEntryKind, agent_link_target: Option<&str>, @@ -1391,7 +2267,7 @@ mod tests { RecordingExecutor, LibraryApplicationDraft, ) { - let (module, executor, draft, _) = application_fixture_with( + let (module, executor, draft, _, _) = application_fixture_with( TargetEntryKind::Missing, agent_entry_kind, agent_link_target, @@ -1399,6 +2275,7 @@ mod tests { id: LibraryId::parse("lib-one"), name: "Library One".to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], Vec::new(), @@ -1407,6 +2284,14 @@ mod tests { (module, executor, draft) } + type ApplicationFixture = ( + LibraryApplicationModule, + RecordingExecutor, + LibraryApplicationDraft, + Arc, + Arc, + ); + fn application_fixture_with( primary_entry_kind: TargetEntryKind, agent_entry_kind: TargetEntryKind, @@ -1414,12 +2299,7 @@ mod tests { libraries: Vec, current_library_ids: Vec, target_library_ids: Vec, - ) -> ( - LibraryApplicationModule, - RecordingExecutor, - LibraryApplicationDraft, - Arc, - ) { + ) -> ApplicationFixture { let context = SkillLocationRef { environment: EnvironmentRef::Native, scope: SkillLocation::Global, @@ -1502,24 +2382,46 @@ mod tests { eve_targets: Vec::new(), }); let current_agent_ids = (!current_library_ids.is_empty()).then(|| agent_id.clone()); + let current_ids = current_library_ids.iter().collect::>(); + let checkpoint = libraries + .iter() + .filter(|library| current_ids.contains(&library.id)) + .flat_map(|library| { + library + .skills + .iter() + .chain(library.retired_skills.iter().map(|retired| &retired.member)) + .map(|member| LibraryMemberIdentity { + library_id: library.id.clone(), + member_name: member.name.clone(), + }) + }) + .collect::>() + .into_iter() + .collect(); + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries, + extra: serde_json::Map::new(), + }; let repository = Arc::new(MemoryApplicationRepository { record: Mutex::new(LibraryApplicationRecord { current: LibraryApplicationState { ordered_library_ids: current_library_ids, selected_agent_ids: current_agent_ids.into_iter().collect(), }, - ..LibraryApplicationRecord::empty(context.clone()) - }), - catalog: Mutex::new(LibraryCatalog { - schema_version: LIBRARY_SCHEMA_VERSION, - libraries, - extra: serde_json::Map::new(), + checkpoint: ReconciliationCheckpoint { + members: checkpoint, + }, + ..LibraryApplicationRecord::empty() }), + catalog: Mutex::new(catalog), + fail_next_checkpoint: AtomicBool::new(false), }); let executor = RecordingExecutor::default(); let resolve_calls = Arc::new(TargetResolveCalls::default()); let module = LibraryApplicationModule::new( - repository, + repository.clone(), facts, FixedTargets { primary_entry_kind, @@ -1534,7 +2436,7 @@ mod tests { ordered_library_ids: target_library_ids, selected_agent_ids: vec![agent_id], }; - (module, executor, draft, resolve_calls) + (module, executor, draft, resolve_calls, repository) } async fn applied_result( @@ -1561,20 +2463,18 @@ mod tests { current: &[&str], pending: Option<(&[&str], &[&str])>, ) -> LibraryApplicationRecord { - let context = SkillLocationRef { - environment: EnvironmentRef::Native, - scope: crate::environment::types::SkillLocation::Global, - }; let ids = |values: &[&str]| values.iter().map(|id| LibraryId::parse(*id)).collect(); LibraryApplicationRecord { schema_version: LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context, current: LibraryApplicationState { ordered_library_ids: ids(current), selected_agent_ids: Vec::new(), }, - pending_operation: pending.map(|(before, target)| PendingLibraryApplication { - operation_id: "operation".to_string(), + checkpoint: Default::default(), + pending: pending.map(|(before, target)| PendingReconciliation { + reconciliation_id: "reconciliation".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::ApplicationChanged], before_application: LibraryApplicationState { ordered_library_ids: ids(before), selected_agent_ids: Vec::new(), @@ -1583,7 +2483,9 @@ mod tests { ordered_library_ids: ids(target), selected_agent_ids: Vec::new(), }, - preview_fingerprint: "fingerprint".to_string(), + recognized_members: Vec::new(), + affected_members: Vec::new(), + target_members: Vec::new(), }), } } @@ -1607,7 +2509,7 @@ mod tests { } #[test] - fn a_library_leaving_in_a_pending_operation_still_counts_as_locked() { + fn a_library_leaving_in_a_pending_still_counts_as_locked() { // 目标状态已经不含该库,但操作尚未完成,成员仍需锁定。 let record = record_with(&[], Some((&["leaving"], &[]))); @@ -1659,7 +2561,111 @@ mod tests { } #[test] - fn resolves_one_library_and_rejects_an_empty_library() { + fn empty_application_uses_only_the_final_schema_fields() { + let value = serde_json::to_value(LibraryApplicationRecord::empty()).unwrap(); + + assert_eq!(value["schemaVersion"], LIBRARY_APPLICATION_SCHEMA_VERSION); + assert_eq!(value["current"]["orderedLibraryIds"], serde_json::json!([])); + assert_eq!(value["checkpoint"]["members"], serde_json::json!([])); + assert!(value["pending"].is_null()); + assert!(value.get("pendingOperation").is_none()); + } + + #[test] + fn sync_state_uses_recovery_unverified_pending_synced_priority() { + let mut record = record_with(&["library-a"], Some((&["library-a"], &["library-a"]))); + record.pending.as_mut().unwrap().attention = ReconciliationAttention::Unverified; + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: LibraryId::parse("library-a"), + name: "Library A".to_string(), + skills: Vec::new(), + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + assert_eq!( + summary(&record, &catalog, ScopeRecoveryState::Required) + .unwrap() + .sync_state, + LibraryApplicationSyncState::RecoveryRequired + ); + assert_eq!( + summary(&record, &catalog, ScopeRecoveryState::Clear) + .unwrap() + .sync_state, + LibraryApplicationSyncState::Unverified + ); + } + + #[test] + fn recovery_facts_override_persisted_pending_attention() { + let record = record_with(&["library-a"], Some((&["library-a"], &["library-a"]))); + + assert_eq!( + application_sync_state(&record, &[], ScopeRecoveryState::Required), + LibraryApplicationSyncState::RecoveryRequired + ); + } + + #[tokio::test] + async fn application_read_uses_the_recovery_status_source() { + let (module, _executor, draft) = application_fixture(TargetEntryKind::Missing, None); + let LibraryApplicationModule { + repository, + facts, + targets, + executor, + .. + } = module; + let module = LibraryApplicationModule::with_recovery_status( + repository, + facts, + targets, + executor, + Arc::new(FixedRecoveryStatus(ScopeRecoveryState::Required)), + ); + + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::RecoveryRequired + ); + } + + #[test] + fn summary_reports_membership_drift_without_a_pending_record() { + let library_id = LibraryId::parse("library-one"); + let record = LibraryApplicationRecord { + current: LibraryApplicationState { + ordered_library_ids: vec![library_id.clone()], + selected_agent_ids: Vec::new(), + }, + ..LibraryApplicationRecord::empty() + }; + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + assert!( + summary(&record, &catalog, ScopeRecoveryState::Clear) + .unwrap() + .pending + ); + } + + #[test] + fn resolves_members_and_keeps_an_empty_library_selectable() { let filled_id = LibraryId::parse("filled"); let empty_id = LibraryId::parse("empty"); let catalog = LibraryCatalog { @@ -1669,12 +2675,14 @@ mod tests { id: filled_id.clone(), name: "Backend".to_string(), skills: vec![skill("api-design")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: empty_id.clone(), name: "Empty".to_string(), skills: Vec::new(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1692,7 +2700,10 @@ mod tests { .library_id, filled_id ); - assert!(validated_library_ids(&catalog, &[empty_id]).is_err()); + assert_eq!( + validated_library_ids(&catalog, std::slice::from_ref(&empty_id)).unwrap(), + vec![empty_id] + ); } #[test] @@ -1706,12 +2717,14 @@ mod tests { id: first.clone(), name: "First".to_string(), skills: vec![skill("review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: second.clone(), name: "Second".to_string(), skills: vec![skill("review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1741,12 +2754,14 @@ mod tests { id: first.clone(), name: "First".to_string(), skills: vec![skill("CE:Review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: second.clone(), name: "Second".to_string(), skills: vec![skill("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1763,7 +2778,7 @@ mod tests { async fn switching_physical_aliases_uses_one_task_and_the_target_member() { let first = LibraryId::parse("first"); let second = LibraryId::parse("second"); - let (module, executor, draft, resolve_calls) = application_fixture_with( + let (module, executor, draft, resolve_calls, _) = application_fixture_with( TargetEntryKind::Missing, TargetEntryKind::Missing, None, @@ -1772,12 +2787,14 @@ mod tests { id: first.clone(), name: "First".to_string(), skills: vec![skill("CE:Review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: second.clone(), name: "Second".to_string(), skills: vec![skill("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1806,7 +2823,7 @@ mod tests { #[tokio::test] async fn multi_skill_application_resolves_candidates_and_placements_once() { let library_id = LibraryId::parse("library-one"); - let (module, _executor, draft, resolve_calls) = application_fixture_with( + let (module, _executor, draft, resolve_calls, _) = application_fixture_with( TargetEntryKind::Missing, TargetEntryKind::Missing, None, @@ -1814,6 +2831,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], Vec::new(), @@ -1833,11 +2851,7 @@ mod tests { #[test] fn library_application_preview_evidence_changes_with_the_catalog() { - let context = SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }; - let record = LibraryApplicationRecord::empty(context); + let record = LibraryApplicationRecord::empty(); let target = LibraryApplicationState::default(); let catalog = LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -1845,6 +2859,7 @@ mod tests { id: LibraryId::parse("lib-one"), name: "Library One".to_string(), skills: Vec::new(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -1884,55 +2899,380 @@ mod tests { } #[tokio::test] - async fn applying_a_library_replaces_a_broken_agent_link() { - let (module, executor, draft) = - application_fixture(TargetEntryKind::BrokenLink, Some("/missing/direct-skill")); - - let (plan, _response) = applied_result(&module, &executor, draft).await; - - assert_eq!(plan.units.len(), 1); - assert!(matches!( - plan.units[0] - .additional_entries - .first() - .map(|entry| &entry.action), - Some(PreparedEntryAction::Link { target }) - if target.native_path == format!("{TEST_LIBRARY_ROOT}/demo") - )); - } - - #[tokio::test] - async fn applying_a_library_preserves_a_valid_direct_agent_link() { - let (module, executor, draft) = - application_fixture(TargetEntryKind::Symlink, Some("/direct-skill/demo")); + async fn reapplying_an_unchanged_application_repairs_a_missing_library_link() { + let (module, executor, draft, _, _) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); let (plan, response) = applied_result(&module, &executor, draft).await; + assert_eq!(response.units.len(), 1); assert!(plan.units[0] - .additional_entries - .iter() - .all(|entry| entry.action == PreparedEntryAction::Keep)); - assert_eq!( - response.application.selected_agent_ids, - vec![AgentId::parse("private-agent").unwrap()] - ); + .primary_entry + .as_ref() + .is_some_and(|entry| { matches!(entry.action, PreparedEntryAction::Link { .. }) })); } #[tokio::test] - async fn applying_a_library_identifies_an_unsupported_agent_entry() { - let (module, _executor, draft) = application_fixture(TargetEntryKind::File, None); - - let error = module.preview(draft).await.unwrap_err(); - - assert!(matches!( - error, - AppError::SkillPlacementTargetConflict { - skill_name, - agent_ids, - target_path, - target_kind, - } if skill_name == "demo" - && agent_ids == vec![AgentId::parse("private-agent").unwrap()] + async fn verification_only_resume_does_not_repair_an_unconfirmed_directory_change() { + let (mut module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + { + let mut record = repository.record.lock().unwrap(); + record.pending = Some(PendingReconciliation { + reconciliation_id: "verification-only".to_string(), + attention: ReconciliationAttention::Unverified, + reasons: vec![ReconciliationReason::VerificationRequired], + before_application: record.current.clone(), + target_application: record.current.clone(), + recognized_members: record.checkpoint.members.clone(), + affected_members: Vec::new(), + target_members: record.checkpoint.members.clone(), + }); + } + module.facts.0.agent_runtime.agents.clear(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .expect("resume verification"); + + assert!(response.units.is_empty()); + assert!(!response.application.pending); + assert!(executor.0.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn removing_an_application_allows_a_saved_agent_that_now_reads_standard() { + let (mut module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + let agent = module + .facts + .0 + .agent_runtime + .agents + .values_mut() + .next() + .expect("saved Agent"); + agent.definition.global.reads_standard = true; + agent.global.reads_standard = true; + agent.global.standard_path = Some(TEST_SKILL_ROOT.to_string()); + agent.global.standard_presence = Some(DirectoryPresenceState::Present); + agent.global.read_paths.push(TEST_SKILL_ROOT.to_string()); + draft.ordered_library_ids.clear(); + draft.selected_agent_ids.clear(); + + let options = module.agent_options(draft.context.clone()).await.unwrap(); + assert_eq!( + options.selection.unavailable_explicit_agents[0].reason, + UnavailableAgentSelectionReason::OptionUnavailable + ); + + let (plan, response) = applied_result(&module, &executor, draft).await; + + assert!(response.application.ordered_libraries.is_empty()); + assert!(response.application.selected_agent_ids.is_empty()); + assert!(plan.units[0] + .additional_entries + .iter() + .any(|entry| entry.action == PreparedEntryAction::Remove)); + assert_eq!( + plan.units[0] + .primary_entry + .as_ref() + .map(|entry| &entry.action), + Some(&PreparedEntryAction::Keep) + ); + } + + #[tokio::test] + async fn removing_a_saved_agent_cleans_its_declared_legacy_path_after_it_reads_standard() { + let (mut module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + let agent = module + .facts + .0 + .agent_runtime + .agents + .values_mut() + .next() + .unwrap(); + agent.definition.global.reads_standard = true; + agent.definition.global.private_path = None; + agent.definition.legacy_paths.push(LegacyPath { + scope: LegacyPathScope::Global, + path: PathSpec::home(".legacy-agent/skills"), + behavior: LegacyPathBehavior::OfferMigration, + migration_target: LegacyMigrationTarget::StandardCanonical, + }); + agent.global.reads_standard = true; + agent.global.standard_path = Some(TEST_SKILL_ROOT.to_string()); + agent.global.private_path = None; + agent.global.standard_presence = Some(DirectoryPresenceState::Present); + agent.global.private_presence = None; + agent.global.read_paths = vec![TEST_SKILL_ROOT.to_string()]; + agent.global.legacy_paths.push(ResolvedPathPresence { + path: Some(TEST_LEGACY_ROOT.to_string()), + presence: DirectoryPresenceState::Present, + }); + draft.ordered_library_ids.clear(); + draft.selected_agent_ids.clear(); + + let (plan, _) = applied_result(&module, &executor, draft).await; + + let legacy_skill = locator(TEST_LEGACY_ROOT).join_child("demo"); + assert!(plan.units[0].additional_entries.iter().any(|entry| { + entry.destination == legacy_skill && entry.action == PreparedEntryAction::Remove + })); + } + + #[tokio::test] + async fn removing_an_application_drops_an_unknown_saved_agent_without_guessing_its_path() { + let (mut module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + draft.ordered_library_ids.clear(); + draft.selected_agent_ids.clear(); + + let (plan, response) = applied_result(&module, &executor, draft).await; + + assert!(response.application.ordered_libraries.is_empty()); + assert!(response.application.selected_agent_ids.is_empty()); + assert!(plan.units[0].additional_entries.is_empty()); + assert_eq!( + plan.units[0] + .primary_entry + .as_ref() + .map(|entry| &entry.action), + Some(&PreparedEntryAction::Keep) + ); + } + + #[tokio::test] + async fn an_unknown_saved_agent_can_be_carried_forward_without_becoming_a_new_selection() { + let (mut module, _executor, draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + + let preview = module + .preview(draft) + .await + .expect("an existing unavailable association can be retained"); + + assert_eq!(preview.target.selected_agent_ids.len(), 1); + assert_eq!( + preview.target.selected_agent_ids[0].as_str(), + "private-agent" + ); + } + + #[tokio::test] + async fn agent_options_use_a_saved_association_as_the_selection_baseline() { + let (module, _executor, draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + + let options = module.agent_options(draft.context).await.unwrap(); + let option = options.selection.install_options.first().unwrap(); + + assert_eq!( + options.selection.baseline_selected_option_ids, + vec![option.id.clone()] + ); + } + + #[tokio::test] + async fn agent_options_expose_an_unknown_saved_association() { + let (mut module, _executor, draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + + let options = module.agent_options(draft.context).await.unwrap(); + + assert_eq!(options.selection.unavailable_explicit_agents.len(), 1); + assert_eq!( + options.selection.unavailable_explicit_agents[0].agent_id, + "private-agent" + ); + assert_eq!( + options.selection.unavailable_explicit_agents[0].reason, + crate::application::agent_selection::UnavailableAgentSelectionReason::DefinitionMissing + ); + } + + #[tokio::test] + async fn an_unknown_agent_cannot_be_added_as_a_new_association() { + let (mut module, _executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + draft.selected_agent_ids = vec![AgentId::parse("new-agent").unwrap()]; + + let error = module.preview(draft).await.unwrap_err(); + + assert_eq!( + error, + AppError::InvalidAgent { + agent: "new-agent".to_string() + } + ); + } + + #[tokio::test] + async fn applying_a_library_replaces_a_broken_agent_link() { + let (module, executor, draft) = + application_fixture(TargetEntryKind::BrokenLink, Some("/missing/direct-skill")); + + let (plan, _response) = applied_result(&module, &executor, draft).await; + + assert_eq!(plan.units.len(), 1); + assert!(matches!( + plan.units[0] + .additional_entries + .first() + .map(|entry| &entry.action), + Some(PreparedEntryAction::Link { target }) + if target.native_path == format!("{TEST_LIBRARY_ROOT}/demo") + )); + } + + #[tokio::test] + async fn applying_a_library_preserves_a_valid_direct_agent_link() { + let (module, executor, draft) = + application_fixture(TargetEntryKind::Symlink, Some("/direct-skill/demo")); + + let (plan, response) = applied_result(&module, &executor, draft).await; + + assert!(plan.units[0] + .additional_entries + .iter() + .all(|entry| entry.action == PreparedEntryAction::Keep)); + assert_eq!( + response.application.selected_agent_ids, + vec![AgentId::parse("private-agent").unwrap()] + ); + } + + #[tokio::test] + async fn applying_a_library_identifies_an_unsupported_agent_entry() { + let (module, _executor, draft) = application_fixture(TargetEntryKind::File, None); + + let error = module.preview(draft).await.unwrap_err(); + + assert!(matches!( + error, + AppError::SkillPlacementTargetConflict { + skill_name, + agent_ids, + target_path, + target_kind, + } if skill_name == "demo" + && agent_ids == vec![AgentId::parse("private-agent").unwrap()] && target_path.ends_with("demo") && target_kind == crate::error::SkillPlacementTargetKind::File )); @@ -1946,9 +3286,10 @@ mod tests { id, name: name.to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }; - let (module, executor, draft, _) = application_fixture_with( + let (module, executor, draft, _, _) = application_fixture_with( TargetEntryKind::Directory, TargetEntryKind::Symlink, Some("/libraries/first/skills/demo"), @@ -1993,7 +3334,7 @@ mod tests { #[tokio::test] async fn removing_a_broken_agent_library_link_is_visible_when_canonical_is_direct() { let library_id = LibraryId::parse("lib-one"); - let (module, _executor, mut draft, _) = application_fixture_with( + let (module, _executor, mut draft, _, _) = application_fixture_with( TargetEntryKind::Directory, TargetEntryKind::BrokenLink, Some("/libraries/lib-one/skills/demo"), @@ -2001,6 +3342,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], vec![library_id], @@ -2017,7 +3359,7 @@ mod tests { #[tokio::test] async fn changing_only_library_agent_associations_reports_directory_changes() { let library_id = LibraryId::parse("lib-one"); - let (module, _executor, mut draft, _) = application_fixture_with( + let (module, _executor, mut draft, _, _) = application_fixture_with( TargetEntryKind::Directory, TargetEntryKind::Symlink, Some("/libraries/lib-one/skills/demo"), @@ -2025,6 +3367,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], vec![library_id.clone()], @@ -2040,19 +3383,846 @@ mod tests { assert_eq!(preview.changed_directory_skill_names, vec!["demo"]); } - #[test] - fn library_links_reject_non_native_storage_facts() { - let mut cross_storage = fact(TargetEntryKind::Missing, None); - cross_storage.storage_access = StorageAccess::CrossStorage; - assert!(matches!( - ensure_library_link_targets_supported(&[cross_storage]), - Err(AppError::CapabilityUnavailable { .. }) - )); - assert!( - ensure_library_link_targets_supported(&[fact(TargetEntryKind::Missing, None,)]).is_ok() + #[tokio::test] + async fn retired_checkpoint_member_is_recognized_for_link_removal_only() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: Vec::new(), + retired_skills: vec![RetiredLibrarySkillRecord { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + member: skill("demo"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }], + vec![library_id], + Vec::new(), + ); + draft.selected_agent_ids.clear(); + + let (plan, _) = applied_result(&module, &executor, draft).await; + + assert_eq!(plan.units.len(), 1); + assert!(plan.units[0] + .additional_entries + .iter() + .any(|entry| entry.action == PreparedEntryAction::Remove)); + } + + #[tokio::test] + async fn failed_checkpoint_commit_is_completed_by_a_keep_only_resume() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Directory, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + Vec::new(), + vec![library_id.clone()], + ); + repository + .fail_next_checkpoint + .store(true, Ordering::SeqCst); + let preview = module.preview(draft.clone()).await.unwrap(); + + let error = module + .apply( + ApplyLibraryApplicationRequest { + draft: draft.clone(), + expected_token: preview.token, + }, + CancellationSignal::default(), + ) + .await + .unwrap_err(); + + assert!(matches!(error, AppError::Io { .. })); + let pending = repository.record.lock().unwrap().clone(); + assert!(pending.pending.is_some()); + assert!(pending.checkpoint.members.is_empty()); + + module + .retry_pending(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + let completed = repository.record.lock().unwrap().clone(); + assert!(completed.pending.is_none()); + assert_eq!( + completed.checkpoint.members, + vec![LibraryMemberIdentity { + library_id, + member_name: "demo".to_string(), + }] + ); + let recorded = executor.0.lock().unwrap(); + let plan = recorded.as_ref().unwrap(); + assert!(plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .all(|entry| entry.action == PreparedEntryAction::Keep)); + } + + #[tokio::test] + async fn recovery_required_execution_remains_visible_after_reload() { + let (module, _executor, draft) = application_fixture(TargetEntryKind::Missing, None); + let LibraryApplicationModule { + repository, + facts, + targets, + .. + } = module; + let module = LibraryApplicationModule::with_recovery_status( + repository, + facts, + targets, + RecoveryExecutor, + Arc::new(FixedRecoveryStatus(ScopeRecoveryState::Required)), + ); + let preview = module.preview(draft.clone()).await.unwrap(); + + let response = module + .apply( + ApplyLibraryApplicationRequest { + draft: draft.clone(), + expected_token: preview.token, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!( + response.application.sync_state, + LibraryApplicationSyncState::RecoveryRequired + ); + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::RecoveryRequired + ); + } + + #[tokio::test] + async fn inaccessible_membership_drift_is_persisted_as_unverified() { + let library_id = LibraryId::parse("lib-one"); + let (module, _executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "demo", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Unverified, + ) + .await + .unwrap(); + + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::Unverified + ); + } + + #[tokio::test] + async fn inaccessible_synced_scope_does_not_create_an_actionable_pending() { + let library_id = LibraryId::parse("lib-one"); + let (module, _executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id], + ); + + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Unverified, + ) + .await + .unwrap(); + + assert!(repository.record.lock().unwrap().pending.is_none()); + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::Synced + ); + } + + #[tokio::test] + async fn membership_resume_rebuilds_pending_from_persisted_drift() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "demo", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + let record = repository.record.lock().unwrap().clone(); + assert!(record.pending.is_none()); + assert!(record.checkpoint.members.is_empty()); + let recorded = executor.0.lock().unwrap(); + let plan = recorded.as_ref().unwrap(); + assert!(plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .any(|entry| entry.action == PreparedEntryAction::Remove)); + } + + #[tokio::test] + async fn projected_catalog_reports_a_high_priority_member_switch_before_commit() { + let high = LibraryId::parse("high"); + let low = LibraryId::parse("low"); + let (module, _executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Symlink, + TargetEntryKind::Symlink, + Some("/libraries/low/skills/demo"), + vec![ + SkillLibraryRecord { + id: high.clone(), + name: "High".to_string(), + skills: Vec::new(), + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }, + SkillLibraryRecord { + id: low.clone(), + name: "Low".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }, + ], + vec![high.clone(), low.clone()], + vec![high.clone(), low], + ); + let mut projected = repository.catalog.lock().unwrap().clone(); + crate::application::library_membership::apply_membership_change( + &mut projected, + &high, + "demo", + crate::application::library_membership::MembershipChange::Upsert(skill("demo")), + ) + .unwrap(); + + let plan = module + .plan_resume_with_catalog(draft.context, projected) + .await + .unwrap(); + + assert_eq!(plan.preview.unwrap().switched_skill_names, vec!["demo"]); + } + + #[test] + fn library_links_reject_non_native_storage_facts() { + let mut cross_storage = fact(TargetEntryKind::Missing, None); + cross_storage.storage_access = StorageAccess::CrossStorage; + assert!(matches!( + ensure_library_link_targets_supported(&[cross_storage]), + Err(AppError::CapabilityUnavailable { .. }) + )); + assert!( + ensure_library_link_targets_supported(&[fact(TargetEntryKind::Missing, None,)]).is_ok() + ); + } + + #[test] + fn pending_merge_preserves_identity_and_expands_recognized_members() { + let original = PendingReconciliation { + reconciliation_id: "reconciliation-1".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::ApplicationChanged], + before_application: LibraryApplicationState::default(), + target_application: LibraryApplicationState::default(), + recognized_members: vec![member("library-a", "alpha")], + affected_members: Vec::new(), + target_members: vec![member("library-a", "alpha")], + }; + let latest_target = LibraryApplicationState { + ordered_library_ids: vec![LibraryId::parse("library-b")], + selected_agent_ids: Vec::new(), + }; + + let merged = original.merged_with( + vec![ReconciliationReason::MembershipChanged], + latest_target.clone(), + vec![member("library-a", "beta")], + vec![member("library-b", "gamma")], + ); + + assert_eq!(merged.reconciliation_id, "reconciliation-1"); + assert_eq!( + merged.reasons, + vec![ + ReconciliationReason::ApplicationChanged, + ReconciliationReason::MembershipChanged, + ] + ); + assert_eq!( + merged.recognized_members, + vec![ + member("library-a", "alpha"), + member("library-a", "beta"), + member("library-b", "gamma"), + ] + ); + assert_eq!(merged.target_application, latest_target); + assert_eq!(merged.target_members, vec![member("library-b", "gamma")]); + } + + #[test] + fn membership_reconciliation_only_targets_changed_skill_directories() { + let checkpoint = ReconciliationCheckpoint { + members: vec![member("lib-one", "alpha"), member("lib-one", "shared")], + }; + let target = vec![ + member("lib-one", "alpha"), + member("lib-two", "shared"), + member("lib-one", "beta"), + ]; + + let changed = membership_changed_skill_directories(&checkpoint, None, &target).unwrap(); + + assert_eq!( + changed, + BTreeSet::from([ + SkillDirectoryName::try_from("beta").unwrap(), + SkillDirectoryName::try_from("shared").unwrap(), + ]) + ); + } + + #[tokio::test] + async fn projected_member_addition_does_not_repair_an_unrelated_missing_skill() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + let projected = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + let plan = module + .plan_resume_with_catalog(draft.context, projected) + .await + .expect("plan member propagation"); + let preview = plan.preview.expect("member preview"); + + assert_eq!(preview.added_skill_names, vec!["beta"]); + assert_eq!(plan.entries.len(), 1); + } + + #[tokio::test] + async fn reapply_with_membership_drift_retains_its_full_scope_after_failure() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, mut draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id], + ); + module.facts.0.agent_runtime.agents.clear(); + draft.selected_agent_ids.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + repository.catalog.lock().unwrap().libraries[0] + .skills + .push(skill("beta")); + repository + .fail_next_checkpoint + .store(true, Ordering::SeqCst); + let preview = module.preview(draft.clone()).await.unwrap(); + + let result = module + .apply( + ApplyLibraryApplicationRequest { + draft: draft.clone(), + expected_token: preview.token, + }, + CancellationSignal::default(), + ) + .await; + assert!(matches!(result, Err(AppError::Io { .. }))); + + let resumed = module + .retry_pending(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert_eq!( + resumed + .units + .iter() + .map(|unit| unit.skill_name.as_str()) + .collect::>(), + vec!["alpha", "beta"] + ); + assert!(!resumed.application.pending); + } + + #[tokio::test] + async fn projected_member_addition_preserves_a_confirmed_reapply_scope() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + { + let mut record = repository.record.lock().unwrap(); + record.current.selected_agent_ids.clear(); + record.pending = Some(PendingReconciliation { + reconciliation_id: "reapply-alpha".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::ReapplyRequested], + before_application: record.current.clone(), + target_application: record.current.clone(), + recognized_members: record.checkpoint.members.clone(), + affected_members: Vec::new(), + target_members: record.checkpoint.members.clone(), + }); + } + let projected = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + let plan = module + .plan_resume_with_catalog(draft.context, projected) + .await + .expect("plan membership and confirmed reapply"); + + assert_eq!(plan.entries.len(), 2); + assert_eq!( + plan.preview.unwrap().changed_directory_skill_names, + vec!["alpha", "beta"] ); } + #[test] + fn reconciliation_scope_is_checkpoint_pending_and_desired_union() { + let checkpoint = ReconciliationCheckpoint { + members: vec![member("library-a", "old")], + }; + let pending = PendingReconciliation { + reconciliation_id: "reconciliation-1".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::MembershipChanged], + before_application: LibraryApplicationState::default(), + target_application: LibraryApplicationState::default(), + recognized_members: vec![member("library-a", "pending")], + affected_members: vec![member("library-a", "pending")], + target_members: Vec::new(), + }; + + assert_eq!( + reconciliation_members( + &checkpoint, + Some(&pending), + &[member("library-b", "desired")], + ), + vec![ + member("library-a", "old"), + member("library-a", "pending"), + member("library-b", "desired"), + ] + ); + } + + #[tokio::test] + async fn membership_resume_cleans_a_published_member_retired_before_checkpoint() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/beta"), + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + repository.catalog.lock().unwrap().libraries[0] + .skills + .extend([skill("beta"), skill("gamma")]); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retire-beta", + ), + retired_at: "2026-09-07T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert!(!response.application.pending); + let plan = executor.0.lock().unwrap(); + let beta_path = locator(TEST_AGENT_ROOT).join_child("beta"); + assert!( + plan.as_ref().unwrap().units.iter().any(|unit| { + unit.skill_name == "beta" + && unit.additional_entries.iter().any(|entry| { + entry.destination == beta_path + && entry.action == PreparedEntryAction::Remove + }) + }), + "the published beta link must be removed before its pending references are released" + ); + } + + #[tokio::test] + async fn net_zero_membership_resume_preserves_an_unrelated_missing_link() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + repository.catalog.lock().unwrap().libraries[0] + .skills + .push(skill("beta")); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retire-beta", + ), + retired_at: "2026-09-07T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert!(!response.application.pending); + assert!(response.units.iter().all(|unit| unit.skill_name != "alpha")); + } + + fn member(library_id: &str, member_name: &str) -> LibraryMemberIdentity { + LibraryMemberIdentity { + library_id: LibraryId::parse(library_id), + member_name: member_name.to_string(), + } + } + + #[tokio::test] + async fn membership_resume_remembers_a_member_removed_and_readded_before_completion() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retire-beta", + ), + retired_at: "2026-09-07T00:00:00Z".to_string(), + }, + ) + .unwrap(); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Upsert(skill("beta")), + ) + .unwrap(); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert!(!response.application.pending); + assert_eq!( + response + .units + .iter() + .map(|unit| unit.skill_name.as_str()) + .collect::>(), + vec!["beta"] + ); + } + + #[tokio::test] + async fn synced_scope_background_resume_does_not_access_the_filesystem() { + struct UnavailableFacts; + + impl ScopePlanningSnapshotSource for UnavailableFacts { + fn snapshot<'a>( + &'a self, + _context: &'a SkillLocationRef, + ) -> InstallFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleEnvironment) }) + } + } + + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id], + ); + let module = LibraryApplicationModule::new( + repository.clone(), + UnavailableFacts, + module.targets, + executor.clone(), + ); + + let plan = module.plan_resume(draft.context.clone()).await.unwrap(); + assert!(plan.entries.is_empty()); + assert!(plan.preview.is_none()); + let response = module + .resume(draft.context.clone(), CancellationSignal::default()) + .await + .unwrap(); + assert_eq!( + response.application.sync_state, + LibraryApplicationSyncState::Synced + ); + assert!(response.units.is_empty()); + assert!(executor.0.lock().unwrap().is_none()); + + let mut projected = repository.catalog.lock().unwrap().clone(); + assert!(module + .plan_resume_with_catalog(draft.context.clone(), projected.clone()) + .await + .unwrap() + .entries + .is_empty()); + projected.libraries[0].skills.push(skill("beta")); + assert!(matches!( + module + .plan_resume_with_catalog(draft.context, projected) + .await, + Err(AppError::StaleEnvironment) + )); + } + fn skill(name: &str) -> LibrarySkillRecord { LibrarySkillRecord { name: name.to_string(), @@ -2101,10 +4271,21 @@ mod tests { fn physical_key(name: &str) -> PhysicalTargetKey { PhysicalTargetKey { - backend: ExecutionBackend::NativeUnix, - physical_parent: PhysicalParentIdentity::Unix { - device: 1, - inode: 2, + backend: if cfg!(windows) { + ExecutionBackend::NativeWindows + } else { + ExecutionBackend::NativeUnix + }, + physical_parent: if cfg!(windows) { + PhysicalParentIdentity::Windows { + volume_serial: 1, + file_id: 2, + } + } else { + PhysicalParentIdentity::Unix { + device: 1, + inode: 2, + } }, normalized_final_child_name: name.to_string(), } diff --git a/src-tauri/src/application/library_candidates.rs b/src-tauri/src/application/library_candidates.rs index 97d3514d..642d7131 100644 --- a/src-tauri/src/application/library_candidates.rs +++ b/src-tauri/src/application/library_candidates.rs @@ -4,10 +4,13 @@ use std::pin::Pin; use std::sync::Arc; use crate::application::installed_skill_resolver::SkillDirectoryName; -use crate::application::library_application::LibraryApplicationRepository; -use crate::application::skill_libraries::{LibraryCatalog, LibraryId}; +use crate::application::library_application::{LibraryApplicationBackend, LibraryMemberIdentity}; +use crate::application::skill_libraries::{LibraryCatalog, LibraryId, SkillLibraryRepository}; use crate::core::agent_definition::AgentId; -use crate::environment::planning::{ResolvedTargetFact, TargetFactResolver}; +use crate::environment::planning::{ + ResolvedLinkTargetIdentity, ResolvedTargetFact, TargetFactResolver, +}; +use crate::environment::runtime::PhysicalTargetKey; use crate::environment::types::{ same_environment_identity, EnvironmentRef, ResourceLocator, SkillLocationRef, }; @@ -18,6 +21,7 @@ pub(crate) struct LibraryVersionCandidate { library_id: LibraryId, member_name: String, physical_locator: ResourceLocator, + physical_key: PhysicalTargetKey, } impl LibraryVersionCandidate { @@ -26,11 +30,13 @@ impl LibraryVersionCandidate { library_id: LibraryId, member_name: impl Into, locator: ResourceLocator, + physical_key: PhysicalTargetKey, ) -> Self { Self { library_id, member_name: member_name.into(), physical_locator: locator, + physical_key, } } @@ -39,6 +45,7 @@ impl LibraryVersionCandidate { library_id: member.library_id.clone(), member_name: member.member_name.clone(), physical_locator: fact.destination, + physical_key: fact.key, } } @@ -53,6 +60,21 @@ impl LibraryVersionCandidate { pub(crate) fn locator(&self) -> &ResourceLocator { &self.physical_locator } + + pub(crate) fn matches_target(&self, target: &ResolvedTargetFact) -> bool { + use crate::environment::planning::TargetEntryKind; + + match target.entry_kind { + TargetEntryKind::Directory => target.key == self.physical_key, + TargetEntryKind::Symlink | TargetEntryKind::Junction | TargetEntryKind::BrokenLink => { + target + .link_target_identity + .as_ref() + .is_some_and(|identity| identity.matches(&self.physical_locator)) + } + _ => false, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -78,27 +100,29 @@ pub(crate) enum LibraryCandidateError { UnknownLibrary { library_id: LibraryId, }, + UnknownCatalogMember { + library_id: LibraryId, + member_name: String, + }, DuplicateOrderedCandidate, OrderedCandidateNotRecognized, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) struct LibraryCatalogMember { - pub(crate) library_id: LibraryId, - pub(crate) member_name: String, -} +pub(crate) type LibraryCatalogMember = LibraryMemberIdentity; #[derive(Debug, Clone)] pub(crate) struct LibraryCatalogMemberIndex { library_ids: BTreeSet, - by_directory_name: BTreeMap>, + active_by_directory_name: BTreeMap>, + all_by_identity: BTreeMap, } impl LibraryCatalogMemberIndex { pub(crate) fn build(catalog: &LibraryCatalog) -> Result { let mut library_ids = BTreeSet::new(); - let mut by_directory_name = + let mut active_by_directory_name = BTreeMap::>::new(); + let mut all_by_identity = BTreeMap::new(); for library in &catalog.libraries { library_ids.insert(library.id.clone()); let mut members_in_library = BTreeMap::::new(); @@ -119,21 +143,65 @@ impl LibraryCatalogMemberIndex { duplicate_member_name: member.name.clone(), }); } - by_directory_name + let identity = LibraryMemberIdentity { + library_id: library.id.clone(), + member_name: member.name.clone(), + }; + all_by_identity.insert(identity.clone(), directory_name.clone()); + active_by_directory_name .entry(directory_name) .or_default() - .push(LibraryCatalogMember { + .push(identity); + } + for retired in &library.retired_skills { + let member = &retired.member; + let directory_name = + SkillDirectoryName::try_from(member.name.as_str()).map_err(|_| { + LibraryCandidateError::InvalidCatalogMemberName { + library_id: library.id.clone(), + member_name: member.name.clone(), + } + })?; + if let Some(first_member_name) = + members_in_library.insert(directory_name.clone(), member.name.clone()) + { + return Err(LibraryCandidateError::DuplicateCatalogMemberDirectory { library_id: library.id.clone(), - member_name: member.name.clone(), + first_member_name, + duplicate_member_name: member.name.clone(), }); + } + all_by_identity.insert( + LibraryMemberIdentity { + library_id: library.id.clone(), + member_name: member.name.clone(), + }, + directory_name, + ); } } Ok(Self { library_ids, - by_directory_name, + active_by_directory_name, + all_by_identity, }) } + pub(crate) fn contains_identity(&self, member: &LibraryMemberIdentity) -> bool { + self.all_by_identity.contains_key(member) + } + + pub(crate) fn known_members_for( + &self, + skills: &BTreeSet, + ) -> Vec { + self.all_by_identity + .iter() + .filter(|(_, name)| skills.contains(*name)) + .map(|(member, _)| member.clone()) + .collect() + } + pub(crate) fn members_for( &self, ordered_library_ids: &[LibraryId], @@ -146,7 +214,7 @@ impl LibraryCatalogMemberIndex { library_id: library_id.clone(), }); } - for (directory_name, members) in &self.by_directory_name { + for (directory_name, members) in &self.active_by_directory_name { if let Some(member) = members .iter() .find(|member| &member.library_id == library_id) @@ -160,6 +228,27 @@ impl LibraryCatalogMemberIndex { } Ok(grouped) } + + pub(crate) fn recognized_members( + &self, + members: &[LibraryMemberIdentity], + ) -> Result>, LibraryCandidateError> + { + let mut grouped = BTreeMap::>::new(); + for member in members { + let directory_name = self.all_by_identity.get(member).ok_or_else(|| { + LibraryCandidateError::UnknownCatalogMember { + library_id: member.library_id.clone(), + member_name: member.member_name.clone(), + } + })?; + grouped + .entry(directory_name.clone()) + .or_default() + .push(member.clone()); + } + Ok(grouped) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -309,12 +398,12 @@ pub(crate) trait LibraryCandidateSource: Send + Sync { } pub(crate) struct RepositoryLibraryCandidateSource { - repository: Arc, + repository: Arc, targets: Arc, } impl RepositoryLibraryCandidateSource { - pub(crate) fn new(repository: Arc, targets: T) -> Self + pub(crate) fn new(repository: Arc, targets: T) -> Self where T: TargetFactResolver + 'static, { @@ -349,7 +438,7 @@ impl LibraryCandidateSource for RepositoryLibraryCandidateSource { return Ok(Vec::new()); } let record = self.repository.load_application(context).await?; - if record.pending_operation.is_some() { + if record.pending.is_some() { return Err(AppError::MutationBusy); } let catalog = self.repository.load_catalog(context).await?; @@ -360,11 +449,24 @@ impl LibraryCandidateSource for RepositoryLibraryCandidateSource { let grouped = index .members_for(&record.current.ordered_library_ids) .map_err(candidate_configuration_error)?; + let desired_members = grouped + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + if record.checkpoint.members != desired_members { + return Err(AppError::MutationBusy); + } let member_groups = skills .iter() .map(|skill| grouped.get(skill).cloned().unwrap_or_default()) .collect::>(); - let members = member_groups.iter().flatten().cloned().collect::>(); + let members = index.known_members_for(&skills.iter().cloned().collect()); + let recognized = index + .recognized_members(&members) + .map_err(candidate_configuration_error)?; let candidate_index = ResolvedLibraryCandidateIndex::load( self.repository.as_ref(), self.targets.as_ref(), @@ -376,12 +478,15 @@ impl LibraryCandidateSource for RepositoryLibraryCandidateSource { .iter() .zip(member_groups) .map(|(skill, members)| { - let candidates = candidate_index.candidates_for(&members)?; + let ordered = candidate_index.candidates_for(&members)?; + let recognized = candidate_index.candidates_for( + recognized.get(skill).map(Vec::as_slice).unwrap_or_default(), + )?; let candidates = LibraryCandidateSet::for_skill( &context.environment, skill, - candidates.clone(), - candidates, + recognized, + ordered, ) .map_err(candidate_configuration_error)?; LibraryCandidateSnapshot::new( @@ -398,11 +503,116 @@ impl LibraryCandidateSource for RepositoryLibraryCandidateSource { pub(crate) struct ResolvedLibraryCandidateIndex { by_member: BTreeMap, + members_by_skill: BTreeMap>, + roots_by_library: BTreeMap, } impl ResolvedLibraryCandidateIndex { + pub(crate) async fn load_known( + repository: &dyn SkillLibraryRepository, + targets: &T, + environment: &EnvironmentRef, + skills: &BTreeSet, + ) -> Result { + if skills.is_empty() { + return Ok(Self { + by_member: BTreeMap::new(), + members_by_skill: BTreeMap::new(), + roots_by_library: BTreeMap::new(), + }); + } + let catalog = repository.load(environment).await?; + Self::from_catalog(repository, targets, environment, skills, &catalog).await + } + + pub(crate) async fn from_catalog( + repository: &dyn SkillLibraryRepository, + targets: &T, + environment: &EnvironmentRef, + skills: &BTreeSet, + catalog: &LibraryCatalog, + ) -> Result { + let index = + LibraryCatalogMemberIndex::build(catalog).map_err(candidate_configuration_error)?; + let members = index.known_members_for(skills); + let mut roots = BTreeMap::new(); + let mut destinations = Vec::with_capacity(members.len()); + for member in &members { + let root = match roots.get(&member.library_id) { + Some(root) => root, + None => { + let root = repository + .resolve_collection(environment, &member.library_id) + .await? + .root; + roots.entry(member.library_id.clone()).or_insert(root) + } + }; + let name = SkillDirectoryName::try_from(member.member_name.as_str())?; + destinations.push(root.join_child(name.as_ref())); + } + let resolved = if destinations.is_empty() { + Vec::new() + } else { + targets + .resolve_environment(environment, &destinations, None) + .await? + }; + if resolved.len() != members.len() { + return Err(AppError::StaleTarget); + } + let members_by_skill = index + .recognized_members(&members) + .map_err(candidate_configuration_error)?; + Ok(Self { + by_member: members + .into_iter() + .zip(resolved) + .map(|(member, fact)| { + let candidate = LibraryVersionCandidate::from_resolved_fact(&member, fact); + (member, candidate) + }) + .collect(), + members_by_skill, + roots_by_library: roots, + }) + } + + pub(crate) fn members_for_root_link<'a>( + &'a self, + identity: &ResolvedLinkTargetIdentity, + skills: &BTreeSet, + ) -> Vec<&'a LibraryVersionCandidate> { + let Some(library_id) = self + .roots_by_library + .iter() + .find_map(|(library_id, root)| identity.matches(root).then_some(library_id)) + else { + return Vec::new(); + }; + skills + .iter() + .filter_map(|skill| self.members_by_skill.get(skill)) + .flatten() + .filter_map(|member| self.by_member.get(member)) + .filter(|candidate| candidate.library_id() == library_id) + .collect() + } + + pub(crate) fn owner( + &self, + skill: &SkillDirectoryName, + target: &ResolvedTargetFact, + ) -> Option<&LibraryVersionCandidate> { + self.members_by_skill + .get(skill)? + .iter() + .filter_map(|member| self.by_member.get(member)) + .find(|candidate| candidate.matches_target(target)) + } + pub(crate) async fn load( - repository: &dyn LibraryApplicationRepository, + repository: &dyn LibraryApplicationBackend, targets: &T, context: &SkillLocationRef, members: &[LibraryCatalogMember], @@ -424,6 +634,14 @@ impl ResolvedLibraryCandidateIndex { if resolved.len() != members.len() { return Err(AppError::StaleTarget); } + let members_by_skill = members.iter().try_fold( + BTreeMap::>::new(), + |mut grouped, member| { + let skill = SkillDirectoryName::try_from(member.member_name.as_str())?; + grouped.entry(skill).or_default().push(member.clone()); + Ok::<_, AppError>(grouped) + }, + )?; Ok(Self { by_member: members .into_iter() @@ -433,6 +651,8 @@ impl ResolvedLibraryCandidateIndex { (member, candidate) }) .collect(), + members_by_skill, + roots_by_library: BTreeMap::new(), }) } @@ -488,11 +708,13 @@ mod tests { use super::*; use crate::application::installed_skill_resolver::SkillDirectoryName; use crate::application::library_application::{ - LibraryApplicationFuture, LibraryApplicationRecord, LibraryApplicationRepository, - LibraryApplicationState, PendingLibraryApplication, + ApplicationInventory, ApplicationRegistry, LibraryApplicationFuture, + LibraryApplicationRecord, LibraryApplicationResources, LibraryApplicationState, + PendingReconciliation, VersionedApplicationRecord, }; use crate::application::skill_libraries::{ - LibraryCatalog, LibraryId, LibrarySkillRecord, SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, + LibraryCatalog, LibraryId, LibrarySkillRecord, RetiredLibrarySkillRecord, + SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, }; use crate::core::agent_definition::AgentId; use crate::environment::planning::{RuntimeTargetFactResolver, TargetFactFuture}; @@ -505,6 +727,32 @@ mod tests { RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())) } + fn candidate_key(name: &str) -> PhysicalTargetKey { + use crate::environment::runtime::{ExecutionBackend, PhysicalParentIdentity}; + PhysicalTargetKey { + backend: if cfg!(windows) { + ExecutionBackend::NativeWindows + } else { + ExecutionBackend::NativeUnix + }, + physical_parent: if cfg!(windows) { + PhysicalParentIdentity::Windows { + volume_serial: 1, + file_id: 10, + } + } else { + PhysicalParentIdentity::Unix { + device: 1, + inode: 10, + } + }, + normalized_final_child_name: SkillDirectoryName::try_from(name) + .unwrap() + .as_ref() + .to_string(), + } + } + #[derive(Clone)] struct CountingTargets { inner: RuntimeTargetFactResolver, @@ -535,6 +783,7 @@ mod tests { environment: native.clone(), native_path: "/libraries/library-one/skills/other".to_string(), }, + candidate_key("other"), ); assert!(matches!( LibraryCandidateSet::for_skill(&native, &skill, vec![wrong_skill], Vec::new(),), @@ -550,6 +799,7 @@ mod tests { }, native_path: "/libraries/library-one/skills/demo".to_string(), }, + candidate_key("demo"), ); assert!(matches!( LibraryCandidateSet::for_skill(&native, &skill, vec![wrong_environment], Vec::new(),), @@ -566,6 +816,7 @@ mod tests { environment: EnvironmentRef::Native, native_path: "/libraries/first/skills/demo".to_string(), }, + candidate_key("demo"), ); let second = LibraryVersionCandidate::new( LibraryId::parse("second"), @@ -574,6 +825,7 @@ mod tests { environment: EnvironmentRef::Native, native_path: "/libraries/second/skills/demo".to_string(), }, + candidate_key("demo"), ); assert_eq!( @@ -597,6 +849,7 @@ mod tests { environment: EnvironmentRef::Native, native_path: "/libraries/library-one/skills/CE:Review".to_string(), }, + candidate_key("CE:Review"), ); let second = LibraryVersionCandidate::new( library_id, @@ -605,6 +858,7 @@ mod tests { environment: EnvironmentRef::Native, native_path: "/libraries/library-one/skills/ce-review".to_string(), }, + candidate_key("ce-review"), ); assert_eq!( @@ -637,6 +891,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill_record("CE:Review"), skill_record("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -654,6 +909,62 @@ mod tests { )); } + #[test] + fn retired_members_are_recognized_but_never_ordered() { + let library_id = LibraryId::parse("library-one"); + let retired = LibraryMemberIdentity { + library_id: library_id.clone(), + member_name: "demo".to_string(), + }; + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: Vec::new(), + retired_skills: vec![RetiredLibrarySkillRecord { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + member: LibrarySkillRecord { + name: "demo".to_string(), + description: String::new(), + source_record: serde_json::json!({}), + content_manifest_hash: "manifest".to_string(), + updated_at: None, + extra: serde_json::Map::new(), + }, + retired_at: "2026-09-06T00:00:00Z".to_string(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + let index = LibraryCatalogMemberIndex::build(&catalog).unwrap(); + + assert_eq!( + index.known_members_for(&BTreeSet::from([ + SkillDirectoryName::try_from("demo").unwrap() + ])), + vec![retired.clone()], + ); + + assert!(index + .members_for(std::slice::from_ref(&library_id)) + .unwrap() + .is_empty()); + let recognized = index + .recognized_members(std::slice::from_ref(&retired)) + .unwrap(); + assert_eq!( + recognized + .get(&SkillDirectoryName::try_from("demo").unwrap()) + .unwrap(), + &[retired] + ); + } + struct MemoryRepository { record: LibraryApplicationRecord, catalog: LibraryCatalog, @@ -661,21 +972,53 @@ mod tests { locator_requests: Mutex>, } - impl LibraryApplicationRepository for MemoryRepository { + impl ApplicationRegistry for MemoryRepository { fn load_application<'a>( &'a self, - _context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { Ok(self.record.clone()) }) + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + context.clone(), + self.record.clone(), + )) + }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, - _record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) + observed: &'a VersionedApplicationRecord, + record: &'a LibraryApplicationRecord, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + observed.context.clone(), + record.clone(), + )) + }) + } + + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(ApplicationInventory { + records: vec![VersionedApplicationRecord::in_memory( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + self.record.clone(), + )], + problems: Vec::new(), + complete: true, + }) + }) } + } + impl LibraryApplicationResources for MemoryRepository { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -713,9 +1056,9 @@ mod tests { Box::pin(async move { Ok(self.catalog.clone()) }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, + _observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async { Ok(()) }) } @@ -812,18 +1155,30 @@ mod tests { id, name: name.to_string(), skills: vec![skill_record(name)], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }; let repository = Arc::new(MemoryRepository { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: LibraryApplicationState { ordered_library_ids: vec![first_id.clone(), second_id.clone()], selected_agent_ids: vec![agent("cursor"), agent("codex")], }, - pending_operation: None, + checkpoint: crate::application::library_application::ReconciliationCheckpoint { + members: vec![ + LibraryMemberIdentity { + library_id: first_id.clone(), + member_name: "CE:Review".to_string(), + }, + LibraryMemberIdentity { + library_id: second_id.clone(), + member_name: "ce-review".to_string(), + }, + ], + }, + pending: None, }, catalog: LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -895,12 +1250,20 @@ mod tests { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: LibraryApplicationState { ordered_library_ids: vec![library_id.clone()], selected_agent_ids: Vec::new(), }, - pending_operation: None, + checkpoint: crate::application::library_application::ReconciliationCheckpoint { + members: ["alpha", "beta"] + .into_iter() + .map(|member_name| LibraryMemberIdentity { + library_id: library_id.clone(), + member_name: member_name.to_string(), + }) + .collect(), + }, + pending: None, }, catalog: LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -918,6 +1281,7 @@ mod tests { extra: serde_json::Map::new(), }) .collect(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -944,6 +1308,43 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn repository_source_recognizes_unapplied_and_retired_members() { + let temp = tempfile::tempdir().unwrap(); + for library in ["active", "retired"] { + std::fs::create_dir_all(temp.path().join(library).join("skills/demo")).unwrap(); + } + let catalog = serde_json::from_value(serde_json::json!({ + "schemaVersion": LIBRARY_SCHEMA_VERSION, + "libraries": [ + {"id":"active", "name":"Active", "skills":[{ + "name":"demo", "description":"Demo", "sourceRecord":{}, "contentManifestHash":"old" + }], "retiredSkills":[]}, + {"id":"retired", "name":"Retired", "skills":[], "retiredSkills":[{ + "retirementId":"retirement-one", "retiredAt":"2026-09-14T00:00:00Z", + "member":{"name":"demo", "description":"Demo", "sourceRecord":{}, "contentManifestHash":"old"} + }]} + ] + })).unwrap(); + let repository = Arc::new(MemoryRepository { + record: LibraryApplicationRecord::empty(), + catalog, + locator_root: Some(temp.path().to_path_buf()), + locator_requests: Mutex::new(Vec::new()), + }); + let source = RepositoryLibraryCandidateSource::new(repository, targets()); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + let snapshot = source + .load_candidates(&context, &SkillDirectoryName::try_from("demo").unwrap()) + .await + .unwrap(); + assert_eq!(snapshot.candidates().recognized().len(), 2); + assert!(snapshot.candidates().ordered().is_empty()); + } + #[tokio::test] async fn repository_source_rejects_one_library_with_two_names_for_the_same_directory() { let context = SkillLocationRef { @@ -963,12 +1364,12 @@ mod tests { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: LibraryApplicationState { ordered_library_ids: vec![library_id.clone()], selected_agent_ids: Vec::new(), }, - pending_operation: None, + checkpoint: Default::default(), + pending: None, }, catalog: LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -976,6 +1377,7 @@ mod tests { id: library_id, name: "Library One".to_string(), skills: vec![skill_record("CE:Review"), skill_record("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -1009,13 +1411,19 @@ mod tests { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: current.clone(), - pending_operation: Some(PendingLibraryApplication { - operation_id: "operation-pending".to_string(), + checkpoint: Default::default(), + pending: Some(PendingReconciliation { + reconciliation_id: "reconciliation-pending".to_string(), + attention: crate::application::library_application::ReconciliationAttention::Pending, + reasons: vec![ + crate::application::library_application::ReconciliationReason::ApplicationChanged, + ], before_application: current.clone(), target_application: current, - preview_fingerprint: "preview-pending".to_string(), + recognized_members: Vec::new(), + affected_members: Vec::new(), + target_members: Vec::new(), }), }, catalog: LibraryCatalog { @@ -1035,6 +1443,52 @@ mod tests { assert!(matches!(result, Err(AppError::MutationBusy))); } + #[tokio::test] + async fn repository_source_rejects_an_unreconciled_checkpoint() { + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + let library_id = LibraryId::parse("library-one"); + let repository = Arc::new(MemoryRepository { + record: LibraryApplicationRecord { + current: LibraryApplicationState { + ordered_library_ids: vec![library_id.clone()], + selected_agent_ids: Vec::new(), + }, + ..LibraryApplicationRecord::empty() + }, + catalog: LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![LibrarySkillRecord { + name: "demo".to_string(), + description: String::new(), + source_record: serde_json::json!({}), + content_manifest_hash: "manifest".to_string(), + updated_at: None, + extra: serde_json::Map::new(), + }], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }, + locator_root: None, + locator_requests: Mutex::new(Vec::new()), + }); + let source = RepositoryLibraryCandidateSource::new(repository, targets()); + + assert!(matches!( + source + .load_candidates(&context, &SkillDirectoryName::try_from("demo").unwrap()) + .await, + Err(AppError::MutationBusy) + )); + } + #[tokio::test] async fn empty_source_returns_deterministic_empty_evidence() { let source = EmptyLibraryCandidateSource; diff --git a/src-tauri/src/application/library_membership.rs b/src-tauri/src/application/library_membership.rs new file mode 100644 index 00000000..cea4dedb --- /dev/null +++ b/src-tauri/src/application/library_membership.rs @@ -0,0 +1,1625 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +mod state; + +use crate::application::library_application::{ + ApplicationInventory, ApplicationRegistry, LibraryApplicationModule, + LibraryApplicationResponse, LibraryApplicationScopePlan, LibraryApplicationSyncState, + ReconciliationAttention, +}; +use crate::application::mutation::executor::MutationPlanExecutor; +use crate::application::payload_session::PayloadSessionManager; +use crate::application::planning_facts::ScopePlanningSnapshotSource; +use crate::application::runtime_admission::RuntimeAdmissionCoordinator; +use crate::application::scope_skill_planning::ScopeSkillPlanner; +use crate::application::skill_libraries::{ + ExecuteAddLibrarySkillsRequest, ExecuteRetireLibrarySkillRequest, LibraryAddPreview, + LibraryAddResponse, LibraryCatalog, LibraryId, LibraryRetirePreview, LibraryRetireResponse, + PreviewAddLibrarySkillsRequest, PurgeRetiredLibraryMemberRequest, RemoveLibrarySkillRequest, + SkillLibraryModule, SkillLibraryRepository, +}; +use crate::core::mutation::CancellationSignal; +use crate::core::mutation::MutationKind; +use crate::environment::content_manifest::ContentManifestReader; +use crate::environment::planning::TargetFactResolver; +use crate::environment::types::{EnvironmentRef, SkillLocationRef}; +use crate::error::AppError; +#[cfg(test)] +use state::MembershipChangeKind; +pub(crate) use state::{apply_membership_change, MembershipChange}; + +pub(crate) trait LibraryTargetFacts: TargetFactResolver + ContentManifestReader {} + +impl LibraryTargetFacts for T where T: TargetFactResolver + ContentManifestReader {} + +pub(crate) type MembershipFuture<'a, T> = Pin + Send + 'a>>; + +pub(crate) trait LibraryApplicationMembership: Send + Sync { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result>; + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result>; + + fn record_attention<'a>( + &'a self, + context: SkillLocationRef, + attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>>; + + fn resume<'a>( + &'a self, + context: SkillLocationRef, + cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result>; +} + +impl LibraryApplicationMembership for LibraryApplicationModule +where + F: ScopePlanningSnapshotSource + Send + Sync, + T: TargetFactResolver + Send + Sync, + E: MutationPlanExecutor + Send + Sync, +{ + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { LibraryApplicationModule::plan_resume(self, context).await }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + LibraryApplicationModule::plan_resume_with_catalog(self, context, catalog).await + }) + } + + fn record_attention<'a>( + &'a self, + context: SkillLocationRef, + attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async move { + LibraryApplicationModule::record_reconciliation_attention(self, context, attention) + .await + }) + } + + fn resume<'a>( + &'a self, + context: SkillLocationRef, + cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { LibraryApplicationModule::resume(self, context, cancellation).await }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryMembershipPreview { + pub environment: EnvironmentRef, + pub library_id: LibraryId, + pub scopes: Vec, + pub impacts: Vec, + pub inventory_complete: bool, + pub inventory_token: String, + pub token: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum MembershipScopeImpactKind { + Added, + Switched, + Fallback, + Removed, + Unchanged, + Unverified, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct MembershipSkillImpact { + pub skill_name: String, + pub kind: MembershipScopeImpactKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct MembershipScopeImpact { + pub context: SkillLocationRef, + pub skills: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum MembershipScopeState { + Synced, + Pending, + Unverified, + RecoveryRequired, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct MembershipScopeResult { + pub context: SkillLocationRef, + pub state: MembershipScopeState, + pub error: Option, +} + +#[derive(Debug)] +pub(crate) struct LibraryMembershipExecution { + pub library: Option, + pub scopes: Vec, + pub cleanup: Vec, + pub snapshot_error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum RetiredCleanupState { + Purged, + Retained, + Failed, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct RetiredCleanupResult { + pub library_id: LibraryId, + pub member_name: String, + pub retirement_id: String, + pub state: RetiredCleanupState, + pub error: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryMembershipOutcome { + pub scopes: Vec, + pub cleanup: Vec, + pub snapshot_error: Option, +} + +impl LibraryMembershipExecution { + pub(crate) fn into_parts(self) -> (Option, LibraryMembershipOutcome) { + ( + self.library, + LibraryMembershipOutcome { + scopes: self.scopes, + cleanup: self.cleanup, + snapshot_error: self.snapshot_error, + }, + ) + } +} + +struct RetiredMemberCollector { + applications: Arc, + libraries: Arc, +} + +struct MembershipReconciliationModule { + applications: Arc, + reconciler: Arc, + collector: RetiredMemberCollector, +} + +impl MembershipReconciliationModule { + fn new( + applications: Arc, + reconciler: Arc, + libraries: Arc, + ) -> Self { + Self { + collector: RetiredMemberCollector { + applications: applications.clone(), + libraries, + }, + applications, + reconciler, + } + } + + pub(crate) async fn preview( + &self, + environment: EnvironmentRef, + library_id: LibraryId, + ) -> Result { + let inventory = self.applications.enumerate(&environment).await?; + membership_preview(environment, library_id, &inventory) + } + + pub(crate) async fn execute( + &self, + preview: LibraryMembershipPreview, + commit: F, + cancellation: CancellationSignal, + ) -> Result, AppError> + where + F: Future>, + { + let current = self + .preview(preview.environment.clone(), preview.library_id.clone()) + .await?; + if current.inventory_token != preview.inventory_token { + return Err(AppError::StaleContext); + } + let library = commit.await?; + let mut result = self + .reconcile_scopes(current.scopes, current.inventory_complete, cancellation) + .await; + match self + .collect_retired(&preview.environment, Some(&preview.library_id)) + .await + { + Ok(cleanup) => result.cleanup = cleanup, + Err(error) if result.snapshot_error.is_none() => result.snapshot_error = Some(error), + Err(_) => {} + } + result.library = Some(library); + Ok(result) + } + + pub(crate) async fn resume( + &self, + environment: EnvironmentRef, + library_id: Option, + cancellation: CancellationSignal, + ) -> Result, AppError> { + let inventory = self.applications.enumerate(&environment).await?; + let mut scopes = inventory + .records + .iter() + .filter(|application| { + library_id + .as_ref() + .is_none_or(|library_id| references_library(&application.record, library_id)) + }) + .map(|application| application.context.clone()) + .collect::>(); + sort_scopes(&mut scopes); + let mut result = self + .reconcile_scopes(scopes, inventory.complete, cancellation) + .await; + match self + .collect_retired(&environment, library_id.as_ref()) + .await + { + Ok(cleanup) => result.cleanup = cleanup, + Err(error) if result.snapshot_error.is_none() => result.snapshot_error = Some(error), + Err(_) => {} + } + Ok(result) + } + + async fn reconcile_scopes( + &self, + scopes: Vec, + inventory_complete: bool, + cancellation: CancellationSignal, + ) -> LibraryMembershipExecution { + let mut snapshot_error = None; + let mut plans = Vec::with_capacity(scopes.len()); + let mut planning_results = Vec::with_capacity(scopes.len()); + for context in &scopes { + if cancellation.is_cancelled() { + plans.push(None); + planning_results.push(Some(MembershipScopeResult { + context: context.clone(), + state: MembershipScopeState::Cancelled, + error: Some(AppError::MutationCancelled), + })); + continue; + } + match self.reconciler.plan(context.clone()).await { + Ok(plan) => { + plans.push(Some(plan)); + planning_results.push(None); + } + Err(error) => { + plans.push(None); + planning_results.push(Some(MembershipScopeResult { + context: context.clone(), + state: membership_error_state(&error), + error: Some(error), + })); + } + } + } + let conflicts = conflicting_scope_plans(plans.iter().flatten()); + let mut results = Vec::with_capacity(scopes.len()); + for (index, context) in scopes.into_iter().enumerate() { + if let Some(result) = planning_results[index].take() { + let attention = match result.state { + MembershipScopeState::Unverified => ReconciliationAttention::Unverified, + _ => ReconciliationAttention::Pending, + }; + if let Err(error) = self + .reconciler + .record_attention(context.clone(), attention) + .await + { + snapshot_error.get_or_insert(error); + } + results.push(result); + continue; + } + if conflicts.contains(&context) { + if let Err(error) = self + .reconciler + .record_attention(context.clone(), ReconciliationAttention::Pending) + .await + { + snapshot_error.get_or_insert(error); + } + results.push(MembershipScopeResult { + context, + state: MembershipScopeState::Pending, + error: Some(AppError::StaleTarget), + }); + continue; + } + match self + .reconciler + .resume(context.clone(), cancellation.clone()) + .await + { + Ok(application) => results.push(MembershipScopeResult { + state: membership_scope_state(application.application.sync_state), + context, + error: None, + }), + Err(error) => results.push(MembershipScopeResult { + state: membership_error_state(&error), + context, + error: Some(error), + }), + } + } + LibraryMembershipExecution { + library: None, + scopes: results, + cleanup: Vec::new(), + snapshot_error: snapshot_error.or_else(|| { + (!inventory_complete).then(|| AppError::ConfigurationCorrupted { + message: "Skill Library application inventory is incomplete".to_string(), + }) + }), + } + } + + async fn collect_retired( + &self, + environment: &EnvironmentRef, + library_id: Option<&LibraryId>, + ) -> Result, AppError> { + self.collector.collect(environment, library_id).await + } +} + +pub(crate) struct LibraryMembershipModule { + reconciliation: MembershipReconciliationModule, + libraries: Arc, + payloads: Arc, + targets: Arc, + admission: Arc, +} + +impl LibraryMembershipModule { + pub(crate) fn new( + applications: Arc, + reconciler: Arc, + repository: Arc, + admission: Arc, + libraries: Arc, + payloads: Arc, + targets: Arc, + ) -> Self { + Self { + reconciliation: MembershipReconciliationModule::new( + applications, + reconciler, + repository, + ), + libraries, + payloads, + targets, + admission, + } + } + + pub(crate) async fn preview_add_skills( + &self, + request: PreviewAddLibrarySkillsRequest, + ) -> Result { + let preview_request = request.clone(); + let membership = self + .reconciliation + .preview(request.environment.clone(), request.library_id.clone()) + .await?; + let (mut preview, projected) = self + .libraries + .preview_add_skills_with_catalog( + self.payloads.as_ref(), + self.targets.as_ref(), + request, + membership, + ) + .await?; + let membership = self + .preview_impacts( + preview.membership.clone(), + projected, + preview + .skills + .iter() + .map(|skill| skill.skill_name.clone()) + .collect(), + false, + ) + .await?; + preview.bind_membership(&preview_request, membership)?; + Ok(preview) + } + + pub(crate) async fn execute_add_skills( + &self, + request: ExecuteAddLibrarySkillsRequest, + cancellation: CancellationSignal, + ) -> Result { + let _permit = self.admission.begin_library_mutation( + MutationKind::ManageLibraries, + request.request.environment.clone(), + request.request.library_id.as_str().to_string(), + )?; + let current = self.preview_add_skills(request.request.clone()).await?; + if current.token != request.expected_token + || current.membership.token != request.membership.token + { + return Err(AppError::StaleContext); + } + let preview = current.membership.clone(); + let request = ExecuteAddLibrarySkillsRequest { + membership: current.membership, + ..request + }; + let commit = self.libraries.execute_add_skills( + self.payloads.as_ref(), + self.targets.as_ref(), + request, + ); + let execution = self + .reconciliation + .execute(preview, commit, cancellation) + .await?; + let (commit, mut membership) = execution.into_parts(); + let commit = commit.ok_or(AppError::StaleContext)?; + if membership.snapshot_error.is_none() { + membership.snapshot_error = commit.snapshot_error; + } + Ok(LibraryAddResponse { + results: commit.results, + library: commit.library, + membership, + }) + } + + pub(crate) async fn preview_retire_skill( + &self, + request: RemoveLibrarySkillRequest, + ) -> Result { + let membership = self + .reconciliation + .preview(request.environment.clone(), request.library_id.clone()) + .await?; + let (prepared, projected) = self + .libraries + .prepare_retire_skill_with_catalog(self.targets.as_ref(), request, membership.clone()) + .await?; + let skill_name = prepared.skill_name().to_string(); + let membership = self + .preview_impacts(membership, projected, vec![skill_name], true) + .await?; + prepared.bind(membership) + } + + pub(crate) async fn retire_skill( + &self, + request: ExecuteRetireLibrarySkillRequest, + cancellation: CancellationSignal, + ) -> Result { + let _permit = self.admission.begin_library_mutation( + MutationKind::ManageLibraries, + request.request.environment.clone(), + request.request.library_id.as_str().to_string(), + )?; + let current = self.preview_retire_skill(request.request.clone()).await?; + if current.token != request.expected_token + || current.membership.token != request.membership.token + { + return Err(AppError::StaleContext); + } + let preview = current.membership.clone(); + let request = ExecuteRetireLibrarySkillRequest { + membership: current.membership, + ..request + }; + let commit = self.libraries.retire_skill(self.targets.as_ref(), request); + let execution = self + .reconciliation + .execute(preview, commit, cancellation) + .await?; + let (commit, mut membership) = execution.into_parts(); + let commit = commit.ok_or(AppError::StaleContext)?; + if membership.snapshot_error.is_none() { + membership.snapshot_error = commit.snapshot_error; + } + Ok(LibraryRetireResponse { + library: commit.library, + membership, + }) + } + + pub(crate) async fn resume( + &self, + environment: EnvironmentRef, + library_id: Option, + cancellation: CancellationSignal, + ) -> Result, AppError> { + let _permit = self.admission.begin_library_mutation( + MutationKind::ManageLibraries, + environment.clone(), + library_id + .as_ref() + .map(|id| id.as_str()) + .unwrap_or("all") + .to_string(), + )?; + self.reconciliation + .resume(environment, library_id, cancellation) + .await + } + + async fn preview_impacts( + &self, + mut membership: LibraryMembershipPreview, + catalog: LibraryCatalog, + skill_names: Vec, + retiring: bool, + ) -> Result { + let mut impacts = Vec::with_capacity(membership.scopes.len()); + let mut evidence = Vec::with_capacity(membership.scopes.len()); + let mut planned = Vec::new(); + for context in &membership.scopes { + match self + .reconciliation + .reconciler + .plan_with_catalog(context.clone(), catalog.clone()) + .await + { + Ok(plan) => { + let skills = + membership_skill_impacts(plan.preview.as_ref(), &skill_names, retiring); + let preview_token = plan + .preview + .as_ref() + .map(|preview| preview.token.generation.clone()) + .unwrap_or_default(); + evidence.push(( + context.clone(), + preview_token, + plan.entries + .iter() + .map(|(key, action)| { + crate::application::mutation::plan::stable_digest(&( + key, + format!("{action:?}"), + )) + }) + .collect::, _>>()?, + )); + impacts.push(MembershipScopeImpact { + context: context.clone(), + skills, + }); + planned.push(plan); + } + Err(error) => { + evidence.push(( + context.clone(), + String::new(), + vec![format!("unverified:{error}")], + )); + impacts.push(MembershipScopeImpact { + context: context.clone(), + skills: skill_names + .iter() + .map(|skill_name| MembershipSkillImpact { + skill_name: skill_name.clone(), + kind: MembershipScopeImpactKind::Unverified, + }) + .collect(), + }); + } + } + } + if !conflicting_scope_plans(planned.iter()).is_empty() { + return Err(AppError::StaleTarget); + } + membership.token = crate::application::mutation::plan::stable_digest(&( + "library-membership-impact-v1", + membership.inventory_token.as_str(), + &impacts, + evidence, + ))?; + membership.impacts = impacts; + Ok(membership) + } +} + +fn membership_skill_impacts( + preview: Option<&crate::application::library_application::LibraryApplicationPreview>, + skill_names: &[String], + retiring: bool, +) -> Vec { + skill_names + .iter() + .map(|skill_name| { + let kind = match preview { + Some(preview) if preview.added_skill_names.contains(skill_name) => { + MembershipScopeImpactKind::Added + } + Some(preview) if preview.switched_skill_names.contains(skill_name) => { + if retiring { + MembershipScopeImpactKind::Fallback + } else { + MembershipScopeImpactKind::Switched + } + } + Some(preview) if preview.removed_skill_names.contains(skill_name) => { + MembershipScopeImpactKind::Removed + } + _ => MembershipScopeImpactKind::Unchanged, + }; + MembershipSkillImpact { + skill_name: skill_name.clone(), + kind, + } + }) + .collect() +} + +fn conflicting_scope_plans<'a>( + plans: impl IntoIterator, +) -> Vec { + let plans = plans.into_iter().collect::>(); + let conflicting_targets = ScopeSkillPlanner::conflicting_mutations( + plans + .iter() + .flat_map(|plan| plan.entries.iter()) + .map(|(key, action)| (key, action)), + ); + plans + .into_iter() + .filter(|plan| { + plan.entries + .iter() + .any(|(key, _)| conflicting_targets.contains(key)) + }) + .map(|plan| plan.context.clone()) + .collect() +} + +impl RetiredMemberCollector { + async fn collect( + &self, + environment: &EnvironmentRef, + selected_library: Option<&LibraryId>, + ) -> Result, AppError> { + let catalog = self.libraries.load(environment).await?; + let inventory = match self.applications.enumerate(environment).await { + Ok(inventory) => inventory, + Err(error) => { + return Ok(catalog + .libraries + .iter() + .filter(|library| selected_library.is_none_or(|id| id == &library.id)) + .flat_map(|library| { + library.retired_skills.iter().map(|retired| { + cleanup_failure( + library.id.clone(), + retired.member.name.clone(), + retired.retirement_id.as_str().to_string(), + error.clone(), + ) + }) + }) + .collect()) + } + }; + let mut results = Vec::new(); + for library in catalog + .libraries + .iter() + .filter(|library| selected_library.is_none_or(|id| id == &library.id)) + { + for retired in &library.retired_skills { + let referenced = inventory.records.iter().any(|application| { + references_member(&application.record, &library.id, &retired.member.name) + }); + if !inventory.complete || referenced { + results.push(RetiredCleanupResult { + library_id: library.id.clone(), + member_name: retired.member.name.clone(), + retirement_id: retired.retirement_id.as_str().to_string(), + state: RetiredCleanupState::Retained, + error: None, + }); + continue; + } + let request = PurgeRetiredLibraryMemberRequest { + environment: environment.clone(), + library_id: library.id.clone(), + skill_name: retired.member.name.clone(), + retirement_id: retired.retirement_id.clone(), + }; + match self.libraries.purge_retired(request).await { + Ok(()) => results.push(RetiredCleanupResult { + library_id: library.id.clone(), + member_name: retired.member.name.clone(), + retirement_id: retired.retirement_id.as_str().to_string(), + state: RetiredCleanupState::Purged, + error: None, + }), + Err(error) => results.push(cleanup_failure( + library.id.clone(), + retired.member.name.clone(), + retired.retirement_id.as_str().to_string(), + error, + )), + } + } + } + Ok(results) + } +} + +fn references_member( + record: &crate::application::library_application::LibraryApplicationRecord, + library_id: &LibraryId, + member_name: &str, +) -> bool { + record + .checkpoint + .members + .iter() + .chain(record.pending.iter().flat_map(|pending| { + pending + .recognized_members + .iter() + .chain(&pending.target_members) + })) + .any(|member| &member.library_id == library_id && member.member_name == member_name) +} + +fn cleanup_failure( + library_id: LibraryId, + member_name: String, + retirement_id: String, + error: AppError, +) -> RetiredCleanupResult { + RetiredCleanupResult { + library_id, + member_name, + retirement_id, + state: RetiredCleanupState::Failed, + error: Some(error), + } +} + +fn membership_preview( + environment: EnvironmentRef, + library_id: LibraryId, + inventory: &ApplicationInventory, +) -> Result { + let mut scopes = inventory + .records + .iter() + .filter(|application| references_library(&application.record, &library_id)) + .map(|application| application.context.clone()) + .collect::>(); + sort_scopes(&mut scopes); + let mut records = inventory + .records + .iter() + .map(|application| (&application.context, &application.record)) + .collect::>(); + records.sort_by(|(left, _), (right, _)| format!("{left:?}").cmp(&format!("{right:?}"))); + let mut problems = inventory + .problems + .iter() + .map(|problem| problem.storage_key.as_str()) + .collect::>(); + problems.sort_unstable(); + let token = crate::application::mutation::plan::stable_digest(&( + &records, + &problems, + inventory.complete, + ))?; + Ok(LibraryMembershipPreview { + environment, + library_id, + scopes, + impacts: Vec::new(), + inventory_complete: inventory.complete, + inventory_token: token.clone(), + token, + }) +} + +fn references_library( + record: &crate::application::library_application::LibraryApplicationRecord, + library_id: &LibraryId, +) -> bool { + record.current.ordered_library_ids.contains(library_id) + || record.pending.as_ref().is_some_and(|pending| { + pending + .before_application + .ordered_library_ids + .contains(library_id) + || pending + .target_application + .ordered_library_ids + .contains(library_id) + || pending + .recognized_members + .iter() + .chain(&pending.target_members) + .any(|member| &member.library_id == library_id) + }) + || record + .checkpoint + .members + .iter() + .any(|member| &member.library_id == library_id) +} + +fn sort_scopes(scopes: &mut [SkillLocationRef]) { + scopes.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}"))); +} + +fn membership_error_state(error: &AppError) -> MembershipScopeState { + match error { + AppError::RecoveryRequired { .. } | AppError::RestoreFailed { .. } => { + MembershipScopeState::RecoveryRequired + } + AppError::EnvironmentUnavailable { .. } + | AppError::StorageUnsupported { .. } + | AppError::PathNotFound { .. } => MembershipScopeState::Unverified, + AppError::MutationCancelled => MembershipScopeState::Cancelled, + _ => MembershipScopeState::Pending, + } +} + +fn membership_scope_state(state: LibraryApplicationSyncState) -> MembershipScopeState { + match state { + LibraryApplicationSyncState::Synced => MembershipScopeState::Synced, + LibraryApplicationSyncState::Pending => MembershipScopeState::Pending, + LibraryApplicationSyncState::Unverified => MembershipScopeState::Unverified, + LibraryApplicationSyncState::RecoveryRequired => MembershipScopeState::RecoveryRequired, + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use super::*; + use crate::application::library_application::{ + ApplicationInventory, ApplicationRegistry, LibraryApplicationFuture, + LibraryApplicationRecord, LibraryApplicationResponse, LibraryApplicationScopePlan, + LibraryApplicationSummary, VersionedApplicationRecord, + }; + use crate::application::mutation::plan::PreparedEntryAction; + use crate::application::skill_libraries::{ + CommitLibraryMemberRequest, LibraryCatalog, LibraryFuture, LibraryId, LibrarySkillRecord, + PurgeRetiredLibraryMemberRequest, RetirementId, SkillLibraryRecord, SkillLibraryRepository, + LIBRARY_SCHEMA_VERSION, + }; + use crate::core::mutation::CancellationSignal; + use crate::environment::runtime::{ + ExecutionBackend, PhysicalParentIdentity, PhysicalTargetKey, + }; + use crate::environment::types::{EnvironmentRef, SkillLocation, SkillLocationRef}; + use crate::error::AppError; + + #[test] + fn one_transition_handles_add_update_retire_and_reactivate() { + let mut catalog = catalog(); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v1")), + ) + .unwrap(), + MembershipChangeKind::Added + ); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v2")), + ) + .unwrap(), + MembershipChangeKind::Updated + ); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(), + MembershipChangeKind::Retired + ); + assert!(catalog.libraries[0].skills.is_empty()); + assert_eq!(catalog.libraries[0].retired_skills.len(), 1); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v3")), + ) + .unwrap(), + MembershipChangeKind::Reactivated + ); + assert_eq!(catalog.libraries[0].skills[0].description, "v3"); + assert!(catalog.libraries[0].retired_skills.is_empty()); + } + + #[test] + fn another_identity_cannot_claim_a_retired_directory() { + let mut catalog = catalog(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "CE:Review", + MembershipChange::Upsert(record("CE:Review", "v1")), + ) + .unwrap(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "CE:Review", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + assert!(matches!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "ce-review", + MembershipChange::Upsert(record("ce-review", "v2")), + ), + Err(AppError::Validation { .. }) + )); + } + + #[test] + fn purge_requires_the_exact_retirement_identity() { + let mut catalog = catalog(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v1")), + ) + .unwrap(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + assert!(matches!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Purge { + retirement_id: RetirementId::parse("stale"), + }, + ), + Err(AppError::StaleTarget) + )); + assert_eq!(catalog.libraries[0].retired_skills.len(), 1); + } + + #[tokio::test] + async fn execute_rejects_an_expanded_scope_before_committing_library_state() { + let registry = Arc::new(MemoryRegistry::new(vec![application( + SkillLocation::Global, + "library-1", + )])); + let module = reconciliation_module( + registry.clone(), + Arc::new(SuccessfulReconciler), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + let preview = module + .preview(EnvironmentRef::Native, LibraryId::parse("library-1")) + .await + .unwrap(); + registry.records.lock().unwrap().push(application( + SkillLocation::Project { + project_id: "project-1".to_string(), + }, + "library-1", + )); + let committed = Arc::new(AtomicBool::new(false)); + let committed_in_future = Arc::clone(&committed); + + let result = module + .execute( + preview, + async move { + committed_in_future.store(true, Ordering::SeqCst); + Ok::<_, AppError>("saved") + }, + CancellationSignal::default(), + ) + .await; + + assert!(matches!(result, Err(AppError::StaleContext))); + assert!(!committed.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn collector_purges_only_without_application_references() { + let mut state = catalog(); + apply_membership_change( + &mut state, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v1")), + ) + .unwrap(); + apply_membership_change( + &mut state, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + let libraries = Arc::new(MemoryLibraries(Mutex::new(state))); + let module = reconciliation_module( + Arc::new(MemoryRegistry::new(Vec::new())), + Arc::new(SuccessfulReconciler), + libraries.clone(), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!(result.cleanup.len(), 1); + assert_eq!(result.cleanup[0].state, RetiredCleanupState::Purged); + assert!(libraries.0.lock().unwrap().libraries[0] + .retired_skills + .is_empty()); + } + + #[tokio::test] + async fn conflicting_physical_targets_block_all_affected_scopes_before_writes() { + let registry = Arc::new(MemoryRegistry::new(vec![ + application(SkillLocation::Global, "library-1"), + application( + SkillLocation::Project { + project_id: "project-1".to_string(), + }, + "library-1", + ), + ])); + let reconciler = Arc::new(ConflictingReconciler::default()); + let module = reconciliation_module( + registry, + reconciler.clone(), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!(result.scopes.len(), 2); + assert!(result.scopes.iter().all(|scope| { + scope.state == MembershipScopeState::Pending + && matches!(scope.error, Some(AppError::StaleTarget)) + })); + assert_eq!(reconciler.resume_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn reconciliation_preserves_the_application_recovery_state() { + let registry = Arc::new(MemoryRegistry::new(vec![application( + SkillLocation::Global, + "library-1", + )])); + let module = reconciliation_module( + registry, + Arc::new(FixedStateReconciler( + LibraryApplicationSyncState::RecoveryRequired, + )), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!( + result.scopes[0].state, + MembershipScopeState::RecoveryRequired + ); + } + + #[tokio::test] + async fn reconciliation_reports_an_attention_persistence_failure() { + let registry = Arc::new(MemoryRegistry::new(vec![application( + SkillLocation::Global, + "library-1", + )])); + let module = reconciliation_module( + registry, + Arc::new(FailingAttentionReconciler), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!(result.scopes[0].state, MembershipScopeState::Unverified); + assert!(matches!(result.snapshot_error, Some(AppError::StaleTarget))); + } + + struct MemoryRegistry { + records: Mutex>, + } + + impl MemoryRegistry { + fn new(records: Vec) -> Self { + Self { + records: Mutex::new(records), + } + } + } + + impl ApplicationRegistry for MemoryRegistry { + fn load_application<'a>( + &'a self, + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + self.records + .lock() + .unwrap() + .iter() + .find(|record| &record.context == context) + .cloned() + .ok_or_else(|| AppError::PathNotFound { + path: format!("{:?}", context.scope), + }) + }) + } + + fn save_application_if<'a>( + &'a self, + _observed: &'a VersionedApplicationRecord, + _record: &'a LibraryApplicationRecord, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn enumerate<'a>( + &'a self, + _environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(ApplicationInventory { + records: self.records.lock().unwrap().clone(), + problems: Vec::new(), + complete: true, + }) + }) + } + } + + struct SuccessfulReconciler; + + impl LibraryApplicationMembership for SuccessfulReconciler { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Ok(()) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + Box::pin(async { + Ok(LibraryApplicationResponse { + application: LibraryApplicationSummary { + ordered_libraries: Vec::new(), + selected_agent_ids: Vec::new(), + pending: false, + sync_state: crate::application::library_application::LibraryApplicationSyncState::Synced, + }, + units: Vec::new(), + }) + }) + } + } + + struct FixedStateReconciler(LibraryApplicationSyncState); + + struct FailingAttentionReconciler; + + impl LibraryApplicationMembership for FailingAttentionReconciler { + fn plan<'a>( + &'a self, + _context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async { + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Native, + message: "offline".to_string(), + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + Box::pin(async { panic!("planning failure must not resume the Scope") }) + } + } + + impl LibraryApplicationMembership for FixedStateReconciler { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Ok(()) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + let state = self.0; + Box::pin(async move { + Ok(LibraryApplicationResponse { + application: LibraryApplicationSummary { + ordered_libraries: Vec::new(), + selected_agent_ids: Vec::new(), + pending: state != LibraryApplicationSyncState::Synced, + sync_state: state, + }, + units: Vec::new(), + }) + }) + } + } + + #[derive(Default)] + struct ConflictingReconciler { + resume_calls: AtomicUsize, + } + + impl LibraryApplicationMembership for ConflictingReconciler { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + let action = match context.scope { + SkillLocation::Global => PreparedEntryAction::Keep, + SkillLocation::Project { .. } => PreparedEntryAction::Remove, + }; + Ok(LibraryApplicationScopePlan { + context, + entries: vec![(physical_target(), action)], + preview: None, + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Ok(()) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + self.resume_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Err(AppError::StaleTarget) }) + } + } + + fn physical_target() -> PhysicalTargetKey { + PhysicalTargetKey { + backend: ExecutionBackend::NativeUnix, + physical_parent: PhysicalParentIdentity::Unix { + device: 1, + inode: 2, + }, + normalized_final_child_name: "demo".to_string(), + } + } + + struct MemoryLibraries(Mutex); + + fn reconciliation_module( + applications: Arc, + reconciler: Arc, + libraries: Arc, + ) -> MembershipReconciliationModule { + MembershipReconciliationModule::new(applications, reconciler, libraries) + } + + impl SkillLibraryRepository for MemoryLibraries { + fn resolve_collection<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + ) -> LibraryFuture<'a, Result> + { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn load<'a>( + &'a self, + _environment: &'a EnvironmentRef, + ) -> LibraryFuture<'a, Result> { + Box::pin(async move { Ok(self.0.lock().unwrap().clone()) }) + } + + fn save<'a>( + &'a self, + _environment: &'a EnvironmentRef, + catalog: &'a LibraryCatalog, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + *self.0.lock().unwrap() = catalog.clone(); + Ok(()) + }) + } + + fn commit_member<'a>( + &'a self, + _request: CommitLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + apply_membership_change( + &mut self.0.lock().unwrap(), + &request.library_id, + &request.skill_name, + MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + Ok(()) + }) + } + + fn delete_library<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn read_skill_content<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + _skill_name: &'a str, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + } + + fn application(scope: SkillLocation, library_id: &str) -> VersionedApplicationRecord { + let mut record = LibraryApplicationRecord::empty(); + record.current.ordered_library_ids = vec![LibraryId::parse(library_id)]; + VersionedApplicationRecord::in_memory( + SkillLocationRef { + environment: EnvironmentRef::Native, + scope, + }, + record, + ) + } + + fn catalog() -> LibraryCatalog { + LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: LibraryId::parse("library-1"), + name: "Library".to_string(), + skills: Vec::new(), + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + } + } + + fn record(name: &str, version: &str) -> LibrarySkillRecord { + LibrarySkillRecord { + name: name.to_string(), + description: version.to_string(), + source_record: json!({ "sourceType": "local", "source": version }), + content_manifest_hash: format!("manifest-{version}"), + updated_at: Some("2026-09-06T00:00:00Z".to_string()), + extra: serde_json::Map::new(), + } + } +} diff --git a/src-tauri/src/application/library_membership/state.rs b/src-tauri/src/application/library_membership/state.rs new file mode 100644 index 00000000..66025b02 --- /dev/null +++ b/src-tauri/src/application/library_membership/state.rs @@ -0,0 +1,146 @@ +use crate::application::installed_skill_resolver::InstalledSkillResolver; +use crate::application::skill_libraries::{ + merge_unknown_source_fields, validate_catalog, LibraryCatalog, LibraryId, LibrarySkillRecord, + RetiredLibrarySkillRecord, RetirementId, +}; +use crate::error::AppError; + +#[derive(Debug, Clone)] +pub(crate) enum MembershipChange { + Upsert(LibrarySkillRecord), + Retire { + retirement_id: RetirementId, + retired_at: String, + }, + Purge { + retirement_id: RetirementId, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MembershipChangeKind { + Added, + Updated, + Retired, + Reactivated, + Purged, +} + +pub(crate) fn apply_membership_change( + catalog: &mut LibraryCatalog, + library_id: &LibraryId, + member_name: &str, + change: MembershipChange, +) -> Result { + validate_catalog(catalog)?; + let library = catalog + .libraries + .iter_mut() + .find(|library| &library.id == library_id) + .ok_or_else(|| AppError::PathNotFound { + path: library_id.as_str().to_string(), + })?; + let result = match change { + MembershipChange::Upsert(mut replacement) => { + if replacement.name != member_name { + return Err(AppError::StaleTarget); + } + let directory = InstalledSkillResolver::install_dir_name(member_name)?; + for active in &library.skills { + if active.name != member_name + && InstalledSkillResolver::install_dir_name(&active.name)? == directory + { + return Err(name_conflict()); + } + } + for retired in &library.retired_skills { + if retired.member.name != member_name + && InstalledSkillResolver::install_dir_name(&retired.member.name)? == directory + { + return Err(name_conflict()); + } + } + if let Some(current) = library + .skills + .iter_mut() + .find(|member| member.name == member_name) + { + preserve_unknown_fields(&mut replacement, current); + *current = replacement; + MembershipChangeKind::Updated + } else if let Some(index) = library + .retired_skills + .iter() + .position(|retired| retired.member.name == member_name) + { + let retired = library.retired_skills.remove(index); + preserve_unknown_fields(&mut replacement, &retired.member); + library.skills.push(replacement); + library + .skills + .sort_by(|left, right| left.name.cmp(&right.name)); + MembershipChangeKind::Reactivated + } else { + library.skills.push(replacement); + library + .skills + .sort_by(|left, right| left.name.cmp(&right.name)); + MembershipChangeKind::Added + } + } + MembershipChange::Retire { + retirement_id, + retired_at, + } => { + if retirement_id.as_str().is_empty() || retired_at.is_empty() { + return Err(AppError::StaleTarget); + } + let index = library + .skills + .iter() + .position(|member| member.name == member_name) + .ok_or_else(|| AppError::PathNotFound { + path: member_name.to_string(), + })?; + let member = library.skills.remove(index); + library.retired_skills.push(RetiredLibrarySkillRecord { + retirement_id, + member, + retired_at, + extra: serde_json::Map::new(), + }); + library + .retired_skills + .sort_by(|left, right| left.member.name.cmp(&right.member.name)); + MembershipChangeKind::Retired + } + MembershipChange::Purge { retirement_id } => { + let index = library + .retired_skills + .iter() + .position(|retired| retired.member.name == member_name) + .ok_or_else(|| AppError::PathNotFound { + path: member_name.to_string(), + })?; + if library.retired_skills[index].retirement_id != retirement_id { + return Err(AppError::StaleTarget); + } + library.retired_skills.remove(index); + MembershipChangeKind::Purged + } + }; + validate_catalog(catalog)?; + Ok(result) +} + +fn preserve_unknown_fields(replacement: &mut LibrarySkillRecord, current: &LibrarySkillRecord) { + replacement.extra = current.extra.clone(); + merge_unknown_source_fields(&mut replacement.source_record, ¤t.source_record); +} + +fn name_conflict() -> AppError { + AppError::Validation { + field: Some("skillName".to_string()), + message: "Skill name conflicts with an active or retired Library member".to_string(), + } +} diff --git a/src-tauri/src/application/library_update.rs b/src-tauri/src/application/library_update.rs index 072672be..8bd20173 100644 --- a/src-tauri/src/application/library_update.rs +++ b/src-tauri/src/application/library_update.rs @@ -6,22 +6,16 @@ use specta::Type; #[cfg(test)] use crate::application::collection_records::SkillSelection; -use crate::application::mutation::plan::stable_digest; +use crate::application::library_membership::LibraryMembershipOutcome; use crate::application::mutation::result::ErrorReport; -use crate::application::payload_session::{ - AcquiredPayloadHandle, DiscoverySessionHandle, PayloadSessionManager, -}; -use crate::application::skill_changes::compare_update_subjects; +use crate::application::payload_session::PayloadSessionManager; +use crate::application::skill_changes::{compare_update_subjects, ReadyUpdatePayload}; use crate::application::skill_libraries::{ SkillLibraryDetail, SkillLibraryModule, UpdateLibrarySkillsRequest, }; -use crate::application::skill_source::{ - validate_saved_payloads, SavedPayloadCandidate, SavedSkillSource, SkillSourceModule, -}; +use crate::application::skill_source::{SavedSkillSource, SkillSourceModule}; use crate::application::update::{UpdateOutcome, UpdateSourceResult, UpdateSourceStatus}; -use crate::application::update_subjects::{ - LibraryUpdateSubjectSnapshots, UpdateSubject, UpdateSubjectSnapshot, -}; +use crate::application::update_subjects::LibraryUpdateSubjectSnapshots; use crate::core::mutation::CancellationSignal; use crate::environment::content_manifest::ContentManifestReader; use crate::environment::planning::TargetFactResolver; @@ -67,114 +61,55 @@ pub struct LibraryUpdateResponse { pub sources: Vec, pub results: Vec, pub outcome: UpdateOutcome, - pub library: SkillLibraryDetail, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -#[specta(rename_all = "camelCase")] -pub struct LibraryUpdatePreviewToken { - pub generation: String, + pub library: Option, + pub membership: LibraryMembershipOutcome, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] -pub struct LibraryUpdatePreview { - pub token: LibraryUpdatePreviewToken, +pub struct PreparedLibraryUpdatePreview { pub skill_names: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -#[specta(rename_all = "camelCase")] -pub struct LibraryUpdateRiskConfirmation { + pub blocked: Vec, pub redirected_download_hosts: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -#[specta(rename_all = "camelCase")] -pub struct LibraryUpdatePreparedPayload { - pub skill_name: String, - pub payload: AcquiredPayloadHandle, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -#[specta(rename_all = "camelCase")] -pub struct LibraryUpdatePreparedSkillError { - pub skill_name: String, - pub error: ErrorReport, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(tag = "status", rename_all = "camelCase")] -#[specta(tag = "status", rename_all = "camelCase")] -pub enum LibraryUpdatePreparedSourceResult { - Acquired { - #[serde(rename = "discoverySession")] - #[specta(rename = "discoverySession")] - discovery_session: DiscoverySessionHandle, - payloads: Vec, - #[serde(rename = "skillErrors")] - #[specta(rename = "skillErrors")] - skill_errors: Vec, - #[serde(rename = "redirectedDownloadHost")] - #[specta(rename = "redirectedDownloadHost")] - redirected_download_host: Option, - }, - Failed { - error: ErrorReport, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -#[specta(rename_all = "camelCase")] -pub struct LibraryUpdatePreparedSource { - pub source_result_id: String, - pub source: String, - pub skill_names: Vec, - pub result: LibraryUpdatePreparedSourceResult, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -#[specta(rename_all = "camelCase")] -pub struct LibraryUpdateContinuation { - pub sources: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -#[specta(rename_all = "camelCase")] -pub struct ExecuteLibraryUpdateRequest { +pub struct PreparedLibraryUpdate { pub request: UpdateLibrarySkillsRequest, - pub expected_token: LibraryUpdatePreviewToken, - pub continuation: Option, - pub risk_confirmation: Option, + pub preview: PreparedLibraryUpdatePreview, + items: Vec<(String, ReadyUpdatePayload)>, + sources: Vec, + source_by_skill: BTreeMap, } -#[derive(Debug, Clone, Serialize, Type)] -#[serde(tag = "status", rename_all = "camelCase")] -#[specta(tag = "status", rename_all = "camelCase")] -pub enum LibraryUpdateExecutionOutcome { - Completed { - response: LibraryUpdateResponse, - }, - ConfirmationRequired { - token: LibraryUpdatePreviewToken, - #[serde(rename = "redirectedDownloadHosts")] - #[specta(rename = "redirectedDownloadHosts")] - redirected_download_hosts: Vec, - continuation: LibraryUpdateContinuation, - }, +impl PreparedLibraryUpdate { + pub fn expires_at_epoch_ms(&self) -> Option { + self.items + .iter() + .map(|(_, item)| item.payload.handle().expires_at_epoch_ms) + .min() + } + + pub fn expire_payloads(&mut self, now: u64) { + self.items.retain(|(_, item)| { + if item.payload.handle().expires_at_epoch_ms > now { + return true; + } + let name = item.payload.name(); + self.preview.skill_names.retain(|skill| skill != name); + self.preview + .blocked + .push(crate::application::update::UpdatePreparationIssue { + skill_name: name.to_string(), + error: AppError::StalePayload, + }); + false + }); + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LibraryUpdateExecutionStage { - Acquiring, Validating, Committing, } @@ -209,414 +144,234 @@ where } } - pub async fn preview( + pub async fn prepare( &self, request: &UpdateLibrarySkillsRequest, - ) -> Result { - let skill_names = validate_request(request)?; - let snapshot = self - .subjects - .snapshot_library(&request.environment, &request.library_id, skill_names) - .await?; - let token = preview_token(request, &snapshot, None)?; - Ok(LibraryUpdatePreview { - token, - skill_names: request.skill_names.clone(), - }) - } - - #[cfg(test)] - pub async fn execute( - &self, - execution: &ExecuteLibraryUpdateRequest, cancellation: CancellationSignal, - ) -> Result { - self.execute_with_stage_observer(execution, cancellation, |_| {}) - .await - } - - pub async fn execute_with_stage_observer( - &self, - execution: &ExecuteLibraryUpdateRequest, - cancellation: CancellationSignal, - observe_stage: F, - ) -> Result - where - F: Fn(LibraryUpdateExecutionStage), - { - let request = &execution.request; - let skill_names = validate_request(request)?; - let selection = skill_names.clone(); - let initial = self - .subjects - .snapshot_library(&request.environment, &request.library_id, selection.clone()) - .await?; - if preview_token(request, &initial, execution.continuation.as_ref())? - != execution.expected_token - { - return Err(AppError::StaleContext); - } - let mut results = BTreeMap::::new(); - let initial_by_name = subjects_by_name(&initial); - let saved = skill_names - .iter() - .filter_map(|name| match initial_by_name.get(name.as_str()) { - Some(subject) => match subject.projection.metadata() { - Some(metadata) => Some(SavedSkillSource { - name: name.clone(), - metadata: metadata.clone(), - }), - None => { - results.insert( - name.clone(), - failed( - name, - "", - AppError::InvalidSource { - value: name.clone(), - }, - ), - ); - None - } - }, - None => { - results.insert( - name.clone(), - failed(name, "", AppError::PathNotFound { path: name.clone() }), - ); - None + ) -> Result { + validate_request(request)?; + let mut inspections = Vec::new(); + let mut saved = Vec::new(); + for name in &request.skill_names { + let snapshot = self + .subjects + .snapshot_library( + &request.environment, + &request.library_id, + BTreeSet::from([name.clone()]), + ) + .await; + let snapshot = snapshot.and_then(|snapshot| { + let metadata = snapshot + .subjects + .iter() + .find(|subject| &subject.skill_name == name) + .and_then(|subject| subject.projection.metadata()) + .ok_or(AppError::StaleTarget)?; + if !crate::application::update::derive_update_capability_from_metadata(metadata) + .can_run_update + { + return Err(AppError::InvalidSource { + value: name.clone(), + }); } - }) - .collect(); - observe_stage(LibraryUpdateExecutionStage::Acquiring); - let continuation = match &execution.continuation { - Some(continuation) => continuation.clone(), - None => match self - .skill_source + crate::core::source_identity::SourceIdentity::from_metadata(metadata)?; + saved.push(SavedSkillSource { + name: name.clone(), + metadata: metadata.clone(), + }); + Ok(snapshot) + }); + inspections.push((name.clone(), snapshot)); + } + let acquisitions = if saved.is_empty() { + Vec::new() + } else { + self.skill_source .acquire_saved_skills(&request.environment, saved, cancellation.clone()) - .await - { - Ok(acquisitions) => continuation_from_acquisitions(acquisitions), - Err(AppError::MutationCancelled) => { - mark_cancelled(&request.skill_names, &mut results); - return self - .response(request, &[], results) - .await - .map(|response| LibraryUpdateExecutionOutcome::Completed { response }); - } - Err(error) => return Err(error), + .await? + }; + let mut prepared = PreparedLibraryUpdate { + request: request.clone(), + preview: PreparedLibraryUpdatePreview { + skill_names: Vec::new(), + blocked: Vec::new(), + redirected_download_hosts: Vec::new(), }, + items: Vec::new(), + sources: Vec::new(), + source_by_skill: acquisitions + .iter() + .flat_map(|source| { + source + .skill_names + .iter() + .map(|name| (name.clone(), source.source_result_id.clone())) + }) + .collect(), }; - let redirected_download_hosts = continuation_redirect_hosts(&continuation); - let confirmed_hosts = execution - .risk_confirmation - .as_ref() - .map(|confirmation| normalized_hosts(&confirmation.redirected_download_hosts)) - .unwrap_or_default(); - if !redirected_download_hosts.is_empty() && redirected_download_hosts != confirmed_hosts { - return Ok(LibraryUpdateExecutionOutcome::ConfirmationRequired { - token: preview_token(request, &initial, Some(&continuation))?, - redirected_download_hosts, - continuation, + let mut hosts = BTreeSet::new(); + for source in &acquisitions { + if let Ok(content) = &source.result { + hosts.extend(content.redirected_download_hosts.iter().cloned()); + } + prepared.sources.push(UpdateSourceResult { + id: source.source_result_id.clone(), + source: source.source.clone(), + status: if source.result.is_ok() { + UpdateSourceStatus::Acquired + } else { + UpdateSourceStatus::Failed + }, + error: source + .result + .as_ref() + .err() + .cloned() + .map(|error| ErrorReport::from_app_error(error, None)), }); } - - let mut candidates = Vec::new(); - observe_stage(LibraryUpdateExecutionStage::Validating); - let mut source_by_skill = BTreeMap::new(); - let mut cancelled_acquisitions = BTreeSet::new(); - for source in &continuation.sources { - for skill_name in &source.skill_names { - source_by_skill.insert(skill_name.clone(), source.source_result_id.clone()); + prepared.preview.redirected_download_hosts = hosts.into_iter().collect(); + for (name, initial) in inspections { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); } - match &source.result { - LibraryUpdatePreparedSourceResult::Acquired { - discovery_session, - payloads, - skill_errors, - .. - } => { - for skill_error in skill_errors { - results.insert( - skill_error.skill_name.clone(), - failed_report( - &skill_error.skill_name, - &source.source_result_id, - skill_error.error.clone(), - ), - ); - } - for prepared in payloads { - candidates.push(SavedPayloadCandidate { - source_result_id: source.source_result_id.clone(), - discovery_session: discovery_session.clone(), - skill_name: prepared.skill_name.clone(), - handle: prepared.payload.clone(), - }); - } - } - LibraryUpdatePreparedSourceResult::Failed { error } => { - for skill_name in &source.skill_names { - let cancelled_result = error.code - == crate::application::mutation::result::OperationErrorCode::MutationCancelled; - if cancelled_result { - cancelled_acquisitions.insert(skill_name.clone()); - continue; - } - results.insert( - skill_name.clone(), - failed_report(skill_name, &source.source_result_id, error.clone()), - ); - } + let outcome = async { + let initial = initial?; + let source = acquisitions + .iter() + .find(|source| source.skill_names.contains(&name)) + .ok_or(AppError::StalePayload)?; + let content = source.result.as_ref().map_err(Clone::clone)?; + let payload = content + .validate_member(self.payloads.as_ref(), &request.environment, &name) + .await?; + let latest = self + .subjects + .snapshot_library( + &request.environment, + &request.library_id, + BTreeSet::from([name.clone()]), + ) + .await?; + let mut comparison = compare_update_subjects(&initial, &latest, vec![payload])?; + if !comparison.stale_skill_names.is_empty() { + return Err(AppError::StaleTarget); } + let ready = comparison.ready.pop().ok_or(AppError::StaleTarget)?; + Ok::<_, AppError>((source.source_result_id.clone(), ready)) } - } - let mut first_cancelled = true; - for skill_name in &request.skill_names { - if cancelled_acquisitions.contains(skill_name) { - let result = if first_cancelled { - first_cancelled = false; - cancelled( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - ) - } else { - not_run( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - ) - }; - results.insert(skill_name.clone(), result); + .await; + match outcome { + Ok(item) => prepared.items.push(item), + Err(error) => prepared.preview.blocked.push( + crate::application::update::UpdatePreparationIssue { + skill_name: name, + error, + }, + ), } } - let validation = - validate_saved_payloads(self.payloads.as_ref(), &request.environment, candidates).await; - for failed_payload in validation.failed { - results.insert( - failed_payload.skill_name.clone(), - failed( - &failed_payload.skill_name, - &failed_payload.source_result_id, - failed_payload.error, + let mut valid = Vec::new(); + for item in prepared.items { + match self.payloads.pin_verified(item.1.payload.handle()).await { + Ok(_) => { + prepared + .preview + .skill_names + .push(item.1.payload.name().to_string()); + valid.push(item); + } + Err(error) => prepared.preview.blocked.push( + crate::application::update::UpdatePreparationIssue { + skill_name: item.1.payload.name().to_string(), + error, + }, ), - ); + } } - let validated = validation - .validated - .into_iter() - .map(|validated| validated.payload) - .collect(); - - let latest = self - .subjects - .snapshot_library(&request.environment, &request.library_id, selection) - .await?; - let prepared = compare_update_subjects(&initial, &latest, validated)?; - for skill_name in prepared.stale_skill_names { - results.insert( - skill_name.clone(), - failed( - &skill_name, - source_by_skill - .get(&skill_name) - .map(String::as_str) - .unwrap_or(""), - AppError::StaleTarget, - ), - ); + prepared.items = valid; + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); } - let mut ready = prepared - .ready - .into_iter() - .map(|prepared| (prepared.payload.name().to_string(), prepared)) - .collect::>(); - observe_stage(LibraryUpdateExecutionStage::Committing); - let mut stop_after_cancel = false; - for skill_name in &request.skill_names { - if results.contains_key(skill_name) { - continue; - } - if stop_after_cancel { - results.insert( - skill_name.clone(), - not_run( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - ), - ); - continue; - } - if cancellation.is_cancelled() { - results.insert( - skill_name.clone(), - cancelled( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - ), - ); - stop_after_cancel = true; - continue; - } - let Some(prepared) = ready.remove(skill_name) else { - results.insert( - skill_name.clone(), + Ok(prepared) + } + + pub async fn execute_prepared( + &self, + prepared: PreparedLibraryUpdate, + cancellation: CancellationSignal, + observe: F, + ) -> Result + where + F: Fn(LibraryUpdateExecutionStage), + { + let mut results = prepared + .preview + .blocked + .iter() + .map(|issue| { + ( + issue.skill_name.clone(), failed( - skill_name, - source_by_skill - .get(skill_name) + &issue.skill_name, + prepared + .source_by_skill + .get(&issue.skill_name) .map(String::as_str) .unwrap_or(""), - AppError::StalePayload, + issue.error.clone(), ), - ); - continue; - }; - let current = match self - .subjects - .snapshot_library( - &request.environment, - &request.library_id, - BTreeSet::from([skill_name.clone()]), ) - .await - { - Ok(current) => current, - Err(error) => { - results.insert( - skill_name.clone(), - failed( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - error, - ), - ); - continue; - } - }; - let mut current_prepared = - match compare_update_subjects(&latest, ¤t, vec![prepared.payload]) { - Ok(prepared) => prepared, - Err(error) => { - results.insert( - skill_name.clone(), - failed( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - error, - ), - ); - continue; - } - }; - if !current_prepared.stale_skill_names.is_empty() { - results.insert( - skill_name.clone(), - failed( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - AppError::StaleTarget, - ), - ); + }) + .collect::>(); + let mut stopped = false; + for (source, item) in prepared.items { + let name = item.payload.name().to_string(); + if stopped { + results.insert(name.clone(), not_run(&name, &source)); continue; } - let Some(prepared) = current_prepared.ready.pop() else { - results.insert( - skill_name.clone(), - failed( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - AppError::StalePayload, - ), - ); - continue; - }; - match self - .libraries - .commit_validated_update( - &self.targets, - &request.environment, - &request.library_id, - prepared, - ) - .await - { - Ok(()) => { - results.insert( - skill_name.clone(), - succeeded( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - ), - ); - } - Err(AppError::MutationCancelled) => { - results.insert( - skill_name.clone(), - cancelled( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - ), - ); - stop_after_cancel = true; - } - Err(error) => { - results.insert( - skill_name.clone(), - failed_commit( - skill_name, - source_by_skill - .get(skill_name) - .map(String::as_str) - .unwrap_or(""), - error, - ), - ); + let result = async { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); } + observe(LibraryUpdateExecutionStage::Validating); + self.payloads.pin_verified(item.payload.handle()).await?; + observe(LibraryUpdateExecutionStage::Committing); + self.libraries + .commit_validated_update( + &self.targets, + &prepared.request.environment, + &prepared.request.library_id, + item, + ) + .await } + .await; + results.insert( + name.clone(), + match result { + Ok(()) => succeeded(&name, &source), + Err(AppError::MutationCancelled) => { + stopped = true; + cancelled(&name, &source) + } + Err(error) => failed_commit(&name, &source, error), + }, + ); } - self.response(request, &continuation.sources, results) + self.response_with_sources(&prepared.request, prepared.sources, results) .await - .map(|response| LibraryUpdateExecutionOutcome::Completed { response }) } - async fn response( + async fn response_with_sources( &self, request: &UpdateLibrarySkillsRequest, - sources: &[LibraryUpdatePreparedSource], + source_results: Vec, mut results: BTreeMap, ) -> Result { - let ordered: Vec = request + let ordered = request .skill_names .iter() .map(|name| { @@ -624,33 +379,25 @@ where .remove(name) .unwrap_or_else(|| failed(name, "", AppError::StalePayload)) }) - .collect(); - let source_results = sources - .iter() - .map(|source| UpdateSourceResult { - id: source.source_result_id.clone(), - source: source.source.clone(), - status: match &source.result { - LibraryUpdatePreparedSourceResult::Acquired { .. } => { - UpdateSourceStatus::Acquired - } - LibraryUpdatePreparedSourceResult::Failed { .. } => UpdateSourceStatus::Failed, - }, - error: match &source.result { - LibraryUpdatePreparedSourceResult::Acquired { .. } => None, - LibraryUpdatePreparedSourceResult::Failed { error } => Some(error.clone()), - }, - }) - .collect(); + .collect::>(); let outcome = library_update_outcome(&ordered); + let snapshot = self + .libraries + .detail(request.environment.clone(), request.library_id.clone()) + .await; + let (library, snapshot_error) = match snapshot { + Ok(library) => (Some(library), None), + Err(error) => (None, Some(error)), + }; Ok(LibraryUpdateResponse { sources: source_results, results: ordered, outcome, - library: self - .libraries - .detail(request.environment.clone(), request.library_id.clone()) - .await?, + library, + membership: LibraryMembershipOutcome { + snapshot_error, + ..LibraryMembershipOutcome::default() + }, }) } } @@ -674,102 +421,6 @@ fn library_update_outcome(results: &[LibraryUpdateSkillResult]) -> UpdateOutcome } } -fn preview_token( - request: &UpdateLibrarySkillsRequest, - snapshot: &UpdateSubjectSnapshot, - continuation: Option<&LibraryUpdateContinuation>, -) -> Result { - let revisions = snapshot - .subjects - .iter() - .map(|subject| { - ( - subject.skill_name.as_str(), - &subject.source_record_revision, - &subject.target_revision, - &subject.content_revision, - ) - }) - .collect::>(); - Ok(LibraryUpdatePreviewToken { - generation: stable_digest(&( - "library-update-preview-v1", - &request.environment, - &request.library_id, - &request.skill_names, - &snapshot.resolution_revision, - revisions, - continuation, - ))?, - }) -} - -fn continuation_from_acquisitions( - acquisitions: Vec, -) -> LibraryUpdateContinuation { - LibraryUpdateContinuation { - sources: acquisitions - .into_iter() - .map(|acquisition| LibraryUpdatePreparedSource { - source_result_id: acquisition.source_result_id, - source: acquisition.source, - skill_names: acquisition.skill_names, - result: match acquisition.result { - Ok(acquired) => LibraryUpdatePreparedSourceResult::Acquired { - discovery_session: acquired.facts.discovery_session, - payloads: acquired - .payloads - .into_iter() - .map(|(skill_name, payload)| LibraryUpdatePreparedPayload { - skill_name, - payload, - }) - .collect(), - skill_errors: acquired - .skill_errors - .into_iter() - .map(|(skill_name, error)| LibraryUpdatePreparedSkillError { - skill_name, - error: ErrorReport::from_app_error(error, None), - }) - .collect(), - redirected_download_host: acquired.redirected_download_host, - }, - Err(error) => LibraryUpdatePreparedSourceResult::Failed { - error: ErrorReport::from_app_error(error, None), - }, - }, - }) - .collect(), - } -} - -fn continuation_redirect_hosts(continuation: &LibraryUpdateContinuation) -> Vec { - normalized_hosts( - &continuation - .sources - .iter() - .filter_map(|source| match &source.result { - LibraryUpdatePreparedSourceResult::Acquired { - redirected_download_host, - .. - } => redirected_download_host.clone(), - LibraryUpdatePreparedSourceResult::Failed { .. } => None, - }) - .collect::>(), - ) -} - -fn normalized_hosts(hosts: &[String]) -> Vec { - hosts - .iter() - .filter(|host| !host.trim().is_empty()) - .cloned() - .collect::>() - .into_iter() - .collect() -} - fn validate_request(request: &UpdateLibrarySkillsRequest) -> Result, AppError> { if request.skill_names.is_empty() || request @@ -792,14 +443,6 @@ fn validate_request(request: &UpdateLibrarySkillsRequest) -> Result BTreeMap<&str, &UpdateSubject> { - snapshot - .subjects - .iter() - .map(|subject| (subject.skill_name.as_str(), subject)) - .collect() -} - fn succeeded(skill_name: &str, source_result_id: &str) -> LibraryUpdateSkillResult { LibraryUpdateSkillResult { skill_name: skill_name.to_string(), @@ -886,27 +529,6 @@ fn not_run(skill_name: &str, source_result_id: &str) -> LibraryUpdateSkillResult } } -fn mark_cancelled( - skill_names: &[String], - results: &mut BTreeMap, -) { - let mut first = true; - for skill_name in skill_names { - if results.contains_key(skill_name) { - continue; - } - results.insert( - skill_name.clone(), - if first { - first = false; - cancelled(skill_name, "") - } else { - not_run(skill_name, "") - }, - ); - } -} - #[cfg(test)] mod tests { use std::future::Future; @@ -919,8 +541,9 @@ mod tests { PayloadPlanningMetadata, PayloadSessionLimits, }; use crate::application::skill_libraries::{ - LibraryCatalog, LibraryId, LibrarySkillRecord, LibrarySkillSourceRecord, - SkillLibraryRecord, SkillLibraryRepository, LIBRARY_SCHEMA_VERSION, + LibraryCatalog, LibraryFuture, LibraryId, LibrarySkillRecord, LibrarySkillSourceRecord, + LibraryUsage, LibraryUsageProvider, LibraryUsageSnapshot, SkillLibraryRecord, + SkillLibraryRepository, LIBRARY_SCHEMA_VERSION, }; use crate::application::skill_paths::{ ContentRevision, RootResolutionRevision, TargetRevision, @@ -929,11 +552,11 @@ mod tests { AcquiredSavedSkillSource, SavedSkillSourceAcquisition, SavedSkillSourceGroup, SkillSourceFuture, }; - use crate::application::source_evidence::{RemoteSnapshotId, SourceSnapshotFacts}; - use crate::application::update_subjects::LibraryUpdateSubjectProvider; + use crate::application::update_subjects::{ + LibraryUpdateSubjectProvider, UpdateSubject, UpdateSubjectSnapshot, + }; use crate::core::projects::{ProjectMigrationRegistry, ProjectMigrationState}; use crate::core::skill_payload::build_skill_payload; - use crate::core::source_identity::NormalizedRef; use crate::core::NormalizedUpdateMetadata; use crate::environment::planning::RuntimeTargetFactResolver; use crate::environment::types::EnvironmentRef; @@ -948,6 +571,35 @@ mod tests { names: Vec, } + struct FailingSnapshotUsages; + + impl LibraryUsageProvider for FailingSnapshotUsages { + fn usages<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + ) -> LibraryFuture<'a, Result, AppError>> { + Box::pin(async { + Err(AppError::Io { + message: "snapshot unavailable".to_string(), + }) + }) + } + + fn usage_projection<'a>( + &'a self, + _environment: &'a EnvironmentRef, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { + Ok(LibraryUsageSnapshot { + projections: Vec::new(), + inventory_complete: true, + problem_count: 0, + }) + }) + } + } + impl FixedSubjects { fn snapshot( &self, @@ -1018,41 +670,6 @@ mod tests { calls: AtomicUsize, } - struct CommitRaceSubjects { - inner: LibraryUpdateSubjectProvider, - repository: Arc, - calls: AtomicUsize, - } - - impl LibraryUpdateSubjectSnapshots for CommitRaceSubjects { - fn snapshot_library<'a>( - &'a self, - environment: &'a EnvironmentRef, - library_id: &'a LibraryId, - selection: SkillSelection, - ) -> Pin> + Send + 'a>> - { - Box::pin(async move { - let snapshot = self - .inner - .snapshot_library(environment, library_id, selection) - .await?; - let call = self.calls.fetch_add(1, Ordering::SeqCst); - if call == 3 { - let mut catalog = self.repository.load(&EnvironmentRef::Native).await?; - set_source_revision( - &mut catalog.libraries[0].skills[0].source_record, - "external", - ); - self.repository - .save(&EnvironmentRef::Native, &catalog) - .await?; - } - Ok(snapshot) - }) - } - } - impl LibraryUpdateSubjectSnapshots for PreviewDriftSubjects { fn snapshot_library<'a>( &'a self, @@ -1124,18 +741,6 @@ mod tests { } } - struct CancellingSource; - - impl SkillSourceModule for CancellingSource { - fn acquire_saved_groups<'a>( - &'a self, - _groups: &'a [SavedSkillSourceGroup], - _cancellation: CancellationSignal, - ) -> SkillSourceFuture<'a, Result, AppError>> { - Box::pin(async { Err(AppError::MutationCancelled) }) - } - } - struct FixedSource { discovery: DiscoverySessionHandle, payloads: Vec<(String, AcquiredPayloadHandle)>, @@ -1162,22 +767,15 @@ mod tests { .map(|skill| skill.name.clone()) .collect(), result: Ok(AcquiredSavedSkillSource { - facts: SourceSnapshotFacts { - discovery_session: self.discovery.clone(), - snapshot_id: RemoteSnapshotId::new( - NormalizedRef::Named("main".to_string()), - "main", - "new", - ), - complete_skill_path_catalog: groups[0] - .skills - .iter() - .map(|skill| skill.skill_path().to_string()) - .collect(), - }, + redirected_download_hosts: self + .redirected_download_host + .iter() + .cloned() + .collect(), + _leases: Vec::new(), + discovery_session: self.discovery.clone(), payloads: self.payloads.clone(), skill_errors: self.skill_errors.clone(), - redirected_download_host: self.redirected_download_host.clone(), }), }]) }) @@ -1246,35 +844,21 @@ mod tests { S: SkillSourceModule, T: TargetFactResolver + ContentManifestReader, { - let preview = service.preview(&request).await?; - match service - .execute( - &ExecuteLibraryUpdateRequest { - request, - expected_token: preview.token, - continuation: None, - risk_confirmation: None, - }, - cancellation, - ) - .await? - { - LibraryUpdateExecutionOutcome::Completed { response } => Ok(response), - LibraryUpdateExecutionOutcome::ConfirmationRequired { .. } => { - Err(AppError::StaleContext) - } - } + let prepared = service.prepare(&request, cancellation.clone()).await?; + service + .execute_prepared(prepared, cancellation, |_| {}) + .await } #[tokio::test] - async fn preview_token_rejects_a_changed_source_record_before_acquisition() { + async fn preparation_rejects_a_changed_source_record() { let Fixture { _temp, repository, library_id, manager, subjects, - .. + source, } = fixture(&["alpha"], None).await; let service = LibraryUpdateService::new( manager, @@ -1282,42 +866,34 @@ mod tests { fixed: subjects, calls: AtomicUsize::new(0), }, - CancellingSource, + source, targets(), Arc::new(SkillLibraryModule::new(repository)), ); - let request = UpdateLibrarySkillsRequest { - environment: EnvironmentRef::Native, - library_id, - skill_names: vec!["alpha".to_string()], - }; - - let preview = service.preview(&request).await.unwrap(); - let error = service - .execute( - &ExecuteLibraryUpdateRequest { - request, - expected_token: preview.token, - continuation: None, - risk_confirmation: None, + let prepared = service + .prepare( + &UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id, + skill_names: vec!["alpha".into()], }, CancellationSignal::default(), ) .await - .unwrap_err(); - - assert_eq!(error, AppError::StaleContext); + .unwrap(); + assert!(prepared.preview.skill_names.is_empty()); + assert_eq!(prepared.preview.blocked[0].error, AppError::StaleTarget); } #[tokio::test] - async fn preview_token_ignores_an_unrelated_catalog_record_change() { + async fn preparation_ignores_an_unrelated_catalog_record_change() { let Fixture { _temp, repository, library_id, manager, subjects, - .. + source, } = fixture(&["alpha"], None).await; let service = LibraryUpdateService::new( manager, @@ -1325,39 +901,27 @@ mod tests { fixed: subjects, calls: AtomicUsize::new(0), }, - CancellingSource, + source, targets(), Arc::new(SkillLibraryModule::new(repository)), ); - let request = UpdateLibrarySkillsRequest { - environment: EnvironmentRef::Native, - library_id, - skill_names: vec!["alpha".to_string()], - }; - - let preview = service.preview(&request).await.unwrap(); - let outcome = service - .execute( - &ExecuteLibraryUpdateRequest { - request, - expected_token: preview.token, - continuation: None, - risk_confirmation: None, + let prepared = service + .prepare( + &UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id, + skill_names: vec!["alpha".into()], }, CancellationSignal::default(), ) .await .unwrap(); - - assert!(matches!( - outcome, - LibraryUpdateExecutionOutcome::Completed { response } - if response.outcome == UpdateOutcome::Cancelled - )); + assert_eq!(prepared.preview.skill_names, vec!["alpha"]); + assert!(prepared.preview.blocked.is_empty()); } #[tokio::test] - async fn commit_rejects_a_source_record_changed_after_the_final_observation() { + async fn commit_rejects_a_source_record_changed_after_preparation() { let Fixture { _temp, repository, @@ -1368,34 +932,42 @@ mod tests { } = fixture(&["alpha"], None).await; let service = LibraryUpdateService::new( manager, - CommitRaceSubjects { - inner: LibraryUpdateSubjectProvider::new(repository.clone(), targets()), - repository: repository.clone(), - calls: AtomicUsize::new(0), - }, + LibraryUpdateSubjectProvider::new(repository.clone(), targets()), source, targets(), Arc::new(SkillLibraryModule::new(repository.clone())), ); - - let response = execute_completed( - &service, - UpdateLibrarySkillsRequest { - environment: EnvironmentRef::Native, - library_id, - skill_names: vec!["alpha".to_string()], - }, - CancellationSignal::default(), - ) - .await - .unwrap(); - + let prepared = service + .prepare( + &UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id, + skill_names: vec!["alpha".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + let mut changed = repository.load(&EnvironmentRef::Native).await.unwrap(); + set_source_revision( + &mut changed.libraries[0].skills[0].source_record, + "external", + ); + repository + .save(&EnvironmentRef::Native, &changed) + .await + .unwrap(); + let response = service + .execute_prepared(prepared, CancellationSignal::default(), |_| {}) + .await + .unwrap(); assert_eq!(response.results[0].status, LibraryUpdateSkillStatus::Failed); let saved = repository.load(&EnvironmentRef::Native).await.unwrap(); assert_eq!( source_revision(&saved.libraries[0].skills[0].source_record).as_deref(), Some("external") ); + assert_eq!(saved.libraries[0].skills[0].description, "alpha old"); } #[tokio::test] @@ -1405,35 +977,38 @@ mod tests { repository, library_id, manager, - subjects, + source, .. } = fixture(&["alpha", "beta"], None).await; let service = LibraryUpdateService::new( manager, - subjects, - CancellingSource, + LibraryUpdateSubjectProvider::new(repository.clone(), targets()), + source, targets(), Arc::new(SkillLibraryModule::new(repository)), ); - - let response = execute_completed( - &service, - UpdateLibrarySkillsRequest { - environment: EnvironmentRef::Native, - library_id, - skill_names: vec!["beta".to_string(), "alpha".to_string()], - }, - CancellationSignal::default(), - ) - .await - .unwrap(); - + let prepared = service + .prepare( + &UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id, + skill_names: vec!["beta".into(), "alpha".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + let cancellation = CancellationSignal::default(); + cancellation.cancel(); + let response = service + .execute_prepared(prepared, cancellation, |_| {}) + .await + .unwrap(); assert_eq!(response.results[0].skill_name, "beta"); assert_eq!( response.results[0].status, LibraryUpdateSkillStatus::Cancelled ); - assert_eq!(response.results[1].skill_name, "alpha"); assert_eq!(response.results[1].status, LibraryUpdateSkillStatus::NotRun); } @@ -1447,6 +1022,7 @@ mod tests { subjects: _, source, } = fixture(&["alpha", "beta"], None).await; + let acquisition_calls = source.calls.clone(); let service = LibraryUpdateService::new( manager, LibraryUpdateSubjectProvider::new(repository.clone(), targets()), @@ -1455,17 +1031,30 @@ mod tests { Arc::new(SkillLibraryModule::new(repository.clone())), ); - let response = execute_completed( - &service, - UpdateLibrarySkillsRequest { - environment: EnvironmentRef::Native, - library_id: library_id.clone(), - skill_names: vec!["alpha".to_string(), "beta".to_string()], - }, - CancellationSignal::default(), - ) - .await - .unwrap(); + let prepared = service + .prepare( + &UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id: library_id.clone(), + skill_names: vec!["alpha".to_string(), "beta".to_string()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert_eq!(prepared.preview.skill_names, vec!["alpha", "beta"]); + assert!(prepared.preview.blocked.is_empty()); + assert_eq!(acquisition_calls.load(Ordering::SeqCst), 1); + let before = repository.load(&EnvironmentRef::Native).await.unwrap(); + assert!(before.libraries[0] + .skills + .iter() + .all(|skill| source_revision(&skill.source_record).as_deref() != Some("new"))); + let response = service + .execute_prepared(prepared, CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(acquisition_calls.load(Ordering::SeqCst), 1); assert_eq!( response @@ -1500,6 +1089,8 @@ mod tests { assert_eq!( response .library + .as_ref() + .unwrap() .skills .iter() .map(|skill| skill.description.as_str()) @@ -1514,7 +1105,7 @@ mod tests { } #[tokio::test] - async fn redirected_source_requires_confirmation_before_any_library_write() { + async fn committed_update_survives_a_failed_detail_snapshot() { let Fixture { _temp, repository, @@ -1522,8 +1113,54 @@ mod tests { manager, subjects: _, source, + } = fixture(&["alpha"], None).await; + let service = LibraryUpdateService::new( + manager, + LibraryUpdateSubjectProvider::new(repository.clone(), targets()), + source, + targets(), + Arc::new(SkillLibraryModule::with_usages( + repository.clone(), + Arc::new(FailingSnapshotUsages), + )), + ); + + let response = execute_completed( + &service, + UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id, + skill_names: vec!["alpha".to_string()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!( + response.results[0].status, + LibraryUpdateSkillStatus::Succeeded + ); + assert!(response.library.is_none()); + assert!(matches!( + response.membership.snapshot_error, + Some(AppError::Io { .. }) + )); + let saved = repository.load(&EnvironmentRef::Native).await.unwrap(); + assert_eq!(saved.libraries[0].skills[0].description, "alpha updated"); + } + + #[tokio::test] + async fn redirected_source_is_prepared_before_confirmation_without_a_library_write() { + let Fixture { + _temp, + repository, + library_id, + manager, + source, + .. } = fixture(&["alpha"], Some("cdn.example.com")).await; - let acquisition_calls = source.calls.clone(); + let calls = source.calls.clone(); let service = LibraryUpdateService::new( manager, LibraryUpdateSubjectProvider::new(repository.clone(), targets()), @@ -1531,60 +1168,40 @@ mod tests { targets(), Arc::new(SkillLibraryModule::new(repository.clone())), ); - let request = UpdateLibrarySkillsRequest { - environment: EnvironmentRef::Native, - library_id, - skill_names: vec!["alpha".to_string()], - }; - let preview = service.preview(&request).await.unwrap(); - let first = service - .execute( - &ExecuteLibraryUpdateRequest { - request: request.clone(), - expected_token: preview.token, - continuation: None, - risk_confirmation: None, + let prepared = service + .prepare( + &UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id, + skill_names: vec!["alpha".into()], }, CancellationSignal::default(), ) .await .unwrap(); - let LibraryUpdateExecutionOutcome::ConfirmationRequired { - token, - redirected_download_hosts, - continuation, - } = first - else { - panic!("redirected update must require confirmation"); - }; - assert_eq!(redirected_download_hosts, vec!["cdn.example.com"]); - assert_eq!(acquisition_calls.load(Ordering::SeqCst), 1); - let unchanged = repository.load(&EnvironmentRef::Native).await.unwrap(); - assert_eq!(unchanged.libraries[0].skills[0].description, "alpha old"); - - let completed = service - .execute( - &ExecuteLibraryUpdateRequest { - request, - expected_token: token, - continuation: Some(continuation), - risk_confirmation: Some(LibraryUpdateRiskConfirmation { - redirected_download_hosts: vec!["cdn.example.com".to_string()], - }), - }, - CancellationSignal::default(), - ) + assert_eq!( + prepared.preview.redirected_download_hosts, + vec!["cdn.example.com"] + ); + assert_eq!( + repository + .load(&EnvironmentRef::Native) + .await + .unwrap() + .libraries[0] + .skills[0] + .description, + "alpha old" + ); + let response = service + .execute_prepared(prepared, CancellationSignal::default(), |_| {}) .await .unwrap(); - let LibraryUpdateExecutionOutcome::Completed { response } = completed else { - panic!("confirmed update must complete"); - }; - assert_eq!(acquisition_calls.load(Ordering::SeqCst), 1); + assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!( response.results[0].status, LibraryUpdateSkillStatus::Succeeded ); - assert_eq!(response.library.skills[0].description, "alpha updated"); } #[tokio::test] @@ -1628,7 +1245,10 @@ mod tests { response.results[0].status, LibraryUpdateSkillStatus::NameChanged ); - assert_eq!(response.library.skills[0].description, "alpha old"); + assert_eq!( + response.library.as_ref().unwrap().skills[0].description, + "alpha old" + ); } #[tokio::test] @@ -1673,8 +1293,14 @@ mod tests { response.results[1].status, LibraryUpdateSkillStatus::Succeeded ); - assert_eq!(response.library.skills[0].description, "alpha old"); - assert_eq!(response.library.skills[1].description, "beta updated"); + assert_eq!( + response.library.as_ref().unwrap().skills[0].description, + "alpha old" + ); + assert_eq!( + response.library.as_ref().unwrap().skills[1].description, + "beta updated" + ); } fn payload_manager() -> PayloadSessionManager { @@ -1723,6 +1349,7 @@ mod tests { extra: serde_json::Map::new(), }) .collect(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), diff --git a/src-tauri/src/application/manage_agents.rs b/src-tauri/src/application/manage_agents.rs index 0848e68f..3452a135 100644 --- a/src-tauri/src/application/manage_agents.rs +++ b/src-tauri/src/application/manage_agents.rs @@ -459,19 +459,23 @@ where let original_payload = if selection.add_options.is_empty() { None } else { - Some( - self.acquirer - .acquire( - &request.context, - &request.skill_name, - loaded - .observed - .plan - .standard_fact() - .map_err(|error| error.into_app_error())?, - ) - .await?, - ) + let candidates = loaded + .observed + .plan + .direct_content_candidates(&loaded.catalog); + let (source, hash) = self + .acquirer + .select_source(&request.context, &request.skill_name, &candidates) + .await?; + validate_shared_link_source(&selection, &loaded.observed.plan, &source)?; + let handle = self + .acquirer + .acquire(&request.context, &request.skill_name, &source) + .await?; + if handle.manifest_hash != hash { + return Err(AppError::StalePayload); + } + Some(handle) }; Ok(ManageAgentsPreviewOutcome::Ready { preview: manage_preview( @@ -595,6 +599,15 @@ where (Some(handle), false) if same_environment_identity(&handle.environment, &request.context.environment) => { + let candidates = observed.plan.direct_content_candidates(&catalog); + let (source, hash) = self + .acquirer + .select_source(&request.context, &request.skill_name, &candidates) + .await?; + validate_shared_link_source(&selection, &observed.plan, &source)?; + if handle.manifest_hash != hash { + return Err(AppError::StalePayload); + } Some(self.payloads.pin_verified(handle).await?) } _ => return Err(AppError::StalePayload), @@ -779,7 +792,7 @@ fn build_observed_agent_selection( } states.push(state); } - let initial_selected_option_ids = states + let baseline_selected_option_ids = states .iter() .filter(|state| state.initial_selected) .map(|state| state.option_id.clone()) @@ -822,7 +835,7 @@ fn build_observed_agent_selection( }) .collect(); let mut selection = catalog.snapshot().clone(); - selection.initial_selected_option_ids = initial_selected_option_ids; + selection.baseline_selected_option_ids = baseline_selected_option_ids; selection.revision = revision; selection.user_mode_option_ids = user_mode_option_ids; @@ -849,6 +862,26 @@ struct ResolvedAdditions { facts: Vec, } +fn validate_shared_link_source( + selection: &ResolvedManageSelection, + plan: &crate::application::scope_skill_planning::ScopeSkillPlan, + source: &ResolvedTargetFact, +) -> Result<(), AppError> { + if selection.requested_mode == InstallMode::Symlink + && selection + .add_options + .iter() + .any(|option| !option.placement.content.uses_eve_payload()) + && !plan.has_shared_source(source) + { + return Err(AppError::CapabilityUnavailable { + capability: "sharedSkillLinkSource".into(), + path: Some(source.destination.native_path.clone()), + }); + } + Ok(()) +} + fn manage_preview( request: &ResolvedManageSelection, observed: &ManageObservedState, @@ -1436,6 +1469,7 @@ mod tests { environment: EnvironmentRef::Native, native_path: (*path).to_string(), }, + manage_target_fact("demo", path, TargetEntryKind::Directory, None).key, ) }) .collect::>(); diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index 64d8ea3a..f3da8ec3 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -23,10 +23,10 @@ pub mod installed_skill_resolver; pub(crate) mod library_agent_placements; pub mod library_application; pub mod library_candidates; +pub mod library_membership; pub mod library_update; pub mod manage_agents; pub mod mutation; -pub mod network_settings; pub mod payload_session; pub mod planning_facts; pub mod recovery; @@ -51,6 +51,8 @@ pub mod source_snapshot_reuse; pub mod update; pub mod update_check; pub mod update_planner; +pub mod update_preparation; +pub mod update_records; pub mod update_subjects; pub(crate) mod wellknown_access; pub(crate) mod wsl_source_access; diff --git a/src-tauri/src/application/mutation/coordinator.rs b/src-tauri/src/application/mutation/coordinator.rs index 19013d01..95fee0d4 100644 --- a/src-tauri/src/application/mutation/coordinator.rs +++ b/src-tauri/src/application/mutation/coordinator.rs @@ -4,14 +4,17 @@ use std::pin::Pin; use std::sync::Arc; use crate::application::mutation::plan::{ExecutionUnit, MutationPlan, RuntimeRevisions}; +#[cfg(test)] +use crate::application::mutation::result::MutationWarningCode; use crate::application::mutation::result::{ - ErrorReport, MutationUnitResult, MutationUnitStatus, MutationWarning, MutationWarningCode, + ErrorReport, MutationUnitResult, MutationUnitStatus, MutationWarning, }; use crate::application::payload_session::PinnedPayloadLease; use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::PayloadId; use crate::environment::types::SkillLocationRef; use crate::error::AppError; +use crate::storage::atomic_document::DocumentWriteFailure; use crate::storage::lock_plan::{LockCommitReceipt, PreparedLockMutation}; pub type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -25,9 +28,11 @@ pub struct MutationUnitProgress { pub type MutationUnitObserver<'a> = Arc; -pub trait PreparedEntryExecutor: Send + Sync { +#[cfg(test)] +pub trait PreparedEntryTestDriver: Send + Sync { type Staged: Send; + #[cfg(test)] fn stage<'a>( &'a self, unit: &'a ExecutionUnit, @@ -56,7 +61,105 @@ pub trait PreparedLockCommitter: Send + Sync { fn commit<'a>( &'a self, mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result>; + ) -> BoxFuture<'a, Result>; +} + +#[derive(Debug, Clone)] +pub struct UnitTransactionReceipt { + pub lock: Option, + pub warnings: Vec, +} + +pub trait PreparedUnitExecutor: Send + Sync { + type Prepared: Send; + + fn prepare<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result>; + + fn execute<'a>( + &'a self, + prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result>; +} + +#[cfg(test)] +pub struct PhasedUnitExecutor { + entries: E, + locks: L, +} + +#[cfg(test)] +impl PhasedUnitExecutor { + pub fn new(entries: E, locks: L) -> Self { + Self { entries, locks } + } +} + +#[cfg(test)] +impl PreparedUnitExecutor for PhasedUnitExecutor +where + E: PreparedEntryTestDriver, + L: PreparedLockCommitter, +{ + type Prepared = E::Staged; + + fn prepare<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + self.entries.stage(unit, payloads, cancellation) + } + + fn execute<'a>( + &'a self, + mut prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let result = async { + self.entries.recheck_entries(&prepared).await?; + self.entries.swap(&mut prepared).await?; + self.entries.verify(&prepared).await?; + match lock { + Some(lock) => self + .locks + .commit(lock) + .await + .map(Some) + .map_err(DocumentWriteFailure::into_error), + None => Ok(None), + } + } + .await; + match result { + Ok(lock) => match self.entries.cleanup(prepared).await { + Ok(warnings) => Ok(UnitTransactionReceipt { lock, warnings }), + Err(error) => Ok(UnitTransactionReceipt { + lock, + warnings: vec![MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: Some(error.to_string().chars().take(4096).collect()), + }], + }), + }, + Err(primary) => { + let restore = self.entries.restore(&mut prepared).await; + let _ = self.entries.cleanup(prepared).await; + Err(restore.err().unwrap_or(primary)) + } + } + }) + } } pub trait RuntimeRevisionSource: Send + Sync { @@ -101,24 +204,18 @@ pub struct RuntimeRevisionSnapshot { pub authority: RuntimeAuthorityRevisions, } -pub struct MutationCoordinator { - entries: E, - locks: L, +pub struct MutationCoordinator { + units: E, revisions: R, } -impl MutationCoordinator +impl MutationCoordinator where - E: PreparedEntryExecutor, - L: PreparedLockCommitter, + E: PreparedUnitExecutor, R: RuntimeRevisionSource, { - pub fn new(entries: E, locks: L, revisions: R) -> Self { - Self { - entries, - locks, - revisions, - } + pub fn new(units: E, revisions: R) -> Self { + Self { units, revisions } } #[cfg(test)] @@ -147,11 +244,11 @@ where } let runtime = self.revisions.snapshot(&unit.target).await?; validate_runtime_revisions(&runtime.revisions, &unit.expected_revisions)?; - let staged = self - .entries - .stage(&unit, &plan.payloads, cancellation.clone()) + let prepared = self + .units + .prepare(&unit, &plan.payloads, cancellation.clone()) .await?; - Ok((staged, runtime.authority)) + Ok((prepared, runtime.authority)) } .await; staged_units.insert(index, preflight); @@ -164,8 +261,7 @@ where .remove(&index) .expect("every unit has a preflight result") { - Ok((staged, _)) => { - let _ = self.entries.cleanup(staged).await; + Ok((_prepared, _)) => { results.push(failed_result(unit, AppError::MutationCancelled, false)); } Err(error) => { @@ -176,6 +272,24 @@ where return results; } + if plan.kind == crate::core::mutation::MutationKind::ManageLibraries + && staged_units.values().any(Result::is_err) + { + let mut results = Vec::with_capacity(plan.units.len()); + for (index, unit) in plan.units.iter().enumerate() { + match staged_units + .remove(&index) + .expect("every unit has a preflight result") + { + Ok((_prepared, _)) => results.push(not_run(unit, AppError::StaleTarget)), + Err(error) => { + results.push(failed_result(unit, error_for_preflight(&error), false)); + } + } + } + return results; + } + let mut results = Vec::with_capacity(plan.units.len()); let mut blocked_targets = BTreeSet::new(); for index in 0..plan.units.len() { @@ -188,8 +302,8 @@ where let staged = staged_units .remove(&index) .expect("every unit has a preflight result"); - let (mut staged, expected_authority) = match staged { - Ok(staged) => staged, + let (prepared, expected_authority) = match staged { + Ok(prepared) => prepared, Err(error) => { results.push(failed_result(&unit, error_for_preflight(&error), false)); continue; @@ -200,36 +314,26 @@ where .iter() .any(|target| blocked_targets.contains(&target.key)) { - let _ = self.entries.cleanup(staged).await; results.push(not_run(&unit, AppError::StaleTarget)); continue; } if cancellation.is_cancelled() { - let _ = self.entries.cleanup(staged).await; results.push(failed_result(&unit, AppError::MutationCancelled, false)); continue; } - let phase_result = async { + let transaction = async { self.recheck_runtime_authority(&unit, &expected_authority) .await?; - self.entries.recheck_entries(&staged).await?; - self.entries.swap(&mut staged).await?; - self.entries.verify(&staged).await?; - let receipt = match &unit.lock_mutation { - Some(mutation) => Some(self.locks.commit(mutation).await?), - None => None, - }; - Ok::<_, AppError>(receipt) + self.units + .execute(prepared, unit.lock_mutation.as_ref(), cancellation.clone()) + .await } .await; - let receipt = match phase_result { - Ok(receipt) => receipt, - Err(primary) => { - let restore = self.entries.restore(&mut staged).await; - let _ = self.entries.cleanup(staged).await; - let error = restore.err().unwrap_or(primary); + let transaction = match transaction { + Ok(transaction) => transaction, + Err(error) => { if matches!(error, AppError::RecoveryRequired { .. }) { blocked_targets.extend( unit.expected_targets @@ -242,24 +346,11 @@ where } }; - if let Some(receipt) = &receipt { + if let Some(receipt) = &transaction.lock { advance_future_lock_expectations(&mut plan.units[index + 1..], &unit, receipt); } let lock_committed = unit.lock_mutation.is_some(); - match self.entries.cleanup(staged).await { - Ok(warnings) => results.push(success_result(&unit, lock_committed, warnings)), - Err(error) => { - results.push(success_result( - &unit, - lock_committed, - vec![MutationWarning { - code: MutationWarningCode::BackupCleanupFailed, - parameters: BTreeMap::new(), - technical_details: Some(error.to_string().chars().take(4096).collect()), - }], - )); - } - } + results.push(success_result(&unit, lock_committed, transaction.warnings)); } results } @@ -282,6 +373,18 @@ where } } +#[cfg(test)] +impl MutationCoordinator, R> +where + E: PreparedEntryTestDriver, + L: PreparedLockCommitter, + R: RuntimeRevisionSource, +{ + pub fn from_phases(entries: E, locks: L, revisions: R) -> Self { + Self::new(PhasedUnitExecutor::new(entries, locks), revisions) + } +} + fn validate_runtime_revisions( actual: &RuntimeRevisions, expected: &RuntimeRevisions, @@ -420,177 +523,125 @@ mod tests { #[derive(Clone, Copy, PartialEq, Eq)] enum Failure { None, - StageSecond, + PrepareSecond, CancelAfterSecond, - Recheck, - Lock, + Execute, Cleanup, - LockAndRestoreRequired, + RecoveryRequired, } - struct FakeEntryExecutor { + struct FakeUnitExecutor { log: Arc>>, failure: Failure, } - struct FakeStaged { + struct FakePrepared { unit_id: String, } - impl PreparedEntryExecutor for FakeEntryExecutor { - type Staged = FakeStaged; + impl PreparedUnitExecutor for FakeUnitExecutor { + type Prepared = FakePrepared; - fn stage<'a>( + fn prepare<'a>( &'a self, unit: &'a ExecutionUnit, _payloads: &'a BTreeMap, cancellation: CancellationSignal, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { - if self.failure == Failure::StageSecond && unit.id == "second" { + if self.failure == Failure::PrepareSecond && unit.id == "second" { return Err(AppError::ExecutionFailed { - message: "second target cannot be staged".to_string(), + message: "second target cannot be prepared".to_string(), }); } - self.log.lock().unwrap().push(format!("stage:{}", unit.id)); - self.log.lock().unwrap().push(format!("marker:{}", unit.id)); + self.log + .lock() + .unwrap() + .push(format!("prepare:{}", unit.id)); if self.failure == Failure::CancelAfterSecond && unit.id == "second" { cancellation.cancel(); } - Ok(FakeStaged { + Ok(FakePrepared { unit_id: unit.id.clone(), }) }) } - fn recheck_entries<'a>( - &'a self, - staged: &'a Self::Staged, - ) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("recheck:{}", staged.unit_id)); - if self.failure == Failure::Recheck { - return Err(AppError::StaleTarget); - } - Ok(()) - }) - } - - fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("swap:{}", staged.unit_id)); - Ok(()) - }) - } - - fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("verify:{}", staged.unit_id)); - Ok(()) - }) - } - - fn restore<'a>( + fn execute<'a>( &'a self, - staged: &'a mut Self::Staged, - ) -> BoxFuture<'a, Result<(), AppError>> { + prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { Box::pin(async move { self.log .lock() .unwrap() - .push(format!("restore:{}", staged.unit_id)); - if self.failure == Failure::LockAndRestoreRequired { + .push(format!("execute:{}", prepared.unit_id)); + if self.failure == Failure::RecoveryRequired && prepared.unit_id == "first" { return Err(AppError::RecoveryRequired { recovery_resource_id: RecoveryResourceId::parse(format!( "recovery-{}", - staged.unit_id + prepared.unit_id )) .unwrap(), - message: "restore failed".to_string(), + message: "transaction result is unknown".to_string(), }); } - Ok(()) - }) - } - - fn cleanup<'a>( - &'a self, - staged: Self::Staged, - ) -> BoxFuture<'a, Result, AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("cleanup:{}", staged.unit_id)); - if self.failure == Failure::Cleanup { - Err(AppError::ExecutionFailed { - message: "backup remains".to_string(), - }) - } else { - Ok(Vec::new()) + if self.failure == Failure::Execute { + return Err(AppError::StaleTarget); } + let lock = lock.map(|mutation| LockCommitReceipt { + entry_snapshots: mutation.expected.entry_snapshots.clone(), + root_snapshots: mutation.expected.root_snapshots.clone(), + }); + let warnings = (self.failure == Failure::Cleanup) + .then(|| MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: Some("backup remains".to_string()), + }) + .into_iter() + .collect(); + Ok(UnitTransactionReceipt { lock, warnings }) }) } } - struct FakeLockCommitter { - log: Arc>>, - failure: Failure, - } - - struct RecordingLockCommitter { + struct RecordingUnitExecutor { expected_entries: Arc>>>, } - impl PreparedLockCommitter for FakeLockCommitter { - fn commit<'a>( + impl PreparedUnitExecutor for RecordingUnitExecutor { + type Prepared = (); + + fn prepare<'a>( &'a self, - mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("lock:{}", mutation.skill_name())); - if self.failure == Failure::Lock - || (self.failure == Failure::LockAndRestoreRequired - && mutation.skill_name() == "first") - { - Err(AppError::ExecutionFailed { - message: "lock failed".to_string(), - }) - } else { - Ok(LockCommitReceipt { - entry_snapshots: mutation.expected.entry_snapshots.clone(), - root_snapshots: mutation.expected.root_snapshots.clone(), - }) - } - }) + _unit: &'a ExecutionUnit, + _payloads: &'a BTreeMap, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(()) }) } - } - impl PreparedLockCommitter for RecordingLockCommitter { - fn commit<'a>( + fn execute<'a>( &'a self, - mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + _prepared: Self::Prepared, + mutation: Option<&'a PreparedLockMutation>, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { Box::pin(async move { + let mutation = mutation.expect("fixture unit has a lock mutation"); self.expected_entries .lock() .unwrap() .push(mutation.expected.entry_snapshots.keys().cloned().collect()); - Ok(LockCommitReceipt { - entry_snapshots: mutation.expected.entry_snapshots.clone(), - root_snapshots: mutation.expected.root_snapshots.clone(), + Ok(UnitTransactionReceipt { + lock: Some(LockCommitReceipt { + entry_snapshots: mutation.expected.entry_snapshots.clone(), + root_snapshots: mutation.expected.root_snapshots.clone(), + }), + warnings: Vec::new(), }) }) } @@ -728,13 +779,9 @@ mod tests { fn coordinator( failure: Failure, log: Arc>>, - ) -> MutationCoordinator { + ) -> MutationCoordinator { MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure, - }, - FakeLockCommitter { + FakeUnitExecutor { log: log.clone(), failure, }, @@ -755,7 +802,7 @@ mod tests { } #[tokio::test] - async fn successful_unit_uses_the_exact_transaction_order() { + async fn successful_unit_uses_the_backend_transaction_seam() { let log = Arc::new(Mutex::new(Vec::new())); let results = coordinator(Failure::None, log.clone()) .execute( @@ -769,22 +816,17 @@ mod tests { *log.lock().unwrap(), vec![ "runtime:global", - "stage:one", - "marker:one", + "prepare:one", "runtime:global", - "recheck:one", - "swap:one", - "verify:one", - "lock:one", - "cleanup:one", + "execute:one", ] ); } #[tokio::test] - async fn stale_entry_recheck_stops_before_swap_verify_and_lock() { + async fn backend_transaction_failure_is_returned_without_coordinator_phases() { let log = Arc::new(Mutex::new(Vec::new())); - let results = coordinator(Failure::Recheck, log.clone()) + let results = coordinator(Failure::Execute, log.clone()) .execute( plan(vec![unit("one", key("one"))]), CancellationSignal::default(), @@ -796,12 +838,9 @@ mod tests { *log.lock().unwrap(), vec![ "runtime:global", - "stage:one", - "marker:one", + "prepare:one", "runtime:global", - "recheck:one", - "restore:one", - "cleanup:one", + "execute:one", ] ); } @@ -837,19 +876,16 @@ mod tests { } #[tokio::test] - async fn lock_failure_restores_before_cleanup() { + async fn backend_transaction_failure_remains_a_failed_unit() { let log = Arc::new(Mutex::new(Vec::new())); - let results = coordinator(Failure::Lock, log.clone()) + let results = coordinator(Failure::Execute, log.clone()) .execute( plan(vec![unit("one", key("one"))]), CancellationSignal::default(), ) .await; assert_eq!(results[0].status, MutationUnitStatus::Failed); - let log = log.lock().unwrap(); - assert!(log - .windows(3) - .any(|window| { window == ["lock:one", "restore:one", "cleanup:one"] })); + assert!(log.lock().unwrap().iter().any(|item| item == "execute:one")); } #[tokio::test] @@ -873,7 +909,7 @@ mod tests { async fn recovery_required_blocks_only_later_overlapping_units() { let log = Arc::new(Mutex::new(Vec::new())); let shared = key("shared"); - let results = coordinator(Failure::LockAndRestoreRequired, log) + let results = coordinator(Failure::RecoveryRequired, log) .execute( plan(vec![ unit("first", shared.clone()), @@ -889,9 +925,9 @@ mod tests { } #[tokio::test] - async fn one_staging_failure_does_not_abort_independent_units() { + async fn one_prepare_failure_does_not_abort_independent_units() { let log = Arc::new(Mutex::new(Vec::new())); - let results = coordinator(Failure::StageSecond, log.clone()) + let results = coordinator(Failure::PrepareSecond, log.clone()) .execute( plan(vec![ unit("first", key("first")), @@ -906,13 +942,43 @@ mod tests { assert_eq!(results[1].status, MutationUnitStatus::Failed); assert_eq!(results[2].status, MutationUnitStatus::Succeeded); let log = log.lock().unwrap(); - let stage_third = log.iter().position(|entry| entry == "stage:third").unwrap(); - let swap_first = log.iter().position(|entry| entry == "swap:first").unwrap(); - assert!(stage_third < swap_first); + let prepare_third = log + .iter() + .position(|entry| entry == "prepare:third") + .unwrap(); + let execute_first = log + .iter() + .position(|entry| entry == "execute:first") + .unwrap(); + assert!(prepare_third < execute_first); } #[tokio::test] - async fn cancellation_before_commit_cleans_every_staged_unit() { + async fn library_application_prepare_failure_stops_the_entire_scope_before_writes() { + let log = Arc::new(Mutex::new(Vec::new())); + let mut plan = plan(vec![ + unit("first", key("first")), + unit("second", key("second")), + unit("third", key("third")), + ]); + plan.kind = crate::core::mutation::MutationKind::ManageLibraries; + + let results = coordinator(Failure::PrepareSecond, log.clone()) + .execute(plan, CancellationSignal::default()) + .await; + + assert_eq!(results[0].status, MutationUnitStatus::NotRun); + assert_eq!(results[1].status, MutationUnitStatus::Failed); + assert_eq!(results[2].status, MutationUnitStatus::NotRun); + assert!(!log + .lock() + .unwrap() + .iter() + .any(|entry| entry.starts_with("execute:"))); + } + + #[tokio::test] + async fn cancellation_after_prepare_stops_before_every_transaction() { let log = Arc::new(Mutex::new(Vec::new())); let results = coordinator(Failure::CancelAfterSecond, log.clone()) .execute( @@ -928,10 +994,11 @@ mod tests { assert!(results .iter() .all(|result| result.status != MutationUnitStatus::Succeeded)); - let log = log.lock().unwrap(); - assert!(log.iter().any(|entry| entry == "cleanup:first")); - assert!(log.iter().any(|entry| entry == "cleanup:second")); - assert!(!log.iter().any(|entry| entry.starts_with("swap:"))); + assert!(!log + .lock() + .unwrap() + .iter() + .any(|entry| entry.starts_with("execute:"))); } #[tokio::test] @@ -939,11 +1006,7 @@ mod tests { let log = Arc::new(Mutex::new(Vec::new())); let expected_entries = Arc::new(Mutex::new(Vec::new())); let coordinator = MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure: Failure::None, - }, - RecordingLockCommitter { + RecordingUnitExecutor { expected_entries: Arc::clone(&expected_entries), }, FakeRevisions { @@ -979,11 +1042,7 @@ mod tests { let log = Arc::new(Mutex::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); let coordinator = MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure: Failure::None, - }, - FakeLockCommitter { + FakeUnitExecutor { log, failure: Failure::None, }, @@ -1005,15 +1064,11 @@ mod tests { } #[tokio::test] - async fn selected_binding_change_is_rejected_after_staging_before_swap() { + async fn selected_binding_change_is_rejected_after_prepare_before_execute() { let log = Arc::new(Mutex::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); let coordinator = MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure: Failure::None, - }, - FakeLockCommitter { + FakeUnitExecutor { log: log.clone(), failure: Failure::None, }, @@ -1031,6 +1086,10 @@ mod tests { .await; assert_eq!(results[0].status, MutationUnitStatus::Failed); - assert!(!log.lock().unwrap().iter().any(|entry| entry == "swap:one")); + assert!(!log + .lock() + .unwrap() + .iter() + .any(|entry| entry == "execute:one")); } } diff --git a/src-tauri/src/application/mutation/planning.rs b/src-tauri/src/application/mutation/planning.rs index 62116057..bf9616ef 100644 --- a/src-tauri/src/application/mutation/planning.rs +++ b/src-tauri/src/application/mutation/planning.rs @@ -10,7 +10,6 @@ use crate::application::mutation::plan::{ use crate::application::payload_session::PinnedPayloadLease; use crate::core::mutation::MutationKind; use crate::core::skill_payload::PayloadId; -use crate::environment::runtime::ContextSnapshotRevision; use crate::environment::types::SkillLocationRef; use crate::error::AppError; use crate::storage::lock_plan::PreparedLockMutation; @@ -84,23 +83,6 @@ where }) } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PreviewScopeRevisions { - registry: String, - environment: String, - context: ContextSnapshotRevision, -} - -impl From<&PreviewToken> for PreviewScopeRevisions { - fn from(token: &PreviewToken) -> Self { - Self { - registry: token.registry_revision.clone(), - environment: token.environment_revision.clone(), - context: token.context_revision.clone(), - } - } -} - pub fn validate_exact_preview( expected: &PreviewToken, actual: &PreviewToken, @@ -119,22 +101,6 @@ pub fn validate_exact_preview( Ok(()) } -pub fn validate_same_scope_revisions( - expected: &PreviewScopeRevisions, - actual: &PreviewScopeRevisions, -) -> Result<(), AppError> { - if expected.registry != actual.registry { - return Err(AppError::StaleRegistry); - } - if expected.environment != actual.environment { - return Err(AppError::StaleEnvironment); - } - if expected.context != actual.context { - return Err(AppError::StaleContext); - } - Ok(()) -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -142,9 +108,8 @@ mod tests { use std::path::Path; use super::{ - assemble_plan, issue_preview_token, validate_exact_preview, validate_same_scope_revisions, - MutationPlanDraft, MutationUnitDraft, PreparedMutationEntries, PreviewScopeRevisions, - PreviewTokenDraft, + assemble_plan, issue_preview_token, validate_exact_preview, MutationPlanDraft, + MutationUnitDraft, PreparedMutationEntries, PreviewTokenDraft, }; use crate::application::mutation::plan::{ ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, PreviewToken, @@ -210,59 +175,6 @@ mod tests { assert_eq!(validate_exact_preview(&expected, &expected), Ok(())); } - #[test] - fn matching_scope_revisions_ignore_generation_but_do_not_authorize_an_update() { - let expected_token = token("registry-1", "environment-1", "context-1", "generation-1"); - let expected = PreviewScopeRevisions::from(&expected_token); - let changed_generation = PreviewScopeRevisions::from(&token( - "registry-1", - "environment-1", - "context-1", - "generation-2", - )); - assert_eq!( - validate_same_scope_revisions(&expected, &changed_generation), - Ok(()) - ); - - assert_eq!( - validate_same_scope_revisions( - &expected, - &PreviewScopeRevisions::from(&token( - "registry-2", - "environment-2", - "context-2", - "generation-2", - )), - ), - Err(AppError::StaleRegistry) - ); - assert_eq!( - validate_same_scope_revisions( - &expected, - &PreviewScopeRevisions::from(&token( - "registry-1", - "environment-2", - "context-2", - "generation-2", - )), - ), - Err(AppError::StaleEnvironment) - ); - assert_eq!( - validate_same_scope_revisions( - &expected, - &PreviewScopeRevisions::from(&token( - "registry-1", - "environment-1", - "context-2", - "generation-2", - )), - ), - Err(AppError::StaleContext) - ); - } - #[test] fn issued_preview_token_preserves_the_existing_byte_contract() { let request = vec!["agent-a", "agent-b"]; diff --git a/src-tauri/src/application/mutation/result.rs b/src-tauri/src/application/mutation/result.rs index 46355316..7796cfb3 100644 --- a/src-tauri/src/application/mutation/result.rs +++ b/src-tauri/src/application/mutation/result.rs @@ -27,6 +27,7 @@ pub enum MutationUnitStatus { #[specta(rename_all = "camelCase")] pub enum OperationErrorCode { Validation, + NoUpdateTargets, SkillPlacementTargetConflict, WellKnownScopeNotFound, EnvironmentUnavailable, @@ -51,6 +52,7 @@ pub enum OperationErrorCode { LibraryRecoveryIncomplete, ConfigurationReadOnly, ConfigurationCorrupted, + ConfigurationWriteUnconfirmed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -154,6 +156,7 @@ impl ErrorReport { .insert("count".to_string(), usages.len().to_string()); report } + AppError::ProjectMatchesEnvironmentHome => Self::new(OperationErrorCode::Validation), AppError::SkillPlacementTargetConflict { skill_name, agent_ids, @@ -250,14 +253,6 @@ impl ErrorReport { .insert("reason".to_string(), reason.code().to_string()); report } - AppError::DirectDownloadUnsupportedOperation => { - let mut report = Self::new(OperationErrorCode::Validation); - report.parameters.insert( - "reason".to_string(), - "direct-download-unsupported-operation".to_string(), - ); - report - } AppError::DirectDownloadConflict { target } => { let mut report = Self::new(OperationErrorCode::Validation); report @@ -409,6 +404,9 @@ impl ErrorReport { Self::with_details(OperationErrorCode::RestoreFailed, false, message) } AppError::ConfigurationReadOnly => Self::new(OperationErrorCode::ConfigurationReadOnly), + AppError::ConfigurationWriteUnconfirmed => { + Self::new(OperationErrorCode::ConfigurationWriteUnconfirmed) + } AppError::ConfigurationCorrupted { message } | AppError::Yaml { message } | AppError::Json { message } => { diff --git a/src-tauri/src/application/network_settings.rs b/src-tauri/src/application/network_settings.rs deleted file mode 100644 index e2dd9435..00000000 --- a/src-tauri/src/application/network_settings.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::core::{read_config, update_config}; -use crate::error::AppError; -use crate::models::NetworkProxySettings; - -pub(crate) fn get_proxy_settings() -> Result { - Ok(read_config()?.network_proxy) -} - -pub(crate) fn save_proxy_settings( - settings: NetworkProxySettings, - activate: impl FnOnce(NetworkProxySettings), -) -> Result { - save_proxy_settings_with(settings, activate, |normalized| { - let normalized = normalized.clone(); - update_config(move |config| config.network_proxy = normalized)?; - Ok(()) - }) -} - -fn save_proxy_settings_with( - settings: NetworkProxySettings, - activate: impl FnOnce(NetworkProxySettings), - persist: impl FnOnce(&NetworkProxySettings) -> Result<(), AppError>, -) -> Result { - let normalized = - settings - .validate_and_normalize() - .map_err(|error| AppError::InvalidProxySettings { - code: error.code().to_string(), - })?; - persist(&normalized)?; - activate(normalized.clone()); - Ok(normalized) -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use crate::error::AppError; - use crate::models::{NetworkProxySettings, ProxyMode}; - - use super::save_proxy_settings_with; - - #[test] - fn runtime_settings_change_only_after_proxy_settings_are_persisted() { - let activated = Mutex::new(Vec::new()); - let custom = NetworkProxySettings { - mode: ProxyMode::Custom, - custom_proxy_url: Some("http://127.0.0.1:7890".to_string()), - ..NetworkProxySettings::default() - }; - - let error = save_proxy_settings_with( - custom.clone(), - |settings| activated.lock().expect("settings").push(settings), - |_| { - Err(AppError::Io { - message: "write failed".to_string(), - }) - }, - ) - .expect_err("persist failure"); - assert!(matches!(error, AppError::Io { .. })); - assert!(activated.lock().expect("settings").is_empty()); - - let saved = save_proxy_settings_with( - custom, - |settings| activated.lock().expect("settings").push(settings), - |_| Ok(()), - ) - .expect("persisted settings"); - assert_eq!(saved.mode, ProxyMode::Custom); - assert_eq!(activated.lock().expect("settings").as_slice(), [saved]); - } -} diff --git a/src-tauri/src/application/payload_session.rs b/src-tauri/src/application/payload_session.rs index f513e1bc..ca4bfdb1 100644 --- a/src-tauri/src/application/payload_session.rs +++ b/src-tauri/src/application/payload_session.rs @@ -1,13 +1,16 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::future::Future; +use std::panic::AssertUnwindSafe; use std::path::PathBuf; use std::pin::Pin; use std::sync::{Arc, Mutex, Weak}; +use futures_util::FutureExt; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use specta::Type; +use tokio::sync::watch; use uuid::Uuid; use crate::application::agent_intent::AgentWriteIntent; @@ -15,9 +18,10 @@ use crate::application::mutation::plan::RuntimeRevisions; use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::{SkillPayload, SkillPayloadManifest}; use crate::environment::planning::{ResolvedTargetFact, TargetEntryKind}; -use crate::environment::runtime::ExecutionBackend; +use crate::environment::runtime::{ExecutionBackend, PhysicalParentIdentity}; use crate::environment::types::{ - same_environment_identity, EnvironmentKey, EnvironmentRef, SkillLocation, SkillLocationRef, + normalized_wsl_distro_name, same_environment_identity, EnvironmentKey, EnvironmentRef, + SkillLocation, SkillLocationRef, }; use crate::error::AppError; @@ -59,7 +63,16 @@ pub struct RetainedDiscoverySource { skills: BTreeMap, well_known_metadata: HashMap, + member_failures: BTreeMap, + redirected_download_hosts: Vec, _owner: Arc, + managed_bytes: u64, + cleanup: Option>, + available: bool, +} + +pub trait RetainedSourceCleanup: Send + Sync { + fn remove(&self) -> PayloadStorageFuture<'_, Result<(), AppError>>; } impl RetainedDiscoverySource { @@ -74,10 +87,25 @@ impl RetainedDiscoverySource { descriptor, skills, well_known_metadata: HashMap::new(), + member_failures: BTreeMap::new(), + redirected_download_hosts: Vec::new(), _owner: Arc::new(owner), + managed_bytes: 0, + cleanup: None, + available: true, } } + pub(crate) fn with_managed_storage( + mut self, + bytes: u64, + cleanup: Arc, + ) -> Self { + self.managed_bytes = bytes; + self.cleanup = Some(cleanup); + self + } + pub(crate) fn with_well_known_metadata( mut self, metadata: HashMap, @@ -93,6 +121,33 @@ impl RetainedDiscoverySource { self.well_known_metadata.get(skill_name) } + pub(crate) fn with_source_outcomes( + mut self, + failures: BTreeMap, + hosts: Vec, + ) -> Self { + self.member_failures = failures; + self.redirected_download_hosts = hosts; + self + } + + pub(crate) fn member_failure( + &self, + name: &str, + ) -> Option { + self.member_failures.get(name).copied() + } + + pub(crate) fn download_hosts(&self) -> Vec { + self.redirected_download_hosts + .iter() + .cloned() + .chain(self.descriptor.redirected_download_host.clone()) + .collect::>() + .into_iter() + .collect() + } + pub fn location(&self) -> &DiscoverySourceLocation { &self.location } @@ -138,6 +193,7 @@ pub struct CopySourceSnapshot { pub revisions: RuntimeRevisions, pub lock_entry: Option, pub project_identity: ResolvedTargetFact, + /// 本次复制选定的实际直接安装目录。 pub standard_identity: ResolvedTargetFact, pub agent_intents: Vec, } @@ -236,7 +292,8 @@ pub enum PayloadLocalSource { }, WslManaged { distro_name: String, - payload_root: String, + worker_generation: u64, + worker_payload_id: u64, }, } @@ -299,7 +356,20 @@ pub struct BackendAcquiredPayload { pub computed_hash: String, } +pub(crate) struct StoredPayload { + pub manifest: SkillPayloadManifest, + pub total_bytes: u64, + pub planning_metadata: PayloadPlanningMetadata, +} + pub trait PayloadSessionStorage: Send + Sync { + fn read_source_skill_md<'a>( + &'a self, + _source_root: &'a str, + _cancellation: CancellationSignal, + ) -> PayloadStorageFuture<'a, Result, AppError>> { + Box::pin(async { Err(AppError::StalePayload) }) + } fn local_source(&self, _key: &PayloadStorageKey) -> Result { Ok(PayloadLocalSource::InProcess) } @@ -324,6 +394,20 @@ pub trait PayloadSessionStorage: Send + Sync { }) } + fn acquire_from_path<'a>( + &'a self, + _key: &'a PayloadStorageKey, + _source_root: &'a str, + _cancellation: Option, + ) -> PayloadStorageFuture<'a, Result> { + Box::pin(async { + Err(AppError::CapabilityUnavailable { + capability: "backendPathPayloadAcquisition".to_string(), + path: None, + }) + }) + } + fn source_metadata_fingerprint<'a>( &'a self, _source_root: &'a str, @@ -475,20 +559,58 @@ struct SessionRecord { total_bytes: u64, pin_count: usize, busy_count: usize, - pending_payloads: HashSet, + pending_payloads: + HashMap>>>, payloads: HashMap, copy_source_snapshots: HashMap, storage: Arc, retained_source: Option, + source_bytes: u64, + source_optional: bool, } -#[derive(Clone)] +impl SessionRecord { + async fn remove_storage(&mut self, session_id: &str) -> Result<(), AppError> { + self.invalidated = true; + if let Some(source) = &mut self.retained_source { + source.available = false; + if let Some(cleanup) = &source.cleanup { + cleanup.remove().await?; + } + } + self.retained_source = None; + self.total_bytes = self.total_bytes.saturating_sub(self.source_bytes); + self.source_bytes = 0; + self.storage.remove_session(session_id).await + } +} + +#[derive(Clone, PartialEq, Eq)] struct PayloadRecord { payload_id: String, manifest_hash: String, planning_metadata: PayloadPlanningMetadata, } +struct PayloadIoGuard { + manager: Arc, + session_id: String, + skill_path: Option, +} + +impl Drop for PayloadIoGuard { + fn drop(&mut self) { + if let Ok(mut sessions) = self.manager.sessions.lock() { + if let Some(session) = sessions.get_mut(&self.session_id) { + if let Some(skill_path) = &self.skill_path { + session.pending_payloads.remove(skill_path); + } + session.busy_count = session.busy_count.saturating_sub(1); + } + } + } +} + pub struct PinnedPayloadLease { manager: Weak, session_id: String, @@ -609,10 +731,32 @@ impl PayloadSessionManager { source_fingerprint: String, storage: Arc, retained_source: Option, + ) -> Result { + let manager = self.clone(); + tokio::spawn(async move { + manager + .discover_owned(environment, source_fingerprint, storage, retained_source) + .await + }) + .await + .map_err(|error| AppError::ExecutionFailed { + message: error.to_string(), + })? + } + + async fn discover_owned( + &self, + environment: EnvironmentRef, + source_fingerprint: String, + storage: Arc, + retained_source: Option, ) -> Result { let now = (self.inner.now)(); let session_id = Uuid::new_v4().simple().to_string(); let expires_at_epoch_ms = now.saturating_add(self.inner.limits.ttl_ms); + let source_bytes = retained_source + .as_ref() + .map_or(0, |source| source.managed_bytes); let record = SessionRecord { environment: environment.clone(), environment_key: EnvironmentKey::from_ref(&environment), @@ -620,10 +764,12 @@ impl PayloadSessionManager { source_fingerprint: source_fingerprint.clone(), expires_at_epoch_ms, created_at_epoch_ms: now, - total_bytes: 0, + total_bytes: source_bytes, + source_bytes, + source_optional: false, pin_count: 0, busy_count: 0, - pending_payloads: HashSet::new(), + pending_payloads: HashMap::new(), payloads: HashMap::new(), copy_source_snapshots: HashMap::new(), storage: storage.clone(), @@ -631,8 +777,23 @@ impl PayloadSessionManager { }; lock(&self.inner.sessions)?.insert(session_id.clone(), record); if let Err(error) = self.enforce_capacity(Some(&session_id)).await { - lock(&self.inner.sessions)?.remove(&session_id); - storage.remove_session(&session_id).await?; + let error = if source_bytes > self.inner.limits.max_bytes + && matches!(&error, AppError::CapabilityUnavailable { capability, .. } if capability == "payloadSessionCapacity") + { + AppError::CapabilityUnavailable { + capability: "sourceSessionCapacity".into(), + path: None, + } + } else { + error + }; + let removed = lock(&self.inner.sessions)?.remove(&session_id); + if let Some(mut record) = removed { + if let Err(cleanup_error) = record.remove_storage(&session_id).await { + lock(&self.inner.sessions)?.insert(session_id, record); + return Err(cleanup_error); + } + } return Err(error); } @@ -653,9 +814,105 @@ impl PayloadSessionManager { valid_discovery_session(&mut sessions, discovery, now)? .retained_source .clone() + .filter(|source| source.available) .ok_or(AppError::StalePayload) } + pub async fn release_source_snapshot( + &self, + discovery: &DiscoverySessionHandle, + ) -> Result<(), AppError> { + let manager = self.clone(); + let discovery = discovery.clone(); + tokio::spawn(async move { manager.release_source_snapshot_owned(&discovery).await }) + .await + .map_err(|error| AppError::ExecutionFailed { + message: error.to_string(), + })? + } + + async fn release_source_snapshot_owned( + &self, + discovery: &DiscoverySessionHandle, + ) -> Result<(), AppError> { + let source = { + let mut sessions = lock(&self.inner.sessions)?; + let session = sessions + .get_mut(&discovery.session_id) + .ok_or(AppError::StalePayload)?; + if session.source_fingerprint != discovery.source_fingerprint { + return Err(AppError::StalePayload); + } + if session + .retained_source + .as_ref() + .is_some_and(|source| Arc::strong_count(&source._owner) > 1) + { + return Err(AppError::StalePayload); + } + session.busy_count += 1; + session.retained_source.take() + }; + let result = + if let Some(cleanup) = source.as_ref().and_then(|source| source.cleanup.as_ref()) { + cleanup.remove().await + } else { + Ok(()) + }; + let mut sessions = lock(&self.inner.sessions)?; + let session = sessions + .get_mut(&discovery.session_id) + .ok_or(AppError::StalePayload)?; + session.busy_count = session.busy_count.saturating_sub(1); + match result { + Ok(()) => { + session.total_bytes = session.total_bytes.saturating_sub(session.source_bytes); + session.source_bytes = 0; + } + Err(_) => { + session.retained_source = source.map(|mut source| { + source.available = false; + source + }); + } + } + result + } + + pub(crate) async fn make_source_optional( + &self, + discovery: &DiscoverySessionHandle, + ) -> Result<(), AppError> { + { + let mut sessions = lock(&self.inner.sessions)?; + let session = sessions + .get_mut(&discovery.session_id) + .ok_or(AppError::StalePayload)?; + if session.source_fingerprint != discovery.source_fingerprint { + return Err(AppError::StalePayload); + } + session.source_optional = true; + } + self.enforce_capacity(Some(&discovery.session_id)).await + } + + pub(crate) fn register_saved_source_skill( + &self, + discovery: &DiscoverySessionHandle, + skill: DiscoverySkillSnapshot, + ) -> Result<(), AppError> { + let mut sessions = lock(&self.inner.sessions)?; + let source = valid_discovery_session(&mut sessions, discovery, (self.inner.now)())? + .retained_source + .as_mut() + .ok_or(AppError::StalePayload)?; + source + .skills + .entry(skill.relative_path.clone()) + .or_insert(skill); + Ok(()) + } + pub fn storage_for_discovery( &self, discovery: &DiscoverySessionHandle, @@ -675,6 +932,9 @@ impl PayloadSessionManager { let now = (self.inner.now)(); let mut sessions = lock(&self.inner.sessions)?; let session = valid_discovery_session(&mut sessions, discovery, now)?; + if session.pending_payloads.contains_key(skill_path) { + return Ok(None); + } Ok(session .payloads .get(skill_path) @@ -856,222 +1116,220 @@ impl PayloadSessionManager { payload: SkillPayload, planning_metadata: PayloadPlanningMetadata, ) -> Result { - let now = (self.inner.now)(); - let payload_id = payload.payload_id.as_str().to_string(); - let manifest_hash = payload.payload_root_hash.clone(); - let key = PayloadStorageKey::new(&discovery.session_id, &skill_path); - - let (existing, storage) = { - let mut sessions = lock(&self.inner.sessions)?; - let session = valid_discovery_session(&mut sessions, discovery, now)?; - let storage = session.storage.clone(); - if let Some(existing) = session.payloads.get(&skill_path) { - if existing.payload_id != payload_id - || existing.manifest_hash != manifest_hash - || existing.planning_metadata != planning_metadata - { - return Err(AppError::StalePayload); - } - (true, storage) - } else { - if !session.pending_payloads.insert(skill_path.clone()) { - return Err(AppError::MutationBusy); - } - session.busy_count = session.busy_count.saturating_add(1); - (false, storage) - } + let expected = PayloadRecord { + payload_id: payload.payload_id.as_str().to_string(), + manifest_hash: payload.payload_root_hash.clone(), + planning_metadata: planning_metadata.clone(), }; - if !existing { - let stored = storage.store(&key, payload).await; - let bytes = match stored { - Ok(bytes) => bytes, - Err(error) => { - finish_payload_io(&self.inner.sessions, discovery, &skill_path)?; - return Err(error); - } - }; - { - let mut sessions = lock(&self.inner.sessions)?; - let session = valid_discovery_session(&mut sessions, discovery, now)?; - session.pending_payloads.remove(&skill_path); - session.busy_count = session.busy_count.saturating_sub(1); - session.total_bytes = session.total_bytes.saturating_add(bytes); - session.payloads.insert( - skill_path.clone(), - PayloadRecord { - payload_id: payload_id.clone(), - manifest_hash: manifest_hash.clone(), - planning_metadata: planning_metadata.clone(), - }, - ); - } - if let Err(error) = self.enforce_capacity(Some(&discovery.session_id)).await { - storage.remove(&key).await?; - if let Some(session) = lock(&self.inner.sessions)?.get_mut(&discovery.session_id) { - session.payloads.remove(&skill_path); - session.total_bytes = session.total_bytes.saturating_sub(bytes); - } - return Err(error); - } - } - - Ok(AcquiredPayloadHandle { - session_id: discovery.session_id.clone(), - skill_path, - environment: discovery.environment.clone(), - payload_id, - manifest_hash, - source_fingerprint: discovery.source_fingerprint.clone(), - expires_at_epoch_ms: discovery.expires_at_epoch_ms, - }) - } - - #[cfg(test)] - pub async fn register_existing_payload( - &self, - discovery: &DiscoverySessionHandle, - skill_path: impl Into, - manifest: SkillPayloadManifest, - total_bytes: u64, - ) -> Result { - let skill_path = skill_path.into(); - let metadata = PayloadPlanningMetadata::legacy_incomplete(&skill_path); - self.register_existing_payload_record( - discovery, - skill_path, - manifest, - total_bytes, - metadata, - ) - .await + let handle = self + .prepare_payload(discovery, skill_path, move |storage, key| async move { + let manifest = payload.manifest(); + let total_bytes = storage.store(&key, payload).await?; + Ok(StoredPayload { + manifest, + total_bytes, + planning_metadata, + }) + }) + .await?; + self.validate_record(&handle, &expected)?; + Ok(handle) } - pub async fn register_existing_payload_with_metadata( + fn validate_record( &self, - discovery: &DiscoverySessionHandle, - skill_path: impl Into, - manifest: SkillPayloadManifest, - total_bytes: u64, - planning_metadata: PayloadPlanningMetadata, - ) -> Result { - let skill_path = skill_path.into(); - planning_metadata.validate()?; - if planning_metadata.skill_path != skill_path { + handle: &AcquiredPayloadHandle, + expected: &PayloadRecord, + ) -> Result<(), AppError> { + let sessions = lock(&self.inner.sessions)?; + validate_payload_handle(&sessions, handle, (self.inner.now)())?; + if sessions[&handle.session_id] + .payloads + .get(&handle.skill_path) + != Some(expected) + { return Err(AppError::StalePayload); } - self.register_existing_payload_record( - discovery, - skill_path, - manifest, - total_bytes, - planning_metadata, - ) - .await + Ok(()) } - async fn register_existing_payload_record( + /// 同一发现会话中的一个 Skill 只由一个任务准备。调用方离开后,该任务继续负责 I/O 和收尾。 + pub(crate) async fn prepare_payload( &self, discovery: &DiscoverySessionHandle, skill_path: String, - manifest: SkillPayloadManifest, - total_bytes: u64, - planning_metadata: PayloadPlanningMetadata, - ) -> Result { - crate::core::skill_payload::verify_skill_payload_manifest(&manifest)?; - let now = (self.inner.now)(); - let payload_id = manifest.payload_id().as_str().to_string(); - let manifest_hash = manifest.payload_root_hash.clone(); - let key = PayloadStorageKey::new(&discovery.session_id, &skill_path); - let (inserted, storage) = { + prepare: F, + ) -> Result + where + F: FnOnce(Arc, PayloadStorageKey) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + self.ensure_maintenance_ready(&discovery.environment)?; + let (mut receiver, producer) = { let mut sessions = lock(&self.inner.sessions)?; - let session = valid_discovery_session(&mut sessions, discovery, now)?; - let storage = session.storage.clone(); - if session.pending_payloads.contains(&skill_path) { - return Err(AppError::MutationBusy); - } - if let Some(existing) = session.payloads.get(&skill_path) { - if existing.payload_id != payload_id - || existing.manifest_hash != manifest_hash - || existing.planning_metadata != planning_metadata - { - return Err(AppError::StalePayload); - } - (false, storage) + let session = valid_discovery_session(&mut sessions, discovery, (self.inner.now)())?; + if let Some(receiver) = session.pending_payloads.get(&skill_path) { + (receiver.clone(), None) + } else if let Some(record) = session.payloads.get(&skill_path) { + return Ok(payload_handle(discovery, &skill_path, record)); } else { - session.total_bytes = session.total_bytes.saturating_add(total_bytes); - session.payloads.insert( - skill_path.clone(), - PayloadRecord { - payload_id: payload_id.clone(), - manifest_hash: manifest_hash.clone(), - planning_metadata: planning_metadata.clone(), - }, - ); - (true, storage) + let (sender, receiver) = watch::channel(None); + session + .pending_payloads + .insert(skill_path.clone(), receiver.clone()); + session.busy_count += 1; + let guard = PayloadIoGuard { + manager: self.inner.clone(), + session_id: discovery.session_id.clone(), + skill_path: Some(skill_path.clone()), + }; + ( + receiver, + Some(( + sender, + session.storage.clone(), + session.retained_source.clone(), + guard, + )), + ) } }; - if inserted { - if let Err(error) = self.enforce_capacity(Some(&discovery.session_id)).await { - storage.remove(&key).await?; - if let Some(session) = lock(&self.inner.sessions)?.get_mut(&discovery.session_id) { - session.payloads.remove(&skill_path); - session.total_bytes = session.total_bytes.saturating_sub(total_bytes); - } - return Err(error); + if let Some((sender, storage, retained_source, guard)) = producer { + let manager = self.clone(); + let discovery = discovery.clone(); + tokio::spawn(async move { + let _source = retained_source; + let key = PayloadStorageKey::new(&discovery.session_id, &skill_path); + let prepared = + AssertUnwindSafe(async { prepare(storage.clone(), key.clone()).await }) + .catch_unwind() + .await + .unwrap_or_else(|_| { + Err(AppError::ExecutionFailed { + message: "payload preparation task panicked".to_string(), + }) + }); + let mut reserved_bytes = 0; + let result = match prepared { + Ok(prepared) => { + manager + .publish_payload(&discovery, &skill_path, prepared, &mut reserved_bytes) + .await + } + Err(error) => Err(error), + }; + let result = if result.is_err() { + let cleanup = storage.remove(&key).await; + if let Ok(mut sessions) = manager.inner.sessions.lock() { + if let Some(session) = sessions.get_mut(&discovery.session_id) { + if cleanup.is_ok() { + session.total_bytes = + session.total_bytes.saturating_sub(reserved_bytes); + } else { + // 清理失败的内容仍由该会话持有,后续容量清理会再次尝试。 + session.invalidated = true; + } + } + } + cleanup.and(result) + } else { + result + }; + drop(guard); + sender.send_replace(Some(result)); + }); + } + loop { + if let Some(result) = receiver.borrow().clone() { + let handle = result?; + let sessions = lock(&self.inner.sessions)?; + validate_payload_handle(&sessions, &handle, (self.inner.now)())?; + return Ok(handle); } + receiver + .changed() + .await + .map_err(|_| AppError::ExecutionFailed { + message: "payload preparation ended without a result".to_string(), + })?; } - Ok(AcquiredPayloadHandle { - session_id: discovery.session_id.clone(), - skill_path, - environment: discovery.environment.clone(), - payload_id, - manifest_hash, - source_fingerprint: discovery.source_fingerprint.clone(), - expires_at_epoch_ms: discovery.expires_at_epoch_ms, - }) + } + + async fn publish_payload( + &self, + discovery: &DiscoverySessionHandle, + skill_path: &str, + prepared: StoredPayload, + reserved_bytes: &mut u64, + ) -> Result { + crate::core::skill_payload::verify_skill_payload_manifest(&prepared.manifest)?; + { + let mut sessions = lock(&self.inner.sessions)?; + let session = valid_discovery_session(&mut sessions, discovery, (self.inner.now)())?; + session.total_bytes = session.total_bytes.saturating_add(prepared.total_bytes); + *reserved_bytes = prepared.total_bytes; + } + self.enforce_capacity(Some(&discovery.session_id)).await?; + let mut sessions = lock(&self.inner.sessions)?; + let session = valid_discovery_session(&mut sessions, discovery, (self.inner.now)())?; + let record = PayloadRecord { + payload_id: prepared.manifest.payload_id().as_str().to_string(), + manifest_hash: prepared.manifest.payload_root_hash, + planning_metadata: prepared.planning_metadata, + }; + let handle = payload_handle(discovery, skill_path, &record); + session.payloads.insert(skill_path.to_string(), record); + Ok(handle) } pub async fn pin_verified( &self, handle: &AcquiredPayloadHandle, + ) -> Result { + let manager = self.clone(); + let handle = handle.clone(); + tokio::spawn(async move { manager.pin_verified_owned(&handle).await }) + .await + .map_err(|error| AppError::ExecutionFailed { + message: format!("payload verification task failed: {error}"), + })? + } + + async fn pin_verified_owned( + &self, + handle: &AcquiredPayloadHandle, ) -> Result { self.ensure_maintenance_ready(&handle.environment)?; let now = (self.inner.now)(); let key = PayloadStorageKey::new(&handle.session_id, &handle.skill_path); - let storage = { + let (storage, _guard) = { let mut sessions = lock(&self.inner.sessions)?; validate_payload_handle(&sessions, handle, now)?; let session = sessions .get_mut(&handle.session_id) .expect("validated session"); session.busy_count += 1; - session.storage.clone() - }; - let verified = storage.verify(&key).await; - let manifest = match verified { - Ok(Some(manifest)) => manifest, - Ok(None) => { - finish_handle_io(&self.inner.sessions, handle)?; - return Err(AppError::StalePayload); - } - Err(error) => { - finish_handle_io(&self.inner.sessions, handle)?; - return Err(error); - } + ( + session.storage.clone(), + PayloadIoGuard { + manager: self.inner.clone(), + session_id: handle.session_id.clone(), + skill_path: None, + }, + ) }; + let manifest = storage.verify(&key).await?.ok_or(AppError::StalePayload)?; if manifest.payload_id().as_str() != handle.payload_id || manifest.payload_root_hash != handle.manifest_hash { - finish_handle_io(&self.inner.sessions, handle)?; return Err(AppError::StalePayload); } let planning_metadata = { let mut sessions = lock(&self.inner.sessions)?; - validate_payload_handle(&sessions, handle, now)?; + validate_payload_handle(&sessions, handle, (self.inner.now)())?; let session = sessions .get_mut(&handle.session_id) .expect("validated session"); - session.busy_count = session.busy_count.saturating_sub(1); session.pin_count = session.pin_count.saturating_add(1); session .payloads @@ -1195,6 +1453,47 @@ impl PayloadSessionManager { } } self.cleanup_internal(protected_session_id).await?; + loop { + let candidate = { + let sessions = lock(&self.inner.sessions)?; + if sessions + .values() + .map(|session| session.total_bytes) + .sum::() + .saturating_add( + lock(&self.inner.maintenance)? + .values() + .map(|state| state.external_retained_bytes) + .sum::(), + ) + <= self.inner.limits.max_bytes + { + None + } else { + sessions + .iter() + .find(|(_, session)| { + session.source_optional + && session.source_bytes > 0 + && session.busy_count == 0 + && session + .retained_source + .as_ref() + .is_some_and(|source| Arc::strong_count(&source._owner) == 1) + }) + .map(|(id, session)| DiscoverySessionHandle { + session_id: id.clone(), + environment: session.environment.clone(), + source_fingerprint: session.source_fingerprint.clone(), + expires_at_epoch_ms: session.expires_at_epoch_ms, + }) + } + }; + let Some(candidate) = candidate else { + break; + }; + self.release_source_snapshot_owned(&candidate).await?; + } let sessions = lock(&self.inner.sessions)?; let session_bytes = sessions .values() @@ -1258,6 +1557,10 @@ impl PayloadSessionManager { .filter(|(session_id, session)| { session.pin_count == 0 && session.busy_count == 0 + && session + .retained_source + .as_ref() + .is_none_or(|source| Arc::strong_count(&source._owner) == 1) && protected_session_id != Some(session_id.as_str()) && (session.invalidated || session.expires_at_epoch_ms < now @@ -1279,10 +1582,10 @@ impl PayloadSessionManager { .map(|record| (session_id.clone(), record)) }) }; - let Some((session_id, record)) = candidate else { + let Some((session_id, mut record)) = candidate else { break; }; - if let Err(error) = record.storage.remove_session(&session_id).await { + if let Err(error) = record.remove_storage(&session_id).await { lock(&self.inner.sessions)?.insert(session_id, record); return Err(error); } @@ -1349,43 +1652,68 @@ fn validate_copy_source_snapshot_binding( &snapshot.source_context.scope, SkillLocation::Project { project_id } if !project_id.trim().is_empty() ); - let native_identity = matches!( - snapshot.project_identity.key.backend, - ExecutionBackend::NativeWindows | ExecutionBackend::NativeUnix - ) && matches!( - snapshot.project_identity.destination.environment, - EnvironmentRef::Native - ) && snapshot.project_identity.entry_kind == TargetEntryKind::Directory; + let project_identity = &snapshot.project_identity; + let matching_identity = match ( + &project_identity.key.backend, + &project_identity.key.physical_parent, + &project_identity.destination.environment, + ) { + ( + ExecutionBackend::NativeWindows, + PhysicalParentIdentity::Windows { .. }, + EnvironmentRef::Native, + ) + | ( + ExecutionBackend::NativeUnix, + PhysicalParentIdentity::Unix { .. }, + EnvironmentRef::Native, + ) => true, + ( + ExecutionBackend::WslPosix { + distro_name: backend, + }, + PhysicalParentIdentity::Wsl { + distro_name: owner, .. + }, + EnvironmentRef::Wsl { distro_name }, + ) => { + let distro = normalized_wsl_distro_name(distro_name); + !distro.is_empty() + && normalized_wsl_distro_name(backend) == distro + && normalized_wsl_distro_name(owner) == distro + && match &snapshot.source_context.environment { + EnvironmentRef::Native => true, + EnvironmentRef::Wsl { distro_name } => { + normalized_wsl_distro_name(distro_name) == distro + } + } + } + _ => false, + } && project_identity.entry_kind == TargetEntryKind::Directory; if !valid_project || !same_environment_identity(&snapshot.source_context.environment, &handle.environment) || snapshot.skill_name != payload.planning_metadata.skill_name - || !native_identity + || !matching_identity { return Err(AppError::StalePayload); } Ok(()) } -fn finish_payload_io( - sessions: &Mutex>, +fn payload_handle( discovery: &DiscoverySessionHandle, skill_path: &str, -) -> Result<(), AppError> { - if let Some(session) = lock(sessions)?.get_mut(&discovery.session_id) { - session.pending_payloads.remove(skill_path); - session.busy_count = session.busy_count.saturating_sub(1); + record: &PayloadRecord, +) -> AcquiredPayloadHandle { + AcquiredPayloadHandle { + session_id: discovery.session_id.clone(), + skill_path: skill_path.to_string(), + environment: discovery.environment.clone(), + payload_id: record.payload_id.clone(), + manifest_hash: record.manifest_hash.clone(), + source_fingerprint: discovery.source_fingerprint.clone(), + expires_at_epoch_ms: discovery.expires_at_epoch_ms, } - Ok(()) -} - -fn finish_handle_io( - sessions: &Mutex>, - handle: &AcquiredPayloadHandle, -) -> Result<(), AppError> { - if let Some(session) = lock(sessions)?.get_mut(&handle.session_id) { - session.busy_count = session.busy_count.saturating_sub(1); - } - Ok(()) } pub(crate) async fn load_payload_from_storage( @@ -1462,6 +1790,164 @@ mod tests { ) } + struct SourceCleanupFixture { + fail: std::sync::atomic::AtomicBool, + calls: AtomicU64, + } + + impl RetainedSourceCleanup for SourceCleanupFixture { + fn remove(&self) -> PayloadStorageFuture<'_, Result<(), AppError>> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.fail.load(Ordering::SeqCst) { + Err(AppError::Io { + message: "source is occupied".into(), + }) + } else { + Ok(()) + } + }) + } + } + + #[tokio::test] + async fn source_capacity_reclaims_optional_checkout_without_evicting_ready_payload() { + let root = tempdir().unwrap(); + let manager = PayloadSessionManager::in_memory( + PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 1_000, + }, + || 1_000, + ); + let cleanup = Arc::new(SourceCleanupFixture { + fail: std::sync::atomic::AtomicBool::new(true), + calls: AtomicU64::new(0), + }); + let retained = || { + RetainedDiscoverySource::new( + DiscoverySourceLocation::Native { + root: root.path().to_path_buf(), + ref_revision: None, + }, + DiscoverySourceDescriptor { + source: "source".into(), + source_type: "git".into(), + source_url: None, + ref_name: None, + redirected_download_host: None, + }, + BTreeMap::new(), + (), + ) + .with_managed_storage(900, cleanup.clone()) + }; + let first = manager + .discover_with_retained_source(EnvironmentRef::Native, "first", retained()) + .await + .unwrap(); + let handle = manager + .acquire_payload(&first, "demo", payload()) + .await + .unwrap(); + let lease = manager.pin_verified(&handle).await.unwrap(); + manager.make_source_optional(&first).await.unwrap(); + assert!(manager.release_source_snapshot(&first).await.is_err()); + assert_eq!( + lock(&manager.inner.sessions).unwrap()[&first.session_id].source_bytes, + 900 + ); + assert!(manager.pin_verified(&handle).await.is_ok()); + cleanup.fail.store(false, Ordering::SeqCst); + let second = manager + .discover_with_retained_source(EnvironmentRef::Native, "second", retained()) + .await + .unwrap(); + assert_eq!( + lock(&manager.inner.sessions).unwrap()[&first.session_id].source_bytes, + 0 + ); + assert!(manager.source_snapshot(&first).is_err()); + assert!(manager.source_snapshot(&second).is_ok()); + assert!(manager.pin_verified(&handle).await.is_ok()); + assert_eq!(cleanup.calls.load(Ordering::SeqCst), 2); + drop(lease); + } + + #[tokio::test] + async fn oversized_source_is_rejected_without_invalidating_prepared_content() { + let root = tempdir().unwrap(); + let manager = PayloadSessionManager::in_memory( + PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 1_000, + }, + || 1_000, + ); + let cleanup = Arc::new(SourceCleanupFixture { + fail: std::sync::atomic::AtomicBool::new(false), + calls: AtomicU64::new(0), + }); + let ready = manager + .discover(EnvironmentRef::Native, "ready") + .await + .unwrap(); + let handle = manager + .acquire_payload(&ready, "demo", payload()) + .await + .unwrap(); + let lease = manager.pin_verified(&handle).await.unwrap(); + let source = RetainedDiscoverySource::new( + DiscoverySourceLocation::Native { + root: root.path().to_path_buf(), + ref_revision: None, + }, + DiscoverySourceDescriptor { + source: "large".into(), + source_type: "git".into(), + source_url: None, + ref_name: None, + redirected_download_host: None, + }, + BTreeMap::new(), + (), + ) + .with_managed_storage(2_000, cleanup.clone()); + let error = manager + .discover_with_retained_source(EnvironmentRef::Native, "large", source) + .await + .unwrap_err(); + assert!( + matches!(error, AppError::CapabilityUnavailable { capability, .. } if capability == "sourceSessionCapacity") + ); + assert_eq!(cleanup.calls.load(Ordering::SeqCst), 1); + assert_eq!(lock(&manager.inner.sessions).unwrap().len(), 1); + assert!(manager.pin_verified(&handle).await.is_ok()); + drop(lease); + } + + #[cfg(any(unix, windows))] + #[test] + fn managed_source_size_does_not_follow_directory_links() { + let temp = tempdir().unwrap(); + let root = temp.path().join("managed"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::File::create(outside.join("large")) + .unwrap() + .set_len(1_000_000) + .unwrap(); + fs::write(root.join("small"), b"small").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, root.join("link")).unwrap(); + #[cfg(windows)] + junction::create(&outside, root.join("link")).unwrap(); + assert!(environment_engine::directory::size_no_follow(&root).unwrap() < 100_000); + } + fn copy_source_snapshot(environment: EnvironmentRef) -> CopySourceSnapshot { CopySourceSnapshot { source_context: SkillLocationRef { @@ -1643,6 +2129,355 @@ mod tests { struct DropCounter(Arc); + #[tokio::test] + async fn different_skills_prepare_concurrently_in_the_same_native_session() { + use crate::environment::native::acquire::NativePayloadSessionStorage; + use crate::payload_storage_test_support::PausedPayloadStorage; + + let root = tempdir().unwrap(); + let storage = Arc::new(PausedPayloadStorage::new(Arc::new( + NativePayloadSessionStorage::new(root.path()).unwrap(), + ))); + let manager = manager_with_storage( + storage.clone(), + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 1, + max_bytes: 1024 * 1024, + }, + Arc::new(AtomicU64::new(1_000)), + ); + let discovery = manager + .discover(EnvironmentRef::Native, "source-v1") + .await + .unwrap(); + let mut tasks = Vec::new(); + for index in 0..8 { + tasks.push(tokio::spawn({ + let manager = manager.clone(); + let discovery = discovery.clone(); + async move { + manager + .acquire_payload(&discovery, format!("skills/demo-{index}"), payload()) + .await + } + })); + storage.store_pause.wait_until_started().await; + } + for _ in &tasks { + storage.store_pause.resume(); + } + let mut paths = HashSet::new(); + for task in tasks { + let handle = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .unwrap() + .unwrap() + .unwrap(); + paths.insert(handle.skill_path.clone()); + let lease = manager.pin_verified(&handle).await.unwrap(); + assert!(lease + .load_payload() + .await + .unwrap() + .blobs + .values() + .any(|bytes| bytes == b"skill")); + } + assert_eq!(paths.len(), 8); + assert_eq!(storage.stores.load(Ordering::SeqCst), 8); + } + + #[tokio::test] + async fn abandoned_preparation_completes_io_and_recovers_capacity() { + use crate::environment::native::acquire::NativePayloadSessionStorage; + use crate::payload_storage_test_support::PausedPayloadStorage; + + let root = tempdir().unwrap(); + let storage = Arc::new(PausedPayloadStorage::new(Arc::new( + NativePayloadSessionStorage::new(root.path()).unwrap(), + ))); + let now = Arc::new(AtomicU64::new(1_000)); + let manager = manager_with_storage( + storage.clone(), + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 1, + max_bytes: 1024 * 1024, + }, + now.clone(), + ); + let discovery = manager + .discover(EnvironmentRef::Native, "source-v1") + .await + .unwrap(); + let acquisition = tokio::spawn({ + let manager = manager.clone(); + let discovery = discovery.clone(); + async move { + manager + .acquire_payload(&discovery, "skills/demo", payload()) + .await + } + }); + storage.store_pause.wait_until_started().await; + acquisition.abort(); + assert!(acquisition.await.unwrap_err().is_cancelled()); + + now.store(1_101, Ordering::SeqCst); + assert_eq!(manager.cleanup().await.unwrap(), 0); + storage.store_pause.resume(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while manager.cleanup().await.unwrap() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("abandoned acquisition must release the session after I/O"); + assert!(root.path().read_dir().unwrap().next().is_none()); + manager + .discover(EnvironmentRef::Native, "source-v2") + .await + .expect("capacity recovered"); + } + + #[tokio::test] + async fn preparation_failure_is_shared_and_partial_storage_can_be_retried() { + use crate::environment::native::acquire::NativePayloadSessionStorage; + use crate::payload_storage_test_support::PausedPayloadStorage; + + let root = tempdir().unwrap(); + let storage = Arc::new(PausedPayloadStorage::new(Arc::new( + NativePayloadSessionStorage::new(root.path()).unwrap(), + ))); + storage.fail_after_store.store(true, Ordering::SeqCst); + let manager = manager_with_storage( + storage.clone(), + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 1, + max_bytes: 1024 * 1024, + }, + Arc::new(AtomicU64::new(1_000)), + ); + let discovery = manager + .discover(EnvironmentRef::Native, "source-v1") + .await + .unwrap(); + let first = tokio::spawn({ + let manager = manager.clone(); + let discovery = discovery.clone(); + async move { + manager + .acquire_payload(&discovery, "skills/demo", payload()) + .await + } + }); + storage.store_pause.wait_until_started().await; + let second = manager.acquire_payload(&discovery, "skills/demo", payload()); + tokio::pin!(second); + assert!(futures_util::poll!(&mut second).is_pending()); + storage.store_pause.resume(); + let first_error = first.await.unwrap().unwrap_err(); + assert_eq!( + first_error, + AppError::Io { + message: "injected store failure".to_string() + } + ); + assert_eq!(second.await.unwrap_err(), first_error); + + storage.store_pause.resume(); + let retried = manager + .acquire_payload(&discovery, "skills/demo", payload()) + .await + .unwrap(); + let lease = manager.pin_verified(&retried).await.unwrap(); + assert!(lease + .load_payload() + .await + .unwrap() + .blobs + .values() + .any(|bytes| bytes == b"skill")); + assert_eq!(storage.stores.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn dropping_one_waiter_keeps_other_waiters_and_conflicting_content_is_rejected() { + use crate::environment::native::acquire::NativePayloadSessionStorage; + use crate::payload_storage_test_support::PausedPayloadStorage; + + let root = tempdir().unwrap(); + let storage = Arc::new(PausedPayloadStorage::new(Arc::new( + NativePayloadSessionStorage::new(root.path().join("payloads")).unwrap(), + ))); + let manager = manager_with_storage( + storage.clone(), + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 1, + max_bytes: 1024 * 1024, + }, + Arc::new(AtomicU64::new(1_000)), + ); + let discovery = manager + .discover(EnvironmentRef::Native, "source-v1") + .await + .unwrap(); + let first = tokio::spawn({ + let manager = manager.clone(); + let discovery = discovery.clone(); + async move { + manager + .acquire_payload(&discovery, "skills/demo", payload()) + .await + } + }); + storage.store_pause.wait_until_started().await; + let second = manager.acquire_payload(&discovery, "skills/demo", payload()); + tokio::pin!(second); + assert!(futures_util::poll!(&mut second).is_pending()); + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + + let changed_root = root.path().join("changed"); + fs::create_dir(&changed_root).unwrap(); + fs::write(changed_root.join("SKILL.md"), b"changed").unwrap(); + let conflicting = manager.acquire_payload( + &discovery, + "skills/demo", + build_skill_payload(&changed_root).unwrap(), + ); + tokio::pin!(conflicting); + assert!(futures_util::poll!(&mut conflicting).is_pending()); + storage.store_pause.resume(); + + let handle = second.await.unwrap(); + assert_eq!(conflicting.await.unwrap_err(), AppError::StalePayload); + assert_eq!(storage.stores.load(Ordering::SeqCst), 1); + assert!(manager.pin_verified(&handle).await.is_ok()); + } + + #[tokio::test] + async fn abandoned_verification_finishes_before_releasing_the_session() { + use crate::environment::native::acquire::NativePayloadSessionStorage; + use crate::payload_storage_test_support::{PausedPayloadStorage, PayloadIoPause}; + + let root = tempdir().unwrap(); + let mut storage = PausedPayloadStorage::new(Arc::new( + NativePayloadSessionStorage::new(root.path()).unwrap(), + )); + storage.verify_pause = Some(PayloadIoPause::default()); + let storage = Arc::new(storage); + let now = Arc::new(AtomicU64::new(1_000)); + let manager = manager_with_storage( + storage.clone(), + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 1, + max_bytes: 1024 * 1024, + }, + now.clone(), + ); + let discovery = manager + .discover(EnvironmentRef::Native, "source-v1") + .await + .unwrap(); + storage.store_pause.resume(); + let handle = manager + .acquire_payload(&discovery, "skills/demo", payload()) + .await + .unwrap(); + let verification = tokio::spawn({ + let manager = manager.clone(); + async move { manager.pin_verified(&handle).await } + }); + let pause = storage.verify_pause.as_ref().unwrap(); + pause.wait_until_started().await; + verification.abort(); + assert!(verification.await.err().unwrap().is_cancelled()); + + now.store(1_101, Ordering::SeqCst); + assert_eq!( + manager.cleanup().await.unwrap(), + 0, + "I/O still owns the session" + ); + pause.resume(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while manager.cleanup().await.unwrap() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("completed verification must release its temporary hold"); + manager + .discover(EnvironmentRef::Native, "source-v2") + .await + .expect("capacity recovered"); + } + + #[tokio::test] + async fn concurrent_payload_requests_share_one_native_snapshot() { + use crate::environment::native::acquire::NativePayloadSessionStorage; + use crate::payload_storage_test_support::PausedPayloadStorage; + + let root = tempdir().unwrap(); + let storage = Arc::new(PausedPayloadStorage::new(Arc::new( + NativePayloadSessionStorage::new(root.path()).unwrap(), + ))); + let manager = manager_with_storage( + storage.clone(), + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 4, + max_bytes: 1024 * 1024, + }, + Arc::new(AtomicU64::new(1_000)), + ); + let discovery = manager + .discover(EnvironmentRef::Native, "source-v1") + .await + .unwrap(); + let content = payload(); + let first = tokio::spawn({ + let manager = manager.clone(); + let discovery = discovery.clone(); + let content = content.clone(); + async move { + manager + .acquire_payload(&discovery, "skills/demo", content) + .await + } + }); + storage.store_pause.wait_until_started().await; + + let second = manager.acquire_payload(&discovery, "skills/demo", content); + tokio::pin!(second); + assert!( + futures_util::poll!(&mut second).is_pending(), + "duplicate acquisition must wait" + ); + storage.store_pause.resume(); + let (first, second) = tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!(first, second) + }) + .await + .unwrap(); + let first = first.unwrap().unwrap(); + let second = second.unwrap(); + + assert_eq!( + serde_json::to_value(&first).unwrap(), + serde_json::to_value(second).unwrap() + ); + assert_eq!(storage.stores.load(Ordering::SeqCst), 1); + let lease = manager.pin_verified(&first).await.unwrap(); + let restored = lease.load_payload().await.unwrap(); + assert!(restored.blobs.values().any(|bytes| bytes == b"skill")); + } + impl Drop for DropCounter { fn drop(&mut self) { self.0.fetch_add(1, Ordering::SeqCst); @@ -1799,6 +2634,56 @@ mod tests { )); } + #[tokio::test] + async fn copy_source_snapshot_accepts_matching_wsl_identity_and_rejects_mixed_types() { + let environment = EnvironmentRef::Wsl { + distro_name: "Ubuntu".into(), + }; + let manager = manager(Arc::new(AtomicU64::new(2_500))); + let discovery = manager + .discover(environment.clone(), "source-v1") + .await + .unwrap(); + let handle = manager + .acquire_payload(&discovery, "skills/demo", payload()) + .await + .unwrap(); + let mut snapshot = copy_source_snapshot(environment.clone()); + snapshot.project_identity.destination.environment = environment; + snapshot.project_identity.key.backend = ExecutionBackend::WslPosix { + distro_name: "ubuntu".into(), + }; + snapshot.project_identity.key.physical_parent = PhysicalParentIdentity::Wsl { + distro_name: "ubuntu".into(), + device: 1, + inode: 2, + }; + + let mut wrong_backend = snapshot.clone(); + wrong_backend.project_identity.key.backend = ExecutionBackend::NativeUnix; + let mut wrong_parent = snapshot.clone(); + wrong_parent.project_identity.key.physical_parent = PhysicalParentIdentity::Unix { + device: 1, + inode: 2, + }; + let mut wrong_distro = snapshot.clone(); + wrong_distro.project_identity.destination.environment = EnvironmentRef::Wsl { + distro_name: "Debian".into(), + }; + let mut missing = snapshot.clone(); + missing.project_identity.entry_kind = TargetEntryKind::Missing; + for invalid in [wrong_backend, wrong_parent, wrong_distro, missing] { + assert!(matches!( + manager.bind_copy_source_snapshot(&handle, invalid), + Err(AppError::StalePayload) + )); + } + manager + .bind_copy_source_snapshot(&handle, snapshot.clone()) + .expect("WSL project snapshot"); + assert_eq!(manager.copy_source_snapshot(&handle).unwrap(), snapshot); + } + #[tokio::test] async fn copy_source_snapshot_rejects_conflicting_or_forged_bindings() { let now = Arc::new(AtomicU64::new(3_000)); @@ -1920,7 +2805,19 @@ mod tests { ); let handle = manager - .register_existing_payload(&discovery, "skills/demo", manifest, 5) + .prepare_payload( + &discovery, + "skills/demo".to_string(), + move |_, _| async move { + Ok(StoredPayload { + manifest, + total_bytes: 5, + planning_metadata: PayloadPlanningMetadata::legacy_incomplete( + "skills/demo", + ), + }) + }, + ) .await .expect("register existing"); let lease = manager.pin_verified(&handle).await.expect("pin"); diff --git a/src-tauri/src/application/recovery.rs b/src-tauri/src/application/recovery.rs index 0a0db374..059fcf12 100644 --- a/src-tauri/src/application/recovery.rs +++ b/src-tauri/src/application/recovery.rs @@ -3,6 +3,9 @@ use std::sync::Arc; use serde::Serialize; use specta::Type; +use crate::application::library_application::{ + LibraryApplicationFuture, LibraryApplicationRecoveryStatus, ScopeRecoveryState, +}; use crate::application::mutation::result::ErrorReport; use crate::environment::recovery::{ RecoveryResourcePath, RecoveryResourcePathKind, RecoverySubject, @@ -145,6 +148,48 @@ where } } +impl LibraryApplicationRecoveryStatus for RecoveryService +where + C: RecoveryConsistencyChecker, +{ + fn status<'a>( + &'a self, + context: &'a crate::environment::types::SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + let mut result = ScopeRecoveryState::Clear; + for recovery in self.list().await? { + let same_environment = recovery.environment.as_ref().is_some_and(|environment| { + crate::environment::types::same_environment_identity( + environment, + &context.environment, + ) + }); + let same_scope = recovery.subject.as_ref().is_some_and(|subject| { + crate::environment::types::same_environment_identity( + &subject.context.environment, + &context.environment, + ) && subject.context.scope == context.scope + }); + if !(same_scope || same_environment && recovery.subject.is_none()) { + continue; + } + match recovery.state { + RecoveryResourceState::NeedsAttention | RecoveryResourceState::Invalid => { + return Ok(ScopeRecoveryState::Required); + } + RecoveryResourceState::EnvironmentUnavailable => { + result = ScopeRecoveryState::Unverified; + } + RecoveryResourceState::ConsistentCanCleanup + | RecoveryResourceState::Missing => {} + } + } + Ok(result) + }) + } +} + fn status_from_assessment( assessment: crate::storage::recovery_repository::RecoveryAssessment, ) -> RecoveryResourceStatus { @@ -261,6 +306,15 @@ mod tests { let attention = service.status(&marker.resource_id).await.expect("status"); assert_eq!(attention.state, RecoveryResourceState::NeedsAttention); assert!(!attention.revision.is_empty()); + assert_eq!( + LibraryApplicationRecoveryStatus::status( + &service, + &marker.subject.as_ref().unwrap().context, + ) + .await + .expect("application recovery status"), + ScopeRecoveryState::Required + ); service .repository() @@ -273,6 +327,15 @@ mod tests { consistent.state, RecoveryResourceState::ConsistentCanCleanup ); + assert_eq!( + LibraryApplicationRecoveryStatus::status( + &service, + &marker.subject.as_ref().unwrap().context, + ) + .await + .expect("application recovery status"), + ScopeRecoveryState::Clear + ); service .confirm_resolved(&marker.resource_id, &consistent.revision) .await diff --git a/src-tauri/src/application/remove.rs b/src-tauri/src/application/remove.rs index 14ff47b5..0caccaf0 100644 --- a/src-tauri/src/application/remove.rs +++ b/src-tauri/src/application/remove.rs @@ -29,7 +29,7 @@ use crate::core::agent_definition::AgentId; use crate::core::mutation::{CancellationSignal, MutationKind}; use crate::environment::planning::TargetFactResolver; use crate::environment::runtime::ObservedEntryId; -use crate::environment::types::SkillLocationRef; +use crate::environment::types::{display_locator, ResourceLocator, SkillLocationRef}; use crate::error::AppError; use crate::storage::lock_plan::{LockEntryMutation, LockExpectedState, PreparedLockMutation}; @@ -37,10 +37,13 @@ use crate::storage::lock_plan::{LockEntryMutation, LockExpectedState, PreparedLo #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub struct RemovePreview { + #[serde(skip_serializing_if = "Option::is_none")] + pub path_base: Option, pub token: PreviewToken, pub context: SkillLocationRef, pub skill_name: String, pub standard: ObservedEntryKind, + pub standard_path: Option, pub physical_entries: Vec, pub restores_library: bool, } @@ -117,7 +120,7 @@ where let entries = plan .project_observed_entries() .map_err(|error| error.into_app_error())?; - remove_preview( + let mut preview = remove_preview( context, skill_name, &facts, @@ -125,7 +128,9 @@ where &entries, &observed, &library_candidates, - ) + )?; + preview.path_base = Some(facts.resolved_context.path_base(&self.targets).await); + Ok(preview) } pub async fn execute( @@ -354,15 +359,23 @@ fn remove_preview( snapshot: &crate::application::scope_skill_placements::ResolvedScopeSkillPlacements, library_candidates: &LibraryCandidateSnapshot, ) -> Result { + let standard = plan + .standard_fact() + .map_err(|error| error.into_app_error())?; + let direct_keys = plan + .directories() + .iter() + .filter(|directory| { + matches!( + directory.observed(), + crate::application::scope_skill_planning::ObservedVersion::Direct + ) + }) + .map(|directory| directory.fact().key.clone()) + .collect::>(); let observed_state_digest = stable_digest(&( - &plan - .standard_fact() - .map_err(|error| error.into_app_error())? - .key, - &plan - .standard_fact() - .map_err(|error| error.into_app_error())? - .fingerprint, + &standard.key, + &standard.fingerprint, entries .iter() .map(|entry| (&entry.public.entry_id, &entry.fact.fingerprint)) @@ -408,15 +421,21 @@ fn remove_preview( planner_contract_version: 3, })?; Ok(RemovePreview { + path_base: None, token, context: context.clone(), skill_name: skill_name.to_string(), standard: crate::application::skill_entry_projection::observed_entry_kind( - plan.standard_fact() - .map_err(|error| error.into_app_error())? - .entry_kind, + standard.entry_kind, ), - physical_entries: entries.iter().map(|entry| entry.public.clone()).collect(), + standard_path: direct_keys + .contains(&standard.key) + .then(|| display_locator(&standard.destination)), + physical_entries: entries + .iter() + .filter(|entry| direct_keys.contains(&entry.fact.key)) + .map(|entry| entry.public.clone()) + .collect(), restores_library: !library_candidates.candidates().ordered().is_empty(), }) } @@ -534,6 +553,7 @@ mod tests { fn agent_entry_intent_rejects_unknown_entry() { let id = ObservedEntryId::parse("entry-v1-copy").unwrap(); let preview = RemovePreview { + path_base: None, token: crate::application::mutation::plan::PreviewToken { generation: "preview-v1-remove".to_string(), registry_revision: "registry-1".to_string(), @@ -549,6 +569,7 @@ mod tests { }, skill_name: "demo".to_string(), standard: ObservedEntryKind::Directory, + standard_path: None, physical_entries: Vec::new(), restores_library: false, }; @@ -581,6 +602,16 @@ mod tests { .unwrap(); let library_path = temp.path().join("libraries/lib-1/skills/demo"); std::fs::create_dir_all(&library_path).unwrap(); + std::fs::write( + library_path.join("SKILL.md"), + b"---\nname: demo\ndescription: Library\n---\nLibrary body\n", + ) + .unwrap(); + std::fs::create_dir_all(library_only_path.parent().unwrap()).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&library_path, &library_only_path).unwrap(); + #[cfg(windows)] + junction::create(&library_path, &library_only_path).unwrap(); let context = SkillLocationRef { environment: EnvironmentRef::Native, scope: SkillLocation::Global, @@ -610,7 +641,7 @@ mod tests { private_presence: None, legacy_paths: Vec::new(), }; - let facts = ScopePlanningSnapshot { + let mut facts = ScopePlanningSnapshot { resolved_context: ResolvedContext { context: context.clone(), project: None, @@ -664,6 +695,21 @@ mod tests { lock_document: LosslessLockDocument::empty(LockSchema::Global), eve_targets: Vec::new(), }; + let library_agent_id = AgentId::parse("library-only").unwrap(); + let mut library_agent = facts.agent_runtime.agents[&agent_id].clone(); + library_agent.definition.id = library_agent_id.clone(); + library_agent.definition.global.private_path = Some(PathSpec::home(".library-only/skills")); + library_agent.global.private_path = Some( + library_only_path + .parent() + .unwrap() + .to_string_lossy() + .into_owned(), + ); + facts + .agent_runtime + .agents + .insert(library_agent_id, library_agent); let targets = RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())); let resolved_targets = targets .resolve( @@ -678,10 +724,17 @@ mod tests { .await .unwrap(); let _ = resolved_targets; + let library_fact = targets + .resolve_environment(&EnvironmentRef::Native, &[locator(&library_path)], None) + .await + .unwrap() + .remove(0); + let library_locator = library_fact.destination.clone(); let candidate = crate::application::library_candidates::LibraryVersionCandidate::new( crate::application::skill_libraries::LibraryId::parse("lib-1"), "demo", - locator(&library_path), + library_locator.clone(), + library_fact.key, ); let library_candidates = LibraryCandidateSnapshot::new( "library-evidence-1", @@ -704,6 +757,19 @@ mod tests { let preview = service.preview(&context, "demo").await.unwrap(); assert!(preview.restores_library); + let serialized = serde_json::to_value(&preview).unwrap(); + let standard = serialized + .get("standardPath") + .expect("actual common installation path"); + assert_eq!( + std::fs::canonicalize(standard["nativePath"].as_str().unwrap()).unwrap(), + std::fs::canonicalize(&canonical_path).unwrap() + ); + assert_eq!( + preview.physical_entries.len(), + 1, + "library-only positions are preserved" + ); service .execute( &RemoveRequest { @@ -720,7 +786,7 @@ mod tests { let plan = recorded.lock().unwrap().take().unwrap(); assert!(matches!( plan.units[0].primary_entry.as_ref().map(|entry| &entry.action), - Some(PreparedEntryAction::Link { target }) if target.native_path == library_path.to_string_lossy() + Some(PreparedEntryAction::Link { target }) if target == &library_locator )); let expected_private = std::fs::canonicalize(private_path.parent().expect("private parent")) @@ -734,7 +800,7 @@ mod tests { assert!(matches!( &private_entry.action, PreparedEntryAction::Link { target } - if target.native_path == library_path.to_string_lossy() + if target == &library_locator )); assert_eq!(private_entry.reader_agent_ids, vec![agent_id]); } diff --git a/src-tauri/src/application/resources.rs b/src-tauri/src/application/resources.rs index b1a49376..f9b110c5 100644 --- a/src-tauri/src/application/resources.rs +++ b/src-tauri/src/application/resources.rs @@ -38,6 +38,11 @@ pub trait ResourceContextSource: Send + Sync { &'a self, context: &'a SkillLocationRef, ) -> ResourceFuture<'a, Result>; + + fn resolve_skill<'a>( + &'a self, + identity: &'a SkillIdentity, + ) -> ResourceFuture<'a, Result>; } pub trait AuthorizedResourceOpener: Send + Sync { @@ -75,17 +80,15 @@ where } pub async fn open_skill(&self, identity: &SkillIdentity) -> Result<(), AppError> { - let install_dir_name = validate_skill_name(&identity.skill_name)?; - let resolved = self.source.resolve(&identity.context).await?; - let target = resolved.canonical_skills_root.join_child(&install_dir_name); + validate_skill_name(&identity.skill_name)?; + let target = self.source.resolve_skill(identity).await?; let target = self.resolve_directory(&identity.context, target).await?; self.opener.open(target).await } pub async fn read_skill(&self, identity: &SkillIdentity) -> Result { - let install_dir_name = validate_skill_name(&identity.skill_name)?; - let resolved = self.source.resolve(&identity.context).await?; - let target = resolved.canonical_skills_root.join_child(&install_dir_name); + validate_skill_name(&identity.skill_name)?; + let target = self.source.resolve_skill(identity).await?; let target = self.resolve_directory(&identity.context, target).await?; self.reader.read_skill(target).await } @@ -145,6 +148,18 @@ mod tests { struct StaticSource; impl ResourceContextSource for StaticSource { + fn resolve_skill<'a>( + &'a self, + identity: &'a SkillIdentity, + ) -> ResourceFuture<'a, Result> { + Box::pin(async move { + let context = self.resolve(&identity.context).await?; + Ok(context + .canonical_skills_root + .join_child(&validate_skill_name(&identity.skill_name)?)) + }) + } + fn resolve<'a>( &'a self, context: &'a SkillLocationRef, diff --git a/src-tauri/src/application/scope_skill_placements.rs b/src-tauri/src/application/scope_skill_placements.rs index 71150c70..67194fd5 100644 --- a/src-tauri/src/application/scope_skill_placements.rs +++ b/src-tauri/src/application/scope_skill_placements.rs @@ -1,12 +1,22 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use crate::application::agent_selection::{AgentSelectionCatalog, DirectoryPlacementId}; +use crate::application::agent_selection::{ + AgentSelectionCatalog, DirectoryContentKind, DirectoryPlacementId, SkillDirectoryAccess, +}; use crate::application::installed_skill_resolver::{ InstalledSkillResolver, ResolvedInstalledSkill, }; +use crate::application::library_candidates::{ + LibraryVersionCandidate, ResolvedLibraryCandidateIndex, +}; +use crate::application::mutation::plan::stable_digest; use crate::application::planning_facts::ScopePlanningSnapshot; use crate::application::scope_skill_planning::ScopeSkillPlacementSet; -use crate::environment::planning::TargetFactResolver; +use crate::application::skill_entry_projection::{ + observed_entry_kind, ObservedEntryReader, ObservedPhysicalEntry, ObservedPlannedEntry, +}; +use crate::environment::planning::{TargetEntryKind, TargetFactResolver}; +use crate::environment::runtime::observed_entry_id; use crate::environment::types::SkillLocationRef; use crate::error::AppError; @@ -14,6 +24,218 @@ use crate::error::AppError; pub struct ResolvedScopeSkillPlacements { pub resolved: ResolvedInstalledSkill, pub(crate) placements: ScopeSkillPlacementSet, + pub(crate) additional_observations: Vec, + recorded_eve_targets: Option>, +} + +pub(crate) fn ambiguous_installed_source(path: &str) -> AppError { + AppError::CapabilityUnavailable { + capability: "installedSkillSourceAmbiguous".into(), + path: Some(path.into()), + } +} + +pub(crate) fn eve_target_ids(subagents: Option<&[String]>) -> Result, AppError> { + let Some(subagents) = subagents else { + return Ok(["eve:root".to_string()].into()); + }; + let mut ids = Vec::new(); + let mut seen = BTreeSet::new(); + for subagent in subagents { + let name = if subagent.is_empty() { + "root".into() + } else { + let name = InstalledSkillResolver::install_dir_name(subagent)?; + if name == "root" { + return Err(ambiguous_installed_source(subagent)); + } + name + }; + let id = format!("eve:{name}"); + if !seen.insert(id.clone()) { + return Err(ambiguous_installed_source(subagent)); + } + ids.push(id); + } + Ok(ids) +} + +pub(crate) fn recorded_eve_target_ids( + name: &str, + document: &crate::core::lossless_lock::LosslessLockDocument, +) -> Result>, AppError> { + use crate::core::local_lock::LocalSkillLockEntry; + let resolved = InstalledSkillResolver::resolve(name, document)?; + let Some(value) = document.entry_snapshot(&resolved.lock_key).value().cloned() else { + return Ok(None); + }; + let Ok(entry) = serde_json::from_value::(value) else { + return Ok(None); + }; + if entry.source.trim().is_empty() || entry.source_type.trim().is_empty() { + return Ok(None); + } + let targets = eve_target_ids(entry.subagents.as_deref())?; + Ok(Some(targets)) +} + +#[derive(Debug, Clone)] +pub(crate) struct ObservedSkillPlacement { + pub id: DirectoryPlacementId, + pub entry: ObservedPlannedEntry, + pub content: DirectoryContentKind, + pub library: Option, +} + +impl ObservedSkillPlacement { + pub(crate) fn is_direct_directory(&self) -> bool { + self.library.is_none() && self.entry.fact.entry_kind == TargetEntryKind::Directory + } +} + +impl ResolvedScopeSkillPlacements { + pub(crate) fn describe( + &self, + catalog: &AgentSelectionCatalog, + libraries: &ResolvedLibraryCandidateIndex, + ) -> Result, AppError> { + let skill = crate::application::installed_skill_resolver::SkillDirectoryName::try_from( + self.resolved.skill_name.as_str(), + )?; + let mut placements = self + .placements + .facts() + .iter() + .filter(|(id, _)| match id { + DirectoryPlacementId::Standard => true, + DirectoryPlacementId::Option(id) => catalog.option(id).is_none_or(|option| { + !option.placement.content.uses_eve_payload() + || self.recorded_eve_targets.as_ref().is_some_and(|ids| { + option.adapter_target_ids.iter().any(|id| ids.contains(id)) + }) + }), + }) + .map(|(id, fact)| { + let (content, readers) = match id { + DirectoryPlacementId::Standard => ( + DirectoryContentKind::Original, + catalog + .snapshot() + .agents + .iter() + .filter(|agent| { + matches!( + agent.directory_access, + Some( + SkillDirectoryAccess::StandardOnly + | SkillDirectoryAccess::Both + ) + ) + }) + .map(|agent| ObservedEntryReader { + agent_id: agent.id.clone(), + display_name: agent.display_name.clone(), + logical_target_id: "canonical".to_string(), + }) + .collect(), + ), + DirectoryPlacementId::Option(option_id) => { + let option = catalog.option(option_id).ok_or(AppError::StaleTarget)?; + let mut readers = Vec::new(); + for agent_id in &option.public.agent_ids { + let agent = catalog + .snapshot() + .agents + .iter() + .find(|agent| &agent.id == agent_id) + .ok_or(AppError::StaleRegistry)?; + let target_ids = if option.adapter_target_ids.is_empty() { + vec![format!("agent:{}:private", agent_id.as_str())] + } else { + option.adapter_target_ids.clone() + }; + for target_id in target_ids { + readers.push(ObservedEntryReader { + agent_id: agent_id.clone(), + display_name: if option.placement.content.uses_eve_payload() { + option.public.display_name.clone() + } else { + agent.display_name.clone() + }, + logical_target_id: target_id, + }); + } + } + (option.placement.content.clone(), readers) + } + }; + Ok(ObservedSkillPlacement { + id: id.clone(), + content, + library: libraries.owner(&skill, fact).cloned(), + entry: ObservedPlannedEntry { + public: ObservedPhysicalEntry { + entry_id: observed_entry_id(&fact.key, &fact.fingerprint)?, + display_path: fact.destination.clone(), + kind: observed_entry_kind(fact.entry_kind), + physical_target_key: stable_digest(&fact.key)?, + readers, + will_break_if_standard_removed: false, + }, + fact: fact.clone(), + }, + }) + }) + .collect::, AppError>>()?; + let links = placements + .iter() + .filter(|placement| { + placement.library.is_none() + && matches!( + placement.entry.fact.entry_kind, + TargetEntryKind::Symlink | TargetEntryKind::Junction + ) + }) + .filter_map(|placement| { + placement + .entry + .fact + .link_target_identity + .clone() + .map(|identity| (identity, placement.entry.public.readers.clone())) + }) + .collect::>(); + for placement in placements + .iter_mut() + .filter(|placement| placement.is_direct_directory()) + { + for (identity, readers) in &links { + if identity.matches(&placement.entry.fact.destination) { + placement.entry.public.readers.extend(readers.clone()); + } + } + placement.entry.public.readers.sort_by(|a, b| { + (&a.agent_id, &a.logical_target_id).cmp(&(&b.agent_id, &b.logical_target_id)) + }); + placement.entry.public.readers.dedup(); + } + Ok(placements) + } +} + +pub(crate) fn representative_direct_placement( + placements: &[ObservedSkillPlacement], +) -> Option<&ObservedSkillPlacement> { + placements + .iter() + .filter(|placement| placement.is_direct_directory()) + .min_by_key(|placement| { + ( + placement.id != DirectoryPlacementId::Standard, + placement.content.uses_eve_payload(), + placement.entry.fact.destination.native_path.as_str(), + ) + }) } pub struct ScopeSkillPlacementResolver { @@ -37,31 +259,141 @@ where facts: &ScopePlanningSnapshot, catalog: &AgentSelectionCatalog, ) -> Result { - let standard = catalog.standard(); - if facts.resolved_context.context != *context || catalog.context() != context { - return Err(AppError::StaleContext); + observe_scope_skill_placements(&self.targets, context, skill_name, facts, catalog).await + } +} + +pub(crate) async fn observe_scope_skill_placements( + targets: &T, + context: &SkillLocationRef, + skill_name: &str, + facts: &ScopePlanningSnapshot, + catalog: &AgentSelectionCatalog, +) -> Result { + let standard = catalog.standard(); + if facts.resolved_context.context != *context || catalog.context() != context { + return Err(AppError::StaleContext); + } + let resolved_identity = InstalledSkillResolver::resolve(skill_name, &facts.lock_document)?; + let install_dir_name = &resolved_identity.install_dir_name; + let mut destinations = vec![standard.root.join_child(install_dir_name)]; + let mut placement_ids = vec![DirectoryPlacementId::Standard]; + let recorded_eve = if facts.lock_schema == crate::core::lossless_lock::LockSchema::Project { + recorded_eve_target_ids(skill_name, &facts.lock_document)? + } else { + None + }; + let mut single_files = Vec::new(); + if let Some(ids) = &recorded_eve { + for id in ids { + let roots = catalog + .options() + .filter(|option| option.adapter_target_ids.contains(id)) + .map(|option| &option.placement.physical_key) + .collect::>(); + if roots.len() > 1 { + return Err(ambiguous_installed_source(skill_name)); + } } - let resolved_identity = InstalledSkillResolver::resolve(skill_name, &facts.lock_document)?; - let install_dir_name = &resolved_identity.install_dir_name; - let mut destinations = vec![standard.root.join_child(install_dir_name)]; - let mut placement_ids = vec![DirectoryPlacementId::Standard]; - for option in catalog.options() { - destinations.push(option.placement.root.join_child(install_dir_name)); - placement_ids.push(option.placement.id.clone()); + } + for option in catalog.options() { + if option.placement.content.uses_eve_payload() + && recorded_eve + .as_ref() + .is_some_and(|ids| option.adapter_target_ids.iter().any(|id| ids.contains(id))) + { + single_files.push(( + destinations.len(), + option + .placement + .root + .join_child(&format!("{install_dir_name}.md")), + )); } - let resolved = self.targets.resolve(context, &destinations, None).await?; - if resolved.len() != destinations.len() || resolved.is_empty() { - return Err(AppError::StaleTarget); + destinations.push(option.placement.root.join_child(install_dir_name)); + placement_ids.push(option.placement.id.clone()); + } + destinations.extend(single_files.iter().map(|(_, path)| path.clone())); + let resolved = targets.resolve(context, &destinations, None).await?; + if resolved.len() != destinations.len() || resolved.is_empty() { + return Err(AppError::StaleTarget); + } + let additional_observations = resolved[placement_ids.len()..].to_vec(); + for ((directory_index, _), file) in single_files.iter().zip(&additional_observations) { + if file.entry_kind != TargetEntryKind::Missing { + if resolved[*directory_index].entry_kind != TargetEntryKind::Missing { + return Err(ambiguous_installed_source(&file.destination.native_path)); + } + return Err(AppError::CapabilityUnavailable { + capability: "eveSingleFile".into(), + path: Some(file.destination.native_path.clone()), + }); + } + } + let placement_facts = placement_ids + .into_iter() + .zip(resolved.iter().cloned()) + .collect::>(); + if let Some(ids) = &recorded_eve { + let owned = |ids: &[String]| { + placement_facts + .iter() + .filter_map(|(id, fact)| { + let DirectoryPlacementId::Option(id) = id else { + return None; + }; + catalog + .option(id) + .filter(|option| { + option.adapter_target_ids.iter().any(|id| ids.contains(id)) + }) + .map(|_| fact) + }) + .collect::>() + }; + let current = owned(ids); + if let Some(records) = facts + .lock_document + .root_snapshot("skills") + .value() + .and_then(serde_json::Value::as_object) + { + for name in records.keys() { + if name == &resolved_identity.lock_key + || InstalledSkillResolver::install_dir_name(name) + .ok() + .as_deref() + != Some(install_dir_name.as_str()) + { + continue; + } + let Some(ids) = recorded_eve_target_ids(name, &facts.lock_document)? else { + continue; + }; + if owned(&ids).iter().any(|other| { + current.iter().any(|target| { + other.key == target.key + || other + .link_target_identity + .as_ref() + .is_some_and(|identity| identity.matches(&target.destination)) + || target + .link_target_identity + .as_ref() + .is_some_and(|identity| identity.matches(&other.destination)) + }) + }) { + return Err(ambiguous_installed_source(skill_name)); + } + } } - let placement_facts = placement_ids - .into_iter() - .zip(resolved.iter().cloned()) - .collect::>(); - Ok(ResolvedScopeSkillPlacements { - resolved: resolved_identity, - placements: ScopeSkillPlacementSet::new(context.clone(), placement_facts), - }) } + Ok(ResolvedScopeSkillPlacements { + resolved: resolved_identity, + placements: ScopeSkillPlacementSet::new(context.clone(), placement_facts), + additional_observations, + recorded_eve_targets: recorded_eve, + }) } #[cfg(test)] @@ -88,6 +420,7 @@ mod tests { EnvironmentRef, EnvironmentStatus, ResourceLocator, SkillLocation, SkillLocationRef, }; use crate::environment::wsl::WslRuntime; + use std::collections::BTreeSet; use std::path::Path; use std::sync::Arc; use tempfile::tempdir; @@ -251,6 +584,83 @@ mod tests { ) } + #[tokio::test] + async fn direct_placement_includes_readers_of_leaf_links() { + let temp = tempdir().unwrap(); + let direct_root = temp.path().join("direct"); + let linked_root = temp.path().join("linked"); + std::fs::create_dir_all(direct_root.join("demo")).unwrap(); + std::fs::create_dir_all(&linked_root).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(direct_root.join("demo"), linked_root.join("demo")).unwrap(); + #[cfg(windows)] + junction::create(direct_root.join("demo"), linked_root.join("demo")).unwrap(); + let mut facts = observer_facts(); + facts.resolved_context.home.native_path = temp.path().to_string_lossy().into_owned(); + facts.resolved_context.skill_root.native_path = + temp.path().join("standard").to_string_lossy().into_owned(); + facts.agent_runtime.agents = [ + observed_agent( + "direct", + "Direct", + AgentAdapter::Standard, + direct_root.to_str(), + ), + observed_agent( + "linked", + "Linked", + AgentAdapter::Standard, + linked_root.to_str(), + ), + ] + .into(); + let context = &facts.resolved_context.context; + let targets = crate::environment::planning::RuntimeTargetFactResolver::new(Arc::new( + WslRuntime::default(), + )); + let catalog = build_agent_selection_catalog( + context, + &facts.agent_runtime, + &[], + &facts.resolved_context.skill_root, + &targets, + ) + .await + .unwrap(); + let libraries = + crate::native_workflow_integration_support::update_library_repository(temp.path()); + let known = ResolvedLibraryCandidateIndex::load_known( + libraries.as_ref(), + &targets, + &context.environment, + &["demo".try_into().unwrap()].into(), + ) + .await + .unwrap(); + let observed = observe_scope_skill_placements(&targets, context, "demo", &facts, &catalog) + .await + .unwrap(); + let placements = observed.describe(&catalog, &known).unwrap(); + let direct = representative_direct_placement(&placements).unwrap(); + assert_eq!( + direct + .entry + .public + .readers + .iter() + .map(|r| r.agent_id.as_str()) + .collect::>(), + ["direct", "linked"].into() + ); + assert_eq!( + placements + .iter() + .filter(|p| p.is_direct_directory()) + .count(), + 1 + ); + } + #[tokio::test] async fn catalog_observation_reuses_shared_overlap_and_eve_placements() { let mut facts = observer_facts(); @@ -342,7 +752,11 @@ mod tests { let temp = tempdir().unwrap(); let standard = temp.path().join("demo"); std::fs::create_dir_all(standard.join("scripts")).unwrap(); - std::fs::write(standard.join("SKILL.md"), b"demo").unwrap(); + std::fs::write( + standard.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nbody", + ) + .unwrap(); std::fs::write(standard.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); let manager = Arc::new(PayloadSessionManager::in_memory( PayloadSessionLimits { diff --git a/src-tauri/src/application/scope_skill_planning/election.rs b/src-tauri/src/application/scope_skill_planning/election.rs index cd403478..f479c23f 100644 --- a/src-tauri/src/application/scope_skill_planning/election.rs +++ b/src-tauri/src/application/scope_skill_planning/election.rs @@ -373,17 +373,17 @@ fn observe_physical_directory( use crate::environment::planning::TargetEntryKind; let fact = &group[0].fact; + if let Some(candidate) = libraries + .recognized() + .iter() + .find(|candidate| candidate.matches_target(fact)) + { + return ObservedVersion::Library(candidate.clone()); + } match fact.entry_kind { TargetEntryKind::Directory => ObservedVersion::Direct, TargetEntryKind::Symlink | TargetEntryKind::Junction | TargetEntryKind::BrokenLink => { if let Some(identity) = fact.link_target_identity.as_ref() { - if let Some(candidate) = libraries - .recognized() - .iter() - .find(|candidate| identity.matches(candidate.locator())) - { - return ObservedVersion::Library(candidate.clone()); - } if group .iter() .flat_map(|input| { @@ -493,6 +493,35 @@ mod tests { } } + #[test] + fn directory_reached_through_a_library_root_keeps_library_ownership() { + let observed = fact( + "demo", + "/scope/agent/skills/demo", + TargetEntryKind::Directory, + None, + ); + let library = LibraryVersionCandidate::new( + LibraryId::parse("library-one"), + "demo", + locator("/libraries/library-one/skills/demo"), + observed.key.clone(), + ); + let input = SkillDirectoryPlacementInput::new( + DirectoryPlacementRef::Catalog(DirectoryPlacementId::Standard), + observed, + PlacementVersionDemand::library(), + PlacementVersionDemand::library(), + ) + .unwrap(); + let candidates = + LibraryCandidateSet::new(vec![library.clone()], vec![library.clone()]).unwrap(); + assert_eq!( + observe_physical_directory(&[input], &candidates), + ObservedVersion::Library(library) + ); + } + #[test] fn placement_request_rejects_missing_duplicate_standard_and_legacy_target_demand() { let skill = SkillDirectoryName::try_from("demo").unwrap(); @@ -590,6 +619,20 @@ mod tests { LibraryId::parse("library-one"), "demo", locator("/libraries/library-one/skills/demo"), + PhysicalTargetKey { + physical_parent: if cfg!(windows) { + PhysicalParentIdentity::Windows { + volume_serial: 1, + file_id: 10, + } + } else { + PhysicalParentIdentity::Unix { + device: 1, + inode: 10, + } + }, + ..key("demo") + }, ); let candidates = LibraryCandidateSet::new(vec![candidate.clone()], vec![candidate.clone()]).unwrap(); diff --git a/src-tauri/src/application/scope_skill_planning/mod.rs b/src-tauri/src/application/scope_skill_planning/mod.rs index effc24c1..2b938432 100644 --- a/src-tauri/src/application/scope_skill_planning/mod.rs +++ b/src-tauri/src/application/scope_skill_planning/mod.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; mod election; @@ -6,9 +6,7 @@ use crate::application::agent_selection::{ AgentInstallOptionKind, AgentSelectionCatalog, DirectoryPlacementId, }; use crate::application::installed_skill_resolver::SkillDirectoryName; -use crate::application::library_agent_placements::{ - LibraryAgentPlacementError, LibraryAgentPlacementMap, -}; +use crate::application::library_agent_placements::LibraryAgentPlacementMap; use crate::application::library_candidates::{LibraryCandidateError, LibraryCandidateSet}; use crate::application::mutation::plan::{ stable_digest, ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, @@ -108,6 +106,7 @@ pub(crate) struct LibrarySkillChangeRequest<'a> { pub(crate) placements: ScopeSkillPlacementSet, pub(crate) before: LibraryElectionState<'a>, pub(crate) after: LibraryElectionState<'a>, + pub(crate) before_applied: bool, pub(crate) legacy: Vec, } @@ -119,13 +118,15 @@ pub(crate) enum ScopeSkillPlanningError { CatalogPlacementMismatch(DirectoryPlacementId), LibraryCandidate(LibraryCandidateError), InvalidInput(SkillDirectoryInputError), - LibraryAgentPlacement(LibraryAgentPlacementError), ConflictingDirectContent { target_path: String, }, ConflictingDirectMaterialization { target_path: String, }, + LibraryContentTarget { + target_path: String, + }, ExternalTarget { skill_name: String, target_path: String, @@ -156,21 +157,15 @@ impl ScopeSkillPlanningError { Self::InvalidInput(error) => AppError::ConfigurationCorrupted { message: format!("Scope Skill planning input is inconsistent: {error:?}"), }, - Self::LibraryAgentPlacement(LibraryAgentPlacementError::UnknownAgent(agent)) => { - AppError::InvalidAgent { - agent: agent.as_str().to_string(), - } - } - Self::LibraryAgentPlacement(LibraryAgentPlacementError::PartialSelection(_)) => { - AppError::AgentSelectionInvalid { - reason: crate::error::AgentSelectionInvalidReason::OptionUnavailable, - } - } Self::ConflictingDirectContent { target_path } | Self::ConflictingDirectMaterialization { target_path } => AppError::Validation { field: Some("skillName".to_string()), message: format!("direct Skill content conflicts at {target_path}"), }, + Self::LibraryContentTarget { target_path } => AppError::CapabilityUnavailable { + capability: "libraryContentTarget".into(), + path: Some(target_path), + }, Self::ExternalTarget { skill_name, target_path, @@ -255,6 +250,60 @@ impl ScopeSkillPlan { &self.directories } + pub(crate) fn direct_content_candidates( + &self, + catalog: &AgentSelectionCatalog, + ) -> Vec { + let mut candidates = Vec::new(); + for directory in &self.directories { + if !matches!(directory.observed, ObservedVersion::Direct) + || directory.fact.entry_kind != TargetEntryKind::Directory + { + continue; + } + if directory + .placements + .contains(&DirectoryPlacementRef::Catalog( + DirectoryPlacementId::Standard, + )) + { + return vec![directory.fact.clone()]; + } + let original = directory + .placements + .iter() + .any(|placement| match placement { + DirectoryPlacementRef::Catalog(id) => catalog + .placement(id) + .is_some_and(|placement| !placement.content.uses_eve_payload()), + DirectoryPlacementRef::Legacy => false, + }); + candidates.push((original, directory.fact.clone())); + } + let has_original = candidates.iter().any(|(original, _)| *original); + candidates.retain(|(original, _)| *original || !has_original); + candidates + .sort_by(|(_, a), (_, b)| a.destination.native_path.cmp(&b.destination.native_path)); + candidates.into_iter().map(|(_, fact)| fact).collect() + } + + pub(crate) fn has_shared_source(&self, source: &ResolvedTargetFact) -> bool { + self.directories.iter().any(|directory| { + matches!(directory.observed, ObservedVersion::Direct) + && directory + .placements + .contains(&DirectoryPlacementRef::Catalog( + DirectoryPlacementId::Standard, + )) + && (directory.fact.key == source.key + || directory + .fact + .link_target_identity + .as_ref() + .is_some_and(|identity| identity.matches(&source.destination))) + }) + } + pub(crate) fn standard_fact(&self) -> Result<&ResolvedTargetFact, ScopeSkillPlanningError> { self.directories .iter() @@ -367,8 +416,7 @@ impl ScopeSkillPlanner { request.placements, request.libraries, request.libraries, - request.direct_changes, - Vec::new(), + ScopeSkillChange::Direct(request.direct_changes), ) } @@ -381,10 +429,39 @@ impl ScopeSkillPlanner { request.placements, request.before, request.after, - BTreeMap::new(), - request.legacy, + ScopeSkillChange::Library { + before_applied: request.before_applied, + legacy: request.legacy, + }, ) } + + pub(crate) fn conflicting_mutations<'a>( + entries: impl IntoIterator, + ) -> BTreeSet { + let mut expected = BTreeMap::::new(); + let mut conflicts = BTreeSet::new(); + for (key, action) in entries { + match expected.get(key) { + Some(existing) if existing != action => { + conflicts.insert(key.clone()); + } + Some(_) => {} + None => { + expected.insert(key.clone(), action.clone()); + } + } + } + conflicts + } +} + +enum ScopeSkillChange { + Direct(BTreeMap), + Library { + before_applied: bool, + legacy: Vec, + }, } fn plan( @@ -393,9 +470,15 @@ fn plan( placements: ScopeSkillPlacementSet, before: LibraryElectionState<'_>, after: LibraryElectionState<'_>, - direct_changes: BTreeMap, - legacy: Vec, + change: ScopeSkillChange, ) -> Result { + let (before_applied, direct_changes, legacy) = match change { + ScopeSkillChange::Direct(changes) => (None, changes, Vec::new()), + ScopeSkillChange::Library { + before_applied, + legacy, + } => (Some(before_applied), BTreeMap::new(), legacy), + }; if catalog.context() != &placements.context { return Err(ScopeSkillPlanningError::ScopeMismatch); } @@ -415,12 +498,8 @@ fn plan( } } let library_placements = LibraryAgentPlacementMap::from_catalog(catalog); - let before_library = library_placements - .placements_for(before.selected_agent_ids) - .map_err(ScopeSkillPlanningError::LibraryAgentPlacement)?; - let after_library = library_placements - .placements_for(after.selected_agent_ids) - .map_err(ScopeSkillPlanningError::LibraryAgentPlacement)?; + let before_library = library_placements.placements_for_saved(before.selected_agent_ids); + let after_library = library_placements.placements_for_saved(after.selected_agent_ids); validate_prepared_direct_changes(&placements.resolved, &direct_changes)?; let mut recognized = before.candidates.recognized().to_vec(); @@ -431,7 +510,8 @@ fn plan( } let candidates = LibraryCandidateSet::new(recognized, after.candidates.ordered().to_vec()) .map_err(ScopeSkillPlanningError::LibraryCandidate)?; - let before_has_library = !before.candidates.ordered().is_empty(); + let before_has_library = + before_applied.unwrap_or_else(|| !before.candidates.ordered().is_empty()); let after_has_library = !after.candidates.ordered().is_empty(); let prepared_keys = direct_changes .iter() @@ -572,6 +652,14 @@ fn plan( })?; let mut directories = Vec::new(); for directory in physical.directories() { + if directory.fact().entry_kind == TargetEntryKind::Directory + && matches!(directory.observed(), ObservedVersion::Library(_)) + && !matches!(directory.action(), PreparedEntryAction::Keep) + { + return Err(ScopeSkillPlanningError::LibraryContentTarget { + target_path: directory.fact().destination.native_path.clone(), + }); + } let mut readers = readers_by_key .remove(&directory.fact().key) .unwrap_or_default(); @@ -652,17 +740,18 @@ fn current_direct_version( standard: &ResolvedTargetFact, libraries: &LibraryCandidateSet, ) -> Option { - let link_is_library = fact.link_target_identity.as_ref().is_some_and(|identity| { - libraries - .recognized() - .iter() - .any(|candidate| identity.matches(candidate.locator())) - }); + if libraries + .recognized() + .iter() + .any(|candidate| candidate.matches_target(fact)) + { + return None; + } let source = (placement != &DirectoryPlacementId::Standard).then_some(standard.destination.clone()); let direct = match fact.entry_kind { TargetEntryKind::Directory => Some(DirectVersionCandidate::existing(source)), - TargetEntryKind::Symlink | TargetEntryKind::Junction if !link_is_library => { + TargetEntryKind::Symlink | TargetEntryKind::Junction => { Some(DirectVersionCandidate::existing(source)) } TargetEntryKind::BrokenLink @@ -820,7 +909,7 @@ mod tests { agents: Vec::new(), install_options: Vec::new(), groups: Vec::new(), - initial_selected_option_ids: Vec::new(), + baseline_selected_option_ids: Vec::new(), unavailable_explicit_agents: Vec::new(), user_mode_option_ids: Vec::new(), revision: AgentSelectionRevision("revision".to_string()), @@ -864,7 +953,7 @@ mod tests { }], install_options: vec![public.clone()], groups: Vec::new(), - initial_selected_option_ids: Vec::new(), + baseline_selected_option_ids: Vec::new(), unavailable_explicit_agents: Vec::new(), user_mode_option_ids: Vec::new(), revision: AgentSelectionRevision("revision".to_string()), @@ -940,7 +1029,7 @@ mod tests { }], install_options: vec![first.clone(), second.clone()], groups: Vec::new(), - initial_selected_option_ids: Vec::new(), + baseline_selected_option_ids: Vec::new(), unavailable_explicit_agents: Vec::new(), user_mode_option_ids: Vec::new(), revision: AgentSelectionRevision("revision".to_string()), @@ -1135,6 +1224,51 @@ mod tests { ); } + #[test] + fn library_member_directory_is_neither_a_direct_source_nor_a_scope_write_target() { + let catalog = catalog(); + let standard = fact("demo", TargetEntryKind::Directory, None); + let member = LibraryVersionCandidate::new( + LibraryId::parse("library-one"), + "demo", + locator("/libraries/library-one/skills/demo"), + standard.key.clone(), + ); + let candidates = LibraryCandidateSet::new(vec![member.clone()], vec![member]).unwrap(); + assert!(current_direct_version( + &standard, + &DirectoryPlacementId::Standard, + &standard, + &candidates + ) + .is_none()); + let result = ScopeSkillPlanner::plan_direct_change(DirectSkillChangeRequest { + skill: SkillDirectoryName::try_from("demo").unwrap(), + catalog: &catalog, + placements: ScopeSkillPlacementSet::new( + context(), + [(DirectoryPlacementId::Standard, standard)].into(), + ), + libraries: LibraryElectionState { + candidates: &candidates, + selected_agent_ids: &[], + }, + direct_changes: [( + DirectoryPlacementId::Standard, + DirectPlacementChange::Set(PreparedDirectVersion::new( + DirectContentIdentity::Existing(key("new")), + PreparedEntryAction::Link { + target: locator("/new/demo"), + }, + )), + )] + .into(), + }); + assert!( + matches!(result.map_err(|error| error.into_app_error()), Err(AppError::CapabilityUnavailable { capability, .. }) if capability == "libraryContentTarget") + ); + } + #[test] fn direct_change_uses_prepared_content_over_preserved_existing_content() { let catalog = catalog(); @@ -1173,6 +1307,7 @@ mod tests { LibraryId::parse("library-one"), "demo", locator("/libraries/library-one/skills/demo"), + library_key(10), ); let candidates = LibraryCandidateSet::for_skill( &EnvironmentRef::Native, @@ -1214,11 +1349,13 @@ mod tests { LibraryId::parse("library-before"), "demo", locator("/libraries/library-before/skills/demo"), + library_key(11), ); let target_candidate = LibraryVersionCandidate::new( LibraryId::parse("library-target"), "demo", locator("/libraries/library-target/skills/demo"), + library_key(12), ); let before_candidates = LibraryCandidateSet::for_skill( &EnvironmentRef::Native, @@ -1253,6 +1390,7 @@ mod tests { candidates: &target_candidates, selected_agent_ids: &[], }, + before_applied: true, legacy: vec![LegacyLibraryPlacement { fact: fact( "legacy-demo", @@ -1271,4 +1409,68 @@ mod tests { .expect("legacy directory remains in the plan"); assert_eq!(legacy.action(), &PreparedEntryAction::Keep); } + + #[test] + fn cross_scope_conflicts_are_grouped_by_physical_target_and_elected_version() { + let first = plan_with_library("shared", "library-a"); + let same = plan_with_library("shared", "library-a"); + let different = plan_with_library("shared", "library-b"); + + assert!(ScopeSkillPlanner::conflicting_mutations( + [&first, &same] + .into_iter() + .flat_map(|plan| plan.directories().iter()) + .map(|directory| (&directory.fact.key, &directory.action)) + ) + .is_empty()); + assert_eq!( + ScopeSkillPlanner::conflicting_mutations( + [&first, &different] + .into_iter() + .flat_map(|plan| plan.directories().iter()) + .map(|directory| (&directory.fact.key, &directory.action)) + ), + std::collections::BTreeSet::from([key("shared")]) + ); + } + + fn library_key(identity: u64) -> PhysicalTargetKey { + PhysicalTargetKey { + physical_parent: if cfg!(windows) { + PhysicalParentIdentity::Windows { + volume_serial: 1, + file_id: identity.into(), + } + } else { + PhysicalParentIdentity::Unix { + device: 1, + inode: identity, + } + }, + ..key("demo") + } + } + + fn plan_with_library(target_key: &str, library_id: &str) -> ScopeSkillPlan { + let candidate = LibraryVersionCandidate::new( + LibraryId::parse(library_id), + "demo", + locator(&format!("/libraries/{library_id}/skills/demo")), + library_key(10), + ); + ScopeSkillPlan { + directories: vec![ScopePlannedDirectory { + fact: fact(target_key, TargetEntryKind::Missing, None), + placements: vec![DirectoryPlacementRef::Catalog( + DirectoryPlacementId::Standard, + )], + observed: ObservedVersion::Unknown, + elected: ElectedVersion::Library(candidate.clone()), + action: PreparedEntryAction::Link { + target: candidate.locator().clone(), + }, + readers: Vec::new(), + }], + } + } } diff --git a/src-tauri/src/application/skill_libraries.rs b/src-tauri/src/application/skill_libraries.rs index defe87ba..a8245060 100644 --- a/src-tauri/src/application/skill_libraries.rs +++ b/src-tauri/src/application/skill_libraries.rs @@ -8,8 +8,9 @@ use specta::Type; use crate::application::collection_records::{ CollectionRecordReader, DocumentRevision, LibraryCatalogRecordReader, SourceRecordRevision, }; -#[cfg(test)] use crate::application::installed_skill_resolver::InstalledSkillResolver; +use crate::application::library_membership::LibraryMembershipOutcome; +use crate::application::library_membership::LibraryMembershipPreview; use crate::application::mutation::plan::stable_digest; use crate::application::payload_session::{ AcquiredPayloadHandle, DiscoverySessionHandle, PayloadSessionManager, @@ -23,7 +24,7 @@ use crate::core::skill::parse_skill_md_content; use crate::core::skill_payload::{PayloadEntryKind, SkillPayload}; use crate::environment::content_manifest::ContentManifestReader; use crate::environment::planning::{TargetEntryKind, TargetFactResolver}; -use crate::environment::types::{EnvironmentRef, SkillLocationRef}; +use crate::environment::types::{same_environment_identity, EnvironmentRef, SkillLocationRef}; use crate::error::AppError; pub(crate) const LIBRARY_SCHEMA_VERSION: u32 = 3; @@ -45,6 +46,25 @@ impl LibraryId { } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] +#[serde(transparent)] +pub struct RetirementId(String); + +impl RetirementId { + pub(crate) fn new() -> Self { + Self(uuid::Uuid::new_v4().simple().to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + #[cfg(test)] + pub(crate) fn parse(value: impl Into) -> Self { + Self(value.into()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -72,6 +92,10 @@ pub struct LibrarySkillSummary { /// Skill Deck 最近一次成功提交该成员本地内容的时间,不表示上游发布时间。 /// 旧成员在下一次成功写入前为 `None`。 pub updated_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comparison_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub update_capability: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -86,8 +110,8 @@ pub struct SkillLibraryDetail { /// 某个 Skill 位置引用当前对象的方式。 /// -/// 生效与锁定是两件事:`Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有 -/// 未完成的应用操作引用它、尚未确认生效。两者的并集才是成员锁定的判定依据。 +/// `Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有未完成的应用操作 +/// 引用它、尚未确认生效。两者的并集用于整库删除保护和使用状态展示。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -118,6 +142,13 @@ pub struct LibraryUsageProjection { pub pending_count: u32, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryUsageSnapshot { + pub projections: Vec, + pub inventory_complete: bool, + pub problem_count: u32, +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -170,6 +201,25 @@ pub struct LibraryAddPreview { pub token: LibraryAddPreviewToken, pub skills: Vec, pub redirected_download_host: Option, + pub membership: LibraryMembershipPreview, +} + +impl LibraryAddPreview { + pub(crate) fn bind_membership( + &mut self, + request: &PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, + ) -> Result<(), AppError> { + self.token.generation = library_add_preview_generation( + request, + &self.redirected_download_host, + &self.token.context_revision, + &self.token.skill_revisions, + &membership, + )?; + self.membership = membership; + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -178,6 +228,7 @@ pub struct LibraryAddPreview { pub struct ExecuteAddLibrarySkillsRequest { pub request: PreviewAddLibrarySkillsRequest, pub expected_token: LibraryAddPreviewToken, + pub membership: LibraryMembershipPreview, pub acknowledge_redirect: bool, } @@ -205,7 +256,14 @@ pub struct LibraryAddSkillResult { #[specta(rename_all = "camelCase")] pub struct LibraryAddResponse { pub results: Vec, - pub library: SkillLibraryDetail, + pub library: Option, + pub membership: LibraryMembershipOutcome, +} + +pub(crate) struct LibraryAddCommitResponse { + pub(crate) results: Vec, + pub(crate) library: Option, + pub(crate) snapshot_error: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -226,6 +284,70 @@ pub struct RemoveLibrarySkillRequest { pub skill_name: String, } +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryRetirePreview { + pub skill_name: String, + pub token: String, + pub membership: LibraryMembershipPreview, +} + +pub(crate) struct PreparedLibraryRetire { + skill_name: String, + base_token: String, +} + +impl PreparedLibraryRetire { + pub(crate) fn skill_name(&self) -> &str { + &self.skill_name + } + + pub(crate) fn bind( + self, + membership: LibraryMembershipPreview, + ) -> Result { + Ok(LibraryRetirePreview { + skill_name: self.skill_name, + token: bound_retire_preview_token(&self.base_token, &membership)?, + membership, + }) + } +} + +fn bound_retire_preview_token( + base_token: &str, + membership: &LibraryMembershipPreview, +) -> Result { + stable_digest(&( + "library-retire-preview", + base_token, + membership.token.as_str(), + )) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct ExecuteRetireLibrarySkillRequest { + pub request: RemoveLibrarySkillRequest, + pub expected_token: String, + pub membership: LibraryMembershipPreview, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryRetireResponse { + pub library: Option, + pub membership: LibraryMembershipOutcome, +} + +pub(crate) struct LibraryRetireCommitResponse { + pub(crate) library: Option, + pub(crate) snapshot_error: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -235,6 +357,8 @@ pub struct LibraryWorkspaceSnapshot { /// catalog 内容的摘要。应用关系不参与该摘要,页面重新进入时自行拉取最新投影。 pub revision: String, pub usage_projection: Vec, + pub usage_inventory_complete: bool, + pub usage_inventory_problem_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -252,6 +376,17 @@ pub(crate) struct SkillLibraryRecord { pub(crate) id: LibraryId, pub(crate) name: String, pub(crate) skills: Vec, + pub(crate) retired_skills: Vec, + #[serde(flatten)] + pub(crate) extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RetiredLibrarySkillRecord { + pub(crate) retirement_id: RetirementId, + pub(crate) member: LibrarySkillRecord, + pub(crate) retired_at: String, #[serde(flatten)] pub(crate) extra: serde_json::Map, } @@ -311,7 +446,10 @@ pub(crate) enum LibraryMemberMutation { content: Box, record: Box, }, - Delete, + Retire { + retirement_id: RetirementId, + retired_at: String, + }, } #[derive(Debug, Clone)] @@ -323,6 +461,14 @@ pub(crate) struct CommitLibraryMemberRequest { pub(crate) mutation: LibraryMemberMutation, } +#[derive(Debug, Clone)] +pub(crate) struct PurgeRetiredLibraryMemberRequest { + pub(crate) environment: EnvironmentRef, + pub(crate) library_id: LibraryId, + pub(crate) skill_name: String, + pub(crate) retirement_id: RetirementId, +} + impl Default for LibraryCatalog { fn default() -> Self { Self { @@ -356,6 +502,11 @@ pub trait SkillLibraryRepository: Send + Sync { request: CommitLibraryMemberRequest, ) -> LibraryFuture<'a, Result<(), AppError>>; + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>>; + fn delete_library<'a>( &'a self, environment: &'a EnvironmentRef, @@ -373,8 +524,8 @@ pub trait SkillLibraryRepository: Send + Sync { pub trait LibraryUsageProvider: Send + Sync { /// 返回引用该库的全部 Skill 位置,包含已确认生效和仅被未完成操作引用两种状态。 /// - /// 成员锁定依赖这个并集语义:任何卷入未完成操作的库都必须锁住。展示层需要区分状态时 - /// 读取每一项的 `state`,不要改变本方法的收集范围。 + /// 整库删除保护依赖这个并集语义:任何卷入未完成操作的库都不能删除。展示层需要区分 + /// 状态时读取每一项的 `state`,不要改变本方法的收集范围。 fn usages<'a>( &'a self, environment: &'a EnvironmentRef, @@ -385,7 +536,7 @@ pub trait LibraryUsageProvider: Send + Sync { fn usage_projection<'a>( &'a self, environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>>; + ) -> LibraryFuture<'a, Result>; fn agent_usages<'a>( &'a self, @@ -412,8 +563,14 @@ impl LibraryUsageProvider for EmptyLibraryUsageProvider { fn usage_projection<'a>( &'a self, _environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>> { - Box::pin(async { Ok(Vec::new()) }) + ) -> LibraryFuture<'a, Result> { + Box::pin(async { + Ok(LibraryUsageSnapshot { + projections: Vec::new(), + inventory_complete: true, + problem_count: 0, + }) + }) } } @@ -423,6 +580,10 @@ pub struct SkillLibraryModule { } impl SkillLibraryModule { + pub(crate) fn repository(&self) -> &dyn SkillLibraryRepository { + self.repository.as_ref() + } + #[cfg(test)] pub fn new(repository: Arc) -> Self { Self { @@ -453,8 +614,8 @@ impl SkillLibraryModule { environment: EnvironmentRef, catalog: LibraryCatalog, ) -> Result { - let usage_projection = self.usages.usage_projection(&environment).await?; - workspace_snapshot(environment, catalog, usage_projection) + let usage = self.usages.usage_projection(&environment).await?; + workspace_snapshot(environment, catalog, usage) } pub async fn create( @@ -470,6 +631,7 @@ impl SkillLibraryModule { id: LibraryId(uuid::Uuid::new_v4().simple().to_string()), name, skills: Vec::new(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }); self.repository.save(&environment, &catalog).await?; @@ -515,19 +677,38 @@ impl SkillLibraryModule { Ok(detail_from_record(library, usages)) } + #[cfg(test)] pub async fn preview_add_skills( &self, payloads: &PayloadSessionManager, targets: &T, request: PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { Ok(self - .build_add_skills(payloads, targets, request, true) + .preview_add_skills_with_catalog(payloads, targets, request, membership) .await? - .preview) + .0) + } + + pub(crate) async fn preview_add_skills_with_catalog( + &self, + payloads: &PayloadSessionManager, + targets: &T, + request: PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, + ) -> Result<(LibraryAddPreview, LibraryCatalog), AppError> + where + T: TargetFactResolver + ContentManifestReader + ?Sized, + { + let built = self + .build_add_skills(payloads, targets, request, membership, true) + .await?; + let projected = projected_add_catalog(&built)?; + Ok((built.preview, projected)) } pub async fn execute_add_skills( @@ -535,18 +716,25 @@ impl SkillLibraryModule { payloads: &PayloadSessionManager, targets: &T, execution: ExecuteAddLibrarySkillsRequest, - ) -> Result + ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { let built = self - .build_add_skills(payloads, targets, execution.request, false) + .build_add_skills( + payloads, + targets, + execution.request, + execution.membership, + false, + ) .await?; let expected_generation = library_add_preview_generation( &built.request, &execution.expected_token.redirected_download_host, &execution.expected_token.context_revision, &execution.expected_token.skill_revisions, + &built.preview.membership, )?; if expected_generation != execution.expected_token.generation { return Err(AppError::StaleContext); @@ -652,13 +840,21 @@ impl SkillLibraryModule { error: None, }); } - let library = self + let snapshot = self .detail( built.request.environment.clone(), built.request.library_id.clone(), ) - .await?; - Ok(LibraryAddResponse { results, library }) + .await; + let (library, snapshot_error) = match snapshot { + Ok(library) => (Some(library), None), + Err(error) => (None, Some(error)), + }; + Ok(LibraryAddCommitResponse { + results, + library, + snapshot_error, + }) } async fn build_add_skills( @@ -666,20 +862,34 @@ impl SkillLibraryModule { payloads: &PayloadSessionManager, targets: &T, request: PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, reject_conflicts: bool, ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { + validate_membership_target(&membership, &request.environment, &request.library_id)?; if request.skills.is_empty() || request.discovery_session.environment != request.environment { return Err(AppError::StalePayload); } let usage_revision = self - .unapplied_usage_revision(&request.environment, &request.library_id) + .usage_revision(&request.environment, &request.library_id) .await?; let catalog = self.repository.load(&request.environment).await?; validate_catalog(&catalog)?; + let library = catalog + .libraries + .iter() + .find(|library| library.id == request.library_id) + .ok_or_else(|| AppError::PathNotFound { + path: request.library_id.as_str().to_string(), + })?; + let retired = library + .retired_skills + .iter() + .map(|retired| (retired.member.name.as_str(), &retired.member)) + .collect::>(); let collection = self .repository .resolve_collection(&request.environment, &request.library_id) @@ -747,8 +957,19 @@ impl SkillLibraryModule { .iter() .find(|record| record.skill_name == change.skill_name) .is_some_and(|record| record.projection.metadata().is_some()); + let reactivating = retired + .get(change.skill_name.as_str()) + .is_some_and(|member| { + change.canonical_target.target.entry_kind == TargetEntryKind::Directory + && change + .canonical_target + .content_revision + .manifest_hash() + .is_some_and(|hash| hash.as_str() == member.content_manifest_hash) + }); if existing_record - || change.canonical_target.target.entry_kind != TargetEntryKind::Missing + || (change.canonical_target.target.entry_kind != TargetEntryKind::Missing + && !reactivating) { return Err(AppError::Validation { field: Some("skillName".to_string()), @@ -792,6 +1013,7 @@ impl SkillLibraryModule { &redirected_download_host, &context_revision, &skill_revisions, + &membership, )?; let preview = LibraryAddPreview { token: LibraryAddPreviewToken { @@ -812,11 +1034,13 @@ impl SkillLibraryModule { }) .collect(), redirected_download_host, + membership, }; Ok(BuiltLibraryAdd { request, items, preview, + catalog, }) } @@ -828,7 +1052,7 @@ impl SkillLibraryModule { prepared: ReadyUpdatePayload, ) -> Result<(), AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { let current_collection = self .repository @@ -910,16 +1134,87 @@ impl SkillLibraryModule { .await } - pub async fn remove_skill( + pub(crate) async fn prepare_retire_skill_with_catalog( &self, targets: &T, request: RemoveLibrarySkillRequest, - ) -> Result + membership: LibraryMembershipPreview, + ) -> Result<(PreparedLibraryRetire, LibraryCatalog), AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { - self.ensure_not_applied(&request.environment, &request.library_id) + let observed = self + .observe_retire_skill(targets, &request, &membership) .await?; + let mut projected = observed.catalog; + crate::application::library_membership::apply_membership_change( + &mut projected, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Retire { + retirement_id: RetirementId("preview".to_string()), + retired_at: "preview".to_string(), + }, + )?; + Ok(( + PreparedLibraryRetire { + skill_name: request.skill_name, + base_token: observed.token, + }, + projected, + )) + } + + pub async fn retire_skill( + &self, + targets: &T, + execution: ExecuteRetireLibrarySkillRequest, + ) -> Result + where + T: TargetFactResolver + ContentManifestReader + ?Sized, + { + let observed = self + .observe_retire_skill(targets, &execution.request, &execution.membership) + .await?; + if bound_retire_preview_token(&observed.token, &execution.membership)? + != execution.expected_token + { + return Err(AppError::StaleContext); + } + let request = execution.request; + self.repository + .commit_member(CommitLibraryMemberRequest { + environment: request.environment.clone(), + library_id: request.library_id.clone(), + skill_name: request.skill_name.clone(), + expected: observed.expected, + mutation: LibraryMemberMutation::Retire { + retirement_id: RetirementId::new(), + retired_at: committed_at(), + }, + }) + .await?; + let snapshot = self.detail(request.environment, request.library_id).await; + let (library, snapshot_error) = match snapshot { + Ok(library) => (Some(library), None), + Err(error) => (None, Some(error)), + }; + Ok(LibraryRetireCommitResponse { + library, + snapshot_error, + }) + } + + async fn observe_retire_skill( + &self, + targets: &T, + request: &RemoveLibrarySkillRequest, + membership: &LibraryMembershipPreview, + ) -> Result + where + T: TargetFactResolver + ContentManifestReader + ?Sized, + { + validate_membership_target(membership, &request.environment, &request.library_id)?; let collection = self .repository .resolve_collection(&request.environment, &request.library_id) @@ -935,7 +1230,7 @@ impl SkillLibraryModule { crate::application::collection_records::RecordProjection::Missing ) { return Err(AppError::PathNotFound { - path: request.skill_name, + path: request.skill_name.clone(), }); } let target = SkillPathObserver::resolve_skill_targets( @@ -949,21 +1244,25 @@ impl SkillLibraryModule { .await? .pop() .ok_or(AppError::StaleTarget)?; - self.repository - .commit_member(CommitLibraryMemberRequest { - environment: request.environment.clone(), - library_id: request.library_id.clone(), - skill_name: request.skill_name.clone(), - expected: LibraryMemberCommitExpectation { - document_revision: snapshot.document_revision, - source_record_revision: snapshot.records[0].source_record_revision.clone(), - target_revision: target.target_revision, - content_revision: target.content_revision, - }, - mutation: LibraryMemberMutation::Delete, - }) - .await?; - self.detail(request.environment, request.library_id).await + let expected = LibraryMemberCommitExpectation { + document_revision: snapshot.document_revision, + source_record_revision: snapshot.records[0].source_record_revision.clone(), + target_revision: target.target_revision, + content_revision: target.content_revision, + }; + let token = stable_digest(&( + "library-retire-evidence", + request, + &expected.document_revision, + expected.source_record_revision.as_str(), + &expected.target_revision, + &expected.content_revision, + ))?; + Ok(ObservedLibraryRetire { + expected, + token, + catalog, + }) } pub async fn delete( @@ -971,39 +1270,44 @@ impl SkillLibraryModule { environment: EnvironmentRef, library_id: LibraryId, ) -> Result { - self.ensure_not_applied(&environment, &library_id).await?; + let usage = self.ensure_not_applied(&environment, &library_id).await?; let catalog = self .repository .delete_library(&environment, &library_id) .await?; - self.snapshot_with_usages(environment, catalog).await + workspace_snapshot(environment, catalog, usage) } async fn ensure_not_applied( &self, environment: &EnvironmentRef, library_id: &LibraryId, - ) -> Result<(), AppError> { - self.unapplied_usage_revision(environment, library_id) - .await - .map(|_| ()) + ) -> Result { + let usage = self.usages.usage_projection(environment).await?; + if !usage.inventory_complete { + return Err(AppError::ConfigurationCorrupted { + message: "Skill Library application inventory is incomplete".to_string(), + }); + } + if usage.projections.iter().any(|projection| { + &projection.library_id == library_id + && (projection.confirmed_count > 0 || projection.pending_count > 0) + }) { + return Err(AppError::Validation { + field: Some("libraryId".to_string()), + message: "Skill Library cannot be deleted while it is applied".to_string(), + }); + } + Ok(usage) } - async fn unapplied_usage_revision( + async fn usage_revision( &self, environment: &EnvironmentRef, library_id: &LibraryId, ) -> Result { let usages = self.usages.usages(environment, library_id).await?; - if usages.is_empty() { - stable_digest(&("library-add-usages-v1", usages)) - } else { - Err(AppError::Validation { - field: Some("libraryId".to_string()), - message: "Skill Library membership cannot change while the Library is applied" - .to_string(), - }) - } + stable_digest(&("library-membership-usages-v1", usages)) } async fn observe_add_skill( @@ -1015,11 +1319,9 @@ impl SkillLibraryModule { install_dir_name: &str, ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { - let usage_revision = self - .unapplied_usage_revision(environment, library_id) - .await?; + let usage_revision = self.usage_revision(environment, library_id).await?; let collection = self .repository .resolve_collection(environment, library_id) @@ -1131,6 +1433,14 @@ fn detail_from_record( .skills .into_iter() .map(|skill| { + let update_metadata = + crate::application::collection_records::library_update_metadata(&skill).ok(); + let comparison_fingerprint = update_metadata + .as_ref() + .map(|metadata| metadata.comparison_fingerprint()); + let update_capability = update_metadata + .as_ref() + .map(crate::application::update::derive_update_capability_from_metadata); let source = serde_json::from_value::(skill.source_record).ok(); LibrarySkillSummary { @@ -1155,6 +1465,8 @@ fn detail_from_record( plugin_name: source.as_ref().and_then(|value| value.plugin_name.clone()), ref_name: source.as_ref().and_then(|value| value.ref_name.clone()), updated_at: skill.updated_at, + comparison_fingerprint, + update_capability, } }) .collect(), @@ -1166,6 +1478,26 @@ struct BuiltLibraryAdd { request: PreviewAddLibrarySkillsRequest, items: Vec, preview: LibraryAddPreview, + catalog: LibraryCatalog, +} + +fn projected_add_catalog(built: &BuiltLibraryAdd) -> Result { + let mut catalog = built.catalog.clone(); + for change in built.items.iter().filter_map(|item| match item { + BuiltLibraryAddItem::Prepared(change) => Some(change.as_ref()), + BuiltLibraryAddItem::Failed(_) => None, + }) { + let frontmatter = payload_frontmatter(change.payload.payload())?; + let mut record = library_record(&change.payload, frontmatter.description)?; + record.updated_at = None; + crate::application::library_membership::apply_membership_change( + &mut catalog, + &built.request.library_id, + &change.skill_name, + crate::application::library_membership::MembershipChange::Upsert(record), + )?; + } + Ok(catalog) } enum BuiltLibraryAddItem { @@ -1215,7 +1547,7 @@ async fn prepare_library_add_targets( cancellation: Option, ) -> Result, AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { if payloads.is_empty() { return Err(AppError::Validation { @@ -1277,11 +1609,18 @@ struct ObservedLibraryAddSkill { expected: LibraryMemberCommitExpectation, } +struct ObservedLibraryRetire { + expected: LibraryMemberCommitExpectation, + token: String, + catalog: LibraryCatalog, +} + fn library_add_preview_generation( request: &PreviewAddLibrarySkillsRequest, redirected_download_host: &Option, context_revision: &str, skill_revisions: &[LibraryAddSkillRevision], + membership: &LibraryMembershipPreview, ) -> Result { stable_digest(&( "library-add-preview-v2", @@ -1289,6 +1628,7 @@ fn library_add_preview_generation( redirected_download_host, context_revision, skill_revisions, + membership, )) } @@ -1350,6 +1690,20 @@ fn committed_at() -> String { .to_string() } +fn validate_membership_target( + membership: &LibraryMembershipPreview, + environment: &EnvironmentRef, + library_id: &LibraryId, +) -> Result<(), AppError> { + if &membership.library_id == library_id + && same_environment_identity(&membership.environment, environment) + { + Ok(()) + } else { + Err(AppError::StaleContext) + } +} + fn library_source_record( source: &crate::application::skill_changes::NormalizedSkillSource, ) -> LibrarySkillSourceRecord { @@ -1395,24 +1749,10 @@ pub(crate) fn merge_unknown_source_fields( } } -#[cfg(test)] -fn find_library_mut<'a>( - catalog: &'a mut LibraryCatalog, - id: &LibraryId, -) -> Result<&'a mut SkillLibraryRecord, AppError> { - catalog - .libraries - .iter_mut() - .find(|library| &library.id == id) - .ok_or_else(|| AppError::PathNotFound { - path: id.as_str().to_string(), - }) -} - fn workspace_snapshot( environment: EnvironmentRef, catalog: LibraryCatalog, - usage_projection: Vec, + usage: LibraryUsageSnapshot, ) -> Result { validate_catalog(&catalog)?; let revision = crate::application::mutation::plan::stable_digest(&catalog)?; @@ -1429,7 +1769,9 @@ fn workspace_snapshot( environment, libraries, revision, - usage_projection, + usage_projection: usage.projections, + usage_inventory_complete: usage.inventory_complete, + usage_inventory_problem_count: usage.problem_count, }) } @@ -1481,9 +1823,46 @@ pub(crate) fn validate_catalog(catalog: &LibraryCatalog) -> Result<(), AppError> ), }); } + let invalid = |message: &str| AppError::ConfigurationCorrupted { + message: message.to_string(), + }; + let mut library_ids = std::collections::BTreeSet::new(); + let mut retirement_ids = std::collections::BTreeSet::new(); + for library in &catalog.libraries { + if !library_ids.insert(library.id.clone()) + || library.name.trim().is_empty() + || !valid_library_storage_component(library.id.as_str()) + { + return Err(invalid("invalid Skill Library identity")); + } + let mut member_directories = std::collections::BTreeSet::new(); + for member in &library.skills { + let directory = InstalledSkillResolver::install_dir_name(&member.name) + .map_err(|_| invalid("invalid active Skill Library member name"))?; + if !member_directories.insert(directory) || member.content_manifest_hash.is_empty() { + return Err(invalid("duplicate active Skill Library member directory")); + } + } + for retired in &library.retired_skills { + let directory = InstalledSkillResolver::install_dir_name(&retired.member.name) + .map_err(|_| invalid("invalid retired Skill Library member name"))?; + if retired.retirement_id.as_str().is_empty() + || retired.retired_at.is_empty() + || !retirement_ids.insert(retired.retirement_id.as_str()) + || !member_directories.insert(directory) + || retired.member.content_manifest_hash.is_empty() + { + return Err(invalid("invalid retired Skill Library member")); + } + } + } Ok(()) } +fn valid_library_storage_component(value: &str) -> bool { + !value.is_empty() && !matches!(value, "." | "..") && !value.contains(['/', '\\', '\0']) +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -1508,8 +1887,31 @@ mod tests { fn usage_projection<'a>( &'a self, _environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>> { - Box::pin(async { Ok(Vec::new()) }) + ) -> LibraryFuture<'a, Result> { + Box::pin(async move { + let confirmed_count = self + .0 + .iter() + .filter(|usage| usage.state == LibraryUsageState::Confirmed) + .count() as u32; + let pending_count = self + .0 + .iter() + .filter(|usage| usage.state == LibraryUsageState::PendingAdjustment) + .count() as u32; + Ok(LibraryUsageSnapshot { + projections: (!self.0.is_empty()) + .then(|| LibraryUsageProjection { + library_id: LibraryId::parse("library-1"), + confirmed_count, + pending_count, + }) + .into_iter() + .collect(), + inventory_complete: true, + problem_count: 0, + }) + }) } } @@ -1603,27 +2005,16 @@ mod tests { { return Err(error); } - let library = find_library_mut(catalog, &request.library_id)?; match request.mutation { LibraryMemberMutation::Upsert { content, record } => { - let mut record = *record; - if let Some(current) = library - .skills - .iter_mut() - .find(|skill| skill.name == request.skill_name) - { - record.extra = current.extra.clone(); - merge_unknown_source_fields( - &mut record.source_record, - ¤t.source_record, - ); - *current = record; - } else { - library.skills.push(record); - library - .skills - .sort_by(|left, right| left.name.cmp(&right.name)); - } + crate::application::library_membership::apply_membership_change( + catalog, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Upsert( + *record, + ), + )?; self.payloads.lock().expect("payloads").insert( ( environment_key, @@ -1633,25 +2024,50 @@ mod tests { *content, ); } - LibraryMemberMutation::Delete => { - let before = library.skills.len(); - library - .skills - .retain(|skill| skill.name != request.skill_name); - if before == library.skills.len() { - return Err(AppError::StaleTarget); - } - self.payloads.lock().expect("payloads").remove(&( - environment_key, - request.library_id.as_str().to_string(), - request.skill_name, - )); + LibraryMemberMutation::Retire { + retirement_id, + retired_at, + } => { + crate::application::library_membership::apply_membership_change( + catalog, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Retire { + retirement_id, + retired_at, + }, + )?; } } Ok(()) }) } + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + let environment_key = EnvironmentKey::from_ref(&request.environment); + let mut catalogs = self.catalogs.lock().expect("catalogs"); + let catalog = catalogs.entry(environment_key.clone()).or_default(); + crate::application::library_membership::apply_membership_change( + catalog, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + self.payloads.lock().expect("payloads").remove(&( + environment_key, + request.library_id.as_str().to_string(), + request.skill_name, + )); + Ok(()) + }) + } + fn delete_library<'a>( &'a self, environment: &'a EnvironmentRef, @@ -1766,6 +2182,21 @@ mod tests { payload_manager_with_storage().0 } + fn membership_preview( + environment: &EnvironmentRef, + library_id: &LibraryId, + ) -> LibraryMembershipPreview { + LibraryMembershipPreview { + environment: environment.clone(), + library_id: library_id.clone(), + scopes: Vec::new(), + impacts: Vec::new(), + inventory_complete: true, + inventory_token: "membership-preview".to_string(), + token: "membership-preview".to_string(), + } + } + fn payload_manager_with_storage() -> ( PayloadSessionManager, Arc, @@ -1784,6 +2215,38 @@ mod tests { (manager, storage) } + #[test] + fn membership_preview_must_match_the_member_request_target() { + let environment = EnvironmentRef::Native; + let library_id = LibraryId::parse("library-1"); + let preview = membership_preview(&environment, &LibraryId::parse("library-2")); + + assert!(matches!( + validate_membership_target(&preview, &environment, &library_id), + Err(AppError::StaleContext) + )); + } + + #[test] + fn retire_preview_token_binds_the_final_membership_impact() { + let environment = EnvironmentRef::Native; + let library_id = LibraryId::parse("library-1"); + let prepared = PreparedLibraryRetire { + skill_name: "demo".to_string(), + base_token: "member-evidence".to_string(), + }; + let mut final_membership = membership_preview(&environment, &library_id); + final_membership.token = "impact-evidence".to_string(); + + let preview = prepared.bind(final_membership).unwrap(); + + assert_ne!(preview.token, "member-evidence"); + assert_eq!( + preview.token, + bound_retire_preview_token("member-evidence", &preview.membership).unwrap() + ); + } + #[tokio::test] async fn creates_lists_and_renames_a_library_without_changing_its_id() { let module = SkillLibraryModule::new(Arc::new(MemoryRepository::default())); @@ -1842,7 +2305,8 @@ mod tests { "sourceExtension": ["keep"] }, "contentManifestHash": "manifest-old" - }] + }], + "retiredSkills": [] }] }); @@ -1861,6 +2325,33 @@ mod tests { ); } + #[test] + fn catalog_rejects_active_and_retired_directory_collision() { + let active = test_library_record("CE:Review", "active"); + let retired = test_library_record("ce-review", "retired"); + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: LibraryId::parse("library-1"), + name: "Library".to_string(), + skills: vec![active], + retired_skills: vec![RetiredLibrarySkillRecord { + retirement_id: RetirementId::parse("retirement-1"), + member: retired, + retired_at: "2026-09-06T00:00:00Z".to_string(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + assert!(matches!( + validate_catalog(&catalog), + Err(AppError::ConfigurationCorrupted { .. }) + )); + } + #[test] fn library_update_request_contains_intent_instead_of_payload_handles() { let request = UpdateLibrarySkillsRequest { @@ -1938,6 +2429,7 @@ mod tests { test_library_record("alpha", "alpha-v1"), test_library_record("beta", "beta-v1"), ], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -2118,7 +2610,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); std::fs::create_dir_all(skills_root.join("stale")).unwrap(); @@ -2129,7 +2626,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2139,7 +2637,7 @@ mod tests { assert_eq!(response.results[0].status, LibraryAddSkillStatus::Failed); assert_eq!(response.results[0].error, Some(AppError::StaleTarget)); assert_eq!(response.results[1].status, LibraryAddSkillStatus::Succeeded); - assert_eq!(response.library.skills[0].name, "ready"); + assert_eq!(response.library.as_ref().unwrap().skills[0].name, "ready"); } #[tokio::test] @@ -2182,7 +2680,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); @@ -2192,7 +2695,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2213,6 +2717,8 @@ mod tests { assert_eq!( response .library + .as_ref() + .unwrap() .skills .iter() .map(|skill| skill.name.as_str()) @@ -2263,7 +2769,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); let stale = &request.skills[0].payload; @@ -2281,7 +2792,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2291,7 +2803,7 @@ mod tests { assert_eq!(response.results[0].status, LibraryAddSkillStatus::Failed); assert_eq!(response.results[0].error, Some(AppError::StalePayload)); assert_eq!(response.results[1].status, LibraryAddSkillStatus::Succeeded); - assert_eq!(response.library.skills[0].name, "ready"); + assert_eq!(response.library.as_ref().unwrap().skills[0].name, "ready"); } #[tokio::test] @@ -2339,7 +2851,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); @@ -2349,7 +2866,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2358,7 +2876,7 @@ mod tests { assert_eq!(response.results[0].status, LibraryAddSkillStatus::Cancelled); assert_eq!(response.results[1].status, LibraryAddSkillStatus::NotRun); - assert!(response.library.skills.is_empty()); + assert!(response.library.as_ref().unwrap().skills.is_empty()); } #[tokio::test] @@ -2437,7 +2955,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .expect("preview add skill"); assert!(repository.payloads.lock().unwrap().is_empty()); @@ -2447,13 +2970,14 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) .await .expect("execute add skill"); - let detail = response.library; + let detail = response.library.unwrap(); assert_eq!(response.results[0].status, LibraryAddSkillStatus::Succeeded); assert_eq!(detail.skills.len(), 1); diff --git a/src-tauri/src/application/skill_paths.rs b/src-tauri/src/application/skill_paths.rs index 3cc7512d..2a4dab57 100644 --- a/src-tauri/src/application/skill_paths.rs +++ b/src-tauri/src/application/skill_paths.rs @@ -133,7 +133,7 @@ impl SkillPathObserver { cancellation: Option, ) -> Result, AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { if requests.is_empty() { return Err(AppError::Validation { @@ -211,7 +211,7 @@ impl SkillPathObserver { cancellation: Option, ) -> Result, AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { if requested_names.is_empty() { return Err(AppError::Validation { diff --git a/src-tauri/src/application/skill_read.rs b/src-tauri/src/application/skill_read.rs index 6829dd0d..513c860c 100644 --- a/src-tauri/src/application/skill_read.rs +++ b/src-tauri/src/application/skill_read.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -10,17 +11,19 @@ use crate::core::agent_availability::{ }; use crate::core::agent_definition::{AgentAdapter, AgentId}; use crate::core::skill::{InstalledSkill, InstalledSkillLocation, SkillFrontmatter}; +use crate::environment::agent_environment::inspect_eve_project; use crate::environment::agent_environment::{AgentRuntimeSnapshot, DetectionState, ResolvedAgent}; use crate::environment::context_resolver::ResolvedContext; use crate::environment::inspection::{ FilesystemEntryKind, RawFilesystemSnapshot, ReadPlan, ReadPlanBuilder, ReadRootPurpose, + SkillMetadataSource, }; -use crate::environment::runtime::ContextSnapshotRevision; +use crate::environment::planning::{locator_comparison_key, ResolvedTargetFact, TargetEntryKind}; +use crate::environment::runtime::{ContextSnapshotRevision, PhysicalTargetKey}; use crate::environment::types::{ - same_environment_identity, EnvironmentRef, ResourceLocator, SkillLocation, + same_environment_identity, EnvironmentKey, EnvironmentRef, ResourceLocator, SkillLocation, }; -use crate::environment::wsl::operations::eve::inspect_eve_project; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; use crate::models::{AgentSkillPresence, SkillInstallTargetInfo}; @@ -36,6 +39,21 @@ pub struct SkillReadPlan { pub read_plan: ReadPlan, context_root: String, owners: BTreeMap>, + project_lock: Option, + eve_project: bool, +} + +impl SkillReadPlan { + pub(crate) fn set_project_lock(&mut self, bytes: Option<&[u8]>) { + self.project_lock = if matches!(self.read_plan.context.scope, SkillLocation::Project { .. }) + { + bytes.and_then(|bytes| { + crate::core::lossless_lock::LosslessLockDocument::parse(bytes).ok() + }) + } else { + None + }; + } } /// `list_skills` 的运行时读取结果。 @@ -44,13 +62,151 @@ pub struct SkillReadPlan { #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub struct ListSkillsResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub path_base: Option, pub skills: Vec, pub agents: Vec, /// 项目目录是否存在(project scope 时有意义,global 始终为 true) pub path_exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_status: Option, pub library_application: crate::application::library_application::LibraryApplicationSummary, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct SkillReadIssue { + pub code: String, + pub path: ResourceLocator, + pub agent_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct SkillReadIssueCount { + pub code: String, + pub count: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct SkillReadStatus { + pub complete: bool, + pub issues: Vec, + pub counts: Vec, + pub omitted_count: u32, +} + +const MAX_PUBLIC_READ_ISSUES: usize = 256; + +#[derive(Default)] +struct SkillReadIssueCollector { + issues: Vec, + counts: BTreeMap, + omitted_count: u32, + seen: BTreeSet<(String, EnvironmentKey, String)>, +} + +impl SkillReadIssueCollector { + fn record( + &mut self, + code: impl Into, + path: ResourceLocator, + agent_ids: impl IntoIterator, + ) { + let code = code.into(); + if !self.seen.insert(( + code.clone(), + EnvironmentKey::from_ref(&path.environment), + path.native_path.clone(), + )) { + return; + } + *self.counts.entry(code.clone()).or_default() += 1; + if self.issues.len() < MAX_PUBLIC_READ_ISSUES { + self.issues.push(SkillReadIssue { + code, + path, + agent_ids: agent_ids.into_iter().collect(), + }); + } else { + self.omitted_count += 1; + } + } + + fn finish(self) -> SkillReadStatus { + SkillReadStatus { + complete: self.counts.is_empty(), + issues: self.issues, + counts: self + .counts + .into_iter() + .map(|(code, count)| SkillReadIssueCount { code, count }) + .collect(), + omitted_count: self.omitted_count, + } + } +} + +type SkillFactId = (u32, String); +type SkillDocuments = BTreeMap>; +type SkillDocumentGroup = Vec<(SkillFactId, ResourceLocator)>; + +fn skill_fact_id(fact: &crate::environment::inspection::RawPathFact) -> SkillFactId { + (fact.root_index, fact.relative_path.clone()) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum DocumentIdentity { + Physical(PhysicalTargetKey), + LinkTarget(EnvironmentKey, String), +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct SkillMetadataCacheKey { + registry_revision: String, + environment_revision: String, + identity: DocumentIdentity, + fingerprint: String, +} + +#[derive(Clone)] +enum CachedSkillMetadata { + Parsed(Arc), + InvalidFrontmatter, +} + +#[derive(Default)] +struct SkillMetadataCache { + entries: Mutex>, +} + +impl SkillMetadataCache { + const MAX_ENTRIES: usize = 16_384; + + fn get(&self, key: &SkillMetadataCacheKey) -> Option { + self.entries.lock().ok()?.get(key).cloned() + } + + fn insert(&self, key: SkillMetadataCacheKey, value: CachedSkillMetadata) { + let Ok(mut entries) = self.entries.lock() else { + return; + }; + if entries.len() >= Self::MAX_ENTRIES && !entries.contains_key(&key) { + entries.clear(); + } + entries.insert(key, value); + } +} + +fn skill_metadata_cache() -> &'static SkillMetadataCache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(SkillMetadataCache::default) +} + #[derive(Debug)] struct SkillCandidate { description: String, @@ -65,19 +221,19 @@ struct SkillCandidate { pub async fn discover_eve_skill_targets( context: &ResolvedContext, runtime: &AgentRuntimeSnapshot, - wsl_session: Option<&WslSession>, + wsl_workspace: Option<&WslWorkspace>, ) -> Result, AppError> { let Some(project) = context.project.as_ref() else { return Ok(Vec::new()); }; - let Some((eve_id, eve)) = runtime + if !runtime .agents - .iter() - .find(|(_, agent)| agent.definition.adapter == AgentAdapter::Eve) - else { + .values() + .any(|agent| agent.definition.adapter == AgentAdapter::Eve) + { return Ok(Vec::new()); - }; - match (&context.context.environment, wsl_session) { + } + match (&context.context.environment, wsl_workspace) { (EnvironmentRef::Native, None) => Ok(crate::core::eve::eve_install_targets_for_project( &project.native_path, ) @@ -90,30 +246,19 @@ pub async fn discover_eve_skill_targets( path: target.path, }) .collect()), - (EnvironmentRef::Wsl { .. }, Some(session)) => { - let inspected = inspect_eve_project(session, &project.native_path).await?; - if !inspected.has_eve { - return Ok(Vec::new()); - } - let project_root = project.native_path.trim_end_matches('/'); - let mut targets = vec![SkillInstallTargetInfo { - target_id: format!("{}:root", eve_id), - agent: eve_id.clone(), - display_name: eve.definition.display_name.clone(), - subagent: None, - path: format!("{project_root}/agent/skills"), - }]; - targets.extend(inspected.subagents.into_iter().map(|subagent| { - let path_name = crate::core::skill::sanitize_name(&subagent); - SkillInstallTargetInfo { - target_id: format!("{}:{path_name}", eve_id), - agent: eve_id.clone(), - display_name: format!("{} ({subagent})", eve.definition.display_name), - subagent: Some(subagent), - path: format!("{project_root}/agent/subagents/{path_name}/skills"), - } - })); - Ok(targets) + (EnvironmentRef::Wsl { .. }, Some(workspace)) => { + let inspected = inspect_eve_project(workspace, &project.native_path).await?; + Ok(inspected + .install_targets(&project.native_path) + .into_iter() + .map(|target| SkillInstallTargetInfo { + target_id: target.target_id, + agent: target.agent, + display_name: target.display_name, + subagent: target.subagent, + path: target.path, + }) + .collect()) } _ => Err(AppError::EnvironmentUnavailable { environment: context.context.environment.clone(), @@ -209,19 +354,730 @@ pub fn build_skill_read_plan( read_plan: builder.build()?, context_root, owners, + project_lock: None, + eve_project: context.project.is_some() && !eve_targets.is_empty(), }) } +async fn read_skill_documents( + plan: &SkillReadPlan, + snapshot: &mut RawFilesystemSnapshot, + targets: &dyn crate::environment::planning::TargetFactResolver, + metadata: &dyn SkillMetadataSource, +) -> Result< + ( + SkillDocuments, + BTreeMap, + Vec, + Vec, + ), + AppError, +> { + struct Candidate { + fact_id: SkillFactId, + target: ResourceLocator, + document: ResourceLocator, + } + + let mut candidates = Vec::new(); + for fact in &snapshot.facts { + let root = plan + .read_plan + .roots + .get(fact.root_index as usize) + .ok_or(AppError::StaleTarget)?; + if let Some(relative) = fact.relative_path.strip_suffix("/SKILL.md") { + if relative.starts_with(".skill-deck-stage-") + || relative.starts_with(".skill-deck-backup-") + { + continue; + } + candidates.push(Candidate { + fact_id: skill_fact_id(fact), + target: root.locator.join_child(relative), + document: root.locator.join_child(&fact.relative_path), + }); + } else if fact.kind == FilesystemEntryKind::File + && !fact.relative_path.contains('/') + && fact.relative_path.ends_with(".md") + { + candidates.push(Candidate { + fact_id: skill_fact_id(fact), + target: root.locator.join_child(&fact.relative_path), + document: root.locator.join_child(&fact.relative_path), + }); + } + } + if candidates.is_empty() { + return Ok((BTreeMap::new(), BTreeMap::new(), Vec::new(), Vec::new())); + } + + let target_locators = candidates + .iter() + .map(|candidate| candidate.target.clone()) + .collect::>(); + let resolved = targets + .resolve(&plan.read_plan.context, &target_locators, None) + .await?; + if resolved.len() != candidates.len() { + return Err(AppError::StaleTarget); + } + let direct_by_path = resolved + .iter() + .filter(|fact| fact.entry_kind == TargetEntryKind::Directory) + .filter_map(|fact| { + locator_comparison_key(&fact.destination).map(|key| (key, fact.key.clone())) + }) + .collect::>(); + let mut groups = BTreeMap::>::new(); + let mut resolved_by_fact = BTreeMap::new(); + for (candidate, fact) in candidates.into_iter().zip(resolved) { + let identity = fact + .link_target_identity + .as_ref() + .map(|identity| identity.comparison_key()) + .map(|key| { + direct_by_path + .get(&key) + .cloned() + .map(DocumentIdentity::Physical) + .unwrap_or_else(|| DocumentIdentity::LinkTarget(key.0, key.1)) + }) + .unwrap_or_else(|| DocumentIdentity::Physical(fact.key.clone())); + groups + .entry(identity) + .or_default() + .push((candidate.fact_id.clone(), candidate.document)); + resolved_by_fact.insert(candidate.fact_id, fact); + } + + let fact_positions = snapshot + .facts + .iter() + .enumerate() + .map(|(index, fact)| (skill_fact_id(fact), index)) + .collect::>(); + struct Group { + members: Vec<(SkillFactId, ResourceLocator)>, + cache_key: Option, + } + #[derive(Clone)] + struct GroupResult { + frontmatter: Option>, + truncated: bool, + error_code: Option, + bytes_read: u32, + } + let groups = groups + .into_iter() + .map(|(identity, members)| { + let fingerprint = members + .first() + .and_then(|(fact_id, _)| fact_positions.get(fact_id)) + .and_then(|index| snapshot.facts[*index].fingerprint.as_ref()); + let cache_key = fingerprint.map(|fingerprint| SkillMetadataCacheKey { + registry_revision: plan.read_plan.registry_revision.clone(), + environment_revision: plan.read_plan.environment_revision.clone(), + identity, + fingerprint: fingerprint.0.clone(), + }); + Group { members, cache_key } + }) + .collect::>(); + let cache = skill_metadata_cache(); + let mut results = vec![None; groups.len()]; + let mut misses = Vec::new(); + for (index, group) in groups.iter().enumerate() { + let cached = group.cache_key.as_ref().and_then(|key| cache.get(key)); + results[index] = cached.map(|cached| match cached { + CachedSkillMetadata::Parsed(frontmatter) => GroupResult { + frontmatter: Some(frontmatter), + truncated: false, + error_code: None, + bytes_read: 0, + }, + CachedSkillMetadata::InvalidFrontmatter => GroupResult { + frontmatter: None, + truncated: false, + error_code: Some("invalidFrontmatter".to_string()), + bytes_read: 0, + }, + }); + if results[index].is_none() { + misses.push((index, group.members[0].1.clone())); + } + } + if !misses.is_empty() { + let representatives = misses + .iter() + .map(|(_, locator)| locator.clone()) + .collect::>(); + let loaded = metadata + .read(&representatives, plan.read_plan.per_file_limit) + .await?; + if loaded.len() != representatives.len() + || loaded + .iter() + .zip(&representatives) + .any(|(fact, expected)| fact.locator != *expected) + { + return Err(AppError::ConfigurationCorrupted { + message: "Skill metadata response does not match its request".to_string(), + }); + } + for ((group_index, _), metadata) in misses.into_iter().zip(loaded) { + let parsed = (!metadata.truncated && metadata.error_code.is_none()) + .then(|| parse_skill_frontmatter_allow_missing_name(&metadata.bytes)) + .flatten() + .map(Arc::new); + let error_code = metadata.error_code.clone().or_else(|| { + (!metadata.truncated && parsed.is_none()).then(|| "invalidFrontmatter".to_string()) + }); + if let Some(cache_key) = groups[group_index].cache_key.clone() { + if let Some(frontmatter) = &parsed { + cache.insert(cache_key, CachedSkillMetadata::Parsed(frontmatter.clone())); + } else if !metadata.truncated && metadata.error_code.is_none() { + cache.insert(cache_key, CachedSkillMetadata::InvalidFrontmatter); + } + } + results[group_index] = Some(GroupResult { + frontmatter: parsed, + truncated: metadata.truncated, + error_code, + bytes_read: metadata.bytes.len() as u32, + }); + } + } + + let mut documents = BTreeMap::new(); + let mut metadata_issues = Vec::new(); + let mut missing_name_groups = Vec::new(); + snapshot.total_content_bytes = 0; + for (group, result) in groups.into_iter().zip(results) { + let result = result.ok_or_else(|| AppError::ConfigurationCorrupted { + message: "Skill metadata group has no result".to_string(), + })?; + snapshot.total_content_bytes = snapshot + .total_content_bytes + .saturating_add(result.bytes_read); + let issue_code = result + .error_code + .clone() + .or_else(|| result.truncated.then(|| "frontmatterTooLarge".to_string())); + if let Some(code) = issue_code { + let agent_ids = group + .members + .iter() + .filter_map(|(fact_id, _)| fact_positions.get(fact_id)) + .filter_map(|index| { + plan.read_plan + .roots + .get(snapshot.facts[*index].root_index as usize) + }) + .flat_map(|root| root.consumer_agent_ids.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + metadata_issues.push(SkillReadIssue { + code, + path: group.members[0].1.clone(), + agent_ids, + }); + } + if result + .frontmatter + .as_ref() + .is_some_and(|frontmatter| frontmatter.name.is_empty()) + { + missing_name_groups.push(group.members.clone()); + } + for (fact_id, _) in group.members { + if let Some(frontmatter) = &result.frontmatter { + documents.insert(fact_id, frontmatter.clone()); + } + } + } + Ok(( + documents, + resolved_by_fact, + metadata_issues, + missing_name_groups, + )) +} + +fn reject_unidentified_skill_documents( + plan: &SkillReadPlan, + snapshot: &RawFilesystemSnapshot, + documents: &mut SkillDocuments, + groups: Vec, + issues: &mut Vec, +) -> Result<(), AppError> { + let positions = snapshot + .facts + .iter() + .enumerate() + .map(|(index, fact)| (skill_fact_id(fact), index)) + .collect::>(); + for group in groups { + let invalid = group + .into_iter() + .filter(|(fact_id, _)| { + documents + .get(fact_id) + .is_some_and(|frontmatter| frontmatter.name.is_empty()) + }) + .collect::>(); + if invalid.is_empty() { + continue; + } + let agent_ids = invalid + .iter() + .filter_map(|(fact_id, _)| positions.get(fact_id)) + .filter_map(|index| { + plan.read_plan + .roots + .get(snapshot.facts[*index].root_index as usize) + }) + .flat_map(|root| root.consumer_agent_ids.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + for (fact_id, _) in &invalid { + documents.remove(fact_id); + } + issues.push(SkillReadIssue { + code: "invalidFrontmatter".to_string(), + path: invalid[0].1.clone(), + agent_ids, + }); + } + Ok(()) +} + +pub(crate) async fn project_direct_skill_snapshot( + plan: &SkillReadPlan, + mut snapshot: RawFilesystemSnapshot, + runtime: &AgentRuntimeSnapshot, + libraries: &dyn crate::application::skill_libraries::SkillLibraryRepository, + targets: &dyn crate::environment::planning::TargetFactResolver, + metadata: &dyn SkillMetadataSource, +) -> Result { + use crate::application::installed_skill_resolver::SkillDirectoryName; + use crate::application::library_candidates::ResolvedLibraryCandidateIndex; + use crate::core::skill::InstalledLibraryVersion; + + let (mut documents, resolved_by_fact, mut metadata_issues, missing_name_groups) = + read_skill_documents(plan, &mut snapshot, targets, metadata).await?; + let single_files = + project_recorded_eve_entries(plan, &mut snapshot, &mut documents, runtime, targets).await?; + reject_unidentified_skill_documents( + plan, + &snapshot, + &mut documents, + missing_name_groups, + &mut metadata_issues, + )?; + let mut entries = Vec::new(); + for fact in &snapshot.facts { + let Some(relative) = fact.relative_path.strip_suffix("/SKILL.md") else { + continue; + }; + let fact_id = skill_fact_id(fact); + let Some(frontmatter) = documents.get(&fact_id) else { + continue; + }; + let root = plan + .read_plan + .roots + .get(fact.root_index as usize) + .ok_or(AppError::StaleTarget)?; + entries.push(( + (fact.root_index, fact.relative_path.clone()), + SkillDirectoryName::try_from(frontmatter.name.as_str())?, + ResourceLocator { + environment: snapshot.environment.clone(), + native_path: join_native_path( + &snapshot.environment, + &root.locator.native_path, + relative, + ), + }, + resolved_by_fact + .get(&fact_id) + .cloned() + .ok_or(AppError::StaleTarget)?, + )); + } + let names = entries + .iter() + .map(|(_, name, _, _)| name.clone()) + .collect::>(); + let catalog = libraries.load(&snapshot.environment).await?; + let known = ResolvedLibraryCandidateIndex::from_catalog( + libraries, + targets, + &snapshot.environment, + &names, + &catalog, + ) + .await?; + let mut references = BTreeMap::>::new(); + let mut record_reference = + |name: &SkillDirectoryName, + owner: &crate::application::library_candidates::LibraryVersionCandidate| + -> Result<(), AppError> { + let library = catalog + .libraries + .iter() + .find(|library| &library.id == owner.library_id()) + .ok_or(AppError::StaleTarget)?; + let reference = InstalledLibraryVersion { + library_id: library.id.as_str().to_string(), + library_name: library.name.clone(), + skill_name: owner.member_name().to_string(), + }; + let versions = references.entry(name.clone()).or_default(); + if !versions.contains(&reference) { + versions.push(reference); + } + Ok(()) + }; + for fact in snapshot.facts.iter().filter(|fact| { + fact.relative_path.is_empty() + && matches!( + fact.kind, + FilesystemEntryKind::Symlink | FilesystemEntryKind::ReparsePoint + ) + }) { + let Some(root) = plan.read_plan.roots.get(fact.root_index as usize) else { + return Err(AppError::StaleTarget); + }; + if !plan.owners.contains_key(&root.locator.native_path) { + continue; + } + let Some(identity) = fact.resolved_target.as_deref().and_then(|target| { + crate::environment::planning::resolve_link_target_identity(&root.locator, target) + }) else { + continue; + }; + for owner in known.members_for_root_link(&identity, &names) { + let name = SkillDirectoryName::try_from(owner.member_name())?; + record_reference(&name, owner)?; + } + } + let mut excluded = BTreeSet::new(); + for (id, name, _, fact) in &entries { + if let Some(owner) = known.owner(name, fact) { + excluded.insert(id.clone()); + record_reference(name, owner)?; + } else if matches!( + fact.entry_kind, + crate::environment::planning::TargetEntryKind::Symlink + | crate::environment::planning::TargetEntryKind::Junction + ) { + let direct = entries + .iter() + .filter(|(_, candidate_name, _, target)| { + candidate_name == name + && target.entry_kind + == crate::environment::planning::TargetEntryKind::Directory + && known.owner(name, target).is_none() + }) + .map(|(_, _, _, target)| target) + .collect::>(); + if !direct.is_empty() + && !direct.iter().any(|target| { + fact.link_target_identity + .as_ref() + .is_some_and(|identity| identity.matches(&target.destination)) + }) + { + excluded.insert(id.clone()); + } + } + } + snapshot + .facts + .retain(|fact| !excluded.contains(&(fact.root_index, fact.relative_path.clone()))); + let mut result = project_skill_snapshot_with_documents( + plan, + snapshot, + runtime, + &documents, + metadata_issues, + )?; + for skill in &mut result.skills { + skill.library_versions = + references.remove(&SkillDirectoryName::try_from(skill.name.as_str())?); + } + for single in single_files { + if let Some(skill) = result + .skills + .iter_mut() + .find(|skill| skill.name == single.name) + { + skill.maintenance_error = single.maintenance_error; + } else { + result.skills.push(single); + } + } + result.skills.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(result) +} + +async fn project_recorded_eve_entries( + plan: &SkillReadPlan, + snapshot: &mut RawFilesystemSnapshot, + documents: &mut SkillDocuments, + runtime: &AgentRuntimeSnapshot, + targets: &dyn crate::environment::planning::TargetFactResolver, +) -> Result, AppError> { + use crate::application::installed_skill_resolver::InstalledSkillResolver; + use crate::application::scope_skill_placements::{ambiguous_installed_source, eve_target_ids}; + use crate::core::local_lock::LocalSkillLockEntry; + + if !plan.eve_project { + return Ok(Vec::new()); + } + let mut actual = Vec::new(); + for (index, fact) in snapshot.facts.iter().enumerate() { + let root = plan + .read_plan + .roots + .get(fact.root_index as usize) + .ok_or(AppError::StaleTarget)?; + if !plan + .owners + .get(&root.locator.native_path) + .is_some_and(|owners| { + owners + .iter() + .any(|owner| matches!(owner, SkillReadOwner::Eve(_))) + }) + { + continue; + } + let (relative, single_file) = + if let Some(relative) = fact.relative_path.strip_suffix("/SKILL.md") { + (relative, false) + } else if fact.kind == FilesystemEntryKind::File + && !fact.relative_path.contains('/') + && fact.relative_path.ends_with(".md") + { + (fact.relative_path.as_str(), true) + } else { + continue; + }; + actual.push(( + index, + fact.root_index, + root.locator.join_child(relative), + single_file, + )); + } + if actual.is_empty() { + return Ok(Vec::new()); + } + let records = plan.project_lock.as_ref().and_then(|document| { + (document + .root_snapshot("version") + .value() + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() + >= 1) + .then(|| document.root_snapshot("skills").value().cloned()) + .flatten() + }); + let mut expected = Vec::new(); + if let Some(records) = records.as_ref().and_then(serde_json::Value::as_object) { + for (name, value) in records { + let Ok(record) = serde_json::from_value::(value.clone()) else { + continue; + }; + if record.source.trim().is_empty() || record.source_type.trim().is_empty() { + continue; + } + let directory = InstalledSkillResolver::install_dir_name(name)?; + let ids = eve_target_ids(record.subagents.as_deref())?; + for (root_index, root) in plan.read_plan.roots.iter().enumerate() { + for owner in plan + .owners + .get(&root.locator.native_path) + .into_iter() + .flatten() + { + let SkillReadOwner::Eve(target) = owner else { + continue; + }; + if !ids.contains(&target.target_id) { + continue; + } + for single_file in [false, true] { + let leaf = if single_file { + format!("{directory}.md") + } else { + directory.clone() + }; + expected.push(( + root_index as u32, + name.clone(), + target.clone(), + root.locator.join_child(&leaf), + single_file, + )); + } + } + } + } + } + let destinations = actual + .iter() + .map(|(_, _, path, _)| path.clone()) + .chain(expected.iter().map(|(_, _, _, path, _)| path.clone())) + .collect::>(); + let facts = targets + .resolve(&plan.read_plan.context, &destinations, None) + .await?; + if facts.len() != destinations.len() { + return Err(AppError::StaleTarget); + } + let mut single_files = Vec::new(); + for (position, (fact_index, root_index, path, single_file)) in actual.iter().enumerate() { + let mut matches = expected + .iter() + .zip(&facts[actual.len()..]) + .filter(|((_, _, _, _, file), fact)| { + file == single_file && fact.key == facts[position].key + }) + .collect::>(); + if matches + .iter() + .map(|((_, name, _, _, _), _)| name) + .collect::>() + .len() + > 1 + { + return Err(ambiguous_installed_source(&path.native_path)); + } + matches.sort_by_key(|((root, _, _, _, _), _)| root != root_index); + let Some(((root, name, target, _, _), _)) = matches.first() else { + documents.remove(&skill_fact_id(&snapshot.facts[*fact_index])); + continue; + }; + let positions = expected + .iter() + .zip(&facts[actual.len()..]) + .filter(|((_, other_name, other_target, _, file), _)| { + other_name == name + && other_target.target_id == target.target_id + && file == single_file + }) + .map(|(_, fact)| &fact.key) + .collect::>(); + if positions.len() > 1 { + return Err(ambiguous_installed_source(&path.native_path)); + } + let fact_id = skill_fact_id(&snapshot.facts[*fact_index]); + // 仅为读取投影补充已确认的身份;磁盘中的 Eve 文件保持原样。 + if let Some(frontmatter) = documents.get(&fact_id) { + let mut frontmatter = frontmatter.as_ref().clone(); + frontmatter.name = name.clone(); + documents.insert(fact_id, Arc::new(frontmatter)); + } + if *single_file { + if expected.iter().zip(&facts[actual.len()..]).any( + |((other_root, other_name, _, _, file), fact)| { + other_root == root + && other_name == name + && !file + && fact.entry_kind != crate::environment::planning::TargetEntryKind::Missing + }, + ) { + return Err(ambiguous_installed_source(&path.native_path)); + } + let mut target = target.clone(); + target.path = path.native_path.clone(); + let mut skill = project_candidate( + name.clone(), + SkillCandidate { + description: String::new(), + canonical_path: path.native_path.clone(), + canonical_present: false, + canonical_is_symlink: false, + private_agents: BTreeSet::new(), + private_symlink_agents: BTreeSet::new(), + eve_targets: vec![target], + }, + runtime, + false, + ); + skill.maintenance_error = Some(AppError::CapabilityUnavailable { + capability: "eveSingleFile".into(), + path: Some(path.native_path.clone()), + }); + single_files.push(skill); + continue; + } + } + Ok(single_files) +} + +#[cfg(test)] pub fn project_skill_snapshot( + plan: &SkillReadPlan, + mut snapshot: RawFilesystemSnapshot, + runtime: &AgentRuntimeSnapshot, +) -> Result { + let mut documents = SkillDocuments::new(); + for fact in &mut snapshot.facts { + if fact.frontmatter_bytes.is_empty() { + continue; + } + if let Some(frontmatter) = parse_skill_frontmatter(&fact.frontmatter_bytes) { + documents.insert(skill_fact_id(fact), Arc::new(frontmatter)); + } else { + fact.error_code = Some("invalidFrontmatter".to_string()); + } + } + project_skill_snapshot_with_documents(plan, snapshot, runtime, &documents, Vec::new()) +} + +fn project_skill_snapshot_with_documents( plan: &SkillReadPlan, snapshot: RawFilesystemSnapshot, runtime: &AgentRuntimeSnapshot, + documents: &SkillDocuments, + initial_issues: Vec, ) -> Result { if !same_environment_identity(&snapshot.environment, &plan.read_plan.context.environment) { return Err(AppError::ConfigurationCorrupted { message: "Skill read snapshot belongs to another Environment".to_string(), }); } + let mut read_issues = SkillReadIssueCollector::default(); + for issue in initial_issues { + read_issues.record(issue.code, issue.path, issue.agent_ids); + } + for fact in &snapshot.facts { + let Some(code) = fact + .error_code + .clone() + .or_else(|| fact.truncated.then(|| "frontmatterTooLarge".to_string())) + else { + continue; + }; + let root = plan + .read_plan + .roots + .get(fact.root_index as usize) + .ok_or(AppError::StaleTarget)?; + let path = if fact.relative_path.is_empty() { + root.locator.clone() + } else { + root.locator.join_child(&fact.relative_path) + }; + read_issues.record(code, path, root.consumer_agent_ids.iter().cloned()); + } let is_global = matches!(plan.read_plan.context.scope, SkillLocation::Global); let mut directory_kinds = BTreeMap::<(u32, String), FilesystemEntryKind>::new(); let mut path_exists = false; @@ -252,10 +1108,16 @@ pub fn project_skill_snapshot( let Some(relative_dir) = fact.relative_path.strip_suffix("/SKILL.md") else { continue; }; + if relative_dir.starts_with(".skill-deck-stage-") + || relative_dir.starts_with(".skill-deck-backup-") + { + continue; + } if fact.truncated || fact.error_code.is_some() { continue; } - let Some(frontmatter) = parse_skill_frontmatter(&fact.frontmatter_bytes) else { + let fact_id = skill_fact_id(fact); + let Some(frontmatter) = documents.get(&fact_id) else { continue; }; if frontmatter @@ -288,9 +1150,13 @@ pub fn project_skill_snapshot( private_symlink_agents: BTreeSet::new(), eve_targets: Vec::new(), }); - if canonical_owner { - candidate.description = frontmatter.description; + if canonical_owner + || (!candidate.canonical_present && skill_path < candidate.canonical_path) + { + candidate.description = frontmatter.description.clone(); candidate.canonical_path = skill_path.clone(); + } + if canonical_owner { candidate.canonical_present = true; candidate.canonical_is_symlink = is_symlink; } @@ -344,13 +1210,17 @@ pub fn project_skill_snapshot( .cloned() .collect(); Ok(ListSkillsResult { + path_base: None, skills, agents, path_exists, + read_status: Some(read_issues.finish()), library_application: crate::application::library_application::LibraryApplicationSummary { ordered_libraries: Vec::new(), selected_agent_ids: Vec::new(), pending: false, + sync_state: + crate::application::library_application::LibraryApplicationSyncState::Synced, }, }) } @@ -406,12 +1276,18 @@ fn join_native_path(environment: &EnvironmentRef, root: &str, relative: &str) -> } } +#[cfg(test)] fn parse_skill_frontmatter(bytes: &[u8]) -> Option { + let frontmatter = parse_skill_frontmatter_allow_missing_name(bytes)?; + (!frontmatter.name.is_empty()).then_some(frontmatter) +} + +fn parse_skill_frontmatter_allow_missing_name(bytes: &[u8]) -> Option { let content = std::str::from_utf8(bytes).ok()?; let rest = content.strip_prefix("---")?; let end = rest.find("---")?; let frontmatter: SkillFrontmatter = serde_yaml::from_str(rest[..end].trim()).ok()?; - (!frontmatter.name.is_empty() && !frontmatter.description.is_empty()).then_some(frontmatter) + (!frontmatter.description.is_empty()).then_some(frontmatter) } fn project_candidate( @@ -505,6 +1381,9 @@ fn project_candidate( }, agents, associated_agents, + library_versions: None, + maintenance_error: None, + comparison_fingerprint: None, source: None, source_url: None, installed_at: None, @@ -530,6 +1409,8 @@ fn project_candidate( #[cfg(test)] mod tests { use std::collections::BTreeMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; use super::{build_skill_read_plan, project_skill_snapshot}; use crate::core::agent_definition::{ @@ -540,15 +1421,23 @@ mod tests { AgentRuntimeSnapshot, DetectionState, ResolvedAgent, ResolvedAgentScope, }; use crate::environment::context_resolver::ResolvedContext; - #[cfg(unix)] - use crate::environment::inspection::FilesystemInspector; - use crate::environment::inspection::{FilesystemEntryKind, RawFilesystemSnapshot, RawPathFact}; - #[cfg(unix)] + use crate::environment::inspection::{ + FilesystemEntryKind, FilesystemInspector, MetadataFuture, RawFilesystemSnapshot, + RawPathFact, RawSkillMetadata, SkillMetadataSource, + }; use crate::environment::native::inspection::NativeInspector; + use crate::environment::planning::{ + ResolvedTargetFact, RuntimeTargetFactResolver, TargetEntryKind, TargetFactFuture, + TargetFactResolver, + }; + use crate::environment::runtime::{ + EntryFingerprint, ExecutionBackend, PhysicalParentIdentity, PhysicalTargetKey, + }; use crate::environment::types::{ EnvironmentRef, EnvironmentStatus, RegisteredProject, ResourceLocator, SkillLocation, - SkillLocationRef, + SkillLocationRef, StorageAccess, }; + use crate::error::AppError; fn resolved_scope(standard_root: &str, private_root: Option<&str>) -> ResolvedAgentScope { ResolvedAgentScope { @@ -657,12 +1546,134 @@ mod tests { relative_path: String::new(), kind: FilesystemEntryKind::Directory, resolved_target: None, + fingerprint: None, frontmatter_bytes: Vec::new(), truncated: false, error_code: None, } } + #[derive(Clone)] + struct CountingTargets { + inner: RuntimeTargetFactResolver, + batch_sizes: Arc>>, + } + + impl TargetFactResolver for CountingTargets { + fn resolve<'a>( + &'a self, + context: &'a SkillLocationRef, + logical_destinations: &'a [ResourceLocator], + cancellation: Option, + ) -> TargetFactFuture<'a, Result, AppError>> { + self.batch_sizes + .lock() + .unwrap() + .push(logical_destinations.len()); + self.inner + .resolve(context, logical_destinations, cancellation) + } + } + + #[cfg(unix)] + struct CountingMetadata { + inner: NativeInspector, + batch_sizes: Arc>>, + } + + #[cfg(unix)] + impl SkillMetadataSource for CountingMetadata { + fn read<'a>( + &'a self, + locators: &'a [ResourceLocator], + per_file_limit: u32, + ) -> MetadataFuture<'a, Result, AppError>> { + self.batch_sizes.lock().unwrap().push(locators.len()); + self.inner.read(locators, per_file_limit) + } + } + + struct SyntheticScaleTargets; + + impl TargetFactResolver for SyntheticScaleTargets { + fn resolve<'a>( + &'a self, + _context: &'a SkillLocationRef, + logical_destinations: &'a [ResourceLocator], + _cancellation: Option, + ) -> TargetFactFuture<'a, Result, AppError>> { + Box::pin(async move { + logical_destinations + .iter() + .map(|destination| { + let name = destination.native_path.rsplit('/').next().ok_or_else(|| { + AppError::Validation { + field: Some("scaleTarget".to_string()), + message: "missing synthetic Skill name".to_string(), + } + })?; + let index = name + .strip_prefix("skill-") + .and_then(|value| value.parse::().ok()) + .unwrap_or_default(); + Ok(ResolvedTargetFact { + key: PhysicalTargetKey { + backend: ExecutionBackend::NativeUnix, + physical_parent: PhysicalParentIdentity::Unix { + device: 1, + inode: index + 1, + }, + normalized_final_child_name: name.to_string(), + }, + destination: destination.clone(), + storage_access: StorageAccess::Native, + fingerprint: EntryFingerprint(format!("scale-{index}")), + entry_kind: TargetEntryKind::Directory, + link_target: None, + link_target_identity: None, + }) + }) + .collect() + }) + } + } + + struct SyntheticScaleMetadata { + reads: Arc, + } + + impl SkillMetadataSource for SyntheticScaleMetadata { + fn read<'a>( + &'a self, + locators: &'a [ResourceLocator], + _per_file_limit: u32, + ) -> MetadataFuture<'a, Result, AppError>> { + self.reads.fetch_add(locators.len(), Ordering::SeqCst); + Box::pin(async move { + Ok(locators + .iter() + .map(|locator| { + let name = locator + .native_path + .trim_end_matches("/SKILL.md") + .rsplit('/') + .next() + .unwrap(); + RawSkillMetadata { + locator: locator.clone(), + bytes: format!( + "---\nname: {name}\ndescription: Synthetic {name}\n---\n" + ) + .into_bytes(), + truncated: false, + error_code: None, + } + }) + .collect()) + }) + } + } + fn skill_facts(root_index: u32) -> [RawPathFact; 2] { [ RawPathFact { @@ -670,6 +1681,7 @@ mod tests { relative_path: "toolkit".to_string(), kind: FilesystemEntryKind::Directory, resolved_target: None, + fingerprint: None, frontmatter_bytes: Vec::new(), truncated: false, error_code: None, @@ -679,6 +1691,7 @@ mod tests { relative_path: "toolkit/SKILL.md".to_string(), kind: FilesystemEntryKind::File, resolved_target: None, + fingerprint: None, frontmatter_bytes: b"---\nname: toolkit\ndescription: Toolkit\n---\n".to_vec(), truncated: false, error_code: None, @@ -760,6 +1773,455 @@ mod tests { assert_eq!(result.agents[0].definition.id.as_str(), "custom-both"); } + #[test] + fn skill_snapshot_ignores_managed_stage_and_backup_directories() { + let environment = EnvironmentRef::Wsl { + distro_name: "Ubuntu".to_string(), + }; + let context = context(environment.clone()); + let runtime = runtime(environment.clone()); + let plan = build_skill_read_plan(&context, &runtime, &[]).unwrap(); + let canonical_index = root_index(&plan, "/work/app/.agents/skills"); + let mut facts = vec![root_fact(canonical_index)]; + for (directory, name) in [ + ("toolkit", "toolkit"), + (".skill-deck-stage-operation-000000", "staged"), + (".skill-deck-backup-operation-000000", "backup"), + ] { + facts.push(RawPathFact { + root_index: canonical_index, + relative_path: directory.to_string(), + kind: FilesystemEntryKind::Directory, + resolved_target: None, + fingerprint: None, + frontmatter_bytes: Vec::new(), + truncated: false, + error_code: None, + }); + facts.push(RawPathFact { + root_index: canonical_index, + relative_path: format!("{directory}/SKILL.md"), + kind: FilesystemEntryKind::File, + resolved_target: None, + fingerprint: None, + frontmatter_bytes: format!("---\nname: {name}\ndescription: Test\n---\n") + .into_bytes(), + truncated: false, + error_code: None, + }); + } + + let result = project_skill_snapshot( + &plan, + RawFilesystemSnapshot { + environment, + facts, + total_content_bytes: 0, + }, + &runtime, + ) + .unwrap(); + + assert_eq!( + result + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>(), + vec!["toolkit"] + ); + } + + #[test] + fn skill_snapshot_keeps_valid_cards_when_another_frontmatter_is_invalid() { + let environment = EnvironmentRef::Native; + let context = context(environment.clone()); + let runtime = runtime(environment.clone()); + let plan = build_skill_read_plan(&context, &runtime, &[]).unwrap(); + let canonical_index = root_index(&plan, "/work/app/.agents/skills"); + let mut facts = vec![root_fact(canonical_index)]; + facts.extend(skill_facts(canonical_index)); + facts.push(RawPathFact { + root_index: canonical_index, + relative_path: "broken".to_string(), + kind: FilesystemEntryKind::Directory, + resolved_target: None, + fingerprint: None, + frontmatter_bytes: Vec::new(), + truncated: false, + error_code: None, + }); + facts.push(RawPathFact { + root_index: canonical_index, + relative_path: "broken/SKILL.md".to_string(), + kind: FilesystemEntryKind::File, + resolved_target: None, + fingerprint: None, + frontmatter_bytes: b"---\ndescription: Missing name\n---\n".to_vec(), + truncated: false, + error_code: None, + }); + + let result = project_skill_snapshot( + &plan, + RawFilesystemSnapshot { + environment, + total_content_bytes: facts + .iter() + .map(|fact| fact.frontmatter_bytes.len() as u32) + .sum(), + facts, + }, + &runtime, + ) + .unwrap(); + + assert_eq!(result.skills.len(), 1); + let status = result.read_status.unwrap(); + assert!(!status.complete); + assert_eq!(status.counts[0].code, "invalidFrontmatter"); + assert_eq!(status.counts[0].count, 1); + assert_eq!(status.omitted_count, 0); + } + + #[tokio::test] + async fn direct_skill_projection_resolves_only_observed_entries() { + use crate::environment::wsl::WslRuntime; + + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path(); + let canonical_root = project_root.join(".agents/skills"); + std::fs::create_dir_all(&canonical_root).unwrap(); + + let environment = EnvironmentRef::Native; + let mut context = context(environment.clone()); + context.project.as_mut().unwrap().native_path = project_root.to_string_lossy().into_owned(); + context.skill_root.native_path = canonical_root.to_string_lossy().into_owned(); + context.lock.native_path = project_root + .join("skills-lock.json") + .to_string_lossy() + .into_owned(); + + let template = runtime(environment.clone()) + .agents + .into_values() + .next() + .unwrap(); + let mut runtime = runtime(environment.clone()); + runtime.project_path = Some(project_root.to_string_lossy().into_owned()); + runtime.agents.clear(); + for index in 0..20 { + let id = AgentId::parse(format!("custom-{index}")).unwrap(); + let relative_root = format!(".custom-{index}/skills"); + let private_root = project_root.join(&relative_root); + let skill_name = format!("skill-{index}"); + std::fs::create_dir_all(private_root.join(&skill_name)).unwrap(); + std::fs::write( + private_root.join(&skill_name).join("SKILL.md"), + format!("---\nname: {skill_name}\ndescription: Skill {index}\n---\n"), + ) + .unwrap(); + + let mut agent = template.clone(); + agent.definition.id = id.clone(); + agent.definition.display_name = format!("Custom {index}"); + agent.definition.project.private_path = Some(PathSpec::project(&relative_root)); + agent.project.standard_path = Some(canonical_root.to_string_lossy().into_owned()); + agent.project.private_path = Some(private_root.to_string_lossy().into_owned()); + runtime.agents.insert(id, agent); + } + + let plan = build_skill_read_plan(&context, &runtime, &[]).unwrap(); + let inspector = NativeInspector::new(environment.clone()); + let snapshot = inspector.inspect(&plan.read_plan).await.unwrap(); + let libraries = + crate::native_workflow_integration_support::update_library_repository(temp.path()); + let batch_sizes = Arc::new(Mutex::new(Vec::new())); + let targets = CountingTargets { + inner: RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), + batch_sizes: batch_sizes.clone(), + }; + + let result = super::project_direct_skill_snapshot( + &plan, + snapshot, + &runtime, + libraries.as_ref(), + &targets, + &inspector, + ) + .await + .unwrap(); + + assert_eq!(result.skills.len(), 20); + assert_eq!(*batch_sizes.lock().unwrap(), vec![20]); + } + + #[tokio::test] + #[ignore = "large-scale Skill read contract"] + async fn scale_projection_deduplicates_ten_thousand_placements_to_one_thousand_reads() { + const AGENTS: usize = 100; + const SKILLS: usize = 1_000; + const PLACEMENTS_PER_ROOT: usize = 100; + + let environment = EnvironmentRef::Native; + let context = context(environment.clone()); + let template = runtime(environment.clone()) + .agents + .into_values() + .next() + .unwrap(); + let mut runtime = runtime(environment.clone()); + runtime.agents.clear(); + for index in 0..AGENTS { + let id = AgentId::parse(format!("scale-agent-{index:03}")).unwrap(); + let relative_root = if index == 0 { + ".agents/skills".to_string() + } else { + format!(".scale-agent-{index:03}/skills") + }; + let private_root = format!("/work/app/{relative_root}"); + let mut agent = template.clone(); + agent.definition.id = id.clone(); + agent.definition.display_name = format!("Scale Agent {index:03}"); + agent.definition.project.private_path = Some(PathSpec::project(&relative_root)); + agent.project.standard_path = Some("/work/app/.agents/skills".to_string()); + agent.project.private_path = Some(private_root); + runtime.agents.insert(id, agent); + } + + let plan = build_skill_read_plan(&context, &runtime, &[]).unwrap(); + let mut facts = plan + .read_plan + .roots + .iter() + .enumerate() + .map(|(index, _)| root_fact(index as u32)) + .collect::>(); + let skill_roots = plan + .read_plan + .roots + .iter() + .enumerate() + .filter(|(_, root)| root.locator.native_path != "/work/app") + .map(|(index, _)| index as u32) + .collect::>(); + assert_eq!(skill_roots.len(), AGENTS); + for (root_position, root_index) in skill_roots.into_iter().enumerate() { + for offset in 0..PLACEMENTS_PER_ROOT { + let skill_index = (root_position * PLACEMENTS_PER_ROOT + offset) % SKILLS; + let name = format!("skill-{skill_index:04}"); + facts.push(RawPathFact { + root_index, + relative_path: name.clone(), + kind: FilesystemEntryKind::Directory, + resolved_target: None, + fingerprint: None, + frontmatter_bytes: Vec::new(), + truncated: false, + error_code: None, + }); + facts.push(RawPathFact { + root_index, + relative_path: format!("{name}/SKILL.md"), + kind: FilesystemEntryKind::File, + resolved_target: None, + fingerprint: Some(EntryFingerprint(format!("document-{skill_index:04}"))), + frontmatter_bytes: Vec::new(), + truncated: false, + error_code: None, + }); + } + } + let reads = Arc::new(AtomicUsize::new(0)); + let metadata = SyntheticScaleMetadata { + reads: reads.clone(), + }; + let temp = tempfile::tempdir().unwrap(); + let libraries = + crate::native_workflow_integration_support::update_library_repository(temp.path()); + + let snapshot = RawFilesystemSnapshot { + environment, + facts, + total_content_bytes: 0, + }; + let result = super::project_direct_skill_snapshot( + &plan, + snapshot.clone(), + &runtime, + libraries.as_ref(), + &SyntheticScaleTargets, + &metadata, + ) + .await + .unwrap(); + + assert_eq!(result.skills.len(), SKILLS); + assert_eq!(reads.load(Ordering::SeqCst), SKILLS); + assert!(result.read_status.unwrap().complete); + + let refreshed = super::project_direct_skill_snapshot( + &plan, + snapshot.clone(), + &runtime, + libraries.as_ref(), + &SyntheticScaleTargets, + &metadata, + ) + .await + .unwrap(); + assert_eq!(refreshed.skills.len(), SKILLS); + assert_eq!(reads.load(Ordering::SeqCst), SKILLS); + + let mut changed = snapshot; + for fact in &mut changed.facts { + if fact.relative_path == "skill-0000/SKILL.md" { + fact.fingerprint = Some(EntryFingerprint("document-0000-changed".to_string())); + } + } + let changed_result = super::project_direct_skill_snapshot( + &plan, + changed, + &runtime, + libraries.as_ref(), + &SyntheticScaleTargets, + &metadata, + ) + .await + .unwrap(); + assert_eq!(changed_result.skills.len(), SKILLS); + assert_eq!(reads.load(Ordering::SeqCst), SKILLS + 1); + } + + #[cfg(target_os = "windows")] + #[tokio::test] + #[ignore = "requires SKILL_DECK_TEST_WSL_DISTRO and a matching real WSL Worker"] + async fn real_wsl_skill_read_scale_p95_is_within_five_seconds() { + use std::time::{Duration, Instant}; + + use crate::environment::wsl::WslRuntime; + + const AGENTS: usize = 100; + const SAMPLES: usize = 20; + const SKILLS: usize = 1_000; + + let distro_name = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").expect("set SKILL_DECK_TEST_WSL_DISTRO"); + let fixture_root = format!("/tmp/skill-deck-skill-read-scale-{}", uuid::Uuid::new_v4()); + let wsl = Arc::new(WslRuntime::for_wsl_test()); + wsl.connect(&distro_name).await.expect("connect WSL Worker"); + wsl.run_test_script( + &distro_name, + r#"set -eu +root=$1 +mkdir -p "$root/content" "$root/project" +i=0 +while [ "$i" -lt 1000 ]; do + name=$(printf 'skill-%04d' "$i") + mkdir -p "$root/content/$name" + printf '%s\n' '---' "name: $name" "description: Synthetic $name" '---' > "$root/content/$name/SKILL.md" + i=$((i + 1)) +done +r=0 +while [ "$r" -lt 100 ]; do + root_name=$(printf 'root-%03d' "$r") + mkdir -p "$root/roots/$root_name" + k=0 + while [ "$k" -lt 100 ]; do + skill=$(((r * 100 + k) % 1000)) + name=$(printf 'skill-%04d' "$skill") + ln -s "$root/content/$name" "$root/roots/$root_name/$name" + k=$((k + 1)) + done + r=$((r + 1)) +done +"#, + vec![fixture_root.clone()], + Duration::from_secs(60), + ) + .await + .expect("create scale fixture"); + + let environment = EnvironmentRef::Wsl { + distro_name: distro_name.clone(), + }; + let mut resolved_context = context(environment.clone()); + resolved_context.project.as_mut().unwrap().native_path = format!("{fixture_root}/project"); + resolved_context.skill_root.native_path = format!("{fixture_root}/roots/root-000"); + resolved_context.lock.native_path = format!("{fixture_root}/project/skills-lock.json"); + let template = runtime(environment.clone()) + .agents + .into_values() + .next() + .unwrap(); + let mut agent_runtime = runtime(environment.clone()); + agent_runtime.project_path = Some(format!("{fixture_root}/project")); + agent_runtime.agents.clear(); + for index in 0..AGENTS { + let id = AgentId::parse(format!("scale-agent-{index:03}")).unwrap(); + let root = format!("{fixture_root}/roots/root-{index:03}"); + let mut agent = template.clone(); + agent.definition.id = id.clone(); + agent.definition.display_name = format!("Scale Agent {index:03}"); + agent.definition.project.private_path = + Some(PathSpec::project(format!(".scale-agent-{index:03}/skills"))); + agent.project.standard_path = Some(resolved_context.skill_root.native_path.clone()); + agent.project.private_path = Some(root); + agent_runtime.agents.insert(id, agent); + } + let base_plan = build_skill_read_plan(&resolved_context, &agent_runtime, &[]).unwrap(); + assert_eq!(base_plan.read_plan.roots.len(), AGENTS + 1); + let workspace = wsl.workspace(&distro_name).unwrap(); + let inspector = + crate::environment::wsl::operations::inspection::WslInspector::new(workspace); + let targets = RuntimeTargetFactResolver::new(wsl.clone()); + let mut elapsed = Vec::with_capacity(SAMPLES); + let measured = async { + for sample in 0..SAMPLES { + let mut plan = base_plan.clone(); + plan.read_plan.registry_revision = format!("scale-registry-{sample}"); + let started = Instant::now(); + let mut snapshot = inspector.inspect(&plan.read_plan).await?; + let (documents, _, metadata_issues, _) = + super::read_skill_documents(&plan, &mut snapshot, &targets, &inspector).await?; + let result = super::project_skill_snapshot_with_documents( + &plan, + snapshot, + &agent_runtime, + &documents, + metadata_issues, + )?; + if result.skills.len() != SKILLS { + return Err(AppError::ConfigurationCorrupted { + message: format!( + "scale fixture projected {} Skills instead of {SKILLS}", + result.skills.len() + ), + }); + } + elapsed.push(started.elapsed()); + } + Ok::<_, AppError>(()) + } + .await; + + wsl.run_test_script( + &distro_name, + "set -eu\ncase $1 in /tmp/skill-deck-skill-read-scale-*) rm -rf -- \"$1\" ;; *) exit 64 ;; esac\n", + vec![fixture_root], + Duration::from_secs(30), + ) + .await + .expect("clean scale fixture"); + measured.expect("execute scale reads"); + + elapsed.sort_unstable(); + let p95 = elapsed[(SAMPLES * 95).div_ceil(100) - 1]; + eprintln!("real WSL Skill read samples: {elapsed:?}; p95={p95:?}"); + assert!(p95 <= Duration::from_secs(5), "P95 was {p95:?}"); + } + #[cfg(unix)] #[tokio::test] async fn native_private_directory_symlink_is_included_in_associated_agents() { @@ -794,12 +2256,28 @@ mod tests { resolved.project.standard_path = Some(canonical_root.to_string_lossy().into_owned()); resolved.project.private_path = Some(agent_root.to_string_lossy().into_owned()); let plan = build_skill_read_plan(&context, &runtime, &[]).unwrap(); - let snapshot = NativeInspector::new(environment) - .inspect(&plan.read_plan) - .await - .unwrap(); - - let result = project_skill_snapshot(&plan, snapshot, &runtime).unwrap(); + let inspector = NativeInspector::new(environment); + let snapshot = inspector.inspect(&plan.read_plan).await.unwrap(); + let targets = RuntimeTargetFactResolver::new(Arc::new( + crate::environment::wsl::WslRuntime::default(), + )); + let libraries = + crate::native_workflow_integration_support::update_library_repository(temp.path()); + let metadata_batches = Arc::new(Mutex::new(Vec::new())); + let metadata = CountingMetadata { + inner: NativeInspector::new(EnvironmentRef::Native), + batch_sizes: metadata_batches.clone(), + }; + let result = super::project_direct_skill_snapshot( + &plan, + snapshot, + &runtime, + libraries.as_ref(), + &targets, + &metadata, + ) + .await + .unwrap(); let skill = result .skills @@ -814,5 +2292,133 @@ mod tests { skill.private_adapted_agents.as_deref(), Some([AgentId::parse("custom-both").unwrap()].as_slice()) ); + assert_eq!(*metadata_batches.lock().unwrap(), vec![1]); + } + + #[tokio::test] + async fn native_skill_list_projects_direct_version_beside_library_root() { + use crate::application::skill_libraries::{LibraryId, LIBRARY_SCHEMA_VERSION}; + use crate::environment::planning::RuntimeTargetFactResolver; + use crate::environment::wsl::WslRuntime; + use std::sync::Arc; + + let temp = tempfile::tempdir().unwrap(); + let libraries = + crate::native_workflow_integration_support::update_library_repository(temp.path()); + let catalog = serde_json::from_value(serde_json::json!({ + "schemaVersion": LIBRARY_SCHEMA_VERSION, + "libraries": [{"id":"library-one", "name":"Team library", "skills":[{ + "name":"toolkit", "description":"Library description", "sourceRecord":{}, "contentManifestHash":"old" + }], "retiredSkills":[]}] + })).unwrap(); + libraries + .save(&EnvironmentRef::Native, &catalog) + .await + .unwrap(); + let root = libraries + .resolve_collection(&EnvironmentRef::Native, &LibraryId::parse("library-one")) + .await + .unwrap(); + let library_root = std::path::PathBuf::from(root.root.native_path); + std::fs::create_dir_all(library_root.join("toolkit")).unwrap(); + std::fs::write( + library_root.join("toolkit/SKILL.md"), + b"---\nname: toolkit\ndescription: Library description\n---\n", + ) + .unwrap(); + let canonical_root = temp.path().join(".agents/skills"); + std::fs::create_dir_all(&canonical_root).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(library_root.join("toolkit"), canonical_root.join("toolkit")) + .unwrap(); + #[cfg(windows)] + junction::create(library_root.join("toolkit"), canonical_root.join("toolkit")).unwrap(); + let private_root = temp.path().join(".custom/skills"); + std::fs::create_dir_all(private_root.join("toolkit")).unwrap(); + std::fs::write( + private_root.join("toolkit/SKILL.md"), + b"---\nname: toolkit\ndescription: Direct description\n---\n", + ) + .unwrap(); + let mut context = context(EnvironmentRef::Native); + context.project.as_mut().unwrap().native_path = temp.path().to_string_lossy().into_owned(); + context.skill_root.native_path = canonical_root.to_string_lossy().into_owned(); + let mut runtime = runtime(EnvironmentRef::Native); + let agent = runtime.agents.values_mut().next().unwrap(); + agent.project.standard_path = Some(canonical_root.to_string_lossy().into_owned()); + agent.project.private_path = Some(private_root.to_string_lossy().into_owned()); + let targets = RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())); + let plan = build_skill_read_plan(&context, &runtime, &[]).unwrap(); + let inspector = NativeInspector::new(EnvironmentRef::Native); + let snapshot = inspector.inspect(&plan.read_plan).await.unwrap(); + let result = super::project_direct_skill_snapshot( + &plan, + snapshot, + &runtime, + libraries.as_ref(), + &targets, + &inspector, + ) + .await + .unwrap(); + assert_eq!(result.skills.len(), 1); + let skill = &result.skills[0]; + assert_eq!(skill.description, "Direct description"); + assert_eq!( + skill.canonical_path, + private_root.join("toolkit").to_string_lossy() + ); + assert_eq!(skill.default_available_agent_count, Some(0)); + assert_eq!( + skill.associated_agents, + vec![AgentId::parse("custom-both").unwrap()] + ); + let versions = skill.library_versions.as_ref().unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].library_name, "Team library"); + assert_eq!(versions[0].skill_name, "toolkit"); + + #[cfg(unix)] + std::fs::remove_file(canonical_root.join("toolkit")).unwrap(); + #[cfg(windows)] + { + junction::delete(canonical_root.join("toolkit")).unwrap(); + std::fs::remove_dir(canonical_root.join("toolkit")).unwrap(); + } + std::fs::remove_dir(&canonical_root).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&library_root, &canonical_root).unwrap(); + #[cfg(windows)] + junction::create(&library_root, &canonical_root).unwrap(); + let snapshot = inspector.inspect(&plan.read_plan).await.unwrap(); + let result = super::project_direct_skill_snapshot( + &plan, + snapshot, + &runtime, + libraries.as_ref(), + &targets, + &inspector, + ) + .await + .unwrap(); + assert_eq!(result.skills[0].description, "Direct description"); + assert_eq!( + result.skills[0].library_versions.as_ref().unwrap()[0].library_id, + "library-one" + ); + + std::fs::remove_dir_all(private_root.join("toolkit")).unwrap(); + let snapshot = inspector.inspect(&plan.read_plan).await.unwrap(); + let result = super::project_direct_skill_snapshot( + &plan, + snapshot, + &runtime, + libraries.as_ref(), + &targets, + &inspector, + ) + .await + .unwrap(); + assert!(result.skills.is_empty()); } } diff --git a/src-tauri/src/application/skill_source.rs b/src-tauri/src/application/skill_source.rs index 3d3fba11..e587d7d5 100644 --- a/src-tauri/src/application/skill_source.rs +++ b/src-tauri/src/application/skill_source.rs @@ -6,7 +6,7 @@ use crate::application::payload_session::{ AcquiredPayloadHandle, DiscoverySessionHandle, PayloadSessionManager, }; use crate::application::skill_changes::ValidatedSkillPayload; -use crate::application::source_evidence::{RemoteEvidenceKey, SourceSnapshotFacts}; +use crate::application::source_evidence::RemoteEvidenceKey; use crate::application::source_snapshot_reuse::PayloadAcquisitionKey; use crate::core::mutation::CancellationSignal; use crate::core::{NormalizedUpdateMetadata, SourceIdentity}; @@ -25,6 +25,7 @@ impl SavedSkillSource { } } +#[derive(Clone)] pub struct SavedSkillSourceGroup { pub source_result_id: String, pub source: String, @@ -35,13 +36,16 @@ pub struct SavedSkillSourceGroup { pub skills: Vec, } +#[derive(Clone)] pub struct AcquiredSavedSkillSource { - pub facts: SourceSnapshotFacts, + pub discovery_session: DiscoverySessionHandle, pub payloads: Vec<(String, AcquiredPayloadHandle)>, pub skill_errors: Vec<(String, AppError)>, - pub redirected_download_host: Option, + pub redirected_download_hosts: Vec, + pub(crate) _leases: Vec>, } +#[derive(Clone)] pub struct SavedSkillSourceAcquisition { pub source_result_id: String, pub source: String, @@ -49,26 +53,31 @@ pub struct SavedSkillSourceAcquisition { pub result: Result, } -pub struct SavedPayloadCandidate { - pub source_result_id: String, - pub discovery_session: DiscoverySessionHandle, - pub skill_name: String, - pub handle: AcquiredPayloadHandle, -} - -pub struct ValidatedSavedPayload { - pub payload: ValidatedSkillPayload, -} - -pub struct FailedSavedPayload { - pub source_result_id: String, - pub skill_name: String, - pub error: AppError, -} - -pub struct SavedPayloadValidation { - pub validated: Vec, - pub failed: Vec, +impl AcquiredSavedSkillSource { + pub async fn validate_member( + &self, + sessions: &PayloadSessionManager, + environment: &EnvironmentRef, + name: &str, + ) -> Result { + if let Some((_, error)) = self.skill_errors.iter().find(|(skill, _)| skill == name) { + return Err(error.clone()); + } + let handle = self + .payloads + .iter() + .find(|(skill, _)| skill == name) + .map(|(_, handle)| handle) + .ok_or(AppError::StalePayload)?; + ValidatedSkillPayload::validate( + handle.clone(), + &self.discovery_session, + environment, + name, + sessions.pin_verified(handle).await?, + ) + .await + } } pub type SkillSourceFuture<'a, T> = Pin + Send + 'a>>; @@ -140,39 +149,6 @@ pub fn group_saved_skills( Ok(groups) } -pub async fn validate_saved_payloads( - payloads: &PayloadSessionManager, - environment: &EnvironmentRef, - candidates: Vec, -) -> SavedPayloadValidation { - let mut validated = Vec::new(); - let mut failed = Vec::new(); - for candidate in candidates { - let result = match payloads.pin_verified(&candidate.handle).await { - Ok(lease) => { - ValidatedSkillPayload::validate( - candidate.handle, - &candidate.discovery_session, - environment, - &candidate.skill_name, - lease, - ) - .await - } - Err(error) => Err(error), - }; - match result { - Ok(payload) => validated.push(ValidatedSavedPayload { payload }), - Err(error) => failed.push(FailedSavedPayload { - source_result_id: candidate.source_result_id, - skill_name: candidate.skill_name, - error, - }), - } - } - SavedPayloadValidation { validated, failed } -} - #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; diff --git a/src-tauri/src/application/skills.rs b/src-tauri/src/application/skills.rs index 7fbf91d5..75ee25f1 100644 --- a/src-tauri/src/application/skills.rs +++ b/src-tauri/src/application/skills.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use crate::application::agents::{AgentCommandError, ManagedAgentRegistry}; use crate::application::installed_skill_resolver::InstalledSkillResolver; use crate::application::skill_read::{ - build_skill_read_plan, discover_eve_skill_targets, project_skill_snapshot, ListSkillsResult, + build_skill_read_plan, discover_eve_skill_targets, project_direct_skill_snapshot, + ListSkillsResult, }; use crate::core::local_lock::LocalSkillLockEntry; use crate::core::lossless_lock::LosslessLockDocument; @@ -16,7 +17,6 @@ use crate::environment::lock_io::EnvironmentLockIo; use crate::environment::native::inspection::NativeInspector; use crate::environment::read_service::ReadService; use crate::environment::types::{EnvironmentRef, ResourceLocator, SkillLocationRef}; -use crate::environment::wsl::operations::inspection::WslInspector; use crate::environment::wsl::WslRuntime; use crate::error::AppError; @@ -51,6 +51,11 @@ fn enrich_environment_skills_from_lock_at( kind: LockKind, project_root: Option<&str>, ) -> Result, AppError> { + for skill in &mut skills { + skill.can_run_update = Some(false); + skill.can_check_for_updates = Some(false); + skill.update_reason = Some("local-source".to_string()); + } let Some(bytes) = bytes else { return Ok(skills); }; @@ -80,46 +85,49 @@ fn enrich_environment_skills_from_lock_at( let Some(value) = entries.get(&resolved.lock_key).cloned() else { continue; }; - let enriched = - match kind { - LockKind::Global => serde_json::from_value::(value) - .ok() - .map(|entry| skill.clone().with_lock_entry(Some(&entry))), - LockKind::Project => serde_json::from_value::(value) - .ok() - .map(|mut entry| { - if entry.source_type == "local" { - if let Some(project_root) = project_root { - entry.source = - crate::core::portable_project_path::resolve_project_source( - project_root, - &entry.source, - ); - } + let enriched = match kind { + LockKind::Global => serde_json::from_value::(value) + .map(|entry| skill.clone().with_lock_entry(Some(&entry))), + LockKind::Project => { + serde_json::from_value::(value).map(|mut entry| { + if entry.source_type == "local" { + if let Some(project_root) = project_root { + entry.source = + crate::core::portable_project_path::resolve_project_source( + project_root, + &entry.source, + ); } - skill.clone().with_local_lock_entry(Some(&entry)) - }), - LockKind::LegacyProject => serde_json::from_value::(value) - .ok() - .map(|entry| { - let local = LocalSkillLockEntry { - source: entry.source, - ref_name: entry.ref_name, - source_type: entry.source_type, - source_url: (!entry.source_url.is_empty()).then_some(entry.source_url), - well_known_digest: entry.well_known_digest, - computed_hash: String::new(), - remote_hash: (!entry.skill_folder_hash.is_empty()) - .then_some(entry.skill_folder_hash), - skill_path: entry.skill_path, - subagents: None, - plugin_name: entry.plugin_name, - }; - skill.clone().with_local_lock_entry(Some(&local)) - }), - }; - if let Some(enriched) = enriched { - *skill = enriched; + } + skill.clone().with_local_lock_entry(Some(&entry)) + }) + } + LockKind::LegacyProject => { + serde_json::from_value::(value).map(|entry| { + let local = LocalSkillLockEntry { + source: entry.source, + ref_name: entry.ref_name, + source_type: entry.source_type, + source_url: (!entry.source_url.is_empty()).then_some(entry.source_url), + well_known_digest: entry.well_known_digest, + computed_hash: String::new(), + remote_hash: (!entry.skill_folder_hash.is_empty()) + .then_some(entry.skill_folder_hash), + skill_path: entry.skill_path, + subagents: None, + plugin_name: entry.plugin_name, + }; + skill.clone().with_local_lock_entry(Some(&local)) + }) + } + }; + match enriched { + Ok(enriched) => *skill = enriched, + Err(_) => { + skill.can_run_update = Some(false); + skill.can_check_for_updates = Some(false); + skill.update_reason = Some("missingSource".to_string()); + } } } Ok(skills) @@ -151,6 +159,9 @@ mod environment_tests { canonical_path: canonical_path.to_string(), scope, associated_agents: agents.clone(), + library_versions: None, + maintenance_error: None, + comparison_fingerprint: None, default_available_agent_count: Some(agents.len() as u32), private_adapted_agent_count: Some(0), duplicate_copy_count: Some(0), @@ -265,6 +276,42 @@ mod environment_tests { assert_eq!(skill.source, None); } + #[test] + fn distinguishes_an_invalid_source_record_from_an_unregistered_skill() { + let invalid_record = br#"{ + "version": 3, + "skills": { + "toolkit": { + "source": 42, + "sourceType": "github" + } + } + }"#; + let skill = || { + installed_skill( + "toolkit", + "Toolkit", + "/home/alice/.agents/skills/toolkit", + InstalledSkillLocation::Global, + Vec::new(), + ) + }; + let enrich = |bytes: Option<&[u8]>| { + enrich_environment_skills_from_lock(vec![skill()], bytes, LockKind::Global) + .unwrap() + .pop() + .unwrap() + }; + + let invalid = enrich(Some(invalid_record)); + assert_eq!(invalid.can_check_for_updates, Some(false)); + assert_eq!(invalid.update_reason.as_deref(), Some("missingSource")); + for local in [enrich(Some(br#"{"version":3,"skills":{}}"#)), enrich(None)] { + assert_eq!(local.can_check_for_updates, Some(false)); + assert_eq!(local.update_reason.as_deref(), Some("local-source")); + } + } + #[test] fn enriches_raw_skill_name_from_a_unique_legacy_sanitized_lock_key() { let skill = installed_skill( @@ -334,6 +381,8 @@ pub async fn list_skills( context: SkillLocationRef, environment_registry: &WslRuntime, agent_registry: &ManagedAgentRegistry, + libraries: &dyn crate::application::skill_libraries::SkillLibraryRepository, + targets: &dyn crate::environment::planning::TargetFactResolver, ) -> Result { let runtime = crate::application::agents::list_agents( context.clone(), @@ -346,47 +395,95 @@ pub async fn list_skills( EnvironmentRef::Native => { let resolved = ContextResolver::resolve_native(context)?; let eve_targets = discover_eve_skill_targets(&resolved, &runtime, None).await?; - let plan = build_skill_read_plan(&resolved, &runtime, &eve_targets)?; - let read_service = - ReadService::new(vec![Arc::new(NativeInspector::new(EnvironmentRef::Native))]); + let mut plan = build_skill_read_plan(&resolved, &runtime, &eve_targets)?; + let inspector = Arc::new(NativeInspector::new(EnvironmentRef::Native)); + let read_service = ReadService::new(vec![inspector.clone()]); let snapshot = read_service.execute(&plan.read_plan).await?; - let result = project_skill_snapshot(&plan, snapshot, &runtime)?; - enrich_from_context_lock(result, &resolved, EnvironmentLockIo::Native).await + let (lock_bytes, lock_kind) = + read_context_lock(&resolved, EnvironmentLockIo::Native).await?; + if lock_kind == LockKind::Project { + plan.set_project_lock(lock_bytes.as_deref()); + } + let mut result = project_direct_skill_snapshot( + &plan, + snapshot, + &runtime, + libraries, + targets, + inspector.as_ref(), + ) + .await?; + result.path_base = Some(resolved.path_base(targets).await); + enrich_from_lock_bytes(result, &resolved, lock_bytes, lock_kind) } EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let retry_context = context.clone(); - environment_registry - .with_session_retry(&distro_name, move |session| { + let retry_runtime = runtime.clone(); + let workspace = environment_registry.workspace(&distro_name)?; + let (resolved, mut plan) = environment_registry + .with_session_read_retry(&distro_name, move |session| { let context = retry_context.clone(); - let runtime = runtime.clone(); + let runtime = retry_runtime.clone(); + let workspace = workspace.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; let eve_targets = - discover_eve_skill_targets(&resolved, &runtime, Some(&session)).await?; + discover_eve_skill_targets(&resolved, &runtime, Some(&workspace)) + .await?; let plan = build_skill_read_plan(&resolved, &runtime, &eve_targets)?; - let read_service = - ReadService::new(vec![Arc::new(WslInspector::new(session.clone()))]); - let snapshot = read_service.execute(&plan.read_plan).await?; - let result = project_skill_snapshot(&plan, snapshot, &runtime)?; - enrich_from_context_lock( - result, + Ok((resolved, plan)) + } + }) + .await?; + let workspace = environment_registry.workspace(&distro_name)?; + let inspector = Arc::new( + crate::environment::wsl::operations::inspection::WslInspector::new( + workspace.clone(), + ), + ); + let read_service = ReadService::new(vec![inspector.clone()]); + let snapshot = read_service.execute(&plan.read_plan).await?; + let retry_resolved = resolved.clone(); + let (lock_bytes, lock_kind) = environment_registry + .with_session_read_retry(&distro_name, move |session| { + let resolved = retry_resolved.clone(); + let workspace = workspace.clone(); + async move { + read_context_lock( &resolved, - EnvironmentLockIo::ActiveWsl(session), + EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }, ) .await } }) - .await + .await?; + if lock_kind == LockKind::Project { + plan.set_project_lock(lock_bytes.as_deref()); + } + let mut result = project_direct_skill_snapshot( + &plan, + snapshot, + &runtime, + libraries, + targets, + inspector.as_ref(), + ) + .await?; + result.path_base = Some(resolved.path_base(targets).await); + enrich_from_lock_bytes(result, &resolved, lock_bytes, lock_kind) } } } -async fn enrich_from_context_lock( - mut result: ListSkillsResult, +async fn read_context_lock( context: &ResolvedContext, lock_io: EnvironmentLockIo, -) -> Result { +) -> Result<(Option>, LockKind), AppError> { let mut lock_bytes = lock_io.read_optional(&context.lock).await.ok().flatten(); let mut lock_kind = if context.project.is_some() { LockKind::Project @@ -408,6 +505,15 @@ async fn enrich_from_context_lock( } } } + Ok((lock_bytes, lock_kind)) +} + +fn enrich_from_lock_bytes( + mut result: ListSkillsResult, + context: &ResolvedContext, + lock_bytes: Option>, + lock_kind: LockKind, +) -> Result { result.skills = enrich_environment_skills_from_lock_at( result.skills, lock_bytes.as_deref(), diff --git a/src-tauri/src/application/source_acquisition.rs b/src-tauri/src/application/source_acquisition.rs index a4481fd9..a33042ab 100644 --- a/src-tauri/src/application/source_acquisition.rs +++ b/src-tauri/src/application/source_acquisition.rs @@ -13,7 +13,7 @@ use crate::application::git_transport::GitSourceTransport; use crate::application::payload_session::{ AcquiredPayloadHandle, DiscoverySessionHandle, DiscoverySkillSnapshot, DiscoverySourceDescriptor, DiscoverySourceLocation, PayloadPlanningMetadata, - PayloadSessionManager, PayloadSessionStorage, PayloadStorageKey, RetainedDiscoverySource, + PayloadSessionManager, PayloadSessionStorage, RetainedDiscoverySource, StoredPayload, }; use crate::application::source_clone_gate::shared_source_clone_gate; use crate::application::wellknown_access::{ @@ -123,8 +123,10 @@ impl InternalSkillVisibility { #[derive(Clone)] pub(crate) struct SourceDiscoveryPolicy { + pub(crate) allow_empty_catalog: bool, pub(crate) full_depth: bool, pub(crate) internal_skill_visibility: InternalSkillVisibility, + pub(crate) selected_skill_names: Option>, } #[derive(Clone)] @@ -199,6 +201,7 @@ impl GitSourceDiscovery { root, cloned, RetainedSourceOptions { + allow_empty_catalog: policy.allow_empty_catalog, full_depth: policy.full_depth, internal_skill_visibility: policy.internal_skill_visibility, ..Default::default() @@ -232,7 +235,9 @@ where let well_known_error = well_known_failure.into_error(); if matches!( well_known_error, - AppError::MutationCancelled | AppError::WellKnownScopeNotFound { .. } + AppError::MutationCancelled + | AppError::WellKnownScopeNotFound { .. } + | AppError::InvalidProxySettings { .. } ) || !allows_direct_download { return Err(well_known_error); @@ -240,7 +245,9 @@ where let well_known_reason = source_acquisition_failure_reason(&well_known_error); match download().await { Ok(result) => Ok(result), - Err(AppError::MutationCancelled) => Err(AppError::MutationCancelled), + Err(error @ (AppError::MutationCancelled | AppError::InvalidProxySettings { .. })) => { + Err(error) + } Err(download_error) => { log::warn!( "{environment_label} Well-known and direct download acquisition failed: well-known={well_known_error}; download={download_error}" @@ -303,9 +310,12 @@ pub(crate) fn source_acquisition_failure_reason( #[derive(Default)] pub(crate) struct RetainedSourceOptions { + pub(crate) allow_empty_catalog: bool, pub(crate) storage: Option>, pub(crate) trust_metadata: Option>, pub(crate) redirected_download_host: Option, + pub(crate) redirected_download_hosts: Vec, + pub(crate) member_failures: BTreeMap, pub(crate) full_depth: bool, pub(crate) internal_skill_visibility: InternalSkillVisibility, } @@ -325,27 +335,38 @@ where O: Send + Sync + 'static, { let RetainedSourceOptions { + allow_empty_catalog, storage, trust_metadata, redirected_download_host, + redirected_download_hosts, + member_failures, full_depth, internal_skill_visibility, } = options; let scan_root = native_root.clone(); let scan_subpath = parsed.subpath.clone(); - let (discovered, catalog) = tokio::task::spawn_blocking(move || { - build_discovery_catalog( + let managed_source = parsed.source_type != SourceType::Local; + let cleanup_root = native_root.clone(); + let (discovered, catalog, managed_bytes) = tokio::task::spawn_blocking(move || { + let (discovered, catalog) = build_discovery_catalog( &scan_root, scan_subpath.as_deref(), &internal_skill_visibility, full_depth, - ) + )?; + let bytes = if managed_source { + environment_engine::directory::size_no_follow(&scan_root)? + } else { + 0 + }; + Ok::<_, AppError>((discovered, catalog, bytes)) }) .await .map_err(|error| AppError::ExecutionFailed { message: format!("native source discovery task failed: {error}"), })??; - if discovered.is_empty() { + if discovered.is_empty() && !allow_empty_catalog { return Err(AppError::NoSkillsFound); } let descriptor = DiscoverySourceDescriptor { @@ -356,8 +377,13 @@ where redirected_download_host: redirected_download_host.clone(), }; let source_fingerprint = snapshot_fingerprint(&descriptor, &catalog); - let retained = RetainedDiscoverySource::new(location, descriptor, catalog, owner) - .with_well_known_metadata(trust_metadata.clone().unwrap_or_default()); + let mut retained = RetainedDiscoverySource::new(location, descriptor, catalog, owner) + .with_well_known_metadata(trust_metadata.clone().unwrap_or_default()) + .with_source_outcomes(member_failures, redirected_download_hosts); + if managed_source { + retained = retained + .with_managed_storage(managed_bytes, Arc::new(NativeSourceCleanup(cleanup_root))); + } let discovery_session = match storage { Some(storage) => { sessions @@ -516,6 +542,27 @@ impl ManagedDownloadedDirectory { struct ManagedDownloadedDirectoryOwner(PathBuf); +struct NativeSourceCleanup(PathBuf); + +impl crate::application::payload_session::RetainedSourceCleanup for NativeSourceCleanup { + fn remove( + &self, + ) -> crate::application::payload_session::PayloadStorageFuture<'_, Result<(), AppError>> { + let root = self.0.clone(); + Box::pin(async move { + tokio::task::spawn_blocking(move || match fs::remove_dir_all(root) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + }) + .await + .map_err(|error| AppError::ExecutionFailed { + message: error.to_string(), + })? + }) + } +} + impl Drop for ManagedDownloadedDirectoryOwner { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); @@ -526,13 +573,7 @@ pub struct SelectedPayloadAcquisitionService { sessions: Arc, } -enum PreparedNativeSelection { - Existing(AcquiredPayloadHandle), - Acquired(Box), -} - struct PreparedNativePayload { - relative_path: String, payload: crate::core::skill_payload::SkillPayload, metadata: PayloadPlanningMetadata, } @@ -542,6 +583,126 @@ impl SelectedPayloadAcquisitionService { Self { sessions } } + pub(crate) async fn ensure_saved_path( + &self, + discovery: &DiscoverySessionHandle, + path: &str, + cancellation: CancellationSignal, + ) -> Result<(), AppError> { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let source = self.sessions.source_snapshot(discovery)?; + let folder = normalize_skill_folder_path(path); + let already_catalogued = source + .skills() + .any(|skill| normalize_skill_folder_path(&skill.relative_path) == folder); + let relative_path = if folder.is_empty() { + "SKILL.md".to_string() + } else { + format!("{folder}/SKILL.md") + }; + validate_selected_paths(std::slice::from_ref(&relative_path))?; + let metadata = match source.location() { + DiscoverySourceLocation::Native { root, .. } => { + let root = root.clone(); + let path = relative_path.clone(); + tokio::task::spawn_blocking(move || { + let root = fs::canonicalize(root)?; + let relative = normalize_skill_folder_path(&path); + let directory = environment_engine::directory::plain_descendant_directory( + &root, + Path::new(&relative), + ) + .map_err(|error| match error.kind() { + std::io::ErrorKind::Unsupported => AppError::CapabilityUnavailable { + capability: "sourceDirectoryLinks".into(), + path: Some(root.join(&relative).to_string_lossy().into_owned()), + }, + std::io::ErrorKind::NotFound => AppError::PathNotFound { + path: root.join(&relative).to_string_lossy().into_owned(), + }, + _ => error.into(), + })?; + if already_catalogued { + return Ok(None); + } + let mut entries = fs::read_dir(&directory)?.collect::, _>>()?; + entries + .sort_by_key(|entry| (entry.file_name() != "SKILL.md", entry.file_name())); + let file = entries + .into_iter() + .find(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) + }) + .ok_or_else(|| AppError::PathNotFound { + path: directory.join("SKILL.md").to_string_lossy().into_owned(), + })?; + let physical = fs::canonicalize(file.path())?; + if !physical.starts_with(&directory) + || fs::metadata(&physical)?.len() > 256 * 1024 + { + return Err(AppError::StalePayload); + } + Ok::<_, AppError>(Some(( + fs::read(&physical)?, + compute_source_metadata_fingerprint(&directory)?, + ))) + }) + .await + .map_err(|error| AppError::ExecutionFailed { + message: error.to_string(), + })?? + } + DiscoverySourceLocation::WslNative { linux_root, .. } => { + let directory = if folder.is_empty() { + linux_root.clone() + } else { + format!("{}/{folder}", linux_root.trim_end_matches('/')) + }; + let storage = self.sessions.storage_for_discovery(discovery)?; + let content = storage + .read_source_skill_md(&directory, cancellation.clone()) + .await?; + if already_catalogued { + None + } else { + Some(( + content, + storage.source_metadata_fingerprint(&directory).await?, + )) + } + } + }; + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let Some((content, fingerprint)) = metadata else { + return Ok(()); + }; + let content = std::str::from_utf8(&content).map_err(|error| AppError::InvalidSkillMd { + message: error.to_string(), + })?; + let frontmatter = crate::core::skill::parse_skill_md_content(content)?; + let install_dir_name = + crate::application::installed_skill_resolver::InstalledSkillResolver::install_dir_name( + &frontmatter.name, + )?; + self.sessions.register_saved_source_skill( + discovery, + DiscoverySkillSnapshot { + skill_name: frontmatter.name, + install_dir_name, + relative_path, + plugin_name: None, + source_metadata_fingerprint: fingerprint, + }, + ) + } + pub async fn acquire( &self, request: AcquireSelectedPayloadsRequest, @@ -552,52 +713,57 @@ impl SelectedPayloadAcquisitionService { match source.location() { DiscoverySourceLocation::Native { root, .. } => { - let mut existing = BTreeMap::new(); for skill_path in &request.skill_paths { - let skill = source.skill(skill_path).ok_or(AppError::StalePayload)?; - if let Some(handle) = self + let skill = source + .skill(skill_path) + .ok_or(AppError::StalePayload)? + .clone(); + let root = root.clone(); + if let Some(existing) = self .sessions .existing_payload_handle(&request.discovery_session, &skill.relative_path)? { - existing.insert(skill.relative_path.clone(), handle); + let source_owner = source.clone(); + tokio::task::spawn_blocking(move || { + let _source = source_owner; + verified_native_skill_root(&root, &skill).map(|_| ()) + }) + .await + .map_err(|error| { + AppError::ExecutionFailed { + message: format!("native source verification task failed: {error}"), + } + })??; + handles.push(existing); + continue; } - } - let selected_paths = request.skill_paths.clone(); - let source_for_task = source.clone(); - let root_for_task = root.clone(); - let prepared = tokio::task::spawn_blocking(move || { - prepare_native_selections( - &source_for_task, - &root_for_task, - &selected_paths, - &existing, - ) - }) - .await - .map_err(|error| AppError::ExecutionFailed { - message: format!("native selected payload task failed: {error}"), - })??; - for prepared in prepared { - match prepared { - PreparedNativeSelection::Existing(handle) => handles.push(handle), - PreparedNativeSelection::Acquired(prepared) => { - let PreparedNativePayload { - relative_path, - payload, - metadata, - } = *prepared; - handles.push( - self.sessions - .acquire_payload_with_metadata( - &request.discovery_session, - relative_path, - payload, - metadata, - ) - .await?, + let source = source.clone(); + handles.push( + self.sessions + .prepare_payload( + &request.discovery_session, + skill.relative_path.clone(), + move |storage, key| async move { + let prepared = tokio::task::spawn_blocking(move || { + prepare_native_payload(&source, &root, &skill) + }) + .await + .map_err(|error| AppError::ExecutionFailed { + message: format!( + "native selected payload task failed: {error}" + ), + })??; + let manifest = prepared.payload.manifest(); + let total_bytes = storage.store(&key, prepared.payload).await?; + Ok(StoredPayload { + manifest, + total_bytes, + planning_metadata: prepared.metadata, + }) + }, ) - } - } + .await?, + ); } } DiscoverySourceLocation::WslNative { @@ -616,120 +782,128 @@ impl SelectedPayloadAcquisitionService { { return Err(AppError::StaleEnvironment); } - let storage = self - .sessions - .storage_for_discovery(&request.discovery_session)?; for skill_path in &request.skill_paths { - let skill = source.skill(skill_path).ok_or(AppError::StalePayload)?; - let key = PayloadStorageKey::new( - &request.discovery_session.session_id, - &skill.relative_path, - ); + let skill = source + .skill(skill_path) + .ok_or(AppError::StalePayload)? + .clone(); let linux_skill_root = format!( "{}/{}", linux_root.trim_end_matches('/'), - normalize_skill_folder_path(&skill.relative_path) + normalize_skill_folder_path(&skill.relative_path), ); - if storage - .source_metadata_fingerprint(&linux_skill_root) - .await? - != skill.source_metadata_fingerprint - { - return Err(AppError::StalePayload); - } if let Some(existing) = self .sessions .existing_payload_handle(&request.discovery_session, &skill.relative_path)? { + let storage = self + .sessions + .storage_for_discovery(&request.discovery_session)?; + if storage + .source_metadata_fingerprint(&linux_skill_root) + .await? + != skill.source_metadata_fingerprint + { + return Err(AppError::StalePayload); + } handles.push(existing); continue; } - let upstream_revision = - if requires_git_tree_revision(&source.descriptor().source_type) { - storage - .source_upstream_revision(linux_root, &skill.relative_path) - .await? - } else { - None - }; - let acquired = storage - .acquire_from_source_path( - &key, - &linux_skill_root, - Some(CancellationSignal::default()), - ) - .await?; - if storage - .source_metadata_fingerprint(&linux_skill_root) - .await? - != skill.source_metadata_fingerprint - { - let _ = storage.remove(&key).await; - return Err(AppError::StalePayload); - } - let metadata = planning_metadata( - source.descriptor(), - skill, - acquired.computed_hash, - upstream_revision, - source.well_known_metadata(&skill.skill_name), - ); + let source = source.clone(); + let linux_root = linux_root.clone(); handles.push( self.sessions - .register_existing_payload_with_metadata( + .prepare_payload( &request.discovery_session, skill.relative_path.clone(), - acquired.manifest, - acquired.total_bytes, - metadata, + move |storage, key| async move { + if storage + .source_metadata_fingerprint(&linux_skill_root) + .await? + != skill.source_metadata_fingerprint + { + return Err(AppError::StalePayload); + } + let upstream_revision = if requires_git_tree_revision( + &source.descriptor().source_type, + ) { + storage + .source_upstream_revision( + &linux_root, + &skill.relative_path, + ) + .await? + } else { + None + }; + let acquired = storage + .acquire_from_source_path( + &key, + &linux_skill_root, + Some(CancellationSignal::default()), + ) + .await?; + if storage + .source_metadata_fingerprint(&linux_skill_root) + .await? + != skill.source_metadata_fingerprint + { + return Err(AppError::StalePayload); + } + let metadata = planning_metadata( + source.descriptor(), + &skill, + acquired.computed_hash, + upstream_revision, + source.well_known_metadata(&skill.skill_name), + ); + metadata.validate()?; + Ok(StoredPayload { + manifest: acquired.manifest, + total_bytes: acquired.total_bytes, + planning_metadata: metadata, + }) + }, ) .await?, ); } } } - Ok(handles) } } -fn prepare_native_selections( - source: &RetainedDiscoverySource, +fn verified_native_skill_root( root: &Path, - selected_paths: &[String], - existing: &BTreeMap, -) -> Result, AppError> { + skill: &DiscoverySkillSnapshot, +) -> Result<(PathBuf, PathBuf), AppError> { let physical_root = fs::canonicalize(root)?; - let mut prepared = Vec::with_capacity(selected_paths.len()); - for skill_path in selected_paths { - let skill = source.skill(skill_path).ok_or(AppError::StalePayload)?; - let source_root = resolve_skill_root(&physical_root, &skill.relative_path)?; - verify_source_fingerprint(&source_root, skill)?; - if let Some(handle) = existing.get(&skill.relative_path) { - prepared.push(PreparedNativeSelection::Existing(handle.clone())); - continue; - } - let payload = build_skill_payload(&source_root)?; - verify_source_fingerprint(&source_root, skill)?; - let upstream_revision = requires_git_tree_revision(&source.descriptor().source_type) - .then(|| compute_local_tree_sha(&physical_root, &skill.relative_path)) - .flatten(); - let metadata = planning_metadata( - source.descriptor(), - skill, - compute_cli_project_hash_from_payload(&payload)?, - upstream_revision, - source.well_known_metadata(&skill.skill_name), - ); - prepared.push(PreparedNativeSelection::Acquired(Box::new( - PreparedNativePayload { - relative_path: skill.relative_path.clone(), - payload, - metadata, - }, - ))); - } - Ok(prepared) + let source_root = resolve_skill_root(&physical_root, &skill.relative_path)?; + verify_source_fingerprint(&source_root, skill)?; + Ok((physical_root, source_root)) +} + +fn prepare_native_payload( + source: &RetainedDiscoverySource, + root: &Path, + skill: &DiscoverySkillSnapshot, +) -> Result { + let (physical_root, source_root) = verified_native_skill_root(root, skill)?; + let payload = build_skill_payload(&source_root)?; + verify_source_fingerprint(&source_root, skill)?; + let upstream_revision = requires_git_tree_revision(&source.descriptor().source_type) + .then(|| compute_local_tree_sha(&physical_root, &skill.relative_path)) + .flatten(); + let metadata = planning_metadata( + source.descriptor(), + skill, + compute_cli_project_hash_from_payload(&payload)?, + upstream_revision, + source.well_known_metadata(&skill.skill_name), + ); + metadata.validate()?; + Ok(PreparedNativePayload { payload, metadata }) } fn planning_metadata( @@ -918,6 +1092,33 @@ fn executable_mode(_metadata: &fs::Metadata) -> u32 { #[cfg(test)] mod tests { + #[tokio::test] + async fn proxy_configuration_failure_does_not_attempt_a_download_fallback() { + let attempted = std::cell::Cell::new(false); + let result = super::attempt_wellknown_then_download( + async { + Err::<(), _>( + crate::application::wellknown_access::WellKnownFetchError::unproven( + crate::error::AppError::InvalidProxySettings { + code: "configurationUnavailable".into(), + }, + ), + ) + }, + || async { + attempted.set(true); + Ok(()) + }, + "Native", + ) + .await; + assert!(matches!( + result, + Err(crate::error::AppError::InvalidProxySettings { .. }) + )); + assert!(!attempted.get()); + } + use std::collections::{BTreeMap, HashMap}; use std::fs; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -942,6 +1143,164 @@ mod tests { use crate::environment::wsl::operations::source_acquisition::WslAcquisitionSource; use crate::models::SourceType; use crate::runtime::source_acquisition::SourceDiscoveryService; + + #[cfg(any(unix, windows))] + #[tokio::test] + async fn saved_paths_reject_directory_links_even_when_already_catalogued() { + let temp = tempdir().unwrap(); + let root = temp.path().join("source"); + let outside = temp.path().join("outside"); + for parent in [&root, &outside] { + fs::create_dir_all(parent.join("real/demo")).unwrap(); + fs::write( + parent.join("real/demo/SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\n", + ) + .unwrap(); + } + for (name, destination) in [ + ("alias", root.join("real")), + ("escape", outside.join("real")), + ("leaf", root.join("real/demo")), + ] { + #[cfg(unix)] + std::os::unix::fs::symlink(&destination, root.join(name)).unwrap(); + #[cfg(windows)] + junction::create(&destination, root.join(name)).unwrap(); + } + let manager = Arc::new(PayloadSessionManager::in_memory( + PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 1_000_000, + }, + || 1_000, + )); + let discovery = manager + .discover_with_retained_source( + EnvironmentRef::Native, + "source", + RetainedDiscoverySource::new( + DiscoverySourceLocation::Native { + root, + ref_revision: None, + }, + DiscoverySourceDescriptor { + source: "repo".into(), + source_type: "git".into(), + source_url: None, + ref_name: None, + redirected_download_host: None, + }, + BTreeMap::from([( + "alias/demo/SKILL.md".into(), + DiscoverySkillSnapshot { + skill_name: "demo".into(), + install_dir_name: "demo".into(), + relative_path: "alias/demo/SKILL.md".into(), + plugin_name: None, + source_metadata_fingerprint: "observed".into(), + }, + )]), + (), + ), + ) + .await + .unwrap(); + let selected = SelectedPayloadAcquisitionService::new(manager); + for path in ["alias/demo", "escape/demo", "leaf"] { + let result = selected + .ensure_saved_path(&discovery, path, CancellationSignal::default()) + .await; + assert!( + matches!(result, Err(AppError::CapabilityUnavailable { ref capability, .. }) if capability == "sourceDirectoryLinks"), + "{path}: {result:?}" + ); + } + selected + .ensure_saved_path(&discovery, "real/demo", CancellationSignal::default()) + .await + .unwrap(); + } + + #[tokio::test] + async fn saved_path_is_acquired_beyond_default_discovery_depth() { + let source = tempdir().unwrap(); + let folder = "one/two/three/four/five/six/seven/deep"; + fs::create_dir_all(source.path().join(folder)).unwrap(); + fs::write( + source.path().join(folder).join("skill.md"), + b"---\nname: deep\ndescription: Deep Skill\n---\ncontent", + ) + .unwrap(); + let manager = Arc::new(PayloadSessionManager::in_memory( + PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 1_000_000, + }, + || 1_000, + )); + let discovery = manager + .discover_with_retained_source( + EnvironmentRef::Native, + "immutable-source", + RetainedDiscoverySource::new( + DiscoverySourceLocation::Native { + root: source.path().to_path_buf(), + ref_revision: Some("commit".into()), + }, + DiscoverySourceDescriptor { + source: "https://example.com/tools.git".into(), + source_type: "git".into(), + source_url: Some("https://example.com/tools.git".into()), + ref_name: None, + redirected_download_host: None, + }, + BTreeMap::new(), + (), + ), + ) + .await + .unwrap(); + let acquisition = SelectedPayloadAcquisitionService::new(manager.clone()); + acquisition + .ensure_saved_path(&discovery, folder, CancellationSignal::default()) + .await + .unwrap(); + let handles = acquisition + .acquire(AcquireSelectedPayloadsRequest { + discovery_session: discovery.clone(), + skill_paths: vec![format!("{folder}/SKILL.md")], + }) + .await + .unwrap(); + assert_eq!( + manager + .pin_verified(&handles[0]) + .await + .unwrap() + .planning_metadata() + .skill_name, + "deep" + ); + assert!(matches!( + acquisition + .ensure_saved_path(&discovery, "absent", CancellationSignal::default()) + .await, + Err(AppError::PathNotFound { .. }) + )); + fs::write(source.path().join(folder).join("skill.md"), b"malformed").unwrap(); + assert!(matches!( + acquisition + .acquire(AcquireSelectedPayloadsRequest { + discovery_session: discovery, + skill_paths: vec![format!("{folder}/SKILL.md")] + }) + .await, + Err(AppError::StalePayload) + )); + } use crate::runtime::wsl_source::{build_wsl_discovery_catalog, wsl_acquisition_source}; fn valid_skill(name: &str) -> Vec { @@ -1192,6 +1551,17 @@ mod tests { acquisitions: AtomicUsize, stores: AtomicUsize, upstream_revision: Option, + acquisition_pause: Option>, + source_paths: HashMap, + } + + impl SourceAcquiringStorage { + fn native_source_path<'a>(&'a self, source_root: &'a str) -> &'a Path { + self.source_paths + .get(source_root) + .map(PathBuf::as_path) + .unwrap_or_else(|| Path::new(source_root)) + } } impl PayloadSessionStorage for SourceAcquiringStorage { @@ -1203,7 +1573,10 @@ mod tests { ) -> PayloadStorageFuture<'a, Result> { Box::pin(async move { self.acquisitions.fetch_add(1, Ordering::SeqCst); - let payload = build_skill_payload(Path::new(source_root))?; + if let Some(pause) = &self.acquisition_pause { + pause.pause().await; + } + let payload = build_skill_payload(self.native_source_path(source_root))?; let acquired = BackendAcquiredPayload { manifest: payload.manifest(), total_bytes: payload.blobs.values().map(|blob| blob.len() as u64).sum(), @@ -1237,7 +1610,9 @@ mod tests { &'a self, source_root: &'a str, ) -> PayloadStorageFuture<'a, Result> { - Box::pin(async move { compute_source_metadata_fingerprint(Path::new(source_root)) }) + Box::pin(async move { + compute_source_metadata_fingerprint(self.native_source_path(source_root)) + }) } fn source_upstream_revision<'a>( @@ -1963,6 +2338,110 @@ mod tests { )); } + #[tokio::test] + async fn wsl_selected_preparation_is_shared_and_survives_an_abandoned_caller() { + use crate::payload_storage_test_support::PayloadIoPause; + use std::sync::atomic::AtomicU64; + + let source = tempdir().unwrap(); + let skill_root = source.path().join("skills").join("demo"); + fs::create_dir_all(&skill_root).unwrap(); + fs::write(skill_root.join("SKILL.md"), b"skill").unwrap(); + let fingerprint = compute_source_metadata_fingerprint(&skill_root).unwrap(); + let pause = Arc::new(PayloadIoPause::default()); + let storage = Arc::new(SourceAcquiringStorage { + acquisition_pause: Some(pause.clone()), + source_paths: HashMap::from([("/source/skills/demo".to_string(), skill_root.clone())]), + ..Default::default() + }); + let now = Arc::new(AtomicU64::new(1_000)); + let manager = Arc::new(PayloadSessionManager::new( + Arc::new(InMemoryPayloadSessionStorage::default()), + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 1, + max_bytes: 1024 * 1024, + }, + { + let now = now.clone(); + move || now.load(Ordering::SeqCst) + }, + )); + let discovery = manager + .discover_with_source( + EnvironmentRef::Wsl { + distro_name: "Ubuntu".to_string(), + }, + "source-v1", + storage.clone(), + RetainedDiscoverySource::new( + DiscoverySourceLocation::WslNative { + distro_name: "Ubuntu".to_string(), + linux_root: "/source".to_string(), + ref_revision: None, + }, + DiscoverySourceDescriptor { + source: "owner/repo".to_string(), + source_type: "git".to_string(), + source_url: None, + ref_name: None, + redirected_download_host: None, + }, + BTreeMap::from([( + "skills/demo".to_string(), + DiscoverySkillSnapshot { + skill_name: "demo".to_string(), + install_dir_name: "demo".to_string(), + relative_path: "skills/demo".to_string(), + plugin_name: None, + source_metadata_fingerprint: fingerprint, + }, + )]), + source, + ), + ) + .await + .unwrap(); + let request = AcquireSelectedPayloadsRequest { + discovery_session: discovery, + skill_paths: vec!["skills/demo".to_string()], + }; + let first = tokio::spawn({ + let service = SelectedPayloadAcquisitionService::new(manager.clone()); + let request = request.clone(); + async move { service.acquire(request).await } + }); + pause.wait_until_started().await; + let service = SelectedPayloadAcquisitionService::new(manager.clone()); + let second = service.acquire(request); + tokio::pin!(second); + assert!(futures_util::poll!(&mut second).is_pending()); + assert_eq!(storage.acquisitions.load(Ordering::SeqCst), 1); + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + pause.resume(); + + let handles = tokio::time::timeout(std::time::Duration::from_secs(5), second) + .await + .unwrap() + .unwrap(); + let lease = manager.pin_verified(&handles[0]).await.unwrap(); + assert!(lease + .load_payload() + .await + .unwrap() + .blobs + .values() + .any(|bytes| bytes == b"skill")); + drop(lease); + now.store(1_101, Ordering::SeqCst); + assert_eq!(manager.cleanup().await.unwrap(), 1); + assert!( + !skill_root.exists(), + "source owner is released after completed I/O" + ); + } + #[tokio::test] async fn wsl_direct_download_bridges_the_materialized_payload_into_wsl_storage() { let source = tempdir().expect("download source"); diff --git a/src-tauri/src/application/source_evidence.rs b/src-tauri/src/application/source_evidence.rs index e0cd2178..e5b39525 100644 --- a/src-tauri/src/application/source_evidence.rs +++ b/src-tauri/src/application/source_evidence.rs @@ -8,9 +8,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; use tokio::sync::{watch, Semaphore}; -use crate::application::payload_session::DiscoverySessionHandle; use crate::application::source_evidence_state::SourceEvidenceStateFile; -use crate::application::source_snapshot_reuse::{PayloadAcquisitionKey, SourceSnapshotReuseIndex}; use crate::core::mutation::CancellationSignal; use crate::core::source_identity::{ AcquisitionDescriptor, AcquisitionTransportIdentity, NormalizedRef, RemoteSourceIdentity, @@ -32,7 +30,7 @@ const TRANSIENT_BACKOFF_DELAYS_MS: [u64; 6] = [ const PROVIDER_COOLDOWN_FALLBACK_MS: u64 = 5 * 60_000; const DIAGNOSTIC_RETENTION_MS: u64 = 7 * 24 * 60 * 60 * 1_000; const SOURCE_RETENTION_MS: u64 = 30 * 24 * 60 * 60 * 1_000; -const PERSISTED_STATE_SCHEMA_VERSION: u32 = 1; +const PERSISTED_STATE_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -45,6 +43,7 @@ pub enum SourceSuppressionWarningCode { pub struct RemoteEvidenceKey { pub remote: RemoteSourceIdentity, pub normalized_ref: NormalizedRef, + content_environment: Option, } impl RemoteEvidenceKey { @@ -53,6 +52,7 @@ impl RemoteEvidenceKey { Self { remote, normalized_ref, + content_environment: None, } } @@ -60,8 +60,18 @@ impl RemoteEvidenceKey { Self { remote: identity.remote().clone(), normalized_ref: identity.normalized_ref().clone(), + content_environment: None, } } + + fn in_environment(&self, environment: &EnvironmentKey) -> Self { + let mut key = self.clone(); + key.content_environment = match self.remote.provider() { + SourceProvider::Git | SourceProvider::Gitlab => Some(environment.clone()), + SourceProvider::Github | SourceProvider::WellKnown => None, + }; + key + } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -87,7 +97,7 @@ impl ProviderThrottleKey { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum SkillRevision { GitTreeOid(String), CliContentHash(String), @@ -115,35 +125,14 @@ impl RemoteSnapshotId { } } -#[derive(Debug, Clone)] -pub struct SourceSnapshotFacts { - pub discovery_session: DiscoverySessionHandle, - pub snapshot_id: RemoteSnapshotId, - pub complete_skill_path_catalog: BTreeSet, -} - -impl PartialEq for SourceSnapshotFacts { - fn eq(&self, other: &Self) -> bool { - self.discovery_session.session_id == other.discovery_session.session_id - && self.discovery_session.environment == other.discovery_session.environment - && self.discovery_session.source_fingerprint - == other.discovery_session.source_fingerprint - && self.discovery_session.expires_at_epoch_ms - == other.discovery_session.expires_at_epoch_ms - && self.snapshot_id == other.snapshot_id - && self.complete_skill_path_catalog == other.complete_skill_path_catalog - } -} - -impl Eq for SourceSnapshotFacts {} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteEvidenceObservation { pub snapshot_id: RemoteSnapshotId, pub provider_validation: Option, pub complete_skill_path_catalog: BTreeSet, + pub catalog_complete: bool, + pub member_failures: BTreeMap, pub skill_revisions: BTreeMap, - pub snapshot_facts: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -153,6 +142,8 @@ pub struct RemoteEvidenceEntry { pub snapshot_id: RemoteSnapshotId, pub provider_validation: Option, pub complete_skill_path_catalog: BTreeSet, + pub catalog_complete: bool, + pub member_failures: BTreeMap, pub skill_revisions: BTreeMap, } @@ -202,10 +193,6 @@ impl EvidenceDetectionFailure { } #[derive(Debug, Clone, PartialEq, Eq)] -#[expect( - clippy::large_enum_variant, - reason = "successful evidence carries the complete typed snapshot used by the coordinator" -)] pub enum EvidenceDetectionOutcome { Modified(RemoteEvidenceObservation), NotModified, @@ -286,7 +273,6 @@ pub struct SourceEvidenceCoordinator { struct SourceEvidenceCoordinatorInner { detector: Arc, detector_permits: Arc, - snapshots: Option>, now: Arc u64 + Send + Sync>, state: Mutex, state_file: Option, @@ -316,16 +302,13 @@ struct EnvironmentEvidenceKey { impl EnvironmentEvidenceKey { fn new(environment: &EnvironmentRef, evidence: &RemoteEvidenceKey) -> Self { - Self { - environment: EnvironmentKey::from_ref(environment), - evidence: evidence.clone(), - } + Self::from_environment_key(EnvironmentKey::from_ref(environment), evidence.clone()) } fn from_environment_key(environment: EnvironmentKey, evidence: RemoteEvidenceKey) -> Self { Self { + evidence: evidence.in_environment(&environment), environment, - evidence, } } } @@ -441,6 +424,8 @@ struct PersistedRemoteEvidenceKey { authority: String, repository: String, normalized_ref: PersistedNormalizedRef, + #[serde(default)] + content_environment: Option, } impl From<&RemoteEvidenceKey> for PersistedRemoteEvidenceKey { @@ -450,6 +435,7 @@ impl From<&RemoteEvidenceKey> for PersistedRemoteEvidenceKey { authority: value.remote.authority().to_string(), repository: value.remote.repository().to_string(), normalized_ref: (&value.normalized_ref).into(), + content_environment: value.content_environment.as_ref().map(Into::into), } } } @@ -463,6 +449,7 @@ impl PersistedRemoteEvidenceKey { self.repository, )?, normalized_ref: self.normalized_ref.into(), + content_environment: self.content_environment.map(Into::into), }) } } @@ -514,10 +501,10 @@ impl From<&EnvironmentEvidenceKey> for PersistedEnvironmentEvidenceKey { impl PersistedEnvironmentEvidenceKey { fn into_runtime(self) -> Result { - Ok(EnvironmentEvidenceKey { - environment: self.environment.into(), - evidence: self.evidence.into_runtime()?, - }) + Ok(EnvironmentEvidenceKey::from_environment_key( + self.environment.into(), + self.evidence.into_runtime()?, + )) } } @@ -590,6 +577,8 @@ struct PersistedRemoteEvidenceEntry { snapshot_id: PersistedRemoteSnapshotId, provider_validation: Option, complete_skill_path_catalog: BTreeSet, + catalog_complete: bool, + member_failures: BTreeMap, skill_revisions: BTreeMap, } @@ -604,6 +593,8 @@ impl From<&RemoteEvidenceEntry> for PersistedRemoteEvidenceEntry { commit_revision: value.snapshot_id.commit_revision.clone(), }, provider_validation: value.provider_validation.clone(), + catalog_complete: value.catalog_complete, + member_failures: value.member_failures.clone(), complete_skill_path_catalog: value.complete_skill_path_catalog.clone(), skill_revisions: value .skill_revisions @@ -625,6 +616,8 @@ impl From for RemoteEvidenceEntry { commit_revision: value.snapshot_id.commit_revision, }, provider_validation: value.provider_validation, + catalog_complete: value.catalog_complete, + member_failures: value.member_failures, complete_skill_path_catalog: value.complete_skill_path_catalog, skill_revisions: value .skill_revisions @@ -915,21 +908,17 @@ impl Drop for DetectionWaiter { impl SourceEvidenceCoordinator { #[cfg(test)] - pub fn with_snapshot_reuse( - detector: Arc, - snapshots: Arc, - ) -> Self { - Self::build(detector, Some(snapshots), || { + pub fn new(detector: Arc) -> Self { + Self::build(detector, || { chrono::Utc::now().timestamp_millis().max(0) as u64 }) } - pub fn with_snapshot_reuse_and_state_path( + pub fn with_state_path( detector: Arc, - snapshots: Arc, state_path: PathBuf, ) -> Result { - Self::build_with_state_path(detector, Some(snapshots), state_path, || { + Self::build_with_state_path(detector, state_path, || { chrono::Utc::now().timestamp_millis().max(0) as u64 }) } @@ -939,16 +928,7 @@ impl SourceEvidenceCoordinator { detector: Arc, now: impl Fn() -> u64 + Send + Sync + 'static, ) -> Self { - Self::build(detector, None, now) - } - - #[cfg(test)] - fn with_clock_and_snapshots( - detector: Arc, - snapshots: Arc, - now: impl Fn() -> u64 + Send + Sync + 'static, - ) -> Self { - Self::build(detector, Some(snapshots), now) + Self::build(detector, now) } #[cfg(test)] @@ -957,20 +937,18 @@ impl SourceEvidenceCoordinator { state_path: PathBuf, now: impl Fn() -> u64 + Send + Sync + 'static, ) -> Result { - Self::build_with_state_path(detector, None, state_path, now) + Self::build_with_state_path(detector, state_path, now) } #[cfg(test)] fn build( detector: Arc, - snapshots: Option>, now: impl Fn() -> u64 + Send + Sync + 'static, ) -> Self { Self { inner: Arc::new(SourceEvidenceCoordinatorInner { detector, detector_permits: Arc::new(Semaphore::new(DETECTOR_CONCURRENCY_LIMIT)), - snapshots, now: Arc::new(now), state: Mutex::new(CoordinatorState::default()), state_file: None, @@ -981,7 +959,6 @@ impl SourceEvidenceCoordinator { fn build_with_state_path( detector: Arc, - snapshots: Option>, state_path: PathBuf, now: impl Fn() -> u64 + Send + Sync + 'static, ) -> Result { @@ -992,7 +969,6 @@ impl SourceEvidenceCoordinator { inner: Arc::new(SourceEvidenceCoordinatorInner { detector, detector_permits: Arc::new(Semaphore::new(DETECTOR_CONCURRENCY_LIMIT)), - snapshots, now, state: Mutex::new(state), state_file: Some(state_file), @@ -1064,28 +1040,20 @@ impl SourceEvidenceCoordinator { pub fn record_acquisition( &self, key: RemoteEvidenceKey, - acquisition_key: PayloadAcquisitionKey, - facts: SourceSnapshotFacts, + environment: EnvironmentKey, + snapshot_id: RemoteSnapshotId, skill_revisions: BTreeMap, ) -> Result<(), AppError> { - if key.normalized_ref != facts.snapshot_id.requested_ref - || acquisition_key.normalized_ref != facts.snapshot_id.requested_ref - || skill_revisions - .keys() - .any(|path| !facts.complete_skill_path_catalog.contains(path)) - { + let key = key.in_environment(&environment); + if key.normalized_ref != snapshot_id.requested_ref { return Err(AppError::StalePayload); } let checked_at = (self.inner.now)(); let mut state = state(&self.inner)?; - let operation_key = EnvironmentEvidenceKey::from_environment_key( - acquisition_key.environment.clone(), - key.clone(), - ); + let operation_key = EnvironmentEvidenceKey::from_environment_key(environment, key.clone()); let skill_revisions = match state.evidence.get(&key) { - Some(previous) if previous.snapshot_id == facts.snapshot_id => { + Some(previous) if previous.snapshot_id == snapshot_id => { let mut merged = previous.skill_revisions.clone(); - merged.retain(|path, _| facts.complete_skill_path_catalog.contains(path)); merged.extend(skill_revisions); merged } @@ -1096,9 +1064,11 @@ impl SourceEvidenceCoordinator { RemoteEvidenceEntry { checked_at_epoch_ms: checked_at, expires_at_epoch_ms: checked_at.saturating_add(EVIDENCE_TTL_MS), - snapshot_id: facts.snapshot_id.clone(), + snapshot_id, provider_validation: None, - complete_skill_path_catalog: facts.complete_skill_path_catalog.clone(), + catalog_complete: false, + member_failures: BTreeMap::new(), + complete_skill_path_catalog: skill_revisions.keys().cloned().collect(), skill_revisions, }, ); @@ -1114,13 +1084,6 @@ impl SourceEvidenceCoordinator { state.network_failure_counts.remove(&operation_key); drop(state); self.persist_current_state()?; - if let Some(snapshots) = &self.inner.snapshots { - snapshots.remember( - acquisition_key, - facts.snapshot_id.commit_revision, - facts.discovery_session, - ); - } Ok(()) } @@ -1130,9 +1093,12 @@ impl SourceEvidenceCoordinator { pub async fn check( &self, - request: EvidenceCheckRequest, + mut request: EvidenceCheckRequest, cancellation: CancellationSignal, ) -> Result { + request.key = request + .key + .in_environment(&EnvironmentKey::from_ref(&request.environment)); let evidence_key = request.key.clone(); let operation_key = EnvironmentEvidenceKey::new(&request.environment, &request.key); let throttle_key = EnvironmentThrottleKey::new(&request.environment, &request.throttle_key); @@ -1355,8 +1321,9 @@ fn evidence_covers_requested_paths( } fn evidence_covers_path(evidence: &RemoteEvidenceEntry, path: &str) -> bool { - !evidence.complete_skill_path_catalog.contains(path) - || evidence.skill_revisions.contains_key(path) + evidence.skill_revisions.contains_key(path) + || evidence.member_failures.contains_key(path) + || (evidence.catalog_complete && !evidence.complete_skill_path_catalog.contains(path)) } async fn run_detection( @@ -1369,7 +1336,6 @@ async fn run_detection( ) { let key = request.key.clone(); let throttle_key = EnvironmentThrottleKey::new(&request.environment, &request.throttle_key); - let acquisition_transport_identity = request.acquisition_transport_identity.clone(); let permit = tokio::select! { permit = inner.detector_permits.clone().acquire_owned() => permit, () = cancellation.cancelled() => { @@ -1405,14 +1371,7 @@ async fn run_detection( ) .await }; - finish_detection( - &inner, - &throttle_key, - &key, - &operation_key, - &acquisition_transport_identity, - outcome, - ) + finish_detection(&inner, &throttle_key, &key, &operation_key, outcome) } Ok(None) => return, Err(error) => Err(error), @@ -1461,7 +1420,6 @@ fn finish_detection( throttle_key: &EnvironmentThrottleKey, key: &RemoteEvidenceKey, operation_key: &EnvironmentEvidenceKey, - acquisition_transport_identity: &AcquisitionTransportIdentity, outcome: Result, ) -> DetectionCompletion { let checked_at = (inner.now)(); @@ -1483,35 +1441,30 @@ fn finish_detection( discard_pending_paths(&mut state, operation_key); return Ok(()); } - if let Some(snapshot) = &observation.snapshot_facts { - let matches_observation = snapshot.snapshot_id == observation.snapshot_id - && snapshot.complete_skill_path_catalog - == observation.complete_skill_path_catalog; - if !matches_observation { - record_failure( - &mut state, - throttle_key, - operation_key, - checked_at, - EvidenceDetectionFailure::incomplete( - "snapshot facts do not match provider evidence", - ), - ); - discard_pending_paths(&mut state, operation_key); - return Ok(()); - } + let previous = state + .evidence + .get(key) + .filter(|previous| previous.snapshot_id == observation.snapshot_id); + let mut skill_revisions = previous + .map(|previous| previous.skill_revisions.clone()) + .unwrap_or_default(); + let mut member_failures = previous + .map(|previous| previous.member_failures.clone()) + .unwrap_or_default(); + if observation.catalog_complete { + skill_revisions + .retain(|path, _| observation.complete_skill_path_catalog.contains(path)); + member_failures + .retain(|path, _| observation.complete_skill_path_catalog.contains(path)); + } + for (path, revision) in observation.skill_revisions { + member_failures.remove(&path); + skill_revisions.insert(path, revision); + } + for (path, failure) in observation.member_failures { + skill_revisions.remove(&path); + member_failures.insert(path, failure); } - let skill_revisions = match state.evidence.get(key) { - Some(previous) if previous.snapshot_id == observation.snapshot_id => { - let mut merged = previous.skill_revisions.clone(); - merged.retain(|path, _| { - observation.complete_skill_path_catalog.contains(path) - }); - merged.extend(observation.skill_revisions); - merged - } - _ => observation.skill_revisions, - }; state.evidence.insert( key.clone(), RemoteEvidenceEntry { @@ -1519,6 +1472,8 @@ fn finish_detection( expires_at_epoch_ms: checked_at.saturating_add(EVIDENCE_TTL_MS), snapshot_id: observation.snapshot_id, provider_validation: observation.provider_validation, + catalog_complete: observation.catalog_complete, + member_failures, complete_skill_path_catalog: observation.complete_skill_path_catalog, skill_revisions, }, @@ -1532,19 +1487,6 @@ fn finish_detection( failure: None, }, ); - if let (Some(snapshots), Some(snapshot)) = - (&inner.snapshots, observation.snapshot_facts) - { - snapshots.remember( - PayloadAcquisitionKey::new( - acquisition_transport_identity.clone(), - snapshot.snapshot_id.requested_ref, - &snapshot.discovery_session.environment, - ), - snapshot.snapshot_id.commit_revision, - snapshot.discovery_session, - ); - } } Ok(EvidenceDetectionOutcome::NotModified) => { if let Some(evidence) = state.evidence.get_mut(key) { @@ -1577,6 +1519,7 @@ fn finish_detection( discard_pending_paths(&mut state, operation_key); } Err(error @ AppError::MutationCancelled) + | Err(error @ AppError::InvalidProxySettings { .. }) | Err(error @ AppError::WellKnownScopeNotFound { .. }) => return Err(error), Err(error) => { record_failure( @@ -2020,6 +1963,8 @@ mod tests { ref_revision, ), provider_validation: None, + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: BTreeSet::from([ "skills/alpha".to_string(), "skills/beta".to_string(), @@ -2028,7 +1973,6 @@ mod tests { .into_iter() .map(|(path, revision)| (path.to_string(), revision)) .collect::>(), - snapshot_facts: None, }) } @@ -2530,6 +2474,49 @@ mod tests { assert_eq!(detector.calls(), 2); } + #[tokio::test] + async fn credential_cleanup_converges_after_an_unconfirmed_publish() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("state/update-check.json"); + let now = Arc::new(AtomicU64::new(1_000)); + let detector = Arc::new(ScriptedDetector::new([EvidenceDetectionOutcome::Failed( + EvidenceDetectionFailure { + reason: EvidenceFailureReason::AuthenticationRequired, + message: "token required".to_string(), + retry_at_epoch_ms: None, + provider_cooldown: false, + }, + )])); + let coordinator = persistent_coordinator(detector, now.clone(), &path); + coordinator + .check( + request("acme/private", EvidenceCheckMode::Force), + CancellationSignal::default(), + ) + .await + .unwrap(); + coordinator + .inner + .state_file + .as_ref() + .expect("state file") + .set_post_publish_failure(true); + + coordinator.clear_native_github_auth_suppression().unwrap(); + + let operation = EnvironmentEvidenceKey::new(&EnvironmentRef::Native, &key("acme/private")); + assert!(!state(&coordinator.inner) + .unwrap() + .attempts + .contains_key(&operation)); + drop(coordinator); + let restarted = persistent_coordinator(Arc::new(ScriptedDetector::new([])), now, &path); + assert!(!state(&restarted.inner) + .unwrap() + .attempts + .contains_key(&operation)); + } + #[tokio::test] async fn source_repair_clears_only_the_exact_environment_and_source() { let detector = Arc::new(ScriptedDetector::new([ @@ -3586,9 +3573,13 @@ mod tests { } #[tokio::test] - async fn cancellation_and_scope_errors_are_not_persisted_as_source_failures() { + async fn cancellation_scope_and_proxy_configuration_failures_are_not_persisted_as_source_failures( + ) { for error in [ AppError::MutationCancelled, + AppError::InvalidProxySettings { + code: "configurationUnavailable".into(), + }, AppError::WellKnownScopeNotFound { scope_path: "/team".to_string(), root_url: "https://example.com".to_string(), @@ -3605,7 +3596,9 @@ mod tests { assert!(matches!( result, - Err(AppError::MutationCancelled | AppError::WellKnownScopeNotFound { .. }) + Err(AppError::MutationCancelled + | AppError::WellKnownScopeNotFound { .. } + | AppError::InvalidProxySettings { .. }) )); let state = coordinator.inner.state.lock().unwrap(); assert!(state.attempts.is_empty()); @@ -3714,6 +3707,8 @@ mod tests { "revision-1", ), provider_validation: None, + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: self.catalog.clone(), skill_revisions: paths .into_iter() @@ -3724,7 +3719,6 @@ mod tests { ) }) .collect(), - snapshot_facts: None, }, )) }) @@ -3911,6 +3905,54 @@ mod tests { assert_eq!(detector.calls(), 2); } + #[tokio::test] + async fn a_member_failure_is_a_terminal_observation_not_an_unfinished_request() { + let EvidenceDetectionOutcome::Modified(mut observation) = modified("revision-1", []) else { + unreachable!() + }; + observation.member_failures.insert( + "skills/beta".to_string(), + crate::error::SourceAcquisitionFailureReason::InvalidContent, + ); + let detector = Arc::new(ScriptedDetector::new([ + EvidenceDetectionOutcome::Modified(observation), + EvidenceDetectionOutcome::Failed(EvidenceDetectionFailure::network( + "unexpected extra request", + )), + ])); + let coordinator = coordinator(detector.clone(), Arc::new(AtomicU64::new(1_000))); + let result = coordinator + .check( + request_for_path("acme/tools", EvidenceCheckMode::Force, "skills/beta"), + CancellationSignal::default(), + ) + .await + .unwrap(); + assert_eq!(detector.calls(), 1); + assert_eq!( + result.evidence.unwrap().member_failures["skills/beta"], + crate::error::SourceAcquisitionFailureReason::InvalidContent + ); + } + + #[test] + fn incomplete_catalog_does_not_prove_a_member_was_deleted() { + let EvidenceDetectionOutcome::Modified(observation) = modified("revision-1", []) else { + unreachable!() + }; + let evidence = RemoteEvidenceEntry { + checked_at_epoch_ms: 1, + expires_at_epoch_ms: 100, + snapshot_id: observation.snapshot_id, + provider_validation: None, + complete_skill_path_catalog: BTreeSet::new(), + catalog_complete: false, + member_failures: BTreeMap::new(), + skill_revisions: BTreeMap::new(), + }; + assert!(!evidence_covers_path(&evidence, "skills/unknown")); + } + #[tokio::test] async fn path_absent_from_a_complete_catalog_completes_without_revision() { let detector = Arc::new(ScriptedDetector::new([EvidenceDetectionOutcome::Modified( @@ -3921,9 +3963,10 @@ mod tests { "revision-1", ), provider_validation: None, + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: BTreeSet::from(["skills/alpha".to_string()]), skill_revisions: BTreeMap::new(), - snapshot_facts: None, }, )])); let coordinator = coordinator(detector.clone(), Arc::new(AtomicU64::new(1_000))); @@ -4016,55 +4059,6 @@ mod tests { assert_eq!(detector.calls.load(Ordering::SeqCst), 2); } - #[tokio::test] - async fn coordinator_registers_detector_snapshot_facts_for_reuse() { - let snapshots = Arc::new(SourceSnapshotReuseIndex::with_clock(|| 1_000)); - let detector = Arc::new(ScriptedDetector::new([EvidenceDetectionOutcome::Modified( - RemoteEvidenceObservation { - snapshot_id: RemoteSnapshotId::new( - NormalizedRef::Named("main".to_string()), - "refs/heads/main", - "revision-1", - ), - provider_validation: None, - complete_skill_path_catalog: BTreeSet::from(["skills/alpha".to_string()]), - skill_revisions: BTreeMap::from([( - "skills/alpha".to_string(), - SkillRevision::CliContentHash("hash-a".to_string()), - )]), - snapshot_facts: Some(SourceSnapshotFacts { - discovery_session: DiscoverySessionHandle { - session_id: "session-1".to_string(), - environment: EnvironmentRef::Native, - source_fingerprint: "source-1".to_string(), - expires_at_epoch_ms: 60_000, - }, - snapshot_id: RemoteSnapshotId::new( - NormalizedRef::Named("main".to_string()), - "refs/heads/main", - "revision-1", - ), - complete_skill_path_catalog: BTreeSet::from(["skills/alpha".to_string()]), - }), - }, - )])); - let coordinator = SourceEvidenceCoordinator::with_clock_and_snapshots( - detector, - snapshots.clone(), - || 1_000, - ); - - coordinator - .check( - request("acme/tools", EvidenceCheckMode::Force), - CancellationSignal::default(), - ) - .await - .unwrap(); - - assert_eq!(snapshots.len(), 1); - } - #[tokio::test] async fn acquisition_refreshes_evidence_without_running_detector() { let detector = Arc::new(ScriptedDetector::new([])); @@ -4076,21 +4070,12 @@ mod tests { coordinator .record_acquisition( RemoteEvidenceKey::from_identity(&identity), - PayloadAcquisitionKey::from_identity(&identity, &EnvironmentRef::Native), - SourceSnapshotFacts { - discovery_session: DiscoverySessionHandle { - session_id: "session-1".to_string(), - environment: EnvironmentRef::Native, - source_fingerprint: "source-1".to_string(), - expires_at_epoch_ms: 60_000, - }, - snapshot_id: RemoteSnapshotId::new( - NormalizedRef::Named("main".to_string()), - "main", - "revision-1", - ), - complete_skill_path_catalog: BTreeSet::from(["skills/alpha".to_string()]), - }, + EnvironmentKey::Native, + RemoteSnapshotId::new( + NormalizedRef::Named("main".to_string()), + "main", + "revision-1", + ), BTreeMap::from([( "skills/alpha".to_string(), SkillRevision::CliContentHash("hash-a".to_string()), @@ -4119,34 +4104,20 @@ mod tests { ) .unwrap(); let evidence_key = RemoteEvidenceKey::from_identity(&identity); - let acquisition_key = - PayloadAcquisitionKey::from_identity(&identity, &EnvironmentRef::Native); - let facts = |session_id: &str, - commit_revision: &str, - catalog: BTreeSet| - -> SourceSnapshotFacts { - SourceSnapshotFacts { - discovery_session: DiscoverySessionHandle { - session_id: session_id.to_string(), - environment: EnvironmentRef::Native, - source_fingerprint: format!("source-{session_id}"), - expires_at_epoch_ms: 60_000, - }, - snapshot_id: RemoteSnapshotId::new( - NormalizedRef::Named("main".to_string()), - "main", - commit_revision, - ), - complete_skill_path_catalog: catalog, - } + let acquisition_key = EnvironmentKey::Native; + let snapshot = |commit_revision: &str| { + RemoteSnapshotId::new( + NormalizedRef::Named("main".to_string()), + "main", + commit_revision, + ) }; - let full_catalog = BTreeSet::from(["skills/alpha".to_string(), "skills/beta".to_string()]); coordinator .record_acquisition( evidence_key.clone(), acquisition_key.clone(), - facts("1", "revision-1", full_catalog.clone()), + snapshot("revision-1"), BTreeMap::from([( "skills/alpha".to_string(), SkillRevision::CliContentHash("hash-a".to_string()), @@ -4157,7 +4128,7 @@ mod tests { .record_acquisition( evidence_key.clone(), acquisition_key.clone(), - facts("2", "revision-1", full_catalog), + snapshot("revision-1"), BTreeMap::from([( "skills/beta".to_string(), SkillRevision::CliContentHash("hash-b".to_string()), @@ -4179,11 +4150,7 @@ mod tests { .record_acquisition( evidence_key, acquisition_key, - facts( - "3", - "revision-2", - BTreeSet::from(["skills/beta".to_string()]), - ), + snapshot("revision-2"), BTreeMap::from([( "skills/beta".to_string(), SkillRevision::CliContentHash("hash-b2".to_string()), @@ -4192,7 +4159,7 @@ mod tests { .unwrap(); let pruned = coordinator .check( - request("acme/tools", EvidenceCheckMode::Automatic), + request_for_path("acme/tools", EvidenceCheckMode::Automatic, "skills/beta"), CancellationSignal::default(), ) .await @@ -4209,6 +4176,61 @@ mod tests { assert_eq!(detector.calls(), 0); } + #[tokio::test] + async fn cli_content_evidence_is_not_reused_across_environments() { + let detector = Arc::new(ScriptedDetector::new([ + modified( + "revision-1", + [( + "skills/alpha", + SkillRevision::CliContentHash("native-hash".to_string()), + )], + ), + modified( + "revision-1", + [( + "skills/alpha", + SkillRevision::CliContentHash("wsl-hash".to_string()), + )], + ), + ])); + let coordinator = coordinator(detector.clone(), Arc::new(AtomicU64::new(1_000))); + let identity = SourceIdentity::from_parsed( + &parse_source("https://example.com/acme/tools.git#main").unwrap(), + ) + .unwrap(); + let request = |environment| EvidenceCheckRequest { + environment, + key: RemoteEvidenceKey::from_identity(&identity), + throttle_key: ProviderThrottleKey::from_identity(&identity), + mode: EvidenceCheckMode::Automatic, + requested_skill_paths: BTreeSet::from(["skills/alpha".to_string()]), + acquisition: Arc::new(identity.acquisition().clone()), + acquisition_transport_identity: identity.acquisition_transport().clone(), + }; + coordinator + .check( + request(EnvironmentRef::Native), + CancellationSignal::default(), + ) + .await + .unwrap(); + let wsl = coordinator + .check( + request(EnvironmentRef::Wsl { + distro_name: "Ubuntu".to_string(), + }), + CancellationSignal::default(), + ) + .await + .unwrap(); + assert_eq!(detector.calls(), 2); + assert_eq!( + wsl.evidence.unwrap().skill_revisions["skills/alpha"], + SkillRevision::CliContentHash("wsl-hash".to_string()) + ); + } + #[test] fn provider_cooldown_never_shrinks_across_repositories() { let mut state = CoordinatorState::default(); diff --git a/src-tauri/src/application/source_evidence_provider.rs b/src-tauri/src/application/source_evidence_provider.rs index 2fce4555..3223cb7b 100644 --- a/src-tauri/src/application/source_evidence_provider.rs +++ b/src-tauri/src/application/source_evidence_provider.rs @@ -11,7 +11,7 @@ use crate::application::source_acquisition::{ use crate::application::source_evidence::{ EvidenceDetectionFailure, EvidenceDetectionOutcome, EvidenceDetectionRequest, EvidenceFailureReason, EvidenceFuture, RemoteEvidenceEntry, RemoteEvidenceObservation, - RemoteSnapshotId, SkillRevision, SourceEvidenceDetector, SourceSnapshotFacts, + RemoteSnapshotId, SkillRevision, SourceEvidenceDetector, }; use crate::application::source_snapshot_reuse::{PayloadAcquisitionKey, SourceSnapshotReuseIndex}; use crate::application::wellknown_access::WellKnownAccess; @@ -173,9 +173,10 @@ impl RuntimeSourceEvidenceDetector { snapshot.ref_revision, ), provider_validation: snapshot.validation, + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: catalog, skill_revisions, - snapshot_facts: None, }, )) } @@ -202,6 +203,11 @@ impl RuntimeSourceEvidenceDetector { None, false, )), + GithubTreeFetchOutcome::Failed(GithubTreeFailure::ProxyConfiguration) => { + Err(AppError::InvalidProxySettings { + code: "configurationUnavailable".into(), + }) + } GithubTreeFetchOutcome::Failed( GithubTreeFailure::AuthenticationRequired | GithubTreeFailure::NotFoundOrUnauthorized @@ -261,7 +267,9 @@ impl RuntimeSourceEvidenceDetector { .snapshots .find(&acquisition_key, &ref_revision, self.payloads.as_ref()) .map(|discovery| (discovery, ref_revision)), - Err(AppError::MutationCancelled) => return Err(AppError::MutationCancelled), + Err( + error @ (AppError::MutationCancelled | AppError::InvalidProxySettings { .. }), + ) => return Err(error), Err(error) => return Ok(git_failure(error)), } } else { @@ -281,6 +289,8 @@ impl RuntimeSourceEvidenceDetector { parsed, source, SourceDiscoveryPolicy { + allow_empty_catalog: true, + selected_skill_names: None, full_depth: true, internal_skill_visibility: InternalSkillVisibility::All, }, @@ -290,7 +300,11 @@ impl RuntimeSourceEvidenceDetector { .await; let discovery = match discovery { Ok(discovery) => discovery, - Err(AppError::MutationCancelled) => return Err(AppError::MutationCancelled), + Err( + error @ (AppError::MutationCancelled + | AppError::InvalidProxySettings { .. }), + ) => return Err(error), + Err(error @ AppError::CapabilityUnavailable { .. }) => return Err(error), Err(error) => return Ok(git_failure(error)), }; let snapshot = self @@ -306,61 +320,118 @@ impl RuntimeSourceEvidenceDetector { } }; - let snapshot = self.payloads.source_snapshot(&discovery_session)?; - let catalog = snapshot - .skills() - .map(|skill| normalize_skill_folder_path(&skill.relative_path)) - .collect::>(); - let selected_paths = request - .requested_skill_paths - .iter() - .filter_map(|requested_path| { - snapshot - .skills() - .find(|skill| { - normalize_skill_folder_path(&skill.relative_path) == *requested_path - }) - .map(|skill| (requested_path.clone(), skill.relative_path.clone())) - }) - .collect::>(); - let handles = SelectedPayloadAcquisitionService::new(self.payloads.clone()) - .acquire(AcquireSelectedPayloadsRequest { - discovery_session: discovery_session.clone(), - skill_paths: selected_paths - .iter() - .map(|(_, relative_path)| relative_path.clone()) - .collect(), - }) - .await?; - let mut skill_revisions = BTreeMap::new(); - for ((skill_path, _), handle) in selected_paths.into_iter().zip(handles) { - let lease = self.payloads.pin_verified(&handle).await?; - let metadata = lease.planning_metadata(); - if let Some(revision) = clone_skill_revision( - provider.clone(), - metadata.computed_hash.clone(), - metadata.upstream_revision.clone(), - ) { - skill_revisions.insert(skill_path, revision); + let result = async { + let selected = SelectedPayloadAcquisitionService::new(self.payloads.clone()); + let mut member_failures = BTreeMap::new(); + for path in &request.requested_skill_paths { + if let Err(error) = selected + .ensure_saved_path(&discovery_session, path, cancellation.clone()) + .await + { + if matches!( + error, + AppError::MutationCancelled + | AppError::StaleEnvironment + | AppError::EnvironmentUnavailable { .. } + ) { + return Err(error); + } + member_failures.insert( + path.clone(), + match error { + AppError::CapabilityUnavailable { capability, .. } if capability == "sourceDirectoryLinks" => { + crate::error::SourceAcquisitionFailureReason::DirectoryLinksUnsupported + } + AppError::PathNotFound { .. } => { + crate::error::SourceAcquisitionFailureReason::NotFound + } + AppError::InvalidSkillMd { .. } | AppError::StalePayload => { + crate::error::SourceAcquisitionFailureReason::InvalidContent + } + _ => crate::error::SourceAcquisitionFailureReason::Unavailable, + }, + ); + } + } + let snapshot = self.payloads.source_snapshot(&discovery_session)?; + let catalog = snapshot + .skills() + .map(|skill| normalize_skill_folder_path(&skill.relative_path)) + .collect::>(); + let selected_paths = request + .requested_skill_paths + .iter() + .filter(|path| !member_failures.contains_key(*path)) + .filter_map(|requested_path| { + snapshot + .skills() + .find(|skill| { + normalize_skill_folder_path(&skill.relative_path) == *requested_path + }) + .map(|skill| (requested_path.clone(), skill.relative_path.clone())) + }) + .collect::>(); + let mut skill_revisions = BTreeMap::new(); + for (skill_path, relative_path) in selected_paths { + let acquired = async { + let handle = selected + .acquire(AcquireSelectedPayloadsRequest { + discovery_session: discovery_session.clone(), + skill_paths: vec![relative_path], + }) + .await? + .into_iter() + .next() + .ok_or(AppError::StalePayload)?; + self.payloads.pin_verified(&handle).await + } + .await; + let lease = match acquired { + Ok(lease) => lease, + Err(AppError::MutationCancelled) => return Err(AppError::MutationCancelled), + Err(_) => { + member_failures.insert( + skill_path, + crate::error::SourceAcquisitionFailureReason::InvalidContent, + ); + continue; + } + }; + let metadata = lease.planning_metadata(); + if let Some(revision) = clone_skill_revision( + provider.clone(), + metadata.computed_hash.clone(), + metadata.upstream_revision.clone(), + ) { + skill_revisions.insert(skill_path, revision); + } } - } - let requested_ref = request.key.normalized_ref; - let resolved_ref = resolved_ref(&requested_ref); - let snapshot_id = RemoteSnapshotId::new(requested_ref, resolved_ref, ref_revision); - Ok(EvidenceDetectionOutcome::Modified( - RemoteEvidenceObservation { - snapshot_id: snapshot_id.clone(), - provider_validation: None, - complete_skill_path_catalog: catalog.clone(), - skill_revisions, - snapshot_facts: Some(SourceSnapshotFacts { - discovery_session, - snapshot_id, - complete_skill_path_catalog: catalog, - }), - }, - )) + let requested_ref = request.key.normalized_ref; + drop(snapshot); + let resolved_ref = resolved_ref(&requested_ref); + let snapshot_id = RemoteSnapshotId::new(requested_ref, resolved_ref, ref_revision); + self.snapshots.remember( + acquisition_key, + snapshot_id.commit_revision.clone(), + discovery_session.clone(), + ); + Ok(EvidenceDetectionOutcome::Modified( + RemoteEvidenceObservation { + snapshot_id: snapshot_id.clone(), + provider_validation: None, + catalog_complete: false, + member_failures, + complete_skill_path_catalog: catalog.clone(), + skill_revisions, + }, + )) + } + .await; + if let Err(error) = self.payloads.make_source_optional(&discovery_session).await { + log::warn!("Source retention cleanup will be retried: {error}"); + } + result } async fn detect_wellknown( @@ -379,7 +450,9 @@ impl RuntimeSourceEvidenceDetector { .await { Ok(evidence) => evidence, - Err(AppError::MutationCancelled) => return Err(AppError::MutationCancelled), + Err(error @ (AppError::MutationCancelled | AppError::InvalidProxySettings { .. })) => { + return Err(error) + } Err(error @ AppError::WellKnownScopeNotFound { .. }) => return Err(error), Err(_) => { return Ok(failure( @@ -410,18 +483,21 @@ impl RuntimeSourceEvidenceDetector { &evidence.index_url, &complete_skill_path_catalog, revision_projection, + evidence.catalog_complete, + &evidence.member_failures, ))?; Ok(EvidenceDetectionOutcome::Modified( RemoteEvidenceObservation { snapshot_id: RemoteSnapshotId::new( NormalizedRef::Default, - evidence.index_url, + "well-known-index", revision, ), provider_validation: None, + catalog_complete: evidence.catalog_complete, + member_failures: evidence.member_failures, complete_skill_path_catalog, skill_revisions, - snapshot_facts: None, }, )) } @@ -1155,6 +1231,8 @@ mod tests { "commit-v1", ), provider_validation: Some("tree-v1".into()), + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: BTreeSet::from(["skills/alpha".into()]), skill_revisions: BTreeMap::from([( "skills/alpha".into(), @@ -1255,10 +1333,7 @@ mod tests { }, ]); let detector = Arc::new(CountingDetector::new(github_detector(&fixture))); - let coordinator = SourceEvidenceCoordinator::with_snapshot_reuse( - detector.clone(), - Arc::new(SourceSnapshotReuseIndex::default()), - ); + let coordinator = SourceEvidenceCoordinator::new(detector.clone()); let main = coordinator .check( @@ -1655,7 +1730,7 @@ mod tests { snapshots.clone(), git_transport.clone(), )); - let coordinator = SourceEvidenceCoordinator::with_snapshot_reuse(detector, snapshots); + let coordinator = SourceEvidenceCoordinator::new(detector); let request = |path: &str| EvidenceCheckRequest { environment: EnvironmentRef::Native, key: RemoteEvidenceKey::from_identity(&identity), @@ -1786,6 +1861,8 @@ mod tests { expires_at_epoch_ms: 2, snapshot_id: facts.snapshot_id, provider_validation: facts.provider_validation, + catalog_complete: facts.catalog_complete, + member_failures: facts.member_failures, complete_skill_path_catalog: facts.complete_skill_path_catalog, skill_revisions: facts.skill_revisions, } diff --git a/src-tauri/src/application/source_evidence_state.rs b/src-tauri/src/application/source_evidence_state.rs index f59637f3..ce7cf997 100644 --- a/src-tauri/src/application/source_evidence_state.rs +++ b/src-tauri/src/application/source_evidence_state.rs @@ -1,5 +1,4 @@ use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::{ @@ -7,16 +6,19 @@ use std::sync::{ Arc, }; -use tempfile::NamedTempFile; use uuid::Uuid; use crate::error::AppError; +const MAX_SOURCE_EVIDENCE_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; + #[derive(Clone)] pub(crate) struct SourceEvidenceStateFile { path: PathBuf, #[cfg(test)] fail_writes: Arc, + #[cfg(test)] + fail_after_publish: Arc, } impl SourceEvidenceStateFile { @@ -25,15 +27,17 @@ impl SourceEvidenceStateFile { path, #[cfg(test)] fail_writes: Arc::new(AtomicBool::new(false)), + #[cfg(test)] + fail_after_publish: Arc::new(AtomicBool::new(false)), } } pub(crate) fn read_optional(&self) -> Result>, AppError> { - match fs::read(&self.path) { - Ok(bytes) => Ok(Some(bytes)), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error.into()), - } + environment_engine::atomic_document::read_optional_bounded( + &self.path, + MAX_SOURCE_EVIDENCE_BYTES, + ) + .map_err(Into::into) } pub(crate) fn write_atomic(&self, bytes: &[u8]) -> Result<(), AppError> { @@ -44,12 +48,55 @@ impl SourceEvidenceStateFile { let parent = self.parent()?; fs::create_dir_all(parent)?; - let mut temporary = NamedTempFile::new_in(parent)?; - temporary.write_all(bytes)?; - temporary.write_all(b"\n")?; - temporary.as_file_mut().sync_all()?; - temporary.persist(&self.path).map_err(|error| error.error)?; - sync_parent(parent) + let mut document = Vec::with_capacity(bytes.len().saturating_add(1)); + document.extend_from_slice(bytes); + document.push(b'\n'); + let result = environment_engine::atomic_document::replace(&self.path, &document) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine) + .map(|_| ()); + #[cfg(test)] + let result = match result { + Ok(()) if self.fail_after_publish.load(Ordering::SeqCst) => { + Err(crate::storage::atomic_document::DocumentWriteFailure { + error: AppError::Io { + message: "forced update-check state confirmation failure".to_string(), + }, + phase: crate::storage::atomic_document::WritePhase::Confirming, + publication: + crate::storage::atomic_document::PublicationState::PublishedUnconfirmed, + }) + } + result => result, + }; + match result { + Ok(()) => Ok(()), + Err(failure) + if failure.publication + == crate::storage::atomic_document::PublicationState::PublishedUnconfirmed => + { + log::warn!( + "来源证据状态已经发布,但持久化确认失败;当前进程继续使用新状态: {}", + failure.error + ); + Ok(()) + } + Err(failure) + if failure.publication + == crate::storage::atomic_document::PublicationState::OutcomeUnknown => + { + match environment_engine::atomic_document::read_optional_bounded( + &self.path, + document.len(), + ) { + Ok(Some(current)) if current == document => { + log::warn!("来源证据状态发布结果未知,重新读取后确认新状态已经生效"); + Ok(()) + } + _ => Err(failure.error), + } + } + Err(failure) => Err(failure.error), + } } #[cfg(test)] @@ -57,6 +104,11 @@ impl SourceEvidenceStateFile { self.fail_writes.store(fail, Ordering::SeqCst); } + #[cfg(test)] + pub(crate) fn set_post_publish_failure(&self, fail: bool) { + self.fail_after_publish.store(fail, Ordering::SeqCst); + } + pub(crate) fn quarantine(&self, now_epoch_ms: u64) -> Result { let file_name = self .path @@ -83,13 +135,35 @@ impl SourceEvidenceStateFile { } } -#[cfg(unix)] fn sync_parent(parent: &Path) -> Result<(), AppError> { - fs::File::open(parent)?.sync_all()?; - Ok(()) + environment_engine::atomic_document::sync_directory(parent).map_err(Into::into) } -#[cfg(not(unix))] -fn sync_parent(_parent: &Path) -> Result<(), AppError> { - Ok(()) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oversized_source_evidence_state_is_rejected_before_deserialization() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("source-evidence.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + let state = SourceEvidenceStateFile::new(path.clone()); + + let error = match state.read_optional() { + Err(error) => error, + Ok(_) => panic!("oversized source evidence must be rejected"), + }; + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/application/update.rs b/src-tauri/src/application/update.rs index 1f691578..b49f5a36 100644 --- a/src-tauri/src/application/update.rs +++ b/src-tauri/src/application/update.rs @@ -10,9 +10,7 @@ use crate::application::agent_intent::AgentTargetFallbackPreview; use crate::application::mutation::coordinator::MutationUnitObserver; use crate::application::mutation::executor::MutationPlanExecutor; use crate::application::mutation::plan::{MutationPlan, PreviewToken}; -use crate::application::mutation::planning::{ - validate_exact_preview, validate_same_scope_revisions, PreviewScopeRevisions, -}; +use crate::application::mutation::planning::validate_exact_preview; use crate::application::mutation::result::{ ErrorReport, MutationUnitResult, MutationUnitStatus, OperationErrorCode, }; @@ -23,11 +21,9 @@ use crate::application::resources::SkillIdentity; use crate::application::skill_changes::ValidatedSkillPayload; use crate::application::skill_entry_projection::ObservedEntryReader; use crate::application::skill_source::{ - validate_saved_payloads, AcquiredSavedSkillSource, SavedPayloadCandidate, SavedSkillSource, - SavedSkillSourceAcquisition, SavedSkillSourceGroup, SkillSourceModule, + AcquiredSavedSkillSource, SavedSkillSource, SavedSkillSourceAcquisition, SavedSkillSourceGroup, + SkillSourceModule, }; -#[cfg(test)] -use crate::application::source_evidence::RemoteSnapshotId; use crate::application::source_evidence::{EvidenceAttempt, EvidenceFreshness}; use crate::application::update_planner::LocalUpdateInspection; #[cfg(test)] @@ -68,7 +64,7 @@ pub enum UpdateCheckReasonCode { DeletedUpstream, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub struct CheckUpdateCapability { @@ -91,6 +87,12 @@ pub struct SkillUpdateInfo { pub source_url: Option, pub skill_path: Option, pub freshness: EvidenceFreshness, + #[serde(skip_serializing_if = "Option::is_none")] + pub comparison_fingerprint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -121,6 +123,8 @@ pub struct UpdateCheckRequest { #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub struct SourceUpdateCheckInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, pub source: String, pub requested_ref: Option, pub resolved_ref: Option, @@ -129,6 +133,10 @@ pub struct SourceUpdateCheckInfo { pub expires_at_epoch_ms: Option, pub freshness: EvidenceFreshness, pub last_attempt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -215,21 +223,88 @@ pub struct UpdateExecutionRequest { #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] -pub struct UpdatePreview { - pub token: PreviewToken, +pub struct UpdatePreparationIssue { + pub skill_name: String, + pub error: AppError, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct PreparedUpdatePreview { + pub sources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub path_base: Option, pub skills: Vec, + pub blocked: Vec, + pub redirected_download_hosts: Vec, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct UpdateSourcePreview { + pub source_key: String, + pub source_display: String, + pub ref_display: String, + pub skill_names: Vec, + pub error: Option, +} + +pub struct PreparedUpdate { + pub request: UpdateRequest, + pub preview: PreparedUpdatePreview, + items: Vec, + sources: Vec, + source_by_skill: std::collections::BTreeMap, +} + +struct PreparedUpdateItem { + name: String, + source_result_id: String, + handle: crate::application::payload_session::AcquiredPayloadHandle, + plan: MutationPlan, +} + +impl PreparedUpdate { + pub fn expires_at_epoch_ms(&self) -> Option { + self.items + .iter() + .map(|item| item.handle.expires_at_epoch_ms) + .min() + } + + pub fn expire_payloads(&mut self, now: u64) { + self.items.retain(|item| { + if item.handle.expires_at_epoch_ms > now { + return true; + } + // 保留确认时允许的覆盖选择;过期只撤销该项的执行内容。 + self.preview.blocked.push(UpdatePreparationIssue { + skill_name: item.name.clone(), + error: AppError::StalePayload, + }); + false + }); + } } #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub struct UpdateSkillPreview { + pub linked_targets: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_key: Option, pub skill_name: String, pub source_display: String, pub ref_display: String, pub adapter_targets: Vec, pub capability: CheckUpdateCapability, pub clean_copy_count: usize, + pub targets: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub preserved_targets: Option>, pub overwrite_private_entries: Vec, pub blocking_reasons: Vec, pub fallback_forecasts: Vec, @@ -239,8 +314,39 @@ pub struct UpdateSkillPreview { #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub struct UpdateConflictCopyPreview { + #[serde(skip_serializing_if = "Option::is_none")] + pub is_standard: Option, pub entry_id: ObservedEntryId, pub readers: Vec, + pub display_path: crate::environment::types::ResourceLocator, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct UpdateTargetPreview { + #[serde(skip_serializing_if = "Option::is_none")] + pub selectable_entry_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_standard: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub link_target: Option, + pub display_path: crate::environment::types::ResourceLocator, + pub readers: Vec, + pub restoring: bool, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct UpdateLinkedTargetPreview { + pub display_path: crate::environment::types::ResourceLocator, + pub readers: Vec, + pub is_standard: bool, + pub target_path: crate::environment::types::ResourceLocator, + pub target_copy_entry_id: Option, } #[derive(Debug, Clone, Serialize, Type)] @@ -274,7 +380,7 @@ pub struct UpdateSourceResult { #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub enum UpdateWarningCode { - PreservedConflictingCopy, + SkippedCopy, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -286,7 +392,7 @@ pub enum UpdateWarningCode { )] pub enum UpdateCoverage { Updated, - PreservedConflicts, + UpdatedWithSkippedCopies, NotUpdated { error: ErrorReport }, } @@ -304,6 +410,8 @@ pub enum UpdateOutcome { #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub struct UpdateSkillResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skipped_copy_paths: Option>, pub skill_identity: SkillIdentity, pub source_result_id: String, pub mutation: Option, @@ -315,10 +423,10 @@ pub struct UpdateSkillResult { pub type UpdateFuture<'a, T> = Pin + Send + 'a>>; pub trait UpdatePlanner: Send + Sync { - fn inspect<'a>( + fn inspect_items<'a>( &'a self, request: &'a UpdateRequest, - ) -> UpdateFuture<'a, Result>; + ) -> UpdateFuture<'a, Vec<(String, Result)>>; fn build<'a>( &'a self, @@ -372,355 +480,479 @@ where } } - pub async fn preview(&self, request: &UpdateRequest) -> Result { - validate_update_request(request)?; - preview_from_inspection(self.planner.inspect(request).await?) - } - - #[cfg(test)] - pub async fn execute( + pub async fn prepare( &self, - execution: &UpdateExecutionRequest, - expected_token: PreviewToken, + request: &UpdateRequest, cancellation: CancellationSignal, - ) -> Result { - self.execute_with_stage_observer(execution, expected_token, cancellation, |_| {}) + ) -> Result { + validate_update_request(request)?; + let inspections = self + .planner + .inspect_items(request) .await - } - - #[cfg(test)] - pub async fn execute_with_stage_observer( - &self, - execution: &UpdateExecutionRequest, - expected_token: PreviewToken, - cancellation: CancellationSignal, - observe_stage: F, - ) -> Result - where - F: Fn(UpdateExecutionProgress) + Send + Sync, - { - self.execute_with_confirmation_and_stage_observer( - execution, - expected_token, - cancellation, - false, - observe_stage, - ) - .await - } - - pub async fn execute_with_confirmation_and_stage_observer( - &self, - execution: &UpdateExecutionRequest, - expected_token: PreviewToken, - cancellation: CancellationSignal, - acknowledge_redirect: bool, - observe_stage: F, - ) -> Result - where - F: Fn(UpdateExecutionProgress) + Send + Sync, - { - validate_update_request(&execution.request)?; - validate_conflict_decisions(execution)?; - let initial = self.planner.inspect(&execution.request).await?; - let initial_subjects = initial.subjects.clone(); - validate_exact_preview(&expected_token, &initial.token)?; - let saved = initial - .source_candidates + .into_iter() + .map(|(name, result)| { + let result = result.and_then(|inspection| { + for skill in &inspection.source_candidates { + SourceIdentity::from_metadata(&skill.metadata())?; + } + Ok(inspection) + }); + (name, result) + }) + .collect::>(); + let saved = inspections .iter() + .filter_map(|(_, item)| item.as_ref().ok()) + .flat_map(|item| item.source_candidates.iter()) .map(|skill| SavedSkillSource { name: skill.name.clone(), metadata: skill.metadata(), }) - .collect(); - let acquisitions = self - .skill_source - .acquire_saved_skills( - &execution.request.context.environment, - saved, - cancellation.clone(), - ) - .await?; - let source_by_skill = acquisitions - .iter() - .flat_map(|acquisition| { - acquisition - .skill_names - .iter() - .map(|skill_name| (skill_name.clone(), acquisition.source_result_id.clone())) - }) - .collect::>(); - if !acknowledge_redirect { - if let Some(host) = acquisitions.iter().find_map(|acquisition| { - acquisition - .result - .as_ref() - .ok() - .and_then(|acquired| acquired.redirected_download_host.clone()) - }) { - return Err(AppError::DirectDownloadRedirectConfirmationRequired { host }); - } - } - observe_stage(UpdateExecutionProgress { - stage: UpdateExecutionStage::Validating, - subject: None, - current: None, - total: None, - }); - let latest = self.planner.inspect(&execution.request).await?; - let latest_subjects = latest.subjects.clone(); - let mut sources = Vec::with_capacity(acquisitions.len()); - let mut skills = Vec::with_capacity(execution.request.skill_names.len()); - let mut executable_names = Vec::new(); - let selected = execution - .overwrite_private_entries + .collect::>(); + let groups = crate::application::skill_source::group_saved_skills( + &request.context.environment, + saved, + )?; + let executable_names = inspections .iter() - .cloned() + .filter_map(|(_, item)| item.as_ref().ok()) + .flat_map(|item| &item.skills) + .filter(|skill| skill.blocking_reasons.is_empty()) + .map(|skill| skill.skill_name.as_str()) .collect::>(); - let latest_by_name = latest - .skills - .iter() - .map(|skill| (skill.skill_name.as_str(), skill)) - .collect::>(); - let initial_by_name = initial - .skills - .iter() - .map(|skill| (skill.skill_name.as_str(), skill)) - .collect::>(); - let drifted_names = execution - .request - .skill_names + let acquisition_groups = groups .iter() - .filter(|name| { - initial_by_name - .get(name.as_str()) - .zip(latest_by_name.get(name.as_str())) - .is_none_or(|(initial, latest)| { - initial.agent_observed_digest != latest.agent_observed_digest + .cloned() + .filter_map(|mut group| { + group + .skills + .retain(|skill| executable_names.contains(skill.name.as_str())); + (!group.skills.is_empty()).then_some(group) + }) + .collect::>(); + let acquisitions = if acquisition_groups.is_empty() { + Vec::new() + } else { + match self + .skill_source + .acquire_saved_groups(&acquisition_groups, cancellation.clone()) + .await + { + Ok(sources) => sources, + Err(AppError::MutationCancelled) => acquisition_groups + .iter() + .map(|group| SavedSkillSourceAcquisition { + source_result_id: group.source_result_id.clone(), + source: group.source.clone(), + skill_names: group + .skills + .iter() + .map(|skill| skill.name.clone()) + .collect(), + result: Err(AppError::MutationCancelled), }) + .collect(), + Err(error) => return Err(error), + } + }; + let source_previews = groups + .iter() + .map(|group| { + let identity = SourceIdentity::from_metadata(&group.skills[0].metadata)?; + let acquired = acquisitions.iter().find(|source| { + group + .skills + .iter() + .any(|skill| source.skill_names.contains(&skill.name)) + }); + Ok(UpdateSourcePreview { + source_key: acquired.map_or_else( + || group.source_result_id.clone(), + |source| source.source_result_id.clone(), + ), + source_display: group.source.clone(), + ref_display: match identity.normalized_ref() { + NormalizedRef::Named(value) => value.clone(), + NormalizedRef::Default => String::new(), + }, + skill_names: group + .skills + .iter() + .map(|skill| skill.name.clone()) + .collect(), + error: acquired + .and_then(|source| source.result.as_ref().err()) + .cloned(), + }) }) - .cloned() - .collect::>(); - validate_same_scope_revisions( - &PreviewScopeRevisions::from(&initial.token), - &PreviewScopeRevisions::from(&latest.token), - )?; - let mut candidates = Vec::new(); - for acquisition in &acquisitions { - let Ok(acquired) = &acquisition.result else { - continue; - }; - for (skill_name, handle) in &acquired.payloads { - if !drifted_names.contains(skill_name) { - candidates.push(SavedPayloadCandidate { - source_result_id: acquisition.source_result_id.clone(), - discovery_session: acquired.facts.discovery_session.clone(), - skill_name: skill_name.clone(), - handle: handle.clone(), - }); - } + .collect::, AppError>>()?; + let mut prepared = PreparedUpdate { + request: request.clone(), + preview: PreparedUpdatePreview { + sources: source_previews, + path_base: inspections.iter().find_map(|(_, item)| { + item.as_ref() + .ok() + .and_then(|inspection| inspection.path_base.clone()) + }), + skills: Vec::new(), + blocked: Vec::new(), + redirected_download_hosts: Vec::new(), + }, + items: Vec::new(), + sources: Vec::new(), + source_by_skill: groups + .iter() + .flat_map(|source| { + source.skills.iter().map(|skill| { + ( + skill.name.clone(), + acquisitions + .iter() + .find(|acquired| acquired.skill_names.contains(&skill.name)) + .map_or_else( + || source.source_result_id.clone(), + |acquired| acquired.source_result_id.clone(), + ), + ) + }) + }) + .collect(), + }; + let mut hosts = BTreeSet::new(); + for source in &acquisitions { + if let Ok(content) = &source.result { + hosts.extend(content.redirected_download_hosts.iter().cloned()); } + prepared.sources.push(UpdateSourceResult { + id: source.source_result_id.clone(), + source: source.source.clone(), + status: if source.result.is_ok() { + UpdateSourceStatus::Acquired + } else { + UpdateSourceStatus::Failed + }, + error: source + .result + .as_ref() + .err() + .cloned() + .map(|error| ErrorReport::from_app_error(error, Some(request.context.clone()))), + }); } - let validation = validate_saved_payloads( - self.payloads.as_ref(), - &execution.request.context.environment, - candidates, - ) - .await; - for acquisition in acquisitions { - match acquisition.result { - Ok(acquired) => { - sources.push(UpdateSourceResult { - id: acquisition.source_result_id.clone(), - source: acquisition.source, - status: UpdateSourceStatus::Acquired, - error: None, - }); - for (skill_name, error) in acquired.skill_errors { - skills.push(not_updated_skill( - &execution.request.context, - skill_name, - acquisition.source_result_id.clone(), - ErrorReport::from_app_error( - error, - Some(execution.request.context.clone()), - ), - )); - } - for (skill_name, _handle) in acquired.payloads { - if drifted_names.contains(&skill_name) { - skills.push(not_updated_skill( - &execution.request.context, - skill_name, - acquisition.source_result_id.clone(), - ErrorReport::from_app_error( - AppError::StaleTarget, - Some(execution.request.context.clone()), - ), - )); - } - } + prepared.preview.redirected_download_hosts = hosts.into_iter().collect(); + for (name, inspection) in inspections { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let outcome = async { + let inspection = inspection?; + let mut preview = previews_from_inspection(inspection.clone())? + .into_iter() + .next() + .ok_or(AppError::StaleTarget)?; + preview.source_key = prepared.source_by_skill.get(&name).cloned(); + if preview.blocking_reasons == [OperationErrorCode::NoUpdateTargets] { + return Ok((preview, None)); } - Err(error) => { - let report = - ErrorReport::from_app_error(error, Some(execution.request.context.clone())); - sources.push(UpdateSourceResult { - id: acquisition.source_result_id.clone(), - source: acquisition.source, - status: UpdateSourceStatus::Failed, - error: Some(report.clone()), - }); - for skill_name in acquisition.skill_names { - skills.push(UpdateSkillResult { - skill_identity: SkillIdentity { - context: execution.request.context.clone(), - skill_name, - }, - source_result_id: acquisition.source_result_id.clone(), - mutation: None, - coverage: UpdateCoverage::NotUpdated { - error: report.clone(), - }, - warnings: Vec::new(), - retryable: report.retryable, - }); + if !preview.blocking_reasons.is_empty() { + return Err(AppError::StaleTarget); + } + let source = acquisitions + .iter() + .find(|source| source.skill_names.contains(&name)) + .ok_or(AppError::StalePayload)?; + let content = source.result.as_ref().map_err(Clone::clone)?; + let payload = content + .validate_member(self.payloads.as_ref(), &request.context.environment, &name) + .await?; + let handle = payload.handle().clone(); + let execution = UpdateExecutionRequest { + request: UpdateRequest { + context: request.context.clone(), + skill_names: vec![name.clone()], + }, + overwrite_private_entries: inspection + .skills + .iter() + .flat_map(|skill| skill.clean_copies.iter().chain(&skill.conflicts)) + .map(|entry| entry.entry_id.clone()) + .collect(), + }; + let (token, plan) = self.planner.build(&execution, vec![payload]).await?; + validate_exact_preview(&inspection.token, &token)?; + Ok::<_, AppError>(( + preview, + Some(PreparedUpdateItem { + name: name.clone(), + source_result_id: source.source_result_id.clone(), + handle: handle.clone(), + plan, + }), + )) + } + .await; + match outcome { + Ok((preview, item)) => { + prepared.preview.skills.push(preview); + if let Some(item) = item { + prepared.items.push(item); } } + Err(error) => prepared.preview.blocked.push(UpdatePreparationIssue { + skill_name: name, + error, + }), } } - - for failed in validation.failed { - skills.push(not_updated_skill( - &execution.request.context, - failed.skill_name, - failed.source_result_id, - ErrorReport::from_app_error(failed.error, Some(execution.request.context.clone())), - )); - } - let mut payloads = validation - .validated - .into_iter() - .map(|validated| validated.payload) - .collect(); - - let prepared = crate::application::skill_changes::compare_update_subjects( - &initial_subjects, - &latest_subjects, - payloads, - )?; - for skill_name in prepared.stale_skill_names { - if !skills + // 共享的只读观察可以复用;同一物理位置涉及写入时,仍阻断相互竞争的 Skill。 + let mut owners = std::collections::BTreeMap::<_, (BTreeSet, bool)>::new(); + let mut collisions = BTreeSet::new(); + for item in &prepared.items { + for entry in item + .plan + .units .iter() - .any(|result| result.skill_identity.skill_name == skill_name) + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) { - let source_result_id = source_by_skill - .get(&skill_name) - .cloned() - .unwrap_or_default(); - skills.push(not_updated_skill( - &execution.request.context, - skill_name, - source_result_id, - ErrorReport::from_app_error( - AppError::StaleTarget, - Some(execution.request.context.clone()), - ), - )); + let (names, has_write) = owners.entry(entry.key.clone()).or_default(); + let writes = !matches!( + entry.action, + crate::application::mutation::plan::PreparedEntryAction::Keep + ); + if (*has_write || writes) && names.iter().any(|name| name != &item.name) { + collisions.extend(names.iter().cloned()); + collisions.insert(item.name.clone()); + } + names.insert(item.name.clone()); + *has_write |= writes; } } - payloads = prepared - .ready - .into_iter() - .map(|prepared| prepared.payload) - .collect(); - executable_names.extend(payloads.iter().map(|payload| payload.name().to_string())); - - if !executable_names.is_empty() { - let successful_request = UpdateRequest { - context: execution.request.context.clone(), - skill_names: executable_names.clone(), + let mut valid = Vec::new(); + for item in prepared.items { + let error = if collisions.contains(&item.name) { + Some(AppError::StaleTarget) + } else { + self.payloads.pin_verified(&item.handle).await.err() }; - let mut overwrite_private_entries = execution.overwrite_private_entries.clone(); - for skill_name in &executable_names { - if let Some(inspection) = latest_by_name.get(skill_name.as_str()) { - overwrite_private_entries.extend( - inspection - .clean_copies + if let Some(error) = error { + prepared + .preview + .skills + .retain(|skill| skill.skill_name != item.name); + prepared.preview.blocked.push(UpdatePreparationIssue { + skill_name: item.name, + error, + }); + } else { + valid.push(item); + } + } + prepared.items = valid; + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + Ok(prepared) + } + + pub async fn execute_prepared( + &self, + prepared: PreparedUpdate, + overwrite: &[ObservedEntryId], + cancellation: CancellationSignal, + observe: F, + ) -> Result + where + F: Fn(UpdateExecutionProgress) + Send + Sync, + { + use crate::application::mutation::plan::PreparedEntryAction; + let selected = overwrite.iter().cloned().collect::>(); + let selectable = prepared + .preview + .skills + .iter() + .flat_map(|skill| { + skill + .targets + .iter() + .filter_map(|target| target.selectable_entry_id.clone()) + .chain( + skill + .overwrite_private_entries .iter() .map(|entry| entry.entry_id.clone()), + ) + }) + .collect::>(); + if selected.len() != overwrite.len() || !selected.is_subset(&selectable) { + return Err(AppError::StaleTarget); + } + let mut results = prepared + .preview + .blocked + .iter() + .map(|issue| { + not_updated_skill( + &prepared.request.context, + issue.skill_name.clone(), + prepared + .source_by_skill + .get(&issue.skill_name) + .cloned() + .unwrap_or_default(), + ErrorReport::from_app_error( + issue.error.clone(), + Some(prepared.request.context.clone()), + ), + ) + }) + .collect::>(); + let total = u32::try_from(prepared.items.len()).unwrap_or(u32::MAX); + results.extend( + prepared + .preview + .skills + .iter() + .filter(|skill| skill.blocking_reasons == [OperationErrorCode::NoUpdateTargets]) + .map(|skill| { + not_updated_skill( + &prepared.request.context, + skill.skill_name.clone(), + prepared + .source_by_skill + .get(&skill.skill_name) + .cloned() + .unwrap_or_default(), + ErrorReport::new(OperationErrorCode::NoUpdateTargets), + ) + }), + ); + for (index, mut item) in prepared.items.into_iter().enumerate() { + let skipped_paths = prepared + .preview + .skills + .iter() + .filter(|skill| skill.skill_name == item.name) + .flat_map(|skill| { + skill + .targets + .iter() + .filter_map(|target| { + target + .selectable_entry_id + .as_ref() + .map(|id| (id, &target.display_path)) + }) + .chain( + skill + .overwrite_private_entries + .iter() + .map(|entry| (&entry.entry_id, &entry.display_path)), + ) + }) + .filter(|(id, _)| !selected.contains(id)) + .map(|(_, path)| path.clone()) + .collect::>(); + let result = async { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + observe(UpdateExecutionProgress { + stage: UpdateExecutionStage::Validating, + subject: Some(item.name.clone()), + current: Some(index as u32), + total: Some(total), + }); + self.payloads.pin_verified(&item.handle).await?; + let mut preserved = false; + for unit in &mut item.plan.units { + for entry in unit + .primary_entry + .iter_mut() + .chain(&mut unit.additional_entries) + { + let expected = unit + .expected_targets + .iter() + .find(|expected| expected.key == entry.key) + .ok_or(AppError::StaleTarget)?; + let id = crate::environment::runtime::observed_entry_id( + &expected.key, + &expected.fingerprint, + )?; + if selectable.contains(&id) && !selected.contains(&id) { + entry.action = PreparedEntryAction::Keep; + preserved = true; + } + } + } + let has_write = item + .plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .any(|entry| !matches!(entry.action, PreparedEntryAction::Keep)); + if !has_write { + let mut result = not_updated_skill( + &prepared.request.context, + item.name.clone(), + item.source_result_id.clone(), + ErrorReport::new(OperationErrorCode::NoUpdateTargets), ); + result.skipped_copy_paths = + (!skipped_paths.is_empty()).then_some(skipped_paths); + return Ok(result); } - } - overwrite_private_entries.sort(); - overwrite_private_entries.dedup(); - let successful_execution = UpdateExecutionRequest { - request: successful_request, - overwrite_private_entries, - }; - let (actual_token, plan) = self.planner.build(&successful_execution, payloads).await?; - validate_same_scope_revisions( - &PreviewScopeRevisions::from(&latest.token), - &PreviewScopeRevisions::from(&actual_token), - )?; - let total = u32::try_from(plan.units.len()).unwrap_or(u32::MAX); - observe_stage(UpdateExecutionProgress { - stage: UpdateExecutionStage::Updating, - subject: None, - current: Some(0), - total: Some(total), - }); - let unit_observer: MutationUnitObserver<'_> = Arc::new(|progress| { - observe_stage(UpdateExecutionProgress { + observe(UpdateExecutionProgress { stage: UpdateExecutionStage::Updating, - subject: Some(progress.skill_name), - current: Some(progress.current), - total: Some(progress.total), + subject: Some(item.name.clone()), + current: Some(index as u32), + total: Some(total), }); - }); - let mutations = self - .executor - .execute_with_observer(plan, cancellation, unit_observer) - .await; - let mut mutation_by_skill = mutations - .into_iter() - .map(|result| (result.skill_name.clone(), result)) - .collect::>(); - for skill_name in executable_names { - let inspection = latest_by_name.get(skill_name.as_str()); - let preserved = inspection.is_some_and(|inspection| { - inspection - .conflicts - .iter() - .any(|entry| !selected.contains(&entry.entry_id)) + let observer: MutationUnitObserver<'_> = Arc::new(|progress| { + observe(UpdateExecutionProgress { + stage: UpdateExecutionStage::Updating, + subject: Some(progress.skill_name), + current: Some((index as u32).saturating_add(progress.current)), + total: Some(total), + }) }); - let mutation = mutation_by_skill.remove(&skill_name); + let mutation = self + .executor + .execute_with_observer(item.plan, cancellation.clone(), observer) + .await + .into_iter() + .find(|result| result.skill_name == item.name); let (coverage, warnings, retryable) = - update_coverage(mutation.as_ref(), preserved, &execution.request.context); - skills.push(UpdateSkillResult { + update_coverage(mutation.as_ref(), preserved, &prepared.request.context); + Ok::<_, AppError>(UpdateSkillResult { + skipped_copy_paths: (!skipped_paths.is_empty()).then_some(skipped_paths), skill_identity: SkillIdentity { - context: execution.request.context.clone(), - skill_name: skill_name.clone(), + context: prepared.request.context.clone(), + skill_name: item.name.clone(), }, - source_result_id: source_by_skill - .get(&skill_name) - .cloned() - .unwrap_or_default(), + source_result_id: item.source_result_id.clone(), mutation, coverage, warnings, retryable, - }); + }) } + .await; + results.push(result.unwrap_or_else(|error| { + not_updated_skill( + &prepared.request.context, + item.name, + item.source_result_id, + ErrorReport::from_app_error(error, Some(prepared.request.context.clone())), + ) + })); } - skills.sort_by(|left, right| { - left.skill_identity - .skill_name - .cmp(&right.skill_identity.skill_name) - }); - let outcome = update_outcome(&skills); + let outcome = update_outcome(&results); Ok(UpdateResponse { - sources, - skills, + sources: prepared.sources, + skills: results, outcome, }) } @@ -733,6 +965,7 @@ fn not_updated_skill( report: ErrorReport, ) -> UpdateSkillResult { UpdateSkillResult { + skipped_copy_paths: None, skill_identity: SkillIdentity { context: context.clone(), skill_name, @@ -768,8 +1001,8 @@ fn update_coverage( if mutation.status == MutationUnitStatus::Succeeded { return if preserved { ( - UpdateCoverage::PreservedConflicts, - vec![UpdateWarningCode::PreservedConflictingCopy], + UpdateCoverage::UpdatedWithSkippedCopies, + vec![UpdateWarningCode::SkippedCopy], mutation.retryable, ) } else { @@ -804,14 +1037,21 @@ pub fn validate_update_request(request: &UpdateRequest) -> Result<(), AppError> Ok(()) } -fn preview_from_inspection(inspection: LocalUpdateInspection) -> Result { +fn previews_from_inspection( + inspection: LocalUpdateInspection, +) -> Result, AppError> { let display_by_name = inspection .source_candidates .iter() .map(|skill| { let identity = SourceIdentity::from_metadata(&skill.metadata())?; let ref_display = match identity.normalized_ref() { - NormalizedRef::Default => "HEAD".to_string(), + _ if identity.remote().provider() + == &crate::core::source_identity::SourceProvider::WellKnown => + { + String::new() + } + NormalizedRef::Default => String::new(), NormalizedRef::Named(value) => value.clone(), }; Ok(( @@ -820,54 +1060,72 @@ fn preview_from_inspection(inspection: LocalUpdateInspection) -> Result, AppError>>()?; - let skills = inspection + inspection .skills .into_iter() .map(|skill| { - let (capability, source_display, ref_display) = display_by_name + let (capability, source_display, ref_display, source_key) = display_by_name .get(&skill.skill_name) .cloned() .ok_or(AppError::StaleContext)?; + let location = |id: &ObservedEntryId| skill.locations.iter().find(|location| &location.entry_id == id); + let selectable_ids = skill.automatic_entries.iter().filter(|entry| { + entry.kind == crate::application::skill_entry_projection::ObservedEntryKind::Directory + && !skill.locations.iter().any(|location| location.entry_id == entry.entry_id && location.is_standard) + }).chain(&skill.conflicts).map(|entry| entry.entry_id.clone()).collect::>(); + let target_preview = |entry: crate::application::skill_entry_projection::ObservedPhysicalEntry| { + let display = location(&entry.entry_id); + UpdateTargetPreview { + selectable_entry_id: selectable_ids.contains(&entry.entry_id).then(|| entry.entry_id.clone()), + is_standard: Some(display.is_some_and(|location| location.is_standard)), + kind: Some(display.map_or(entry.kind, |location| location.kind)), + link_target: display.and_then(|location| location.link_target.clone()), + display_path: display.map_or(entry.display_path, |location| location.path.clone()), + readers: display.map_or(entry.readers, |location| location.readers.clone()), + restoring: entry.kind == crate::application::skill_entry_projection::ObservedEntryKind::Missing, + } + }; + let linked_targets = skill.locations.iter().filter_map(|display| { + let target = display.linked_entry.as_ref()?; + Some(UpdateLinkedTargetPreview { + display_path: display.path.clone(), readers: display.readers.clone(), + is_standard: display.is_standard, + target_path: location(target).map(|target| target.path.clone()) + .or_else(|| display.link_target.clone())?, + target_copy_entry_id: selectable_ids.contains(target).then(|| target.clone()), + }) + }).collect(); Ok(UpdateSkillPreview { + linked_targets, + source_key: Some(source_key), capability, source_display, ref_display, adapter_targets: skill.adapter_targets, skill_name: skill.skill_name, clean_copy_count: skill.clean_copies.len(), + targets: skill.automatic_entries.into_iter().map(target_preview).collect(), + preserved_targets: (!skill.preserved_entries.is_empty()).then(|| skill.preserved_entries.into_iter().map(target_preview).collect()), overwrite_private_entries: skill .conflicts .into_iter() .map(|entry| UpdateConflictCopyPreview { + is_standard: Some(location(&entry.entry_id).is_some_and(|location| location.is_standard)), + display_path: location(&entry.entry_id).map_or(entry.display_path, |location| location.path.clone()), + readers: location(&entry.entry_id).map_or(entry.readers, |location| location.readers.clone()), entry_id: entry.entry_id, - readers: entry.readers, }) .collect(), blocking_reasons: skill.blocking_reasons, fallback_forecasts: Vec::new(), }) }) - .collect::, AppError>>()?; - Ok(UpdatePreview { - token: inspection.token, - skills, - }) -} - -fn validate_conflict_decisions(execution: &UpdateExecutionRequest) -> Result<(), AppError> { - let mut entries = BTreeSet::new(); - if execution - .overwrite_private_entries - .iter() - .any(|entry| !entries.insert(entry)) - { - return Err(AppError::StalePayload); - } - Ok(()) + .collect() } fn update_outcome(skills: &[UpdateSkillResult]) -> UpdateOutcome { @@ -919,18 +1177,17 @@ fn validation(message: &str) -> AppError { #[cfg(test)] mod tests { use std::fs; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use super::*; use crate::application::mutation::executor::MutationPlanExecutor; use crate::application::mutation::plan::MutationPlan; + use crate::application::payload_session::DiscoverySessionHandle; use crate::application::payload_session::PayloadPlanningMetadata; use crate::application::payload_session::{PayloadSessionLimits, PayloadSessionManager}; - use crate::application::source_evidence::SourceSnapshotFacts; use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::build_skill_payload; - use crate::core::source_identity::NormalizedRef; use crate::environment::runtime::ContextSnapshotRevision; use crate::environment::types::{EnvironmentRef, SkillLocation}; use crate::error::AppError; @@ -968,7 +1225,7 @@ mod tests { } #[test] - fn update_preview_counts_clean_copies_without_exposing_their_entries() { + fn update_preview_exposes_locations_without_internal_target_identity() { fn observed_entry( id: &str, path: &str, @@ -1003,16 +1260,25 @@ mod tests { observed_entry("entry-v1-clean-one", "/agents/clean-one"), observed_entry("entry-v1-clean-two", "/agents/clean-two"), ]; + inspection.skills[0].automatic_entries = inspection.skills[0].clean_copies.clone(); inspection.skills[0].conflicts = vec![observed_entry("entry-v1-conflict", "/agents/conflict")]; - let preview = serde_json::to_value(preview_from_inspection(inspection).unwrap()).unwrap(); - let skill = &preview["skills"][0]; + let preview = serde_json::to_value(previews_from_inspection(inspection).unwrap()).unwrap(); + let skill = &preview[0]; assert_eq!(skill["cleanCopyCount"], serde_json::json!(2)); assert!(skill.get("cleanCopies").is_none()); - assert!(!skill.to_string().contains("entry-v1-clean")); - assert!(!skill.to_string().contains("/agents/clean")); + assert_eq!( + skill["targets"][0]["selectableEntryId"], + "entry-v1-clean-one" + ); + assert!(!skill.to_string().contains("credential-secret")); + assert_eq!(skill["targets"].as_array().unwrap().len(), 2); + assert_eq!( + skill["targets"][0]["displayPath"]["nativePath"], + "/agents/clean-one" + ); assert_eq!( skill["overwritePrivateEntries"].as_array().unwrap().len(), 1 @@ -1022,10 +1288,11 @@ mod tests { serde_json::json!("entry-v1-conflict") ); let serialized = skill["overwritePrivateEntries"][0].to_string(); - assert!(!serialized.contains("nativePath")); - assert!(!serialized.contains("displayPath")); + assert_eq!( + skill["overwritePrivateEntries"][0]["displayPath"]["nativePath"], + "/agents/conflict" + ); assert!(!serialized.contains("physicalTargetKey")); - assert!(!serialized.contains("/agents/conflict")); assert!(!serialized.contains("credential-secret")); assert!(serialized.contains("Codex")); } @@ -1073,8 +1340,8 @@ mod tests { }, ]; - let preview = serde_json::to_value(preview_from_inspection(inspection).unwrap()).unwrap(); - let skill = &preview["skills"][0]; + let preview = serde_json::to_value(previews_from_inspection(inspection).unwrap()).unwrap(); + let skill = &preview[0]; assert_eq!( skill["sourceDisplay"], @@ -1088,7 +1355,10 @@ mod tests { let serialized = preview.to_string(); assert!(!serialized.contains("secret-token")); assert!(!serialized.contains("sourceUrl")); - assert!(!serialized.contains("/agents/private")); + assert_eq!( + skill["overwritePrivateEntries"][0]["displayPath"]["nativePath"], + "/agents/private" + ); assert!(!serialized.contains("credential-secret")); } @@ -1128,23 +1398,119 @@ mod tests { ); } - struct Planner { - token: PreviewToken, - rebuilds: Arc, - } - - fn update_test_manager() -> Arc { - Arc::new(PayloadSessionManager::in_memory( - PayloadSessionLimits { - ttl_ms: 60_000, - max_sessions: 4, - max_bytes: 1_000_000, - }, - || 1_000, - )) - } - - fn update_test_token() -> PreviewToken { + fn update_test_plan( + request: &UpdateRequest, + payloads: Vec, + token: &PreviewToken, + keep_entries: bool, + ) -> MutationPlan { + use crate::application::mutation::plan::{ + ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, RuntimeRevisions, + }; + use crate::application::mutation::planning::{ + assemble_plan, MutationPlanDraft, MutationUnitDraft, PreparedMutationEntries, + }; + use crate::environment::runtime::{ + EntryFingerprint, ExecutionBackend, PhysicalParentIdentity, PhysicalTargetKey, + }; + let units = payloads + .iter() + .zip(&request.skill_names) + .map(|(payload, name)| { + let key = PhysicalTargetKey { + backend: if cfg!(windows) { + ExecutionBackend::NativeWindows + } else { + ExecutionBackend::NativeUnix + }, + physical_parent: if cfg!(windows) { + PhysicalParentIdentity::Windows { + volume_serial: 1, + file_id: 2, + } + } else { + PhysicalParentIdentity::Unix { + device: 1, + inode: 2, + } + }, + normalized_final_child_name: name.clone(), + }; + let fingerprint = EntryFingerprint(format!("entry-{name}")); + MutationUnitDraft { + id: format!("update:{name}"), + skill_name: name.clone(), + source: None, + target: request.context.clone(), + expected_revisions: RuntimeRevisions { + registry: token.registry_revision.clone(), + environment: token.environment_revision.clone(), + context: token.context_revision.clone(), + }, + entries: PreparedMutationEntries { + primary: Some(PreparedEntryMutation { + key: key.clone(), + destination: crate::environment::types::ResourceLocator { + environment: request.context.environment.clone(), + native_path: std::env::temp_dir() + .join("skill-deck-update-test") + .join(name) + .to_string_lossy() + .into_owned(), + }, + action: if keep_entries { + PreparedEntryAction::Keep + } else { + PreparedEntryAction::Replace { + payload_id: payload.manifest().payload_id().clone(), + requested_mode: crate::models::InstallMode::Copy, + } + }, + reader_agent_ids: Vec::new(), + }), + additional: Vec::new(), + expected_targets: vec![ExpectedTargetEntry { + key, + fingerprint, + expected_content_manifest_hash: None, + }], + }, + lock_mutation: None, + } + }) + .collect(); + assemble_plan(MutationPlanDraft { + kind: crate::core::mutation::MutationKind::Update, + payloads: payloads + .into_iter() + .map(|payload| { + ( + payload.manifest().payload_id().clone(), + payload.into_lease(), + ) + }) + .collect(), + units, + }) + } + + struct Planner { + token: PreviewToken, + rebuilds: Arc, + } + + fn update_test_manager() -> Arc { + Arc::new(PayloadSessionManager::in_memory( + PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 1_000_000, + }, + || 1_000, + )) + } + + fn update_test_token() -> PreviewToken { PreviewToken { generation: "preview-initial".to_string(), registry_revision: "registry-1".to_string(), @@ -1172,7 +1538,7 @@ mod tests { async fn acquired_update_source( manager: &PayloadSessionManager, skill_name: &str, - ) -> (SourceSnapshotFacts, AcquiredPayloadHandle) { + ) -> (DiscoverySessionHandle, AcquiredPayloadHandle) { let source = tempdir().unwrap(); let skill = source.path().join(skill_name); fs::create_dir_all(&skill).unwrap(); @@ -1194,16 +1560,7 @@ mod tests { ) .await .unwrap(); - let facts = SourceSnapshotFacts { - discovery_session: discovery, - snapshot_id: RemoteSnapshotId::new( - NormalizedRef::Named("main".to_string()), - "main", - "revision-1", - ), - complete_skill_path_catalog: Default::default(), - }; - (facts, handle) + (discovery, handle) } fn locked_update_skill(name: &str, source_url: &str) -> LockedUpdateSkill { @@ -1223,116 +1580,307 @@ mod tests { } } - fn update_subject_snapshot( - names: &[(&str, &str)], - ) -> crate::application::update_subjects::UpdateSubjectSnapshot { - crate::application::update_subjects::UpdateSubjectSnapshot { - environment: EnvironmentRef::Native, - resolution_revision: crate::application::skill_paths::RootResolutionRevision::for_test( - "collection-v1", - ), - document_revision: crate::application::collection_records::DocumentRevision::for_test( - "document-v1", - ), - subjects: names - .iter() - .map( - |(name, source_revision)| crate::application::update_subjects::UpdateSubject { - skill_name: (*name).to_string(), - source_record_revision: - crate::application::collection_records::SourceRecordRevision::for_test( - source_revision, - ), - target_revision: crate::application::skill_paths::TargetRevision::for_test( - &format!("target-{name}"), - ), - content_revision: - crate::application::skill_paths::ContentRevision::missing_for_test(), - projection: - crate::application::collection_records::RecordProjection::Available( - locked_update_skill(name, "https://github.com/owner/repo.git") - .metadata(), - ), - }, - ) - .collect(), - } - } - impl UpdatePlanner for Planner { - fn inspect<'a>( + fn inspect_items<'a>( &'a self, - _request: &'a UpdateRequest, - ) -> UpdateFuture<'a, Result> { + request: &'a UpdateRequest, + ) -> UpdateFuture<'a, Vec<(String, Result)>> { Box::pin(async move { - Ok(LocalUpdateInspection { - token: self.token.clone(), - source_candidates: vec![LockedUpdateSkill { - name: "demo".to_string(), - lock_key: "demo".to_string(), - source: "owner/repo".to_string(), - source_type: "github".to_string(), - source_url: Some("https://github.com/owner/repo.git".to_string()), - ref_name: Some("main".to_string()), - skill_path: "skills/demo".to_string(), - remote_hash: Some("old".to_string()), - computed_hash: None, - installed_at: None, - subagents: None, - well_known_digest: None, - }], - skills: vec![ - crate::application::update_planner::LocalUpdateSkillInspection { - skill_name: "demo".to_string(), - agent_observed_digest: "demo-agent-observed".to_string(), - adapter_targets: Vec::new(), - clean_copies: Vec::new(), - conflicts: Vec::new(), - blocking_reasons: Vec::new(), - }, - ], - subjects: update_subject_snapshot(&[("demo", "source-demo")]), - }) + request + .skill_names + .iter() + .map(|name| { + ( + name.clone(), + Ok(LocalUpdateInspection { + path_base: None, + token: self.token.clone(), + source_candidates: vec![locked_update_skill( + name, + "https://github.com/owner/repo.git", + )], + skills: vec![ + crate::application::update_planner::LocalUpdateSkillInspection { + locations: Vec::new(), + skill_name: name.clone(), + adapter_targets: Vec::new(), + automatic_entries: Vec::new(), + preserved_entries: Vec::new(), + clean_copies: Vec::new(), + conflicts: Vec::new(), + blocking_reasons: Vec::new(), + }, + ], + }), + ) + }) + .collect() }) } fn build<'a>( &'a self, - _execution: &'a UpdateExecutionRequest, + execution: &'a UpdateExecutionRequest, payloads: Vec, ) -> UpdateFuture<'a, Result<(PreviewToken, MutationPlan), AppError>> { self.rebuilds.fetch_add(1, Ordering::SeqCst); Box::pin(async move { Ok(( self.token.clone(), - MutationPlan { - kind: crate::core::mutation::MutationKind::Update, - operation_id: "update-1".to_string(), - payloads: payloads - .into_iter() - .map(|payload| { - let id = payload.manifest().payload_id().clone(); - (id, payload.into_lease()) - }) - .collect(), - units: Vec::new(), - }, + update_test_plan(&execution.request, payloads, &self.token, false), )) }) } } - struct Acquirer(Arc); + #[test] + fn cancellation_and_window_replacement_cannot_publish_late_results() { + use crate::application::update_preparation::{PreparedUpdates, UpdatePreparations}; + + let prepared = || { + PreparedUpdates::Direct(PreparedUpdate { + request: UpdateRequest { + context: SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }, + skill_names: vec!["demo".into()], + }, + preview: PreparedUpdatePreview { + sources: Vec::new(), + path_base: None, + skills: Vec::new(), + blocked: Vec::new(), + redirected_download_hosts: Vec::new(), + }, + items: Vec::new(), + sources: Vec::new(), + source_by_skill: Default::default(), + }) + }; + let registry = UpdatePreparations::default(); + let id = uuid::Uuid::new_v4().to_string(); + let cancelled = registry.begin(id.clone(), "main").unwrap(); + registry.cancel(&id, "main").unwrap(); + assert!(cancelled.cancellation.is_cancelled()); - impl SkillSourceModule for Acquirer { - fn acquire_saved_groups<'a>( - &'a self, - _groups: &'a [UpdateAcquisitionGroup], - _cancellation: CancellationSignal, - ) -> UpdateFuture<'a, Result, AppError>> { - self.0.fetch_add(1, Ordering::SeqCst); - Box::pin(async { Ok(Vec::new()) }) + let replacement = registry.begin(id.clone(), "main").unwrap(); + assert_eq!( + cancelled.publish(prepared()), + Err(AppError::MutationCancelled) + ); + assert!(!replacement.cancellation.is_cancelled()); + assert_eq!( + registry.cancel(&id, "other-window"), + Err(AppError::StaleContext) + ); + replacement.publish(prepared()).unwrap(); + assert!(matches!( + registry.take(&id, "other-window"), + Err(AppError::StalePayload) + )); + assert!(matches!( + registry.take(&id, "main"), + Ok(PreparedUpdates::Direct(_)) + )); + assert!(matches!( + registry.take(&id, "main"), + Err(AppError::StalePayload) + )); + + let old_window_operation = registry + .begin(uuid::Uuid::new_v4().to_string(), "main") + .unwrap(); + let current_window_operation = registry + .begin(uuid::Uuid::new_v4().to_string(), "main") + .unwrap(); + assert!(old_window_operation.cancellation.is_cancelled()); + assert_eq!( + old_window_operation.publish(prepared()), + Err(AppError::MutationCancelled) + ); + assert!(!current_window_operation.cancellation.is_cancelled()); + registry.close_window("main"); + assert!(current_window_operation.cancellation.is_cancelled()); + assert_eq!( + current_window_operation.publish(prepared()), + Err(AppError::MutationCancelled) + ); + } + + #[tokio::test] + async fn expiry_releases_only_the_expired_member_and_executes_other_content() { + let now = Arc::new(AtomicU64::new(1_000)); + let clock = now.clone(); + let manager = Arc::new(PayloadSessionManager::in_memory( + PayloadSessionLimits { + ttl_ms: 100, + max_sessions: 4, + max_bytes: 1_000_000, + }, + move || clock.load(Ordering::SeqCst), + )); + let (alpha_source, alpha) = acquired_update_source(&manager, "alpha").await; + now.store(1_050, Ordering::SeqCst); + let (beta_source, beta) = acquired_update_source(&manager, "beta").await; + let planner = Planner { + token: update_test_token(), + rebuilds: Arc::new(AtomicUsize::new(0)), + }; + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + let mut items = Vec::new(); + for (name, source, handle) in [ + ("alpha", alpha_source, alpha), + ("beta", beta_source, beta.clone()), + ] { + let payload = ValidatedSkillPayload::validate( + handle.clone(), + &source, + &context.environment, + name, + manager.pin_verified(&handle).await.unwrap(), + ) + .await + .unwrap(); + let execution = UpdateExecutionRequest { + request: UpdateRequest { + context: context.clone(), + skill_names: vec![name.into()], + }, + overwrite_private_entries: Vec::new(), + }; + let (_, plan) = planner.build(&execution, vec![payload]).await.unwrap(); + items.push(PreparedUpdateItem { + name: name.into(), + source_result_id: "source-1".into(), + handle, + plan, + }); } + let selected = ObservedEntryId::parse("entry-v1-alpha-private").unwrap(); + let mut skills = previews_from_inspection(inspection(update_test_token(), "old")).unwrap(); + skills.truncate(1); + skills[0].skill_name = "alpha".into(); + skills[0].overwrite_private_entries = vec![UpdateConflictCopyPreview { + is_standard: Some(false), + entry_id: selected.clone(), + readers: Vec::new(), + display_path: crate::environment::types::ResourceLocator { + environment: context.environment.clone(), + native_path: "/agents/private".to_string(), + }, + }]; + let mut prepared = PreparedUpdate { + request: UpdateRequest { + context, + skill_names: vec!["alpha".into(), "beta".into()], + }, + preview: PreparedUpdatePreview { + sources: Vec::new(), + path_base: None, + skills, + blocked: Vec::new(), + redirected_download_hosts: Vec::new(), + }, + items, + sources: Vec::new(), + source_by_skill: Default::default(), + }; + now.store(1_101, Ordering::SeqCst); + prepared.expire_payloads(1_101); + assert_eq!(prepared.items.len(), 1); + assert_eq!(prepared.preview.blocked[0].skill_name, "alpha"); + assert_eq!(manager.cleanup().await.unwrap(), 1); + assert!(manager.pin_verified(&beta).await.is_ok()); + let executions = Arc::new(AtomicUsize::new(0)); + let service = UpdateService::new( + manager, + planner, + FailingAcquirer, + ResultExecutor { + calls: executions.clone(), + expected_payload_count: 1, + results: vec![mutation_result("beta", MutationUnitStatus::Succeeded)], + }, + ); + let result = service + .execute_prepared(prepared, &[selected], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(result.outcome, UpdateOutcome::Partial); + assert_eq!(executions.load(Ordering::SeqCst), 1); + assert!(result + .skills + .iter() + .find(|skill| skill.skill_identity.skill_name == "beta") + .unwrap() + .mutation + .as_ref() + .is_some_and(|mutation| mutation.status == MutationUnitStatus::Succeeded)); + } + + #[tokio::test] + async fn prepared_update_executes_without_reacquiring_or_replanning() { + let manager = update_test_manager(); + let (discovery_session, handle) = acquired_update_source(&manager, "demo").await; + let calls = Arc::new(AtomicUsize::new(0)); + let builds = Arc::new(AtomicUsize::new(0)); + let executed = Arc::new(AtomicUsize::new(0)); + let service = UpdateService::new( + manager, + Planner { + token: update_test_token(), + rebuilds: builds.clone(), + }, + FixedAcquirer { + calls: calls.clone(), + expected_group_count: 1, + acquisitions: Mutex::new(Some(vec![UpdateSourceAcquisition { + source_result_id: "source-1".into(), + source: "owner/repo".into(), + skill_names: vec!["demo".into()], + result: Ok(AcquiredUpdateSource { + discovery_session, + payloads: vec![("demo".into(), handle)], + skill_errors: Vec::new(), + redirected_download_hosts: Vec::new(), + _leases: Vec::new(), + }), + }])), + }, + ResultExecutor { + calls: executed.clone(), + expected_payload_count: 1, + results: vec![mutation_result("demo", MutationUnitStatus::Succeeded)], + }, + ); + let request = UpdateRequest { + context: SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }, + skill_names: vec!["demo".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert_eq!(prepared.preview.skills.len(), 1); + assert!(prepared.preview.blocked.is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(builds.load(Ordering::SeqCst), 1); + assert_eq!(executed.load(Ordering::SeqCst), 0); + let response = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(response.outcome, UpdateOutcome::Succeeded); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(builds.load(Ordering::SeqCst), 1); + assert_eq!(executed.load(Ordering::SeqCst), 1); } struct FailingAcquirer; @@ -1387,41 +1935,63 @@ mod tests { } } - struct SequencedPlanner { - inspections: Mutex>, + struct PreparedPlanner { + keep_entries: bool, + fail_inspection: Option, + invalid_source: Option, builds: Arc, build_token: PreviewToken, } - impl UpdatePlanner for SequencedPlanner { - fn inspect<'a>( + impl UpdatePlanner for PreparedPlanner { + fn inspect_items<'a>( &'a self, - _request: &'a UpdateRequest, - ) -> UpdateFuture<'a, Result> { - Box::pin(async move { Ok(self.inspections.lock().unwrap().remove(0)) }) + request: &'a UpdateRequest, + ) -> UpdateFuture<'a, Vec<(String, Result)>> { + Box::pin(async move { + request + .skill_names + .iter() + .map(|name| { + let result = if self.fail_inspection.as_ref() == Some(name) { + Err(AppError::Io { + message: "associated target unavailable".into(), + }) + } else { + let mut value = inspection(update_test_token(), "old"); + value.source_candidates.retain(|skill| &skill.name == name); + for skill in &mut value.source_candidates { + skill.source_url = + Some(format!("https://github.com/{}/repo.git", skill.name)); + if self.invalid_source.as_ref() == Some(&skill.name) { + skill.source_type = "git".into(); + skill.source_url = Some("http://".into()); + } + } + value.skills.retain(|skill| &skill.skill_name == name); + Ok(value) + }; + (name.clone(), result) + }) + .collect() + }) } fn build<'a>( &'a self, - _execution: &'a UpdateExecutionRequest, + execution: &'a UpdateExecutionRequest, payloads: Vec, ) -> UpdateFuture<'a, Result<(PreviewToken, MutationPlan), AppError>> { self.builds.fetch_add(1, Ordering::SeqCst); Box::pin(async move { Ok(( self.build_token.clone(), - MutationPlan { - kind: crate::core::mutation::MutationKind::Update, - operation_id: "update-1".to_string(), - payloads: payloads - .into_iter() - .map(|payload| { - let id = payload.manifest().payload_id().clone(); - (id, payload.into_lease()) - }) - .collect(), - units: Vec::new(), - }, + update_test_plan( + &execution.request, + payloads, + &self.build_token, + self.keep_entries, + ), )) }) } @@ -1487,6 +2057,7 @@ mod tests { fn inspection(token: PreviewToken, alpha_hash: &str) -> LocalUpdateInspection { LocalUpdateInspection { + path_base: None, token, source_candidates: vec![ LockedUpdateSkill { @@ -1498,403 +2069,225 @@ mod tests { skills: vec![ crate::application::update_planner::LocalUpdateSkillInspection { skill_name: "alpha".to_string(), - agent_observed_digest: "alpha-agent-observed".to_string(), + locations: Vec::new(), adapter_targets: Vec::new(), + automatic_entries: Vec::new(), + preserved_entries: Vec::new(), clean_copies: Vec::new(), conflicts: Vec::new(), blocking_reasons: Vec::new(), }, crate::application::update_planner::LocalUpdateSkillInspection { skill_name: "beta".to_string(), - agent_observed_digest: "beta-agent-observed".to_string(), + locations: Vec::new(), adapter_targets: Vec::new(), + automatic_entries: Vec::new(), + preserved_entries: Vec::new(), clean_copies: Vec::new(), conflicts: Vec::new(), blocking_reasons: Vec::new(), }, ], - subjects: update_subject_snapshot(&[("alpha", alpha_hash), ("beta", "old")]), + } + } + + async fn beta_acquisition(manager: &PayloadSessionManager) -> UpdateSourceAcquisition { + let (discovery_session, handle) = acquired_update_source(manager, "beta").await; + UpdateSourceAcquisition { + source_result_id: "beta-source".into(), + source: "beta/repo".into(), + skill_names: vec!["beta".into()], + result: Ok(AcquiredUpdateSource { + discovery_session, + payloads: vec![("beta".into(), handle)], + skill_errors: Vec::new(), + redirected_download_hosts: Vec::new(), + _leases: Vec::new(), + }), } } #[tokio::test] - async fn matching_scope_revisions_do_not_override_per_skill_drift() { + async fn preparation_blocks_a_bad_association_but_keeps_an_independent_skill_ready() { let manager = update_test_manager(); - let source = tempdir().unwrap(); - for name in ["alpha", "beta"] { - let root = source.path().join(name); - fs::create_dir_all(&root).unwrap(); - fs::write( - root.join("SKILL.md"), - format!("---\nname: {name}\ndescription: {name}\n---\n"), - ) - .unwrap(); - } - let discovery = manager - .discover(EnvironmentRef::Native, "source") - .await - .unwrap(); - let mut payloads = Vec::new(); - for name in ["alpha", "beta"] { - payloads.push(( - name.to_string(), - manager - .acquire_payload_with_metadata( - &discovery, - format!("skills/{name}"), - build_skill_payload(&source.path().join(name)).unwrap(), - update_payload_metadata(name), - ) - .await - .unwrap(), - )); - } - let token = update_test_token(); - let facts = SourceSnapshotFacts { - discovery_session: discovery, - snapshot_id: RemoteSnapshotId::new( - NormalizedRef::Named("main".to_string()), - "main", - "revision-1", - ), - complete_skill_path_catalog: Default::default(), - }; - let calls = Arc::new(AtomicUsize::new(0)); - let builds = Arc::new(AtomicUsize::new(0)); + let acquisition = beta_acquisition(&manager).await; let executed = Arc::new(AtomicUsize::new(0)); let service = UpdateService::new( - Arc::clone(&manager), - SequencedPlanner { - inspections: Mutex::new(vec![ - inspection(token.clone(), "old"), - inspection( - PreviewToken { - generation: "preview-latest".to_string(), - ..token.clone() - }, - "changed", - ), - ]), - builds: Arc::clone(&builds), - build_token: PreviewToken { - generation: "rebuilt".to_string(), - ..token.clone() - }, + manager, + PreparedPlanner { + keep_entries: false, + fail_inspection: Some("alpha".into()), + invalid_source: None, + builds: Arc::new(AtomicUsize::new(0)), + build_token: update_test_token(), }, FixedAcquirer { - calls: Arc::clone(&calls), + acquisitions: Mutex::new(Some(vec![acquisition])), + calls: Arc::new(AtomicUsize::new(0)), expected_group_count: 1, - acquisitions: Mutex::new(Some(vec![UpdateSourceAcquisition { - source_result_id: "source-1".to_string(), - source: "owner/repo".to_string(), - skill_names: vec!["alpha".to_string(), "beta".to_string()], - result: Ok(AcquiredUpdateSource { - facts, - payloads, - skill_errors: Vec::new(), - redirected_download_host: None, - }), - }])), }, ResultExecutor { - calls: Arc::clone(&executed), - expected_payload_count: 1, + calls: executed.clone(), results: vec![mutation_result("beta", MutationUnitStatus::Succeeded)], + expected_payload_count: 1, }, ); - - let stages = Arc::new(Mutex::new(Vec::new())); - let observed_stages = Arc::clone(&stages); + let request = UpdateRequest { + context: mutation_result("beta", MutationUnitStatus::Succeeded).target, + skill_names: vec!["alpha".into(), "beta".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert_eq!(prepared.preview.skills[0].skill_name, "beta"); + assert_eq!(prepared.preview.blocked[0].skill_name, "alpha"); + assert_eq!(executed.load(Ordering::SeqCst), 0); let response = service - .execute_with_stage_observer( - &UpdateExecutionRequest { - request: UpdateRequest { - context: SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }, - skill_names: vec!["alpha".to_string(), "beta".to_string()], - }, - overwrite_private_entries: Vec::new(), - }, - token, - CancellationSignal::default(), - move |stage| observed_stages.lock().unwrap().push(stage), - ) + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) .await .unwrap(); - - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(builds.load(Ordering::SeqCst), 1); - assert_eq!(executed.load(Ordering::SeqCst), 1); - assert_eq!( - *stages.lock().unwrap(), - vec![ - UpdateExecutionProgress { - stage: UpdateExecutionStage::Validating, - subject: None, - current: None, - total: None, - }, - UpdateExecutionProgress { - stage: UpdateExecutionStage::Updating, - subject: None, - current: Some(0), - total: Some(0), - }, - ] - ); - assert!(matches!( - response - .skills - .iter() - .find(|skill| skill.skill_identity.skill_name == "alpha") - .unwrap() - .coverage, - UpdateCoverage::NotUpdated { .. } - )); - assert_eq!( - response - .skills - .iter() - .find(|skill| skill.skill_identity.skill_name == "beta") - .unwrap() - .coverage, - UpdateCoverage::Updated - ); assert_eq!(response.outcome, UpdateOutcome::Partial); + assert_eq!(executed.load(Ordering::SeqCst), 1); } #[tokio::test] - async fn scope_revision_change_after_acquisition_aborts_before_plan_build() { + async fn prepared_update_with_only_kept_entries_does_not_reach_the_executor() { let manager = update_test_manager(); - let token = update_test_token(); - let acquire_calls = Arc::new(AtomicUsize::new(0)); - let builds = Arc::new(AtomicUsize::new(0)); - let executor_calls = Arc::new(AtomicUsize::new(0)); + let acquisition = beta_acquisition(&manager).await; + let executed = Arc::new(AtomicUsize::new(0)); let service = UpdateService::new( - Arc::clone(&manager), - SequencedPlanner { - inspections: Mutex::new(vec![ - inspection(token.clone(), "old"), - inspection( - PreviewToken { - registry_revision: "registry-2".to_string(), - ..token.clone() - }, - "old", - ), - ]), - builds: Arc::clone(&builds), - build_token: token.clone(), + manager, + PreparedPlanner { + keep_entries: true, + fail_inspection: None, + invalid_source: None, + builds: Arc::new(AtomicUsize::new(0)), + build_token: update_test_token(), }, FixedAcquirer { - calls: Arc::clone(&acquire_calls), + acquisitions: Mutex::new(Some(vec![acquisition])), + calls: Arc::new(AtomicUsize::new(0)), expected_group_count: 1, - acquisitions: Mutex::new(Some(Vec::new())), }, ResultExecutor { - calls: Arc::clone(&executor_calls), - expected_payload_count: 0, - results: Vec::new(), + calls: executed.clone(), + results: vec![mutation_result("beta", MutationUnitStatus::Succeeded)], + expected_payload_count: 1, }, ); - - let result = service - .execute( - &UpdateExecutionRequest { - request: UpdateRequest { - context: SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }, - skill_names: vec!["alpha".to_string(), "beta".to_string()], - }, - overwrite_private_entries: Vec::new(), + let prepared = service + .prepare( + &UpdateRequest { + context: mutation_result("beta", MutationUnitStatus::Succeeded).target, + skill_names: vec!["beta".into()], }, - token, CancellationSignal::default(), ) - .await; - - assert!(matches!(result, Err(AppError::StaleRegistry))); - assert_eq!(acquire_calls.load(Ordering::SeqCst), 1); - assert_eq!(builds.load(Ordering::SeqCst), 0); - assert_eq!(executor_calls.load(Ordering::SeqCst), 0); + .await + .unwrap(); + let response = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(executed.load(Ordering::SeqCst), 0); + assert!(response.skills[0].mutation.is_none()); + assert!(matches!( + response.skills[0].coverage, + UpdateCoverage::NotUpdated { .. } + )); } #[tokio::test] - async fn scope_revision_change_after_plan_build_aborts_before_execution() { + async fn changed_environment_during_plan_build_blocks_confirmation() { let manager = update_test_manager(); - let (facts, alpha_handle) = acquired_update_source(&manager, "alpha").await; - let token = update_test_token(); - let mut initial = inspection(token.clone(), "old"); - initial.source_candidates.truncate(1); - initial.skills.truncate(1); - let acquire_calls = Arc::new(AtomicUsize::new(0)); - let builds = Arc::new(AtomicUsize::new(0)); - let executor_calls = Arc::new(AtomicUsize::new(0)); + let acquisition = beta_acquisition(&manager).await; + let executed = Arc::new(AtomicUsize::new(0)); let service = UpdateService::new( - Arc::clone(&manager), - SequencedPlanner { - inspections: Mutex::new(vec![initial.clone(), initial]), - builds: Arc::clone(&builds), + manager, + PreparedPlanner { + keep_entries: false, + fail_inspection: None, + invalid_source: None, + builds: Arc::new(AtomicUsize::new(0)), build_token: PreviewToken { - environment_revision: "environment-2".to_string(), - ..token.clone() + environment_revision: "changed".into(), + ..update_test_token() }, }, FixedAcquirer { - calls: Arc::clone(&acquire_calls), + acquisitions: Mutex::new(Some(vec![acquisition])), + calls: Arc::new(AtomicUsize::new(0)), expected_group_count: 1, - acquisitions: Mutex::new(Some(vec![UpdateSourceAcquisition { - source_result_id: "source-1".to_string(), - source: "owner/repo".to_string(), - skill_names: vec!["alpha".to_string()], - result: Ok(AcquiredUpdateSource { - facts, - payloads: vec![("alpha".to_string(), alpha_handle)], - skill_errors: Vec::new(), - redirected_download_host: None, - }), - }])), }, ResultExecutor { - calls: Arc::clone(&executor_calls), - expected_payload_count: 1, + calls: executed.clone(), results: Vec::new(), + expected_payload_count: 1, }, ); - - let result = service - .execute( - &UpdateExecutionRequest { - request: UpdateRequest { - context: SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }, - skill_names: vec!["alpha".to_string()], - }, - overwrite_private_entries: Vec::new(), - }, - token, - CancellationSignal::default(), - ) - .await; - - assert!(matches!(result, Err(AppError::StaleEnvironment))); - assert_eq!(acquire_calls.load(Ordering::SeqCst), 1); - assert_eq!(builds.load(Ordering::SeqCst), 1); - assert_eq!(executor_calls.load(Ordering::SeqCst), 0); + let request = UpdateRequest { + context: mutation_result("beta", MutationUnitStatus::Succeeded).target, + skill_names: vec!["beta".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!(prepared.preview.skills.is_empty()); + assert_eq!( + prepared.preview.blocked[0].error, + AppError::StaleEnvironment + ); + assert_eq!(executed.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn mixed_source_failure_keeps_the_other_source_executable_in_one_coordinator_run() { + async fn mixed_source_failure_keeps_an_independent_source_executable() { let manager = update_test_manager(); - let (facts, alpha_handle) = acquired_update_source(&manager, "alpha").await; - let token = update_test_token(); - let inspection = LocalUpdateInspection { - token: token.clone(), - source_candidates: vec![ - locked_update_skill("alpha", "https://github.com/owner/alpha.git"), - locked_update_skill("beta", "https://github.com/owner/beta.git"), - ], - skills: vec![ - crate::application::update_planner::LocalUpdateSkillInspection { - skill_name: "alpha".to_string(), - agent_observed_digest: "alpha-agent-observed".to_string(), - adapter_targets: Vec::new(), - clean_copies: Vec::new(), - conflicts: Vec::new(), - blocking_reasons: Vec::new(), - }, - crate::application::update_planner::LocalUpdateSkillInspection { - skill_name: "beta".to_string(), - agent_observed_digest: "beta-agent-observed".to_string(), - adapter_targets: Vec::new(), - clean_copies: Vec::new(), - conflicts: Vec::new(), - blocking_reasons: Vec::new(), - }, - ], - subjects: update_subject_snapshot(&[("alpha", "old"), ("beta", "old")]), + let beta = beta_acquisition(&manager).await; + let alpha = UpdateSourceAcquisition { + source_result_id: "alpha-source".into(), + source: "alpha/repo".into(), + skill_names: vec!["alpha".into()], + result: Err(AppError::GitCloneFailed { + message: "unavailable".into(), + }), }; - let acquire_calls = Arc::new(AtomicUsize::new(0)); - let builds = Arc::new(AtomicUsize::new(0)); - let executor_calls = Arc::new(AtomicUsize::new(0)); + let executed = Arc::new(AtomicUsize::new(0)); let service = UpdateService::new( - Arc::clone(&manager), - SequencedPlanner { - inspections: Mutex::new(vec![inspection.clone(), inspection]), - builds: Arc::clone(&builds), - build_token: PreviewToken { - generation: "rebuilt".to_string(), - ..token.clone() - }, + manager, + PreparedPlanner { + keep_entries: false, + fail_inspection: None, + invalid_source: None, + builds: Arc::new(AtomicUsize::new(0)), + build_token: update_test_token(), }, FixedAcquirer { - calls: Arc::clone(&acquire_calls), + acquisitions: Mutex::new(Some(vec![alpha, beta])), + calls: Arc::new(AtomicUsize::new(0)), expected_group_count: 2, - acquisitions: Mutex::new(Some(vec![ - UpdateSourceAcquisition { - source_result_id: "source-1".to_string(), - source: "owner/alpha".to_string(), - skill_names: vec!["alpha".to_string()], - result: Ok(AcquiredUpdateSource { - facts, - payloads: vec![("alpha".to_string(), alpha_handle)], - skill_errors: Vec::new(), - redirected_download_host: None, - }), - }, - UpdateSourceAcquisition { - source_result_id: "source-2".to_string(), - source: "owner/beta".to_string(), - skill_names: vec!["beta".to_string()], - result: Err(AppError::GitCloneFailed { - message: "unavailable".to_string(), - }), - }, - ])), }, ResultExecutor { - calls: Arc::clone(&executor_calls), + calls: executed.clone(), + results: vec![mutation_result("beta", MutationUnitStatus::Succeeded)], expected_payload_count: 1, - results: vec![mutation_result("alpha", MutationUnitStatus::Succeeded)], }, ); - + let request = UpdateRequest { + context: mutation_result("beta", MutationUnitStatus::Succeeded).target, + skill_names: vec!["alpha".into(), "beta".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); let response = service - .execute( - &UpdateExecutionRequest { - request: UpdateRequest { - context: SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }, - skill_names: vec!["alpha".to_string(), "beta".to_string()], - }, - overwrite_private_entries: Vec::new(), - }, - token, - CancellationSignal::default(), - ) + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) .await .unwrap(); - - assert_eq!(acquire_calls.load(Ordering::SeqCst), 1); - assert_eq!(builds.load(Ordering::SeqCst), 1); - assert_eq!(executor_calls.load(Ordering::SeqCst), 1); assert_eq!(response.outcome, UpdateOutcome::Partial); - assert_eq!( - response - .sources - .iter() - .map(|source| source.status) - .collect::>(), - vec![UpdateSourceStatus::Acquired, UpdateSourceStatus::Failed] - ); assert_eq!( response .skills @@ -1902,26 +2295,9 @@ mod tests { .find(|skill| skill.skill_identity.skill_name == "alpha") .unwrap() .source_result_id, - "source-1" + "alpha-source" ); - assert_eq!( - response - .skills - .iter() - .find(|skill| skill.skill_identity.skill_name == "beta") - .unwrap() - .source_result_id, - "source-2" - ); - assert!(matches!( - response - .skills - .iter() - .find(|skill| skill.skill_identity.skill_name == "beta") - .unwrap() - .coverage, - UpdateCoverage::NotUpdated { .. } - )); + assert_eq!(executed.load(Ordering::SeqCst), 1); } #[test] @@ -1935,7 +2311,7 @@ mod tests { assert_eq!( update_coverage(Some(&succeeded), true, &context).0, - UpdateCoverage::PreservedConflicts, + UpdateCoverage::UpdatedWithSkippedCopies, ); assert!(matches!( update_coverage(Some(&failed), false, &context).0, @@ -1963,6 +2339,7 @@ mod tests { Some(crate::error::RecoveryResourceId::parse("recovery-update").unwrap()) ); let result = UpdateSkillResult { + skipped_copy_paths: None, skill_identity: SkillIdentity { context: context.clone(), skill_name: "demo".to_string(), @@ -1976,6 +2353,46 @@ mod tests { assert_eq!(update_outcome(&[result]), UpdateOutcome::Failed); } + #[tokio::test] + async fn malformed_source_is_blocked_before_grouping_other_members() { + let manager = update_test_manager(); + let beta = beta_acquisition(&manager).await; + let service = UpdateService::new( + manager, + PreparedPlanner { + keep_entries: false, + fail_inspection: None, + invalid_source: Some("alpha".into()), + builds: Arc::new(AtomicUsize::new(0)), + build_token: update_test_token(), + }, + FixedAcquirer { + acquisitions: Mutex::new(Some(vec![beta])), + calls: Arc::new(AtomicUsize::new(0)), + expected_group_count: 1, + }, + ResultExecutor { + calls: Arc::new(AtomicUsize::new(0)), + results: Vec::new(), + expected_payload_count: 1, + }, + ); + let request = UpdateRequest { + context: mutation_result("beta", MutationUnitStatus::Succeeded).target, + skill_names: vec!["alpha".into(), "beta".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert_eq!(prepared.preview.skills[0].skill_name, "beta"); + assert_eq!(prepared.preview.blocked[0].skill_name, "alpha"); + assert!(matches!( + prepared.preview.blocked[0].error, + AppError::InvalidSource { .. } + )); + } + #[test] fn earlier_success_and_later_cancellation_remain_partial() { let context = SkillLocationRef { @@ -1986,6 +2403,7 @@ mod tests { ErrorReport::from_app_error(AppError::MutationCancelled, Some(context.clone())); let skills = vec![ UpdateSkillResult { + skipped_copy_paths: None, skill_identity: SkillIdentity { context: context.clone(), skill_name: "alpha".to_string(), @@ -2007,62 +2425,6 @@ mod tests { assert_eq!(update_outcome(&skills), UpdateOutcome::Partial); } - #[tokio::test] - async fn stale_preview_token_is_rejected_before_acquisition() { - let manager = Arc::new(PayloadSessionManager::in_memory( - PayloadSessionLimits { - ttl_ms: 60_000, - max_sessions: 4, - max_bytes: 1_000_000, - }, - || 1_000, - )); - let token = PreviewToken { - generation: "preview-v1-demo".to_string(), - registry_revision: "registry-1".to_string(), - environment_revision: "environment-1".to_string(), - context_revision: ContextSnapshotRevision::parse("context-v1-demo").unwrap(), - }; - let request = UpdateRequest { - context: SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }, - skill_names: vec!["demo".to_string()], - }; - let rebuilds = Arc::new(AtomicUsize::new(0)); - let acquisitions = Arc::new(AtomicUsize::new(0)); - let executions = Arc::new(AtomicUsize::new(0)); - let service = UpdateService::new( - Arc::clone(&manager), - Planner { - token: token.clone(), - rebuilds: Arc::clone(&rebuilds), - }, - Acquirer(Arc::clone(&acquisitions)), - Executor(Arc::clone(&executions)), - ); - let preview = service.preview(&request).await.unwrap(); - let serialized_preview = serde_json::to_value(&preview).unwrap(); - assert!(serialized_preview["skills"][0].get("payload").is_none()); - let execution = UpdateExecutionRequest { - request, - overwrite_private_entries: Vec::new(), - }; - let mut changed_token = token; - changed_token.generation = "preview-v1-changed".to_string(); - - assert!(matches!( - service - .execute(&execution, changed_token, CancellationSignal::default()) - .await, - Err(AppError::StaleContext) - )); - assert_eq!(acquisitions.load(Ordering::SeqCst), 0); - assert_eq!(rebuilds.load(Ordering::SeqCst), 0); - assert_eq!(executions.load(Ordering::SeqCst), 0); - } - #[tokio::test] async fn source_failure_is_referenced_without_a_fake_mutation_result() { let manager = Arc::new(PayloadSessionManager::in_memory( @@ -2096,15 +2458,12 @@ mod tests { Executor(Arc::new(AtomicUsize::new(0))), ); + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); let response = service - .execute( - &UpdateExecutionRequest { - request, - overwrite_private_entries: Vec::new(), - }, - token, - CancellationSignal::default(), - ) + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) .await .unwrap(); @@ -2149,20 +2508,20 @@ mod tests { Executor(Arc::new(AtomicUsize::new(0))), ); - let response = service - .execute( - &UpdateExecutionRequest { - request: UpdateRequest { - context, - skill_names: vec!["demo".to_string()], - }, - overwrite_private_entries: Vec::new(), + let prepared = service + .prepare( + &UpdateRequest { + context, + skill_names: vec!["demo".to_string()], }, - token, CancellationSignal::default(), ) .await .unwrap(); + let response = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); assert_eq!(response.outcome, UpdateOutcome::Cancelled); assert_eq!(response.sources.len(), 1); @@ -2184,6 +2543,7 @@ mod tests { let cancelled = ErrorReport::from_app_error(AppError::MutationCancelled, Some(context.clone())); let skills = vec![UpdateSkillResult { + skipped_copy_paths: None, skill_identity: SkillIdentity { context, skill_name: "demo".to_string(), diff --git a/src-tauri/src/application/update_check.rs b/src-tauri/src/application/update_check.rs index e409f8a4..e2018f0e 100644 --- a/src-tauri/src/application/update_check.rs +++ b/src-tauri/src/application/update_check.rs @@ -1,7 +1,7 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; -use crate::application::collection_records::{RecordProjection, SourceRecordRevision}; +use crate::application::collection_records::RecordProjection; use crate::application::source_evidence::{ EvidenceCheckMode, EvidenceCheckRequest, EvidenceCheckResult, EvidenceFreshness, ProviderThrottleKey, RemoteEvidenceKey, SkillRevision, SourceEvidenceCoordinator, @@ -12,10 +12,9 @@ use crate::application::update::{ UpdateCheckOutcome, UpdateCheckReasonCode, UpdateCheckRequest, UpdateCheckResponse, UpdateCheckSelection, }; -use crate::application::update_subjects::{ - BoundInstalledUpdateSubjectSource, BoundLibraryUpdateSubjectSource, - InstalledUpdateSubjectSnapshots, LibraryUpdateSubjectSnapshots, UpdateSubjectSnapshot, - UpdateSubjectSource, +use crate::application::update_records::{ + BoundInstalledUpdateRecords, BoundLibraryUpdateRecords, InstalledUpdateRecordSnapshots, + LibraryUpdateRecordSnapshots, UpdateRecord, UpdateRecordSource, UpdateRecordState, }; use crate::core::mutation::CancellationSignal; use crate::core::skill_paths::normalize_skill_folder_path; @@ -55,7 +54,7 @@ pub struct LibraryUpdateCheckService

{ impl

LibraryUpdateCheckService

where - P: LibraryUpdateSubjectSnapshots, + P: LibraryUpdateRecordSnapshots, { pub fn new(subjects: P, evidence: SourceEvidenceCoordinator) -> Self { Self { @@ -71,14 +70,18 @@ where mode: UpdateCheckMode, names: BTreeSet, ) -> Result { - let source = BoundLibraryUpdateSubjectSource::new(&self.subjects, environment, library_id); + let source = BoundLibraryUpdateRecords { + provider: &self.subjects, + environment, + library_id, + }; self.evidence.check(&source, mode, names).await } } impl

UpdateCheckService

where - P: InstalledUpdateSubjectSnapshots, + P: InstalledUpdateRecordSnapshots, { pub fn new(subjects: P, evidence: SourceEvidenceCoordinator) -> Self { Self { @@ -87,13 +90,56 @@ where } } + pub async fn qualify_skills( + &self, + context: &crate::environment::types::SkillLocationRef, + skills: &mut [crate::core::skill::InstalledSkill], + ) -> Result<(), AppError> { + let records = self + .subjects + .snapshot_installed_records( + context, + skills.iter().map(|skill| skill.name.clone()).collect(), + ) + .await?; + let records = records + .into_iter() + .map(|record| (record.skill_name.clone(), record)) + .collect::>(); + for skill in skills { + if skill.maintenance_error.is_some() { + skill.can_run_update = Some(false); + skill.can_check_for_updates = Some(false); + continue; + } + match records.get(&skill.name) { + Some(record) if matches!(record.state, UpdateRecordState::Source(_)) => { + skill.comparison_fingerprint = record.comparison_fingerprint.clone(); + } + Some(record) if matches!(record.state, UpdateRecordState::Invalid(_)) => { + skill.can_run_update = Some(false); + skill.can_check_for_updates = Some(true); + skill.comparison_fingerprint = record.comparison_fingerprint.clone(); + } + _ => { + skill.can_run_update = Some(false); + skill.can_check_for_updates = Some(false); + skill.comparison_fingerprint = None; + } + } + } + Ok(()) + } + pub async fn check( &self, request: &UpdateCheckRequest, ) -> Result { let selected = selected_names(request)?; - let source = - BoundInstalledUpdateSubjectSource::new(&self.subjects, request.context.clone()); + let source = BoundInstalledUpdateRecords { + provider: &self.subjects, + context: request.context.clone(), + }; self.evidence.check(&source, request.mode, selected).await } } @@ -110,11 +156,11 @@ impl UpdateEvidenceModule { names: BTreeSet, ) -> Result where - S: UpdateSubjectSource, + S: UpdateRecordSource, { let initial = source.snapshot(names.clone()).await?; let initial_revisions = subject_revisions(&initial); - let prepared = prepare_update_checks(projection_entries(initial)); + let prepared = prepare_record_checks(initial); let mut checked = HashMap::new(); let mut sources = Vec::new(); let mut detections = tokio::task::JoinSet::new(); @@ -149,28 +195,62 @@ impl UpdateEvidenceModule { }, CancellationSignal::default(), ) - .await?; - Ok::<_, AppError>((key, group, result)) + .await; + (key, group, result) }); } while let Some(joined) = detections.join_next().await { let (key, group, result) = joined.map_err(|error| AppError::ExecutionFailed { message: format!("source evidence task failed: {error}"), - })??; - sources.push(source_info(&group, &result)); + })?; + let diagnostic = match &result { + Ok(result) => source_info(&group, result), + Err( + error @ (AppError::MutationCancelled + | AppError::StaleEnvironment + | AppError::StaleContext + | AppError::EnvironmentUnavailable { .. }), + ) => return Err(error.clone()), + Err(error) => { + let mut diagnostic = source_info( + &group, + &EvidenceCheckResult { + evidence: None, + evidence_is_fresh: false, + freshness: EvidenceFreshness::Unavailable, + last_attempt: None, + }, + ); + diagnostic.error = Some(error.clone()); + diagnostic + } + }; + sources.push(diagnostic); checked.insert(key, result); } let latest = source.snapshot(names).await?; let latest_revisions = subject_revisions(&latest); - let latest = prepare_update_checks(projection_entries(latest)); + let latest = prepare_record_checks(latest); let mut skills = latest.immediate_results; for (key, group) in latest.groups { skills.extend(group.skills.into_iter().map(|skill| { let unchanged = initial_revisions.get(&skill.name) == latest_revisions.get(&skill.name); match unchanged.then(|| checked.get(&key)).flatten() { - Some(result) => info_from_evidence(skill, result), + Some(Ok(result)) => info_from_evidence(skill, result), + Some(Err(error)) => { + let mut result = info( + skill.name, + skill.metadata, + false, + SkillUpdateCheckStatus::CannotCheck, + Some(UpdateCheckReasonCode::UpstreamUnavailable), + EvidenceFreshness::Unavailable, + ); + result.error = Some(error.clone()); + result + } None => info( skill.name, skill.metadata, @@ -197,6 +277,9 @@ fn update_check_outcome( sources: &[SourceUpdateCheckInfo], skills: &[SkillUpdateInfo], ) -> UpdateCheckOutcome { + if sources.is_empty() && skills.is_empty() { + return UpdateCheckOutcome::NotCompleted; + } let source_completed = |source: &&SourceUpdateCheckInfo| { matches!( source.freshness, @@ -242,24 +325,44 @@ fn selected_names(request: &UpdateCheckRequest) -> Result, AppE } } -fn projection_entries(snapshot: UpdateSubjectSnapshot) -> Vec<(String, RecordProjection)> { +fn projection_entries(snapshot: Vec) -> Vec<(String, RecordProjection)> { snapshot - .subjects .into_iter() - .map(|subject| (subject.skill_name, subject.projection)) + .filter_map(|record| match record.state { + UpdateRecordState::Source(projection) => Some((record.skill_name, projection)), + UpdateRecordState::Invalid(_) => { + Some((record.skill_name, RecordProjection::Uninterpretable)) + } + UpdateRecordState::Excluded => None, + }) .collect() } -fn subject_revisions(snapshot: &UpdateSubjectSnapshot) -> HashMap { - snapshot - .subjects +fn prepare_record_checks(records: Vec) -> PreparedUpdateChecks { + let mut failures = records .iter() - .map(|subject| { - ( - subject.skill_name.clone(), - subject.source_record_revision.clone(), - ) + .filter_map(|record| match &record.state { + UpdateRecordState::Invalid(error) => Some(( + record.skill_name.clone(), + (error.clone(), record.comparison_fingerprint.clone()), + )), + _ => None, }) + .collect::>(); + let mut prepared = prepare_update_checks(projection_entries(records)); + for item in &mut prepared.immediate_results { + if let Some((error, fingerprint)) = failures.remove(&item.name) { + item.error = Some(error); + item.comparison_fingerprint = fingerprint; + } + } + prepared +} + +fn subject_revisions(snapshot: &[UpdateRecord]) -> HashMap { + snapshot + .iter() + .map(|subject| (subject.skill_name.clone(), subject.revision.clone())) .collect() } @@ -334,6 +437,9 @@ fn unavailable_info( reason: UpdateCheckReasonCode, ) -> SkillUpdateInfo { SkillUpdateInfo { + comparison_fingerprint: None, + source_key: None, + error: None, name, source: String::new(), has_update: false, @@ -367,7 +473,43 @@ fn info_from_evidence(skill: UpdateCheckSkill, result: &EvidenceCheckResult) -> ); }; let path = evidence_path(&skill).unwrap_or_default(); - if !evidence.complete_skill_path_catalog.contains(&path) { + if let Some(reason) = evidence.member_failures.get(&path) { + let well_known = skill.metadata.source_type == "well-known"; + if !well_known && *reason == crate::error::SourceAcquisitionFailureReason::NotFound { + return info( + skill.name, + skill.metadata, + false, + SkillUpdateCheckStatus::DeletedUpstream, + Some(UpdateCheckReasonCode::DeletedUpstream), + result.freshness, + ); + } + let mut result = info( + skill.name, + skill.metadata, + false, + SkillUpdateCheckStatus::CannotCheck, + Some(UpdateCheckReasonCode::UpstreamUnavailable), + result.freshness, + ); + result.error = Some( + if *reason == crate::error::SourceAcquisitionFailureReason::DirectoryLinksUnsupported { + AppError::CapabilityUnavailable { + capability: "sourceDirectoryLinks".into(), + path: None, + } + } else if well_known { + AppError::WellKnownSourceFailed { reason: *reason } + } else { + AppError::InvalidSkillMd { + message: format!("Source Skill metadata is unavailable: {path}"), + } + }, + ); + return result; + } + if evidence.catalog_complete && !evidence.complete_skill_path_catalog.contains(&path) { return info( skill.name, skill.metadata, @@ -425,6 +567,9 @@ fn evidence_path(skill: &UpdateCheckSkill) -> Option { fn source_info(group: &UpdateCheckGroup, result: &EvidenceCheckResult) -> SourceUpdateCheckInfo { let evidence = result.evidence.as_ref(); SourceUpdateCheckInfo { + provider: Some(group.identity.remote().provider().clone()), + source_key: Some(group.identity.key()), + error: None, source: group.identity.sanitized_display().to_string(), requested_ref: (!matches!( group.identity.remote().provider(), @@ -434,7 +579,11 @@ fn source_info(group: &UpdateCheckGroup, result: &EvidenceCheckResult) -> Source NormalizedRef::Default => "HEAD".to_string(), NormalizedRef::Named(value) => value.clone(), }), - resolved_ref: evidence.map(|entry| entry.snapshot_id.resolved_ref.clone()), + resolved_ref: evidence + .filter(|_| { + group.identity.remote().provider() != &crate::core::SourceProvider::WellKnown + }) + .map(|entry| entry.snapshot_id.resolved_ref.clone()), ref_revision: evidence.map(|entry| entry.snapshot_id.commit_revision.clone()), checked_at_epoch_ms: evidence.map(|entry| entry.checked_at_epoch_ms), expires_at_epoch_ms: evidence.map(|entry| entry.expires_at_epoch_ms), @@ -464,6 +613,11 @@ fn info( freshness: EvidenceFreshness, ) -> SkillUpdateInfo { SkillUpdateInfo { + comparison_fingerprint: Some(metadata.comparison_fingerprint()), + source_key: SourceIdentity::from_metadata(&metadata) + .ok() + .map(|identity| identity.key()), + error: None, name, source: metadata.source.clone(), has_update, @@ -493,7 +647,7 @@ mod tests { EvidenceDetectionFailure, EvidenceDetectionOutcome, EvidenceDetectionRequest, EvidenceFuture, RemoteEvidenceObservation, SourceEvidenceDetector, }; - use crate::application::update_subjects::InstalledUpdateSubjectProvider; + use crate::application::update_records::InstalledUpdateRecordProvider; use crate::core::lossless_lock::{LockSchema, LosslessLockDocument}; use crate::core::NormalizedUpdateMetadata; use crate::environment::agent_environment::AgentRuntimeSnapshot; @@ -516,6 +670,18 @@ mod tests { let skill_root = root.path().join(".agents/skills"); fs::create_dir_all(&skill_root).unwrap(); for facts in &mut values { + if let Some(skills) = facts + .lock_document + .clone() + .into_value() + .get("skills") + .and_then(serde_json::Value::as_object) + { + for name in skills.keys() { + let directory = crate::application::installed_skill_resolver::InstalledSkillResolver::install_dir_name(name).unwrap(); + fs::create_dir_all(skill_root.join(directory)).unwrap(); + } + } facts.resolved_context.home.native_path = root.path().to_string_lossy().into_owned(); facts.resolved_context.skill_root.native_path = @@ -678,6 +844,8 @@ mod tests { "revision-1", ), provider_validation: None, + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: catalog, skill_revisions: paths .iter() @@ -688,7 +856,6 @@ mod tests { ) }) .collect(), - snapshot_facts: None, }) } @@ -697,6 +864,9 @@ mod tests { last_attempt: Option, ) -> SourceUpdateCheckInfo { SourceUpdateCheckInfo { + provider: Some(crate::core::source_identity::SourceProvider::Github), + source_key: None, + error: None, source: "owner/repo".to_string(), requested_ref: Some("main".to_string()), resolved_ref: Some("refs/heads/main".to_string()), @@ -708,14 +878,33 @@ mod tests { } } - type TestSubjectProvider = InstalledUpdateSubjectProvider; + type TestSubjectProvider = InstalledUpdateRecordProvider; + + fn record_provider(facts: Facts, targets: T) -> InstalledUpdateRecordProvider { + let libraries = test_libraries(&facts._root.path().join("libraries")); + InstalledUpdateRecordProvider::new(facts, targets, libraries) + } + + fn test_libraries( + root: &std::path::Path, + ) -> Arc { + Arc::new( + crate::runtime::skill_libraries::RuntimeSkillLibraryRepository::new( + root.to_path_buf(), + Arc::new(WslRuntime::new_with_support(false, false)), + Arc::new(crate::core::projects::ProjectMigrationRegistry::new( + crate::core::projects::ProjectMigrationState::NotNeeded, + )), + ), + ) + } fn service( values: Vec, detector: Arc, ) -> UpdateCheckService { UpdateCheckService::new( - InstalledUpdateSubjectProvider::new( + record_provider( Facts::new(values), RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), ), @@ -723,6 +912,217 @@ mod tests { ) } + #[cfg(any(unix, windows))] + #[tokio::test] + async fn checking_versions_does_not_require_readable_installed_content() { + let lock = r#"{"skills":{"alpha":{"source":"owner/repo","sourceType":"github","ref":"main","skillPath":"skills/alpha","skillFolderHash":"tree-old"}}}"#; + let facts = Facts::new(vec![facts(lock)]); + let directory = facts + ._root + .path() + .join(".agents") + .join("skills") + .join("alpha"); + let unreadable = directory.join("resource"); + let detector = Arc::new(RecordingDetector { + requested: Mutex::new(Vec::new()), + outcome: observation(&["skills/alpha"]), + }); + let service = UpdateCheckService::new( + record_provider( + facts, + RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), + ), + SourceEvidenceCoordinator::with_clock(detector.clone(), || 1_000), + ); + #[cfg(unix)] + let _blocked_content = std::os::unix::net::UnixListener::bind(&unreadable).unwrap(); + #[cfg(windows)] + let _blocked_content = { + use std::os::windows::fs::OpenOptionsExt; + + fs::OpenOptions::new() + .write(true) + .create_new(true) + .share_mode(0) + .open(&unreadable) + .unwrap() + }; + assert!(crate::environment::native::content_manifest::read_directory(&directory).is_err()); + + let response = service + .check(&UpdateCheckRequest { + context: context(), + mode: UpdateCheckMode::Force, + selection: selection(&["alpha"]), + }) + .await + .unwrap(); + assert_eq!( + response.skills[0].status, + SkillUpdateCheckStatus::UpdateAvailable + ); + assert_eq!(detector.requested.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn absent_canonical_installation_does_not_request_remote_versions() { + let lock = r#"{"skills":{"alpha":{"source":"owner/repo","sourceType":"github","ref":"main","skillPath":"skills/alpha","skillFolderHash":"tree-old"}}}"#; + let facts = Facts::new(vec![facts(lock)]); + let directory = facts._root.path().join(".agents/skills/alpha"); + if directory.exists() { + fs::remove_dir_all(directory).unwrap(); + } + let detector = Arc::new(RecordingDetector { + requested: Mutex::new(Vec::new()), + outcome: observation(&["skills/alpha"]), + }); + let service = UpdateCheckService::new( + record_provider( + facts, + RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), + ), + SourceEvidenceCoordinator::with_clock(detector.clone(), || 1_000), + ); + let response = service + .check(&UpdateCheckRequest { + context: context(), + mode: UpdateCheckMode::Force, + selection: selection(&["alpha"]), + }) + .await + .unwrap(); + assert!(detector.requested.lock().unwrap().is_empty()); + assert!(response.skills.iter().all(|skill| !skill.has_update)); + } + + #[cfg(any(unix, windows))] + #[tokio::test] + async fn canonical_library_link_does_not_use_a_stale_direct_installation_record() { + use crate::application::skill_libraries::{LibraryCatalog, LibraryId}; + let lock = r#"{"skills":{"alpha":{"source":"owner/repo","sourceType":"github","ref":"main","skillPath":"skills/alpha","skillFolderHash":"tree-old"}}}"#; + let facts = Facts::new(vec![facts(lock)]); + let library_root = facts._root.path().join("library-storage"); + let libraries = test_libraries(&library_root); + let catalog: LibraryCatalog = serde_json::from_value(serde_json::json!({ + "schemaVersion": crate::application::skill_libraries::LIBRARY_SCHEMA_VERSION, + "libraries": [{"id": "library-one", "name": "Library", "retiredSkills": [], "skills": [{ + "name": "alpha", "description": "Alpha", "sourceRecord": {}, "contentManifestHash": "old" + }]}] + })).unwrap(); + libraries + .save(&EnvironmentRef::Native, &catalog) + .await + .unwrap(); + let library = libraries + .resolve_collection(&EnvironmentRef::Native, &LibraryId::parse("library-one")) + .await + .unwrap(); + let member = std::path::PathBuf::from(&library.root.native_path).join("alpha"); + fs::create_dir_all(&member).unwrap(); + let canonical = facts._root.path().join(".agents/skills/alpha"); + fs::remove_dir(&canonical).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&member, &canonical).unwrap(); + #[cfg(windows)] + junction::create(&member, &canonical).unwrap(); + let detector = Arc::new(RecordingDetector { + requested: Mutex::new(Vec::new()), + outcome: observation(&["skills/alpha"]), + }); + let provider = InstalledUpdateRecordProvider::new( + facts, + RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), + libraries, + ); + let records = provider + .snapshot_installed_records(&context(), BTreeSet::from(["alpha".to_string()])) + .await + .unwrap(); + assert!( + matches!(records[0].state, UpdateRecordState::Excluded), + "{records:?}" + ); + let service = UpdateCheckService::new( + provider, + SourceEvidenceCoordinator::with_clock(detector.clone(), || 1_000), + ); + let response = service + .check(&UpdateCheckRequest { + context: context(), + mode: UpdateCheckMode::Force, + selection: selection(&["alpha"]), + }) + .await + .unwrap(); + assert!(detector.requested.lock().unwrap().is_empty()); + assert!(response.skills.is_empty()); + } + + #[tokio::test] + async fn one_scoped_source_error_does_not_discard_other_source_results() { + struct MixedDetector; + impl SourceEvidenceDetector for MixedDetector { + fn detect<'a>( + &'a self, + request: EvidenceDetectionRequest, + _: Option, + _: CancellationSignal, + ) -> EvidenceFuture<'a> { + Box::pin(async move { + if request.key.remote.provider() + == &crate::core::source_identity::SourceProvider::WellKnown + { + Err(AppError::WellKnownScopeNotFound { + scope_path: "/docs".to_string(), + root_url: "https://example.com".to_string(), + }) + } else { + Ok(observation(&["skills/alpha"])) + } + }) + } + } + let lock = r#"{"skills":{ + "alpha":{"source":"owner/repo","sourceType":"github","ref":"main","skillPath":"skills/alpha","skillFolderHash":"old"}, + "site":{"source":"example.com","sourceType":"well-known","sourceBaseUrl":"https://example.com/docs","skillPath":"site","wellKnownDigest":"sha256:old"} + }}"#; + let service = UpdateCheckService::new( + record_provider( + Facts::new(vec![facts(lock)]), + RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), + ), + SourceEvidenceCoordinator::with_clock(Arc::new(MixedDetector), || 1_000), + ); + let response = service + .check(&UpdateCheckRequest { + context: context(), + mode: UpdateCheckMode::Force, + selection: selection(&["alpha", "site"]), + }) + .await + .unwrap(); + assert_eq!(response.outcome, UpdateCheckOutcome::Partial); + assert!( + response + .skills + .iter() + .find(|skill| skill.name == "alpha") + .unwrap() + .has_update + ); + let site = serde_json::to_value( + response + .skills + .iter() + .find(|skill| skill.name == "site") + .unwrap(), + ) + .unwrap(); + assert_eq!(site["error"]["kind"], "wellKnownScopeNotFound"); + assert_eq!(site["error"]["data"]["scopePath"], "/docs"); + } + #[test] fn preparation_groups_checkable_entries_and_keeps_missing_remote_hash_runnable() { let prepared = prepare_update_checks(vec![ @@ -819,7 +1219,7 @@ mod tests { } #[tokio::test] - async fn selected_missing_and_uninterpretable_records_remain_in_the_report() { + async fn selected_uninterpretable_records_remain_but_absent_installations_are_excluded() { let lock = r#"{"skills":{"available":{"source":"owner/repo","sourceType":"github","sourceUrl":"https://github.com/owner/repo","ref":"main","skillPath":"skills/available","skillFolderHash":"tree-available"},"broken":{"source":42}}}"#; let detector = Arc::new(RecordingDetector { requested: Mutex::new(Vec::new()), @@ -846,18 +1246,13 @@ mod tests { detector.requested.lock().unwrap().as_slice(), &[BTreeSet::from(["skills/available".to_string()])] ); - assert_eq!(response.skills.len(), 3); + assert_eq!(response.skills.len(), 2); assert_eq!(response.skills[0].name, "available"); assert_eq!(response.skills[1].name, "broken"); assert_eq!( response.skills[1].reason, Some(UpdateCheckReasonCode::UnsupportedSource) ); - assert_eq!(response.skills[2].name, "missing"); - assert_eq!( - response.skills[2].reason, - Some(UpdateCheckReasonCode::MissingSource) - ); } #[tokio::test] @@ -966,6 +1361,8 @@ mod tests { "revision-1", ), provider_validation: None, + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: catalog, skill_revisions: revisions, }), @@ -985,6 +1382,27 @@ mod tests { ); assert_eq!(deleted.status, SkillUpdateCheckStatus::DeletedUpstream); + let mut unsupported = result( + BTreeSet::new(), + BTreeMap::new(), + true, + EvidenceFreshness::Fresh, + ); + unsupported + .evidence + .as_mut() + .unwrap() + .member_failures + .insert( + "skills/alpha".into(), + crate::error::SourceAcquisitionFailureReason::DirectoryLinksUnsupported, + ); + let unsupported = info_from_evidence(skill(), &unsupported); + assert_eq!(unsupported.status, SkillUpdateCheckStatus::CannotCheck); + assert!( + matches!(unsupported.error, Some(AppError::CapabilityUnavailable { capability, .. }) if capability == "sourceDirectoryLinks") + ); + let sparse = info_from_evidence( skill(), &result( @@ -1041,6 +1459,8 @@ mod tests { "catalog-revision", ), provider_validation: None, + catalog_complete: true, + member_failures: BTreeMap::new(), complete_skill_path_catalog: catalog, skill_revisions: digest .map(|digest| { @@ -1153,7 +1573,7 @@ mod tests { let lock = format!(r#"{{"skills":{{{skills}}}}}"#); let detector = Arc::new(ConcurrentDetector::new()); let service = UpdateCheckService::new( - InstalledUpdateSubjectProvider::new( + record_provider( Facts::new(vec![facts(&lock), facts(&lock)]), RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), ), diff --git a/src-tauri/src/application/update_planner.rs b/src-tauri/src/application/update_planner.rs index b9ccb88d..4bd70c46 100644 --- a/src-tauri/src/application/update_planner.rs +++ b/src-tauri/src/application/update_planner.rs @@ -3,7 +3,15 @@ use std::sync::Arc; use serde_json::{json, Map, Value}; -use crate::application::collection_records::{CollectionRecordReader, LockCollectionRecordReader}; +use crate::application::agent_selection::{ + build_agent_selection_catalog, AgentSelectionCatalog, DirectoryPlacementId, +}; +use crate::application::installed_skill_resolver::SkillDirectoryName; +use crate::application::library_candidates::ResolvedLibraryCandidateIndex; +use crate::application::scope_skill_placements::{ + observe_scope_skill_placements, ObservedSkillPlacement, +}; + use crate::application::mutation::plan::{ group_physical_mutations, stable_digest, ExpectedTargetEntry, MutationPlan, PreparedEntryAction, PreparedEntryMutation, PreviewToken, @@ -19,30 +27,28 @@ use crate::application::payload_session::{ use crate::application::planning_facts::{ScopePlanningSnapshot, ScopePlanningSnapshotSource}; use crate::application::skill_changes::ValidatedSkillPayload; use crate::application::skill_entry_projection::{ - ObservedEntryKind, ObservedEntryReader, ObservedPhysicalEntry, -}; -use crate::application::skill_paths::{ - ResolvedSkillRoot, ResolvedSkillTarget, SkillPathObserver, SkillTargetRequest, + ObservedEntryReader, ObservedPhysicalEntry, ObservedPlannedEntry, }; +use crate::application::skill_libraries::SkillLibraryRepository; use crate::application::update::{ derive_update_capability_from_metadata, CheckUpdateCapability, UpdateFuture, UpdatePlanner, UpdateRequest, }; -use crate::application::update_subjects::{ - build_update_subject_snapshot_from_targets, UpdateSubjectSnapshot, -}; use crate::core::agent_definition::{AgentAdapter, AgentId}; use crate::core::lossless_lock::LockSchema; use crate::core::mutation::MutationKind; use crate::core::update_metadata::recover_source_url; use crate::environment::agent_environment::{ - AgentRuntimeSnapshot, DetectionState, DirectoryPresenceState, ResolvedAgentScope, + AgentRuntimeSnapshot, DirectoryPresenceState, ResolvedAgentScope, }; use crate::environment::content_manifest::{ ContentManifestHash, ContentManifestReader, ContentManifestTarget, }; -use crate::environment::planning::{ResolvedTargetFact, TargetEntryKind, TargetFactResolver}; -use crate::environment::runtime::{observed_entry_id, ObservedEntryId, PhysicalTargetKey}; +use crate::environment::planning::{ + is_within_resolved_root, observe_root_links, ResolvedTargetFact, TargetEntryKind, + TargetFactResolver, +}; +use crate::environment::runtime::{ObservedEntryId, PhysicalTargetKey}; use crate::environment::types::{ same_environment_identity, EnvironmentRef, ResourceLocator, SkillLocation, }; @@ -50,7 +56,7 @@ use crate::error::AppError; use crate::models::InstallMode; use crate::storage::lock_plan::{LockEntryMutation, LockExpectedState, PreparedLockMutation}; -const UPDATE_PLANNER_CONTRACT_VERSION: u32 = 2; +const UPDATE_PLANNER_CONTRACT_VERSION: u32 = 3; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LockedUpdateSkill { @@ -70,22 +76,35 @@ pub struct LockedUpdateSkill { #[derive(Debug, Clone)] pub struct LocalUpdateInspection { + pub path_base: Option, pub token: PreviewToken, pub source_candidates: Vec, pub skills: Vec, - pub subjects: UpdateSubjectSnapshot, } #[derive(Debug, Clone)] pub struct LocalUpdateSkillInspection { pub skill_name: String, - pub agent_observed_digest: String, + pub(crate) locations: Vec, pub adapter_targets: Vec, + pub(crate) automatic_entries: Vec, + pub(crate) preserved_entries: Vec, pub(crate) clean_copies: Vec, pub conflicts: Vec, pub blocking_reasons: Vec, } +#[derive(Debug, Clone)] +pub(crate) struct UpdateLocationDisplay { + pub entry_id: ObservedEntryId, + pub path: ResourceLocator, + pub is_standard: bool, + pub kind: crate::application::skill_entry_projection::ObservedEntryKind, + pub readers: Vec, + pub link_target: Option, + pub linked_entry: Option, +} + impl LockedUpdateSkill { pub(crate) fn metadata(&self) -> crate::core::NormalizedUpdateMetadata { crate::core::NormalizedUpdateMetadata { @@ -109,6 +128,7 @@ pub struct ConcreteUpdatePlanner { facts: F, targets: T, payloads: Arc, + libraries: Arc, now: Arc String + Send + Sync>, } @@ -117,12 +137,14 @@ impl ConcreteUpdatePlanner { facts: F, targets: T, payloads: Arc, + libraries: Arc, now: impl Fn() -> String + Send + Sync + 'static, ) -> Self { Self { facts, targets, payloads, + libraries, now: Arc::new(now), } } @@ -133,11 +155,43 @@ where F: ScopePlanningSnapshotSource, T: TargetFactResolver + ContentManifestReader, { - fn inspect<'a>( + fn inspect_items<'a>( &'a self, request: &'a UpdateRequest, - ) -> UpdateFuture<'a, Result> { - Box::pin(ConcreteUpdatePlanner::inspect(self, request)) + ) -> UpdateFuture<'a, Vec<(String, Result)>> { + Box::pin(async move { + let snapshot = async { + let facts = self.facts.snapshot(&request.context).await?; + let (catalog, libraries, project_root) = + self.location_context(request, &facts).await?; + let path_base = facts.resolved_context.path_base(&self.targets).await; + Ok::<_, AppError>((facts, catalog, libraries, path_base, project_root)) + } + .await; + let mut results = Vec::new(); + for name in &request.skill_names { + let item = UpdateRequest { + context: request.context.clone(), + skill_names: vec![name.clone()], + }; + let result = match &snapshot { + Ok((facts, catalog, libraries, path_base, project_root)) => { + self.inspect_with_facts( + &item, + facts, + catalog, + libraries, + path_base, + project_root.as_ref(), + ) + .await + } + Err(error) => Err(error.clone()), + }; + results.push((name.clone(), result)); + } + results + }) } fn build<'a>( @@ -170,18 +224,21 @@ where } } -#[derive(Clone)] -enum CandidateKind { - Canonical, - Private { readers: Vec }, - Adapter { owner: ObservedEntryReader }, -} - struct SkillSeed { locked_index: usize, - target_facts: Vec, - candidates: Vec, - additional_roots: Vec, + placements: Vec, + locations: Vec, + update_placements: BTreeSet, + out_of_scope_placements: BTreeSet, + additional_observations: Vec, +} + +impl SkillSeed { + fn targets(&self) -> impl Iterator { + self.placements + .iter() + .filter(|placement| self.update_placements.contains(&placement.id)) + } } impl ConcreteUpdatePlanner @@ -189,109 +246,384 @@ where F: ScopePlanningSnapshotSource, T: TargetFactResolver + ContentManifestReader, { - pub async fn inspect(&self, request: &UpdateRequest) -> Result - where - T: ContentManifestReader, - { - let facts = self.facts.snapshot(&request.context).await?; - let locked = locked_skills(request, &facts)?; - let (collection, path_requests, mut seeds) = planning_seeds(&facts, request, &locked)?; - let (mut manifests, canonical_targets) = - resolve_planning_targets(&self.targets, &collection, path_requests, &mut seeds).await?; + async fn location_context( + &self, + request: &UpdateRequest, + facts: &ScopePlanningSnapshot, + ) -> Result< + ( + AgentSelectionCatalog, + ResolvedLibraryCandidateIndex, + Option, + ), + AppError, + > { + let project_root = if matches!(request.context.scope, SkillLocation::Project { .. }) { + Some(facts.resolved_context.resolved_root(&self.targets).await?) + } else { + None + }; + private_update_roots( + &facts.agent_runtime, + &request.context.scope, + &request.context.environment, + )?; + let catalog = build_agent_selection_catalog( + &request.context, + &facts.agent_runtime, + &facts.eve_targets, + &facts.resolved_context.skill_root, + &self.targets, + ) + .await?; + let names = request + .skill_names + .iter() + .filter_map(|name| SkillDirectoryName::try_from(name.as_str()).ok()) + .collect(); + let libraries = ResolvedLibraryCandidateIndex::load_known( + self.libraries.as_ref(), + &self.targets, + &request.context.environment, + &names, + ) + .await?; + Ok((catalog, libraries, project_root)) + } + + async fn planning_seeds( + &self, + request: &UpdateRequest, + facts: &ScopePlanningSnapshot, + locked: &[LockedUpdateSkill], + catalog: &AgentSelectionCatalog, + libraries: &ResolvedLibraryCandidateIndex, + project_root: Option<&ResourceLocator>, + ) -> Result, AppError> { + let mut seeds = Vec::with_capacity(locked.len()); + for (locked_index, skill) in locked.iter().enumerate() { + let observed = observe_scope_skill_placements( + &self.targets, + &request.context, + &skill.name, + facts, + catalog, + ) + .await?; + let placements = observed + .describe(catalog, libraries)? + .into_iter() + .filter(|placement| { + placement.id == DirectoryPlacementId::Standard || placement.library.is_none() + }) + .collect::>(); + let eve_targets = + eve_adapter_roots(&facts.agent_runtime, skill, &request.context.environment)?; + let mut update_placements = BTreeSet::new(); + let mut out_of_scope_placements = BTreeSet::new(); + for placement in &placements { + if placement.library.is_some() { + continue; + } + let target = &placement.entry.fact; + if let Some(root) = project_root { + if !is_within_resolved_root(root, &target.destination)? { + out_of_scope_placements.insert(placement.id.clone()); + continue; + } + } + if matches!( + target.entry_kind, + TargetEntryKind::File | TargetEntryKind::Other | TargetEntryKind::BrokenLink + ) { + return Err(AppError::UnsafePath { + path: target.destination.native_path.clone(), + reason: "associated Skill target is not a readable directory".into(), + }); + } + let supported = !placement.content.uses_eve_payload() + || placement.entry.public.readers.iter().any(|reader| { + eve_targets + .iter() + .any(|(id, _, _)| id == &reader.logical_target_id) + }); + if supported && placement.is_direct_directory() { + update_placements.insert(placement.id.clone()); + } + } + if skill + .subagents + .as_ref() + .is_some_and(|targets| !targets.is_empty()) + { + for (id, root, _) in &eve_targets { + let existing = placements.iter().find(|placement| { + placement + .entry + .public + .readers + .iter() + .any(|reader| &reader.logical_target_id == id) + }); + let Some(existing) = existing else { + return Err(AppError::PathNotFound { + path: root + .join_child(&observed.resolved.install_dir_name) + .native_path, + }); + }; + if existing.entry.fact.entry_kind == TargetEntryKind::Missing + && !out_of_scope_placements.contains(&existing.id) + { + if !placements.iter().any(|placement| { + update_placements.contains(&placement.id) + && placement.is_direct_directory() + }) { + return Err(AppError::PathNotFound { + path: existing.entry.fact.destination.native_path.clone(), + }); + } + update_placements.insert(existing.id.clone()); + } + } + } + let locations = + placements + .iter() + .filter(|placement| placement.library.is_none()) + .map(|placement| { + let root = match &placement.id { + DirectoryPlacementId::Standard => &catalog.standard().root, + DirectoryPlacementId::Option(id) => { + &catalog + .option(id) + .ok_or(AppError::StaleRegistry)? + .placement + .root + } + }; + let linked = placement.entry.fact.link_target_identity.as_ref().and_then( + |identity| { + placements.iter().find(|other| { + update_placements.contains(&other.id) + && identity.matches(&other.entry.fact.destination) + }) + }, + ); + let link_target = linked + .map(|target| target.entry.fact.destination.clone()) + .or_else(|| { + placement.entry.fact.link_target.as_deref().and_then(|raw| { + crate::environment::planning::resolved_link_target_path( + &placement.entry.fact.destination, + raw, + ) + }) + }); + Ok(UpdateLocationDisplay { + entry_id: placement.entry.public.entry_id.clone(), + path: crate::environment::types::display_locator( + &root.join_child(&observed.resolved.install_dir_name), + ), + is_standard: placement.id == DirectoryPlacementId::Standard, + kind: placement.entry.public.kind, + readers: placement + .entry + .public + .readers + .iter() + .filter(|reader| match &placement.id { + DirectoryPlacementId::Standard => { + reader.logical_target_id == "canonical" + } + DirectoryPlacementId::Option(id) => { + catalog.option(id).is_some_and(|option| { + option.public.agent_ids.contains(&reader.agent_id) + }) + } + }) + .cloned() + .collect(), + link_target: link_target + .map(|path| crate::environment::types::display_locator(&path)), + linked_entry: linked.map(|target| target.entry.public.entry_id.clone()), + }) + }) + .collect::, AppError>>()?; + let mut additional_observations = observed.additional_observations; + if project_root.is_some() { + let private_roots = private_update_roots( + &facts.agent_runtime, + &request.context.scope, + &request.context.environment, + )?; + let roots = std::iter::once(facts.resolved_context.skill_root.clone()) + .chain(private_roots.keys().map(|path| ResourceLocator { + environment: request.context.environment.clone(), + native_path: path.clone(), + })) + .chain(eve_targets.iter().map(|(_, root, _)| root.clone())) + .collect::>(); + additional_observations + .extend(observe_root_links(&self.targets, &request.context, &roots).await?); + } + seeds.push(SkillSeed { + locked_index, + placements, + locations, + update_placements, + out_of_scope_placements, + additional_observations, + }); + } + Ok(seeds) + } + + async fn inspect_with_facts( + &self, + request: &UpdateRequest, + facts: &ScopePlanningSnapshot, + catalog: &AgentSelectionCatalog, + libraries: &ResolvedLibraryCandidateIndex, + path_base: &crate::environment::context_resolver::ScopePathBase, + project_root: Option<&ResourceLocator>, + ) -> Result { + let locked = locked_skills(request, facts)?; + let seeds = self + .planning_seeds(request, facts, &locked, catalog, libraries, project_root) + .await?; + let mut manifests = BTreeMap::new(); let mut skills = Vec::with_capacity(seeds.len()); for seed in &seeds { - let facts_slice = &seed.target_facts; - read_manifest_states(&self.targets, facts_slice, &mut manifests).await; - let agent_observed_digest = agent_observed_digest(facts_slice, &manifests)?; - skills.push( - self.inspect_skill_copies( - &locked[seed.locked_index], - facts_slice, - &seed.candidates, - &mut manifests, - agent_observed_digest, - ) - .await?, - ); + let targets = seed + .targets() + .map(|placement| placement.entry.fact.clone()) + .collect::>(); + read_manifest_states(&self.targets, &targets, &mut manifests).await?; + skills.push(self.inspect_skill_copies(&locked[seed.locked_index], seed, &manifests)?); } let target_facts = flattened_target_facts(&seeds); - let token = inspection_token(request, &facts, &locked, &target_facts, &manifests)?; - let records = installed_record_snapshot(&facts, &collection, request)?; - let subjects = - build_update_subject_snapshot_from_targets(collection, records, canonical_targets)?; + let token = inspection_token(request, facts, &locked, &target_facts, &manifests)?; Ok(LocalUpdateInspection { + path_base: Some(path_base.clone()), token, source_candidates: locked, skills, - subjects, }) } - async fn inspect_skill_copies( + fn inspect_skill_copies( &self, locked: &LockedUpdateSkill, - facts: &[ResolvedTargetFact], - candidates: &[CandidateKind], - manifests: &mut BTreeMap>, - agent_observed_digest: String, - ) -> Result - where - T: ContentManifestReader, - { - let canonical = facts.first().ok_or(AppError::StaleTarget)?; - let canonical_hash = if canonical.entry_kind == TargetEntryKind::Directory { - read_manifest_once(&self.targets, canonical, manifests).await - } else { - None - }; - let mut clean = BTreeMap::::new(); - let mut conflicts = BTreeMap::::new(); - let mut adapter_targets = BTreeMap::::new(); - for (fact, candidate) in facts.iter().zip(candidates).skip(1) { - let readers = match candidate { - CandidateKind::Private { readers } => readers, - CandidateKind::Adapter { owner } - if fact.entry_kind == TargetEntryKind::Directory => - { - adapter_targets.insert(owner.logical_target_id.clone(), owner.clone()); - continue; + seed: &SkillSeed, + manifests: &BTreeMap>, + ) -> Result { + let canonical = seed + .targets() + .find(|placement| placement.id == DirectoryPlacementId::Standard); + let original_hashes = seed + .targets() + .filter(|placement| !placement.content.uses_eve_payload()) + .filter_map(|placement| { + manifests + .get(&placement.entry.fact.key) + .and_then(Option::as_ref) + }) + .collect::>(); + let baseline = canonical + .and_then(|placement| { + manifests + .get(&placement.entry.fact.key) + .and_then(Option::as_ref) + }) + .or_else(|| { + original_hashes + .first() + .copied() + .filter(|first| original_hashes.iter().all(|hash| *hash == *first)) + }); + let mut clean = BTreeMap::new(); + let mut conflicts = BTreeMap::new(); + let mut automatic = BTreeMap::new(); + let mut adapter_targets = BTreeMap::new(); + let eve_hashes = seed + .targets() + .filter(|placement| placement.content.uses_eve_payload()) + .filter_map(|placement| { + manifests + .get(&placement.entry.fact.key) + .and_then(Option::as_ref) + }) + .collect::>(); + let eve_copies_match = eve_hashes + .first() + .is_none_or(|first| eve_hashes.iter().all(|hash| hash == first)); + for placement in seed.targets() { + if placement.content.uses_eve_payload() { + for reader in &placement.entry.public.readers { + adapter_targets.insert(reader.logical_target_id.clone(), reader.clone()); } - CandidateKind::Canonical | CandidateKind::Adapter { .. } => continue, - }; - if fact.key == canonical.key - || matches!( - fact.entry_kind, - TargetEntryKind::Missing | TargetEntryKind::Symlink | TargetEntryKind::Junction - ) + if placement.entry.fact.entry_kind == TargetEntryKind::Missing || eve_copies_match { + merge_update_entry(&mut automatic, &placement.entry); + } else { + merge_update_entry(&mut conflicts, &placement.entry); + } + continue; + } + if canonical + .is_some_and(|canonical| canonical.entry.fact.key == placement.entry.fact.key) { + merge_update_entry(&mut automatic, &placement.entry); continue; } - let is_clean = if fact.entry_kind == TargetEntryKind::Directory { - read_manifest_once(&self.targets, fact, manifests) - .await - .zip(canonical_hash.as_ref()) - .is_some_and(|(observed, canonical)| observed == *canonical) + let clean_copy = baseline.is_some_and(|baseline| { + manifests + .get(&placement.entry.fact.key) + .and_then(Option::as_ref) + == Some(baseline) + }); + if clean_copy { + merge_update_entry(&mut clean, &placement.entry); + merge_update_entry(&mut automatic, &placement.entry); } else { - false - }; - let grouped = if is_clean { &mut clean } else { &mut conflicts }; - for owner in readers { - insert_private_entry(grouped, fact, owner)?; + merge_update_entry(&mut conflicts, &placement.entry); } } - let clean_copies = clean.into_values().collect::>(); - let blocking_reasons = (canonical.entry_kind == TargetEntryKind::Directory - && canonical_hash.is_none()) - .then_some(OperationErrorCode::ConfigurationCorrupted) - .into_iter() - .collect(); Ok(LocalUpdateSkillInspection { skill_name: locked.name.clone(), - agent_observed_digest, + locations: seed.locations.clone(), adapter_targets: adapter_targets.into_values().collect(), - clean_copies, + automatic_entries: automatic.into_values().collect(), + preserved_entries: seed + .placements + .iter() + .filter(|placement| { + placement.library.is_none() + && placement.entry.fact.entry_kind != TargetEntryKind::Missing + && (seed.out_of_scope_placements.contains(&placement.id) + || (matches!( + placement.entry.fact.entry_kind, + TargetEntryKind::Symlink | TargetEntryKind::Junction + ) && !seed.targets().any(|target| { + placement + .entry + .fact + .link_target_identity + .as_ref() + .is_some_and(|identity| { + identity.matches(&target.entry.fact.destination) + }) + }))) + }) + .map(|placement| placement.entry.public.clone()) + .collect(), + clean_copies: clean.into_values().collect(), conflicts: conflicts.into_values().collect(), - blocking_reasons, + blocking_reasons: if seed.update_placements.is_empty() { + vec![OperationErrorCode::NoUpdateTargets] + } else { + Vec::new() + }, }) } @@ -304,81 +636,87 @@ where locked: Vec, handles: Vec, payloads: Vec, - ) -> Result<(PreviewToken, MutationPlan), AppError> - where - T: ContentManifestReader, - { + ) -> Result<(PreviewToken, MutationPlan), AppError> { validate_payloads(request, &facts, &locked, &handles, &payloads)?; - let mut payloads = payloads; - let (collection, path_requests, mut seeds) = planning_seeds(&facts, request, &locked)?; - let (mut manifests, _) = - resolve_planning_targets(&self.targets, &collection, path_requests, &mut seeds).await?; + let (catalog, libraries, project_root) = self.location_context(request, &facts).await?; + let seeds = self + .planning_seeds( + request, + &facts, + &locked, + &catalog, + &libraries, + project_root.as_ref(), + ) + .await?; + let mut manifests = BTreeMap::new(); + for seed in &seeds { + let targets = seed + .targets() + .map(|placement| placement.entry.fact.clone()) + .collect::>(); + read_manifest_states(&self.targets, &targets, &mut manifests).await?; + } let target_facts = flattened_target_facts(&seeds); - read_manifest_states(&self.targets, &target_facts, &mut manifests).await; let token = inspection_token(request, &facts, &locked, &target_facts, &manifests)?; + let mut payloads = payloads; let mut eve_payload_indexes = BTreeMap::new(); - for (seed_index, seed) in seeds.iter().enumerate() { - let facts_slice = &seed.target_facts; - let needs_eve_payload = - facts_slice - .iter() - .zip(&seed.candidates) - .any(|(fact, candidate)| { - matches!(candidate, CandidateKind::Adapter { .. }) - && fact.entry_kind == TargetEntryKind::Directory - }); - if needs_eve_payload { - let canonical = &payloads[seed.locked_index]; + for (index, seed) in seeds.iter().enumerate() { + if seed + .targets() + .any(|placement| placement.content.uses_eve_payload()) + { + let original = &payloads[seed.locked_index]; let derived = - crate::core::eve::derive_eve_skill_payload(&canonical.load_payload().await?)?; + crate::core::eve::derive_eve_skill_payload(&original.load_payload().await?)?; let lease = self .payloads - .pin_derived_payload(canonical, "eve-update", derived) + .pin_derived_payload(original, "eve-update", derived) .await?; - let index = payloads.len(); + eve_payload_indexes.insert(index, payloads.len()); payloads.push(lease); - eve_payload_indexes.insert(seed_index, index); } } let selected = overwrite_private_entries .iter() .cloned() .collect::>(); - let mut all_selectable = BTreeSet::new(); + let mut selectable = BTreeSet::new(); let mut units = Vec::with_capacity(seeds.len()); - for (seed_index, seed) in seeds.iter().enumerate() { - let locked = &locked[seed.locked_index]; - let payload = &payloads[seed.locked_index]; - let facts_slice = &seed.target_facts; - let observed = private_entries(facts_slice, &seed.candidates)?; - all_selectable.extend(observed.iter().map(|entry| entry.entry_id.clone())); + for (index, seed) in seeds.iter().enumerate() { + selectable.extend( + seed.targets() + .filter(|placement| placement.id != DirectoryPlacementId::Standard) + .map(|placement| placement.entry.public.entry_id.clone()), + ); units.push(build_unit( request, &facts, - locked, - payload, + &locked[seed.locked_index], + &payloads[seed.locked_index], eve_payload_indexes - .get(&seed_index) + .get(&index) .map(|index| &payloads[*index]), - facts_slice, - &seed.candidates, + seed, &selected, &manifests, (self.now)(), )?); } - if !selected.is_subset(&all_selectable) { + if !selected.is_subset(&selectable) { return Err(AppError::StaleTarget); } - let plan = assemble_plan(MutationPlanDraft { - kind: MutationKind::Update, - payloads: payloads - .into_iter() - .map(|lease| (lease.manifest().payload_id().clone(), lease)) - .collect(), - units, - }); - Ok((token, plan)) + Ok(( + token, + assemble_plan(MutationPlanDraft { + kind: MutationKind::Update, + payloads: payloads + .into_iter() + .map(|lease| (lease.manifest().payload_id().clone(), lease)) + .collect(), + units, + }), + )) } } @@ -529,36 +867,6 @@ fn validate_payloads( Ok(()) } -fn private_entries( - facts: &[ResolvedTargetFact], - candidates: &[CandidateKind], -) -> Result, AppError> { - let canonical_key = facts.first().map(|fact| &fact.key); - let mut grouped = BTreeMap::::new(); - for (fact, candidate) in facts.iter().zip(candidates) { - let CandidateKind::Private { readers } = candidate else { - continue; - }; - if fact.entry_kind != TargetEntryKind::Directory - || canonical_key.is_some_and(|canonical| canonical == &fact.key) - { - continue; - } - for owner in readers { - insert_private_entry(&mut grouped, fact, owner)?; - } - } - for entry in grouped.values_mut() { - entry - .readers - .sort_by(|left, right| left.agent_id.cmp(&right.agent_id)); - entry - .readers - .dedup_by(|left, right| left.agent_id == right.agent_id); - } - Ok(grouped.into_values().collect()) -} - fn private_update_roots( runtime: &AgentRuntimeSnapshot, scope: &SkillLocation, @@ -629,137 +937,15 @@ fn private_update_roots( Ok(private_roots) } -fn planning_seeds( - facts: &ScopePlanningSnapshot, - request: &UpdateRequest, - locked: &[LockedUpdateSkill], -) -> Result<(ResolvedSkillRoot, Vec, Vec), AppError> { - let private_roots = private_update_roots( - &facts.agent_runtime, - &request.context.scope, - &request.context.environment, - )?; - let collection = SkillPathObserver::resolve_installed_collection( - &facts.resolved_context, - &facts.revisions.environment, - )?; - - let mut path_requests = Vec::with_capacity(locked.len()); - let mut seeds = Vec::with_capacity(locked.len()); - for (index, skill) in locked.iter().enumerate() { - let mut candidates = vec![CandidateKind::Canonical]; - let mut additional_roots = Vec::new(); - for (root, readers) in &private_roots { - additional_roots.push(ResourceLocator { - environment: request.context.environment.clone(), - native_path: root.clone(), - }); - candidates.push(CandidateKind::Private { - readers: readers.clone(), - }); - } - for (_target_id, root, owner) in - eve_adapter_roots(&facts.agent_runtime, skill, &request.context.environment)? - { - additional_roots.push(root); - candidates.push(CandidateKind::Adapter { owner }); - } - path_requests.push(SkillTargetRequest { - skill_name: skill.name.clone(), - }); - seeds.push(SkillSeed { - locked_index: index, - target_facts: Vec::new(), - candidates, - additional_roots, - }); - } - Ok((collection, path_requests, seeds)) -} - -fn installed_record_snapshot( - facts: &ScopePlanningSnapshot, - root: &ResolvedSkillRoot, - request: &UpdateRequest, -) -> Result { - let project_root = facts - .resolved_context - .project - .as_ref() - .map(|project| ResourceLocator { - environment: root.environment.clone(), - native_path: project.native_path.clone(), - }); - LockCollectionRecordReader::new( - &root.environment, - facts.lock_schema, - &facts.lock_document, - project_root.as_ref(), - ) - .load_snapshot(request.skill_names.iter().cloned().collect()) -} - -async fn resolve_planning_targets( - targets: &T, - root: &ResolvedSkillRoot, - requests: Vec, - seeds: &mut [SkillSeed], -) -> Result< - ( - BTreeMap>, - Vec, - ), - AppError, -> -where - T: TargetFactResolver + ContentManifestReader, -{ - let resolved = SkillPathObserver::resolve_skill_targets(targets, root, requests, None).await?; - if resolved.len() != seeds.len() { - return Err(AppError::StaleTarget); - } - let additional_destinations = seeds - .iter() - .zip(&resolved) - .flat_map(|(seed, resolved)| { - seed.additional_roots - .iter() - .map(move |root| root.join_child(&resolved.install_dir_name)) - }) - .collect::>(); - let additional = targets - .resolve_environment(&root.environment, &additional_destinations, None) - .await?; - if additional.len() != additional_destinations.len() { - return Err(AppError::StaleTarget); - } - let mut manifests = BTreeMap::new(); - let mut additional_cursor = 0; - for (seed, resolved) in seeds.iter_mut().zip(&resolved) { - if let Some(manifest) = resolved.content_revision.manifest_hash().cloned() { - manifests.insert(resolved.target.key.clone(), Some(manifest)); - } - seed.target_facts.push(resolved.target.clone()); - let additional_end = additional_cursor + seed.additional_roots.len(); - seed.target_facts.extend( - additional - .get(additional_cursor..additional_end) - .ok_or(AppError::StaleTarget)? - .iter() - .cloned(), - ); - additional_cursor = additional_end; - if seed.target_facts.len() != seed.candidates.len() { - return Err(AppError::StaleTarget); - } - } - Ok((manifests, resolved)) -} - fn flattened_target_facts(seeds: &[SkillSeed]) -> Vec { seeds .iter() - .flat_map(|seed| seed.target_facts.iter().cloned()) + .flat_map(|seed| { + seed.placements + .iter() + .map(|placement| placement.entry.fact.clone()) + .chain(seed.additional_observations.iter().cloned()) + }) .collect() } @@ -783,71 +969,46 @@ async fn read_manifest_states( reader: &R, facts: &[ResolvedTargetFact], manifests: &mut BTreeMap>, -) { +) -> Result<(), AppError> { for fact in facts { - if fact.entry_kind == TargetEntryKind::Directory { - read_manifest_once(reader, fact, manifests).await; + match fact.entry_kind { + TargetEntryKind::Directory => { + if manifests.get(&fact.key).is_none_or(Option::is_none) { + let target = ContentManifestTarget { + key: fact.key.clone(), + location: fact.destination.clone(), + }; + let manifest = reader.read(&target).await?; + manifests.insert(fact.key.clone(), Some(manifest.hash().clone())); + } + } + TargetEntryKind::Missing | TargetEntryKind::Symlink | TargetEntryKind::Junction => {} + _ => { + return Err(AppError::UnsafePath { + path: fact.destination.native_path.clone(), + reason: "associated Skill target is not a readable directory".into(), + }) + } } } -} - -async fn read_manifest_once( - reader: &R, - fact: &ResolvedTargetFact, - manifests: &mut BTreeMap>, -) -> Option { - if let Some(cached) = manifests.get(&fact.key) { - return cached.clone(); - } - let target = ContentManifestTarget { - key: fact.key.clone(), - location: fact.destination.clone(), - }; - let hash = reader - .read(&target) - .await - .ok() - .map(|manifest| manifest.hash().clone()); - manifests.insert(fact.key.clone(), hash.clone()); - hash -} - -fn insert_private_entry( - grouped: &mut BTreeMap, - fact: &ResolvedTargetFact, - owner: &ObservedEntryReader, -) -> Result<(), AppError> { - let entry_id = observed_entry_id(&fact.key, &fact.fingerprint)?; - let physical_target_key = stable_digest(&fact.key)?; - let entry = grouped - .entry(fact.key.clone()) - .or_insert_with(|| ObservedPhysicalEntry { - entry_id, - display_path: fact.destination.clone(), - kind: observed_kind(fact.entry_kind), - physical_target_key, - readers: Vec::new(), - will_break_if_standard_removed: false, - }); - entry.readers.push(owner.clone()); - entry - .readers - .sort_by(|left, right| left.agent_id.cmp(&right.agent_id)); - entry - .readers - .dedup_by(|left, right| left.agent_id == right.agent_id); Ok(()) } -fn observed_kind(kind: TargetEntryKind) -> ObservedEntryKind { - match kind { - TargetEntryKind::Missing => ObservedEntryKind::Missing, - TargetEntryKind::File | TargetEntryKind::Other => ObservedEntryKind::Other, - TargetEntryKind::Directory => ObservedEntryKind::Directory, - TargetEntryKind::Symlink => ObservedEntryKind::Symlink, - TargetEntryKind::Junction => ObservedEntryKind::Junction, - TargetEntryKind::BrokenLink => ObservedEntryKind::BrokenLink, +fn merge_update_entry( + grouped: &mut BTreeMap, + entry: &ObservedPlannedEntry, +) { + let value = grouped + .entry(entry.fact.key.clone()) + .or_insert_with(|| entry.public.clone()); + for reader in &entry.public.readers { + if !value.readers.contains(reader) { + value.readers.push(reader.clone()); + } } + value.readers.sort_by(|left, right| { + (&left.agent_id, &left.logical_target_id).cmp(&(&right.agent_id, &right.logical_target_id)) + }); } #[allow(clippy::too_many_arguments)] @@ -857,78 +1018,66 @@ fn build_unit( locked: &LockedUpdateSkill, payload: &PinnedPayloadLease, eve_payload: Option<&PinnedPayloadLease>, - target_facts: &[ResolvedTargetFact], - candidates: &[CandidateKind], + seed: &SkillSeed, selected: &BTreeSet, manifests: &BTreeMap>, now: String, ) -> Result { - let canonical_fact = &target_facts[0]; - if matches!( - canonical_fact.entry_kind, - TargetEntryKind::File | TargetEntryKind::Other | TargetEntryKind::BrokenLink - ) { - return Err(AppError::UnsafePath { - path: canonical_fact.destination.native_path.clone(), - reason: "canonical Skill entry is not a directory or link".to_string(), - }); - } - let canonical_owners = standard_owners(&facts.agent_runtime, &request.context.scope); - let canonical = PreparedEntryMutation { - key: canonical_fact.key.clone(), - destination: canonical_fact.destination.clone(), - action: PreparedEntryAction::Replace { - payload_id: payload.manifest().payload_id().clone(), - requested_mode: InstallMode::Copy, - }, - reader_agent_ids: canonical_owners, - }; - let mut private = Vec::new(); - for (fact, candidate) in target_facts.iter().zip(candidates).skip(1) { - match candidate { - CandidateKind::Private { readers } - if fact.entry_kind == TargetEntryKind::Directory - && selected.contains(&observed_entry_id(&fact.key, &fact.fingerprint)?) => - { - private.push(PreparedEntryMutation { - key: fact.key.clone(), - destination: fact.destination.clone(), - action: PreparedEntryAction::Replace { - payload_id: payload.manifest().payload_id().clone(), - requested_mode: InstallMode::Copy, - }, - reader_agent_ids: readers.iter().map(|owner| owner.agent_id.clone()).collect(), - }); - } - CandidateKind::Adapter { owner, .. } - if fact.entry_kind == TargetEntryKind::Directory => - { - private.push(PreparedEntryMutation { - key: fact.key.clone(), - destination: fact.destination.clone(), - action: PreparedEntryAction::Replace { - payload_id: eve_payload - .ok_or(AppError::StalePayload)? - .manifest() - .payload_id() - .clone(), - requested_mode: InstallMode::Copy, - }, - reader_agent_ids: vec![owner.agent_id.clone()], - }); + let standard = seed + .placements + .iter() + .find(|placement| placement.id == DirectoryPlacementId::Standard) + .ok_or(AppError::StaleTarget)?; + let mut entries = Vec::with_capacity(seed.placements.len()); + for placement in &seed.placements { + let should_replace = seed.update_placements.contains(&placement.id) + && (placement.id == DirectoryPlacementId::Standard + || placement.content.uses_eve_payload() + || selected.contains(&placement.entry.public.entry_id)); + let action = if should_replace { + let content = if placement.content.uses_eve_payload() { + eve_payload.ok_or(AppError::StalePayload)? + } else { + payload + }; + PreparedEntryAction::Replace { + payload_id: content.manifest().payload_id().clone(), + requested_mode: InstallMode::Copy, } - _ => {} - } + } else { + PreparedEntryAction::Keep + }; + entries.push(PreparedEntryMutation { + key: placement.entry.fact.key.clone(), + destination: placement.entry.fact.destination.clone(), + action, + reader_agent_ids: placement + .entry + .public + .readers + .iter() + .map(|reader| reader.agent_id.clone()) + .collect(), + }); } - let grouped = - group_physical_mutations(std::iter::once(canonical.clone()).chain(private).collect())?; + entries.extend( + seed.additional_observations + .iter() + .map(|fact| PreparedEntryMutation { + key: fact.key.clone(), + destination: fact.destination.clone(), + action: PreparedEntryAction::Keep, + reader_agent_ids: Vec::new(), + }), + ); + let grouped = group_physical_mutations(entries)?; let primary_entry = grouped .iter() - .find(|entry| entry.key == canonical.key) + .find(|entry| entry.key == standard.entry.fact.key) .cloned(); let additional_entries = grouped .into_iter() - .filter(|entry| entry.key != canonical.key) + .filter(|entry| entry.key != standard.entry.fact.key) .collect(); Ok(MutationUnitDraft { id: format!("update:{}", locked.name), @@ -939,13 +1088,26 @@ fn build_unit( entries: PreparedMutationEntries { primary: primary_entry, additional: additional_entries, - expected_targets: target_facts + expected_targets: seed + .placements .iter() - .map(|fact| ExpectedTargetEntry { - key: fact.key.clone(), - fingerprint: fact.fingerprint.clone(), - expected_content_manifest_hash: manifests.get(&fact.key).cloned().flatten(), + .map(|placement| ExpectedTargetEntry { + key: placement.entry.fact.key.clone(), + fingerprint: placement.entry.fact.fingerprint.clone(), + expected_content_manifest_hash: manifests + .get(&placement.entry.fact.key) + .cloned() + .flatten(), }) + .chain( + seed.additional_observations + .iter() + .map(|fact| ExpectedTargetEntry { + key: fact.key.clone(), + fingerprint: fact.fingerprint.clone(), + expected_content_manifest_hash: None, + }), + ) .collect(), }, lock_mutation: Some(lock_mutation(facts, locked, payload, now)?), @@ -1034,25 +1196,6 @@ fn lock_mutation( }) } -fn standard_owners(runtime: &AgentRuntimeSnapshot, scope: &SkillLocation) -> Vec { - let mut readers = runtime - .agents - .iter() - .filter_map(|(id, agent)| { - let resolved = match scope { - SkillLocation::Global => &agent.global, - SkillLocation::Project { .. } => &agent.project, - }; - (agent.definition.adapter == AgentAdapter::Standard - && resolved.enabled - && resolved.reads_standard) - .then(|| id.clone()) - }) - .collect::>(); - readers.sort(); - readers -} - fn agent_scope<'a>( runtime: &'a AgentRuntimeSnapshot, id: &AgentId, @@ -1074,39 +1217,31 @@ fn eve_adapter_roots( .subagents .as_ref() .is_some_and(|targets| !targets.is_empty()); - let Some((agent_id, agent)) = runtime.agents.iter().find(|(_, agent)| { - agent.definition.adapter == AgentAdapter::Eve - && agent.project.enabled - && agent.detection == DetectionState::Detected - }) else { + let Some((agent_id, agent)) = runtime + .agents + .iter() + .find(|(_, agent)| agent.definition.adapter == AgentAdapter::Eve && agent.project.enabled) + else { if has_explicit_placement { return Err(AppError::Validation { field: Some("subagents".to_string()), message: format!( - "Skill '{}' records Eve placement, but Eve is not detected in this Project", + "Skill '{}' records Eve placement, but Eve is unavailable in this Project", skill.name ), }); } return Ok(Vec::new()); }; - let project = runtime - .project_path - .as_deref() - .ok_or(AppError::StaleContext)?; - let target_ids = match &skill.subagents { - None => vec!["eve:root".to_string()], - Some(subagents) => subagents - .iter() - .map(|subagent| { - if subagent.is_empty() { - "eve:root".to_string() - } else { - format!("eve:{}", crate::core::skill::sanitize_name(subagent)) - } - }) - .collect(), + let Some(project) = runtime.project_path.as_deref() else { + return if has_explicit_placement { + Err(AppError::StaleContext) + } else { + Ok(Vec::new()) + }; }; + let target_ids = + crate::application::scope_skill_placements::eve_target_ids(skill.subagents.as_deref())?; target_ids .into_iter() .map(|target_id| { @@ -1166,20 +1301,6 @@ fn observed_digest( )) } -fn agent_observed_digest( - target_facts: &[ResolvedTargetFact], - manifests: &BTreeMap>, -) -> Result { - let agent_targets = target_facts.get(1..).unwrap_or_default(); - stable_digest(&( - agent_targets - .iter() - .map(|fact| (&fact.key, &fact.fingerprint, fact.entry_kind as u8)) - .collect::>(), - manifest_digest_entries(agent_targets, manifests), - )) -} - fn manifest_digest_entries<'a>( target_facts: &'a [ResolvedTargetFact], manifests: &'a BTreeMap>, @@ -1388,13 +1509,14 @@ mod tests { eve_targets: Vec::new(), }; let manifest_reads = Arc::new(Mutex::new(BTreeMap::new())); - let planner = ConcreteUpdatePlanner::new( + let mut planner = ConcreteUpdatePlanner::new( Facts(facts), CountingTargets { inner: RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), manifest_reads: Arc::clone(&manifest_reads), }, Arc::clone(&manager), + crate::native_workflow_integration_support::update_library_repository(temp.path()), || "2026-07-18T00:00:00.000Z".to_string(), ); let request = UpdateRequest { @@ -1402,18 +1524,8 @@ mod tests { skill_names: vec!["demo".to_string()], }; - let inspection = planner.inspect(&request).await.unwrap(); + let inspection = planner.inspect_items(&request).await.remove(0).1.unwrap(); assert_eq!(inspection.source_candidates.len(), 1); - assert_eq!(inspection.subjects.environment, context.environment); - assert_eq!(inspection.subjects.subjects.len(), 1); - assert_eq!( - inspection.subjects.subjects[0] - .projection - .metadata() - .unwrap() - .source, - "owner/repo" - ); assert_eq!(inspection.skills[0].clean_copies.len(), 1); assert!(inspection.skills[0].conflicts.is_empty()); assert!(inspection.skills[0].adapter_targets.is_empty()); @@ -1465,17 +1577,23 @@ mod tests { .all( |mutation| plan.units[0].expected_targets.iter().any(|expected| { expected.key == mutation.key - && expected.expected_content_manifest_hash.is_some() + && (matches!(mutation.action, PreparedEntryAction::Keep) + || expected.expected_content_manifest_hash.is_some()) }) )); assert!(plan.units[0].primary_entry.is_some()); - assert_eq!(plan.units[0].additional_entries.len(), 1); + let replacements = plan.units[0] + .additional_entries + .iter() + .filter(|entry| matches!(entry.action, PreparedEntryAction::Replace { .. })) + .collect::>(); + assert_eq!(replacements.len(), 1); assert_eq!( - plan.units[0].additional_entries[0].destination.native_path, + replacements[0].destination.native_path, copy_root.join("demo").to_string_lossy() ); assert_eq!( - plan.units[0].additional_entries[0].reader_agent_ids, + replacements[0].reader_agent_ids, vec![ AgentId::parse("alias-agent").unwrap(), AgentId::parse("copy-agent").unwrap(), @@ -1493,7 +1611,7 @@ mod tests { ); fs::write(copy_root.join("demo/local-change.txt"), b"modified").unwrap(); - let changed = planner.inspect(&request).await.unwrap(); + let changed = planner.inspect_items(&request).await.remove(0).1.unwrap(); assert_ne!(changed.token, token); assert_eq!(changed.skills[0].clean_copies.len(), 0); assert_eq!(changed.skills[0].conflicts.len(), 1); @@ -1502,6 +1620,88 @@ mod tests { .unwrap() .values() .all(|reads| *reads == 3)); + let catalog = serde_json::from_value(serde_json::json!({ + "schemaVersion": crate::application::skill_libraries::LIBRARY_SCHEMA_VERSION, + "libraries": [{"id": "library-one", "name": "Library", "retiredSkills": [], "skills": [{ + "name": "demo", "description": "Demo", "sourceRecord": {}, "contentManifestHash": "old" + }]}] + })).unwrap(); + planner + .libraries + .save(&EnvironmentRef::Native, &catalog) + .await + .unwrap(); + let library = planner + .libraries + .resolve_collection( + &EnvironmentRef::Native, + &crate::application::skill_libraries::LibraryId::parse("library-one"), + ) + .await + .unwrap(); + let member = std::path::PathBuf::from(&library.root.native_path).join("demo"); + fs::create_dir_all(&member).unwrap(); + fs::remove_file(link_root.join("demo")).unwrap(); + symlink(&member, link_root.join("demo")).unwrap(); + let independent_library = planner.inspect_items(&request).await.remove(0).1.unwrap(); + fs::remove_dir_all(&member).unwrap(); + let broken_library = planner.inspect_items(&request).await.remove(0).1.unwrap(); + assert_eq!(independent_library.token, broken_library.token); + + let external = temp.path().join("external/demo"); + fs::create_dir_all(&external).unwrap(); + fs::remove_file(link_root.join("demo")).unwrap(); + symlink(&external, link_root.join("demo")).unwrap(); + let independent = planner.inspect_items(&request).await.remove(0).1.unwrap(); + assert_eq!(independent.skills[0].preserved_entries.len(), 1); + assert_eq!(independent.skills[0].conflicts.len(), 1); + + planner.facts.0.agent_runtime = eve_runtime(physical_root.to_str().unwrap()); + planner + .facts + .0 + .agent_runtime + .agents + .values_mut() + .next() + .unwrap() + .detection = DetectionState::NotDetected; + planner.facts.0.lock_document = LosslessLockDocument::parse(br#"{"version":1,"skills":{"demo":{"source":"owner/repo","sourceType":"github","sourceUrl":"https://github.com/owner/repo.git","skillPath":"skills/demo","computedHash":"old","remoteHash":"old","subagents":["research"]}}}"#).unwrap(); + assert!(matches!( + planner.inspect_items(&request).await.remove(0).1, + Err(AppError::PathNotFound { .. }) + )); + let eve_target = physical_root.join("agent/subagents/research/skills/demo"); + fs::create_dir_all(&eve_target).unwrap(); + planner.facts.0.eve_targets = vec![crate::models::InstallTargetInfo { + target_id: "eve:research".into(), + agent: AgentId::parse("eve").unwrap(), + display_name: "Eve (research)".into(), + subagent: Some("research".into()), + path: eve_target.parent().unwrap().to_string_lossy().into_owned(), + }]; + fs::write( + eve_target.join("SKILL.md"), + b"---\nname: demo\ndescription: Demo\n---\nold", + ) + .unwrap(); + assert_eq!( + planner + .inspect_items(&request) + .await + .remove(0) + .1 + .unwrap() + .skills[0] + .adapter_targets + .len(), + 1 + ); + + fs::remove_dir_all(canonical_root.join("demo")).unwrap(); + let private_only = planner.inspect_items(&request).await.remove(0).1.unwrap(); + assert_eq!(private_only.skills[0].adapter_targets.len(), 1); + assert!(!canonical_root.join("demo").exists()); } fn locator(path: &std::path::Path) -> ResourceLocator { @@ -1824,7 +2024,7 @@ mod tests { } #[test] - fn eve_adapter_roots_require_a_detected_eve_project() { + fn eve_adapter_roots_preserve_recorded_targets_without_detection() { let temp = tempdir().unwrap(); let project = temp.path().join("project"); let mut runtime = eve_runtime(project.to_string_lossy().as_ref()); @@ -1844,16 +2044,21 @@ mod tests { well_known_digest: None, }; - assert!(eve_adapter_roots(&runtime, &skill, &EnvironmentRef::Native) - .unwrap() - .is_empty()); + assert_eq!( + eve_adapter_roots(&runtime, &skill, &EnvironmentRef::Native) + .unwrap() + .len(), + 1 + ); let mut explicit = skill; explicit.subagents = Some(vec!["research".to_string()]); - assert!(matches!( - eve_adapter_roots(&runtime, &explicit, &EnvironmentRef::Native), - Err(AppError::Validation { .. }) - )); + assert_eq!( + eve_adapter_roots(&runtime, &explicit, &EnvironmentRef::Native) + .unwrap() + .len(), + 1 + ); } #[test] diff --git a/src-tauri/src/application/update_preparation.rs b/src-tauri/src/application/update_preparation.rs new file mode 100644 index 00000000..922a089e --- /dev/null +++ b/src-tauri/src/application/update_preparation.rs @@ -0,0 +1,252 @@ +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::application::library_update::PreparedLibraryUpdate; +use crate::application::skill_libraries::UpdateLibrarySkillsRequest; +use crate::application::update::PreparedUpdate; +use crate::core::mutation::CancellationSignal; +use crate::environment::types::SkillLocationRef; +use crate::error::AppError; + +pub enum PreparedUpdates { + Direct(PreparedUpdate), + Library(PreparedLibraryUpdate), +} + +impl PreparedUpdates { + fn expire_payloads(&mut self, now: u64) -> Option { + match self { + Self::Direct(value) => { + value.expire_payloads(now); + value.expires_at_epoch_ms() + } + Self::Library(value) => { + value.expire_payloads(now); + value.expires_at_epoch_ms() + } + } + } +} + +fn epoch_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[derive(Clone)] +pub enum PreparationTarget { + Direct(SkillLocationRef), + Library(UpdateLibrarySkillsRequest), +} + +struct Operation { + owner: String, + nonce: uuid::Uuid, + cancellation: CancellationSignal, + prepared: Option, +} + +#[derive(Clone, Default)] +pub struct UpdatePreparations { + operations: Arc>>, +} + +pub struct PreparationTicket { + registry: UpdatePreparations, + id: String, + nonce: uuid::Uuid, + pub cancellation: CancellationSignal, + published: bool, +} + +impl UpdatePreparations { + pub fn begin(&self, id: String, owner: &str) -> Result { + uuid::Uuid::parse_str(&id).map_err(|_| AppError::Validation { + field: Some("operationId".into()), + message: "invalid preparation ID".into(), + })?; + let mut operations = self.operations.lock().map_err(|_| AppError::StaleContext)?; + if operations.contains_key(&id) { + return Err(AppError::StaleContext); + } + operations.retain(|_, operation| { + let keep = operation.owner != owner; + if !keep { + operation.cancellation.cancel(); + } + keep + }); + let nonce = uuid::Uuid::new_v4(); + let cancellation = CancellationSignal::default(); + operations.insert( + id.clone(), + Operation { + owner: owner.into(), + nonce, + cancellation: cancellation.clone(), + prepared: None, + }, + ); + drop(operations); + Ok(PreparationTicket { + registry: self.clone(), + id, + nonce, + cancellation, + published: false, + }) + } + + pub fn target(&self, id: &str, owner: &str) -> Result { + let operations = self.operations.lock().map_err(|_| AppError::StaleContext)?; + let operation = operations + .get(id) + .filter(|operation| operation.owner == owner) + .ok_or(AppError::StalePayload)?; + match operation.prepared.as_ref().ok_or(AppError::StalePayload)? { + PreparedUpdates::Direct(prepared) => { + Ok(PreparationTarget::Direct(prepared.request.context.clone())) + } + PreparedUpdates::Library(prepared) => { + Ok(PreparationTarget::Library(prepared.request.clone())) + } + } + } + + pub fn take(&self, id: &str, owner: &str) -> Result { + let mut operations = self.operations.lock().map_err(|_| AppError::StaleContext)?; + operations + .get(id) + .filter(|operation| operation.owner == owner && operation.prepared.is_some()) + .ok_or(AppError::StalePayload)?; + let mut operation = operations.remove(id).ok_or(AppError::StalePayload)?; + if let Some(prepared) = &mut operation.prepared { + prepared.expire_payloads(epoch_ms()); + } + operation.prepared.ok_or(AppError::StalePayload) + } + + pub fn cancel(&self, id: &str, owner: &str) -> Result<(), AppError> { + let mut operations = self.operations.lock().map_err(|_| AppError::StaleContext)?; + if operations + .get(id) + .is_some_and(|operation| operation.owner != owner) + { + return Err(AppError::StaleContext); + } + if let Some(operation) = operations.remove(id) { + operation.cancellation.cancel(); + } + Ok(()) + } + + pub fn close_window(&self, owner: &str) { + if let Ok(mut operations) = self.operations.lock() { + operations.retain(|_, operation| { + if operation.owner != owner { + return true; + } + operation.cancellation.cancel(); + false + }); + } + } + + fn discard(&self, id: &str, nonce: uuid::Uuid) { + if let Ok(mut operations) = self.operations.lock() { + if operations + .get(id) + .is_some_and(|operation| operation.nonce == nonce) + { + if let Some(operation) = operations.remove(id) { + operation.cancellation.cancel(); + } + } + } + } + + fn schedule_payload_expiry(&self, id: &str, nonce: uuid::Uuid, mut due: u64) { + let operations = Arc::downgrade(&self.operations); + let id = id.to_string(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(due.saturating_sub(epoch_ms()).max(1))) + .await; + let next = { + let Some(operations) = operations.upgrade() else { + return; + }; + let Ok(mut operations) = operations.lock() else { + return; + }; + let Some(operation) = operations + .get_mut(&id) + .filter(|operation| operation.nonce == nonce) + else { + return; + }; + operation + .prepared + .as_mut() + .and_then(|prepared| prepared.expire_payloads(epoch_ms())) + }; + let Some(next) = next else { + return; + }; + due = next; + } + }); + } +} + +impl PreparationTicket { + pub fn publish(mut self, mut prepared: PreparedUpdates) -> Result<(), AppError> { + let next_expiry = prepared.expire_payloads(epoch_ms()); + let mut operations = self + .registry + .operations + .lock() + .map_err(|_| AppError::StaleContext)?; + let operation = operations + .get_mut(&self.id) + .filter(|operation| { + operation.nonce == self.nonce && !operation.cancellation.is_cancelled() + }) + .ok_or(AppError::MutationCancelled)?; + operation.prepared = Some(prepared); + self.published = true; + drop(operations); + if let Some(expiry) = next_expiry { + self.registry + .schedule_payload_expiry(&self.id, self.nonce, expiry); + } + Ok(()) + } +} + +impl Drop for PreparationTicket { + fn drop(&mut self) { + if !self.published { + self.registry.discard(&self.id, self.nonce); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn abandoning_preparation_cancels_work_and_releases_the_slot() { + let registry = UpdatePreparations::default(); + let id = uuid::Uuid::new_v4().to_string(); + let ticket = registry.begin(id.clone(), "main").unwrap(); + let cancellation = ticket.cancellation.clone(); + drop(ticket); + assert!(cancellation.is_cancelled()); + assert!(registry.begin(id, "main").is_ok()); + } +} diff --git a/src-tauri/src/application/update_records.rs b/src-tauri/src/application/update_records.rs new file mode 100644 index 00000000..864b9e29 --- /dev/null +++ b/src-tauri/src/application/update_records.rs @@ -0,0 +1,274 @@ +use std::collections::BTreeSet; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use crate::application::agent_selection::build_agent_selection_catalog; +use crate::application::collection_records::{ + CollectionRecordReader, CollectionSkillRecord, LibraryCatalogRecordReader, + LockCollectionRecordReader, RecordProjection, +}; +use crate::application::installed_skill_resolver::SkillDirectoryName; +use crate::application::library_candidates::ResolvedLibraryCandidateIndex; +use crate::application::mutation::plan::stable_digest; +use crate::application::planning_facts::ScopePlanningSnapshotSource; +use crate::application::scope_skill_placements::observe_scope_skill_placements; +use crate::application::skill_libraries::{LibraryId, SkillLibraryRepository}; +use crate::environment::planning::TargetFactResolver; +use crate::environment::types::{EnvironmentRef, ResourceLocator, SkillLocationRef}; +use crate::error::AppError; + +#[derive(Debug, Clone)] +pub enum UpdateRecordState { + Source(RecordProjection), + Excluded, + Invalid(AppError), +} + +#[derive(Debug, Clone)] +pub struct UpdateRecord { + pub skill_name: String, + pub revision: String, + pub comparison_fingerprint: Option, + pub state: UpdateRecordState, +} + +pub type UpdateRecordFuture<'a> = + Pin, AppError>> + Send + 'a>>; + +pub trait InstalledUpdateRecordSnapshots: Send + Sync { + fn snapshot_installed_records<'a>( + &'a self, + context: &'a SkillLocationRef, + names: BTreeSet, + ) -> UpdateRecordFuture<'a>; +} + +pub trait LibraryUpdateRecordSnapshots: Send + Sync { + fn snapshot_library_records<'a>( + &'a self, + environment: &'a EnvironmentRef, + library_id: &'a LibraryId, + names: BTreeSet, + ) -> UpdateRecordFuture<'a>; +} + +pub trait UpdateRecordSource: Send + Sync { + fn environment(&self) -> &EnvironmentRef; + fn snapshot(&self, names: BTreeSet) -> UpdateRecordFuture<'_>; +} + +pub struct BoundInstalledUpdateRecords<'a, P> { + pub provider: &'a P, + pub context: SkillLocationRef, +} + +impl UpdateRecordSource for BoundInstalledUpdateRecords<'_, P> { + fn environment(&self) -> &EnvironmentRef { + &self.context.environment + } + + fn snapshot(&self, names: BTreeSet) -> UpdateRecordFuture<'_> { + self.provider + .snapshot_installed_records(&self.context, names) + } +} + +pub struct BoundLibraryUpdateRecords<'a, P> { + pub provider: &'a P, + pub environment: EnvironmentRef, + pub library_id: LibraryId, +} + +impl UpdateRecordSource for BoundLibraryUpdateRecords<'_, P> { + fn environment(&self) -> &EnvironmentRef { + &self.environment + } + + fn snapshot(&self, names: BTreeSet) -> UpdateRecordFuture<'_> { + self.provider + .snapshot_library_records(&self.environment, &self.library_id, names) + } +} + +pub struct InstalledUpdateRecordProvider { + facts: F, + targets: T, + libraries: Arc, +} + +impl InstalledUpdateRecordProvider { + pub fn new(facts: F, targets: T, libraries: Arc) -> Self { + Self { + facts, + targets, + libraries, + } + } +} + +impl InstalledUpdateRecordSnapshots + for InstalledUpdateRecordProvider +{ + fn snapshot_installed_records<'a>( + &'a self, + context: &'a SkillLocationRef, + names: BTreeSet, + ) -> UpdateRecordFuture<'a> { + Box::pin(async move { + let facts = self.facts.snapshot(context).await?; + let project = facts + .resolved_context + .project + .as_ref() + .map(|project| ResourceLocator { + environment: context.environment.clone(), + native_path: project.native_path.clone(), + }); + let records = LockCollectionRecordReader::new( + &context.environment, + facts.lock_schema, + &facts.lock_document, + project.as_ref(), + ) + .load_snapshot(names)?; + let observed_names = records + .records + .iter() + .filter_map(|record| SkillDirectoryName::try_from(record.skill_name.as_str()).ok()) + .collect(); + let scope = async { + let catalog = build_agent_selection_catalog( + context, + &facts.agent_runtime, + &facts.eve_targets, + &facts.resolved_context.skill_root, + &self.targets, + ) + .await?; + let libraries = ResolvedLibraryCandidateIndex::load_known( + self.libraries.as_ref(), + &self.targets, + &context.environment, + &observed_names, + ) + .await?; + Ok::<_, AppError>((catalog, libraries)) + } + .await; + let mut result = Vec::with_capacity(records.records.len()); + for record in records.records { + let observation = async { + let (catalog, libraries) = scope.as_ref().map_err(Clone::clone)?; + let observed = observe_scope_skill_placements( + &self.targets, + context, + &record.skill_name, + &facts, + catalog, + ) + .await?; + let placements = observed.describe(catalog, libraries)?; + let eligible = placements + .iter() + .any(|placement| placement.is_direct_directory()); + let ownership = stable_digest(&( + &facts.revisions, + placements + .iter() + .map(|placement| { + ( + &placement.entry.fact.key, + &placement.entry.fact.fingerprint, + placement.entry.fact.entry_kind as u8, + &placement.entry.fact.link_target, + placement.library.as_ref().map(|library| { + (library.library_id().as_str(), library.member_name()) + }), + ) + }) + .collect::>(), + ))?; + Ok::<_, AppError>((eligible, ownership)) + } + .await; + let (state, ownership) = match observation { + Ok((true, revision)) => ( + UpdateRecordState::Source(record.projection.clone()), + revision, + ), + Ok((false, revision)) => (UpdateRecordState::Excluded, revision), + Err(error) => (UpdateRecordState::Invalid(error), String::new()), + }; + result.push(project_record(record, state, &ownership)?); + } + Ok(result) + }) + } +} + +pub struct LibraryUpdateRecordProvider { + repository: Arc, +} + +impl LibraryUpdateRecordProvider { + pub fn new(repository: Arc) -> Self { + Self { repository } + } +} + +impl LibraryUpdateRecordSnapshots for LibraryUpdateRecordProvider { + fn snapshot_library_records<'a>( + &'a self, + environment: &'a EnvironmentRef, + library_id: &'a LibraryId, + names: BTreeSet, + ) -> UpdateRecordFuture<'a> { + Box::pin(async move { + let root = self + .repository + .resolve_collection(environment, library_id) + .await?; + let catalog = self.repository.load(environment).await?; + let records = + LibraryCatalogRecordReader::new(&catalog, library_id).load_snapshot(names)?; + records + .records + .into_iter() + .map(|record| { + let state = UpdateRecordState::Source(record.projection.clone()); + project_record(record, state, root.resolution_revision.as_str()) + }) + .collect() + }) + } +} + +fn project_record( + record: CollectionSkillRecord, + state: UpdateRecordState, + ownership: &str, +) -> Result { + let state = match &record.projection { + RecordProjection::Missing => UpdateRecordState::Excluded, + RecordProjection::Available(metadata) + if matches!(metadata.source_type.as_str(), "local" | "download") => + { + UpdateRecordState::Excluded + } + _ => state, + }; + Ok(UpdateRecord { + comparison_fingerprint: record + .projection + .metadata() + .map(|metadata| metadata.comparison_fingerprint()), + revision: stable_digest(&( + "update-record-v1", + &record.source_record_revision, + ownership, + ))?, + skill_name: record.skill_name, + state, + }) +} diff --git a/src-tauri/src/application/update_subjects.rs b/src-tauri/src/application/update_subjects.rs index 8bebe80d..422271b8 100644 --- a/src-tauri/src/application/update_subjects.rs +++ b/src-tauri/src/application/update_subjects.rs @@ -1,13 +1,11 @@ -use std::collections::BTreeSet; use std::future::Future; use std::pin::Pin; use std::sync::Arc; use crate::application::collection_records::{ CollectionRecordReader, CollectionRecordSnapshot, DocumentRevision, LibraryCatalogRecordReader, - LockCollectionRecordReader, RecordProjection, SkillSelection, SourceRecordRevision, + RecordProjection, SkillSelection, SourceRecordRevision, }; -use crate::application::planning_facts::ScopePlanningSnapshotSource; use crate::application::skill_libraries::{LibraryId, SkillLibraryRepository}; use crate::application::skill_paths::{ ContentRevision, ResolvedSkillRoot, ResolvedSkillTarget, RootResolutionRevision, @@ -15,7 +13,7 @@ use crate::application::skill_paths::{ }; use crate::environment::content_manifest::ContentManifestReader; use crate::environment::planning::TargetFactResolver; -use crate::environment::types::{EnvironmentRef, ResourceLocator, SkillLocationRef}; +use crate::environment::types::EnvironmentRef; use crate::error::AppError; #[derive(Debug, Clone)] @@ -38,14 +36,6 @@ pub struct UpdateSubjectSnapshot { pub type UpdateSubjectFuture<'a> = Pin> + Send + 'a>>; -pub trait InstalledUpdateSubjectSnapshots: Send + Sync { - fn snapshot_installed<'a>( - &'a self, - context: &'a SkillLocationRef, - names: SkillSelection, - ) -> UpdateSubjectFuture<'a>; -} - pub trait LibraryUpdateSubjectSnapshots: Send + Sync { fn snapshot_library<'a>( &'a self, @@ -55,71 +45,6 @@ pub trait LibraryUpdateSubjectSnapshots: Send + Sync { ) -> UpdateSubjectFuture<'a>; } -pub trait UpdateSubjectSource: Send + Sync { - fn environment(&self) -> &crate::environment::types::EnvironmentRef; - - fn snapshot<'a>(&'a self, names: BTreeSet) -> UpdateSubjectFuture<'a>; -} - -pub struct BoundInstalledUpdateSubjectSource<'a, P> { - provider: &'a P, - context: SkillLocationRef, -} - -impl<'a, P> BoundInstalledUpdateSubjectSource<'a, P> { - pub fn new(provider: &'a P, context: SkillLocationRef) -> Self { - Self { provider, context } - } -} - -impl

UpdateSubjectSource for BoundInstalledUpdateSubjectSource<'_, P> -where - P: InstalledUpdateSubjectSnapshots, -{ - fn environment(&self) -> &EnvironmentRef { - &self.context.environment - } - - fn snapshot<'a>(&'a self, names: BTreeSet) -> UpdateSubjectFuture<'a> { - self.provider.snapshot_installed(&self.context, names) - } -} - -pub struct BoundLibraryUpdateSubjectSource<'a, P> { - provider: &'a P, - environment: EnvironmentRef, - library_id: LibraryId, -} - -impl<'a, P> BoundLibraryUpdateSubjectSource<'a, P> { - pub fn new(provider: &'a P, environment: EnvironmentRef, library_id: LibraryId) -> Self { - Self { - provider, - environment, - library_id, - } - } -} - -impl

UpdateSubjectSource for BoundLibraryUpdateSubjectSource<'_, P> -where - P: LibraryUpdateSubjectSnapshots, -{ - fn environment(&self) -> &EnvironmentRef { - &self.environment - } - - fn snapshot<'a>(&'a self, names: BTreeSet) -> UpdateSubjectFuture<'a> { - self.provider - .snapshot_library(&self.environment, &self.library_id, names) - } -} - -pub struct InstalledUpdateSubjectProvider { - facts: F, - targets: T, -} - pub struct LibraryUpdateSubjectProvider { repository: Arc, targets: T, @@ -134,52 +59,6 @@ impl LibraryUpdateSubjectProvider { } } -impl InstalledUpdateSubjectProvider { - pub fn new(facts: F, targets: T) -> Self { - Self { facts, targets } - } -} - -impl InstalledUpdateSubjectSnapshots for InstalledUpdateSubjectProvider -where - F: ScopePlanningSnapshotSource, - T: TargetFactResolver + ContentManifestReader, -{ - fn snapshot_installed<'a>( - &'a self, - context: &'a SkillLocationRef, - names: SkillSelection, - ) -> UpdateSubjectFuture<'a> { - Box::pin(async move { - let facts = self.facts.snapshot(context).await?; - let root = SkillPathObserver::resolve_installed_collection( - &facts.resolved_context, - &facts.revisions.environment, - )?; - if root.environment != context.environment { - return Err(AppError::StaleContext); - } - let project_root = - facts - .resolved_context - .project - .as_ref() - .map(|project| ResourceLocator { - environment: root.environment.clone(), - native_path: project.native_path.clone(), - }); - let records = LockCollectionRecordReader::new( - &root.environment, - facts.lock_schema, - &facts.lock_document, - project_root.as_ref(), - ) - .load_snapshot(names)?; - build_update_subject_snapshot(&self.targets, root, records).await - }) - } -} - impl LibraryUpdateSubjectSnapshots for LibraryUpdateSubjectProvider where T: TargetFactResolver + ContentManifestReader + Send + Sync, @@ -268,119 +147,20 @@ pub fn build_update_subject_snapshot_from_targets( #[cfg(test)] mod tests { - use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fs; use std::sync::Arc; - use crate::application::install::InstallFuture; - use crate::application::mutation::plan::RuntimeRevisions; - use crate::application::planning_facts::{ScopePlanningSnapshot, ScopePlanningSnapshotSource}; use crate::application::skill_libraries::{ LibraryCatalog, LibraryId, LibrarySkillRecord, LibrarySkillSourceRecord, SkillLibraryRecord, SkillLibraryRepository, LIBRARY_SCHEMA_VERSION, }; - use crate::core::lossless_lock::{LockSchema, LosslessLockDocument}; use crate::core::projects::{ProjectMigrationRegistry, ProjectMigrationState}; - use crate::environment::agent_environment::AgentRuntimeSnapshot; - use crate::environment::context_resolver::ResolvedContext; use crate::environment::planning::RuntimeTargetFactResolver; - use crate::environment::runtime::ContextSnapshotRevision; - use crate::environment::types::{ - EnvironmentRef, EnvironmentStatus, ResourceLocator, SkillLocation, SkillLocationRef, - }; + use crate::environment::types::EnvironmentRef; use crate::environment::wsl::WslRuntime; - use crate::error::AppError; - - use super::{ - InstalledUpdateSubjectProvider, InstalledUpdateSubjectSnapshots, - LibraryUpdateSubjectProvider, LibraryUpdateSubjectSnapshots, - }; - struct Facts(ScopePlanningSnapshot); - - impl ScopePlanningSnapshotSource for Facts { - fn snapshot<'a>( - &'a self, - _context: &'a SkillLocationRef, - ) -> InstallFuture<'a, Result> { - Box::pin(async { Ok(self.0.clone()) }) - } - } - - #[tokio::test] - async fn installed_provider_combines_lock_target_and_content_revisions() { - let root = tempfile::tempdir().unwrap(); - let skill_root = root.path().join("skills"); - let skill_dir = skill_root.join("demo"); - fs::create_dir_all(&skill_dir).unwrap(); - fs::write( - skill_dir.join("SKILL.md"), - "---\nname: demo\ndescription: Demo\n---\nbody", - ) - .unwrap(); - let context = SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }; - let facts = ScopePlanningSnapshot { - resolved_context: ResolvedContext { - context: context.clone(), - project: None, - home: ResourceLocator { - environment: EnvironmentRef::Native, - native_path: root.path().to_string_lossy().into_owned(), - }, - skill_root: ResourceLocator { - environment: EnvironmentRef::Native, - native_path: skill_root.to_string_lossy().into_owned(), - }, - lock: ResourceLocator { - environment: EnvironmentRef::Native, - native_path: root.path().join("skills-lock.json").to_string_lossy().into_owned(), - }, - }, - agent_runtime: AgentRuntimeSnapshot { - registry_revision: "registry-v1".to_string(), - environment_revision: "environment-v1".to_string(), - environment: EnvironmentRef::Native, - availability: EnvironmentStatus::Available, - project_path: None, - agents: BTreeMap::new(), - }, - revisions: RuntimeRevisions { - registry: "registry-v1".to_string(), - environment: "environment-v1".to_string(), - context: ContextSnapshotRevision::parse("context-v1").unwrap(), - }, - lock_schema: LockSchema::Global, - lock_document: LosslessLockDocument::parse( - br#"{"version":3,"skills":{"demo":{"source":"owner/repo","sourceType":"github","sourceUrl":"https://github.com/owner/repo","ref":"main","skillPath":"skills/demo","skillFolderHash":"tree-old"}}}"#, - ) - .unwrap(), - eve_targets: Vec::new(), - }; - let provider = InstalledUpdateSubjectProvider::new( - Facts(facts), - RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())), - ); - - let snapshot = provider - .snapshot_installed(&context, BTreeSet::from(["demo".to_string()])) - .await - .unwrap(); - - assert_eq!(snapshot.subjects.len(), 1); - assert_eq!(snapshot.subjects[0].skill_name, "demo"); - assert_eq!( - snapshot.subjects[0].projection.metadata().unwrap().source, - "owner/repo" - ); - assert!(snapshot.subjects[0] - .content_revision - .manifest_hash() - .is_some()); - } + use super::{LibraryUpdateSubjectProvider, LibraryUpdateSubjectSnapshots}; #[tokio::test] async fn library_provider_combines_catalog_target_and_content_revisions() { @@ -437,6 +217,7 @@ mod tests { updated_at: None, extra: serde_json::Map::new(), }], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), diff --git a/src-tauri/src/application/wellknown_access.rs b/src-tauri/src/application/wellknown_access.rs index 94fe5cf6..77c1665f 100644 --- a/src-tauri/src/application/wellknown_access.rs +++ b/src-tauri/src/application/wellknown_access.rs @@ -11,6 +11,9 @@ pub(crate) struct WellKnownFetchResult { pub(crate) repo_path: PathBuf, pub(crate) trust_metadata: HashMap, pub(crate) redirected_download_host: Option, + pub(crate) redirected_download_hosts: Vec, + pub(crate) member_failures: + std::collections::BTreeMap, } #[derive(Debug)] @@ -44,6 +47,9 @@ pub(crate) struct WellKnownIndexEvidence { pub(crate) index_url: String, pub(crate) complete_skill_catalog: Vec, pub(crate) digests: HashMap, + pub(crate) catalog_complete: bool, + pub(crate) member_failures: + std::collections::BTreeMap, } #[derive(Debug, Clone, Default)] @@ -76,6 +82,15 @@ pub(crate) trait WellKnownAccess: Send + Sync { cancellation: &'a CancellationSignal, ) -> WellKnownFetchFuture<'a>; + fn fetch_selected<'a>( + &'a self, + url: &'a str, + _skill_names: &'a [String], + cancellation: &'a CancellationSignal, + ) -> WellKnownFetchFuture<'a> { + self.fetch(url, cancellation) + } + fn check<'a>( &'a self, _url: &'a str, diff --git a/src-tauri/src/application/wsl_source_access.rs b/src-tauri/src/application/wsl_source_access.rs index bc446ecf..5541a3c3 100644 --- a/src-tauri/src/application/wsl_source_access.rs +++ b/src-tauri/src/application/wsl_source_access.rs @@ -11,6 +11,16 @@ pub(crate) type WslSourceFuture<'a> = Pin> + Send + 'a>>; pub(crate) trait WslSourceAccess: Send + Sync { + fn probe_ref<'a>( + &'a self, + _distro_name: &'a str, + _source: &'a str, + _git_ref: Option<&'a str>, + _cancellation: CancellationSignal, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Err(AppError::StaleEnvironment) }) + } + fn discover<'a>( &'a self, distro_name: &'a str, diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 02af1a33..84d83152 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -3,7 +3,6 @@ use tauri::State; use crate::application::environment_settings; use crate::application::runtime_admission::RuntimeAdmissionCoordinator; use crate::core::mutation::MutationKind; -use crate::core::read_config; use crate::environment::types::{EnvironmentRef, SkillLocation, SkillLocationRef}; use crate::error::AppError; use crate::models::SkillDeckConfig; @@ -11,8 +10,8 @@ use crate::runtime::RuntimeServiceGraph; #[tauri::command] #[specta::specta] -pub fn get_config() -> Result { - read_config() +pub fn get_config(runtime: State<'_, RuntimeServiceGraph>) -> Result { + Ok(runtime.config().config()) } #[tauri::command] @@ -22,7 +21,7 @@ pub fn save_config( runtime: State<'_, RuntimeServiceGraph>, ) -> Result<(), AppError> { save_config_with_admission(config, runtime.admission(), |config| { - environment_settings::save_config_preserving_wsl_setting(config) + environment_settings::save_config_preserving_wsl_setting(config, runtime.config()) }) } @@ -51,6 +50,7 @@ pub async fn set_wsl_integration_enabled( runtime.wsl(), runtime.admission(), runtime.payloads(), + runtime.config(), ) .set_enabled(enabled) .await diff --git a/src-tauri/src/commands/install_workflow.rs b/src-tauri/src/commands/install_workflow.rs index 7ffb413c..06e3abf6 100644 --- a/src-tauri/src/commands/install_workflow.rs +++ b/src-tauri/src/commands/install_workflow.rs @@ -2,9 +2,7 @@ use std::future::Future; use tauri::{State, WebviewWindow}; -use crate::application::install::{ - InstallOperation, InstallPreviewOutcome, InstallRequest, InstallResponse, -}; +use crate::application::install::{InstallPreviewOutcome, InstallRequest, InstallResponse}; use crate::application::mutation::plan::PreviewToken; use crate::application::runtime_admission::{MutationPermit, RuntimeAdmissionCoordinator}; use crate::commands::window_role::WindowRole; @@ -20,9 +18,8 @@ pub async fn preview_install( window: WebviewWindow, runtime: State<'_, RuntimeServiceGraph>, ) -> Result { - let operation = - install_operation_for_window(WindowRole::from_label(window.label()), "preview_install")?; - runtime.install().preview(operation, &request).await + ensure_install_window(WindowRole::from_label(window.label()), "preview_install")?; + runtime.install().preview(&request).await } #[tauri::command] @@ -39,11 +36,11 @@ pub async fn install_skills( &admission, role, request.context.clone(), - |operation, guard| async move { + |guard| async move { guard.transition(MutationPhase::Acquiring, None, true); runtime .install() - .execute(operation, &request, expected_token, guard.cancellation()) + .execute(&request, expected_token, guard.cancellation()) .await }, ) @@ -57,38 +54,18 @@ async fn execute_install_for_window( execute: Execute, ) -> Result where - Execute: FnOnce(InstallOperation, MutationPermit) -> ExecuteFuture, + Execute: FnOnce(MutationPermit) -> ExecuteFuture, ExecuteFuture: Future>, { - let operation = install_operation_for_window(role, "install_skills")?; - let guard = begin_install_for_window(admission, role, operation, context)?; - execute(operation, guard).await + ensure_install_window(role, "install_skills")?; + let guard = admission.begin_install_from_active_wizard(context)?; + execute(guard).await } -fn begin_install_for_window( - admission: &RuntimeAdmissionCoordinator, - role: WindowRole, - operation: InstallOperation, - context: SkillLocationRef, -) -> Result { - match role { - WindowRole::Main => admission.begin_mutation(operation.mutation_kind(), context), - WindowRole::InstallWizard => admission.begin_install_from_active_wizard(context), - WindowRole::Unknown => Err(AppError::CapabilityUnavailable { - capability: "install_skills".to_string(), - path: None, - }), - } -} - -fn install_operation_for_window( - role: WindowRole, - capability: &str, -) -> Result { +fn ensure_install_window(role: WindowRole, capability: &str) -> Result<(), AppError> { match role { - WindowRole::Main => Ok(InstallOperation::Repair), - WindowRole::InstallWizard => Ok(InstallOperation::Install), - WindowRole::Unknown => Err(AppError::CapabilityUnavailable { + WindowRole::InstallWizard => Ok(()), + WindowRole::Main | WindowRole::Unknown => Err(AppError::CapabilityUnavailable { capability: capability.to_string(), path: None, }), @@ -113,47 +90,23 @@ mod tests { } #[test] - fn window_role_selects_install_or_repair_identity() { - assert_eq!( - install_operation_for_window(WindowRole::Main, "preview_install").unwrap(), - crate::application::install::InstallOperation::Repair - ); + fn only_the_install_wizard_can_run_install_commands() { + assert!(matches!( + ensure_install_window(WindowRole::Main, "preview_install"), + Err(AppError::CapabilityUnavailable { capability, path: None }) + if capability == "preview_install" + )); assert_eq!( - install_operation_for_window(WindowRole::InstallWizard, "preview_install").unwrap(), - crate::application::install::InstallOperation::Install + ensure_install_window(WindowRole::InstallWizard, "preview_install"), + Ok(()) ); assert!(matches!( - install_operation_for_window(WindowRole::Unknown, "preview_install"), + ensure_install_window(WindowRole::Unknown, "preview_install"), Err(AppError::CapabilityUnavailable { capability, path: None }) if capability == "preview_install" )); } - #[tokio::test] - async fn main_window_source_repair_registers_repair_activity() { - let admission = RuntimeAdmissionCoordinator::default(); - let observed_admission = admission.clone(); - - execute_install_for_window( - &admission, - WindowRole::Main, - native_global(), - |operation, guard| async move { - let _guard = guard; - assert_eq!(operation, InstallOperation::Repair); - assert_eq!( - observed_admission.active().map(|mutation| mutation.kind), - Some(crate::core::mutation::MutationKind::Repair) - ); - Ok(()) - }, - ) - .await - .expect("main-window source repair admitted"); - - assert!(admission.active().is_none()); - } - #[tokio::test] async fn install_handler_runs_the_executor_only_after_role_admission() { let admission = RuntimeAdmissionCoordinator::default(); @@ -170,7 +123,7 @@ mod tests { &admission, WindowRole::InstallWizard, native_global(), - |_, _| async { + |_| async { calls.fetch_add(1, Ordering::SeqCst); Ok(()) }, @@ -180,25 +133,23 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!( - execute_install_for_window( - &admission, - WindowRole::Main, - native_global(), - |_, _| async { - calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - ) + execute_install_for_window(&admission, WindowRole::Main, native_global(), |_| async { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) .await .unwrap_err(), - AppError::InstallWizardActive + AppError::CapabilityUnavailable { + capability: "install_skills".to_string(), + path: None, + } ); assert!(matches!( execute_install_for_window( &admission, WindowRole::Unknown, native_global(), - |_, _| async { + |_| async { calls.fetch_add(1, Ordering::SeqCst); Ok(()) }, @@ -215,7 +166,7 @@ mod tests { &unavailable_admission, WindowRole::InstallWizard, native_global(), - |_, _| async { + |_| async { calls.fetch_add(1, Ordering::SeqCst); Ok(()) }, diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs index 00e40a90..e3c3c1ed 100644 --- a/src-tauri/src/commands/network.rs +++ b/src-tauri/src/commands/network.rs @@ -1,17 +1,30 @@ use tauri::State; -use crate::application::network_settings; use crate::core::mutation::MutationKind; use crate::environment::types::{EnvironmentRef, SkillLocation, SkillLocationRef}; use crate::error::AppError; -use crate::models::NetworkProxySettings; +use crate::models::{NetworkProxySettings, ProxySettingsSnapshot}; use crate::runtime::network_connection::ProxyConnectionTestResult; use crate::runtime::RuntimeServiceGraph; #[tauri::command] #[specta::specta] -pub fn get_proxy_settings() -> Result { - network_settings::get_proxy_settings() +pub fn get_proxy_settings( + reload: Option, + runtime: State<'_, RuntimeServiceGraph>, +) -> Result { + if reload.unwrap_or(false) { + let _permit = runtime.admission().begin_mutation( + MutationKind::UpdateSettings, + SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }, + )?; + Ok(runtime.config().reload()) + } else { + Ok(runtime.config().proxy_snapshot()) + } } #[tauri::command] @@ -27,9 +40,7 @@ pub fn save_proxy_settings( scope: SkillLocation::Global, }, )?; - network_settings::save_proxy_settings(settings, |settings| { - runtime.activate_network_settings(settings) - }) + runtime.config().save_proxy(settings) } #[tauri::command] diff --git a/src-tauri/src/commands/skill_libraries.rs b/src-tauri/src/commands/skill_libraries.rs index 53c8a0bb..d98718cd 100644 --- a/src-tauri/src/commands/skill_libraries.rs +++ b/src-tauri/src/commands/skill_libraries.rs @@ -1,20 +1,22 @@ -use tauri::State; +use tauri::{State, WebviewWindow}; use crate::application::library_application::{ ApplyLibraryApplicationRequest, LibraryAgentOptions, LibraryApplicationDraft, LibraryApplicationPreview, LibraryApplicationResponse, LibraryApplicationSummary, }; +use crate::application::library_membership::LibraryMembershipOutcome; use crate::application::library_update::{ - ExecuteLibraryUpdateRequest, LibraryUpdateExecutionOutcome, LibraryUpdateExecutionStage, - LibraryUpdatePreview, + LibraryUpdateExecutionStage, LibraryUpdateResponse, PreparedLibraryUpdatePreview, }; use crate::application::skill_libraries::{ - ExecuteAddLibrarySkillsRequest, LibraryAddPreview, LibraryAddResponse, LibraryId, + ExecuteAddLibrarySkillsRequest, ExecuteRetireLibrarySkillRequest, LibraryAddPreview, + LibraryAddResponse, LibraryId, LibraryRetirePreview, LibraryRetireResponse, LibraryWorkspaceSnapshot, PreviewAddLibrarySkillsRequest, RemoveLibrarySkillRequest, SkillLibraryDetail, UpdateLibrarySkillsRequest, }; use crate::application::update::{UpdateCheckMode, UpdateCheckResponse}; -use crate::core::mutation::{MutationKind, MutationPhase}; +use crate::application::update_preparation::{PreparationTarget, PreparedUpdates}; +use crate::core::mutation::{CancellationSignal, MutationKind, MutationPhase}; use crate::environment::types::EnvironmentRef; use crate::environment::types::SkillLocationRef; use crate::error::AppError; @@ -88,14 +90,9 @@ pub async fn add_skills_to_library( request: ExecuteAddLibrarySkillsRequest, runtime: State<'_, RuntimeServiceGraph>, ) -> Result { - let _permit = runtime.admission().begin_exclusive_action()?; runtime - .skill_libraries() - .execute_add_skills( - runtime.payloads(), - runtime.agent_selection_targets(), - request, - ) + .library_membership() + .execute_add_skills(request, CancellationSignal::default()) .await } @@ -106,12 +103,8 @@ pub async fn preview_add_library_skills( runtime: State<'_, RuntimeServiceGraph>, ) -> Result { runtime - .skill_libraries() - .preview_add_skills( - runtime.payloads(), - runtime.agent_selection_targets(), - request, - ) + .library_membership() + .preview_add_skills(request) .await } @@ -138,30 +131,53 @@ pub async fn check_library_skill_updates( #[tauri::command] #[specta::specta] -pub async fn preview_library_skill_updates( +pub async fn prepare_library_skill_updates( + operation_id: String, request: UpdateLibrarySkillsRequest, + window: WebviewWindow, runtime: State<'_, RuntimeServiceGraph>, -) -> Result { - runtime.library_update().preview(&request).await +) -> Result { + let ticket = runtime + .update_preparations() + .begin(operation_id, window.label())?; + let prepared = runtime + .library_update() + .prepare(&request, ticket.cancellation.clone()) + .await?; + let preview = prepared.preview.clone(); + ticket.publish(PreparedUpdates::Library(prepared))?; + Ok(preview) } #[tauri::command] #[specta::specta] pub async fn update_library_skills( - request: ExecuteLibraryUpdateRequest, + operation_id: String, + window: WebviewWindow, runtime: State<'_, RuntimeServiceGraph>, -) -> Result { +) -> Result { + let PreparationTarget::Library(request) = runtime + .update_preparations() + .target(&operation_id, window.label())? + else { + return Err(AppError::StaleContext); + }; let permit = runtime.admission().begin_library_mutation( MutationKind::ManageLibraries, - request.request.environment.clone(), - request.request.library_id.as_str().to_string(), + request.environment.clone(), + request.library_id.as_str().to_string(), )?; - permit.transition(MutationPhase::Acquiring, None, true); + let PreparedUpdates::Library(prepared) = runtime + .update_preparations() + .take(&operation_id, window.label())? + else { + return Err(AppError::StaleContext); + }; + permit.transition(MutationPhase::Validating, None, true); let result = runtime .library_update() - .execute_with_stage_observer(&request, permit.cancellation(), |stage| { + .execute_prepared(prepared, permit.cancellation(), |stage| { let (phase, cancelable) = match stage { - LibraryUpdateExecutionStage::Acquiring => (MutationPhase::Acquiring, true), LibraryUpdateExecutionStage::Validating => (MutationPhase::Validating, true), LibraryUpdateExecutionStage::Committing => (MutationPhase::Committing, false), }; @@ -174,17 +190,42 @@ pub async fn update_library_skills( #[tauri::command] #[specta::specta] -pub async fn remove_library_skill( +pub async fn preview_remove_library_skill( request: RemoveLibrarySkillRequest, runtime: State<'_, RuntimeServiceGraph>, -) -> Result { - let _permit = runtime.admission().begin_exclusive_action()?; +) -> Result { runtime - .skill_libraries() - .remove_skill(runtime.agent_selection_targets(), request) + .library_membership() + .preview_retire_skill(request) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn remove_library_skill( + request: ExecuteRetireLibrarySkillRequest, + runtime: State<'_, RuntimeServiceGraph>, +) -> Result { + runtime + .library_membership() + .retire_skill(request, CancellationSignal::default()) .await } +#[tauri::command] +#[specta::specta] +pub async fn resume_library_membership( + environment: EnvironmentRef, + library_id: Option, + runtime: State<'_, RuntimeServiceGraph>, +) -> Result { + let execution = runtime + .library_membership() + .resume(environment, library_id, CancellationSignal::default()) + .await?; + Ok(execution.into_parts().1) +} + #[tauri::command] #[specta::specta] pub async fn delete_skill_library( diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs index 3a6297f5..2201d6b0 100644 --- a/src-tauri/src/commands/skills.rs +++ b/src-tauri/src/commands/skills.rs @@ -12,16 +12,18 @@ pub async fn list_skills( context: SkillLocationRef, runtime: State<'_, RuntimeServiceGraph>, ) -> Result { - let mut result = - crate::application::skills::list_skills(context.clone(), runtime.wsl(), runtime.agents()) - .await?; - let managed_names = runtime - .library_application() - .managed_skill_names(context.clone()) + let mut result = crate::application::skills::list_skills( + context.clone(), + runtime.wsl(), + runtime.agents(), + runtime.skill_libraries().repository(), + runtime.agent_selection_targets(), + ) + .await?; + runtime + .update_check() + .qualify_skills(&context, &mut result.skills) .await?; - result - .skills - .retain(|skill| !managed_names.contains(&skill.name)); result.library_application = runtime.library_application().read(context).await?; Ok(result) } diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 639ffa35..1ad4bbc4 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -1,11 +1,12 @@ -use tauri::State; +use tauri::{State, WebviewWindow}; -use crate::application::mutation::plan::PreviewToken; use crate::application::update::{ - UpdateCheckRequest, UpdateCheckResponse, UpdateExecutionProgress, UpdateExecutionRequest, - UpdateExecutionStage, UpdatePreview, UpdateRequest, UpdateResponse, + PreparedUpdatePreview, UpdateCheckRequest, UpdateCheckResponse, UpdateExecutionProgress, + UpdateExecutionStage, UpdateRequest, UpdateResponse, }; +use crate::application::update_preparation::{PreparationTarget, PreparedUpdates}; use crate::core::mutation::{MutationKind, MutationPhase, MutationProgress}; +use crate::environment::runtime::ObservedEntryId; use crate::error::AppError; use crate::runtime::RuntimeServiceGraph; @@ -20,64 +21,66 @@ pub async fn check_updates( #[tauri::command] #[specta::specta] -pub async fn preview_update( +pub async fn prepare_update( + operation_id: String, request: UpdateRequest, + window: WebviewWindow, runtime: State<'_, RuntimeServiceGraph>, -) -> Result { - runtime.update().preview(&request).await +) -> Result { + let ticket = runtime + .update_preparations() + .begin(operation_id, window.label())?; + let prepared = runtime + .update() + .prepare(&request, ticket.cancellation.clone()) + .await?; + let preview = prepared.preview.clone(); + ticket.publish(PreparedUpdates::Direct(prepared))?; + Ok(preview) } #[tauri::command] #[specta::specta] -pub async fn update_skill( - execution: UpdateExecutionRequest, - expected_token: PreviewToken, - acknowledge_redirect: bool, +pub async fn cancel_update_preparation( + operation_id: String, + window: WebviewWindow, runtime: State<'_, RuntimeServiceGraph>, -) -> Result { - validate_single_skill_update(&execution)?; - execute_update(execution, expected_token, acknowledge_redirect, runtime).await -} - -fn validate_single_skill_update(execution: &UpdateExecutionRequest) -> Result<(), AppError> { - if execution.request.skill_names.len() == 1 { - return Ok(()); - } - Err(AppError::Validation { - field: Some("skillNames".to_string()), - message: "single-Skill update requires exactly one Skill".to_string(), - }) +) -> Result<(), AppError> { + runtime + .update_preparations() + .cancel(&operation_id, window.label()) } #[tauri::command] #[specta::specta] -pub async fn update_skills_batch( - execution: UpdateExecutionRequest, - expected_token: PreviewToken, - acknowledge_redirect: bool, +pub async fn execute_update( + operation_id: String, + selected_copy_entries: Vec, + window: WebviewWindow, runtime: State<'_, RuntimeServiceGraph>, ) -> Result { - execute_update(execution, expected_token, acknowledge_redirect, runtime).await -} - -async fn execute_update( - execution: UpdateExecutionRequest, - expected_token: PreviewToken, - acknowledge_redirect: bool, - runtime: State<'_, RuntimeServiceGraph>, -) -> Result { - let context = execution.request.context.clone(); + let PreparationTarget::Direct(context) = runtime + .update_preparations() + .target(&operation_id, window.label())? + else { + return Err(AppError::StaleContext); + }; let guard = runtime .admission() .begin_mutation(MutationKind::Update, context.clone())?; - guard.transition(MutationPhase::Acquiring, None, true); + let PreparedUpdates::Direct(prepared) = runtime + .update_preparations() + .take(&operation_id, window.label())? + else { + return Err(AppError::StaleContext); + }; + guard.transition(MutationPhase::Validating, None, true); let result = runtime .update() - .execute_with_confirmation_and_stage_observer( - &execution, - expected_token, + .execute_prepared( + prepared, + &selected_copy_entries, guard.cancellation(), - acknowledge_redirect, |event| { let UpdateExecutionProgress { stage, @@ -103,32 +106,3 @@ async fn execute_update( guard.transition(MutationPhase::Finishing, None, false); result } - -#[cfg(test)] -mod tests { - use super::*; - use crate::application::update::UpdateRequest; - use crate::environment::types::{EnvironmentRef, SkillLocation, SkillLocationRef}; - - #[test] - fn single_skill_update_rejects_batch_requests() { - let execution = UpdateExecutionRequest { - request: UpdateRequest { - context: SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }, - skill_names: vec!["alpha".to_string(), "beta".to_string()], - }, - overwrite_private_entries: Vec::new(), - }; - - assert!(matches!( - validate_single_skill_update(&execution), - Err(AppError::Validation { - field: Some(field), - .. - }) if field == "skillNames" - )); - } -} diff --git a/src-tauri/src/core/app_config.rs b/src-tauri/src/core/app_config.rs index 27387855..a904166f 100644 --- a/src-tauri/src/core/app_config.rs +++ b/src-tauri/src/core/app_config.rs @@ -1,10 +1,16 @@ use crate::error::AppError; -use crate::models::{NetworkProxySettings, SkillDeckConfig}; +use crate::models::{ + NetworkProxySettings, ProxySettingsIssue, ProxySettingsIssueCode, ProxySettingsSnapshot, + ProxySettingsTarget, SkillDeckConfig, +}; +use crate::storage::atomic_document::{DocumentWriteFailure, PublicationState}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; -static CONFIG_UPDATE_LOCK: Mutex<()> = Mutex::new(()); +mod network; + +const MAX_CONFIG_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; fn get_skill_deck_home() -> Result { let home = dirs::home_dir().ok_or(AppError::Path { @@ -23,88 +29,459 @@ pub fn get_skill_library_root() -> Result { Ok(get_skill_deck_home()?.join("skill-libraries")) } -pub fn read_config() -> Result { - let path = get_config_path()?; - let _guard = CONFIG_UPDATE_LOCK - .lock() - .expect("config update lock poisoned"); - read_config_from_path(&path) +struct ConfigDocument { + bytes: Option>, + object: Option>, + config: SkillDeckConfig, + issues: Vec, + read_error: Option, + config_error: Option, + physical_path: Option, } -pub fn update_config( - update: impl FnOnce(&mut SkillDeckConfig), -) -> Result { - let path = get_config_path()?; - update_config_at_path(&path, update) +impl ConfigDocument { + fn read(path: &Path) -> Self { + let physical_path = match fs::canonicalize(path) { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // A dangling configuration link is not an absent document. + if fs::symlink_metadata(path).is_ok() { + return Self::unreadable(error.into()); + } + path.to_path_buf() + } + Err(error) => return Self::unreadable(error.into()), + }; + match environment_engine::atomic_document::read_optional_bounded( + &physical_path, + MAX_CONFIG_BYTES, + ) { + Ok(bytes) => { + let mut document = Self::decode(bytes); + document.physical_path = Some(physical_path); + document + } + Err(error) => Self::unreadable(error.into()), + } + } + + fn unreadable(error: AppError) -> Self { + Self { + bytes: None, + object: None, + config: SkillDeckConfig::default(), + issues: vec![network::issue( + ProxySettingsTarget::All, + ProxySettingsIssueCode::UnreadableDocument, + )], + read_error: Some(error), + config_error: None, + physical_path: None, + } + } + + fn decode(bytes: Option>) -> Self { + let object = match bytes.as_deref() { + None => Some(serde_json::Map::new()), + Some(bytes) => serde_json::from_slice::(bytes) + .ok() + .and_then(|value| value.as_object().cloned()), + }; + let Some(object) = object else { + return Self { + bytes, + object: None, + config: SkillDeckConfig::default(), + issues: vec![network::issue( + ProxySettingsTarget::All, + ProxySettingsIssueCode::InvalidDocument, + )], + read_error: None, + config_error: Some(configuration_error( + "configuration must be a valid JSON object", + )), + physical_path: None, + }; + }; + let (network_proxy, issues) = network::decode(object.get("networkProxy")); + let mut general = object.clone(); + general.remove("networkProxy"); + let (mut config, config_error) = + match serde_json::from_value::(general.into()) { + Ok(config) => (config, None), + Err(_) => ( + SkillDeckConfig::default(), + Some(configuration_error( + "application settings have invalid field types", + )), + ), + }; + config.network_proxy = network_proxy; + Self { + bytes, + object: Some(object), + config, + issues, + read_error: None, + config_error, + physical_path: None, + } + } + + fn snapshot(&self, path: Option<&Path>) -> ProxySettingsSnapshot { + ProxySettingsSnapshot { + settings: self.config.network_proxy.clone(), + issues: self.issues.clone(), + config_path: path.map(|path| path.to_string_lossy().into_owned()), + repairable: self.object.is_some(), + } + } } -fn update_config_at_path( - path: &Path, - update: impl FnOnce(&mut SkillDeckConfig), -) -> Result { - let _guard = CONFIG_UPDATE_LOCK - .lock() - .expect("config update lock poisoned"); - let mut config = read_config_from_path(path)?; - update(&mut config); - write_config_to_path(&config, path)?; - Ok(config) +/// Owns the document snapshot and the settings currently used by the application. +/// Reading a snapshot performs no filesystem access and emits no diagnostics. +pub(crate) struct ConfigStore { + path: Option, + document: Mutex, } -fn read_config_from_path(path: &Path) -> Result { - if !path.exists() { - log::info!("配置文件不存在,返回默认配置"); - return Ok(SkillDeckConfig::default()); +impl ConfigStore { + pub(crate) fn open(path: PathBuf) -> Self { + let document = ConfigDocument::read(&path); + report_issues(&path, &document); + Self { + path: Some(path), + document: Mutex::new(document), + } } - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - log::warn!("读取配置文件失败: {},返回默认配置", e); - return Ok(SkillDeckConfig::default()); + pub(crate) fn from_proxy_settings(settings: NetworkProxySettings) -> Self { + let config = SkillDeckConfig { + network_proxy: settings, + ..Default::default() + }; + let bytes = serde_json::to_vec(&config).expect("configuration serialization"); + Self { + path: None, + document: Mutex::new(ConfigDocument::decode(Some(bytes))), } - }; + } - let mut value: serde_json::Value = serde_json::from_str(&content).unwrap_or_else(|e| { - log::warn!("解析配置文件失败: {},返回默认配置", e); - serde_json::json!({}) - }); - let network_proxy = value - .as_object_mut() - .and_then(|object| object.remove("networkProxy")) - .map_or_else( - || Ok(NetworkProxySettings::default()), - |network_proxy| { - serde_json::from_value::(network_proxy) - .map_err(|error| error.to_string()) - .and_then(|settings| { - settings - .validate_and_normalize() - .map_err(|error| format!("code={}", error.code())) - }) - }, - ) - .unwrap_or_else(|error| { - log::warn!("代理设置无效,使用直接连接: {}", error); - NetworkProxySettings::default() + pub(crate) fn config(&self) -> SkillDeckConfig { + self.document + .lock() + .expect("config lock poisoned") + .config + .clone() + } + + pub(crate) fn git_clone_timeout_secs(&self) -> u32 { + self.document + .lock() + .expect("config lock poisoned") + .config + .git_clone_timeout_secs + } + + pub(crate) fn proxy_snapshot(&self) -> ProxySettingsSnapshot { + self.document + .lock() + .expect("config lock poisoned") + .snapshot(self.path.as_deref()) + } + + pub(crate) fn reload(&self) -> ProxySettingsSnapshot { + let mut current = self.document.lock().expect("config lock poisoned"); + if let Some(path) = &self.path { + let mut loaded = ConfigDocument::read(path); + preserve_available_settings(&mut loaded, ¤t); + if loaded.bytes != current.bytes || loaded.issues != current.issues { + report_issues(path, &loaded); + } + *current = loaded; + } + current.snapshot(self.path.as_deref()) + } + + pub(crate) fn save_proxy( + &self, + settings: NetworkProxySettings, + ) -> Result { + let normalized = + settings + .validate_and_normalize() + .map_err(|error| AppError::InvalidProxySettings { + code: error.code().to_string(), + })?; + let mut current = self.document.lock().expect("config lock poisoned"); + let mut object = writable_object(¤t)?; + object.insert("networkProxy".into(), serde_json::to_value(&normalized)?); + self.publish(&mut current, object)?; + Ok(normalized) + } + + pub(crate) fn update( + &self, + update: impl FnOnce(&mut SkillDeckConfig), + ) -> Result { + let mut current = self.document.lock().expect("config lock poisoned"); + let mut object = writable_object(¤t)?; + if let Some(error) = ¤t.config_error { + return Err(error.clone()); + } + let before = serde_json::to_value(¤t.config)?; + let mut updated = current.config.clone(); + update(&mut updated); + if updated.network_proxy != current.config.network_proxy { + updated.network_proxy = + updated + .network_proxy + .validate_and_normalize() + .map_err(|error| AppError::InvalidProxySettings { + code: error.code().to_string(), + })?; + } + let after = serde_json::to_value(&updated)?; + for (key, value) in after.as_object().expect("configuration object") { + if before.get(key) != Some(value) { + object.insert(key.clone(), value.clone()); + } + } + self.publish(&mut current, object)?; + Ok(current.config.clone()) + } + + fn publish( + &self, + current: &mut ConfigDocument, + object: serde_json::Map, + ) -> Result<(), AppError> { + self.publish_with(current, object, |path, expected, bytes| { + environment_engine::atomic_document::replace_if_unchanged(path, expected, bytes) + .map_err(DocumentWriteFailure::from_engine) + }) + } + + fn publish_with( + &self, + current: &mut ConfigDocument, + object: serde_json::Map, + write: impl FnOnce(&Path, Option<&[u8]>, &[u8]) -> Result<(), DocumentWriteFailure>, + ) -> Result<(), AppError> { + let bytes = serde_json::to_vec_pretty(&object)?; + if bytes.len() > MAX_CONFIG_BYTES { + return Err(configuration_error("configuration exceeds its size limit")); + } + if let Some(path) = &self.path { + let target = current.physical_path.as_deref().ok_or_else(|| { + configuration_error("configuration must be readable before saving") + })?; + if current.bytes.is_some() && fs::canonicalize(path).ok().as_deref() != Some(target) { + return Err(AppError::StaleTarget); + } + let parent = target + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| { + configuration_error("configuration needs an explicit parent directory") + })?; + fs::create_dir_all(parent)?; + if let Err(failure) = write(target, current.bytes.as_deref(), &bytes) { + if failure.publication != PublicationState::NotPublished { + self.reconcile_unconfirmed_write(current, &bytes, &failure); + return Err(AppError::ConfigurationWriteUnconfirmed); + } + return Err(failure.into_error()); + } + } + let previous_issues = current.issues.clone(); + let mut saved = ConfigDocument::decode(Some(bytes)); + saved.physical_path = self + .path + .as_ref() + .map(|path| fs::canonicalize(path).unwrap_or_else(|_| path.clone())); + preserve_available_settings(&mut saved, current); + *current = saved; + if let Some(path) = &self.path { + if current.issues != previous_issues { + report_issues(path, current); + } + } + Ok(()) + } + + fn reconcile_unconfirmed_write( + &self, + current: &mut ConfigDocument, + proposed: &[u8], + failure: &DocumentWriteFailure, + ) { + let Some(path) = &self.path else { + return; + }; + let mut observed = ConfigDocument::read(path); + let observation = if observed.read_error.is_some() { + "unreadable" + } else if observed.bytes.as_deref() == Some(proposed) { + "proposed" + } else if observed.bytes == current.bytes { + "previous" + } else { + "changed" + }; + log::warn!( + "配置写入未确认: path={}, phase={:?}, publication={:?}, observed={}, error={}", + path.display(), + failure.phase, + failure.publication, + observation, + failure.error, + ); + observed.config = current.config.clone(); + observed.issues = current + .issues + .iter() + .filter(|issue| issue.code != ProxySettingsIssueCode::WriteUnconfirmed) + .cloned() + .collect(); + observed.issues.push(ProxySettingsIssue { + target: ProxySettingsTarget::All, + code: ProxySettingsIssueCode::WriteUnconfirmed, + using_previous: true, }); - let mut config: SkillDeckConfig = serde_json::from_value(value).unwrap_or_else(|e| { - log::warn!("解析应用配置失败: {},返回默认配置", e); - SkillDeckConfig::default() - }); - config.network_proxy = network_proxy; - Ok(config) + if observed.issues != current.issues { + report_issues(path, &observed); + } + *current = observed; + } } -fn write_config_to_path(config: &SkillDeckConfig, path: &Path) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; +fn writable_object( + document: &ConfigDocument, +) -> Result, AppError> { + if let Some(error) = &document.read_error { + return Err(error.clone()); + } + document.object.clone().ok_or_else(|| { + configuration_error("configuration must be repaired as a JSON object before saving") + }) +} + +fn preserve_available_settings(loaded: &mut ConfigDocument, previous: &ConfigDocument) { + let old = previous.snapshot(None); + if loaded.config_error.is_some() { + let network_proxy = loaded.config.network_proxy.clone(); + loaded.config = previous.config.clone(); + loaded.config.network_proxy = network_proxy; + } + for mut issue in std::mem::take(&mut loaded.issues) { + if matches!( + issue.target, + ProxySettingsTarget::All | ProxySettingsTarget::WslGit { distro: None } + ) { + let blocked_whole_scope = old.issues.iter().any(|old_issue| { + !old_issue.using_previous && old_issue.target.includes(&issue.target) + }); + if !blocked_whole_scope { + if issue.target == ProxySettingsTarget::All { + loaded.config.network_proxy = old.settings.clone(); + } else { + loaded.config.network_proxy.wsl_git = old.settings.wsl_git.clone(); + } + loaded.issues.extend( + old.issues + .iter() + .filter(|old_issue| { + !old_issue.using_previous && issue.target.includes(&old_issue.target) + }) + .cloned(), + ); + issue.using_previous = true; + } + loaded.issues.push(issue); + continue; + } + if !old.is_available(&issue.target) { + loaded.issues.push(issue); + continue; + } + match &issue.target { + ProxySettingsTarget::All => unreachable!("whole scopes handled above"), + ProxySettingsTarget::Http => { + loaded.config.network_proxy.mode = old.settings.mode; + loaded.config.network_proxy.custom_proxy_url = + old.settings.custom_proxy_url.clone(); + } + ProxySettingsTarget::NativeGit => { + loaded.config.network_proxy.native_git = old.settings.native_git.clone() + } + ProxySettingsTarget::WslGit { distro: None } => { + unreachable!("whole scopes handled above") + } + ProxySettingsTarget::WslGit { + distro: Some(distro), + } => { + if let Some(settings) = old.settings.wsl_git.get(distro) { + loaded + .config + .network_proxy + .wsl_git + .insert(distro.clone(), settings.clone()); + } + } + } + issue.using_previous = true; + loaded.issues.push(issue); + } +} + +fn report_issues(path: &Path, document: &ConfigDocument) { + if document.issues.is_empty() && document.config_error.is_none() { + log::info!("代理配置已就绪: path={}", path.display()); + } + for issue in &document.issues { + log::warn!( + "代理配置需要修复: path={}, target={:?}, code={:?}, action={}", + path.display(), + issue.target, + issue.code, + if issue.using_previous { + "keep_previous" + } else { + "block_affected_requests" + } + ); + } + if document.config_error.is_some() { + log::warn!( + "应用配置需要修复: path={}, code=invalidDocument", + path.display() + ); + } +} + +fn configuration_error(message: &str) -> AppError { + AppError::ConfigurationCorrupted { + message: message.to_string(), } +} - let content = serde_json::to_string_pretty(config)?; - fs::write(path, content)?; +#[cfg(test)] +fn update_config_at_path( + path: &Path, + update: impl FnOnce(&mut SkillDeckConfig), +) -> Result { + ConfigStore::open(path.to_path_buf()).update(update) +} + +#[cfg(test)] +fn read_config_from_path(path: &Path) -> Result { + Ok(ConfigStore::open(path.to_path_buf()).config()) +} - log::info!("配置已保存到: {:?}", path); +#[cfg(test)] +fn write_config_to_path(config: &SkillDeckConfig, path: &Path) -> Result<(), AppError> { + ConfigStore::open(path.to_path_buf()).update(|current| *current = config.clone())?; Ok(()) } @@ -116,10 +493,193 @@ mod tests { use super::{ get_skill_library_root, read_config_from_path, update_config_at_path, write_config_to_path, + ConfigStore, }; + use crate::error::AppError; use crate::models::{NativeGitProxySettings, ProxyMode, SkillDeckConfig}; use tempfile::tempdir; + fn custom_proxy(port: u16) -> crate::models::NetworkProxySettings { + crate::models::NetworkProxySettings { + mode: ProxyMode::Custom, + custom_proxy_url: Some(format!("http://127.0.0.1:{port}")), + ..Default::default() + } + } + + #[test] + fn snapshots_do_not_reread_disk_and_saving_rejects_external_changes() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let store = ConfigStore::open(path.clone()); + store.save_proxy(custom_proxy(7890)).unwrap(); + let external = br#"{"futureSetting":"external","networkProxy":{"mode":"direct"}}"#; + fs::write(&path, external).unwrap(); + + for _ in 0..3 { + assert_eq!(store.proxy_snapshot().settings, custom_proxy(7890)); + } + assert!(matches!( + store.save_proxy(custom_proxy(7891)), + Err(AppError::StaleTarget) + )); + assert_eq!(fs::read(&path).unwrap(), external); + assert_eq!(store.proxy_snapshot().settings, custom_proxy(7890)); + } + + #[test] + fn reload_keeps_previous_valid_proxy_and_successful_save_clears_the_issue() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let store = ConfigStore::open(path.clone()); + store.save_proxy(custom_proxy(7890)).unwrap(); + fs::write( + &path, + br#"{"networkProxy":{"mode":"custom","customProxyUrl":"invalid"}}"#, + ) + .unwrap(); + + let reloaded = store.reload(); + assert_eq!(reloaded.settings, custom_proxy(7890)); + assert_eq!(reloaded.issues.len(), 1); + assert!(reloaded.issues[0].using_previous); + assert!(reloaded.is_available(&crate::models::ProxySettingsTarget::Http)); + store.save_proxy(custom_proxy(7891)).unwrap(); + assert!(store.proxy_snapshot().issues.is_empty()); + assert_eq!(store.proxy_snapshot().settings, custom_proxy(7891)); + } + + #[test] + fn explicitly_saving_default_settings_repairs_an_invalid_section() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + fs::write( + &path, + br#"{"networkProxy":{"mode":"system"},"futureSetting":true}"#, + ) + .unwrap(); + let store = ConfigStore::open(path.clone()); + assert!(!store.proxy_snapshot().issues.is_empty()); + + store.save_proxy(Default::default()).unwrap(); + assert!(store.proxy_snapshot().issues.is_empty()); + let saved: serde_json::Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(saved["networkProxy"]["mode"], "direct"); + assert_eq!(saved["futureSetting"], true); + } + + #[test] + fn unconfirmed_publication_reads_back_the_document_without_activating_new_settings() { + use crate::models::{ProxySettingsIssueCode, ProxySettingsTarget}; + use crate::storage::atomic_document::{DocumentWriteFailure, PublicationState, WritePhase}; + + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let store = ConfigStore::open(path.clone()); + store.save_proxy(custom_proxy(7890)).unwrap(); + let mut current = store.document.lock().unwrap(); + let mut object = current.object.clone().unwrap(); + object.insert( + "networkProxy".into(), + serde_json::to_value(custom_proxy(7891)).unwrap(), + ); + for _ in 0..2 { + let result = store.publish_with(&mut current, object.clone(), |path, _, bytes| { + fs::write(path, bytes).unwrap(); + Err(DocumentWriteFailure { + error: AppError::Io { + message: "confirmation failed".into(), + }, + publication: PublicationState::PublishedUnconfirmed, + phase: WritePhase::Confirming, + }) + }); + assert!(matches!( + result, + Err(AppError::ConfigurationWriteUnconfirmed) + )); + assert_eq!(current.issues.len(), 1); + } + assert_eq!( + current.bytes.as_deref(), + Some(fs::read(&path).unwrap().as_slice()) + ); + drop(current); + let snapshot = store.proxy_snapshot(); + assert_eq!(snapshot.settings, custom_proxy(7890)); + assert_eq!( + snapshot.issues[0].code, + ProxySettingsIssueCode::WriteUnconfirmed + ); + assert!(snapshot.is_available(&ProxySettingsTarget::Http)); + assert_eq!(store.reload().settings, custom_proxy(7891)); + assert!(store.proxy_snapshot().issues.is_empty()); + } + + #[test] + fn configuration_in_a_unicode_directory_round_trips() { + let temp = tempdir().unwrap(); + let path = temp.path().join("配置 directory").join("config.json"); + let store = ConfigStore::open(path.clone()); + store.save_proxy(custom_proxy(7890)).unwrap(); + store + .update(|config| config.git_clone_timeout_secs = 300) + .unwrap(); + let reloaded = ConfigStore::open(path); + assert_eq!(reloaded.proxy_snapshot().settings, custom_proxy(7890)); + assert_eq!(reloaded.config().git_clone_timeout_secs, 300); + } + + #[cfg(unix)] + #[test] + fn saving_through_a_file_symlink_preserves_the_link_and_rejects_retargeting() { + let temp = tempdir().unwrap(); + let first = temp.path().join("first.json"); + let second = temp.path().join("second.json"); + let link = temp.path().join("config.json"); + fs::write(&first, b"{}").unwrap(); + std::os::unix::fs::symlink(&first, &link).unwrap(); + let store = ConfigStore::open(link.clone()); + store.save_proxy(custom_proxy(7890)).unwrap(); + assert!(fs::symlink_metadata(&link).unwrap().is_symlink()); + assert_eq!( + ConfigStore::open(first.clone()).proxy_snapshot().settings, + custom_proxy(7890) + ); + + fs::copy(&first, &second).unwrap(); + fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&second, &link).unwrap(); + assert!(matches!( + store.save_proxy(custom_proxy(7891)), + Err(AppError::StaleTarget) + )); + assert_eq!(fs::read(first).unwrap(), fs::read(second).unwrap()); + } + + #[cfg(windows)] + #[test] + fn configuration_through_a_directory_junction_preserves_the_junction() { + let temp = tempdir().unwrap(); + let target = temp.path().join("settings"); + let junction_path = temp.path().join("linked settings"); + fs::create_dir(&target).unwrap(); + junction::create(&target, &junction_path).unwrap(); + let store = ConfigStore::open(junction_path.join("config.json")); + store.save_proxy(custom_proxy(7890)).unwrap(); + store + .update(|config| config.git_clone_timeout_secs = 300) + .unwrap(); + assert!(junction::exists(&junction_path).unwrap()); + assert_eq!( + ConfigStore::open(target.join("config.json")) + .proxy_snapshot() + .settings, + custom_proxy(7890) + ); + junction::delete(junction_path).unwrap(); + } + #[test] fn skill_library_root_is_stored_under_the_shared_skill_deck_home() { let home = dirs::home_dir().expect("home directory"); @@ -190,35 +750,139 @@ mod tests { assert!(config.network_proxy.wsl_git.is_empty()); } + #[test] + fn invalid_network_settings_are_not_overwritten_by_an_unrelated_update() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + let original = br#"{ + "projects": ["/must-survive"], + "networkProxy": { + "mode": "system", + "customProxyUrl": "http://127.0.0.1:7890" + } + }"#; + fs::write(&path, original).expect("invalid network settings"); + + let result = update_config_at_path(&path, |config| { + config.git_clone_timeout_secs = 60; + }); + + result.expect("unrelated settings remain writable"); + let saved: serde_json::Value = + serde_json::from_slice(&fs::read(path).expect("saved config")).unwrap(); + let original: serde_json::Value = serde_json::from_slice(original).unwrap(); + assert_eq!(saved["networkProxy"], original["networkProxy"]); + assert_eq!(saved["projects"], original["projects"]); + assert_eq!(saved["gitCloneTimeoutSecs"], 60); + } + + #[test] + fn valid_proxy_settings_repair_a_legacy_proxy_section() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let original = serde_json::json!({ + "projects": ["/must-survive"], + "futureSetting": {"keep": true}, + "networkProxy": { + "mode": "direct", + "customProxyUrl": null, + "nativeGit": "useProxy", + "nativeGitProxyUrl": "http://127.0.0.1:7890", + "nativeGitProxyScope": "githubOnly", + "wslGitProxyUrls": {}, + "wslGitBehaviors": {}, + "wslGitProxyScopes": {} + } + }); + fs::write(&path, serde_json::to_vec(&original).unwrap()).unwrap(); + + update_config_at_path(&path, |config| { + config.network_proxy = crate::models::NetworkProxySettings { + native_git: NativeGitProxySettings::UseProxy { + proxy_url: "http://127.0.0.1:7890".to_string(), + scope: crate::models::GitProxyScope::GithubOnly, + }, + ..Default::default() + }; + }) + .expect("valid settings repair the invalid section"); + + let saved: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["projects"], original["projects"]); + assert_eq!(saved["futureSetting"], original["futureSetting"]); + assert_eq!(saved["networkProxy"]["nativeGit"]["behavior"], "useProxy"); + assert!(saved["networkProxy"].get("nativeGitProxyUrl").is_none()); + } + + #[test] + fn invalid_git_settings_preserve_a_valid_http_proxy() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + fs::write( + &path, + br#"{"networkProxy":{"mode":"custom","customProxyUrl":"http://127.0.0.1:7890","nativeGit":"useProxy"}}"#, + ) + .unwrap(); + + let config = read_config_from_path(&path).unwrap(); + assert_eq!(config.network_proxy.mode, ProxyMode::Custom); + assert_eq!( + config.network_proxy.custom_proxy_url.as_deref(), + Some("http://127.0.0.1:7890") + ); + } + + #[test] + fn invalid_wsl_distribution_preserves_other_network_settings() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + fs::write( + &path, + br#"{"networkProxy":{"mode":"custom","customProxyUrl":"http://127.0.0.1:7890","nativeGit":{"behavior":"useProxy","proxyUrl":"http://127.0.0.1:7891","scope":"githubOnly"},"wslGit":{"Ubuntu":"useProxy","Debian":{"behavior":"useProxy","proxyUrl":"http://127.0.0.1:7892","scope":"allHttpHttps"}}}}"#, + ) + .unwrap(); + + let config = read_config_from_path(&path).unwrap(); + assert_eq!(config.network_proxy.mode, ProxyMode::Custom); + assert!(matches!( + config.network_proxy.native_git, + NativeGitProxySettings::UseProxy { .. } + )); + assert!(config.network_proxy.wsl_git.contains_key("Debian")); + } + #[test] fn config_updates_are_serialized_across_read_modify_write() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("config.json"); + let store = std::sync::Arc::new(ConfigStore::open(path.clone())); let (first_entered_tx, first_entered_rx) = mpsc::channel(); let (release_first_tx, release_first_rx) = mpsc::channel(); let (second_attempting_tx, second_attempting_rx) = mpsc::channel(); let (second_entered_tx, second_entered_rx) = mpsc::channel(); std::thread::scope(|scope| { - let first_path = path.clone(); + let first_store = store.clone(); scope.spawn(move || { - update_config_at_path(&first_path, |config| { - config.git_clone_timeout_secs = 300; - first_entered_tx.send(()).expect("first entered"); - release_first_rx.recv().expect("release first"); - }) - .expect("first update"); + first_store + .update(|config| { + config.git_clone_timeout_secs = 300; + first_entered_tx.send(()).expect("first entered"); + release_first_rx.recv().expect("release first"); + }) + .expect("first update"); }); first_entered_rx.recv().expect("first update started"); - let second_path = path.clone(); + let second_store = store.clone(); scope.spawn(move || { second_attempting_tx.send(()).expect("second attempting"); - update_config_at_path(&second_path, |config| { - second_entered_tx.send(()).expect("second entered"); - config.wsl_integration_enabled = true; - }) - .expect("second update"); + second_store + .update(|config| { + second_entered_tx.send(()).expect("second entered"); + config.wsl_integration_enabled = true; + }) + .expect("second update"); }); second_attempting_rx .recv() @@ -237,4 +901,73 @@ mod tests { assert_eq!(config.git_clone_timeout_secs, 300); assert!(config.wsl_integration_enabled); } + #[test] + fn corrupt_config_can_degrade_for_reading_but_is_not_overwritten_by_an_update() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let original = b"{\"projects\": [\"/keep-me\"]"; + fs::write(&path, original).unwrap(); + assert!(read_config_from_path(&path).is_ok()); + assert!(update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).is_err()); + assert_eq!(fs::read(path).unwrap(), original); + } + + #[test] + fn invalid_typed_config_is_preserved_instead_of_saved_as_defaults() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let original = br#"{"projects":"not-an-array"}"#; + fs::write(&path, original).unwrap(); + assert!(update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).is_err()); + assert_eq!(fs::read(path).unwrap(), original); + } + + #[test] + fn an_unreadable_document_is_not_treated_as_missing_during_update() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + fs::create_dir(&path).unwrap(); + assert!(update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).is_err()); + assert!(path.is_dir()); + } + + #[test] + fn first_config_update_initializes_missing_file_without_removing_a_backup() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let backup = temp.path().join("config.json.bak"); + fs::write(&backup, b"owned-by-another-maintenance-flow").unwrap(); + update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).unwrap(); + assert_eq!( + read_config_from_path(&path).unwrap().git_clone_timeout_secs, + 60 + ); + assert_eq!( + fs::read(backup).unwrap(), + b"owned-by-another-maintenance-flow" + ); + } + + #[test] + fn oversized_config_is_rejected_before_it_can_be_saved() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + + let error = update_config_at_path(&path, |config| { + config.git_clone_timeout_secs = 60; + }) + .unwrap_err(); + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/core/app_config/network.rs b/src-tauri/src/core/app_config/network.rs new file mode 100644 index 00000000..969baa69 --- /dev/null +++ b/src-tauri/src/core/app_config/network.rs @@ -0,0 +1,112 @@ +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::models::{ + NativeGitProxySettings, NetworkProxySettings, ProxySettingsIssue, ProxySettingsIssueCode, + ProxySettingsTarget, WslGitProxySettings, +}; + +pub(super) fn decode(value: Option<&Value>) -> (NetworkProxySettings, Vec) { + let mut settings = NetworkProxySettings::default(); + let Some(value) = value else { + return (settings, Vec::new()); + }; + let Some(object) = value.as_object() else { + return ( + settings, + vec![issue( + ProxySettingsTarget::All, + ProxySettingsIssueCode::InvalidFormat, + )], + ); + }; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "mode" | "customProxyUrl" | "nativeGit" | "wslGit" + ) + }) { + return ( + settings, + vec![issue( + ProxySettingsTarget::All, + ProxySettingsIssueCode::UnsupportedFormat, + )], + ); + } + + let mut issues = Vec::new(); + let http = serde_json::json!({ + "mode": object.get("mode"), + "customProxyUrl": object.get("customProxyUrl"), + }); + match parse::(http).and_then(normalize) { + Ok(http) => { + settings.mode = http.mode; + settings.custom_proxy_url = http.custom_proxy_url; + } + Err(code) => issues.push(issue(ProxySettingsTarget::Http, code)), + } + + if let Some(native) = object.get("nativeGit") { + let native = parse::(native.clone()).and_then(|native_git| { + normalize(NetworkProxySettings { + native_git, + ..Default::default() + }) + }); + match native { + Ok(native) => settings.native_git = native.native_git, + Err(code) => issues.push(issue(ProxySettingsTarget::NativeGit, code)), + } + } + + if let Some(wsl) = object.get("wslGit") { + if let Some(wsl) = wsl.as_object() { + for (distro, value) in wsl { + let target = ProxySettingsTarget::WslGit { + distro: Some(distro.trim().to_string()), + }; + let entry = parse::(value.clone()).and_then(|entry| { + normalize(NetworkProxySettings { + wsl_git: [(distro.clone(), entry)].into_iter().collect(), + ..Default::default() + }) + }); + match entry { + Ok(entry) => settings.wsl_git.extend(entry.wsl_git), + Err(code) => issues.push(issue(target, code)), + } + } + } else { + issues.push(issue( + ProxySettingsTarget::WslGit { distro: None }, + ProxySettingsIssueCode::InvalidFormat, + )); + } + } + (settings, issues) +} + +fn parse(value: Value) -> Result { + serde_json::from_value(value).map_err(|_| ProxySettingsIssueCode::InvalidFormat) +} + +fn normalize( + settings: NetworkProxySettings, +) -> Result { + settings + .validate_and_normalize() + .map_err(|_| ProxySettingsIssueCode::InvalidProxyUrl) +} + +pub(super) fn issue( + target: ProxySettingsTarget, + code: ProxySettingsIssueCode, +) -> ProxySettingsIssue { + ProxySettingsIssue { + target, + code, + using_previous: false, + } +} diff --git a/src-tauri/src/core/custom_agent_repository.rs b/src-tauri/src/core/custom_agent_repository.rs index 82e9b446..c0cf9846 100644 --- a/src-tauri/src/core/custom_agent_repository.rs +++ b/src-tauri/src/core/custom_agent_repository.rs @@ -1,9 +1,6 @@ -use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; -use tempfile::NamedTempFile; use super::agent_definition::{AgentFieldError, AgentId, CustomAgentDefinition}; use super::agent_settings::CustomAgentRecord; @@ -11,6 +8,7 @@ use super::app_config::get_config_path; use crate::error::AppError; pub const CUSTOM_AGENT_SCHEMA_VERSION: u32 = 1; +const MAX_CUSTOM_AGENT_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CustomAgentFile { @@ -79,14 +77,20 @@ impl CustomAgentRepository { Err(ReadFailure::UnsupportedSchema(version)) => Err(unsupported_schema_error(version)), Err(ReadFailure::Io(error)) => Err(error), Err(ReadFailure::Missing) => match read_file(&self.backup_path()) { - Ok(file) => Ok(file), + Ok(file) => { + log::warn!("Custom Agent repository loaded its last valid backup"); + Ok(file) + } Err(ReadFailure::UnsupportedSchema(version)) => { Err(unsupported_schema_error(version)) } Err(_) => Ok(CustomAgentFile::default()), }, Err(ReadFailure::Corrupt(primary_error)) => match read_file(&self.backup_path()) { - Ok(file) => Ok(file), + Ok(file) => { + log::warn!("Custom Agent repository loaded its last valid backup"); + Ok(file) + } Err(ReadFailure::UnsupportedSchema(version)) => { Err(unsupported_schema_error(version)) } @@ -100,21 +104,19 @@ impl CustomAgentRepository { return Err(unsupported_schema_error(file.schema_version as u64)); } validate_records(&file.records)?; + let bytes = serialize_file(file)?; - match fs::read(&self.path) { - Ok(existing) => match parse_file(&existing) { + if let Some(existing) = read_document_bytes(&self.path)? { + match parse_file(&existing) { Ok(_) => write_atomic(&self.backup_path(), &existing)?, Err(ReadFailure::UnsupportedSchema(version)) => { return Err(unsupported_schema_error(version)); } Err(ReadFailure::Io(error)) => return Err(error), Err(ReadFailure::Missing | ReadFailure::Corrupt(_)) => {} - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), + } } - let bytes = serialize_file(file)?; write_atomic(&self.path, &bytes) } @@ -373,13 +375,18 @@ fn serialize_file(file: &CustomAgentFile) -> Result, AppError> { } fn read_file(path: &Path) -> Result { - match fs::read(path) { - Ok(bytes) => parse_file(&bytes), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(ReadFailure::Missing), - Err(error) => Err(ReadFailure::Io(error.into())), + match read_document_bytes(path) { + Ok(Some(bytes)) => parse_file(&bytes), + Ok(None) => Err(ReadFailure::Missing), + Err(error) => Err(ReadFailure::Io(error)), } } +fn read_document_bytes(path: &Path) -> Result>, AppError> { + environment_engine::atomic_document::read_optional_bounded(path, MAX_CUSTOM_AGENT_BYTES) + .map_err(Into::into) +} + fn parse_file(bytes: &[u8]) -> Result { let root: Value = serde_json::from_slice(bytes).map_err(|error| ReadFailure::Corrupt(error.into()))?; @@ -452,14 +459,9 @@ fn deserialization_error(error: &serde_json::Error) -> AgentFieldError { } fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), AppError> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - let mut temp = NamedTempFile::new_in(parent)?; - temp.write_all(bytes)?; - temp.flush()?; - temp.as_file().sync_all()?; - temp.persist(path).map_err(|error| error.error)?; - Ok(()) + // The repository owns its one-generation backup. The shared publication + // mechanism must neither rotate nor delete it. + crate::environment::native::atomic_file::write_native_atomic(path, bytes) } enum ReadFailure { @@ -1141,4 +1143,39 @@ mod tests { assert_eq!(repository.load().expect("reload repository"), saved); assert_eq!(fs::read_to_string(marker).expect("read marker"), "keep me"); } + #[test] + fn backup_publication_failure_does_not_overwrite_the_primary() { + let temp = tempdir().unwrap(); + let repository = CustomAgentRepository::new(temp.path().join("custom-agents.json")); + let first = file(vec![CustomAgentRecord::valid(definition("first-agent"))]); + repository.save(&first).unwrap(); + let before = fs::read(repository.path()).unwrap(); + // A directory is a deterministic, cross-platform publication failure. + fs::create_dir(repository.backup_path()).unwrap(); + let second = file(vec![CustomAgentRecord::valid(definition("second-agent"))]); + assert!(repository.save(&second).is_err()); + assert_eq!(fs::read(repository.path()).unwrap(), before); + assert!(repository.backup_path().is_dir()); + } + + #[test] + fn oversized_repository_is_rejected_as_io_without_modifying_it() { + let temp = tempdir().unwrap(); + let path = temp.path().join("custom-agents.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + let repository = CustomAgentRepository::new(path.clone()); + + let error = repository.load().unwrap_err(); + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/core/eve.rs b/src-tauri/src/core/eve.rs index 8877e0fa..400acb21 100644 --- a/src-tauri/src/core/eve.rs +++ b/src-tauri/src/core/eve.rs @@ -138,10 +138,14 @@ pub fn eve_install_targets( targets.extend(subagents.into_iter().map(|subagent| { let subagent = lock_subagent_value(Some(&subagent)); InstallTargetInfo { - target_id: eve_target_id(Some(&subagent)), + target_id: eve_target_id(Some(&sanitize_name(&subagent))), agent: eve_agent_id(), display_name: eve_target_label(Some(&subagent)), - path: eve_skills_dir_for_target(project_path, Some(&subagent)) + path: Path::new(project_path) + .join("agent") + .join("subagents") + .join(&subagent) + .join("skills") .to_string_lossy() .to_string(), subagent: Some(subagent), @@ -369,6 +373,20 @@ mod tests { ); } + #[test] + fn eve_install_targets_keep_the_spelling_of_discovered_subagent_directories() { + let root = tempdir().unwrap(); + let targets = eve_install_targets(&root.path().to_string_lossy(), ["Research Team".into()]); + assert_eq!( + Path::new(&targets[1].path), + root.path() + .join("agent/subagents") + .join("Research Team") + .join("skills") + ); + assert_eq!(targets[1].target_id, "eve:research-team"); + } + #[test] fn eve_skill_md_normalizer_removes_unsupported_name_field() { let raw = "---\nname: demo\ndescription: Demo\nlicense: MIT\nmetadata:\n keep: value\n skip: 1\n---\n# Demo\n"; diff --git a/src-tauri/src/core/git.rs b/src-tauri/src/core/git.rs index cc234fb3..cedb9204 100644 --- a/src-tauri/src/core/git.rs +++ b/src-tauri/src/core/git.rs @@ -106,10 +106,11 @@ where // 如果指定了分支/tag if let Some(branch) = git_ref { - cmd.arg("--branch").arg(branch); + cmd.arg("--branch") + .arg(environment_engine::git_ref::clone_branch(branch)); } - cmd.arg(url).arg(&repo_path); + cmd.arg("--").arg(url).arg(&repo_path); // 执行克隆 let result = execute_with_timeout_and_progress(&mut cmd, timeout, &on_progress, &cancellation); @@ -117,6 +118,33 @@ where match result { Ok(output) => { if output.success { + if let Some(reference) = git_ref.filter(|reference| reference.starts_with("refs/")) + { + for arguments in [ + vec!["fetch", "--depth", "1", "origin", reference], + vec!["checkout", "--detach", "FETCH_HEAD"], + ] { + let mut command = std_command("git"); + apply_proxy_override(&mut command, proxy); + apply_clone_env(&mut command); + command.arg("-C").arg(&repo_path).args(arguments); + let remaining = timeout.saturating_sub(started_at.elapsed()); + if remaining.is_zero() { + return Err(AppError::GitTimeout { + timeout_secs: display_timeout_secs.try_into().unwrap_or(u32::MAX), + }); + } + let output = execute_with_timeout_and_progress( + &mut command, + remaining, + &on_progress, + &cancellation, + )?; + if !output.success { + return Err(classify_git_command_error(&output, url, "checkout ref")); + } + } + } on_progress(CloneProgress { phase: ClonePhase::Done, elapsed_secs: output.elapsed_secs, @@ -161,7 +189,7 @@ fn build_error_progress( } } -fn normalize_clone_timeout_secs(value: u64) -> u64 { +pub(crate) fn normalize_clone_timeout_secs(value: u64) -> u64 { if value == 0 { DEFAULT_CLONE_TIMEOUT_SECS } else { @@ -169,12 +197,6 @@ fn normalize_clone_timeout_secs(value: u64) -> u64 { } } -pub(crate) fn resolve_clone_timeout_secs() -> u64 { - crate::core::read_config() - .map(|config| normalize_clone_timeout_secs(config.git_clone_timeout_secs.into())) - .unwrap_or(DEFAULT_CLONE_TIMEOUT_SECS) -} - fn clone_env_pairs() -> [(&'static str, &'static str); 4] { [ ("GIT_TERMINAL_PROMPT", "0"), @@ -474,23 +496,11 @@ pub(crate) fn probe_remote_ref_revision_options( ) -> Result { let mut cmd = std_command("git"); apply_proxy_override(&mut cmd, proxy); - cmd.arg("ls-remote").arg("--exit-code").arg(url); - match git_ref.filter(|value| !value.is_empty()) { - Some(value) if value.starts_with("refs/") => { - cmd.arg(value); - if value.starts_with("refs/tags/") { - cmd.arg(format!("{value}^{{}}")); - } - } - Some(value) => { - cmd.arg(format!("refs/heads/{value}")) - .arg(format!("refs/tags/{value}^{{}}")) - .arg(format!("refs/tags/{value}")); - } - None => { - cmd.arg("HEAD"); - } - } + cmd.arg("ls-remote") + .arg("--exit-code") + .arg("--") + .arg(url) + .args(environment_engine::git_ref::remote_ref_patterns(git_ref)); apply_clone_env(&mut cmd); let output = execute_with_timeout_and_progress(&mut cmd, timeout, &|_| {}, &cancellation)?; @@ -503,13 +513,7 @@ pub(crate) fn probe_remote_ref_revision_options( return Err(classify_git_command_error(&output, url, "probe ref")); } - let lines = output.stdout; - let revision = lines - .lines() - .find(|line| line.ends_with("^{}")) - .or_else(|| lines.lines().next()) - .and_then(|line| line.split_whitespace().next()) - .and_then(normalize_git_object_id) + let revision = environment_engine::git_ref::resolve_remote_revision(&output.stdout, git_ref) .ok_or_else(|| AppError::GitRefNotFound { ref_name: git_ref.unwrap_or("HEAD").to_string(), })?; @@ -617,6 +621,67 @@ pub(crate) fn classify_git_failure( mod tests { use super::*; + #[test] + fn saved_ref_probe_and_checkout_agree_for_branch_tag_name_collisions() { + let temp = tempfile::tempdir().unwrap(); + let git = |args: &[&str]| { + let output = Command::new("git") + .current_dir(temp.path()) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + }; + git(&["init", "-b", "main"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "Skill Deck Test"]); + git(&["commit", "--allow-empty", "-m", "tag content"]); + let tag = git(&["rev-parse", "HEAD"]); + git(&["tag", "-a", "release", "-m", "release"]); + git(&["commit", "--allow-empty", "-m", "branch content"]); + let branch = git(&["rev-parse", "HEAD"]); + git(&["branch", "release"]); + let source = url::Url::from_directory_path(temp.path()) + .unwrap() + .to_string(); + for (reference, expected) in [ + (None, &branch), + (Some("release"), &branch), + (Some("refs/heads/release"), &branch), + (Some("refs/tags/release"), &tag), + ] { + let probed = probe_remote_ref_revision_options( + &source, + reference, + CancellationSignal::default(), + None, + Duration::from_secs(30), + ) + .unwrap(); + let cloned = clone_repo_with_progress_options( + &source, + reference, + |_| {}, + CancellationSignal::default(), + None, + Duration::from_secs(30), + 30, + ) + .unwrap(); + assert_eq!(&probed, expected, "{reference:?}"); + assert_eq!( + cloned.ref_revision.as_ref(), + Some(expected), + "{reference:?}" + ); + } + } + const SUBPROCESS_OUTPUT_FIXTURE_ENV: &str = "SKILL_DECK_GIT_OUTPUT_FIXTURE"; const SUBPROCESS_PID_FILE_ENV: &str = "SKILL_DECK_GIT_PROCESS_PID_FILE"; const SUBPROCESS_DESCENDANT_PID_FILE_ENV: &str = "SKILL_DECK_GIT_DESCENDANT_PID_FILE"; diff --git a/src-tauri/src/core/github_api.rs b/src-tauri/src/core/github_api.rs index 1fc2b3f9..c0a50bc9 100644 --- a/src-tauri/src/core/github_api.rs +++ b/src-tauri/src/core/github_api.rs @@ -3,6 +3,7 @@ pub enum GithubTreeFailure { AuthenticationRequired, NotFoundOrUnauthorized, Network, + ProxyConfiguration, SourceUnavailable, } diff --git a/src-tauri/src/core/lock_repository.rs b/src-tauri/src/core/lock_repository.rs index a81eed91..27a4075d 100644 --- a/src-tauri/src/core/lock_repository.rs +++ b/src-tauri/src/core/lock_repository.rs @@ -8,6 +8,7 @@ use crate::core::lossless_lock::{ use crate::environment::lock_io::EnvironmentLockIo; use crate::environment::types::ResourceLocator; use crate::error::AppError; +use crate::storage::atomic_document::{DocumentSnapshot, DocumentWriteFailure}; pub struct LockTarget { pub primary: ResourceLocator, @@ -30,6 +31,11 @@ pub struct LockTransaction<'a> { pending_roots: BTreeMap, } +struct ParsedLockSnapshot { + document: LosslessLockDocument, + primary: DocumentSnapshot, +} + impl LockRepository { pub fn new(io: EnvironmentLockIo) -> Self { Self { io } @@ -39,20 +45,40 @@ impl LockRepository { &self, target: &LockTarget, ) -> Result { - if let Some(bytes) = self.io.read_optional(&target.primary).await? { - return LosslessLockDocument::parse(&bytes); + Ok(self.read_document_snapshot(target).await?.document) + } + + async fn read_document_snapshot( + &self, + target: &LockTarget, + ) -> Result { + let primary = self.io.observe(&target.primary).await?; + if let Some(bytes) = primary.bytes.as_deref() { + return Ok(ParsedLockSnapshot { + document: LosslessLockDocument::parse(bytes)?, + primary, + }); } let Some(legacy) = target.legacy.as_ref() else { - return Ok(LosslessLockDocument::empty(target.schema)); + return Ok(ParsedLockSnapshot { + document: LosslessLockDocument::empty(target.schema), + primary, + }); }; let Some(bytes) = self.io.read_optional(legacy).await? else { - return Ok(LosslessLockDocument::empty(target.schema)); + return Ok(ParsedLockSnapshot { + document: LosslessLockDocument::empty(target.schema), + primary, + }); }; let document = LosslessLockDocument::parse(&bytes)?; - match target.schema { - LockSchema::Global => Ok(document), - LockSchema::Project => convert_legacy_project_document(document), - } + Ok(ParsedLockSnapshot { + document: match target.schema { + LockSchema::Global => document, + LockSchema::Project => convert_legacy_project_document(document)?, + }, + primary, + }) } pub async fn begin( @@ -97,7 +123,8 @@ impl LockTransaction<'_> { root_snapshots, pending_roots, } = self; - let mut latest = repository.read_document(&target).await?; + let latest_snapshot = repository.read_document_snapshot(&target).await?; + let mut latest = latest_snapshot.document; for (field, replacement) in pending_roots { latest.replace_root( &field, @@ -109,8 +136,14 @@ impl LockTransaction<'_> { } repository .io - .write_atomic(&target.primary, latest.to_pretty_bytes()?) + .replace( + &target.primary, + latest_snapshot.primary, + latest.to_pretty_bytes()?, + ) .await + .map(|_| ()) + .map_err(DocumentWriteFailure::into_error) } fn require_root_snapshot(&self, field: &str) -> Result<&LockRootSnapshot, AppError> { diff --git a/src-tauri/src/core/lossless_lock.rs b/src-tauri/src/core/lossless_lock.rs index 7cd67662..69408148 100644 --- a/src-tauri/src/core/lossless_lock.rs +++ b/src-tauri/src/core/lossless_lock.rs @@ -42,6 +42,20 @@ pub struct LockEntrySnapshot(Option); pub struct LockRootSnapshot(Option); impl LockEntrySnapshot { + pub(crate) fn from_value(value: Option) -> Self { + Self(value) + } + + pub fn value(&self) -> Option<&Value> { + self.0.as_ref() + } +} + +impl LockRootSnapshot { + pub(crate) fn from_value(value: Option) -> Self { + Self(value) + } + pub fn value(&self) -> Option<&Value> { self.0.as_ref() } @@ -84,6 +98,7 @@ impl LosslessLockDocument { LockRootSnapshot(self.root.get(field).cloned()) } + #[cfg(test)] pub fn replace_entry( &mut self, schema: LockSchema, @@ -105,54 +120,6 @@ impl LosslessLockDocument { Ok(()) } - pub fn remove_entry( - &mut self, - skill_name: &str, - expected: &LockEntrySnapshot, - ) -> Result<(), AppError> { - if self.skills().get(skill_name) != expected.0.as_ref() { - return Err(AppError::LockConflict { - target: LockConflictTarget::Skill { - skill_name: skill_name.to_string(), - }, - }); - } - self.skills_mut().remove(skill_name); - Ok(()) - } - - pub fn move_and_replace_entry( - &mut self, - schema: LockSchema, - from: &str, - to: &str, - expected_from: &LockEntrySnapshot, - expected_to: &LockEntrySnapshot, - replacement: Value, - ) -> Result<(), AppError> { - self.validate_entry_snapshot(from, expected_from)?; - self.validate_entry_snapshot(to, expected_to)?; - let replacement = merge_entry_fields(schema, self.skills().get(from), replacement); - self.skills_mut().remove(from); - self.skills_mut().insert(to.to_string(), replacement); - Ok(()) - } - - pub fn validate_entry_snapshot( - &self, - skill_name: &str, - expected: &LockEntrySnapshot, - ) -> Result<(), AppError> { - if self.skills().get(skill_name) != expected.0.as_ref() { - return Err(AppError::LockConflict { - target: LockConflictTarget::Skill { - skill_name: skill_name.to_string(), - }, - }); - } - Ok(()) - } - pub fn replace_root( &mut self, field: &str, @@ -187,6 +154,7 @@ impl LosslessLockDocument { self.root["skills"].as_object().expect("validated skills") } + #[cfg(test)] fn skills_mut(&mut self) -> &mut Map { self.root["skills"] .as_object_mut() diff --git a/src-tauri/src/core/mutation.rs b/src-tauri/src/core/mutation.rs index 03fe1c12..f6b1cb5a 100644 --- a/src-tauri/src/core/mutation.rs +++ b/src-tauri/src/core/mutation.rs @@ -21,6 +21,7 @@ pub enum MutationKind { // Kept so persisted recovery records from the retired cleanup operation remain readable. #[allow(dead_code)] DuplicateCleanup, + // Kept so recovery records created by the retired source-repair flow remain readable. Repair, ManageAgentDefinitions, ProjectMigration, diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs index 56ee2856..88d3f595 100644 --- a/src-tauri/src/core/paths.rs +++ b/src-tauri/src/core/paths.rs @@ -1,12 +1,12 @@ // 路径工具函数 // 对应 CLI: agents.ts 顶层常量 (home, configHome) -use once_cell::sync::Lazy; use std::path::PathBuf; +use std::sync::LazyLock; /// 路径上下文(与 CLI 顶层常量对应) -/// 使用 Lazy 单例,只初始化一次 -pub static PATHS: Lazy = Lazy::new(PathContext::new); +/// 使用 LazyLock 单例,只初始化一次 +pub static PATHS: LazyLock = LazyLock::new(PathContext::new); /// 路径上下文 /// 对应 CLI: agents.ts 第 7-11 行 diff --git a/src-tauri/src/core/projects.rs b/src-tauri/src/core/projects.rs index b4bf1a1b..53ea1b47 100644 --- a/src-tauri/src/core/projects.rs +++ b/src-tauri/src/core/projects.rs @@ -1,17 +1,16 @@ use std::collections::HashSet; use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Mutex; use serde::{Deserialize, Serialize}; -use tempfile::NamedTempFile; use uuid::Uuid; use crate::environment::types::RegisteredProject; use crate::error::AppError; const PROJECTS_SCHEMA_VERSION: u32 = 1; +const MAX_PROJECT_DOCUMENT_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ProjectPathSemantics { @@ -168,15 +167,28 @@ impl ProjectsStore { } pub fn read(&self) -> Result, AppError> { - if !self.path.exists() { - return Ok(Vec::new()); + let content = match read_document_bytes(&self.path)? { + Some(content) => content, + None => return Ok(Vec::new()), + }; + // Read the version before deserializing an older shape. Future versions + // may have changed the remaining fields and must never be rewritten. + let value: serde_json::Value = serde_json::from_slice(&content)?; + if value + .get("schemaVersion") + .and_then(serde_json::Value::as_u64) + != Some(u64::from(PROJECTS_SCHEMA_VERSION)) + { + return Err(AppError::ConfigurationReadOnly); } - let content = fs::read(&self.path)?; - let file: ProjectsFile = serde_json::from_slice(&content)?; + let file: ProjectsFile = serde_json::from_value(value)?; Ok(file.projects) } pub fn write(&self, projects: &[RegisteredProject]) -> Result<(), AppError> { + // Direct writes obey the same unreadable/corrupt/future-version guard as + // read/modify/write callers. The application still owns serialization. + self.read()?; let projects = deduplicate_projects(projects, self.semantics); let file = ProjectsFile { schema_version: PROJECTS_SCHEMA_VERSION, @@ -215,15 +227,20 @@ pub fn migrate_legacy_projects( config_path: &Path, projects_path: &Path, ) -> Result { - let store = ProjectsStore::new(projects_path.to_path_buf()); - if projects_path.is_file() { - return Ok(ProjectMigrationState::NotNeeded); - } - if !config_path.exists() { - return Ok(ProjectMigrationState::NotNeeded); - } + migrate_legacy_projects_with_before_config_commit(config_path, projects_path, || {}) +} - let mut config: serde_json::Value = serde_json::from_slice(&fs::read(config_path)?)?; +fn migrate_legacy_projects_with_before_config_commit( + config_path: &Path, + projects_path: &Path, + before_config_commit: impl FnOnce(), +) -> Result { + let store = ProjectsStore::new(projects_path.to_path_buf()); + let original = match read_document_bytes(config_path)? { + Some(bytes) => bytes, + None => return Ok(ProjectMigrationState::NotNeeded), + }; + let mut config: serde_json::Value = serde_json::from_slice(&original)?; let Some(root) = config.as_object_mut() else { return Err(AppError::Json { message: "config root must be a JSON object".to_string(), @@ -244,23 +261,58 @@ pub fn migrate_legacy_projects( return Ok(ProjectMigrationState::NotNeeded); } - let projects = legacy_paths - .into_iter() - .map(|native_path| RegisteredProject { - id: Uuid::new_v4().to_string(), - native_path: normalize_native_path(&native_path, store.semantics), - display_name: None, - order: None, - suppress_cross_storage_warning: false, - }) - .collect::>(); - - store.write(&projects)?; + let registry_exists = match fs::symlink_metadata(projects_path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error.into()), + }; + if registry_exists { + // Retrying after the new registry was published must not allocate new + // project IDs. Only finish cleanup when every old path is represented. + let current = store.read()?; + let keys = current + .iter() + .map(|project| normalize_project_path(&project.native_path, store.semantics).key) + .collect::>(); + if legacy_paths + .iter() + .any(|path| !keys.contains(&normalize_project_path(path, store.semantics).key)) + { + return Err(AppError::ConfigurationCorrupted { + message: "existing projects do not contain all legacy project paths; original config retained".to_string(), + }); + } + } else { + let projects = legacy_paths + .into_iter() + .map(|native_path| RegisteredProject { + id: Uuid::new_v4().to_string(), + native_path: normalize_native_path(&native_path, store.semantics), + display_name: None, + order: None, + suppress_cross_storage_warning: false, + }) + .collect::>(); + store.write(&projects)?; + } - let backup_path = config_backup_path(config_path); - fs::copy(config_path, backup_path)?; + // Preserve the exact source bytes. A partial backup cannot authorize + // removing the last legacy representation. + crate::environment::native::atomic_file::write_native_atomic( + &config_backup_path(config_path), + &original, + )?; root.remove("projects"); - atomic_write_json(config_path, &config)?; + before_config_commit(); + let mut updated = serde_json::to_vec_pretty(&config)?; + updated.push(b'\n'); + environment_engine::atomic_document::replace_if_unchanged( + config_path, + Some(&original), + &updated, + ) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error)?; Ok(ProjectMigrationState::Succeeded) } @@ -401,14 +453,14 @@ fn normalize_windows_project_path(path: &str) -> NormalizedProjectPath { } fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<(), AppError> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - let mut temp = NamedTempFile::new_in(parent)?; - serde_json::to_writer_pretty(&mut temp, value)?; - temp.write_all(b"\n")?; - temp.as_file().sync_all()?; - temp.persist(path).map_err(|error| error.error)?; - Ok(()) + let mut bytes = serde_json::to_vec_pretty(value)?; + bytes.push(b'\n'); + crate::environment::native::atomic_file::write_native_atomic(path, &bytes) +} + +fn read_document_bytes(path: &Path) -> Result>, AppError> { + environment_engine::atomic_document::read_optional_bounded(path, MAX_PROJECT_DOCUMENT_BYTES) + .map_err(Into::into) } fn config_backup_path(config_path: &Path) -> PathBuf { @@ -424,7 +476,8 @@ mod tests { use tempfile::tempdir; use super::{ - add_project_binding, migrate_legacy_projects, ProjectMigrationRegistry, + add_project_binding, migrate_legacy_projects, + migrate_legacy_projects_with_before_config_commit, ProjectMigrationRegistry, ProjectMigrationState, ProjectPathSemantics, ProjectsStore, }; use crate::environment::types::RegisteredProject; @@ -589,6 +642,48 @@ mod tests { assert_eq!(config["projects"], json!(["/demo"])); } + #[test] + fn migration_does_not_overwrite_a_config_changed_after_observation() { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("config.json"); + let projects_path = temp.path().join("projects.json"); + let original = serde_json::to_vec(&json!({ + "projects": ["/demo"], + "gitCloneTimeoutSecs": 120 + })) + .unwrap(); + fs::write(&config_path, original).unwrap(); + let externally_updated = serde_json::to_vec(&json!({ + "projects": ["/demo"], + "gitCloneTimeoutSecs": 300 + })) + .unwrap(); + + let result = + migrate_legacy_projects_with_before_config_commit(&config_path, &projects_path, || { + fs::write(&config_path, &externally_updated).unwrap() + }); + + assert!(matches!(result, Err(AppError::StaleTarget))); + assert_eq!(fs::read(&config_path).unwrap(), externally_updated); + let first_id = ProjectsStore::new(projects_path.clone()).read().unwrap()[0] + .id + .clone(); + + assert_eq!( + migrate_legacy_projects(&config_path, &projects_path).unwrap(), + ProjectMigrationState::Succeeded + ); + assert_eq!( + ProjectsStore::new(projects_path).read().unwrap()[0].id, + first_id + ); + let config: serde_json::Value = + serde_json::from_slice(&fs::read(config_path).unwrap()).unwrap(); + assert_eq!(config["gitCloneTimeoutSecs"], 300); + assert!(config.get("projects").is_none()); + } + #[test] fn failed_migration_state_blocks_native_projects_until_replaced() { let registry = ProjectMigrationRegistry::new(ProjectMigrationState::Failed { @@ -690,4 +785,97 @@ mod tests { assert_eq!(posix.project.native_path, "/"); assert_eq!(windows.project.native_path, "C:\\"); } + + #[test] + fn future_project_schema_is_read_only_even_for_direct_write() { + let temp = tempdir().unwrap(); + let path = temp.path().join("projects.json"); + let original = br#"{"schemaVersion":2,"projects":[],"future":true}"#; + fs::write(&path, original).unwrap(); + let store = ProjectsStore::new(path.clone()); + assert!(matches!(store.read(), Err(AppError::ConfigurationReadOnly))); + assert!(matches!( + store.write(&[]), + Err(AppError::ConfigurationReadOnly) + )); + assert_eq!(fs::read(path).unwrap(), original); + } + + #[test] + fn corrupt_project_registry_is_not_replaced_with_an_empty_registry() { + let temp = tempdir().unwrap(); + let path = temp.path().join("projects.json"); + fs::write(&path, b"{broken").unwrap(); + let store = ProjectsStore::new(path.clone()); + assert!(store.write(&[]).is_err()); + assert_eq!(fs::read(path).unwrap(), b"{broken"); + } + + #[test] + fn migration_retry_preserves_existing_project_ids_and_finishes_config_cleanup() { + let temp = tempdir().unwrap(); + let config = temp.path().join("config.json"); + let projects = temp.path().join("projects.json"); + let project_path = temp.path().join("project").to_string_lossy().into_owned(); + let store = ProjectsStore::new(projects.clone()); + let existing = store.add(project_path.clone()).unwrap().project; + // Simulate an interruption after projects.json was published but before + // the legacy config field was removed. This is not a new migration. + fs::write( + &config, + serde_json::to_vec(&json!({ + "projects": [project_path], "futureField": {"keep": true} + })) + .unwrap(), + ) + .unwrap(); + assert_eq!( + migrate_legacy_projects(&config, &projects).unwrap(), + ProjectMigrationState::Succeeded + ); + assert_eq!(store.read().unwrap()[0].id, existing.id); + let result: serde_json::Value = serde_json::from_slice(&fs::read(config).unwrap()).unwrap(); + assert!(result.get("projects").is_none()); + assert_eq!(result["futureField"], json!({"keep": true})); + } + + #[test] + fn migration_does_not_merge_unknown_partial_state_into_an_existing_registry() { + let temp = tempdir().unwrap(); + let config = temp.path().join("config.json"); + let projects = temp.path().join("projects.json"); + let store = ProjectsStore::new(projects.clone()); + store + .add(temp.path().join("new").to_string_lossy().into_owned()) + .unwrap(); + let original = serde_json::to_vec(&json!({ + "projects": [temp.path().join("old").to_string_lossy()] + })) + .unwrap(); + fs::write(&config, &original).unwrap(); + assert!(migrate_legacy_projects(&config, &projects).is_err()); + assert_eq!(fs::read(config).unwrap(), original); + assert_eq!(store.read().unwrap().len(), 1); + } + + #[test] + fn oversized_project_registry_is_rejected_before_deserialization() { + let temp = tempdir().unwrap(); + let path = temp.path().join("projects.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + let store = ProjectsStore::new(path.clone()); + + let error = store.read().unwrap_err(); + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/core/skill.rs b/src-tauri/src/core/skill.rs index 677d90bc..1878c454 100644 --- a/src-tauri/src/core/skill.rs +++ b/src-tauri/src/core/skill.rs @@ -23,6 +23,7 @@ pub struct SkillMetadata { /// 对应 CLI: parseSkillMd 返回的数据结构 #[derive(Debug, Clone, Deserialize)] pub struct SkillFrontmatter { + #[serde(default)] pub name: String, pub description: String, #[serde(default)] @@ -117,8 +118,17 @@ pub enum InstalledSkillLocation { Project, } -/// 已安装的 Skill 信息 -/// 对应 CLI: InstalledSkill (installer.ts:783-790) +/// 当前 Scope 引用的库版本,用于定位到对应库成员。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct InstalledLibraryVersion { + pub library_id: String, + pub library_name: String, + pub skill_name: String, +} + +/// 已安装的直接 Skill 信息。 #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -132,6 +142,13 @@ pub struct InstalledSkill { /// 每次读取 Skill 时根据当前 runtime 和文件系统重新组装,不写入 skill-lock。 /// 只包含当前已检测到并且实际能够读取该 Skill 的关联 Agent。 pub associated_agents: Vec, + /// 当前 Scope 中实际被引用的同名库版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub library_versions: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub maintenance_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub comparison_fingerprint: Option, // 来自 skill-lock.json 的元数据 #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, @@ -191,6 +208,8 @@ impl InstalledSkill { /// 从 global lock entry 填充元数据 pub fn with_lock_entry(mut self, entry: Option<&SkillLockEntry>) -> Self { if let Some(e) = entry { + self.comparison_fingerprint = + Some(normalize_global_lock_entry(e).comparison_fingerprint()); self.source = Some(e.source.clone()); self.source_url = normalize_global_lock_entry(e).source_url; self.installed_at = Some(e.installed_at.clone()); @@ -211,6 +230,7 @@ impl InstalledSkill { pub fn with_local_lock_entry(mut self, entry: Option<&LocalSkillLockEntry>) -> Self { if let Some(e) = entry { let metadata = normalize_local_lock_entry(e); + self.comparison_fingerprint = Some(metadata.comparison_fingerprint()); self.source = Some(e.source.clone()); self.source_url = metadata.source_url.clone(); self.plugin_name = e.plugin_name.clone(); @@ -346,6 +366,9 @@ Content. scope: InstalledSkillLocation::Project, agents: Vec::new(), associated_agents: Vec::new(), + library_versions: None, + maintenance_error: None, + comparison_fingerprint: None, source: None, source_url: None, installed_at: None, @@ -372,6 +395,16 @@ Content. skill.source_url.as_deref(), Some("git@github.com:owner/private-repo.git") ); + let previous = serde_json::to_value(&skill).unwrap(); + let mut updated_entry = entry.clone(); + updated_entry.remote_hash = Some("new-upstream-revision".to_string()); + let updated = + serde_json::to_value(skill.with_local_lock_entry(Some(&updated_entry))).unwrap(); + assert!(previous["comparisonFingerprint"].is_string()); + assert_ne!( + previous["comparisonFingerprint"], + updated["comparisonFingerprint"] + ); } #[test] @@ -397,6 +430,9 @@ Content. scope: InstalledSkillLocation::Project, agents: Vec::new(), associated_agents: Vec::new(), + library_versions: None, + maintenance_error: None, + comparison_fingerprint: None, source: None, source_url: None, installed_at: None, @@ -423,42 +459,6 @@ Content. assert_eq!(skill.skill_path.as_deref(), Some("skills/demo/SKILL.md")); } - #[test] - fn test_installed_skill_runtime_update_capabilities_can_be_stored() { - let skill = InstalledSkill { - name: "demo".to_string(), - description: "Demo".to_string(), - path: String::new(), - canonical_path: String::new(), - scope: InstalledSkillLocation::Global, - agents: Vec::new(), - associated_agents: Vec::new(), - source: None, - source_url: None, - installed_at: None, - updated_at: None, - has_update: Some(false), - can_run_update: Some(true), - can_check_for_updates: Some(false), - update_reason: Some("missing-skill-path".to_string()), - plugin_name: None, - git_ref: None, - skill_path: None, - default_available_agent_count: None, - private_adapted_agent_count: None, - duplicate_copy_count: None, - default_available_agents: None, - private_adapted_agents: None, - duplicate_copy_agents: None, - private_only_agents: None, - private_copy_agents: None, - }; - - assert_eq!(skill.can_run_update, Some(true)); - assert_eq!(skill.can_check_for_updates, Some(false)); - assert_eq!(skill.update_reason.as_deref(), Some("missing-skill-path")); - } - #[test] fn test_read_skill_content_returns_body_without_frontmatter() { let content = diff --git a/src-tauri/src/core/skill_lock.rs b/src-tauri/src/core/skill_lock.rs index 23b66236..2e502887 100644 --- a/src-tauri/src/core/skill_lock.rs +++ b/src-tauri/src/core/skill_lock.rs @@ -58,11 +58,10 @@ pub fn get_skill_lock_path() -> std::path::PathBuf { #[cfg(test)] mod tests { use super::*; - use once_cell::sync::Lazy; - use std::sync::Mutex; + use std::sync::{LazyLock, Mutex}; use tempfile::tempdir; - static ENV_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); #[test] fn test_get_skill_lock_path() { diff --git a/src-tauri/src/core/source_identity.rs b/src-tauri/src/core/source_identity.rs index 6fe94367..f8d2541e 100644 --- a/src-tauri/src/core/source_identity.rs +++ b/src-tauri/src/core/source_identity.rs @@ -1,12 +1,15 @@ use std::fmt; +use sha2::{Digest, Sha256}; use url::Url; use crate::core::NormalizedUpdateMetadata; use crate::error::AppError; use crate::models::{ParsedSource, SourceType}; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] pub enum SourceProvider { Github, Gitlab, @@ -14,13 +17,13 @@ pub enum SourceProvider { WellKnown, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] pub enum NormalizedRef { Default, Named(String), } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] pub struct RemoteSourceIdentity { provider: SourceProvider, authority: String, @@ -172,9 +175,10 @@ impl SourceIdentity { .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| metadata.source.clone()); - let source = if source_type == SourceType::GitHub && !source.contains("://") { + let shorthand = !source.contains("://") && !source.contains('@'); + let source = if source_type == SourceType::GitHub && shorthand { format!("https://github.com/{source}") - } else if source_type == SourceType::GitLab && !source.contains("://") { + } else if source_type == SourceType::GitLab && shorthand { format!("https://gitlab.com/{source}") } else { source @@ -192,17 +196,32 @@ impl SourceIdentity { "source does not have a remote Git identity", )); } - let location = parse_remote_location(&source)?; + let git_ref = if source_type == SourceType::WellKnown { + None + } else { + git_ref + }; + let location = parse_remote_location(&source, source_type == SourceType::WellKnown)?; let provider = provider_for(&source_type, &location.authority); + let display = format!("{}/{}", location.authority, location.repository); + let repository = if provider == SourceProvider::WellKnown + || (provider != SourceProvider::Github && location.authority != "gitlab.com") + { + opaque_locator(&source, provider == SourceProvider::WellKnown)? + } else if provider == SourceProvider::Github { + location.repository.to_ascii_lowercase() + } else { + location.repository.clone() + }; let remote = RemoteSourceIdentity { provider, authority: location.authority.clone(), - repository: location.repository.clone(), + repository: repository.clone(), }; let transport = AcquisitionTransportIdentity { transport: location.transport, authority: location.authority.clone(), - repository: location.repository.clone(), + repository, }; let normalized_ref = match git_ref .as_deref() @@ -217,7 +236,7 @@ impl SourceIdentity { transport, normalized_ref, acquisition: AcquisitionDescriptor { source, git_ref }, - display: format!("{}/{}", location.authority, location.repository), + display, }) } @@ -240,6 +259,12 @@ impl SourceIdentity { pub fn sanitized_display(&self) -> &str { &self.display } + + pub fn key(&self) -> String { + let bytes = serde_json::to_vec(&(&self.remote, &self.normalized_ref)) + .expect("source identity serializes deterministically"); + format!("source-v2:{:x}", Sha256::digest(bytes)) + } } struct RemoteLocation { @@ -248,7 +273,30 @@ struct RemoteLocation { repository: String, } -fn parse_remote_location(source: &str) -> Result { +fn opaque_locator(source: &str, wellknown: bool) -> Result { + let normalized = match Url::parse(source) { + Ok(mut url) => { + if wellknown && !matches!(url.scheme(), "http" | "https") { + return Err(invalid_identity("Well-known sources require HTTP or HTTPS")); + } + url.set_fragment(None); + if wellknown && !url.path().trim_end_matches('/').ends_with("/index.json") { + url.set_query(None); + let path = url.path().trim_end_matches('/').to_string(); + url.set_path(&path); + } + url.to_string() + } + Err(_) if !wellknown => source.to_string(), + Err(_) => return Err(invalid_identity("invalid Well-known index URL")), + }; + Ok(format!( + "url-v2:{:x}", + Sha256::digest(normalized.as_bytes()) + )) +} + +fn parse_remote_location(source: &str, wellknown: bool) -> Result { if let Some((user_host, repository)) = source.split_once(':') { if !user_host.contains("//") && user_host.contains('@') { let authority = user_host @@ -270,6 +318,13 @@ fn parse_remote_location(source: &str) -> Result { "ssh" => AcquisitionTransport::Ssh, _ => AcquisitionTransport::Git, }; + if wellknown { + return Ok(RemoteLocation { + transport, + authority, + repository: url.path().trim_start_matches('/').to_string(), + }); + } remote_location(transport, &authority, url.path()) } @@ -329,6 +384,61 @@ mod tests { use super::*; use crate::core::parse_source; + fn wellknown(source: &str) -> SourceIdentity { + SourceIdentity::build(SourceType::WellKnown, source.to_string(), None).unwrap() + } + + #[test] + fn wellknown_identity_preserves_request_semantics_without_exposing_secrets() { + let original = wellknown("https://example.com/Team/index.json?token=secret-one"); + for different in [ + "https://example.com/Team/index.json?token=secret-two", + "http://example.com/Team/index.json?token=secret-one", + "https://example.com:8443/Team/index.json?token=secret-one", + "https://example.com/team/index.json?token=secret-one", + "https://example.com/Team.git/index.json?token=secret-one", + ] { + assert_ne!(original.remote(), wellknown(different).remote()); + } + assert_eq!( + original.remote(), + wellknown("https://EXAMPLE.com:443/Team/index.json?token=secret-one#section").remote() + ); + let rendered = format!("{original:?} {}", original.sanitized_display()); + assert!(!rendered.contains("secret-one")); + assert!(!rendered.contains("token=")); + assert!(original.remote().repository().starts_with("url-v2:")); + } + + #[test] + fn wellknown_root_and_scoped_inputs_do_not_forward_page_queries() { + for base in ["https://example.com/", "https://example.com/Team"] { + assert_eq!( + wellknown(base).remote(), + wellknown(&format!("{base}?page=2")).remote() + ); + } + assert_ne!( + wellknown("https://example.com/Team").remote(), + wellknown("https://example.com/team").remote() + ); + } + + #[test] + fn ordinary_git_urls_do_not_merge_distinct_transports_or_queries() { + let identity = + |url: &str| SourceIdentity::build(SourceType::Git, url.to_string(), None).unwrap(); + let first = identity("https://example.com/Tools.git?repo=one"); + for second in [ + "https://example.com/Tools.git?repo=two", + "http://example.com/Tools.git?repo=one", + "ssh://git@example.com/Tools.git", + "https://example.com/tools.git?repo=one", + ] { + assert_ne!(first.remote(), identity(second).remote()); + } + } + #[test] fn github_https_and_ssh_share_remote_identity_but_not_transport() { let https = SourceIdentity::from_parsed( @@ -400,6 +510,7 @@ mod tests { assert!(!rendered.contains("alice")); assert!(!rendered.contains("secret")); assert!(!rendered.contains("hidden")); - assert_eq!(identity.remote().repository(), "acme/tools"); + assert!(identity.remote().repository().starts_with("url-v2:")); + assert_eq!(identity.sanitized_display(), "example.com/acme/tools"); } } diff --git a/src-tauri/src/core/source_parser.rs b/src-tauri/src/core/source_parser.rs index 036a2b47..02619b74 100644 --- a/src-tauri/src/core/source_parser.rs +++ b/src-tauri/src/core/source_parser.rs @@ -16,9 +16,9 @@ use crate::error::AppError; use crate::models::{ParsedSource, SourceType}; -use once_cell::sync::Lazy; use regex::Regex; use std::path::Path; +use std::sync::LazyLock; use url::Url; /// Source 别名映射 @@ -48,13 +48,13 @@ struct FragmentRefResult { } // Lazy regex patterns for looks_like_git_source -static GITHUB_PATH_RE: Lazy = - Lazy::new(|| Regex::new(r"^/[^/]+/[^/]+(?:\.git)?(?:/tree/[^/]+(?:/.*)?)?/?$").unwrap()); -static GITLAB_PATH_RE: Lazy = - Lazy::new(|| Regex::new(r"^/.+?/[^/]+(?:\.git)?(?:/-/tree/[^/]+(?:/.*)?)?/?$").unwrap()); -static GIT_URL_RE: Lazy = Lazy::new(|| Regex::new(r"\.git(?:$|\?|/)").unwrap()); -static SHORTHAND_RE: Lazy = - Lazy::new(|| Regex::new(r"^[^/]+/[^/]+(?:/(.+)|@(.+))?$").unwrap()); +static GITHUB_PATH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^/[^/]+/[^/]+(?:\.git)?(?:/tree/[^/]+(?:/.*)?)?/?$").unwrap()); +static GITLAB_PATH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^/.+?/[^/]+(?:\.git)?(?:/-/tree/[^/]+(?:/.*)?)?/?$").unwrap()); +static GIT_URL_RE: LazyLock = LazyLock::new(|| Regex::new(r"\.git(?:$|\?|/)").unwrap()); +static SHORTHAND_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[^/]+/[^/]+(?:/(.+)|@(.+))?$").unwrap()); /// 判断输入是否看起来像 git 来源(用于决定是否提取 #fragment 作为分支引用) /// 只有 git-like 来源才应将 # 后的内容解释为分支 ref @@ -529,7 +529,8 @@ pub fn get_owner_repo(parsed: &ParsedSource) -> Option { } SourceType::Git => { // git@host:owner/repo.git → owner/repo - static SSH_RE: Lazy = Lazy::new(|| Regex::new(r"^git@[^:]+:(.+)$").unwrap()); + static SSH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^git@[^:]+:(.+)$").unwrap()); if let Some(caps) = SSH_RE.captures(&parsed.url) { let path = caps[1].trim_end_matches(".git"); if path.contains('/') { diff --git a/src-tauri/src/core/update_metadata.rs b/src-tauri/src/core/update_metadata.rs index 8a33d24c..93910ac4 100644 --- a/src-tauri/src/core/update_metadata.rs +++ b/src-tauri/src/core/update_metadata.rs @@ -1,7 +1,7 @@ use crate::core::local_lock::LocalSkillLockEntry; use crate::core::skill_lock::SkillLockEntry; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct NormalizedUpdateMetadata { pub source: String, pub source_type: String, @@ -14,6 +14,26 @@ pub struct NormalizedUpdateMetadata { } impl NormalizedUpdateMetadata { + pub fn comparison_fingerprint(&self) -> String { + use sha2::{Digest, Sha256}; + let value = match crate::core::SourceIdentity::from_metadata(self) { + Ok(identity) => serde_json::json!([ + identity.key(), + self.skill_path + .as_deref() + .map(crate::core::skill_paths::normalize_skill_folder_path), + self.comparison_baseline(), + ]), + Err(_) => { + serde_json::to_value(self).expect("update metadata serializes deterministically") + } + }; + format!( + "comparison-v1:{:x}", + Sha256::digest(value.to_string().as_bytes()) + ) + } + pub fn comparison_baseline(&self) -> Option<&str> { match self.source_type.as_str() { "well-known" => self diff --git a/src-tauri/src/environment/agent_environment.rs b/src-tauri/src/environment/agent_environment.rs index 132caba1..9ab9a27a 100644 --- a/src-tauri/src/environment/agent_environment.rs +++ b/src-tauri/src/environment/agent_environment.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use std::sync::Mutex; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use specta::Type; use crate::core::agent_definition::{ @@ -14,16 +15,15 @@ use crate::core::agent_definition::{ ScopeDefinition, }; use crate::core::agent_registry::AgentRegistrySnapshot; +use crate::core::paths::PATHS; +use crate::environment::context_resolver::ResolvedContext; use crate::environment::types::{same_environment_identity, EnvironmentRef, EnvironmentStatus}; use crate::environment::wsl::operations::path_metadata::{ - self, PathMetadataContent, PathMetadataKind, PathMetadataQuery, + PathMetadataContent, PathMetadataFact, PathMetadataKind, PathMetadataQuery, }; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -#[cfg(all(test, unix))] -use crate::environment::wsl::operations::path_metadata::PATH_METADATA_SCRIPT as WSL_PATH_METADATA_SCRIPT; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct EnvironmentContext { pub environment: EnvironmentRef, @@ -35,6 +35,145 @@ pub struct EnvironmentContext { pub wsl_workspace: Option, } +pub(crate) fn native_environment_context(resolved: &ResolvedContext) -> EnvironmentContext { + let environment_variables = std::env::vars().collect::>(); + let home = resolved.home.native_path.clone(); + let config_home = PATHS.config_home.to_string_lossy().to_string(); + let revision = environment_revision( + "native", + &(home.clone(), config_home.clone(), &environment_variables), + ); + EnvironmentContext { + environment: EnvironmentRef::Native, + home, + config_home, + environment_variables, + availability: EnvironmentStatus::Available, + revision, + wsl_workspace: None, + } +} + +pub(crate) fn wsl_environment_context( + resolved: &ResolvedContext, + session: WslSession, + workspace: WslWorkspace, +) -> EnvironmentContext { + let revision = environment_revision("wsl", &session); + EnvironmentContext { + environment: resolved.context.environment.clone(), + home: session.home.clone(), + config_home: session.config_home.clone(), + environment_variables: session.environment.clone(), + availability: EnvironmentStatus::Available, + revision, + wsl_workspace: Some(workspace), + } +} + +fn environment_revision(value_kind: &str, value: &impl Serialize) -> String { + let mut hasher = Sha256::new(); + hasher.update(value_kind.as_bytes()); + hasher.update( + serde_json::to_vec(value) + .expect("environment revision inputs must serialize deterministically"), + ); + format!("{:x}", hasher.finalize()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EveProjectTargets { + pub has_eve: bool, + pub subagents: Vec, +} + +impl EveProjectTargets { + /// 使用 Worker 返回的目录名称构造 WSL 路径,保留实际大小写。 + pub fn install_targets(self, project_path: &str) -> Vec { + if !self.has_eve { + return Vec::new(); + } + let project_path = project_path.trim_end_matches('/'); + std::iter::once(None) + .chain(self.subagents.into_iter().map(Some)) + .map(|subagent| { + let normalized = subagent.as_deref().map(crate::core::skill::sanitize_name); + let path = match &subagent { + Some(name) => format!("{project_path}/agent/subagents/{name}/skills"), + None => format!("{project_path}/agent/skills"), + }; + crate::models::InstallTargetInfo { + target_id: crate::core::eve::eve_target_id(normalized.as_deref()), + agent: crate::core::builtin_agent_catalog::eve_agent_id(), + display_name: crate::core::eve::eve_target_label(subagent.as_deref()), + subagent, + path, + } + }) + .collect() + } +} + +pub async fn inspect_eve_project( + workspace: &WslWorkspace, + project_path: &str, +) -> Result { + let project_path = project_path.trim_end_matches('/'); + let agent_path = format!("{project_path}/agent"); + let package_path = format!("{project_path}/package.json"); + let mut facts = workspace + .inspect_path_metadata(vec![ + PathMetadataQuery { + path: agent_path.clone(), + content_limit: None, + }, + PathMetadataQuery { + path: package_path.clone(), + content_limit: Some(1024 * 1024), + }, + ]) + .await?; + let package = facts.pop().expect("two path queries return two facts"); + let agent = facts.pop().expect("two path queries return two facts"); + if !matches!( + agent.kind, + PathMetadataKind::Directory | PathMetadataKind::SymlinkDirectory + ) { + return Ok(EveProjectTargets { + has_eve: false, + subagents: Vec::new(), + }); + } + let bytes = match package.content { + PathMetadataContent::Bytes(bytes) => bytes, + PathMetadataContent::Empty => Vec::new(), + PathMetadataContent::NotRequested => { + return Ok(EveProjectTargets { + has_eve: false, + subagents: Vec::new(), + }); + } + PathMetadataContent::Unreadable => { + return Err(AppError::Path { + message: format!("Eve package is not readable: {package_path}"), + }); + } + }; + let package: serde_json::Value = serde_json::from_slice(&bytes)?; + let has_eve = ["dependencies", "devDependencies"] + .into_iter() + .any(|section| { + package + .get(section) + .and_then(serde_json::Value::as_object) + .is_some_and(|dependencies| dependencies.contains_key("eve")) + }); + let subagents = workspace + .list_child_directories(format!("{agent_path}/subagents"), 10_000) + .await?; + Ok(EveProjectTargets { has_eve, subagents }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -169,7 +308,6 @@ type MetadataQuery = enum MetadataBackend { Native, Wsl(WslWorkspace), - ActiveWsl(WslSession), Unavailable, #[cfg(test)] Custom(MetadataQuery), @@ -198,13 +336,13 @@ impl AgentEnvironmentResolver { } } - pub(crate) fn from_active_wsl_session( + pub(crate) fn from_active_wsl_workspace( context: EnvironmentContext, - session: WslSession, + workspace: WslWorkspace, ) -> Self { Self { environment_context: context, - metadata_backend: MetadataBackend::ActiveWsl(session), + metadata_backend: MetadataBackend::Wsl(workspace), cache: Mutex::new(BTreeMap::new()), } } @@ -470,9 +608,6 @@ impl AgentEnvironmentResolver { let metadata = match &self.metadata_backend { MetadataBackend::Native => query_native_metadata(queries), MetadataBackend::Wsl(workspace) => query_wsl_metadata(workspace, queries).await, - MetadataBackend::ActiveWsl(session) => { - query_active_wsl_metadata(session, queries).await - } #[cfg(test)] MetadataBackend::Custom(query) => query(queries), MetadataBackend::Unavailable => Ok(BTreeMap::new()), @@ -959,36 +1094,16 @@ async fn query_wsl_metadata( .iter() .map(|query| PathMetadataQuery { path: query.path.clone(), - inspect_content: query.inspect_eve_package, + content_limit: query.inspect_eve_package.then_some(1024 * 1024), }) .collect::>(), - None, ) .await?; metadata_from_typed_facts(facts) } -async fn query_active_wsl_metadata( - session: &WslSession, - queries: &[PathQuery], -) -> Result, AppError> { - let queries = queries - .iter() - .map(|query| PathMetadataQuery { - path: query.path.clone(), - inspect_content: query.inspect_eve_package, - }) - .collect::>(); - metadata_from_typed_facts(path_metadata::inspect(session, &queries, None).await?) -} - -#[cfg(test)] -fn parse_wsl_path_metadata(bytes: &[u8]) -> Result, AppError> { - metadata_from_typed_facts(path_metadata::parse_path_metadata(bytes)?) -} - fn metadata_from_typed_facts( - facts: Vec, + facts: Vec, ) -> Result, AppError> { let mut metadata = BTreeMap::new(); for fact in facts { @@ -1134,21 +1249,30 @@ fn join_posix(base: &str, child: &str) -> String { } #[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "Agent Environment 测试需要直接运行待验证的 shell 脚本" -)] mod tests { + #[test] + fn wsl_eve_install_targets_use_posix_paths_and_actual_directory_names() { + let targets = super::EveProjectTargets { + has_eve: true, + subagents: vec!["Research Team".into()], + } + .install_targets("/home/alice/My Project"); + assert_eq!(targets[0].path, "/home/alice/My Project/agent/skills"); + assert_eq!( + targets[1].path, + "/home/alice/My Project/agent/subagents/Research Team/skills" + ); + assert_eq!(targets[1].target_id, "eve:research-team"); + } + use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::sync::{Arc, Mutex}; use tempfile::tempdir; - #[cfg(unix)] - use super::WSL_PATH_METADATA_SCRIPT; use super::{ - parse_wsl_path_metadata, AgentEnvironmentResolver, DirectoryPresenceState, + metadata_from_typed_facts, AgentEnvironmentResolver, DirectoryPresenceState, EnvironmentContext, PathEntryKind, PathMetadata, }; use crate::core::agent_definition::{ @@ -1159,6 +1283,9 @@ mod tests { use crate::core::agent_registry::AgentRegistrySnapshot; use crate::core::custom_agent_repository::CustomAgentRepository; use crate::environment::types::{EnvironmentRef, EnvironmentStatus}; + use crate::environment::wsl::operations::path_metadata::{ + PathMetadataContent, PathMetadataFact, PathMetadataKind, + }; fn scope( enabled: bool, @@ -2764,46 +2891,31 @@ mod tests { ); } - #[cfg(unix)] - #[test] - fn wsl_metadata_script_keeps_empty_eve_payload_aligned_with_later_agent() { - let temp = tempfile::tempdir().unwrap(); - let package_path = temp.path().join("package.json"); - let other_agent_path = temp.path().join("other-agent"); - std::fs::write(&package_path, []).unwrap(); - std::fs::create_dir(&other_agent_path).unwrap(); - let package_path = package_path.to_string_lossy().into_owned(); - let other_agent_path = other_agent_path.to_string_lossy().into_owned(); - let output = std::process::Command::new("/bin/sh") - .args([ - "-c", - WSL_PATH_METADATA_SCRIPT, - "--", - "inspect", - package_path.as_str(), - "1", - other_agent_path.as_str(), - "0", - ]) - .output() - .unwrap(); - - let metadata = parse_wsl_path_metadata(&output.stdout).expect("parse metadata frame"); - - assert_eq!(metadata[&package_path].eve_package, Some(false)); - assert_eq!( - metadata[&other_agent_path].entry_kind, - PathEntryKind::Directory - ); - } - #[test] - fn wsl_metadata_parser_keeps_unreadable_eve_and_inaccessible_path_records_distinct() { - let bytes = b"1\0path\0/work/package.json\0other\0eve-unreadable\0-\0path\0/home/alice/.other-agent\0directory\0none\0-\0path\0/home/alice/.blocked\0inaccessible\0none\0-\0"; - - let metadata = parse_wsl_path_metadata(bytes).expect("parse metadata frame"); + fn worker_metadata_facts_keep_content_and_path_states_distinct() { + let metadata = metadata_from_typed_facts(vec![ + PathMetadataFact { + path: "/work/package.json".to_string(), + kind: PathMetadataKind::Other, + content: PathMetadataContent::Empty, + truncated: false, + }, + PathMetadataFact { + path: "/home/alice/.other-agent".to_string(), + kind: PathMetadataKind::Directory, + content: PathMetadataContent::NotRequested, + truncated: false, + }, + PathMetadataFact { + path: "/home/alice/.blocked".to_string(), + kind: PathMetadataKind::Inaccessible, + content: PathMetadataContent::Unreadable, + truncated: false, + }, + ]) + .expect("project Worker facts"); - assert_eq!(metadata["/work/package.json"].eve_package, None); + assert_eq!(metadata["/work/package.json"].eve_package, Some(false)); assert_eq!( metadata["/home/alice/.other-agent"].entry_kind, PathEntryKind::Directory @@ -2812,31 +2924,6 @@ mod tests { metadata["/home/alice/.blocked"].entry_kind, PathEntryKind::Inaccessible ); - } - - #[cfg(unix)] - #[test] - fn wsl_metadata_script_preserves_the_no_payload_record() { - let missing_path = tempfile::tempdir() - .unwrap() - .path() - .join("missing") - .to_string_lossy() - .into_owned(); - let output = std::process::Command::new("/bin/sh") - .args([ - "-c", - WSL_PATH_METADATA_SCRIPT, - "--", - "inspect", - missing_path.as_str(), - "0", - ]) - .output() - .unwrap(); - - let metadata = parse_wsl_path_metadata(&output.stdout).expect("parse metadata frame"); - - assert_eq!(metadata[&missing_path].entry_kind, PathEntryKind::Missing); + assert_eq!(metadata["/home/alice/.blocked"].eve_package, None); } } diff --git a/src-tauri/src/environment/context_resolver.rs b/src-tauri/src/environment/context_resolver.rs index bb2cb957..4b234a4f 100644 --- a/src-tauri/src/environment/context_resolver.rs +++ b/src-tauri/src/environment/context_resolver.rs @@ -1,5 +1,8 @@ use std::path::PathBuf; +use serde::Serialize; +use specta::Type; + use crate::core::projects::ProjectsStore; use crate::core::{get_config_path, skill_lock}; use crate::environment::types::{ @@ -7,7 +10,7 @@ use crate::environment::types::{ SkillLocationRef, }; use crate::environment::wsl::operations::projects; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; pub struct ContextResolver; @@ -21,6 +24,24 @@ pub struct ResolvedContext { pub lock: ResourceLocator, } +/// 仅供界面缩短安装路径;执行仍使用原有目标标识与物理身份。 +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct ScopePathBase { + pub logical_root: ResourceLocator, + pub physical_root: Option, + pub path_style: DisplayPathStyle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum DisplayPathStyle { + Posix, + Windows, +} + impl ResolvedContext { pub fn context_root(&self) -> &str { self.project @@ -28,6 +49,80 @@ impl ResolvedContext { .map(|project| project.native_path.as_str()) .unwrap_or(self.home.native_path.as_str()) } + + pub(crate) async fn resolved_root( + &self, + targets: &dyn crate::environment::planning::TargetFactResolver, + ) -> Result { + use crate::environment::types::same_environment_identity; + + let logical_root = ResourceLocator { + environment: self.context.environment.clone(), + native_path: self.context_root().to_string(), + }; + // 通过虚拟子路径观察父目录的实际身份,不创建目录或探针文件。 + const PROBE: &str = ".skill-deck-path-base"; + let facts = targets + .resolve(&self.context, &[logical_root.join_child(PROBE)], None) + .await?; + let [fact] = facts.as_slice() else { + return Err(AppError::StaleTarget); + }; + if !same_environment_identity(&fact.destination.environment, &self.context.environment) { + return Err(AppError::StaleEnvironment); + } + if fact.key.normalized_final_child_name != PROBE { + return Err(AppError::PathNotFound { + path: logical_root.native_path, + }); + } + let native_path = match &self.context.environment { + EnvironmentRef::Native => std::path::Path::new(&fact.destination.native_path) + .parent() + .map(|parent| parent.to_string_lossy().into_owned()), + EnvironmentRef::Wsl { .. } => { + fact.destination + .native_path + .rsplit_once('/') + .map(|(parent, _)| { + if parent.is_empty() { + "/".into() + } else { + parent.into() + } + }) + } + } + .ok_or(AppError::StaleTarget)?; + Ok(ResourceLocator { + environment: self.context.environment.clone(), + native_path, + }) + } + + pub async fn path_base( + &self, + targets: &dyn crate::environment::planning::TargetFactResolver, + ) -> ScopePathBase { + use crate::environment::types::display_locator; + + ScopePathBase { + logical_root: display_locator(&ResourceLocator { + environment: self.context.environment.clone(), + native_path: self.context_root().to_string(), + }), + physical_root: self + .resolved_root(targets) + .await + .ok() + .map(|root| display_locator(&root)), + path_style: if cfg!(windows) && self.context.environment == EnvironmentRef::Native { + DisplayPathStyle::Windows + } else { + DisplayPathStyle::Posix + }, + } + } } impl ContextResolver { @@ -46,9 +141,10 @@ impl ContextResolver { pub async fn resolve_wsl( context: SkillLocationRef, session: &WslSession, + workspace: &WslWorkspace, ) -> Result { let projects = if matches!(context.scope, SkillLocation::Project { .. }) { - projects::read_projects(session).await? + projects::read_projects(session, workspace).await? } else { Vec::new() }; @@ -182,6 +278,93 @@ mod tests { use crate::environment::wsl::WslSession; use crate::error::AppError; + #[tokio::test] + async fn path_base_resolves_project_aliases_without_creating_files() { + use crate::environment::planning::RuntimeTargetFactResolver; + use crate::environment::types::display_locator; + use crate::environment::wsl::WslRuntime; + use std::sync::Arc; + + let temp = tempfile::tempdir().unwrap(); + let actual = temp.path().join("project"); + let alias = temp.path().join("alias"); + std::fs::create_dir(&actual).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&actual, &alias).unwrap(); + #[cfg(windows)] + junction::create(&actual, &alias).unwrap(); + let resolved = ContextResolver::resolve_native_from( + SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Project { + project_id: "app".into(), + }, + }, + temp.path().join("home"), + temp.path().join("lock.json"), + vec![project("app", &alias.to_string_lossy())], + ) + .unwrap(); + let targets = RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())); + + let base = resolved.path_base(&targets).await; + + assert_eq!(base.logical_root.native_path, alias.to_string_lossy()); + let expected = crate::environment::types::ResourceLocator { + environment: EnvironmentRef::Native, + native_path: std::fs::canonicalize(&actual) + .unwrap() + .to_string_lossy() + .into_owned(), + }; + assert_eq!(base.physical_root, Some(display_locator(&expected))); + assert_eq!(std::fs::read_dir(actual).unwrap().count(), 0); + } + + #[tokio::test] + async fn path_base_retains_selected_wsl_home_when_projection_is_unavailable() { + use crate::environment::planning::{ + ResolvedTargetFact, TargetFactFuture, TargetFactResolver, + }; + use crate::environment::types::ResourceLocator; + + struct Unavailable; + impl TargetFactResolver for Unavailable { + fn resolve<'a>( + &'a self, + _: &'a SkillLocationRef, + _: &'a [ResourceLocator], + _: Option, + ) -> TargetFactFuture<'a, Result, AppError>> { + Box::pin(async { Err(AppError::StaleEnvironment) }) + } + } + let environment = EnvironmentRef::Wsl { + distro_name: "Ubuntu".into(), + }; + let resolved = ContextResolver::resolve_wsl_from_projects( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + &wsl_session(None), + vec![], + ) + .unwrap(); + + let base = resolved.path_base(&Unavailable).await; + + assert_eq!( + base.logical_root, + ResourceLocator { + environment, + native_path: "/home/alice".into() + } + ); + assert_eq!(base.physical_root, None); + assert_eq!(base.path_style, super::DisplayPathStyle::Posix); + } + fn project(id: &str, native_path: &str) -> RegisteredProject { RegisteredProject { id: id.to_string(), diff --git a/src-tauri/src/environment/directory_inspection.rs b/src-tauri/src/environment/directory_inspection.rs index 7ba92378..3a2b71fe 100644 --- a/src-tauri/src/environment/directory_inspection.rs +++ b/src-tauri/src/environment/directory_inspection.rs @@ -2,8 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::Path; -use crate::environment::wsl::operations::directory_count; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; pub const MAX_OBSERVED_SKILL_ENTRIES: u32 = 10_000; @@ -25,28 +24,27 @@ pub async fn inspect_native(paths: &[String]) -> BTreeMap Result, AppError> { let paths = unique_paths(paths); if paths.is_empty() { return Ok(BTreeMap::new()); } - Ok( - directory_count::inspect(session, &paths, MAX_OBSERVED_SKILL_ENTRIES) - .await? - .into_iter() - .map(|fact| { - ( - fact.path, - DirectoryInspection { - observed_skill_count: fact.observed_count, - observed_skill_count_truncated: fact.truncated, - }, - ) - }) - .collect(), - ) + Ok(workspace + .count_directory_entries(paths.clone(), MAX_OBSERVED_SKILL_ENTRIES) + .await? + .into_iter() + .map(|fact| { + ( + fact.path, + DirectoryInspection { + observed_skill_count: fact.observed_count, + observed_skill_count_truncated: fact.truncated, + }, + ) + }) + .collect()) } fn unique_paths(paths: &[String]) -> Vec { diff --git a/src-tauri/src/environment/inspection.rs b/src-tauri/src/environment/inspection.rs index a2a2681d..7f8df62e 100644 --- a/src-tauri/src/environment/inspection.rs +++ b/src-tauri/src/environment/inspection.rs @@ -5,7 +5,7 @@ use std::pin::Pin; use serde::{Deserialize, Serialize}; use crate::core::agent_definition::AgentId; -use crate::environment::runtime::ContextSnapshotRevision; +use crate::environment::runtime::{ContextSnapshotRevision, EntryFingerprint}; use crate::environment::types::{ same_environment_identity, EnvironmentRef, ResourceLocator, SkillLocationRef, }; @@ -132,6 +132,7 @@ pub struct RawPathFact { pub relative_path: String, pub kind: FilesystemEntryKind, pub resolved_target: Option, + pub fingerprint: Option, pub frontmatter_bytes: Vec, pub truncated: bool, pub error_code: Option, @@ -144,7 +145,16 @@ pub struct RawFilesystemSnapshot { pub total_content_bytes: u32, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawSkillMetadata { + pub locator: ResourceLocator, + pub bytes: Vec, + pub truncated: bool, + pub error_code: Option, +} + pub type InspectionFuture<'a, T> = Pin + Send + 'a>>; +pub type MetadataFuture<'a, T> = Pin + Send + 'a>>; pub trait FilesystemInspector: Send + Sync { fn environment(&self) -> EnvironmentRef; @@ -154,3 +164,11 @@ pub trait FilesystemInspector: Send + Sync { plan: &'a ReadPlan, ) -> InspectionFuture<'a, Result>; } + +pub trait SkillMetadataSource: Send + Sync { + fn read<'a>( + &'a self, + locators: &'a [ResourceLocator], + per_file_limit: u32, + ) -> MetadataFuture<'a, Result, AppError>>; +} diff --git a/src-tauri/src/environment/lock_io.rs b/src-tauri/src/environment/lock_io.rs index a1faa397..63a26698 100644 --- a/src-tauri/src/environment/lock_io.rs +++ b/src-tauri/src/environment/lock_io.rs @@ -1,13 +1,16 @@ use crate::environment::native::atomic_file::NativeAtomicDocumentIo; use crate::environment::types::ResourceLocator; use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -use crate::storage::atomic_document::AtomicDocumentIo; +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentSnapshot, DocumentWriteFailure}; pub enum EnvironmentLockIo { Native, - ActiveWsl(WslSession), + ActiveWsl { + session: Box, + workspace: WslWorkspace, + }, } impl EnvironmentLockIo { @@ -15,11 +18,42 @@ impl EnvironmentLockIo { &self, locator: &ResourceLocator, ) -> Result>, AppError> { + Ok(self.observe(locator).await?.bytes) + } + + pub async fn observe(&self, locator: &ResourceLocator) -> Result { match self { - Self::Native => NativeAtomicDocumentIo.read_optional(locator).await, - Self::ActiveWsl(session) => { - WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(locator) + Self::Native => { + NativeAtomicDocumentIo + .observe(locator, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await + } + Self::ActiveWsl { session, workspace } => { + require_active_wsl_target(session, locator)?; + WslAtomicDocumentIo::from_active_session((**session).clone(), workspace.clone()) + .observe(locator, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await + } + } + } + + pub async fn replace( + &self, + locator: &ResourceLocator, + expected: DocumentSnapshot, + bytes: Vec, + ) -> Result { + match self { + Self::Native => { + NativeAtomicDocumentIo + .replace(locator, expected, bytes) + .await + } + Self::ActiveWsl { session, workspace } => { + require_active_wsl_target(session, locator) + .map_err(DocumentWriteFailure::not_published)?; + WslAtomicDocumentIo::from_active_session((**session).clone(), workspace.clone()) + .replace(locator, expected, bytes) .await } } @@ -34,19 +68,34 @@ impl EnvironmentLockIo { }) } + #[cfg(test)] pub async fn write_atomic( &self, locator: &ResourceLocator, bytes: Vec, ) -> Result<(), AppError> { - match self { - Self::Native => NativeAtomicDocumentIo.write_atomic(locator, bytes).await, - Self::ActiveWsl(session) => { - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic(locator, bytes) - .await - } + let snapshot = self.observe(locator).await?; + self.replace(locator, snapshot, bytes) + .await + .map(|_| ()) + .map_err(DocumentWriteFailure::into_error) + } +} + +fn require_active_wsl_target( + session: &WslSession, + locator: &ResourceLocator, +) -> Result<(), AppError> { + match &locator.environment { + crate::environment::types::EnvironmentRef::Wsl { distro_name } + if distro_name.eq_ignore_ascii_case(&session.distro_name) + && locator.native_path.starts_with('/') => + { + Ok(()) } + _ => Err(AppError::StorageUnsupported { + path: locator.native_path.clone(), + }), } } diff --git a/src-tauri/src/environment/native/acquire.rs b/src-tauri/src/environment/native/acquire.rs index 61bfa64a..25fbf651 100644 --- a/src-tauri/src/environment/native/acquire.rs +++ b/src-tauri/src/environment/native/acquire.rs @@ -61,17 +61,23 @@ impl NativePayloadSessionStorage { let session_dir = self.session_dir(session_id); fs::create_dir_all(&session_dir)?; let marker_path = session_dir.join(MARKER_NAME); - if marker_path.exists() { - self.verify_marker(&session_dir, session_id)?; - } else { - write_new_file( - &marker_path, - &serde_json::to_vec(&SessionMarker { - schema_version: STORAGE_SCHEMA_VERSION, - session_id: session_id.to_string(), - })?, - )?; + if !marker_path.exists() { + use std::io::Write; + + // 不同 Skill 并行准备时,通过原子发布共享完整的会话标记。 + let mut marker = tempfile::NamedTempFile::new_in(&session_dir)?; + marker.write_all(&serde_json::to_vec(&SessionMarker { + schema_version: STORAGE_SCHEMA_VERSION, + session_id: session_id.to_string(), + })?)?; + marker.as_file().sync_all()?; + match marker.persist_noclobber(&marker_path) { + Ok(_) => {} + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.error.into()), + } } + self.verify_marker(&session_dir, session_id)?; Ok(session_dir) } diff --git a/src-tauri/src/environment/native/atomic_file.rs b/src-tauri/src/environment/native/atomic_file.rs index 3095b6f3..e624ce23 100644 --- a/src-tauri/src/environment/native/atomic_file.rs +++ b/src-tauri/src/environment/native/atomic_file.rs @@ -1,59 +1,119 @@ use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use tempfile::NamedTempFile; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::error::AppError; -use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; +use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentSnapshot, DocumentWriteFailure, IoFuture, +}; #[derive(Clone, Copy)] pub struct NativeAtomicDocumentIo; impl AtomicDocumentIo for NativeAtomicDocumentIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { + max_bytes: u64, + ) -> IoFuture<'a, Result> { + let path = native_path(target).map(Path::to_path_buf); Box::pin(async move { - let path = native_path(target)?; - match fs::read(path) { - Ok(bytes) => Ok(Some(bytes)), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error.into()), - } + let path = path?; + let max_bytes = usize::try_from(max_bytes).map_err(|_| AppError::Validation { + field: Some("documentRead".to_string()), + message: "document read limit is not supported on this platform".to_string(), + })?; + tokio::task::spawn_blocking(move || { + environment_engine::atomic_document::read_optional_bounded(&path, max_bytes) + .map(|bytes| DocumentSnapshot { + bytes, + generation: None, + }) + .map_err(AppError::from) + }) + .await + .map_err(native_document_task_error)? }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { - Box::pin(async move { write_native_atomic(native_path(target)?, &bytes) }) + ) -> IoFuture<'a, Result> { + Box::pin(async move { + let path = native_path(target).map_err(DocumentWriteFailure::not_published)?; + if expected.generation.is_some() { + return Err(DocumentWriteFailure::not_published( + AppError::StaleEnvironment, + )); + } + prepare_parent(path).map_err(DocumentWriteFailure::not_published)?; + environment_engine::atomic_document::replace_if_unchanged( + path, + expected.bytes.as_deref(), + &bytes, + ) + .map_err(DocumentWriteFailure::from_engine)?; + Ok(DocumentSnapshot { + bytes: Some(bytes), + generation: None, + }) + }) + } + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { + Box::pin(async move { + let path = native_path(target).map_err(DocumentWriteFailure::not_published)?; + if expected.generation.is_some() { + return Err(DocumentWriteFailure::not_published( + AppError::StaleEnvironment, + )); + } + environment_engine::atomic_document::remove_if_unchanged( + path, + expected.bytes.as_deref(), + ) + .map_err(DocumentWriteFailure::from_engine) + }) } } pub(crate) fn write_native_atomic(path: &Path, bytes: &[u8]) -> Result<(), AppError> { - let parent = path.parent().ok_or_else(|| AppError::UnsafePath { - path: path.to_string_lossy().into_owned(), - reason: "document path has no parent".to_string(), - })?; - fs::create_dir_all(parent)?; - let legacy_backup = backup_path(path); - if legacy_backup.exists() { - fs::remove_file(&legacy_backup)?; - } + prepare_parent(path)?; + environment_engine::atomic_document::replace(path, bytes) + .map_err(DocumentWriteFailure::from_engine) + .map_err(DocumentWriteFailure::into_error) +} - let mut temporary = NamedTempFile::new_in(parent)?; - temporary.write_all(bytes)?; - temporary.as_file_mut().sync_all()?; - temporary.persist(path).map_err(|error| error.error)?; - sync_parent(parent)?; +fn prepare_parent(path: &Path) -> Result<(), AppError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| AppError::UnsafePath { + path: path.to_string_lossy().into_owned(), + reason: "document path has no explicit parent".to_string(), + })?; + // Parent creation remains the Native adapter's explicit private-store + // policy, not an implicit fallback inside the publication primitive. + fs::create_dir_all(parent)?; Ok(()) } +fn native_document_task_error(error: tokio::task::JoinError) -> AppError { + AppError::Io { + message: format!("native document task did not complete: {error}"), + } +} + +#[cfg(test)] pub(crate) fn backup_path(path: &Path) -> PathBuf { let mut name = path .file_name() @@ -72,17 +132,6 @@ fn native_path(locator: &ResourceLocator) -> Result<&Path, AppError> { Ok(Path::new(&locator.native_path)) } -#[cfg(unix)] -fn sync_parent(parent: &Path) -> Result<(), AppError> { - fs::File::open(parent)?.sync_all()?; - Ok(()) -} - -#[cfg(not(unix))] -fn sync_parent(_parent: &Path) -> Result<(), AppError> { - Ok(()) -} - #[cfg(test)] mod tests { use std::fs; @@ -101,42 +150,45 @@ mod tests { } #[tokio::test] - async fn atomic_write_round_trips_without_leaving_a_sidecar() { + async fn atomic_write_does_not_create_or_delete_an_unrelated_sidecar() { let temp = tempdir().expect("temp"); let path = temp.path().join("state/document.json"); let target = locator(&path); let io = NativeAtomicDocumentIo; - assert_eq!(io.read_optional(&target).await.unwrap(), None); + assert_eq!(io.observe(&target, 32).await.unwrap().bytes, None); assert!(!backup_path(&path).exists()); - io.write_atomic(&target, b"first".to_vec()) + let snapshot = io.observe(&target, 32).await.unwrap(); + io.replace(&target, snapshot, b"first".to_vec()) .await .expect("first write"); assert_eq!( - io.read_optional(&target).await.unwrap(), + io.observe(&target, 32).await.unwrap().bytes, Some(b"first".to_vec()) ); assert!(!backup_path(&path).exists()); fs::write(backup_path(&path), b"legacy backup").expect("legacy backup"); - io.write_atomic(&target, b"second".to_vec()) + let snapshot = io.observe(&target, 32).await.unwrap(); + io.replace(&target, snapshot, b"second".to_vec()) .await .expect("second write"); assert_eq!(fs::read(&path).unwrap(), b"second"); - assert!(!backup_path(&path).exists()); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"legacy backup"); - io.write_atomic(&target, b"third".to_vec()) + let snapshot = io.observe(&target, 32).await.unwrap(); + io.replace(&target, snapshot, b"third".to_vec()) .await .expect("third write"); assert_eq!(fs::read(&path).unwrap(), b"third"); - assert!(!backup_path(&path).exists()); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"legacy backup"); assert_eq!( fs::read_dir(path.parent().unwrap()) .unwrap() .filter_map(Result::ok) .count(), - 1 + 2 ); } @@ -150,8 +202,59 @@ mod tests { native_path: "/tmp/document.json".to_string(), }; assert!(matches!( - io.write_atomic(&target, b"data".to_vec()).await, + io.observe(&target, 32).await, Err(AppError::StorageUnsupported { .. }) )); } + #[tokio::test] + async fn conditional_save_uses_original_snapshot_not_a_fresh_revision() { + let temp = tempdir().unwrap(); + let path = temp.path().join("document.json"); + let target = locator(&path); + let io = NativeAtomicDocumentIo; + let missing = io.observe(&target, 16).await.unwrap(); + io.replace(&target, missing, b"old".to_vec()).await.unwrap(); + let snapshot = io.observe(&target, 16).await.unwrap(); + fs::write(&path, b"external").unwrap(); + let failure = io + .replace(&target, snapshot, b"new".to_vec()) + .await + .unwrap_err(); + assert_eq!( + failure.publication, + crate::storage::atomic_document::PublicationState::NotPublished + ); + assert!(matches!(failure.error, AppError::StaleTarget)); + assert_eq!(fs::read(path).unwrap(), b"external"); + } + + #[tokio::test] + async fn successful_replace_returns_the_committed_snapshot() { + let temp = tempdir().unwrap(); + let path = temp.path().join("document.json"); + let target = locator(&path); + fs::write(&path, b"old").unwrap(); + let snapshot = NativeAtomicDocumentIo.observe(&target, 16).await.unwrap(); + + let receipt = NativeAtomicDocumentIo + .replace(&target, snapshot, b"new".to_vec()) + .await + .unwrap(); + + assert_eq!(receipt.bytes.as_deref(), Some(b"new".as_slice())); + assert_eq!(receipt.generation, None); + } + + #[tokio::test] + async fn bounded_observe_rejects_an_oversized_document() { + let temp = tempdir().unwrap(); + let path = temp.path().join("document.json"); + let target = locator(&path); + fs::write(&path, b"12345").unwrap(); + + let result = NativeAtomicDocumentIo.observe(&target, 4).await; + + assert!(result.is_err()); + assert_eq!(fs::read(path).unwrap(), b"12345"); + } } diff --git a/src-tauri/src/environment/native/entry.rs b/src-tauri/src/environment/native/entry.rs index a34c5ae9..c92ffca3 100644 --- a/src-tauri/src/environment/native/entry.rs +++ b/src-tauri/src/environment/native/entry.rs @@ -108,6 +108,52 @@ pub fn stage_entry_set(intents: &[NativeEntryIntent]) -> Result Result<(), AppError> { + preflight_initial_intents(intents)?; + for intent in intents { + if matches!(intent.action, NativeEntryAction::Keep) { + continue; + } + let mut parent = intent + .destination + .parent() + .ok_or_else(|| unsafe_destination(&intent.destination))?; + while matches!(fs::symlink_metadata(parent), Err(error) if error.kind() == std::io::ErrorKind::NotFound) + { + parent = parent + .parent() + .ok_or_else(|| unsafe_destination(&intent.destination))?; + } + if !fs::metadata(parent).is_ok_and(|metadata| metadata.is_dir()) { + return Err(unsafe_destination(&intent.destination)); + } + let probe = parent.join(format!(".skill-deck-preflight-{}", Uuid::new_v4().simple())); + let renamed = parent.join(format!( + ".skill-deck-preflight-renamed-{}", + Uuid::new_v4().simple() + )); + let result = (|| { + match &intent.action { + NativeEntryAction::Symlink { target } => { + create_directory_link(target, &probe)?; + } + NativeEntryAction::Keep => unreachable!(), + NativeEntryAction::Materialize { .. } | NativeEntryAction::Remove => { + fs::create_dir(&probe)?; + } + } + fs::rename(&probe, &renamed)?; + remove_entry_no_follow(&renamed) + })(); + if result.is_err() { + let _ = remove_entry_no_follow(&probe); + let _ = remove_entry_no_follow(&renamed); + } + result?; + } + Ok(()) +} + pub fn planned_recovery_paths(entries: &NativeEntrySet) -> Vec { entries .entries @@ -548,7 +594,8 @@ fn staged_directory_link_matches(_link: &Path, _target: &Path) -> Result Option { let from = fs::canonicalize(from).ok()?; - let to = fs::canonicalize(to).ok()?; + // 目标目录项可能在本次事务中被替换,链接必须继续指向该目录项。 + let to = fs::canonicalize(to.parent()?).ok()?.join(to.file_name()?); let from_components = from.components().collect::>(); let to_components = to.components().collect::>(); let common = from_components diff --git a/src-tauri/src/environment/native/inspection.rs b/src-tauri/src/environment/native/inspection.rs index f028bb11..1bd12f7f 100644 --- a/src-tauri/src/environment/native/inspection.rs +++ b/src-tauri/src/environment/native/inspection.rs @@ -1,11 +1,17 @@ -use std::fs; -use std::io::Read; -use std::path::Path; +use std::{fs, io::Read, path::Path}; + +#[cfg(target_os = "linux")] +use environment_engine::inspection::{ + self as engine_inspection, EntryKind as EngineEntryKind, ErrorCode as EngineErrorCode, + InspectionRequest, InspectionRoot, +}; use crate::environment::inspection::{ - FilesystemEntryKind, FilesystemInspector, InspectionFuture, RawFilesystemSnapshot, RawPathFact, - ReadPlan, ReadRootPurpose, + FilesystemEntryKind, FilesystemInspector, InspectionFuture, MetadataFuture, + RawFilesystemSnapshot, RawPathFact, RawSkillMetadata, ReadPlan, ReadRootPurpose, + SkillMetadataSource, }; +#[cfg(not(target_os = "linux"))] use crate::environment::native::tree::{inspect_entry_no_follow, NativeEntryKind}; use crate::environment::types::EnvironmentRef; use crate::error::AppError; @@ -48,6 +54,130 @@ impl FilesystemInspector for NativeInspector { } } +impl SkillMetadataSource for NativeInspector { + fn read<'a>( + &'a self, + locators: &'a [crate::environment::types::ResourceLocator], + per_file_limit: u32, + ) -> MetadataFuture<'a, Result, AppError>> { + let environment = self.environment.clone(); + let locators = locators.to_vec(); + Box::pin(async move { + if environment != EnvironmentRef::Native + || per_file_limit == 0 + || locators + .iter() + .any(|locator| locator.environment != EnvironmentRef::Native) + { + return Err(AppError::StorageUnsupported { + path: "nativeSkillMetadata".to_string(), + }); + } + tokio::task::spawn_blocking(move || read_native_metadata(&locators, per_file_limit)) + .await + .map_err(|error| AppError::ExecutionFailed { + message: format!("native metadata task failed: {error}"), + })? + }) + } +} + +fn read_native_metadata( + locators: &[crate::environment::types::ResourceLocator], + per_file_limit: u32, +) -> Result, AppError> { + Ok(locators + .iter() + .map(|locator| { + let path = Path::new(&locator.native_path); + let mut bytes = Vec::new(); + let mut truncated = false; + let mut error_code = None; + match fs::File::open(path) { + Ok(file) => { + if file + .take(per_file_limit as u64) + .read_to_end(&mut bytes) + .is_err() + { + bytes.clear(); + error_code = Some("readFailed".to_string()); + } else { + truncated = fs::metadata(path) + .map(|metadata| metadata.len() > bytes.len() as u64) + .unwrap_or(false); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + error_code = Some("missing".to_string()); + } + Err(_) => error_code = Some("readFailed".to_string()), + } + RawSkillMetadata { + locator: locator.clone(), + bytes, + truncated, + error_code, + } + }) + .collect()) +} + +#[cfg(target_os = "linux")] +fn inspect_native(plan: &ReadPlan) -> Result { + let snapshot = engine_inspection::inspect(&InspectionRequest { + read_content: false, + roots: plan + .roots + .iter() + .map(|root| InspectionRoot { + path: root.locator.native_path.clone().into(), + stat_only: root.purposes.len() == 1 + && root.purposes.contains(&ReadRootPurpose::Context), + }) + .collect(), + per_file_limit: plan.per_file_limit, + aggregate_limit: plan.aggregate_limit, + }) + .map_err(|error| AppError::ExecutionFailed { + message: format!("native inspection failed: {error}"), + })?; + + Ok(RawFilesystemSnapshot { + environment: EnvironmentRef::Native, + facts: snapshot + .facts + .into_iter() + .map(|fact| RawPathFact { + root_index: fact.root_index, + relative_path: fact.relative_path.to_string_lossy().into_owned(), + kind: match fact.kind { + EngineEntryKind::Missing => FilesystemEntryKind::Missing, + EngineEntryKind::File => FilesystemEntryKind::File, + EngineEntryKind::Directory => FilesystemEntryKind::Directory, + EngineEntryKind::Symlink => FilesystemEntryKind::Symlink, + EngineEntryKind::Other => FilesystemEntryKind::Other, + }, + resolved_target: fact + .resolved_target + .map(|target| target.to_string_lossy().into_owned()), + fingerprint: fact + .fingerprint + .map(crate::environment::runtime::EntryFingerprint), + frontmatter_bytes: fact.content_bytes, + truncated: fact.truncated, + error_code: fact.error_code.map(|code| match code { + EngineErrorCode::PathUnavailable => "pathUnavailable".to_string(), + EngineErrorCode::ReadFailed => "readFailed".to_string(), + EngineErrorCode::ReadLinkFailed => "readLinkFailed".to_string(), + }), + }) + .collect(), + total_content_bytes: snapshot.total_content_bytes, + }) +} + +#[cfg(not(target_os = "linux"))] fn inspect_native(plan: &ReadPlan) -> Result { let mut facts = Vec::new(); let mut total = 0usize; @@ -57,6 +187,7 @@ fn inspect_native(plan: &ReadPlan) -> Result { path, root_index as u32, String::new(), + false, plan, &mut total, )); @@ -88,6 +219,7 @@ fn inspect_native(plan: &ReadPlan) -> Result { &child_path, root_index as u32, relative.clone(), + false, plan, &mut total, )); @@ -105,6 +237,7 @@ fn inspect_native(plan: &ReadPlan) -> Result { &skill, root_index as u32, format!("{relative}/SKILL.md"), + false, plan, &mut total, )); @@ -119,10 +252,12 @@ fn inspect_native(plan: &ReadPlan) -> Result { }) } +#[cfg(not(target_os = "linux"))] fn inspect_path( path: &Path, root_index: u32, relative_path: String, + read_content: bool, plan: &ReadPlan, total: &mut usize, ) -> RawPathFact { @@ -134,6 +269,7 @@ fn inspect_path( relative_path, kind: FilesystemEntryKind::Other, resolved_target: None, + fingerprint: None, frontmatter_bytes: Vec::new(), truncated: false, error_code: Some("pathUnavailable".to_string()), @@ -152,6 +288,7 @@ fn inspect_path( let mut truncated = false; let mut error_code = None; if kind == FilesystemEntryKind::File + && read_content && (relative_path == "SKILL.md" || relative_path.ends_with("/SKILL.md")) { let remaining = (plan.aggregate_limit as usize).saturating_sub(*total); @@ -178,6 +315,7 @@ fn inspect_path( resolved_target: inspected .link_target .map(|target| target.to_string_lossy().into_owned()), + fingerprint: Some(inspected.fingerprint), frontmatter_bytes: content, truncated, error_code, @@ -221,10 +359,9 @@ mod tests { ) .unwrap(); - let snapshot = NativeInspector::new(EnvironmentRef::Native) - .inspect(&builder.build().unwrap()) - .await - .unwrap(); + let plan = builder.build().unwrap(); + let inspector = NativeInspector::new(EnvironmentRef::Native); + let snapshot = inspector.inspect(&plan).await.unwrap(); assert_eq!(snapshot.facts.len(), 1); assert_eq!(snapshot.total_content_bytes, 0); @@ -266,10 +403,9 @@ mod tests { ) .unwrap(); - let snapshot = NativeInspector::new(EnvironmentRef::Native) - .inspect(&builder.build().unwrap()) - .await - .unwrap(); + let plan = builder.build().unwrap(); + let inspector = NativeInspector::new(EnvironmentRef::Native); + let snapshot = inspector.inspect(&plan).await.unwrap(); let linked_directory = snapshot .facts @@ -283,7 +419,22 @@ mod tests { .find(|fact| fact.relative_path == "toolkit/SKILL.md") .expect("Skill document through directory link"); assert_eq!(skill_document.kind, FilesystemEntryKind::File); - assert_eq!(skill_document.frontmatter_bytes, document); + assert!(skill_document.frontmatter_bytes.is_empty()); + assert!(skill_document.fingerprint.is_some()); + + let locator = ResourceLocator { + environment: EnvironmentRef::Native, + native_path: agent_root + .join("toolkit/SKILL.md") + .to_string_lossy() + .into_owned(), + }; + let metadata = inspector + .read(std::slice::from_ref(&locator), plan.per_file_limit) + .await + .unwrap(); + assert_eq!(metadata[0].locator, locator); + assert_eq!(metadata[0].bytes, document); } #[cfg(unix)] @@ -362,8 +513,9 @@ mod tests { ) .unwrap(); + let plan = builder.build().unwrap(); let snapshot = NativeInspector::new(EnvironmentRef::Native) - .inspect(&builder.build().unwrap()) + .inspect(&plan) .await .unwrap(); @@ -379,6 +531,22 @@ mod tests { .iter() .find(|fact| fact.relative_path == "toolkit/SKILL.md") .expect("Skill document through junction"); - assert_eq!(skill_document.frontmatter_bytes, document); + assert_eq!(skill_document.kind, FilesystemEntryKind::File); + assert!(skill_document.frontmatter_bytes.is_empty()); + assert!(skill_document.fingerprint.is_some()); + + let locator = ResourceLocator { + environment: EnvironmentRef::Native, + native_path: agent_root + .join("toolkit/SKILL.md") + .to_string_lossy() + .into_owned(), + }; + let metadata = NativeInspector::new(EnvironmentRef::Native) + .read(std::slice::from_ref(&locator), plan.per_file_limit) + .await + .unwrap(); + assert_eq!(metadata[0].locator, locator); + assert_eq!(metadata[0].bytes, document); } } diff --git a/src-tauri/src/environment/native/materialize.rs b/src-tauri/src/environment/native/materialize.rs index 3e20b7e6..537cb4ba 100644 --- a/src-tauri/src/environment/native/materialize.rs +++ b/src-tauri/src/environment/native/materialize.rs @@ -5,7 +5,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; -use crate::application::mutation::coordinator::{BoxFuture, PreparedEntryExecutor}; +#[cfg(test)] +use crate::application::mutation::coordinator::PreparedEntryTestDriver; +use crate::application::mutation::coordinator::{ + BoxFuture, PreparedLockCommitter, PreparedUnitExecutor, UnitTransactionReceipt, +}; use crate::application::mutation::plan::{ ExecutionUnit, PreparedEntryAction, PreparedEntryMutation, }; @@ -14,9 +18,9 @@ use crate::application::payload_session::{PayloadLocalSource, PinnedPayloadLease use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::{PayloadId, SkillPayload}; use crate::environment::native::entry::{ - cleanup_entry_set, planned_recovery_paths, recheck_entry_set, restore_entry_set, - stage_entry_set, swap_entry_set, verify_entry_set, NativeEntryAction, NativeEntryIntent, - NativeEntrySet, + cleanup_entry_set, planned_recovery_paths, preflight_entry_writes, recheck_entry_set, + restore_entry_set, stage_entry_set, swap_entry_set, verify_entry_set, NativeEntryAction, + NativeEntryIntent, NativeEntrySet, }; use crate::environment::recovery::{ RecoveryEntryPhase, RecoveryMarker, RecoveryMarkerEntry, RecoveryMarkerKind, RecoveryMarkerRef, @@ -26,10 +30,15 @@ use crate::environment::runtime::ExecutionBackend; use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::error::{AppError, RecoveryResourceId}; use crate::models::InstallMode; +use crate::storage::atomic_document::{DocumentWriteFailure, PublicationState}; +use crate::storage::lock_plan::PreparedLockMutation; pub struct NativePreparedEntrySet { entries: NativeEntrySet, recovery: Option, + // In-memory safety latch also protects evidence when persisting the + // RecoveryRequired marker itself fails. + retain_recovery: bool, } struct NativePreparedRecovery { @@ -45,6 +54,215 @@ pub struct NativePreparedEntryExecutor { recovery_store: Arc, } +pub struct NativePreparedUnitExecutor { + entries: NativePreparedEntryExecutor, + locks: L, +} + +pub struct PreparedNativeUnit { + unit: ExecutionUnit, + intents: Vec, +} + +impl NativePreparedUnitExecutor { + pub fn new(entries: NativePreparedEntryExecutor, locks: L) -> Self { + Self { entries, locks } + } +} + +impl PreparedUnitExecutor for NativePreparedUnitExecutor +where + L: PreparedLockCommitter, +{ + type Prepared = PreparedNativeUnit; + + fn prepare<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let mut loaded = BTreeMap::new(); + for entry in unit + .primary_entry + .iter() + .chain(unit.additional_entries.iter()) + { + let PreparedEntryAction::Replace { + payload_id, + requested_mode: InstallMode::Copy, + } = &entry.action + else { + continue; + }; + if loaded.contains_key(payload_id) { + continue; + } + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let lease = payloads.get(payload_id).ok_or(AppError::StalePayload)?; + match lease.local_source()? { + PayloadLocalSource::InProcess | PayloadLocalSource::NativeManaged { .. } => {} + PayloadLocalSource::WslManaged { .. } => { + return Err(AppError::CapabilityUnavailable { + capability: "backendLocalPayload".to_string(), + path: None, + }); + } + } + loaded.insert(payload_id.clone(), Arc::new(lease.load_payload().await?)); + } + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let intents = prepare_native_mutations(unit, &loaded, self.entries.backend.clone())?; + preflight_entry_writes(&intents)?; + Ok(PreparedNativeUnit { + unit: unit.clone(), + intents, + }) + }) + } + + fn execute<'a>( + &'a self, + prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + // Staging creates directories and temporary entries. Keep it in the + // transaction future so dropping the caller cannot detach writes + // after RuntimeAdmission has released its permit. + let entries = stage_entry_set(&prepared.intents)?; + let recovery = if planned_recovery_paths(&entries).is_empty() { + None + } else { + let marker = native_recovery_marker( + &self.entries.operation_id, + &prepared.unit.id, + RecoverySubject { + operation_kind: self.entries.operation_kind, + skill_name: prepared.unit.skill_name.clone(), + context: prepared.unit.target.clone(), + }, + &entries, + now_epoch_ms(), + )?; + let recovery_ref = match self.entries.recovery_store.create(&marker).await { + Ok(marker_ref) => marker_ref, + Err(error) => { + let cleanup = cleanup_entry_set(entries)?; + if cleanup.is_empty() { + return Err(error); + } + return Err(AppError::ExecutionFailed { + message: format!( + "{error}; native staging cleanup failed: {}", + cleanup.join("; ") + ), + }); + } + }; + Some(NativePreparedRecovery { + recovery_store: Arc::clone(&self.entries.recovery_store), + recovery_marker: Mutex::new(marker), + recovery_ref, + }) + }; + let mut staged = NativePreparedEntrySet { + entries, + recovery, + retain_recovery: false, + }; + enum TransactionFailure { + Entry(AppError), + Lock(DocumentWriteFailure), + } + + let transaction = async { + if cancellation.is_cancelled() { + return Err(TransactionFailure::Entry(AppError::MutationCancelled)); + } + self.entries + .recheck_entries(&staged) + .await + .map_err(TransactionFailure::Entry)?; + self.entries + .swap(&mut staged) + .await + .map_err(TransactionFailure::Entry)?; + self.entries + .verify(&staged) + .await + .map_err(TransactionFailure::Entry)?; + match lock { + Some(lock) => self + .locks + .commit(lock) + .await + .map(Some) + .map_err(TransactionFailure::Lock), + None => Ok(None), + } + } + .await; + match transaction { + Ok(lock) => match self.entries.cleanup(staged).await { + Ok(warnings) => Ok(UnitTransactionReceipt { lock, warnings }), + Err(error) => Ok(UnitTransactionReceipt { + lock, + warnings: vec![MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: Some(error.to_string().chars().take(4096).collect()), + }], + }), + }, + Err(TransactionFailure::Lock( + failure @ DocumentWriteFailure { + publication: + PublicationState::PublishedUnconfirmed | PublicationState::OutcomeUnknown, + .. + }, + )) => staged + .recovery_required(format!( + "lock publication is not confirmed: {}", + failure.error + )) + .await + .map(|()| unreachable!("recovery_required always returns an error")), + Err(TransactionFailure::Entry(primary @ AppError::RecoveryRequired { .. })) => { + Err(primary) + } + Err(TransactionFailure::Entry(primary)) + | Err(TransactionFailure::Lock(DocumentWriteFailure { + error: primary, + publication: PublicationState::NotPublished, + .. + })) => { + // swap may already have attempted and failed to restore. + // Do not retry a destructive compensation after that + // boundary, or turn retained evidence into cleanup work. + if staged.retain_recovery { + return Err(primary); + } + // A failed restore leaves the only recoverable copy in + // backup. Cleanup must not relabel or delete that evidence. + self.entries.restore(&mut staged).await?; + let _ = self.entries.cleanup(staged).await; + Err(primary) + } + } + }) + } +} + impl NativePreparedEntryExecutor { #[cfg(test)] pub fn new( @@ -75,15 +293,14 @@ impl NativePreparedEntryExecutor { } } -impl PreparedEntryExecutor for NativePreparedEntryExecutor { - type Staged = NativePreparedEntrySet; - +impl NativePreparedEntryExecutor { + #[cfg(test)] fn stage<'a>( &'a self, unit: &'a ExecutionUnit, payloads: &'a BTreeMap, cancellation: CancellationSignal, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { let mut loaded = BTreeMap::new(); for entry in unit @@ -120,9 +337,7 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { return Err(AppError::MutationCancelled); } let intents = prepare_native_mutations(unit, &loaded, self.backend.clone())?; - let entries = tokio::task::spawn_blocking(move || stage_entry_set(&intents)) - .await - .map_err(native_task_error)??; + let entries = stage_entry_set(&intents)?; let recovery = if planned_recovery_paths(&entries).is_empty() { None } else { @@ -158,13 +373,17 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { recovery_ref, }) }; - Ok(NativePreparedEntrySet { entries, recovery }) + Ok(NativePreparedEntrySet { + entries, + recovery, + retain_recovery: false, + }) }) } fn recheck_entries<'a>( &'a self, - staged: &'a Self::Staged, + staged: &'a NativePreparedEntrySet, ) -> BoxFuture<'a, Result<(), AppError>> { let entries = staged.entries.clone(); Box::pin(async move { @@ -174,7 +393,10 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { }) } - fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + fn swap<'a>( + &'a self, + staged: &'a mut NativePreparedEntrySet, + ) -> BoxFuture<'a, Result<(), AppError>> { Box::pin(async move { match swap_entry_set(&mut staged.entries) { Ok(()) => { @@ -191,7 +413,10 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { }) } - fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + fn verify<'a>( + &'a self, + staged: &'a NativePreparedEntrySet, + ) -> BoxFuture<'a, Result<(), AppError>> { let entries = staged.entries.clone(); Box::pin(async move { tokio::task::spawn_blocking(move || verify_entry_set(&entries)) @@ -206,13 +431,18 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { }) } - fn restore<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + fn restore<'a>( + &'a self, + staged: &'a mut NativePreparedEntrySet, + ) -> BoxFuture<'a, Result<(), AppError>> { Box::pin(async move { match restore_entry_set(&mut staged.entries) { Ok(()) => { staged .update_recovery(RecoveryMarkerKind::CleanupOnly, None) - .await + .await?; + staged.retain_recovery = false; + Ok(()) } Err(error) => staged.recovery_required(error.to_string()).await, } @@ -221,9 +451,22 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { fn cleanup<'a>( &'a self, - staged: Self::Staged, + staged: NativePreparedEntrySet, ) -> BoxFuture<'a, Result, AppError>> { Box::pin(async move { + if staged.retain_recovery { + return Err(match &staged.recovery { + Some(recovery) => AppError::RecoveryRequired { + recovery_resource_id: recovery.recovery_ref.resource_id.clone(), + message: + "native recovery evidence cannot be cleaned before a confirmed restore" + .to_string(), + }, + None => AppError::RestoreFailed { + message: "native restore is not confirmed".to_string(), + }, + }); + } staged .update_recovery(RecoveryMarkerKind::CleanupOnly, None) .await?; @@ -257,6 +500,46 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { } } +#[cfg(test)] +impl PreparedEntryTestDriver for NativePreparedEntryExecutor { + type Staged = NativePreparedEntrySet; + + fn stage<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + NativePreparedEntryExecutor::stage(self, unit, payloads, cancellation) + } + + fn recheck_entries<'a>( + &'a self, + staged: &'a Self::Staged, + ) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::recheck_entries(self, staged) + } + + fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::swap(self, staged) + } + + fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::verify(self, staged) + } + + fn restore<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::restore(self, staged) + } + + fn cleanup<'a>( + &'a self, + staged: Self::Staged, + ) -> BoxFuture<'a, Result, AppError>> { + NativePreparedEntryExecutor::cleanup(self, staged) + } +} + fn native_task_error(error: tokio::task::JoinError) -> AppError { AppError::ExecutionFailed { message: format!("native mutation task failed: {error}"), @@ -295,7 +578,8 @@ impl NativePreparedEntrySet { Ok(()) } - async fn recovery_required(&self, message: String) -> Result<(), AppError> { + async fn recovery_required(&mut self, message: String) -> Result<(), AppError> { + self.retain_recovery = true; let Some(recovery) = &self.recovery else { return Err(AppError::RestoreFailed { message }); }; @@ -495,12 +779,12 @@ fn validate_native_entry( mod tests { use std::collections::BTreeMap; use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tempfile::tempdir; use super::*; - use crate::application::mutation::coordinator::PreparedEntryExecutor; use crate::application::mutation::plan::{ ExecutionUnit, ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, RuntimeRevisions, @@ -950,4 +1234,498 @@ mod tests { ExecutionBackend::NativeUnix } } + + struct FailingLockCommitter { + destination: PathBuf, + block_restore: bool, + } + + impl PreparedLockCommitter for FailingLockCommitter { + fn commit<'a>( + &'a self, + _mutation: &'a PreparedLockMutation, + ) -> BoxFuture< + 'a, + Result< + crate::storage::lock_plan::LockCommitReceipt, + crate::storage::atomic_document::DocumentWriteFailure, + >, + > { + Box::pin(async move { + if self.block_restore { + // The production entry set has already moved the original + // directory into its backup. A nonempty replacement makes + // the restore rename fail without deleting that backup. + fs::create_dir(&self.destination) + .map_err(AppError::from) + .map_err( + crate::storage::atomic_document::DocumentWriteFailure::not_published, + )?; + fs::write(self.destination.join("external.txt"), b"external") + .map_err(AppError::from) + .map_err( + crate::storage::atomic_document::DocumentWriteFailure::not_published, + )?; + } + Err( + crate::storage::atomic_document::DocumentWriteFailure::not_published( + AppError::ExecutionFailed { + message: "injected lock failure".to_string(), + }, + ), + ) + }) + } + } + + struct PublishedUnconfirmedDocumentIo; + + struct MarkerUpdateUnconfirmedDocumentIo { + writes: AtomicUsize, + } + + impl crate::storage::atomic_document::AtomicDocumentIo for MarkerUpdateUnconfirmedDocumentIo { + fn observe<'a>( + &'a self, + target: &'a ResourceLocator, + max_bytes: u64, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result, + > { + crate::environment::native::atomic_file::NativeAtomicDocumentIo + .observe(target, max_bytes) + } + + fn replace<'a>( + &'a self, + target: &'a ResourceLocator, + expected: crate::storage::atomic_document::DocumentSnapshot, + bytes: Vec, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result< + crate::storage::atomic_document::DocumentCommitReceipt, + crate::storage::atomic_document::DocumentWriteFailure, + >, + > { + Box::pin(async move { + let receipt = crate::environment::native::atomic_file::NativeAtomicDocumentIo + .replace(target, expected, bytes) + .await?; + if self.writes.fetch_add(1, Ordering::SeqCst) > 0 { + return Err(crate::storage::atomic_document::DocumentWriteFailure { + error: AppError::Io { + message: "injected marker sync failure".to_string(), + }, + phase: crate::storage::atomic_document::WritePhase::Confirming, + publication: + crate::storage::atomic_document::PublicationState::PublishedUnconfirmed, + }); + } + Ok(receipt) + }) + } + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: crate::storage::atomic_document::DocumentSnapshot, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result<(), crate::storage::atomic_document::DocumentWriteFailure>, + > { + crate::environment::native::atomic_file::NativeAtomicDocumentIo.remove(target, expected) + } + } + + impl crate::storage::atomic_document::AtomicDocumentIo for PublishedUnconfirmedDocumentIo { + fn observe<'a>( + &'a self, + target: &'a ResourceLocator, + max_bytes: u64, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result, + > { + Box::pin(async move { + let max_bytes = usize::try_from(max_bytes).unwrap(); + let bytes = environment_engine::atomic_document::read_optional_bounded( + Path::new(&target.native_path), + max_bytes, + )?; + Ok(crate::storage::atomic_document::DocumentSnapshot { + bytes, + generation: None, + }) + }) + } + + fn replace<'a>( + &'a self, + target: &'a ResourceLocator, + expected: crate::storage::atomic_document::DocumentSnapshot, + bytes: Vec, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result< + crate::storage::atomic_document::DocumentCommitReceipt, + crate::storage::atomic_document::DocumentWriteFailure, + >, + > { + Box::pin(async move { + environment_engine::atomic_document::replace_if_unchanged( + Path::new(&target.native_path), + expected.bytes.as_deref(), + &bytes, + ) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine)?; + Err(crate::storage::atomic_document::DocumentWriteFailure { + error: AppError::Io { + message: "injected parent sync failure".to_string(), + }, + phase: crate::storage::atomic_document::WritePhase::Confirming, + publication: + crate::storage::atomic_document::PublicationState::PublishedUnconfirmed, + }) + }) + } + + fn remove<'a>( + &'a self, + _target: &'a ResourceLocator, + _expected: crate::storage::atomic_document::DocumentSnapshot, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result<(), crate::storage::atomic_document::DocumentWriteFailure>, + > { + Box::pin(async { panic!("materialization test does not remove documents") }) + } + } + + #[tokio::test] + async fn native_unit_retains_backup_and_marker_when_restore_fails() { + exercise_native_restore_failure(true).await; + } + + #[tokio::test] + async fn native_unit_cleans_after_confirmed_successful_restore() { + exercise_native_restore_failure(false).await; + } + + #[tokio::test] + async fn native_unit_does_not_restore_entries_after_an_unconfirmed_lock_publish() { + use crate::core::lossless_lock::{LockSchema, LosslessLockDocument}; + use crate::storage::lock_plan::{LockEntryMutation, LockExpectedState}; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(&canonical).unwrap(); + fs::create_dir_all(&agent).unwrap(); + fs::write(canonical.join("SKILL.md"), b"old").unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Remove), + mutation(&agent, PreparedEntryAction::Keep), + ); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new( + native_backend(), + "unconfirmed-lock", + recovery.clone(), + ), + crate::runtime::plan_runner::RuntimeLockCommitter::with_io(Arc::new( + PublishedUnconfirmedDocumentIo, + )), + ); + let lock_path = root.join("skills-lock.json"); + let lock = PreparedLockMutation { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: lock_path.to_string_lossy().into_owned(), + }, + legacy_target: None, + schema: LockSchema::Project, + entry: LockEntryMutation::Remove { + key: "demo".to_string(), + }, + root_replacements: BTreeMap::new(), + expected: LockExpectedState::capture( + &LosslessLockDocument::empty(LockSchema::Project), + ["demo"], + std::iter::empty::<&str>(), + ), + }; + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await + .unwrap(); + + let error = executor + .execute(prepared, Some(&lock), CancellationSignal::default()) + .await + .unwrap_err(); + + assert!(matches!(error, AppError::RecoveryRequired { .. })); + assert!(!canonical.exists()); + let lock: serde_json::Value = + serde_json::from_slice(&fs::read(lock_path).unwrap()).unwrap(); + assert_eq!(lock["version"], 1); + let markers = recovery.enumerate().await.unwrap(); + let [RecoveryMarkerLoad::Valid { marker, .. }] = markers.as_slice() else { + panic!("unconfirmed lock publication must retain recovery evidence"); + }; + assert_eq!(marker.kind, RecoveryMarkerKind::RecoveryRequired); + let backup = marker + .entries + .iter() + .find(|entry| entry.destination.native_path == canonical.to_string_lossy()) + .unwrap() + .backup + .as_ref() + .unwrap(); + assert_eq!( + fs::read(Path::new(&backup.native_path).join("SKILL.md")).unwrap(), + b"old" + ); + } + + #[tokio::test] + async fn native_unit_does_not_restore_after_an_unconfirmed_marker_publish() { + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(&canonical).unwrap(); + fs::create_dir_all(&agent).unwrap(); + fs::write(canonical.join("SKILL.md"), b"old").unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Remove), + mutation(&agent, PreparedEntryAction::Keep), + ); + let recovery = Arc::new( + NativeRecoveryMarkerStore::with_io( + root.join("recovery"), + Arc::new(MarkerUpdateUnconfirmedDocumentIo { + writes: AtomicUsize::new(0), + }), + ) + .unwrap(), + ); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new( + native_backend(), + "unconfirmed-marker", + recovery.clone(), + ), + FailingLockCommitter { + destination: canonical.clone(), + block_restore: false, + }, + ); + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await + .unwrap(); + + let error = executor + .execute(prepared, None, CancellationSignal::default()) + .await + .unwrap_err(); + + assert!(matches!(error, AppError::RecoveryRequired { .. })); + assert!(!canonical.exists()); + assert_eq!(recovery.enumerate().await.unwrap().len(), 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn native_prepare_rejects_a_target_parent_that_is_already_read_only() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(canonical.parent().unwrap()).unwrap(); + fs::create_dir_all(agent.parent().unwrap()).unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Keep), + mutation( + &agent, + PreparedEntryAction::Link { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: canonical.to_string_lossy().into_owned(), + }, + }, + ), + ); + fs::set_permissions(agent.parent().unwrap(), fs::Permissions::from_mode(0o500)).unwrap(); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new(native_backend(), "read-only-preflight", recovery), + FailingLockCommitter { + destination: canonical, + block_restore: false, + }, + ); + + let result = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await; + + fs::set_permissions(agent.parent().unwrap(), fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); + assert!(!agent.exists()); + } + + #[cfg(windows)] + #[tokio::test] + async fn native_prepare_rejects_a_parent_that_denies_child_rename() { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_GENERIC_READ, FILE_SHARE_READ, + OPEN_EXISTING, + }; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(canonical.parent().unwrap()).unwrap(); + fs::create_dir_all(agent.parent().unwrap()).unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Keep), + mutation( + &agent, + PreparedEntryAction::Link { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: canonical.to_string_lossy().into_owned(), + }, + }, + ), + ); + let wide = agent + .parent() + .unwrap() + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + assert_ne!(handle, INVALID_HANDLE_VALUE); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new(native_backend(), "locked-parent-preflight", recovery), + FailingLockCommitter { + destination: canonical, + block_restore: false, + }, + ); + + let result = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await; + + unsafe { CloseHandle(handle) }; + assert!(result.is_err()); + assert!(!agent.exists()); + } + + async fn exercise_native_restore_failure(block_restore: bool) { + use crate::core::lossless_lock::LockSchema; + use crate::storage::lock_plan::{LockEntryMutation, LockExpectedState}; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(&canonical).unwrap(); + fs::create_dir_all(&agent).unwrap(); + fs::write(canonical.join("SKILL.md"), b"old").unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Remove), + mutation(&agent, PreparedEntryAction::Keep), + ); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new( + native_backend(), + "restore-regression", + recovery.clone(), + ), + FailingLockCommitter { + destination: canonical.clone(), + block_restore, + }, + ); + let lock = PreparedLockMutation { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: root.join("skills-lock.json").to_string_lossy().into_owned(), + }, + legacy_target: None, + schema: LockSchema::Project, + entry: LockEntryMutation::Remove { + key: "demo".to_string(), + }, + root_replacements: BTreeMap::new(), + expected: LockExpectedState { + entry_snapshots: BTreeMap::new(), + root_snapshots: BTreeMap::new(), + }, + }; + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await + .unwrap(); + let error = executor + .execute(prepared, Some(&lock), CancellationSignal::default()) + .await + .unwrap_err(); + let markers = recovery.enumerate().await.unwrap(); + if block_restore { + assert!(matches!(error, AppError::RecoveryRequired { .. })); + let [RecoveryMarkerLoad::Valid { marker, .. }] = markers.as_slice() else { + panic!("restore failure must retain a valid recovery marker"); + }; + assert_eq!(marker.kind, RecoveryMarkerKind::RecoveryRequired); + let backup = marker + .entries + .iter() + .find(|entry| entry.destination.native_path == canonical.to_string_lossy()) + .unwrap() + .backup + .as_ref() + .unwrap(); + assert_eq!( + fs::read(Path::new(&backup.native_path).join("SKILL.md")).unwrap(), + b"old" + ); + assert_eq!( + fs::read(canonical.join("external.txt")).unwrap(), + b"external" + ); + } else { + assert!(matches!(error, AppError::ExecutionFailed { .. })); + assert!(markers.is_empty()); + assert_eq!(fs::read(canonical.join("SKILL.md")).unwrap(), b"old"); + } + } } diff --git a/src-tauri/src/environment/native/recovery.rs b/src-tauri/src/environment/native/recovery.rs index 4cbd1d61..a3922623 100644 --- a/src-tauri/src/environment/native/recovery.rs +++ b/src-tauri/src/environment/native/recovery.rs @@ -9,14 +9,14 @@ use crate::environment::recovery::{ }; use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::error::{AppError, RecoveryResourceId}; -use crate::storage::atomic_document::AtomicDocumentIo; +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentWriteFailure, PublicationState}; const MARKER_FILE: &str = "recovery.json"; #[derive(Clone)] pub struct NativeRecoveryMarkerStore { root: PathBuf, - io: NativeAtomicDocumentIo, + io: std::sync::Arc, } impl NativeRecoveryMarkerStore { @@ -24,7 +24,19 @@ impl NativeRecoveryMarkerStore { fs::create_dir_all(root.as_ref())?; Ok(Self { root: fs::canonicalize(root.as_ref())?, - io: NativeAtomicDocumentIo, + io: std::sync::Arc::new(NativeAtomicDocumentIo), + }) + } + + #[cfg(test)] + pub(crate) fn with_io( + root: impl AsRef, + io: std::sync::Arc, + ) -> Result { + fs::create_dir_all(root.as_ref())?; + Ok(Self { + root: fs::canonicalize(root.as_ref())?, + io, }) } @@ -162,17 +174,28 @@ impl RecoveryMarkerStore for NativeRecoveryMarkerStore { } let root = self.managed_root(&marker.resource_id); fs::create_dir(&root)?; - let result = self + let locator = self.marker_locator(&root); + let snapshot = self .io - .write_atomic( - &self.marker_locator(&root), - serde_json::to_vec_pretty(marker)?, + .observe( + &locator, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), ) + .await?; + let result = self + .io + .replace(&locator, snapshot, serde_json::to_vec_pretty(marker)?) .await; - if result.is_err() { + if matches!( + &result, + Err(DocumentWriteFailure { + publication: PublicationState::NotPublished, + .. + }) + ) { let _ = fs::remove_dir_all(&root); } - result?; + result.map_err(|failure| marker_write_error(&marker.resource_id, failure))?; Ok(self.marker_ref(marker, &root)) }) } @@ -190,12 +213,19 @@ impl RecoveryMarkerStore for NativeRecoveryMarkerStore { { return Err(AppError::StaleTarget); } - self.io - .write_atomic( - &self.marker_locator(&root), - serde_json::to_vec_pretty(marker)?, + let locator = self.marker_locator(&root); + let snapshot = self + .io + .observe( + &locator, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), ) + .await?; + self.io + .replace(&locator, snapshot, serde_json::to_vec_pretty(marker)?) .await + .map(|_| ()) + .map_err(|failure| marker_write_error(&marker_ref.resource_id, failure)) }) } @@ -276,6 +306,19 @@ impl RecoveryMarkerStore for NativeRecoveryMarkerStore { } } +fn marker_write_error(resource_id: &RecoveryResourceId, failure: DocumentWriteFailure) -> AppError { + if failure.publication == PublicationState::NotPublished { + return failure.error; + } + AppError::RecoveryRequired { + recovery_resource_id: resource_id.clone(), + message: format!( + "recovery marker publication is not confirmed during {:?}: {}", + failure.phase, failure.error + ), + } +} + #[cfg(test)] mod tests { use std::fs; diff --git a/src-tauri/src/environment/path_mapping.rs b/src-tauri/src/environment/path_mapping.rs index b9d6eb76..f2484b4c 100644 --- a/src-tauri/src/environment/path_mapping.rs +++ b/src-tauri/src/environment/path_mapping.rs @@ -1,6 +1,5 @@ use crate::environment::types::EnvironmentRef; -use crate::environment::wsl::operations::path; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; pub(crate) fn parse_wsl_unc_path(path: &str) -> Option<(String, String)> { @@ -100,19 +99,10 @@ pub fn map_wsl_input_without_wslpath( } pub async fn map_windows_path_with_wslpath( - session: &WslSession, + workspace: &WslWorkspace, path: &str, ) -> Result { - match path::map_host_bridge_path(session, path, None).await { - Ok(mapped) => Ok(mapped), - Err(AppError::WslCommandFailed { .. }) => Err(AppError::StorageMappingUnsupported { - path: path.to_string(), - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), - }, - }), - Err(error) => Err(error), - } + workspace.map_host_path(path.to_string(), None).await } #[cfg(test)] diff --git a/src-tauri/src/environment/planning.rs b/src-tauri/src/environment/planning.rs index 07f39ea1..6723e747 100644 --- a/src-tauri/src/environment/planning.rs +++ b/src-tauri/src/environment/planning.rs @@ -1,3 +1,4 @@ +use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; use std::path::{Component, Path, PathBuf}; use std::pin::Pin; @@ -15,8 +16,8 @@ use crate::environment::runtime::{ PhysicalTargetKey, }; use crate::environment::types::{ - normalized_wsl_distro_name, same_environment_identity, EnvironmentRef, ResourceLocator, - SkillLocationRef, StorageAccess, + normalized_wsl_distro_name, same_environment_identity, EnvironmentKey, EnvironmentRef, + ResourceLocator, SkillLocationRef, StorageAccess, }; use crate::environment::wsl::operations::content_manifest as wsl_content_manifest; use crate::environment::wsl::operations::entry::inspect_entries; @@ -53,17 +54,63 @@ pub struct ResolvedLinkTargetIdentity { } impl ResolvedLinkTargetIdentity { + fn from_locator(target: &ResourceLocator) -> Option { + let comparison_path = normalized_comparison_path(&target.environment, &target.native_path)?; + Some(Self { + environment: target.environment.clone(), + comparison_path, + }) + } + pub(crate) fn matches(&self, target: &ResourceLocator) -> bool { same_environment_identity(&self.environment, &target.environment) && normalized_comparison_path(&target.environment, &target.native_path) .is_some_and(|candidate| candidate == self.comparison_path) } + + pub(crate) fn comparison_key(&self) -> (EnvironmentKey, String) { + ( + EnvironmentKey::from_ref(&self.environment), + self.comparison_path.clone(), + ) + } +} + +pub(crate) fn locator_comparison_key( + locator: &ResourceLocator, +) -> Option<(EnvironmentKey, String)> { + normalized_comparison_path(&locator.environment, &locator.native_path) + .map(|path| (EnvironmentKey::from_ref(&locator.environment), path)) } pub(crate) fn resolve_link_target_identity( destination: &ResourceLocator, raw_target: &str, ) -> Option { + let target = resolved_link_target_path(destination, raw_target)?; + ResolvedLinkTargetIdentity::from_locator(&target) +} + +fn resolve_native_link_target_identity( + destination: &ResourceLocator, + raw_target: &str, + backend: ExecutionBackend, +) -> Option { + let target = resolved_link_target_path(destination, raw_target)?; + let projected = project_target(Path::new(&target.native_path), backend).ok()?; + ResolvedLinkTargetIdentity::from_locator(&ResourceLocator { + environment: EnvironmentRef::Native, + native_path: projected + .physical_destination + .to_string_lossy() + .into_owned(), + }) +} + +pub(crate) fn resolved_link_target_path( + destination: &ResourceLocator, + raw_target: &str, +) -> Option { let native_path = match &destination.environment { EnvironmentRef::Native => { let raw = Path::new(raw_target); @@ -86,10 +133,9 @@ pub(crate) fn resolve_link_target_identity( lexical_normalize_posix(&joined) } }; - let comparison_path = normalized_comparison_path(&destination.environment, &native_path)?; - Some(ResolvedLinkTargetIdentity { + Some(ResourceLocator { environment: destination.environment.clone(), - comparison_path, + native_path, }) } @@ -165,8 +211,127 @@ fn native_comparison_path(path: &Path) -> String { without_verbatim.to_lowercase() } +/// 比较已完成物理投影的路径;本机路径和 WSL 协议路径各自使用对应的组件规则。 +pub(crate) fn is_within_resolved_root( + root: &ResourceLocator, + target: &ResourceLocator, +) -> Result { + if !same_environment_identity(&root.environment, &target.environment) { + return Err(AppError::StaleEnvironment); + } + let invalid = |path: &str| AppError::UnsafePath { + path: path.into(), + reason: "scope comparison requires an absolute resolved path".into(), + }; + match &root.environment { + EnvironmentRef::Native => { + for locator in [root, target] { + let path = Path::new(&locator.native_path); + if !path.is_absolute() + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(invalid(&locator.native_path)); + } + } + let root = PathBuf::from(native_comparison_path(Path::new(&root.native_path))); + let target = PathBuf::from(native_comparison_path(Path::new(&target.native_path))); + Ok(target + .strip_prefix(&root) + .is_ok_and(|suffix| suffix.components().next().is_some())) + } + EnvironmentRef::Wsl { .. } => { + for locator in [root, target] { + if !locator.native_path.starts_with('/') + || locator.native_path.split('/').any(|part| part == "..") + { + return Err(invalid(&locator.native_path)); + } + } + let components = |path: &str| { + path.split('/') + .filter(|part| !part.is_empty() && *part != ".") + .map(str::to_owned) + .collect::>() + }; + let root = components(&root.native_path); + let target = components(&target.native_path); + Ok(target.len() > root.len() && target.starts_with(&root)) + } + } +} + pub type TargetFactFuture<'a, T> = Pin + Send + 'a>>; +/// 记录解析根目录时经过的链接本身,供既有执行协议复核链接是否被改指。 +pub(crate) async fn observe_root_links( + targets: &dyn TargetFactResolver, + context: &SkillLocationRef, + roots: &[ResourceLocator], +) -> Result, AppError> { + let mut pending = roots.to_vec(); + let mut visited = BTreeSet::new(); + let mut links = BTreeMap::new(); + while !pending.is_empty() { + let mut paths = BTreeSet::new(); + for root in pending.drain(..) { + if !same_environment_identity(&root.environment, &context.environment) { + return Err(AppError::StaleEnvironment); + } + match &context.environment { + EnvironmentRef::Native => paths.extend( + Path::new(&root.native_path) + .ancestors() + .filter(|path| path.file_name().is_some()) + .map(|path| path.to_string_lossy().into_owned()), + ), + EnvironmentRef::Wsl { .. } => { + let mut path = root.native_path.trim_end_matches('/'); + while let Some((parent, _)) = path.rsplit_once('/') { + paths.insert(path.to_string()); + path = parent; + } + } + } + } + let locations = paths + .into_iter() + .filter(|path| visited.insert(path.clone())) + .map(|native_path| ResourceLocator { + environment: context.environment.clone(), + native_path, + }) + .collect::>(); + if locations.is_empty() { + break; + } + let facts = targets.resolve(context, &locations, None).await?; + if facts.len() != locations.len() { + return Err(AppError::StaleTarget); + } + for fact in facts.into_iter().filter(|fact| { + matches!( + fact.entry_kind, + TargetEntryKind::Symlink | TargetEntryKind::Junction + ) + }) { + if links.contains_key(&fact.key) { + continue; + } + if let Some(target) = fact + .link_target + .as_deref() + .and_then(|raw| resolved_link_target_path(&fact.destination, raw)) + { + pending.push(target); + } + links.insert(fact.key.clone(), fact); + } + } + Ok(links.into_values().collect()) +} + pub trait TargetFactResolver: Send + Sync { fn resolve<'a>( &'a self, @@ -236,11 +401,15 @@ impl TargetFactResolver for RuntimeTargetFactResolver { .map(|target| target.native_path.clone()) .collect::>(); let cancellation_for_retry = cancellation.clone(); + let workspace = self.environments.workspace(distro_name)?; self.environments - .with_session_retry(distro_name, move |session| { + .with_session_read_retry(distro_name, move |session| { let destinations = destinations.clone(); let cancellation = cancellation_for_retry.clone(); - async move { resolve_wsl(&session, &destinations, cancellation).await } + let workspace = workspace.clone(); + async move { + resolve_wsl(&session, &workspace, &destinations, cancellation).await + } }) .await } @@ -258,15 +427,8 @@ impl ContentManifestReader for RuntimeTargetFactResolver { match &target.location.environment { EnvironmentRef::Native => NativeContentManifestReader.read(target).await, EnvironmentRef::Wsl { distro_name } => { - let target = target.clone(); - self.environments - .with_session_retry(distro_name, move |session| { - let target = target.clone(); - async move { - wsl_content_manifest::inspect(&session, &target, None).await - } - }) - .await + let workspace = self.environments.workspace(distro_name)?; + wsl_content_manifest::inspect(&workspace, target, None).await } } }) @@ -296,9 +458,9 @@ fn resolve_native( let link_target = inspection .link_target .map(|target| target.to_string_lossy().into_owned()); - let link_target_identity = link_target - .as_deref() - .and_then(|raw| resolve_link_target_identity(&destination, raw)); + let link_target_identity = link_target.as_deref().and_then(|raw| { + resolve_native_link_target_identity(&destination, raw, backend.clone()) + }); Ok(ResolvedTargetFact { key: projected.key, destination, @@ -320,15 +482,16 @@ pub(crate) fn resolve_native_targets( async fn resolve_wsl( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, logical_destinations: &[String], cancellation: Option, ) -> Result, AppError> { - let projected = project_targets(session, logical_destinations, cancellation.clone()).await?; + let projected = project_targets(workspace, logical_destinations, cancellation.clone()).await?; let physical_paths = projected .iter() .map(|target| target.physical_destination.clone()) .collect::>(); - let entries = inspect_entries(session, &physical_paths, cancellation).await?; + let entries = inspect_entries(workspace, &physical_paths, cancellation).await?; if entries.len() != projected.len() { return Err(protocol_mismatch()); } @@ -368,10 +531,11 @@ async fn resolve_wsl( pub(crate) async fn resolve_wsl_targets( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, logical_destinations: &[String], cancellation: Option, ) -> Result, AppError> { - resolve_wsl(session, logical_destinations, cancellation).await + resolve_wsl(session, workspace, logical_destinations, cancellation).await } fn wsl_fact( @@ -514,6 +678,119 @@ mod tests { }; use crate::environment::wsl::WslRuntime; + #[test] + fn resolved_root_contains_only_its_native_descendants() { + let root = std::env::temp_dir().join("scope-root"); + let locator = |path: &Path| ResourceLocator { + environment: EnvironmentRef::Native, + native_path: path.to_string_lossy().into_owned(), + }; + let root_locator = locator(&root); + assert!(is_within_resolved_root( + &root_locator, + &locator(&root.join(".claude/skills/demo")) + ) + .unwrap()); + assert!(!is_within_resolved_root(&root_locator, &locator(&root)).unwrap()); + assert!(!is_within_resolved_root( + &root_locator, + &locator(&root.with_file_name("scope-root-other").join("demo")) + ) + .unwrap()); + assert!( + is_within_resolved_root(&root_locator, &locator(&root.join("..").join("other"))) + .is_err() + ); + } + + #[tokio::test] + async fn root_link_observations_include_intermediate_link_targets() { + let temp = tempfile::tempdir().unwrap(); + let actual = temp.path().join("actual"); + let middle = temp.path().join("middle"); + let first = temp.path().join("first"); + std::fs::create_dir_all(actual.join("skills")).unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&actual, &middle).unwrap(); + std::os::unix::fs::symlink(&middle, &first).unwrap(); + } + #[cfg(windows)] + { + junction::create(&actual, &middle).unwrap(); + junction::create(&middle, &first).unwrap(); + } + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: crate::environment::types::SkillLocation::Global, + }; + let targets = RuntimeTargetFactResolver::new(Arc::new(WslRuntime::default())); + let links = observe_root_links( + &targets, + &context, + &[ResourceLocator { + environment: EnvironmentRef::Native, + native_path: first.join("skills").to_string_lossy().into_owned(), + }], + ) + .await + .unwrap(); + let expected = targets + .resolve( + &context, + &[ResourceLocator { + environment: EnvironmentRef::Native, + native_path: middle.to_string_lossy().into_owned(), + }], + None, + ) + .await + .unwrap(); + assert!(links.iter().any(|fact| fact.key == expected[0].key)); + } + + #[test] + fn resolved_root_uses_posix_components_for_wsl() { + let locator = |path: &str| ResourceLocator { + environment: EnvironmentRef::Wsl { + distro_name: "Ubuntu".into(), + }, + native_path: path.into(), + }; + let root = locator("/work/a\\b"); + assert!( + is_within_resolved_root(&root, &locator("/work/a\\b/.claude/skills/demo")).unwrap() + ); + assert!( + !is_within_resolved_root(&root, &locator("/work/a/b/.claude/skills/demo")).unwrap() + ); + assert!(!is_within_resolved_root(&root, &locator("/work/A\\b/demo")).unwrap()); + assert!(is_within_resolved_root(&root, &locator("/work/a\\b/../other/demo")).is_err()); + assert!(is_within_resolved_root(&locator("/"), &locator("/skills/demo")).unwrap()); + } + + #[cfg(windows)] + #[test] + fn resolved_root_handles_windows_case_unc_and_verbatim_forms() { + let locator = |path: &str| ResourceLocator { + environment: EnvironmentRef::Native, + native_path: path.into(), + }; + assert!(is_within_resolved_root( + &locator(r"\\?\C:\Scope"), + &locator("c:/scope/.claude/skills/demo") + ) + .unwrap()); + assert!(is_within_resolved_root( + &locator(r"\\?\UNC\Server\Share\Scope"), + &locator(r"\\server\share\scope\demo") + ) + .unwrap()); + assert!( + !is_within_resolved_root(&locator(r"C:\Scope"), &locator(r"D:\Scope\demo")).unwrap() + ); + } + fn wsl_session() -> WslSession { WslSession { distro_name: "Ubuntu-24.04".to_string(), @@ -550,6 +827,55 @@ mod tests { assert!(identity.matches(&target)); } + #[cfg(unix)] + #[test] + fn native_link_identity_resolves_a_symlinked_target_ancestor() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let physical = temp.path().join("physical"); + let alias = temp.path().join("alias"); + let links = physical.join("links"); + let target = physical.join("shared/toolkit"); + std::fs::create_dir_all(&links).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + symlink(&physical, &alias).unwrap(); + symlink(alias.join("shared/toolkit"), links.join("demo")).unwrap(); + + let resolved = resolve_native_targets(&[ + ResourceLocator { + environment: EnvironmentRef::Native, + native_path: links.join("demo").to_string_lossy().into_owned(), + }, + ResourceLocator { + environment: EnvironmentRef::Native, + native_path: target.to_string_lossy().into_owned(), + }, + ]) + .unwrap(); + let identity = resolved[0].link_target_identity.as_ref().unwrap(); + + assert!(identity.matches(&resolved[1].destination)); + + std::fs::remove_dir_all(&target).unwrap(); + let broken = resolve_native_targets(&[ + ResourceLocator { + environment: EnvironmentRef::Native, + native_path: links.join("demo").to_string_lossy().into_owned(), + }, + ResourceLocator { + environment: EnvironmentRef::Native, + native_path: target.to_string_lossy().into_owned(), + }, + ]) + .unwrap(); + assert!(broken[0] + .link_target_identity + .as_ref() + .unwrap() + .matches(&broken[1].destination)); + } + #[test] fn resolved_link_target_identity_uses_posix_rules_for_wsl() { let wsl_destination = ResourceLocator { diff --git a/src-tauri/src/environment/project_service.rs b/src-tauri/src/environment/project_service.rs index cb2f2f2a..9a4dd220 100644 --- a/src-tauri/src/environment/project_service.rs +++ b/src-tauri/src/environment/project_service.rs @@ -204,12 +204,8 @@ pub async fn map_environment_path( if let Some(mapped) = registry.map_input_without_process(&distro_name, &path)? { return Ok(mapped); } - registry - .with_session_retry(&distro_name, move |session| { - let path = path.clone(); - async move { map_windows_path_with_wslpath(&session, &path).await } - }) - .await + let workspace = registry.workspace(&distro_name)?; + map_windows_path_with_wslpath(&workspace, &path).await } } } @@ -284,33 +280,29 @@ fn updated_native_project( }) } -#[cfg(test)] -fn parse_wsl_project_storage( - environment: &EnvironmentRef, - project_count: usize, - bytes: &[u8], -) -> Result, AppError> { - projects::parse_project_storage(environment, project_count, bytes) -} - async fn wsl_project_infos( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, bindings: Vec, ) -> Result, AppError> { - projects::project_infos(session, bindings).await + projects::project_infos(session, workspace, bindings).await } pub(crate) async fn read_wsl_projects( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, ) -> Result, AppError> { - projects::read_projects(session).await + projects::read_projects(session, workspace).await } async fn write_wsl_projects( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, projects: Vec, -) -> Result, AppError> { - projects::write_projects(session, projects).await + generation: u64, + expected_revision: Option, +) -> Result<(), AppError> { + projects::write_projects(session, workspace, projects, generation, expected_revision).await } pub async fn list_environment_projects( @@ -323,10 +315,14 @@ pub async fn list_environment_projects( ensure_native_projects_ready(migration)?.read()?, )), EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, |session| async move { - let projects = read_wsl_projects(&session).await?; - wsl_project_infos(&session, projects).await + .with_session_read_retry(&distro_name, move |session| { + let workspace = workspace.clone(); + async move { + let projects = read_wsl_projects(&session, &workspace).await?; + wsl_project_infos(&session, &workspace, projects).await + } }) .await } @@ -341,35 +337,48 @@ pub async fn add_environment_project( ) -> Result { match environment { EnvironmentRef::Native => { - let result = ensure_native_projects_ready(migration)?.add(native_path)?; - Ok(AddProjectResult { - project: native_project_info(result.project), - created: result.created, - }) + let store = ensure_native_projects_ready(migration)?; + let home = dirs::home_dir().ok_or_else(|| AppError::Path { + message: "cannot resolve home directory".to_string(), + })?; + add_native_environment_project(&store, &home, native_path) } EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let native_path = native_path.clone(); + let workspace = workspace.clone(); async move { let native_path = match map_wsl_input_without_wslpath( &session.distro_name, &native_path, )? { Some(mapped) => mapped, - None => map_windows_path_with_wslpath(&session, &native_path).await?, + None => map_windows_path_with_wslpath(&workspace, &native_path).await?, }; + ensure_wsl_project_is_not_home(&session, &workspace, &native_path).await?; + let snapshot = + projects::read_projects_snapshot(&session, &workspace).await?; let result = add_project_binding( - read_wsl_projects(&session).await?, + snapshot.projects, native_path, ProjectPathSemantics::Posix, ); - let project = wsl_project_infos(&session, vec![result.project.clone()]) - .await? - .pop() - .expect("one project info"); + let project = + wsl_project_infos(&session, &workspace, vec![result.project.clone()]) + .await? + .pop() + .expect("one project info"); if result.created { - write_wsl_projects(&session, result.projects).await?; + write_wsl_projects( + &session, + &workspace, + result.projects, + snapshot.generation, + snapshot.revision, + ) + .await?; } Ok(AddProjectResult { project, @@ -382,6 +391,57 @@ pub async fn add_environment_project( } } +fn add_native_environment_project( + store: &ProjectsStore, + home: &std::path::Path, + native_path: String, +) -> Result { + let semantics = ProjectPathSemantics::native(); + let normalized_project = normalize_project_native_path(&native_path, semantics); + let normalized_home = normalize_project_native_path(&home.to_string_lossy(), semantics); + let same_directory = normalized_project == normalized_home + || std::fs::canonicalize(&native_path) + .ok() + .zip(std::fs::canonicalize(home).ok()) + .is_some_and(|(project, home)| project == home); + if same_directory { + return Err(AppError::ProjectMatchesEnvironmentHome); + } + let result = store.add(native_path)?; + Ok(AddProjectResult { + project: native_project_info(result.project), + created: result.created, + }) +} + +async fn ensure_wsl_project_is_not_home( + session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, + native_path: &str, +) -> Result<(), AppError> { + let normalized_project = + normalize_project_native_path(native_path, ProjectPathSemantics::Posix); + let normalized_home = normalize_project_native_path(&session.home, ProjectPathSemantics::Posix); + if normalized_project == normalized_home { + return Err(AppError::ProjectMatchesEnvironmentHome); + } + let identity_child = ".skill-deck-project-directory-identity"; + let destinations = vec![ + format!("{}/{identity_child}", native_path.trim_end_matches('/')), + format!("{}/{identity_child}", session.home.trim_end_matches('/')), + ]; + let resolved = + crate::environment::planning::resolve_wsl_targets(session, workspace, &destinations, None) + .await?; + if resolved.len() != 2 { + return Err(AppError::StaleTarget); + } + if resolved[0].key == resolved[1].key { + return Err(AppError::ProjectMatchesEnvironmentHome); + } + Ok(()) +} + pub async fn remove_environment_project( environment: EnvironmentRef, project_id: String, @@ -393,14 +453,25 @@ pub async fn remove_environment_project( ensure_native_projects_ready(migration)?.remove(&project_id)?, )), EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let project_id = project_id.clone(); + let workspace = workspace.clone(); async move { - let projects = - remove_project_binding(read_wsl_projects(&session).await?, &project_id); - let infos = wsl_project_infos(&session, projects.clone()).await?; - write_wsl_projects(&session, projects).await?; + let snapshot = + projects::read_projects_snapshot(&session, &workspace).await?; + let projects = remove_project_binding(snapshot.projects, &project_id); + let infos = + wsl_project_infos(&session, &workspace, projects.clone()).await?; + write_wsl_projects( + &session, + &workspace, + projects, + snapshot.generation, + snapshot.revision, + ) + .await?; Ok(infos) } }) @@ -423,23 +494,34 @@ pub async fn set_environment_project_cross_storage_warning( updated_native_project(projects, &project_id) } EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let project_id = project_id.clone(); + let workspace = workspace.clone(); async move { + let snapshot = + projects::read_projects_snapshot(&session, &workspace).await?; let projects = set_project_cross_storage_warning_suppressed( - read_wsl_projects(&session).await?, + snapshot.projects, &project_id, suppressed, ); - let project = wsl_project_infos(&session, projects.clone()) + let project = wsl_project_infos(&session, &workspace, projects.clone()) .await? .into_iter() .find(|project| project.binding.id == project_id) .ok_or_else(|| AppError::PathNotFound { path: project_id.clone(), })?; - write_wsl_projects(&session, projects).await?; + write_wsl_projects( + &session, + &workspace, + projects, + snapshot.generation, + snapshot.revision, + ) + .await?; Ok(project) } }) @@ -473,9 +555,9 @@ mod tests { use tempfile::tempdir; use super::{ - environment_infos_from_wsl_discovery, list_environments_with, map_environment_path, - native_environment_info, native_project_info_for_platform, - native_projects_store_from_config, parse_wsl_project_storage, + add_native_environment_project, environment_infos_from_wsl_discovery, + list_environments_with, map_environment_path, native_environment_info, + native_project_info_for_platform, native_projects_store_from_config, }; use crate::environment::types::{ EnvironmentRef, EnvironmentStatus, RegisteredProject, StorageAccess, @@ -483,6 +565,29 @@ mod tests { use crate::environment::wsl::WslRuntime; use crate::error::AppError; + #[cfg(target_os = "windows")] + struct WslPathCleanup { + distro: String, + path: String, + } + + #[cfg(target_os = "windows")] + impl Drop for WslPathCleanup { + fn drop(&mut self) { + let _ = crate::background_process::std_command("wsl.exe") + .args([ + "--distribution", + &self.distro, + "--exec", + "/bin/rm", + "-f", + "--", + &self.path, + ]) + .status(); + } + } + #[test] fn native_environment_is_always_available() { let native = native_environment_info(); @@ -536,37 +641,6 @@ mod tests { assert_eq!(malformed.storage.owner, None); } - #[test] - fn parses_wsl_project_storage_batch_without_guessing_automount_root() { - let session_environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let storage = parse_wsl_project_storage( - &session_environment, - 3, - b"1\0ok\0C:\\Code\\app\0ok\0\\\\wsl.localhost\\Ubuntu\\home\\alice\\app\0error\0\0", - ) - .expect("storage batch"); - - assert_eq!(storage.len(), 3); - assert_eq!(storage[0].access, StorageAccess::CrossStorage); - assert_eq!(storage[0].owner, Some(EnvironmentRef::Native)); - assert_eq!(storage[1].access, StorageAccess::Native); - assert_eq!(storage[1].owner, Some(session_environment)); - assert_eq!(storage[2].access, StorageAccess::Unsupported); - assert_eq!(storage[2].owner, None); - } - - #[test] - fn rejects_malformed_wsl_project_storage_batches() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - - assert!(parse_wsl_project_storage(&environment, 1, b"2\0ok\0C:\\app\0").is_err()); - assert!(parse_wsl_project_storage(&environment, 2, b"1\0ok\0C:\\app\0").is_err()); - } - #[test] fn unavailable_wsl_discovery_keeps_the_native_environment() { let registry = WslRuntime::default(); @@ -703,4 +777,125 @@ mod tests { assert!(!temp.path().join("projects.json").exists()); assert_eq!(fs::read_dir(temp.path()).expect("list files").count(), 1); } + + #[test] + fn native_project_add_rejects_the_environment_home_without_writing() { + let temp = tempdir().expect("tempdir"); + let home = temp.path().join("home"); + let config = temp.path().join("config.json"); + fs::create_dir_all(&home).expect("create home"); + let store = native_projects_store_from_config(&config); + + let error = + add_native_environment_project(&store, &home, home.to_string_lossy().into_owned()) + .expect_err("home cannot be a Project"); + + assert_eq!(error, AppError::ProjectMatchesEnvironmentHome); + assert!(store.read().expect("read projects").is_empty()); + } + + #[cfg(unix)] + #[test] + fn native_project_add_rejects_a_symlink_to_the_environment_home() { + let temp = tempdir().expect("tempdir"); + let home = temp.path().join("home"); + let alias = temp.path().join("home-alias"); + let config = temp.path().join("config.json"); + fs::create_dir_all(&home).expect("create home"); + std::os::unix::fs::symlink(&home, &alias).expect("create home alias"); + let store = native_projects_store_from_config(&config); + + let error = + add_native_environment_project(&store, &home, alias.to_string_lossy().into_owned()) + .expect_err("home alias cannot be a Project"); + + assert_eq!(error, AppError::ProjectMatchesEnvironmentHome); + assert!(store.read().expect("read projects").is_empty()); + } + + #[cfg(windows)] + #[test] + fn native_project_add_rejects_windows_aliases_to_the_environment_home() { + let temp = tempdir().expect("tempdir"); + let home = temp.path().join("home"); + let alias = temp.path().join("home-alias"); + let config = temp.path().join("config.json"); + fs::create_dir_all(&home).expect("create home"); + junction::create(&home, &alias).expect("create home junction"); + let store = native_projects_store_from_config(&config); + + for candidate in [ + alias.to_string_lossy().into_owned(), + home.to_string_lossy().to_uppercase(), + ] { + let error = add_native_environment_project(&store, &home, candidate) + .expect_err("home alias cannot be a Project"); + assert_eq!(error, AppError::ProjectMatchesEnvironmentHome); + } + assert!(store.read().expect("read projects").is_empty()); + } + + #[test] + fn native_project_add_accepts_a_directory_below_the_environment_home() { + let temp = tempdir().expect("tempdir"); + let home = temp.path().join("home"); + let project = home.join("project"); + let config = temp.path().join("config.json"); + fs::create_dir_all(&project).expect("create project"); + let store = native_projects_store_from_config(&config); + + let added = + add_native_environment_project(&store, &home, project.to_string_lossy().into_owned()) + .expect("add child Project"); + + assert!(added.created); + assert_eq!(added.project.binding.native_path, project.to_string_lossy()); + } + + #[cfg(target_os = "windows")] + #[tokio::test] + #[ignore = "requires Windows with a WSL 2 distribution"] + async fn real_wsl_project_add_rejects_the_environment_home_and_its_final_symlink() { + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let runtime = WslRuntime::for_wsl_test(); + let workspace = runtime.workspace(&distro).expect("WSL workspace"); + let session = runtime.connect(&distro).await.expect("connect WSL"); + + let error = super::ensure_wsl_project_is_not_home(&session, &workspace, &session.home) + .await + .expect_err("WSL home cannot be a Project"); + + assert_eq!(error, AppError::ProjectMatchesEnvironmentHome); + + let alias = format!( + "/tmp/skill-deck-home-alias-{}", + uuid::Uuid::new_v4().simple() + ); + let status = crate::background_process::tokio_command("wsl.exe") + .args([ + "--distribution", + &distro, + "--exec", + "/bin/ln", + "-s", + "--", + &session.home, + &alias, + ]) + .status() + .await + .expect("create WSL home alias"); + assert!(status.success()); + let _cleanup = WslPathCleanup { + distro: distro.clone(), + path: alias.clone(), + }; + + let alias_error = super::ensure_wsl_project_is_not_home(&session, &workspace, &alias) + .await + .expect_err("a final symlink to WSL home cannot be a Project"); + + assert_eq!(alias_error, AppError::ProjectMatchesEnvironmentHome); + } } diff --git a/src-tauri/src/environment/read_service.rs b/src-tauri/src/environment/read_service.rs index eee329ed..6e79ae8a 100644 --- a/src-tauri/src/environment/read_service.rs +++ b/src-tauri/src/environment/read_service.rs @@ -104,6 +104,7 @@ mod tests { FilesystemEntryKind::Directory }, resolved_target: None, + fingerprint: None, frontmatter_bytes: content.clone(), truncated: self.oversized, error_code: (index == 1).then(|| "pathUnavailable".to_string()), diff --git a/src-tauri/src/environment/runtime.rs b/src-tauri/src/environment/runtime.rs index 7ec9b224..7015354a 100644 --- a/src-tauri/src/environment/runtime.rs +++ b/src-tauri/src/environment/runtime.rs @@ -6,8 +6,7 @@ use specta::Type; use unicode_normalization::UnicodeNormalization; use crate::environment::types::{ - same_environment_identity, EnvironmentKey, EnvironmentRef, RegisteredProject, ResourceLocator, - SkillLocation, SkillLocationRef, + EnvironmentKey, EnvironmentRef, RegisteredProject, SkillLocation, SkillLocationRef, }; use crate::error::AppError; @@ -209,44 +208,6 @@ pub fn posix_relative_target( }) } -pub fn physical_paths_overlap( - left: &ResourceLocator, - right: &ResourceLocator, - case_sensitive: bool, -) -> Result { - if !same_environment_identity(&left.environment, &right.environment) { - return Ok(false); - } - let left = normalized_path_components(&left.native_path, case_sensitive)?; - let right = normalized_path_components(&right.native_path, case_sensitive)?; - Ok(is_component_prefix(&left, &right) || is_component_prefix(&right, &left)) -} - -fn normalized_path_components(path: &str, case_sensitive: bool) -> Result, AppError> { - let mut components = Vec::new(); - for component in path.replace('\\', "/").split('/') { - match component { - "" | "." => {} - ".." => { - return Err(AppError::UnsafePath { - path: path.to_string(), - reason: "physical path is not normalized".to_string(), - }); - } - value => components.push(if case_sensitive { - value.to_string() - } else { - value.to_lowercase() - }), - } - } - Ok(components) -} - -fn is_component_prefix(left: &[String], right: &[String]) -> bool { - left.len() <= right.len() && left.iter().zip(right).all(|(left, right)| left == right) -} - pub fn context_snapshot_revision( input: &ContextRevisionInput, ) -> Result { @@ -455,27 +416,6 @@ mod tests { assert_eq!(upper, lower); } - #[test] - fn physical_path_overlap_uses_component_and_environment_identity_rules() { - let locator = |environment, native_path: &str| ResourceLocator { - environment, - native_path: native_path.to_string(), - }; - let native = locator(EnvironmentRef::Native, r"C:\Code\App"); - let native_child = locator(EnvironmentRef::Native, r"c:\code\app\skills"); - let native_sibling = locator(EnvironmentRef::Native, r"C:\Code\Application"); - let wsl = locator( - EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }, - "/mnt/c/Code/App", - ); - - assert!(physical_paths_overlap(&native, &native_child, false).expect("native overlap")); - assert!(!physical_paths_overlap(&native, &native_sibling, false).expect("native sibling")); - assert!(!physical_paths_overlap(&native, &wsl, false).expect("different Environment")); - } - #[test] fn context_revision_is_deterministic_and_scoped_to_owned_inputs() { let mut first_fields = BTreeMap::new(); diff --git a/src-tauri/src/environment/wsl.rs b/src-tauri/src/environment/wsl.rs index cd76eb0c..60d45854 100644 --- a/src-tauri/src/environment/wsl.rs +++ b/src-tauri/src/environment/wsl.rs @@ -2,9 +2,11 @@ use std::collections::{BTreeMap, HashMap}; use std::future::Future; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; +use sha2::Digest; use specta::Type; use tokio::sync::{Mutex as AsyncMutex, Notify}; use tokio::time::timeout; @@ -19,8 +21,46 @@ use crate::environment::types::{ }; use crate::error::AppError; +#[derive(Clone, Copy)] +struct RequestDeadline { + deadline: tokio::time::Instant, +} + +impl RequestDeadline { + fn from_start(started: tokio::time::Instant, limit: std::time::Duration) -> Self { + Self { + deadline: started + limit, + } + } + + fn new(limit: std::time::Duration) -> Self { + Self::from_start(tokio::time::Instant::now(), limit) + } + + fn remaining_at(self, now: tokio::time::Instant) -> Result { + self.deadline + .checked_duration_since(now) + .filter(|remaining| !remaining.is_zero()) + .ok_or(AppError::WslCommandTimedOut) + } + + fn remaining(self) -> Result { + self.remaining_at(tokio::time::Instant::now()) + } +} + pub mod operations; pub(crate) mod protocol; +mod worker; + +#[cfg(target_os = "windows")] +fn wsl_command() -> tokio::process::Command { + let executable = std::env::var_os("SystemRoot") + .map(std::path::PathBuf::from) + .map(|root| root.join("System32").join("wsl.exe")) + .unwrap_or_else(|| std::path::PathBuf::from("wsl.exe")); + tokio_command(executable) +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -42,6 +82,7 @@ pub(crate) struct WslSession { struct CachedWslSession { generation: u64, session: WslSession, + worker: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -97,13 +138,7 @@ pub(crate) struct WslRuntime { listener: Arc>>, quiescence: Arc, source_retirement: Arc, - deferred_source_cleanups: Arc>>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WslSourceCleanupIntent { - distro_name: String, - native_root: String, + worker_artifact_directory: Option>, } #[derive(Clone)] @@ -147,6 +182,14 @@ impl WslRuntime { } pub fn new_with_support(supported: bool, wsl_integration_enabled: bool) -> Self { + Self::new_with_worker_artifact_directory(supported, wsl_integration_enabled, None) + } + + pub fn new_with_worker_artifact_directory( + supported: bool, + wsl_integration_enabled: bool, + worker_artifact_directory: Option, + ) -> Self { Self { state: Arc::new(Mutex::new(WslRuntimeState::new( supported, @@ -156,7 +199,48 @@ impl WslRuntime { listener: Arc::new(Mutex::new(None)), quiescence: Arc::new(Notify::new()), source_retirement: Arc::new(Notify::new()), - deferred_source_cleanups: Arc::new(Mutex::new(Vec::new())), + worker_artifact_directory: worker_artifact_directory.map(Arc::new), + } + } + + #[cfg(test)] + pub(crate) fn for_wsl_test() -> Self { + Self::new_with_worker_artifact_directory( + true, + true, + Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/wsl-worker/current")), + ) + } + + #[cfg(all(test, target_os = "windows"))] + pub(crate) async fn run_test_script( + &self, + distro_name: &str, + script: &'static str, + args: Vec, + limit: std::time::Duration, + ) -> Result<(), AppError> { + let session = self.connect(distro_name).await?; + let output = protocol::WslCommandRunner::run(protocol::WslCommandRequest { + session, + script, + args, + stdin: Vec::new(), + timeout: limit, + stdout_limit: protocol::DEFAULT_WSL_STDOUT_LIMIT, + stderr_limit: protocol::DEFAULT_WSL_STDERR_LIMIT, + cancellation: None, + }) + .await?; + if output.exit_code == Some(0) { + Ok(()) + } else { + Err(AppError::ExecutionFailed { + message: format!( + "WSL test fixture command failed: {}", + String::from_utf8_lossy(&output.stderr) + ), + }) } } @@ -170,6 +254,18 @@ impl WslRuntime { ) } + fn worker_artifact_directory(&self, distro_name: &str) -> Result<&std::path::Path, AppError> { + self.worker_artifact_directory + .as_ref() + .map(|directory| directory.as_path()) + .ok_or_else(|| AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL worker artifact directory is not configured".to_string(), + }) + } + #[cfg(test)] pub fn set_wsl_integration_enabled(&self, enabled: bool) { let mut state = self @@ -314,35 +410,52 @@ impl WslRuntime { self.discover_with(discover).await } - async fn connect_with( - &self, - distro_name: &str, - mut connector: C, - ) -> Result - where - C: FnMut(String) -> CFut, - CFut: Future>, - { + pub async fn connect(&self, distro_name: &str) -> Result { let expected_cycle = self.enabled_cycle(distro_name)?; let reconnect_lock = self.reconnect_lock(distro_name); let _reconnect = reconnect_lock.lock().await; let permit = self.acquire_wsl_access_for_cycle(distro_name, Some(expected_cycle))?; - let mut session = connector(distro_name.to_string()).await?; - self.insert_with_permit(&mut session, &permit)?; + #[cfg(target_os = "windows")] + ensure_wsl2_candidate( + distro_name, + &discover_wsl_distributions().await.map_err(|error| { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: error.to_string(), + } + })?, + )?; + let mut session = connect_wsl_environment(distro_name).await?; + let worker = + match worker::connect_worker(&session, self.worker_artifact_directory(distro_name)?) + .await + { + Ok(worker) => worker, + Err(error) => { + self.publish_connect_failure_if_cycle( + distro_name, + permit.capability_revision, + error.clone(), + ); + return Err(error); + } + }; + let worker_closed = worker.closed_receiver(); + self.insert_with_permit(&mut session, &permit, Some(worker))?; + self.monitor_worker( + distro_name.to_string(), + session.runtime_generation, + worker_closed, + ); Ok(session) } - pub async fn connect(&self, distro_name: &str) -> Result { - self.connect_with(distro_name, |distro_name| async move { - connect_wsl_environment(&distro_name).await - }) - .await - } - #[cfg(test)] pub(crate) fn insert(&self, mut session: WslSession) { if let Ok(permit) = self.acquire_wsl_access(&session.distro_name) { - let _ = self.insert_with_permit(&mut session, &permit); + let _ = self.insert_with_permit(&mut session, &permit, None); } } @@ -350,6 +463,7 @@ impl WslRuntime { &self, session: &mut WslSession, permit: &WslAccessPermit, + worker: Option, ) -> Result<(), AppError> { let distro_name = session.distro_name.clone(); let key = EnvironmentKey::wsl(&distro_name); @@ -376,6 +490,7 @@ impl WslRuntime { CachedWslSession { generation, session: session.clone(), + worker, }, ); state.runtime.insert( @@ -533,6 +648,104 @@ impl WslRuntime { } } + fn publish_connect_failure_if_cycle( + &self, + distro_name: &str, + capability_revision: u64, + error: AppError, + ) { + let mut state = self + .state + .lock() + .expect("environment registry lock poisoned"); + if state.capability_revision != capability_revision + || state.capability != WslCapabilityState::Enabled + { + return; + } + state.next_generation = state.next_generation.saturating_add(1); + let revision = state.next_generation; + state.runtime.insert( + EnvironmentKey::wsl(distro_name), + EnvironmentRuntimeStatus { + revision, + status: EnvironmentStatus::Unavailable, + error: Some(error.clone()), + }, + ); + drop(state); + self.publish(EnvironmentRuntimeEvent { + capability_revision, + revision, + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + status: EnvironmentStatus::Unavailable, + error: Some(error), + }); + } + + fn monitor_worker( + &self, + distro_name: String, + generation: u64, + mut closed: tokio::sync::watch::Receiver, + ) { + let registry = self.clone(); + tokio::spawn(async move { + if !*closed.borrow() && closed.changed().await.is_err() { + return; + } + if *closed.borrow() { + registry.publish_worker_closed_if_current(&distro_name, generation); + } + }); + } + + fn publish_worker_closed_if_current(&self, distro_name: &str, generation: u64) { + let error = AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL worker session closed".to_string(), + }; + let mut state = self + .state + .lock() + .expect("environment registry lock poisoned"); + let key = EnvironmentKey::wsl(distro_name); + if state.capability != WslCapabilityState::Enabled + || state + .sessions + .get(&key) + .is_none_or(|cached| cached.generation != generation) + { + return; + } + state.sessions.remove(&key); + state.next_generation = state.next_generation.saturating_add(1); + let revision = state.next_generation; + state.runtime.insert( + key, + EnvironmentRuntimeStatus { + revision, + status: EnvironmentStatus::Unavailable, + error: Some(error.clone()), + }, + ); + let capability_revision = state.capability_revision; + drop(state); + self.publish(EnvironmentRuntimeEvent { + capability_revision, + revision, + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + status: EnvironmentStatus::Unavailable, + error: Some(error), + }); + } + async fn get_or_connect_using( &self, distro_name: &str, @@ -556,7 +769,7 @@ impl WslRuntime { return Ok((cached, permit)); } let mut session = connector(distro_name.to_string()).await?; - self.insert_with_permit(&mut session, &permit)?; + self.insert_with_permit(&mut session, &permit, None)?; self.get_cached(distro_name) .map(|cached| (cached, permit)) .ok_or_else(|| AppError::EnvironmentUnavailable { @@ -600,8 +813,6 @@ impl WslRuntime { let (initial, initial_access) = self .get_or_connect_using(distro_name, expected_cycle, &mut connector) .await?; - self.reconcile_deferred_source_cleanups(&initial.session) - .await; match operation(initial.session.clone()).await { Ok(result) => Ok(result), Err(AppError::EnvironmentUnavailable { .. }) => { @@ -624,7 +835,7 @@ impl WslRuntime { } Err(error) => return Err(error), }; - self.insert_with_permit(&mut session, &access)?; + self.insert_with_permit(&mut session, &access, None)?; self.get_cached(distro_name).ok_or_else(|| { AppError::EnvironmentUnavailable { environment: EnvironmentRef::Wsl { @@ -654,44 +865,7 @@ impl WslRuntime { } } - async fn reconcile_deferred_source_cleanups(&self, session: &WslSession) { - let pending = { - let mut intents = self - .deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned"); - let mut pending = Vec::new(); - let mut retained = Vec::new(); - for intent in intents.drain(..) { - if intent - .distro_name - .eq_ignore_ascii_case(&session.distro_name) - { - pending.push(intent); - } else { - retained.push(intent); - } - } - *intents = retained; - pending - }; - for intent in pending { - if crate::environment::wsl::operations::source_acquisition::cleanup_wsl_source( - session, - &intent.native_root, - ) - .await - .is_err() - { - self.deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned") - .push(intent); - } - } - } - - pub async fn with_session_retry( + pub async fn with_session_read_retry( &self, distro_name: &str, operation: O, @@ -736,15 +910,41 @@ impl WslWorkspace { &self.distro_name } - pub(crate) fn defer_source_cleanup(&self, native_root: String) { - self.registry - .deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned") - .push(WslSourceCleanupIntent { - distro_name: self.distro_name.clone(), - native_root, + pub(crate) fn payload_storage( + &self, + ) -> Arc { + Arc::new(operations::acquire::WslPayloadSessionStorage::new( + self.clone(), + )) + } + + pub(crate) fn defer_worker_source_release( + &self, + handle: operations::source_acquisition::WorkerSourceHandle, + ) { + let workspace = self.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let Some(cached) = workspace.registry.get_cached(&workspace.distro_name) else { + return; + }; + if cached.generation != handle.generation { + return; + } + let Some(worker) = cached.worker else { + return; + }; + let _ = worker + .request_control_with_cancellation( + environment_protocol::Message::ReleaseSource { + source_id: handle.id, + }, + std::time::Duration::from_secs(10), + None, + ) + .await; }); + } } pub(crate) fn register_source_owner(&self) -> Result<(), AppError> { @@ -779,15 +979,6 @@ impl WslWorkspace { } } - #[cfg(test)] - pub(crate) fn deferred_source_cleanup_count(&self) -> usize { - self.registry - .deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned") - .len() - } - pub(crate) async fn with_access(&self, operation: O) -> Result where O: FnOnce() -> OFut, @@ -799,21 +990,538 @@ impl WslWorkspace { operation().await } - async fn with_session_retry(&self, operation: O) -> Result + pub(crate) async fn inspect_filesystem( + &self, + request: environment_protocol::InspectionRequest, + ) -> Result { + self.request_worker_payload(environment_protocol::Message::InspectFilesystem { request }) + .await + } + + pub(crate) async fn map_host_path( + &self, + path: String, + cancellation: Option, + ) -> Result { + if path.is_empty() || path.contains('\0') { + return Err(AppError::Validation { + field: Some("bridgePath".to_string()), + message: "Host bridge path is invalid".to_string(), + }); + } + let message = environment_protocol::Message::MapHostPaths { + request: environment_protocol::MapHostPathsRequest { + paths: vec![path.clone()], + deadline_millis: 10_000, + }, + }; + let response: environment_protocol::MapHostPathsResponse = match cancellation { + Some(cancellation) => { + self.request_worker_payload_with_cancellation(message, cancellation) + .await + } + None => self.request_worker_payload(message).await, + } + .map_err(|error| match error { + AppError::CapabilityUnavailable { capability, .. } + if capability == "wslPathMapping" => + { + AppError::StorageMappingUnsupported { + path: path.clone(), + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + } + } + error => error, + })?; + match response.mapped.as_slice() { + [mapped] if mapped.starts_with('/') && !mapped.contains('\0') => Ok(mapped.clone()), + _ => Err(AppError::ConfigurationCorrupted { + message: "invalid WSL path mapping response".to_string(), + }), + } + } + + #[cfg(test)] + pub(crate) async fn map_path_to_windows( + &self, + path: String, + ) -> Result, AppError> { + if !path.starts_with('/') || path.contains('\0') { + return Err(AppError::Validation { + field: Some("storagePath".to_string()), + message: "WSL storage path must be absolute".to_string(), + }); + } + let response: environment_protocol::MapWindowsPathsResponse = self + .request_worker_payload(environment_protocol::Message::MapPathsToWindows { + request: environment_protocol::MapWindowsPathsRequest { + paths: vec![path], + deadline_millis: 10_000, + }, + }) + .await?; + match response.mapped.as_slice() { + [mapped] => Ok(mapped.clone()), + _ => Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Windows path mapping response".to_string(), + }), + } + } + + pub(crate) async fn request_worker_payload( + &self, + message: environment_protocol::Message, + ) -> Result where - O: FnMut(WslSession) -> OFut, - OFut: Future>, + T: serde::de::DeserializeOwned, { - self.registry - .with_session_retry_in_cycle( + let payload = self.request_worker_bytes(message, None).await?; + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + }) + } + + pub(crate) async fn request_worker_payload_with_cancellation( + &self, + message: environment_protocol::Message, + cancellation: crate::core::mutation::CancellationSignal, + ) -> Result + where + T: serde::de::DeserializeOwned, + { + let payload = self + .request_worker_bytes(message, Some(cancellation)) + .await?; + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + }) + } + + pub(crate) async fn request_worker_control_once( + &self, + message: environment_protocol::Message, + cancellation: Option, + limit: std::time::Duration, + ) -> Result<(u64, environment_protocol::Message), AppError> { + let deadline = RequestDeadline::new(limit); + let (worker, generation, _access) = self.worker_for_deadline(deadline).await?; + let result = worker + .request_control_with_cancellation(message, deadline.remaining()?, cancellation) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( &self.distro_name, - Some(self.capability_cycle), - |distro_name| async move { connect_wsl_environment(&distro_name).await }, - operation, + generation, + error.clone(), + ); + } + result.map(|message| (generation, message)) + } + + pub(crate) async fn request_worker_control_for_generation( + &self, + generation: u64, + message: environment_protocol::Message, + cancellation: Option, + limit: std::time::Duration, + ) -> Result { + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; + self.require_worker_generation(generation, current_generation)?; + let result = worker + .request_control_with_cancellation(message, deadline.remaining()?, cancellation) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( + &self.distro_name, + current_generation, + error.clone(), + ); + } + result + } + + pub(crate) async fn request_worker_payload_for_generation( + &self, + generation: u64, + message: environment_protocol::Message, + max_payload_bytes: usize, + cancellation: Option, + limit: std::time::Duration, + ) -> Result + where + T: serde::de::DeserializeOwned, + { + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; + self.require_worker_generation(generation, current_generation)?; + let result = worker + .request_payload_with_limit( + message, + deadline.remaining()?, + max_payload_bytes, + cancellation, + ) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( + &self.distro_name, + current_generation, + error.clone(), + ); + } + let payload = result?; + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + }) + } + + pub(crate) async fn request_worker_payload_once( + &self, + message: environment_protocol::Message, + max_payload_bytes: usize, + cancellation: Option, + limit: std::time::Duration, + ) -> Result<(u64, T), AppError> + where + T: serde::de::DeserializeOwned, + { + let deadline = RequestDeadline::new(limit); + let (worker, generation, _access) = self.worker_for_deadline(deadline).await?; + let result = worker + .request_payload_with_limit( + message, + deadline.remaining()?, + max_payload_bytes, + cancellation, ) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( + &self.distro_name, + generation, + error.clone(), + ); + } + let payload = result?; + let decoded = environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + })?; + Ok((generation, decoded)) + } + + pub(crate) async fn request_worker_bytes_for_generation( + &self, + generation: u64, + message: environment_protocol::Message, + max_payload_bytes: usize, + limit: std::time::Duration, + ) -> Result, AppError> { + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; + self.require_worker_generation(generation, current_generation)?; + worker + .request_payload_with_limit(message, deadline.remaining()?, max_payload_bytes, None) .await } + pub(crate) async fn send_worker_transfer_for_generation( + &self, + generation: u64, + transfer_id: u64, + payload: &[u8], + max_payload_bytes: usize, + limit: std::time::Duration, + ) -> Result { + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; + self.require_worker_generation(generation, current_generation)?; + worker + .send_prepared_transfer( + transfer_id, + payload, + max_payload_bytes, + deadline.remaining()?, + ) + .await + } + + pub(crate) async fn execute_worker_mutation( + &self, + generation: u64, + resource_id: &str, + request: &environment_protocol::MutationUnitRequest, + cancellation: crate::core::mutation::CancellationSignal, + ) -> Result { + if request.deadline_millis == 0 + || request.deadline_millis > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + return Err(AppError::Validation { + field: Some("wslMutationDeadline".to_string()), + message: "invalid WSL mutation deadline".to_string(), + }); + } + let deadline = + RequestDeadline::new(std::time::Duration::from_millis(request.deadline_millis)); + let payload = environment_protocol::encode_payload(request).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("failed to encode WSL Worker mutation request: {error}"), + } + })?; + if payload.len() > environment_protocol::MAX_MUTATION_TRANSFER_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslMutationRequestSize".to_string(), + path: None, + }); + } + let digest = format!("sha256:{:x}", sha2::Sha256::digest(&payload)); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; + self.require_worker_generation(generation, current_generation)?; + let prepare_limit = deadline + .remaining()? + .min(std::time::Duration::from_secs(10)); + let prepared = worker + .request_control_with_cancellation( + environment_protocol::Message::PrepareMutationUnit { + resource_id: resource_id.to_string(), + total_bytes: payload.len() as u64, + sha256: digest, + }, + prepare_limit, + Some(cancellation.clone()), + ) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &prepared { + self.registry.publish_unavailable_if_current( + &self.distro_name, + current_generation, + error.clone(), + ); + } + let prepared = prepared?; + let transfer_id = match prepared { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + environment_protocol::Message::Error { code, phase, .. } => { + return Err(AppError::ExecutionFailed { + message: format!( + "WSL Worker mutation preparation failed during {phase}: {code}" + ), + }); + } + _ => { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Worker mutation preparation response".to_string(), + }); + } + }; + let mutation_limit = deadline.remaining()?; + match worker + .send_prepared_mutation( + transfer_id, + resource_id, + &payload, + cancellation, + mutation_limit, + ) + .await + { + Ok(outcome) => Ok(outcome), + Err(worker::MutationSessionError { + recovery_resource_id: Some(accepted), + error, + }) => Err(AppError::RecoveryRequired { + recovery_resource_id: crate::error::RecoveryResourceId::parse(accepted) + .unwrap_or_else(|_| { + crate::error::RecoveryResourceId::parse(resource_id.to_string()) + .expect("validated mutation resource ID") + }), + message: error.to_string(), + }), + Err(worker::MutationSessionError { error, .. }) => Err(error), + } + } + + fn require_worker_generation(&self, expected: u64, actual: u64) -> Result<(), AppError> { + if expected == actual { + Ok(()) + } else { + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + message: "WSL Worker handle belongs to an expired session".to_string(), + }) + } + } + + async fn request_worker_bytes( + &self, + message: environment_protocol::Message, + cancellation: Option, + ) -> Result, AppError> { + let deadline = RequestDeadline::new(std::time::Duration::from_secs(35)); + for attempt in 0..=1 { + let (worker, generation, _access) = self.worker_for_deadline(deadline).await?; + let remaining = deadline.remaining()?; + let result = match &cancellation { + Some(cancellation) => { + worker + .request_payload_with_cancellation( + message.clone(), + remaining, + cancellation.clone(), + ) + .await + } + None => worker.request_payload(message.clone(), remaining).await, + }; + match result { + Ok(payload) => return Ok(payload), + Err(error @ AppError::EnvironmentUnavailable { .. }) if attempt == 0 => { + self.registry.publish_unavailable_if_current( + &self.distro_name, + generation, + error, + ); + } + Err(error) => return Err(error), + } + } + unreachable!("WSL Worker read retry has a fixed attempt count") + } + + async fn worker_for_deadline( + &self, + deadline: RequestDeadline, + ) -> Result<(worker::WorkerSession, u64, WslAccessPermit), AppError> { + tokio::time::timeout_at(deadline.deadline, self.worker_for_cycle()) + .await + .map_err(|_| AppError::WslCommandTimedOut)? + } + + async fn worker_for_cycle( + &self, + ) -> Result<(worker::WorkerSession, u64, WslAccessPermit), AppError> { + let access = self + .registry + .acquire_wsl_access_for_cycle(&self.distro_name, Some(self.capability_cycle))?; + if let Some(cached) = self.registry.get_cached(&self.distro_name) { + if let Some(worker) = cached.worker { + return Ok((worker, cached.generation, access)); + } + } + drop(access); + + let reconnect_lock = self.registry.reconnect_lock(&self.distro_name); + let _reconnect = reconnect_lock.lock().await; + let access = self + .registry + .acquire_wsl_access_for_cycle(&self.distro_name, Some(self.capability_cycle))?; + if let Some(cached) = self.registry.get_cached(&self.distro_name) { + if let Some(worker) = cached.worker { + return Ok((worker, cached.generation, access)); + } + self.ensure_wsl2_candidate().await?; + let worker = match worker::connect_worker( + &cached.session, + self.registry.worker_artifact_directory(&self.distro_name)?, + ) + .await + { + Ok(worker) => worker, + Err(error) => { + self.registry.publish_unavailable_if_current( + &self.distro_name, + cached.generation, + error.clone(), + ); + return Err(error); + } + }; + let closed = worker.closed_receiver(); + { + let mut state = self + .registry + .state + .lock() + .expect("environment registry lock poisoned"); + let key = EnvironmentKey::wsl(&self.distro_name); + if state.capability_revision != self.capability_cycle + || state.capability != WslCapabilityState::Enabled + { + return Err(WslRuntime::disabled_error(&self.distro_name)); + } + let current = state.sessions.get_mut(&key).ok_or_else(|| { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + message: "WSL session changed while connecting its Worker".to_string(), + } + })?; + if current.generation != cached.generation { + return Err(WslRuntime::disabled_error(&self.distro_name)); + } + current.worker = Some(worker.clone()); + } + self.registry + .monitor_worker(self.distro_name.clone(), cached.generation, closed); + return Ok((worker, cached.generation, access)); + } + + self.ensure_wsl2_candidate().await?; + let mut session = connect_wsl_environment(&self.distro_name).await?; + let worker = match worker::connect_worker( + &session, + self.registry.worker_artifact_directory(&self.distro_name)?, + ) + .await + { + Ok(worker) => worker, + Err(error) => { + self.registry.publish_connect_failure_if_cycle( + &self.distro_name, + self.capability_cycle, + error.clone(), + ); + return Err(error); + } + }; + let closed = worker.closed_receiver(); + self.registry + .insert_with_permit(&mut session, &access, Some(worker.clone()))?; + let generation = session.runtime_generation; + self.registry + .monitor_worker(self.distro_name.clone(), generation, closed); + Ok((worker, generation, access)) + } + + async fn ensure_wsl2_candidate(&self) -> Result<(), AppError> { + #[cfg(target_os = "windows")] + ensure_wsl2_candidate( + &self.distro_name, + &discover_wsl_distributions().await.map_err(|error| { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + message: error.to_string(), + } + })?, + )?; + Ok(()) + } + #[cfg(test)] async fn with_session_retry_using( &self, @@ -886,23 +1594,6 @@ impl WslDisableTransition { timeout(limit, wait).await.map_err(|_| ()) } - pub(crate) async fn flush_deferred_source_cleanups(&self) { - let sessions = self - .registry - .state - .lock() - .expect("environment registry lock poisoned") - .sessions - .values() - .map(|cached| cached.session.clone()) - .collect::>(); - for session in sessions { - self.registry - .reconcile_deferred_source_cleanups(&session) - .await; - } - } - pub(crate) async fn wait_for_source_retirement( &self, limit: std::time::Duration, @@ -997,13 +1688,25 @@ impl Drop for WslEnableTransition { } pub fn parse_wsl_list_output(bytes: &[u8]) -> Vec { - let decoded = if bytes.len() >= 2 && bytes.len().is_multiple_of(2) { + let looks_utf16 = bytes.len() >= 2 + && bytes.len().is_multiple_of(2) + && bytes + .iter() + .skip(1) + .step_by(2) + .filter(|byte| **byte == 0) + .count() + > bytes.len() / 8; + let decoded = if looks_utf16 { let (pairs, remainder) = bytes.as_chunks::<2>(); debug_assert!(remainder.is_empty()); - let utf16 = pairs + let mut utf16 = pairs .iter() .map(|pair| u16::from_le_bytes(*pair)) .collect::>(); + if utf16.first() == Some(&0xfeff) { + utf16.remove(0); + } String::from_utf16(&utf16).unwrap_or_else(|_| String::from_utf8_lossy(bytes).into_owned()) } else { String::from_utf8_lossy(bytes).into_owned() @@ -1012,10 +1715,41 @@ pub fn parse_wsl_list_output(bytes: &[u8]) -> Vec { .lines() .map(|line| line.trim_matches(['\0', '\r', ' ', '\t'])) .filter(|line| !line.is_empty()) - .map(str::to_string) + .filter_map(parse_wsl_verbose_line) + .filter_map(|(name, version)| (version == 2).then_some(name)) .collect() } +fn parse_wsl_verbose_line(line: &str) -> Option<(String, u8)> { + let line = line.strip_prefix('*').unwrap_or(line).trim_start(); + let columns = line + .split('\t') + .flat_map(|column| column.split(" ")) + .map(str::trim) + .filter(|column| !column.is_empty()) + .collect::>(); + if columns.len() < 3 { + return None; + } + let version = columns.last()?.parse().ok()?; + Some((columns[0].to_string(), version)) +} + +fn ensure_wsl2_candidate(distro_name: &str, candidates: &[String]) -> Result<(), AppError> { + if candidates + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(distro_name)) + { + return Ok(()); + } + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "the distribution is not an available WSL 2 environment".to_string(), + }) +} + enum WslDiscoveryCommandOutcome { TimedOut, SpawnFailed(std::io::Error), @@ -1036,7 +1770,7 @@ fn interpret_wsl_discovery_outcome( Ok(Vec::new()) } WslDiscoveryCommandOutcome::TimedOut => Err(AppError::EnvironmentDiscoveryFailed { - message: "wsl.exe --list --quiet timed out".to_string(), + message: "wsl.exe --list --verbose timed out".to_string(), }), WslDiscoveryCommandOutcome::SpawnFailed(error) => { Err(AppError::EnvironmentDiscoveryFailed { @@ -1052,7 +1786,7 @@ fn interpret_wsl_discovery_outcome( let message = String::from_utf8_lossy(&stderr).trim().to_string(); Err(AppError::EnvironmentDiscoveryFailed { message: if message.is_empty() { - "wsl.exe --list --quiet exited unsuccessfully".to_string() + "wsl.exe --list --verbose exited unsuccessfully".to_string() } else { message }, @@ -1106,8 +1840,8 @@ pub fn parse_wsl_session_output(distro_name: &str, bytes: &[u8]) -> Result Result, AppError> { - let mut command = tokio_command("wsl.exe"); - command.args(["--list", "--quiet"]); + let mut command = wsl_command(); + command.args(["--list", "--verbose"]); let outcome = match timeout(Duration::from_secs(10), command.output()).await { Err(_) => WslDiscoveryCommandOutcome::TimedOut, Ok(Err(error)) => WslDiscoveryCommandOutcome::SpawnFailed(error), @@ -1128,7 +1862,7 @@ async fn discover_wsl_distributions() -> Result, AppError> { #[cfg(target_os = "windows")] async fn connect_wsl_environment(distro_name: &str) -> Result { const SCRIPT: &str = include_str!("wsl/scripts/session.sh"); - let mut command = tokio_command("wsl.exe"); + let mut command = wsl_command(); command.args([ "--distribution", distro_name, @@ -1193,11 +1927,28 @@ mod tests { use super::{ interpret_wsl_discovery_outcome, parse_wsl_list_output, parse_wsl_session_output, - WslDiscoveryCommandOutcome, WslRuntime, WslSession, + RequestDeadline, WslDiscoveryCommandOutcome, WslRuntime, WslSession, }; use crate::environment::types::{EnvironmentRef, EnvironmentRuntimeEvent, EnvironmentStatus}; use crate::error::{AppError, LockConflictTarget}; + #[test] + fn request_deadline_budget_is_shared_across_phases() { + let started = tokio::time::Instant::now(); + let deadline = RequestDeadline::from_start(started, std::time::Duration::from_secs(120)); + + assert_eq!( + deadline + .remaining_at(started + std::time::Duration::from_secs(10)) + .unwrap(), + std::time::Duration::from_secs(110) + ); + assert!(matches!( + deadline.remaining_at(started + std::time::Duration::from_secs(121)), + Err(AppError::WslCommandTimedOut) + )); + } + #[cfg(target_os = "linux")] fn command_output_with_timeout( command: &mut Command, @@ -1814,6 +2565,36 @@ mod tests { ); } + #[tokio::test] + async fn current_worker_closure_invalidates_the_environment() { + let registry = WslRuntime::default(); + registry.insert(sample_session("Ubuntu", "alice")); + let generation = registry.get("Ubuntu").unwrap().runtime_generation; + let (closed_tx, closed_rx) = tokio::sync::watch::channel(false); + registry.monitor_worker("Ubuntu".to_string(), generation, closed_rx); + + closed_tx.send(true).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if registry + .runtime_status("Ubuntu") + .is_some_and(|runtime| runtime.status == EnvironmentStatus::Unavailable) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + assert!(registry.get("Ubuntu").is_none()); + assert!(matches!( + registry.runtime_status("Ubuntu").unwrap().error, + Some(AppError::EnvironmentUnavailable { .. }) + )); + } + #[tokio::test] async fn concurrent_session_failures_share_one_reconnect() { let registry = Arc::new(WslRuntime::default()); @@ -1911,17 +2692,30 @@ mod tests { } #[test] - fn parses_utf16_wsl_list_and_removes_nul_and_blank_lines() { - let text = "Ubuntu-24.04\0\r\nDebian\0\r\n\r\n"; - let bytes = text + fn parses_utf8_and_utf16_verbose_wsl_lists() { + let text = " NAME STATE VERSION\r\n* Ubuntu-24.04 Running 2\r\n Legacy Stopped 1\r\n Imported Distro Stopped 2\r\n\r\n"; + let utf16 = text .encode_utf16() .flat_map(u16::to_le_bytes) .collect::>(); + let expected = vec!["Ubuntu-24.04", "Imported Distro"]; - assert_eq!( - parse_wsl_list_output(&bytes), - vec!["Ubuntu-24.04", "Debian"] - ); + assert_eq!(parse_wsl_list_output(text.as_bytes()), expected); + assert_eq!(parse_wsl_list_output(&utf16), expected); + } + + #[test] + fn connection_requires_a_discovered_wsl2_candidate() { + let candidates = vec!["Ubuntu".to_string(), "Debian".to_string()]; + + super::ensure_wsl2_candidate("ubuntu", &candidates).unwrap(); + assert!(matches!( + super::ensure_wsl2_candidate("Legacy", &candidates), + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { ref distro_name }, + .. + }) if distro_name == "Legacy" + )); } #[test] @@ -1999,18 +2793,27 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn bundled_session_script_reports_the_complete_session_baseline() { + fn bundled_session_script_reports_identity_without_business_tool_probes() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("session fixture"); let bin = temp.path().join("bin"); - let probe_parent = temp.path().join("probe"); std::fs::create_dir_all(&bin).expect("fixture bin"); - std::fs::create_dir_all(&probe_parent).expect("probe parent"); - let flock = bin.join("flock"); - std::fs::write(&flock, "#!/bin/sh\nexit 99\n").expect("failing flock fixture"); - std::fs::set_permissions(&flock, std::fs::Permissions::from_mode(0o755)) - .expect("make flock executable"); + for command in [ + "git", + "timeout", + "xargs", + "sort", + "sha256sum", + "readlink", + "stat", + ] { + let command_path = bin.join(command); + std::fs::write(&command_path, "#!/bin/sh\nexit 99\n") + .expect("failing business tool fixture"); + std::fs::set_permissions(&command_path, std::fs::Permissions::from_mode(0o755)) + .expect("make business tool fixture executable"); + } let path = format!( "{}:{}", bin.display(), @@ -2023,8 +2826,7 @@ mod tests { .arg("--") .arg("session") .env("GROK_HOME", "/opt/grok") - .env("PATH", path) - .env("TMPDIR", &probe_parent), + .env("PATH", path), std::time::Duration::from_secs(10), ) .expect("session script"); @@ -2040,154 +2842,6 @@ mod tests { assert!(!session.user.is_empty()); assert!(session.home.starts_with('/')); assert_eq!(session.environment["GROK_HOME"], "/opt/grok"); - assert!( - std::fs::read_dir(probe_parent) - .expect("probe parent remains readable") - .next() - .is_none(), - "session capability probe must clean up its temporary directory" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn bundled_session_script_rejects_each_missing_baseline_tool() { - use std::os::unix::fs::PermissionsExt; - - for command in [ - "git", - "timeout", - "xargs", - "sort", - "sha256sum", - "readlink", - "stat", - ] { - let temp = tempfile::tempdir().expect("temporary command directory"); - let command_path = temp.path().join(command); - std::fs::write(&command_path, "#!/bin/sh\nexit 1\n").expect("write failing command"); - std::fs::set_permissions(&command_path, std::fs::Permissions::from_mode(0o755)) - .expect("make failing command executable"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").unwrap_or_default() - ); - - let output = command_output_with_timeout( - Command::new("/bin/sh") - .arg("-c") - .arg(include_str!("wsl/scripts/session.sh")) - .arg("--") - .arg("session") - .env("PATH", path), - std::time::Duration::from_secs(10), - ) - .expect("session script"); - - assert!( - !output.status.success(), - "unavailable {command} must reject the WSL session" - ); - assert!( - String::from_utf8_lossy(&output.stderr) - .to_ascii_lowercase() - .contains(command), - "{command} failure returned stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn bundled_session_script_rejects_incompatible_baseline_behavior() { - use std::os::unix::fs::PermissionsExt; - - let incompatible_commands = [ - ( - "timeout", - r#"#!/bin/sh -for argument in "$@"; do - [ "$argument" = "--kill-after=1s" ] && exit 64 -done -exit 0 -"#, - ), - ( - "xargs", - r#"#!/bin/sh -for argument in "$@"; do - [ "$argument" = "-r" ] && exit 64 -done -printf 'a\nb\n' -"#, - ), - ( - "sort", - r#"#!/bin/sh -printf 'a\0b\0' -"#, - ), - ( - "sha256sum", - r#"#!/bin/sh -[ "$#" -eq 0 ] || exit 64 -printf '%s -\n' 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' -"#, - ), - ( - "readlink", - r#"#!/bin/sh -for argument in "$@"; do - [ "$argument" = "--" ] && exit 64 -done -printf '/\n' -"#, - ), - ]; - let mut unexpected_successes = Vec::new(); - - for (command, script) in incompatible_commands { - let temp = tempfile::tempdir().expect("temporary command directory"); - let command_path = temp.path().join(command); - std::fs::write(&command_path, script).expect("write incompatible command"); - std::fs::set_permissions(&command_path, std::fs::Permissions::from_mode(0o755)) - .expect("make incompatible command executable"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").unwrap_or_default() - ); - - let output = command_output_with_timeout( - Command::new("/bin/sh") - .arg("-c") - .arg(include_str!("wsl/scripts/session.sh")) - .arg("--") - .arg("session") - .env("PATH", path), - std::time::Duration::from_secs(10), - ) - .expect("session script"); - - if output.status.success() { - unexpected_successes.push(command); - continue; - } - assert!( - String::from_utf8_lossy(&output.stderr) - .to_ascii_lowercase() - .contains(command), - "{command} incompatibility returned stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - assert!( - unexpected_successes.is_empty(), - "incompatible commands passed the WSL session baseline: {unexpected_successes:?}" - ); } #[test] @@ -2197,4 +2851,33 @@ printf '/\n' assert_eq!(session.xdg_state_home, None); } + + #[cfg(target_os = "windows")] + #[tokio::test] + #[ignore = "requires Windows with a WSL 2 distribution"] + async fn real_wsl2_worker_maps_a_windows_directory_round_trip() { + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let root = tempfile::tempdir().expect("Windows path fixture"); + let directory = root.path().join("Skill Deck 项目"); + std::fs::create_dir(&directory).expect("create Windows path fixture"); + let runtime = WslRuntime::for_wsl_test(); + let workspace = runtime.workspace(&distro).expect("WSL workspace"); + + let mapped = workspace + .map_host_path(directory.to_string_lossy().into_owned(), None) + .await + .expect("map Windows path into WSL"); + assert!(mapped.starts_with('/')); + let round_trip = workspace + .map_path_to_windows(mapped) + .await + .expect("map WSL path into Windows") + .expect("Windows projection"); + + assert_eq!( + std::fs::canonicalize(round_trip).expect("canonical mapped path"), + std::fs::canonicalize(directory).expect("canonical fixture path") + ); + } } diff --git a/src-tauri/src/environment/wsl/operations/acquire.rs b/src-tauri/src/environment/wsl/operations/acquire.rs index 3286604a..cd5ee729 100644 --- a/src-tauri/src/environment/wsl/operations/acquire.rs +++ b/src-tauri/src/environment/wsl/operations/acquire.rs @@ -1,7 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::time::Duration; use sha2::{Digest, Sha256}; -use tokio::time::Duration; use crate::application::payload_session::{ BackendAcquiredPayload, PayloadCleanupReport, PayloadCleanupWarning, PayloadCleanupWarningCode, @@ -10,73 +11,34 @@ use crate::application::payload_session::{ }; use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::{ - verify_skill_payload_integrity, verify_skill_payload_manifest, PayloadEntry, PayloadEntryKind, - SkillPayload, SkillPayloadManifest, + verify_skill_payload_integrity, PayloadEntry, PayloadEntryKind, SkillPayload, + SkillPayloadManifest, }; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, +use crate::environment::wsl::operations::source_acquisition::{ + WorkerSourceHandle, WslNativeSource, }; -use crate::environment::wsl::{WslSession, WslWorkspace}; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "1"; -#[cfg(all(test, target_os = "linux"))] -const OWNER_FILE: &str = ".skill-deck-owner"; -#[cfg(all(test, target_os = "linux"))] -const MANIFEST_FILE: &str = "manifest.json"; -#[cfg(all(test, target_os = "linux"))] -const BLOB_LIST_FILE: &str = "blob-list"; const MAX_MANIFEST_BYTES: usize = 8 * 1024 * 1024; -const MAX_BRIDGE_BLOB_BYTES: usize = 256 * 1024 * 1024; +const MAX_BLOB_BYTES: usize = 256 * 1024 * 1024; -const ACQUIRE_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const STORE_BEGIN_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const STORE_BLOB_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const STORE_FINALIZE_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const FINALIZE_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const VERIFY_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const READ_BLOB_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const REMOVE_PAYLOAD_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const REMOVE_SESSION_SCRIPT: &str = include_str!("../scripts/acquire.sh"); +#[derive(Debug, Clone)] +struct SourceBinding { + handle: WorkerSourceHandle, + root: String, +} -const SWEEP_ORPHANS_SCRIPT: &str = include_str!("../scripts/acquire.sh"); -const SOURCE_FINGERPRINT_SCRIPT: &str = include_str!("../scripts/acquire.sh"); -const SOURCE_REVISION_SCRIPT: &str = include_str!("../scripts/acquire.sh"); -const ACQUIRE_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "acquire", ACQUIRE_SCRIPT); -const STORE_BEGIN_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "store-begin", STORE_BEGIN_SCRIPT); -const STORE_BLOB_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "store-blob", STORE_BLOB_SCRIPT); -const STORE_FINALIZE_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "store-finalize", STORE_FINALIZE_SCRIPT); -const FINALIZE_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "finalize", FINALIZE_SCRIPT); -const VERIFY_OPERATION: WslOperationDescriptor = wsl_operation("payload", "verify", VERIFY_SCRIPT); -const READ_BLOB_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "read-blob", READ_BLOB_SCRIPT); -const REMOVE_PAYLOAD_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "remove-payload", REMOVE_PAYLOAD_SCRIPT); -const REMOVE_SESSION_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "remove-session", REMOVE_SESSION_SCRIPT); -const SWEEP_ORPHANS_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "sweep-orphans", SWEEP_ORPHANS_SCRIPT); -const SOURCE_FINGERPRINT_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "fingerprint", SOURCE_FINGERPRINT_SCRIPT); -const SOURCE_REVISION_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "source-revision", SOURCE_REVISION_SCRIPT); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WorkerPayloadHandle { + generation: u64, + id: u64, +} pub struct WslPayloadSessionStorage { workspace: WslWorkspace, + source: Option, + handles: Mutex>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -88,64 +50,194 @@ pub struct WslAcquiredPayload { impl WslPayloadSessionStorage { pub fn new(workspace: WslWorkspace) -> Self { - Self { workspace } + Self { + workspace, + source: None, + handles: Mutex::new(HashMap::new()), + } + } + + pub(crate) fn for_source(workspace: WslWorkspace, source: &WslNativeSource) -> Self { + Self { + workspace, + source: Some(SourceBinding { + handle: source.handle(), + root: source.native_root().to_string(), + }), + handles: Mutex::new(HashMap::new()), + } } - fn managed_paths(&self, key: &PayloadStorageKey) -> Result<(String, String), AppError> { - let session_root = managed_session_root(key.session_id())?; - let payload_root = format!("{session_root}/payload-{}", digest(key.skill_path())); - Ok((session_root, payload_root)) + fn payload_name(&self, key: &PayloadStorageKey) -> String { + format!("payload-{}", digest(key.skill_path())) } - async fn run( - &self, - operation: &WslOperationDescriptor, - args: Vec, - stdout_limit: usize, - ) -> Result, AppError> { - self.run_with( - operation, - args, - Vec::new(), - Duration::from_secs(30), - stdout_limit, - None, - ) - .await + fn remember_handle(&self, key: &PayloadStorageKey, generation: u64, id: u64) { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .insert(key.clone(), WorkerPayloadHandle { generation, id }); + } + + fn forget_key(&self, key: &PayloadStorageKey) { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .remove(key); + } + + fn handle(&self, key: &PayloadStorageKey) -> Result { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .get(key) + .copied() + .ok_or(AppError::StalePayload) + } + + async fn remove_key_best_effort(&self, key: &PayloadStorageKey) { + let _ = self + .workspace + .request_worker_control_once( + environment_protocol::Message::RemovePayload { + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + }, + None, + Duration::from_secs(10), + ) + .await; + self.forget_key(key); } - async fn run_with( + async fn store_in_worker( &self, - operation: &WslOperationDescriptor, - args: Vec, - stdin: Vec, - timeout: Duration, - stdout_limit: usize, - cancellation: Option, - ) -> Result, AppError> { - self.workspace - .with_session_retry(move |session| { - let args = args.clone(); - let stdin = stdin.clone(); - let cancellation = cancellation.clone(); - async move { - WslOperationExecutor::execute( - operation, - WslOperationRequest { - session, - args, - stdin, - timeout, - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, + key: &PayloadStorageKey, + payload: SkillPayload, + ) -> Result { + verify_skill_payload_integrity(&payload)?; + let manifest = payload.manifest(); + let payload_name = self.payload_name(key); + let (generation, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::BeginPayloadUpload { + session_id: key.session_id().to_string(), + payload_name: payload_name.clone(), + }, + None, + Duration::from_secs(10), + ) + .await?; + let upload_id = match response { + environment_protocol::Message::PayloadUploadBegun { upload_id } => upload_id, + message => return Err(worker_response_error(message, "PayloadUploadBegun")), + }; + let result = async { + for (blob_id, blob) in &payload.blobs { + if blob.len() > MAX_BLOB_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslPayloadBlobSize".to_string(), + path: None, + }); + } + let digest = format!("sha256:{blob_id}"); + let response = self + .workspace + .request_worker_control_for_generation( + generation, + environment_protocol::Message::UploadPayloadBlob { + upload_id, + blob_id: blob_id.clone(), + total_bytes: blob.len() as u64, + sha256: digest, }, + None, + Duration::from_secs(10), + ) + .await?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => return Err(worker_response_error(message, "TransferReady")), + }; + let response = self + .workspace + .send_worker_transfer_for_generation( + generation, + transfer_id, + blob, + MAX_BLOB_BYTES, + Duration::from_secs(60), ) - .await - .map(|output| output.stdout) + .await?; + match response { + environment_protocol::Message::PayloadBlobUploaded { + upload_id: actual_upload, + blob_id: actual_blob, + } if actual_upload == upload_id && actual_blob == *blob_id => {} + message => { + return Err(worker_response_error(message, "PayloadBlobUploaded")); + } } + } + let manifest_bytes = serde_json::to_vec(&manifest)?; + if manifest_bytes.is_empty() || manifest_bytes.len() > MAX_MANIFEST_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslPayloadManifestSize".to_string(), + path: None, + }); + } + let manifest_sha = format!("sha256:{:x}", Sha256::digest(&manifest_bytes)); + let response = self + .workspace + .request_worker_control_for_generation( + generation, + environment_protocol::Message::FinalizePayloadUpload { + upload_id, + total_bytes: manifest_bytes.len() as u64, + sha256: manifest_sha, + }, + None, + Duration::from_secs(10), + ) + .await?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => return Err(worker_response_error(message, "TransferReady")), + }; + let response = self + .workspace + .send_worker_transfer_for_generation( + generation, + transfer_id, + &manifest_bytes, + MAX_MANIFEST_BYTES, + Duration::from_secs(30), + ) + .await?; + let payload_id = match response { + environment_protocol::Message::PayloadUploadFinalized { payload_id, .. } => { + payload_id + } + message => return Err(worker_response_error(message, "PayloadUploadFinalized")), + }; + self.remember_handle(key, generation, payload_id); + Ok(payload.blobs.values().map(|blob| blob.len() as u64).sum()) + } + .await; + if result.is_err() { + self.remove_key_best_effort(key).await; + } + result + } + + fn source_binding(&self) -> Result<&SourceBinding, AppError> { + self.source + .as_ref() + .ok_or_else(|| AppError::CapabilityUnavailable { + capability: "wslSourceHandle".to_string(), + path: None, }) - .await } pub async fn acquire_from_path( @@ -154,105 +246,195 @@ impl WslPayloadSessionStorage { source_root: &str, cancellation: Option, ) -> Result { + if let Some(source) = &self.source { + return self + .acquire_from_bound_source(key, source, source_root, cancellation) + .await; + } if !source_root.starts_with('/') { return Err(AppError::UnsafePath { path: source_root.to_string(), reason: "WSL payload source must be an absolute POSIX path".to_string(), }); } - let (session_root, payload_root) = self.managed_paths(key)?; - let base_args = vec![ - source_root.to_string(), - session_root.clone(), - payload_root.clone(), - key.session_id().to_string(), - ]; - let response = self - .run_with( - &ACQUIRE_OPERATION, - base_args, - Vec::new(), - Duration::from_secs(60), - MAX_MANIFEST_BYTES, + let (generation, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::OpenLocalSource { + request: environment_protocol::OpenLocalSourceRequest { + path: source_root.to_string(), + }, + }, cancellation.clone(), + Duration::from_secs(10), ) .await?; - let acquired = match parse_acquire_response(&response) { - Ok(acquired) => acquired, - Err(error) => { - let _ = self - .run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, payload_root, key.session_id().to_string()], - 0, - ) - .await; - return Err(error); - } + let (source_id, root) = match response { + environment_protocol::Message::SourceOpened { + source_id, root, .. + } => (source_id, root), + message => return Err(worker_response_error(message, "SourceOpened")), }; - let finalize_args = vec![ - session_root.clone(), - payload_root.clone(), - key.session_id().to_string(), - ]; - let finalize = self - .run_with( - &FINALIZE_OPERATION, - finalize_args, - finalize_request(&acquired.manifest)?, - Duration::from_secs(30), - 32, - cancellation, + let source = SourceBinding { + handle: WorkerSourceHandle { + generation, + id: source_id, + }, + root, + }; + let result = self + .acquire_from_bound_source(key, &source, source_root, cancellation) + .await; + let _ = self + .workspace + .request_worker_control_for_generation( + generation, + environment_protocol::Message::ReleaseSource { source_id }, + None, + Duration::from_secs(10), ) - .await - .and_then(|response| parse_finalize_response(&response)); - if let Err(error) = finalize { - let _ = self - .run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, payload_root, key.session_id().to_string()], - 0, - ) - .await; - return Err(error); - } - Ok(acquired) + .await; + result } - pub(crate) async fn source_metadata_fingerprint_in_active_session( + async fn acquire_from_bound_source( &self, - session: &WslSession, + key: &PayloadStorageKey, + source: &SourceBinding, source_root: &str, - ) -> Result { - if !source_root.starts_with('/') { - return Err(AppError::UnsafePath { - path: source_root.to_string(), - reason: "WSL payload source must be an absolute POSIX path".to_string(), - }); - } - let output = WslOperationExecutor::execute( - &SOURCE_FINGERPRINT_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![source_root.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(30), - stdout_limit: 128, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - parse_source_fingerprint(&output.stdout) + cancellation: Option, + ) -> Result { + let relative_path = relative_source_path(&source.root, source_root)?; + let response: environment_protocol::PayloadReadyResponse = self + .workspace + .request_worker_payload_for_generation( + source.handle.generation, + environment_protocol::Message::AcquirePayloadFromSource { + request: environment_protocol::AcquirePayloadFromSourceRequest { + source_id: source.handle.id, + relative_path: relative_path.into_bytes(), + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + deadline_millis: 60_000, + }, + }, + MAX_MANIFEST_BYTES, + cancellation, + Duration::from_secs(65), + ) + .await?; + self.remember_handle(key, source.handle.generation, response.payload_id); + Ok(WslAcquiredPayload { + manifest: map_manifest(response.manifest)?, + total_bytes: response.total_bytes, + computed_hash: response.computed_hash.ok_or_else(|| { + AppError::ConfigurationCorrupted { + message: "WSL Worker source payload omitted its CLI hash".to_string(), + } + })?, + }) + } +} + +impl crate::application::payload_session::RetainedSourceCleanup for WslPayloadSessionStorage { + fn remove(&self) -> PayloadStorageFuture<'_, Result<(), AppError>> { + Box::pin(async move { + let source = self.source_binding()?; + let response = self + .workspace + .request_worker_control_for_generation( + source.handle.generation, + environment_protocol::Message::ReleaseSource { + source_id: source.handle.id, + }, + None, + Duration::from_secs(35), + ) + .await?; + match response { + environment_protocol::Message::SourceReleased { source_id } + if source_id == source.handle.id => + { + Ok(()) + } + message => Err(worker_response_error(message, "SourceReleased")), + } + }) } } impl PayloadSessionStorage for WslPayloadSessionStorage { + fn read_source_skill_md<'a>( + &'a self, + source_root: &'a str, + cancellation: CancellationSignal, + ) -> PayloadStorageFuture<'a, Result, AppError>> { + Box::pin(async move { + let source = self.source_binding()?; + let relative = relative_source_path(&source.root, source_root)?; + let response: environment_protocol::SourceScanResponse = self + .workspace + .request_worker_payload_for_generation( + source.handle.generation, + environment_protocol::Message::ScanSource { + request: environment_protocol::SourceScanRequest { + source_id: source.handle.id, + roots: vec![environment_protocol::SourceScanRoot { + relative_path: relative.into_bytes(), + stat_only: false, + }], + mode: environment_protocol::SourceScanMode::SkillMetadata, + per_file_limit: 256 * 1024, + aggregate_limit: 1024 * 1024, + deadline_millis: 30_000, + }, + }, + 3 * 1024 * 1024, + Some(cancellation), + Duration::from_secs(35), + ) + .await?; + if response.entries.iter().any(|entry| { + entry.relative_path.is_empty() + && (entry.error_code.is_some() + || !matches!( + entry.kind, + environment_protocol::SourceEntryKind::Directory + | environment_protocol::SourceEntryKind::Missing + )) + }) { + return Err(AppError::InvalidSkillMd { + message: "saved Skill directory is unavailable".into(), + }); + } + let entry = response + .entries + .into_iter() + .find(|entry| entry.relative_path.eq_ignore_ascii_case(b"SKILL.md")) + .ok_or_else(|| AppError::PathNotFound { + path: format!("{source_root}/SKILL.md"), + })?; + if entry.truncated + || entry.error_code.is_some() + || !matches!( + entry.kind, + environment_protocol::SourceEntryKind::File + | environment_protocol::SourceEntryKind::Symlink + ) + { + return Err(AppError::InvalidSkillMd { + message: "saved Skill metadata could not be read completely".into(), + }); + } + Ok(entry.content_bytes) + }) + } fn local_source(&self, key: &PayloadStorageKey) -> Result { - let (_, payload_root) = self.managed_paths(key)?; + let handle = self.handle(key)?; Ok(PayloadLocalSource::WslManaged { distro_name: self.workspace.distro_name().to_string(), - payload_root, + worker_generation: handle.generation, + worker_payload_id: handle.id, }) } @@ -261,82 +443,43 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { key: &'a PayloadStorageKey, payload: SkillPayload, ) -> PayloadStorageFuture<'a, Result> { + Box::pin(async move { self.store_in_worker(key, payload).await }) + } + + fn acquire_from_source_path<'a>( + &'a self, + key: &'a PayloadStorageKey, + source_root: &'a str, + cancellation: Option, + ) -> PayloadStorageFuture<'a, Result> { Box::pin(async move { - verify_skill_payload_integrity(&payload)?; - let manifest = payload.manifest(); - let total_bytes = payload.blobs.values().map(|blob| blob.len() as u64).sum(); - let (session_root, payload_root) = self.managed_paths(key)?; - let session_id = key.session_id().to_string(); - let stage_root = format!("{payload_root}.upload"); - let store_result = async { - let response = self - .run_with( - &STORE_BEGIN_OPERATION, - vec![ - session_root.clone(), - payload_root.clone(), - session_id.clone(), - ], - Vec::new(), - Duration::from_secs(10), - 32, - None, - ) - .await?; - parse_finalize_response(&response)?; - for (blob_id, blob) in &payload.blobs { - let response = self - .run_with( - &STORE_BLOB_OPERATION, - vec![ - session_root.clone(), - payload_root.clone(), - session_id.clone(), - blob_id.clone(), - ], - blob.clone(), - Duration::from_secs(60), - 32, - None, - ) - .await?; - parse_finalize_response(&response)?; - } - let request = finalize_request(&manifest)?; - if request.len() > MAX_MANIFEST_BYTES { - return Err(AppError::CapabilityUnavailable { - capability: "wslPayloadManifestSize".to_string(), - path: None, - }); - } - let response = self - .run_with( - &STORE_FINALIZE_OPERATION, - vec![ - session_root.clone(), - payload_root.clone(), - session_id.clone(), - ], - request, - Duration::from_secs(30), - 32, - None, - ) + let source = self.source_binding()?; + let response = self + .acquire_from_bound_source(key, source, source_root, cancellation) + .await?; + Ok(BackendAcquiredPayload { + manifest: response.manifest, + total_bytes: response.total_bytes, + computed_hash: response.computed_hash, + }) + }) + } + + fn acquire_from_path<'a>( + &'a self, + key: &'a PayloadStorageKey, + source_root: &'a str, + cancellation: Option, + ) -> PayloadStorageFuture<'a, Result> { + Box::pin(async move { + let response = + WslPayloadSessionStorage::acquire_from_path(self, key, source_root, cancellation) .await?; - parse_finalize_response(&response)?; - Ok(total_bytes) - } - .await; - if store_result.is_err() { - let _ = self - .run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, stage_root, session_id], - 0, - ) - .await; - } - store_result + Ok(BackendAcquiredPayload { + manifest: response.manifest, + total_bytes: response.total_bytes, + computed_hash: response.computed_hash, + }) }) } @@ -345,20 +488,27 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { source_root: &'a str, ) -> PayloadStorageFuture<'a, Result> { Box::pin(async move { - if !source_root.starts_with('/') { - return Err(AppError::UnsafePath { - path: source_root.to_string(), - reason: "WSL payload source must be an absolute POSIX path".to_string(), - }); - } + let source = self.source_binding()?; + let relative_path = relative_source_path(&source.root, source_root)?; let response = self - .run( - &SOURCE_FINGERPRINT_OPERATION, - vec![source_root.to_string()], - 128, + .workspace + .request_worker_control_for_generation( + source.handle.generation, + environment_protocol::Message::SourceFingerprint { + source_id: source.handle.id, + relative_path: relative_path.into_bytes(), + deadline_millis: 30_000, + }, + None, + Duration::from_secs(35), ) .await?; - parse_source_fingerprint(&response) + match response { + environment_protocol::Message::SourceFingerprintResult { fingerprint } => { + Ok(fingerprint) + } + message => Err(worker_response_error(message, "SourceFingerprintResult")), + } }) } @@ -368,39 +518,29 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { skill_path: &'a str, ) -> PayloadStorageFuture<'a, Result, AppError>> { Box::pin(async move { - if !repository_root.starts_with('/') { - return Err(AppError::UnsafePath { - path: repository_root.to_string(), - reason: "WSL Git source root must be an absolute POSIX path".to_string(), - }); + let source = self.source_binding()?; + if repository_root != source.root { + return Err(AppError::StalePayload); } - let skill_directory = crate::core::skill_paths::normalize_skill_folder_path(skill_path); let response = self - .run( - &SOURCE_REVISION_OPERATION, - vec![repository_root.to_string(), skill_directory], - 128, + .workspace + .request_worker_control_for_generation( + source.handle.generation, + environment_protocol::Message::SourceRevision { + source_id: source.handle.id, + relative_path: normalize_skill_revision_path(skill_path)?.into_bytes(), + deadline_millis: 30_000, + }, + None, + Duration::from_secs(35), ) .await?; - parse_source_revision(&response).map(Some) - }) - } - - fn acquire_from_source_path<'a>( - &'a self, - key: &'a PayloadStorageKey, - source_root: &'a str, - cancellation: Option, - ) -> PayloadStorageFuture<'a, Result> { - Box::pin(async move { - let acquired = self - .acquire_from_path(key, source_root, cancellation) - .await?; - Ok(BackendAcquiredPayload { - manifest: acquired.manifest, - total_bytes: acquired.total_bytes, - computed_hash: acquired.computed_hash, - }) + match response { + environment_protocol::Message::SourceRevisionResult { revision } => { + Ok(Some(revision)) + } + message => Err(worker_response_error(message, "SourceRevisionResult")), + } }) } @@ -409,40 +549,27 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { key: &'a PayloadStorageKey, ) -> PayloadStorageFuture<'a, Result, AppError>> { Box::pin(async move { - let (session_root, payload_root) = self.managed_paths(key)?; - let base_args = vec![ - session_root.clone(), - payload_root.clone(), - key.session_id().to_string(), - ]; - let response = self - .run(&VERIFY_OPERATION, base_args.clone(), MAX_MANIFEST_BYTES) - .await; - let manifest = match response { - Ok(response) => parse_manifest_response(&response)?, - Err(AppError::WslCommandFailed { - exit_code: Some(69..=72), - .. - }) => return Ok(None), - Err(error) => return Err(error), + let (generation, response): (u64, Option) = + self.workspace + .request_worker_payload_once( + environment_protocol::Message::VerifyPayload { + request: environment_protocol::VerifyPayloadRequest { + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + deadline_millis: 30_000, + }, + }, + MAX_MANIFEST_BYTES, + None, + Duration::from_secs(35), + ) + .await?; + let Some(response) = response else { + self.forget_key(key); + return Ok(None); }; - let mut exact_args = base_args; - exact_args.push("--expected".to_string()); - let exact_response = self - .run_with( - &VERIFY_OPERATION, - exact_args, - expected_blob_list(&manifest), - Duration::from_secs(30), - MAX_MANIFEST_BYTES, - None, - ) - .await?; - let exact_manifest = parse_manifest_response(&exact_response)?; - if exact_manifest != manifest { - return Err(AppError::StalePayload); - } - Ok(Some(manifest)) + self.remember_handle(key, generation, response.payload_id); + map_manifest(response.manifest).map(Some) }) } @@ -455,25 +582,27 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { if !valid_blob_id(blob_id) { return Err(AppError::StalePayload); } - let (session_root, payload_root) = self.managed_paths(key)?; - let response = self - .run( - &READ_BLOB_OPERATION, - vec![ - session_root, - payload_root, - key.session_id().to_string(), - blob_id.to_string(), - ], - MAX_BRIDGE_BLOB_BYTES, + let handle = self.handle(key)?; + let result = self + .workspace + .request_worker_bytes_for_generation( + handle.generation, + environment_protocol::Message::ReadPayloadBlob { + payload_id: handle.id, + blob_id: blob_id.to_string(), + deadline_millis: 60_000, + }, + MAX_BLOB_BYTES, + Duration::from_secs(65), ) .await; - match response { - Ok(response) => Ok(Some(parse_blob_response(&response)?)), - Err(AppError::WslCommandFailed { - exit_code: Some(63 | 71 | 72), - .. - }) => Ok(None), + match result { + Ok(blob) => Ok(Some(blob)), + Err(AppError::ExecutionFailed { message }) + if message.contains("missingPayload") => + { + Ok(None) + } Err(error) => Err(error), } }) @@ -484,14 +613,24 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { key: &'a PayloadStorageKey, ) -> PayloadStorageFuture<'a, Result<(), AppError>> { Box::pin(async move { - let (session_root, payload_root) = self.managed_paths(key)?; - self.run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, payload_root, key.session_id().to_string()], - 0, - ) - .await?; - Ok(()) + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::RemovePayload { + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + }, + None, + Duration::from_secs(10), + ) + .await?; + match response { + environment_protocol::Message::PayloadRemoved { .. } => { + self.forget_key(key); + Ok(()) + } + message => Err(worker_response_error(message, "PayloadRemoved")), + } }) } @@ -500,14 +639,26 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { session_id: &'a str, ) -> PayloadStorageFuture<'a, Result<(), AppError>> { Box::pin(async move { - let session_root = managed_session_root(session_id)?; - self.run( - &REMOVE_SESSION_OPERATION, - vec![session_root, session_id.to_string()], - 0, - ) - .await?; - Ok(()) + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::RemovePayloadSession { + session_id: session_id.to_string(), + }, + None, + Duration::from_secs(10), + ) + .await?; + match response { + environment_protocol::Message::PayloadSessionRemoved { .. } => { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .retain(|key, _| key.session_id() != session_id); + Ok(()) + } + message => Err(worker_response_error(message, "PayloadSessionRemoved")), + } }) } } @@ -520,896 +671,160 @@ impl PayloadSessionMaintenance for WslPayloadSessionStorage { Box::pin(async move { let mut protected = protected_session_ids.iter().cloned().collect::>(); protected.sort(); - let mut args = Vec::with_capacity(protected.len() + 1); - args.push("/tmp".to_string()); - args.extend(protected); - let response = self - .run(&SWEEP_ORPHANS_OPERATION, args, 1024 * 1024) + let (_, response): (u64, environment_protocol::PayloadCleanupResponse) = self + .workspace + .request_worker_payload_once( + environment_protocol::Message::SweepPayloadOrphans { + protected_session_ids: protected, + }, + 1024 * 1024, + None, + Duration::from_secs(35), + ) .await?; - parse_cleanup_report(&response) + Ok(PayloadCleanupReport { + removed_sessions: response.removed_sessions as usize, + protected_sessions: response.protected_sessions as usize, + external_retained_bytes: response.retained_external_bytes, + capacity_blocked: response.cleanup_blocked, + warnings: response + .warnings + .into_iter() + .map(|warning| { + Ok::<_, AppError>(PayloadCleanupWarning { + code: cleanup_warning_code(&warning.code)?, + candidate_name: warning.candidate_name, + technical_details: warning.technical_details, + }) + }) + .collect::>()?, + }) }) } } -fn managed_session_root(session_id: &str) -> Result { - if session_id.is_empty() - || session_id.len() > 128 - || !session_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +fn map_manifest( + manifest: environment_protocol::PayloadManifest, +) -> Result { + let expected_root_hash = manifest.payload_root_hash.clone(); + let expected_payload_id = manifest.payload_id.clone(); + let entries = manifest + .entries + .into_iter() + .map(|entry| PayloadEntry { + relative_path: entry.relative_path, + kind: match entry.kind { + environment_protocol::PayloadEntryKind::File => PayloadEntryKind::File, + environment_protocol::PayloadEntryKind::Directory => PayloadEntryKind::Directory, + }, + blob_id: entry.blob_id, + content_hash: entry.content_hash, + size: entry.size, + executable: entry.executable, + }) + .collect(); + let mapped = SkillPayloadManifest::from_entries(entries)?; + if mapped.payload_id().as_str() != expected_payload_id + || mapped.payload_root_hash != expected_root_hash { return Err(AppError::StalePayload); } - Ok(format!("/tmp/skill-deck-source-{session_id}")) -} - -fn digest(value: &str) -> String { - format!("{:x}", Sha256::digest(value.as_bytes())) + Ok(mapped) } -fn valid_blob_id(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) -} - -fn parse_manifest_response(bytes: &[u8]) -> Result { - let manifest = serde_json::from_slice(bytes)?; - verify_skill_payload_manifest(&manifest)?; - Ok(manifest) -} - -fn parse_acquire_response(bytes: &[u8]) -> Result { - let mut cursor = 0; - if take_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error("unsupported WSL acquire protocol version")); +fn relative_source_path(source_root: &str, requested: &str) -> Result { + if requested == source_root { + return Ok(String::new()); } - if take_text_field(bytes, &mut cursor)? != "H" { - return Err(protocol_error("missing WSL acquire CLI hash record")); - } - let computed_hash = take_text_field(bytes, &mut cursor)?.to_string(); - if !valid_blob_id(&computed_hash) { - return Err(protocol_error("invalid WSL acquire CLI hash")); - } - let mut entries = Vec::new(); - let mut blob_sizes = BTreeMap::new(); - while cursor < bytes.len() { - if take_text_field(bytes, &mut cursor)? != "E" { - return Err(protocol_error("invalid WSL acquire record tag")); - } - let kind = take_text_field(bytes, &mut cursor)?; - let relative_path = take_text_field(bytes, &mut cursor)?.to_string(); - let blob_id = take_text_field(bytes, &mut cursor)?; - let size = parse_text_field::(bytes, &mut cursor, "entry size")?; - let executable = match take_text_field(bytes, &mut cursor)? { - "0" => false, - "1" => true, - _ => return Err(protocol_error("invalid WSL executable flag")), - }; - let entry = match kind { - "directory" if blob_id.is_empty() && size == 0 && !executable => PayloadEntry { - relative_path, - kind: PayloadEntryKind::Directory, - blob_id: None, - content_hash: None, - size: 0, - executable: false, - }, - "file" if valid_blob_id(blob_id) => { - match blob_sizes.insert(blob_id.to_string(), size) { - Some(previous) if previous != size => { - return Err(protocol_error("conflicting WSL blob sizes")); - } - _ => {} - } - PayloadEntry { - relative_path, - kind: PayloadEntryKind::File, - blob_id: Some(blob_id.to_string()), - content_hash: Some(blob_id.to_string()), - size, - executable, - } - } - _ => return Err(protocol_error("invalid WSL acquire entry")), - }; - entries.push(entry); - } - let manifest = SkillPayloadManifest::from_entries(entries)?; - Ok(WslAcquiredPayload { - manifest, - total_bytes: blob_sizes.values().copied().sum(), - computed_hash, - }) + requested + .strip_prefix(source_root) + .and_then(|relative| relative.strip_prefix('/')) + .map(normalize_relative_path) + .transpose()? + .ok_or(AppError::StalePayload) } -fn parse_source_fingerprint(bytes: &[u8]) -> Result { - let mut cursor = 0; - if take_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error( - "unsupported WSL source fingerprint protocol version", - )); - } - let fingerprint = take_text_field(bytes, &mut cursor)?.to_string(); - if cursor != bytes.len() || !valid_blob_id(&fingerprint) { - return Err(protocol_error("invalid WSL source metadata fingerprint")); - } - Ok(fingerprint) +fn normalize_skill_revision_path(path: &str) -> Result { + let path = crate::core::skill_paths::normalize_skill_folder_path(path); + normalize_relative_path(&path) } -fn parse_source_revision(bytes: &[u8]) -> Result { - let mut cursor = 0; - if take_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error( - "unsupported WSL source revision protocol version", - )); +fn normalize_relative_path(path: &str) -> Result { + if path.is_empty() { + return Ok(String::new()); } - let revision = take_text_field(bytes, &mut cursor)?.to_string(); - if cursor != bytes.len() - || !matches!(revision.len(), 40 | 64) - || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + if path.starts_with('/') + || path.contains('\\') + || path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) { - return Err(protocol_error("invalid WSL source revision")); - } - Ok(revision.to_ascii_lowercase()) -} - -fn parse_finalize_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0") - .then_some(()) - .ok_or_else(|| protocol_error("invalid WSL acquire finalize response")) -} - -fn expected_blob_ids(manifest: &SkillPayloadManifest) -> BTreeSet<&str> { - manifest - .entries - .iter() - .filter_map(|entry| entry.blob_id.as_deref()) - .collect() -} - -fn expected_blob_list(manifest: &SkillPayloadManifest) -> Vec { - let mut list = expected_blob_ids(manifest) - .into_iter() - .collect::>() - .join("\n") - .into_bytes(); - if !list.is_empty() { - list.push(b'\n'); + return Err(AppError::StalePayload); } - list + Ok(path.to_string()) } -fn finalize_request(manifest: &SkillPayloadManifest) -> Result, AppError> { - let ids = expected_blob_ids(manifest); - let mut request = format!("{}\n", ids.len()).into_bytes(); - for id in ids { - request.extend_from_slice(id.as_bytes()); - request.push(b'\n'); +fn worker_response_error(message: environment_protocol::Message, expected: &str) -> AppError { + match message { + environment_protocol::Message::Error { code, phase, .. } => AppError::ExecutionFailed { + message: format!("WSL Worker request failed during {phase}: {code}"), + }, + _ => AppError::ConfigurationCorrupted { + message: format!("WSL Worker returned an invalid {expected} response"), + }, } - request.extend(serde_json::to_vec(manifest)?); - Ok(request) -} - -fn take_text_field<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a str, AppError> { - let remaining = bytes - .get(*cursor..) - .ok_or_else(|| protocol_error("WSL acquire cursor is out of range"))?; - let length = remaining - .iter() - .position(|byte| *byte == 0) - .ok_or_else(|| protocol_error("WSL acquire field terminator is missing"))?; - let field = std::str::from_utf8(&remaining[..length]) - .map_err(|_| protocol_error("WSL acquire field is not UTF-8"))?; - *cursor += length + 1; - Ok(field) -} - -fn parse_text_field(bytes: &[u8], cursor: &mut usize, field: &str) -> Result -where - T: std::str::FromStr, -{ - take_text_field(bytes, cursor)? - .parse() - .map_err(|_| protocol_error(&format!("invalid WSL acquire {field}"))) } -fn protocol_error(message: &str) -> AppError { - AppError::ConfigurationCorrupted { - message: message.to_string(), +fn cleanup_warning_code(code: &str) -> Result { + match code { + "unknownEntry" => Ok(PayloadCleanupWarningCode::UnknownEntry), + "invalidMarker" => Ok(PayloadCleanupWarningCode::InvalidMarker), + "futureMarkerVersion" => Ok(PayloadCleanupWarningCode::FutureMarkerVersion), + "boundaryRejected" => Ok(PayloadCleanupWarningCode::BoundaryRejected), + "deleteFailed" => Ok(PayloadCleanupWarningCode::DeleteFailed), + "sizeUnavailable" => Ok(PayloadCleanupWarningCode::SizeUnavailable), + _ => Err(AppError::ConfigurationCorrupted { + message: format!("unknown WSL payload cleanup warning code: {code}"), + }), } } -fn parse_blob_response(bytes: &[u8]) -> Result, AppError> { - Ok(bytes.to_vec()) +fn digest(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) } -fn parse_cleanup_report(bytes: &[u8]) -> Result { - let records = bytes - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()) - .map(|record| String::from_utf8_lossy(record).into_owned()) - .collect::>(); - if records.first().map(String::as_str) != Some(PROTOCOL_VERSION) { - return Err(protocol_error("invalid payload cleanup protocol version")); - } - let mut cursor = 1; - let mut report = PayloadCleanupReport::default(); - while cursor < records.len() { - match records[cursor].as_str() { - "W" if cursor + 3 < records.len() => { - let code = match records[cursor + 1].as_str() { - "unknownEntry" => PayloadCleanupWarningCode::UnknownEntry, - "invalidMarker" => PayloadCleanupWarningCode::InvalidMarker, - "futureMarkerVersion" => PayloadCleanupWarningCode::FutureMarkerVersion, - "boundaryRejected" => PayloadCleanupWarningCode::BoundaryRejected, - "deleteFailed" => PayloadCleanupWarningCode::DeleteFailed, - "sizeUnavailable" => PayloadCleanupWarningCode::SizeUnavailable, - _ => return Err(protocol_error("unknown payload cleanup warning code")), - }; - report.warnings.push(PayloadCleanupWarning { - code, - candidate_name: Some(records[cursor + 2].clone()), - technical_details: (records[cursor + 3] != "-") - .then(|| records[cursor + 3].clone()), - }); - cursor += 4; - } - "S" if cursor + 4 < records.len() => { - report.removed_sessions = records[cursor + 1] - .parse() - .map_err(|_| protocol_error("invalid removed session count"))?; - report.protected_sessions = records[cursor + 2] - .parse() - .map_err(|_| protocol_error("invalid protected session count"))?; - report.external_retained_bytes = records[cursor + 3] - .parse() - .map_err(|_| protocol_error("invalid external retained bytes"))?; - report.capacity_blocked = match records[cursor + 4].as_str() { - "0" => false, - "1" => true, - _ => return Err(protocol_error("invalid payload capacity state")), - }; - cursor += 5; - } - _ => return Err(protocol_error("malformed payload cleanup response")), - } - } - Ok(report) +fn valid_blob_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "获取协议测试需要直接调用真实 Git 并运行 shell 测试脚本" -)] +#[cfg(test)] mod tests { - use std::collections::BTreeMap; - use std::collections::BTreeSet; - use std::fs; - use std::io::Write; - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::process::{Command, Stdio}; - - use tempfile::tempdir; - - use super::*; - use crate::application::payload_session::PayloadLocalSource; - use crate::core::skill_payload::{ - build_skill_payload, compute_cli_project_hash_from_payload, SkillPayloadManifest, - }; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - fn storage() -> WslPayloadSessionStorage { - let runtime = crate::environment::wsl::WslRuntime::default(); - runtime.insert(session()); - WslPayloadSessionStorage::new(runtime.workspace("Ubuntu").expect("enabled workspace")) - } - - #[test] - fn local_source_is_an_opaque_backend_owned_wsl_path() { - let storage = storage(); - let key = PayloadStorageKey::new("session-1", "skills/demo"); - assert_eq!( - storage.local_source(&key).expect("local source"), - PayloadLocalSource::WslManaged { - distro_name: "Ubuntu".to_string(), - payload_root: format!( - "/tmp/skill-deck-source-session-1/payload-{}", - digest("skills/demo") - ), - } - ); - } - - #[test] - fn source_revision_script_returns_selected_git_tree_object_id() { - let repo = tempdir().expect("repo"); - let skill = repo.path().join("skills/demo"); - fs::create_dir_all(&skill).expect("skill"); - fs::write(skill.join("SKILL.md"), b"demo").expect("document"); - for args in [ - vec!["init"], - vec!["config", "user.email", "test@example.com"], - vec!["config", "user.name", "Skill Deck Test"], - vec!["add", "."], - vec!["commit", "-m", "fixture"], - ] { - let output = Command::new("git") - .arg("-C") - .arg(repo.path()) - .args(args) - .output() - .expect("git command"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - let expected = Command::new("git") - .arg("-C") - .arg(repo.path()) - .args(["rev-parse", "HEAD:skills/demo"]) - .output() - .expect("expected revision"); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("source-revision") - .arg(repo.path()) - .arg("skills/demo") - .output() - .expect("source revision script"); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - output.stdout, - format!("1\0{}\0", String::from_utf8_lossy(&expected.stdout).trim()).into_bytes() - ); - } - #[test] - fn source_revision_parser_rejects_non_git_hashes() { + fn source_paths_are_relative_to_the_bound_worker_source() { assert_eq!( - parse_source_revision(format!("1\0{}\0", "A".repeat(40)).as_bytes()).unwrap(), - "a".repeat(40) - ); - assert!(parse_source_revision(format!("1\0{}\0", "a".repeat(39)).as_bytes()).is_err()); - assert!(parse_source_revision(format!("1\0{}z\0", "a".repeat(39)).as_bytes()).is_err()); - } - - fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, SkillPayloadManifest) { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir(&source).unwrap(); - fs::write(source.join("SKILL.md"), [0, 1, 2, 255]).unwrap(); - let payload = build_skill_payload(&source).unwrap(); - let manifest = payload.manifest(); - let session_root = temp.path().join("skill-deck-source-session-1"); - let payload_root = session_root.join("payload-demo"); - fs::create_dir_all(payload_root.join("blobs")).unwrap(); - fs::write(session_root.join(OWNER_FILE), b"1\nsession-1\n").unwrap(); - fs::write( - payload_root.join(MANIFEST_FILE), - serde_json::to_vec(&manifest).unwrap(), - ) - .unwrap(); - let ids = payload.blobs.keys().cloned().collect::>(); - fs::write( - payload_root.join(BLOB_LIST_FILE), - ids.iter() - .map(String::as_str) - .collect::>() - .join("\n") - + "\n", - ) - .unwrap(); - for (id, blob) in payload.blobs { - fs::write(payload_root.join("blobs").join(id), blob).unwrap(); - } - (temp, session_root, payload_root, manifest) - } - - #[test] - fn verify_script_returns_only_manifest_after_local_blob_hash_validation() { - let (_temp, session_root, payload_root, expected) = fixture(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-1") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) + super::relative_source_path("/home/alice/repo", "/home/alice/repo/skills/demo") + .unwrap(), + "skills/demo" ); - assert_eq!(parse_manifest_response(&output.stdout).unwrap(), expected); - - let blob = fs::read_dir(payload_root.join("blobs")) - .unwrap() - .next() - .unwrap() - .unwrap() - .path(); - fs::write(blob, b"tampered").unwrap(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-1") - .output() - .unwrap(); - assert!(!output.status.success()); + assert!(super::relative_source_path("/home/alice/repo", "/home/alice/other").is_err()); } #[test] - fn acquire_script_builds_backend_local_full_tree_snapshot() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("scripts")).unwrap(); - fs::create_dir_all(source.join("assets")).unwrap(); - fs::create_dir_all(source.join(".git")).unwrap(); - fs::write(source.join("SKILL.md"), b"skill").unwrap(); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); - fs::write(source.join("assets/data.bin"), [0, 1, 255]).unwrap(); - fs::write(source.join("metadata.json"), b"excluded").unwrap(); - fs::write(source.join(".git/config"), b"excluded").unwrap(); - fs::set_permissions( - source.join("scripts/run.sh"), - fs::Permissions::from_mode(0o755), - ) - .unwrap(); - let expected_payload = build_skill_payload(&source).unwrap(); - let expected_hash = compute_cli_project_hash_from_payload(&expected_payload).unwrap(); - let expected = expected_payload.manifest(); - let session_root = temp.path().join("skill-deck-source-session-2"); - let payload_root = session_root.join("payload-demo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("acquire") - .arg(&source) - .arg(&session_root) - .arg(&payload_root) - .arg("session-2") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let acquired = parse_acquire_response(&output.stdout).unwrap(); - assert_eq!(acquired.manifest, expected); - assert_eq!(acquired.total_bytes, 5 + 10 + 3); - assert_eq!(acquired.computed_hash, expected_hash); - assert!(!payload_root.join(MANIFEST_FILE).exists()); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(FINALIZE_SCRIPT) - .arg("--") - .arg("finalize") - .arg(&session_root) - .arg(&payload_root) - .arg("session-2") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&finalize_request(&acquired.manifest).unwrap()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-2") - .arg("--expected") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&expected_blob_list(&expected)) - .unwrap(); - let output = child.wait_with_output().unwrap(); - assert!(output.status.success()); - assert_eq!(parse_manifest_response(&output.stdout).unwrap(), expected); - } - - #[cfg(unix)] - #[test] - fn source_fingerprint_tracks_mode_content_and_safe_link_targets() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("scripts")).expect("scripts"); - fs::write(source.join("SKILL.md"), b"skill").expect("Skill"); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").expect("script"); - std::os::unix::fs::symlink("scripts/run.sh", source.join("run")).expect("internal link"); - - let fingerprint = || { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("fingerprint") - .arg(&source) - .output() - .expect("fingerprint script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_source_fingerprint(&output.stdout).expect("fingerprint response") - }; - - let initial = fingerprint(); - fs::set_permissions( - source.join("scripts/run.sh"), - fs::Permissions::from_mode(0o755), - ) - .expect("mode"); - let mode_changed = fingerprint(); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\necho changed\n").expect("content"); - let content_changed = fingerprint(); - - assert_ne!(initial, mode_changed); - assert_ne!(mode_changed, content_changed); - } - - #[cfg(unix)] - #[test] - fn source_fingerprint_rejects_external_links() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir(&source).expect("source"); - fs::write(temp.path().join("outside"), b"outside").expect("outside"); - std::os::unix::fs::symlink(temp.path().join("outside"), source.join("external")) - .expect("external link"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("fingerprint") - .arg(&source) - .output() - .expect("fingerprint script"); - - assert!(!output.status.success()); - } - - #[test] - fn store_scripts_write_an_in_memory_payload_into_backend_local_storage() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("assets")).unwrap(); - fs::write(source.join("SKILL.md"), b"skill").unwrap(); - fs::write(source.join("assets/data.bin"), [0, 1, 2, 255]).unwrap(); - let payload = build_skill_payload(&source).unwrap(); - let session_root = temp.path().join("skill-deck-source-session-3"); - let payload_root = session_root.join("payload-demo"); - - let begin = Command::new("/bin/sh") - .arg("-c") - .arg(STORE_BEGIN_SCRIPT) - .arg("--") - .arg("store-begin") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .output() - .unwrap(); - assert!( - begin.status.success(), - "{}", - String::from_utf8_lossy(&begin.stderr) - ); - - for (blob_id, blob) in &payload.blobs { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(STORE_BLOB_SCRIPT) - .arg("--") - .arg("store-blob") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .arg(blob_id) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(blob).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(STORE_FINALIZE_SCRIPT) - .arg("--") - .arg("store-finalize") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&finalize_request(&payload.manifest()).unwrap()) - .unwrap(); - let finalized = child.wait_with_output().unwrap(); - assert!( - finalized.status.success(), - "{}", - String::from_utf8_lossy(&finalized.stderr) - ); - - let verified = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .output() - .unwrap(); - assert!(verified.status.success()); + fn source_revision_uses_the_skill_directory_instead_of_the_skill_md_blob() { assert_eq!( - parse_manifest_response(&verified.stdout).unwrap(), - payload.manifest() - ); - for (blob_id, blob) in &payload.blobs { - assert_eq!( - fs::read(payload_root.join("blobs").join(blob_id)).unwrap(), - *blob - ); - } - } - - #[test] - fn blob_protocol_preserves_binary_content() { - let (_temp, session_root, payload_root, manifest) = fixture(); - let blob_id = manifest.entries[0].blob_id.as_deref().unwrap(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(READ_BLOB_SCRIPT) - .arg("--") - .arg("read-blob") - .arg(&session_root) - .arg(&payload_root) - .arg("session-1") - .arg(blob_id) - .output() - .unwrap(); - assert!(output.status.success()); - assert_eq!(parse_blob_response(&output.stdout).unwrap(), [0, 1, 2, 255]); - } - - #[test] - fn removal_script_requires_matching_owner_and_managed_child() { - let (temp, session_root, payload_root, _manifest) = fixture(); - let forged = temp.path().join("outside"); - fs::create_dir(&forged).unwrap(); - assert!(!run_remove(&session_root, &forged, "session-1")); - assert!(forged.is_dir()); - assert!(run_remove(&session_root, &payload_root, "session-1")); - assert!(!payload_root.exists()); - } - - #[test] - fn reconnect_sweep_preserves_protected_sessions_and_reports_invalid_roots() { - let temp = tempdir().expect("temp"); - let protected = temp.path().join("skill-deck-source-protected"); - let orphan = temp.path().join("skill-deck-source-orphan"); - let invalid = temp.path().join("skill-deck-source-invalid"); - for (root, id) in [(&protected, "protected"), (&orphan, "orphan")] { - fs::create_dir(root).expect("session root"); - fs::write(root.join(OWNER_FILE), format!("1\n{id}\n")).expect("owner"); - fs::write(root.join("payload.bin"), b"payload").expect("payload"); - } - fs::create_dir(&invalid).expect("invalid root"); - fs::write(invalid.join("payload.bin"), b"retained").expect("invalid payload"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(SWEEP_ORPHANS_SCRIPT) - .arg("--") - .arg("sweep-orphans") - .arg(temp.path()) - .arg("protected") - .output() - .expect("sweep script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) + super::normalize_skill_revision_path("quick-brainstorm/SKILL.md").unwrap(), + "quick-brainstorm" ); - let report = parse_cleanup_report(&output.stdout).expect("cleanup report"); - - assert_eq!(report.removed_sessions, 1); - assert_eq!(report.protected_sessions, 1); - assert!(report.capacity_blocked); - assert!(report.external_retained_bytes >= b"retained".len() as u64); - assert!(protected.is_dir()); - assert!(!orphan.exists()); - assert!(invalid.is_dir()); - } - - fn run_remove(session_root: &Path, payload_root: &Path, session_id: &str) -> bool { - Command::new("/bin/sh") - .arg("-c") - .arg(REMOVE_PAYLOAD_SCRIPT) - .arg("--") - .arg("remove-payload") - .arg(session_root) - .arg(payload_root) - .arg(session_id) - .status() - .unwrap() - .success() - } -} - -#[cfg(all(test, not(target_os = "linux")))] -mod portable_tests { - use std::collections::BTreeMap; - - use super::*; - use crate::application::payload_session::PayloadLocalSource; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - fn storage() -> WslPayloadSessionStorage { - let runtime = crate::environment::wsl::WslRuntime::default(); - runtime.insert(session()); - WslPayloadSessionStorage::new(runtime.workspace("Ubuntu").expect("enabled workspace")) - } - - #[test] - fn local_source_is_an_opaque_backend_owned_wsl_path() { - let storage = storage(); - let key = PayloadStorageKey::new("session-1", "skills/demo"); assert_eq!( - storage.local_source(&key).expect("local source"), - PayloadLocalSource::WslManaged { - distro_name: "Ubuntu".to_string(), - payload_root: format!( - "/tmp/skill-deck-source-session-1/payload-{}", - digest("skills/demo") - ), - } + super::normalize_skill_revision_path("skills/demo/skill.md").unwrap(), + "skills/demo" ); - } - - #[test] - fn source_revision_parser_rejects_non_git_hashes() { assert_eq!( - parse_source_revision(format!("1\0{}\0", "A".repeat(40)).as_bytes()).unwrap(), - "a".repeat(40) + super::normalize_skill_revision_path("SKILL.md").unwrap(), + "" ); - assert!(parse_source_revision(format!("1\0{}\0", "a".repeat(39)).as_bytes()).is_err()); - assert!(parse_source_revision(format!("1\0{}z\0", "a".repeat(39)).as_bytes()).is_err()); - } -} - -#[cfg(test)] -mod capability_tests { - use std::collections::BTreeMap; - - use super::*; - use crate::environment::wsl::{WslRuntime, WslSession}; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - #[tokio::test] - async fn payload_storage_from_an_old_cycle_cannot_start_an_operation_after_reenable() { - let runtime = WslRuntime::default(); - runtime.insert(session()); - let storage = - WslPayloadSessionStorage::new(runtime.workspace("Ubuntu").expect("enabled workspace")); - - let disable = runtime.begin_disable().expect("begin disable"); - disable - .wait_for_quiescence(Duration::from_secs(1)) - .await - .expect("quiescent runtime"); - disable.commit_disabled(); - runtime - .begin_enable() - .expect("begin enable") - .commit_enabled(); - - let error = storage - .source_metadata_fingerprint("/tmp/source") - .await - .expect_err("stale payload storage"); - - assert!(matches!(error, AppError::EnvironmentUnavailable { .. })); } } diff --git a/src-tauri/src/environment/wsl/operations/atomic_file.rs b/src-tauri/src/environment/wsl/operations/atomic_file.rs index 2b71f0ab..0c8699c5 100644 --- a/src-tauri/src/environment/wsl/operations/atomic_file.rs +++ b/src-tauri/src/environment/wsl/operations/atomic_file.rs @@ -1,21 +1,10 @@ -#[cfg(all(test, target_os = "linux"))] -use std::path::{Path, PathBuf}; - -use tokio::time::Duration; - use crate::environment::types::{EnvironmentRef, ResourceLocator}; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, -}; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; - -const READ_SCRIPT: &str = include_str!("../scripts/atomic-file.sh"); -pub(crate) const WRITE_SCRIPT: &str = include_str!("../scripts/atomic-file.sh"); -const READ_OPERATION: WslOperationDescriptor = wsl_operation("atomic-file", "read", READ_SCRIPT); -const WRITE_OPERATION: WslOperationDescriptor = wsl_operation("atomic-file", "write", WRITE_SCRIPT); +use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentSnapshot, DocumentWriteFailure, IoFuture, +}; +use sha2::{Digest, Sha256}; pub struct WslAtomicDocumentIo { access: WslAtomicDocumentAccess, @@ -23,7 +12,10 @@ pub struct WslAtomicDocumentIo { enum WslAtomicDocumentAccess { Workspace(WslWorkspace), - Session(WslSession), + Active { + session: WslSession, + workspace: WslWorkspace, + }, } impl WslAtomicDocumentIo { @@ -33,49 +25,23 @@ impl WslAtomicDocumentIo { } } - pub(crate) fn from_active_session(session: WslSession) -> Self { + pub(crate) fn from_active_session(session: WslSession, workspace: WslWorkspace) -> Self { Self { - access: WslAtomicDocumentAccess::Session(session), + access: WslAtomicDocumentAccess::Active { session, workspace }, } } - async fn run( - &self, - operation: &WslOperationDescriptor, - path: &str, - stdin: Vec, - stdout_limit: usize, - ) -> Result, AppError> { - let execute = |session, stdin: Vec| async move { - WslOperationExecutor::execute( - operation, - WslOperationRequest { - session, - args: vec![path.to_string()], - stdin, - timeout: Duration::from_secs(10), - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await - .map(|output| output.stdout) - }; + fn workspace(&self) -> &WslWorkspace { match &self.access { - WslAtomicDocumentAccess::Workspace(workspace) => { - workspace - .with_session_retry(move |session| execute(session, stdin.clone())) - .await - } - WslAtomicDocumentAccess::Session(session) => execute(session.clone(), stdin).await, + WslAtomicDocumentAccess::Workspace(workspace) + | WslAtomicDocumentAccess::Active { workspace, .. } => workspace, } } fn path<'a>(&self, target: &'a ResourceLocator) -> Result<&'a str, AppError> { let expected_distro_name = match &self.access { WslAtomicDocumentAccess::Workspace(workspace) => workspace.distro_name(), - WslAtomicDocumentAccess::Session(session) => &session.distro_name, + WslAtomicDocumentAccess::Active { session, .. } => &session.distro_name, }; match &target.environment { EnvironmentRef::Wsl { distro_name } @@ -92,230 +58,118 @@ impl WslAtomicDocumentIo { } impl AtomicDocumentIo for WslAtomicDocumentIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { + max_bytes: u64, + ) -> IoFuture<'a, Result> { Box::pin(async move { - let output = self - .run( - &READ_OPERATION, - self.path(target)?, - Vec::new(), - DEFAULT_WSL_STDOUT_LIMIT, - ) + let max_bytes = u32::try_from(max_bytes).map_err(|_| AppError::Validation { + field: Some("documentRead".to_string()), + message: "document read limit exceeds the WSL protocol".to_string(), + })?; + let snapshot = self + .workspace() + .read_optional_document_snapshot_once(self.path(target)?.to_string(), max_bytes) .await?; - parse_read_response(&output) + Ok(DocumentSnapshot { + bytes: snapshot.bytes, + generation: Some(snapshot.generation), + }) }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { + ) -> IoFuture<'a, Result> { Box::pin(async move { - let output = self - .run(&WRITE_OPERATION, self.path(target)?, bytes, 32) + let path = self + .path(target) + .map_err(DocumentWriteFailure::not_published)? + .to_string(); + let (generation, revision) = snapshot_binding(&expected)?; + // The request stays bound to the generation and original bytes. + // A transport error is not proof that the Worker did not publish. + self.workspace() + .commit_document_atomic(generation, path, revision, bytes.clone()) .await?; - parse_write_response(&output) + Ok(DocumentSnapshot { + bytes: Some(bytes), + generation: Some(generation), + }) }) } -} - -pub fn parse_read_response(bytes: &[u8]) -> Result>, AppError> { - let (version, rest) = take_field(bytes)?; - let (exists, body) = take_field(rest)?; - if version != b"1" { - return Err(protocol_error()); - } - match exists { - b"0" if body.is_empty() => Ok(None), - b"1" => Ok(Some(body.to_vec())), - _ => Err(protocol_error()), - } -} - -pub fn parse_write_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0").then_some(()).ok_or_else(protocol_error) -} - -fn take_field(bytes: &[u8]) -> Result<(&[u8], &[u8]), AppError> { - let index = bytes - .iter() - .position(|byte| *byte == 0) - .ok_or_else(protocol_error)?; - Ok((&bytes[..index], &bytes[index + 1..])) -} -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL atomic document protocol response".to_string(), + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { + Box::pin(async move { + let path = self + .path(target) + .map_err(DocumentWriteFailure::not_published)? + .to_string(); + let (generation, revision) = snapshot_binding(&expected)?; + self.workspace() + .remove_document_if_revision(generation, path, revision) + .await + }) } } -#[cfg(all(test, target_os = "linux"))] -fn backup_path(path: &Path) -> PathBuf { - let mut name = path.file_name().unwrap_or_default().to_os_string(); - name.push(".bak"); - path.with_file_name(name) +fn snapshot_binding( + snapshot: &DocumentSnapshot, +) -> Result<(u64, Option), DocumentWriteFailure> { + let generation = snapshot + .generation + .ok_or_else(|| DocumentWriteFailure::not_published(AppError::StaleEnvironment))?; + let revision = snapshot + .bytes + .as_deref() + .map(|bytes| format!("sha256:{:x}", Sha256::digest(bytes))); + Ok((generation, revision)) } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "原子文件协议测试需要直接运行待验证的 shell 测试脚本" -)] +#[cfg(test)] mod tests { - use std::fs; - use std::io::Write; - use std::process::{Command, Stdio}; - - use tempfile::tempdir; - use super::*; - - fn run_write(path: &Path, content: &[u8]) { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(WRITE_SCRIPT) - .arg("--") - .arg("write") - .arg(path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("write script"); - child.stdin.take().unwrap().write_all(content).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_write_response(&output.stdout).expect("write response"); - } - - fn run_read(path: &Path) -> Vec { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(READ_SCRIPT) - .arg("--") - .arg("read") - .arg(path) - .output() - .expect("read script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - output.stdout - } + use crate::storage::atomic_document::{DocumentSnapshot, PublicationState}; #[test] - fn posix_atomic_write_leaves_no_sidecar() { - let temp = tempdir().expect("temp"); - let path = temp.path().join("state/document.json"); - run_write(&path, &[0, 1, 2]); - assert_eq!(fs::read(&path).unwrap(), [0, 1, 2]); - assert!(!backup_path(&path).exists()); - - fs::write(backup_path(&path), b"legacy backup").expect("legacy backup"); - run_write(&path, &[3, 0, 4]); - assert_eq!(fs::read(&path).unwrap(), [3, 0, 4]); - assert!(!backup_path(&path).exists()); - - run_write(&path, &[5]); - assert_eq!(fs::read(&path).unwrap(), [5]); - assert!(!backup_path(&path).exists()); - assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); - } - - #[test] - fn optional_read_parser_preserves_binary_body_and_rejects_invalid_header() { - assert_eq!(parse_read_response(b"1\0\x30\0").unwrap(), None); - assert_eq!( - parse_read_response(&[b'1', 0, b'1', 0, 0, 255, 1]).unwrap(), - Some(vec![0, 255, 1]) - ); - assert!(parse_read_response(b"2\0\x31\0data").is_err()); - } - - #[test] - fn optional_read_script_separates_protocol_fields_before_file_content() { - let temp = tempdir().expect("temp"); - let path = temp.path().join("projects.json"); - fs::write(&path, br#"{"schemaVersion":1}"#).expect("fixture document"); - - assert_eq!( - parse_read_response(&run_read(&path)).expect("read response"), - Some(br#"{"schemaVersion":1}"#.to_vec()) - ); - assert_eq!( - parse_read_response(&run_read(&temp.path().join("missing.json"))) - .expect("missing response"), - None - ); - } - - #[cfg(unix)] - #[test] - fn atomic_write_does_not_replace_the_document_when_durability_fails() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempdir().expect("temp"); - let path = temp.path().join("state/document.json"); - fs::create_dir_all(path.parent().expect("parent")).expect("state directory"); - fs::write(&path, b"previous").expect("existing document"); - let bin = temp.path().join("bin"); - fs::create_dir(&bin).expect("bin"); - let sync = bin.join("sync"); - fs::write(&sync, b"#!/bin/sh\nexit 1\n").expect("failing sync"); - fs::set_permissions(&sync, fs::Permissions::from_mode(0o755)).expect("sync mode"); - let path_env = format!( - "{}:{}", - bin.display(), - std::env::var("PATH").unwrap_or_default() - ); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(WRITE_SCRIPT) - .arg("--") - .arg("write") - .arg(&path) - .env("PATH", path_env) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("write script"); - child - .stdin - .take() - .expect("stdin") - .write_all(b"replacement") - .expect("replacement"); - let output = child.wait_with_output().expect("write result"); - - assert!(!output.status.success()); - assert_eq!(fs::read(&path).expect("preserved document"), b"previous"); + fn snapshot_binding_rejects_missing_worker_generation() { + let failure = snapshot_binding(&DocumentSnapshot { + bytes: None, + generation: None, + }) + .unwrap_err(); + assert_eq!(failure.publication, PublicationState::NotPublished); + assert!(matches!(failure.error, AppError::StaleEnvironment)); } -} - -#[cfg(all(test, not(target_os = "linux")))] -mod portable_tests { - use super::parse_read_response; #[test] - fn optional_read_parser_preserves_binary_body_and_rejects_invalid_header() { - assert_eq!(parse_read_response(b"1\0\x30\0").unwrap(), None); + fn snapshot_binding_distinguishes_missing_and_empty_documents() { + let absent = DocumentSnapshot { + bytes: None, + generation: Some(7), + }; + let empty = DocumentSnapshot { + bytes: Some(Vec::new()), + generation: Some(7), + }; + assert_eq!(snapshot_binding(&absent).unwrap(), (7, None)); assert_eq!( - parse_read_response(&[b'1', 0, b'1', 0, 0, 255, 1]).unwrap(), - Some(vec![0, 255, 1]) + snapshot_binding(&empty).unwrap(), + ( + 7, + Some( + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string() + ) + ) ); - assert!(parse_read_response(b"2\0\x31\0data").is_err()); } } diff --git a/src-tauri/src/environment/wsl/operations/content_manifest.rs b/src-tauri/src/environment/wsl/operations/content_manifest.rs index d4f36982..e1e6f197 100644 --- a/src-tauri/src/environment/wsl/operations/content_manifest.rs +++ b/src-tauri/src/environment/wsl/operations/content_manifest.rs @@ -1,36 +1,27 @@ -use tokio::time::Duration; - use crate::core::mutation::CancellationSignal; use crate::environment::content_manifest::{ ContentManifest, ContentManifestRecord, ContentManifestTarget, }; use crate::environment::runtime::ExecutionBackend; use crate::environment::types::{normalized_wsl_distro_name, EnvironmentRef}; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_HEADER: &[u8] = b"SDCM 1\n"; -pub(crate) const CONTENT_MANIFEST_SCRIPT: &str = include_str!("../scripts/content-manifest.sh"); -const CONTENT_MANIFEST_OPERATION: WslOperationDescriptor = - wsl_operation("content-manifest", "inspect", CONTENT_MANIFEST_SCRIPT); +const MANIFEST_DEADLINE_MILLIS: u64 = 60_000; pub async fn inspect( - session: &WslSession, + workspace: &WslWorkspace, target: &ContentManifestTarget, cancellation: Option, ) -> Result { let expected_environment = EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + distro_name: workspace.distro_name().to_string(), }; if target.location.environment != expected_environment || !matches!( &target.key.backend, ExecutionBackend::WslPosix { distro_name } - if distro_name == &normalized_wsl_distro_name(&session.distro_name) + if distro_name == &normalized_wsl_distro_name(workspace.distro_name()) ) || !target.location.native_path.starts_with('/') { @@ -38,255 +29,122 @@ pub async fn inspect( path: target.location.native_path.clone(), }); } - inspect_path(session, &target.location.native_path, cancellation).await + inspect_path(workspace, &target.location.native_path, cancellation).await } pub(crate) async fn inspect_path( - session: &WslSession, + workspace: &WslWorkspace, path: &str, cancellation: Option, ) -> Result { + #[cfg(target_os = "linux")] + let _ = workspace; if !path.starts_with('/') { return Err(AppError::StorageUnsupported { path: path.to_string(), }); } - let output = WslOperationExecutor::execute( - &CONTENT_MANIFEST_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(60), - stdout_limit: 32 * 1024 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_content_manifest(&output.stdout) -} - -pub fn parse_content_manifest(bytes: &[u8]) -> Result { - if !bytes.starts_with(PROTOCOL_HEADER) { - return Err(protocol_error()); - } - let mut cursor = PROTOCOL_HEADER.len(); - let mut records = Vec::new(); - loop { - let header = next_line(bytes, &mut cursor)?; - let header = std::str::from_utf8(header).map_err(|_| protocol_error())?; - if let Some(count) = header.strip_prefix("E ") { - if cursor != bytes.len() - || count.parse::().map_err(|_| protocol_error())? != records.len() - { - return Err(protocol_error()); - } - return ContentManifest::from_records(records); - } - let fields = header.split(' ').collect::>(); - if fields.len() != 5 || fields[0] != "R" { - return Err(protocol_error()); - } - let executable = match fields[2] { - "0" => false, - "1" => true, - _ => return Err(protocol_error()), + #[cfg(target_os = "linux")] + let response = linux_manifest_response(path, cancellation.as_ref())?; + #[cfg(not(target_os = "linux"))] + let response: environment_protocol::ManifestResponse = { + let message = environment_protocol::Message::BuildManifest { + request: environment_protocol::ManifestRequest { + root: path.to_string(), + deadline_millis: MANIFEST_DEADLINE_MILLIS, + }, }; - let path_length = fields[3].parse::().map_err(|_| protocol_error())?; - let data_length = fields[4].parse::().map_err(|_| protocol_error())?; - let record_end = cursor - .checked_add(path_length) - .and_then(|value| value.checked_add(data_length)) - .filter(|end| *end <= bytes.len()) - .ok_or_else(protocol_error)?; - let path_end = cursor + path_length; - let path = std::str::from_utf8(&bytes[cursor..path_end]).map_err(|_| protocol_error())?; - let data = - std::str::from_utf8(&bytes[path_end..record_end]).map_err(|_| protocol_error())?; - cursor = record_end; - let record = match fields[1] { - "d" if !executable && data.is_empty() => ContentManifestRecord::directory(path), - "f" => ContentManifestRecord::file(path, data, executable), - "l" if !executable => ContentManifestRecord::symlink(path, data), - _ => return Err(protocol_error()), + match cancellation { + Some(cancellation) => { + workspace + .request_worker_payload_with_cancellation(message, cancellation) + .await? + } + None => workspace.request_worker_payload(message).await?, } - .map_err(|_| protocol_error())?; - records.push(record); - } + }; + let records = + response + .records + .into_iter() + .map(|record| { + let path = String::from_utf8(record.relative_path).map_err(|_| protocol_error())?; + match record.kind { + environment_protocol::ManifestRecordKind::Directory + if record.digest.is_none() + && !record.executable + && record.symlink_target.is_none() => + { + ContentManifestRecord::directory(path) + } + environment_protocol::ManifestRecordKind::File + if record.digest.is_some() && record.symlink_target.is_none() => + { + ContentManifestRecord::file( + path, + record.digest.expect("checked file digest"), + record.executable, + ) + } + environment_protocol::ManifestRecordKind::Symlink + if record.digest.is_none() && !record.executable => + { + let target = record.symlink_target.ok_or_else(protocol_error).and_then( + |target| String::from_utf8(target).map_err(|_| protocol_error()), + )?; + ContentManifestRecord::symlink(path, target) + } + _ => return Err(protocol_error()), + } + .map_err(|_| protocol_error()) + }) + .collect::, _>>()?; + ContentManifest::from_records(records) } -fn next_line<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], AppError> { - let end = bytes[*cursor..] - .iter() - .position(|byte| *byte == b'\n') - .map(|offset| *cursor + offset) - .ok_or_else(protocol_error)?; - let line = &bytes[*cursor..end]; - *cursor = end + 1; - Ok(line) +#[cfg(target_os = "linux")] +fn linux_manifest_response( + path: &str, + cancellation: Option<&CancellationSignal>, +) -> Result { + use std::os::unix::ffi::OsStrExt; + + let response = environment_engine::manifest::build_manifest_with_cancel( + &environment_engine::manifest::ManifestRequest { root: path.into() }, + || cancellation.is_some_and(CancellationSignal::is_cancelled), + ) + .map_err(|error| AppError::ExecutionFailed { + message: format!("Linux content manifest failed: {error}"), + })?; + Ok(environment_protocol::ManifestResponse { + records: response + .records + .into_iter() + .map(|record| environment_protocol::ManifestRecord { + relative_path: record.relative_path.as_os_str().as_bytes().to_vec(), + kind: match record.kind { + environment_engine::manifest::ManifestKind::Directory => { + environment_protocol::ManifestRecordKind::Directory + } + environment_engine::manifest::ManifestKind::File => { + environment_protocol::ManifestRecordKind::File + } + environment_engine::manifest::ManifestKind::Symlink => { + environment_protocol::ManifestRecordKind::Symlink + } + }, + digest: record.digest, + executable: record.executable, + symlink_target: record + .symlink_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL content manifest protocol response".to_string(), - } -} - -#[cfg(test)] -mod parser_tests { - use crate::environment::content_manifest::{ContentManifest, ContentManifestRecord}; - - use super::parse_content_manifest; - - fn fixture_bytes(records: &[(&str, &str, bool, &str)]) -> Vec { - let mut bytes = b"SDCM 1\n".to_vec(); - for (kind, path, executable, data) in records { - bytes.extend_from_slice( - format!( - "R {kind} {} {} {}\n", - u8::from(*executable), - path.len(), - data.len() - ) - .as_bytes(), - ); - bytes.extend_from_slice(path.as_bytes()); - bytes.extend_from_slice(data.as_bytes()); - } - bytes.extend_from_slice(format!("E {}\n", records.len()).as_bytes()); - bytes - } - - #[test] - fn wsl_records_use_the_same_rust_aggregate_hash_as_native_records() { - let digest = "a".repeat(64); - let bytes = fixture_bytes(&[ - ("l", "current", false, "run.sh"), - ("d", "empty", false, ""), - ("f", "run.sh", true, &digest), - ]); - let parsed = parse_content_manifest(&bytes).unwrap(); - let native = ContentManifest::from_records(vec![ - ContentManifestRecord::directory("empty").unwrap(), - ContentManifestRecord::file("run.sh", digest, true).unwrap(), - ContentManifestRecord::symlink("current", "run.sh").unwrap(), - ]) - .unwrap(); - - assert_eq!(parsed.hash(), native.hash()); - } - - #[test] - fn wsl_parser_rejects_unknown_versions_malformed_lengths_and_truncation() { - let digest = "a".repeat(64); - let valid = fixture_bytes(&[("f", "SKILL.md", false, &digest)]); - let mut wrong_version = valid.clone(); - wrong_version[5] = b'2'; - let malformed_length = b"SDCM 1\nR f 0 nope 64\n"; - let truncated = &valid[..valid.len() - 3]; - - assert!(parse_content_manifest(&wrong_version).is_err()); - assert!(parse_content_manifest(malformed_length).is_err()); - assert!(parse_content_manifest(truncated).is_err()); - } - - #[test] - fn wsl_parser_rejects_unsafe_relative_paths_and_record_count_mismatch() { - let digest = "a".repeat(64); - let unsafe_path = fixture_bytes(&[("f", "../SKILL.md", false, &digest)]); - let mut wrong_count = fixture_bytes(&[("f", "SKILL.md", false, &digest)]); - let footer = wrong_count.len() - 4; - wrong_count[footer..].copy_from_slice(b"E 2\n"); - - assert!(parse_content_manifest(&unsafe_path).is_err()); - assert!(parse_content_manifest(&wrong_count).is_err()); - } -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "内容清单协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod linux_shell_tests { - use std::fs; - use std::process::Command; - - use super::{parse_content_manifest, CONTENT_MANIFEST_SCRIPT}; - - #[test] - fn content_manifest_remains_readable_without_nul_safe_xargs() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().expect("temporary content root"); - let content = temp.path().join("skill"); - fs::create_dir_all(&content).expect("content directory"); - fs::write(content.join("SKILL.md"), "# Demo\n").expect("skill content"); - - let commands = temp.path().join("commands"); - fs::create_dir_all(&commands).expect("command directory"); - let xargs = commands.join("xargs"); - fs::write(&xargs, "#!/bin/sh\nexit 1\n").expect("failing xargs"); - fs::set_permissions(&xargs, fs::Permissions::from_mode(0o755)).expect("xargs permissions"); - - let path = format!( - "{}:{}", - commands.display(), - std::env::var("PATH").unwrap_or_default() - ); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(CONTENT_MANIFEST_SCRIPT) - .arg("--") - .arg("inspect") - .arg(&content) - .env("PATH", path) - .output() - .expect("content manifest script"); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let manifest = parse_content_manifest(&output.stdout).expect("content manifest"); - assert_eq!(manifest.records().len(), 1); - } - - #[test] - fn wsl_script_emits_records_only_and_rust_computes_the_manifest_hash() { - use std::os::unix::fs::{symlink, PermissionsExt}; - - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("skill"); - fs::create_dir_all(root.join("empty")).unwrap(); - fs::write(root.join("run.sh"), b"#!/bin/sh\n").unwrap(); - let mut permissions = fs::metadata(root.join("run.sh")).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(root.join("run.sh"), permissions).unwrap(); - symlink("run.sh", root.join("current")).unwrap(); - fs::write(root.join("target\n"), b"target").unwrap(); - symlink("target\n", root.join("newline-target")).unwrap(); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(CONTENT_MANIFEST_SCRIPT) - .arg("--") - .arg("inspect") - .arg(&root) - .output() - .unwrap(); - - assert!(output.status.success(), "{:?}", output.stderr); - let manifest = parse_content_manifest(&output.stdout) - .unwrap_or_else(|error| panic!("{error:?}: {:?}", output.stdout)); - assert_eq!(manifest.records().len(), 5); - assert!(manifest.records().iter().any(|record| { - record.relative_path == "newline-target" - && record.symlink_target.as_deref() == Some("target\n") - })); - assert_eq!(manifest.hash().as_str().len(), 64); - assert!(!String::from_utf8_lossy(&output.stdout).contains(manifest.hash().as_str())); + message: "invalid WSL Worker content manifest response".to_string(), } } diff --git a/src-tauri/src/environment/wsl/operations/directory_count.rs b/src-tauri/src/environment/wsl/operations/directory_count.rs index 7480c67c..951f32c1 100644 --- a/src-tauri/src/environment/wsl/operations/directory_count.rs +++ b/src-tauri/src/environment/wsl/operations/directory_count.rs @@ -1,15 +1,7 @@ -use tokio::time::Duration; - -use crate::environment::wsl::protocol::{ - decode_nul_records, wsl_operation, WslOperationDescriptor, WslOperationExecutor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -pub(crate) const DIRECTORY_COUNT_SCRIPT: &str = include_str!("../scripts/directory-count.sh"); -const DIRECTORY_COUNT_OPERATION: WslOperationDescriptor = - wsl_operation("directory-count", "inspect", DIRECTORY_COUNT_SCRIPT); +const DIRECTORY_COUNT_DEADLINE_MILLIS: u64 = 20_000; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirectoryCountFact { @@ -18,129 +10,84 @@ pub struct DirectoryCountFact { pub truncated: bool, } -pub async fn inspect( - session: &WslSession, - paths: &[String], - limit: u32, -) -> Result, AppError> { - if paths.is_empty() || limit == 0 || paths.iter().any(|path| !path.starts_with('/')) { - return Err(AppError::Validation { - field: Some("directoryCount".to_string()), - message: "WSL directory count requires absolute paths and a positive limit".to_string(), - }); - } - let mut args = Vec::with_capacity(paths.len() + 1); - args.push(limit.to_string()); - args.extend(paths.iter().cloned()); - let output = WslOperationExecutor::execute( - &DIRECTORY_COUNT_OPERATION, - WslOperationRequest { - session: session.clone(), - args, - stdin: Vec::new(), - timeout: Duration::from_secs(20), - stdout_limit: paths.len().saturating_mul(16 * 1024).saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - let facts = parse_directory_counts(&output.stdout, paths)?; - if facts.len() != paths.len() - || facts - .iter() - .zip(paths) - .any(|(fact, path)| fact.path != *path) - { - return Err(protocol_error()); - } - Ok(facts) -} - -pub fn parse_directory_counts( - bytes: &[u8], - expected_paths: &[String], -) -> Result, AppError> { - let records = decode_nul_records(bytes); - if records.first().map(String::as_str) != Some("1") { - return Err(protocol_error()); - } - let mut facts = Vec::new(); - let mut index = 1; - while index < records.len() { - if records.get(index).map(String::as_str) != Some("path") || index + 4 >= records.len() { - return Err(protocol_error()); +impl WslWorkspace { + pub(crate) async fn count_directory_entries( + &self, + paths: Vec, + limit: u32, + ) -> Result, AppError> { + if paths.is_empty() || limit == 0 || paths.iter().any(|path| !path.starts_with('/')) { + return Err(AppError::Validation { + field: Some("directoryCount".to_string()), + message: "WSL directory count requires absolute paths and a positive limit" + .to_string(), + }); } - let path = records[index + 1].clone(); - let (observed_count, truncated) = match records[index + 2].as_str() { - "none" if records[index + 3] == "0" && records[index + 4] == "0" => (None, false), - "count" => { - let count = records[index + 3] - .parse::() - .map_err(|_| protocol_error())?; - let truncated = match records[index + 4].as_str() { - "0" => false, - "1" => true, - _ => return Err(protocol_error()), - }; - (Some(count), truncated) - } - _ => return Err(protocol_error()), - }; - facts.push(DirectoryCountFact { - path, - observed_count, - truncated, - }); - index += 5; - } - if facts.len() != expected_paths.len() { - return Err(protocol_error()); - } - Ok(facts) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL directory count protocol response".to_string(), + let response: environment_protocol::DirectoryCountResponse = self + .request_worker_payload(environment_protocol::Message::CountDirectoryEntries { + request: environment_protocol::DirectoryCountRequest { + paths: paths.clone(), + limit, + deadline_millis: DIRECTORY_COUNT_DEADLINE_MILLIS, + }, + }) + .await?; + if response.facts.len() != paths.len() + || response + .facts + .iter() + .zip(&paths) + .any(|(fact, path)| fact.path != *path) + { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Worker directory count response".to_string(), + }); + } + Ok(response + .facts + .into_iter() + .map(|fact| DirectoryCountFact { + path: fact.path, + observed_count: fact.observed_count, + truncated: fact.truncated, + }) + .collect()) } -} -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "目录计数协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - use std::process::Command; - - use super::{parse_directory_counts, DIRECTORY_COUNT_SCRIPT}; - - #[test] - fn versioned_directory_count_isolates_missing_paths_and_reports_counts() { - let temp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(temp.path().join("skills/one")).unwrap(); - std::fs::create_dir_all(temp.path().join("skills/two")).unwrap(); - let skills = temp.path().join("skills").to_string_lossy().into_owned(); - let missing = temp.path().join("missing").to_string_lossy().into_owned(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(DIRECTORY_COUNT_SCRIPT) - .arg("--") - .arg("inspect") - .arg("10000") - .arg(&skills) - .arg(&missing) - .output() - .unwrap(); - - assert!(output.status.success()); - let facts = - parse_directory_counts(&output.stdout, &[skills.clone(), missing.clone()]).unwrap(); - assert_eq!(facts[0].path, skills); - assert_eq!(facts[0].observed_count, Some(2)); - assert!(!facts[0].truncated); - assert_eq!(facts[1].path, missing); - assert_eq!(facts[1].observed_count, None); + pub(crate) async fn list_child_directories( + &self, + path: String, + limit: u32, + ) -> Result, AppError> { + let response: environment_protocol::DirectoryListResponse = self + .request_worker_payload(environment_protocol::Message::ListChildDirectories { + request: environment_protocol::DirectoryListRequest { + path, + limit, + deadline_millis: DIRECTORY_COUNT_DEADLINE_MILLIS, + }, + }) + .await?; + if response.truncated { + return Err(AppError::ExecutionFailed { + message: "WSL child directory list exceeds its boundary".to_string(), + }); + } + response + .names + .into_iter() + .map(|name| { + let name = + String::from_utf8(name).map_err(|_| AppError::ConfigurationCorrupted { + message: "WSL child directory name is not UTF-8".to_string(), + })?; + if name.is_empty() || matches!(name.as_str(), "." | "..") || name.contains('/') { + return Err(AppError::ConfigurationCorrupted { + message: "WSL Worker returned an unsafe directory name".to_string(), + }); + } + Ok(name) + }) + .collect() } } diff --git a/src-tauri/src/environment/wsl/operations/document.rs b/src-tauri/src/environment/wsl/operations/document.rs new file mode 100644 index 00000000..2f104e03 --- /dev/null +++ b/src-tauri/src/environment/wsl/operations/document.rs @@ -0,0 +1,454 @@ +use crate::environment::wsl::WslWorkspace; +use crate::error::AppError; +use crate::storage::atomic_document::{DocumentWriteFailure, PublicationState}; +use sha2::{Digest, Sha256}; +use tokio::time::Duration; + +const DOCUMENT_READ_DEADLINE_MILLIS: u64 = 20_000; +const DOCUMENT_WRITE_DEADLINE_MILLIS: u64 = 30_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentQuery { + pub path: String, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DocumentState { + Missing, + NotFile, + Unreadable, + Bytes(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentFact { + pub path: String, + pub state: DocumentState, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OptionalDocumentSnapshot { + pub bytes: Option>, + pub revision: Option, + pub generation: u64, +} + +impl WslWorkspace { + pub(crate) async fn read_documents( + &self, + queries: Vec, + aggregate_limit: u32, + ) -> Result, AppError> { + validate_document_queries(&queries, aggregate_limit)?; + let response: environment_protocol::DocumentReadResponse = self + .request_worker_payload(document_read_message(&queries, aggregate_limit)) + .await?; + document_facts(queries, response) + } + + async fn read_documents_once( + &self, + queries: Vec, + aggregate_limit: u32, + ) -> Result<(u64, Vec), AppError> { + validate_document_queries(&queries, aggregate_limit)?; + let (generation, response): (u64, environment_protocol::DocumentReadResponse) = self + .request_worker_payload_once( + document_read_message(&queries, aggregate_limit), + environment_protocol::MAX_RESPONSE_TRANSFER_BYTES, + None, + Duration::from_millis(DOCUMENT_READ_DEADLINE_MILLIS), + ) + .await?; + Ok((generation, document_facts(queries, response)?)) + } + + pub(crate) async fn read_optional_document( + &self, + path: String, + limit: u32, + ) -> Result>, AppError> { + let mut facts = self + .read_documents( + vec![DocumentQuery { + path: path.clone(), + limit, + }], + limit, + ) + .await?; + Ok(optional_snapshot(facts.pop().expect("one document query returns one fact"), 0)?.bytes) + } + + pub(crate) async fn read_optional_document_snapshot_once( + &self, + path: String, + limit: u32, + ) -> Result { + let (generation, mut facts) = self + .read_documents_once( + vec![DocumentQuery { + path: path.clone(), + limit, + }], + limit, + ) + .await?; + optional_snapshot( + facts.pop().expect("one document query returns one fact"), + generation, + ) + } + + pub(crate) async fn write_document_atomic( + &self, + generation: u64, + path: String, + expected_revision: Option, + bytes: Vec, + ) -> Result { + self.commit_document_atomic(generation, path, expected_revision, bytes) + .await + .map_err(DocumentWriteFailure::into_error) + } + + pub(crate) async fn commit_document_atomic( + &self, + generation: u64, + path: String, + expected_revision: Option, + bytes: Vec, + ) -> Result { + if !path.starts_with('/') || bytes.len() > environment_protocol::MAX_DOCUMENT_BYTES as usize + { + return Err(DocumentWriteFailure::not_published(AppError::Validation { + field: Some("documentWrite".to_string()), + message: "WSL document write requires an absolute path and bounded content" + .to_string(), + })); + } + let deadline = + tokio::time::Instant::now() + Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS); + let revision = document_revision(&bytes); + let response = self + .request_worker_control_for_generation( + generation, + environment_protocol::Message::PrepareDocumentWrite { + request: environment_protocol::DocumentWritePreparation { + path, + expected_revision, + total_bytes: bytes.len() as u64, + sha256: revision.clone(), + deadline_millis: DOCUMENT_WRITE_DEADLINE_MILLIS, + }, + }, + None, + remaining_document_time(deadline)?, + ) + .await + .map_err(DocumentWriteFailure::not_published)?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => { + return Err(DocumentWriteFailure::not_published( + document_write_response_error(message, "TransferReady"), + )); + } + }; + let response = self + .send_worker_transfer_for_generation( + generation, + transfer_id, + &bytes, + environment_protocol::MAX_DOCUMENT_BYTES as usize, + remaining_document_time(deadline)?, + ) + .await + .map_err(DocumentWriteFailure::unknown)?; + match response { + environment_protocol::Message::DocumentWritten { + revision: actual_revision, + } if actual_revision == revision => Ok(revision), + message => Err(document_write_response_failure(message, "DocumentWritten")), + } + } + + pub(crate) async fn remove_document_if_revision( + &self, + generation: u64, + path: String, + expected_revision: Option, + ) -> Result<(), DocumentWriteFailure> { + if !path.starts_with('/') { + return Err(DocumentWriteFailure::not_published(AppError::Validation { + field: Some("documentRemove".to_string()), + message: "WSL document remove requires an absolute path".to_string(), + })); + } + let response = self + .request_worker_control_for_generation( + generation, + environment_protocol::Message::RemoveDocument { + request: environment_protocol::DocumentRemoveRequest { + path, + expected_revision, + deadline_millis: DOCUMENT_WRITE_DEADLINE_MILLIS, + }, + }, + None, + Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS), + ) + .await + .map_err(DocumentWriteFailure::unknown)?; + match response { + environment_protocol::Message::DocumentRemoved => Ok(()), + message => Err(document_write_response_failure(message, "DocumentRemoved")), + } + } +} + +fn validate_document_queries( + queries: &[DocumentQuery], + aggregate_limit: u32, +) -> Result<(), AppError> { + if queries.is_empty() + || aggregate_limit == 0 + || queries + .iter() + .any(|query| !query.path.starts_with('/') || query.limit == 0) + { + return Err(AppError::Validation { + field: Some("documentRead".to_string()), + message: "WSL document read requires absolute paths and positive limits".to_string(), + }); + } + Ok(()) +} + +fn document_read_message( + queries: &[DocumentQuery], + aggregate_limit: u32, +) -> environment_protocol::Message { + environment_protocol::Message::ReadDocuments { + request: environment_protocol::DocumentReadRequest { + queries: queries + .iter() + .map(|query| environment_protocol::DocumentReadQuery { + path: query.path.clone(), + limit: query.limit, + }) + .collect(), + aggregate_limit, + deadline_millis: DOCUMENT_READ_DEADLINE_MILLIS, + }, + } +} + +fn document_facts( + queries: Vec, + response: environment_protocol::DocumentReadResponse, +) -> Result, AppError> { + let expected_paths = queries + .into_iter() + .map(|query| query.path) + .collect::>(); + if response.facts.len() != expected_paths.len() + || response + .facts + .iter() + .zip(&expected_paths) + .any(|(fact, path)| fact.path != *path) + { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Worker document response".to_string(), + }); + } + Ok(response + .facts + .into_iter() + .map(|fact| DocumentFact { + path: fact.path, + state: match fact.state { + environment_protocol::DocumentReadState::Missing => DocumentState::Missing, + environment_protocol::DocumentReadState::NotFile => DocumentState::NotFile, + environment_protocol::DocumentReadState::Unreadable => DocumentState::Unreadable, + environment_protocol::DocumentReadState::Bytes(bytes) => { + DocumentState::Bytes(bytes) + } + }, + truncated: fact.truncated, + }) + .collect()) +} + +fn optional_snapshot( + fact: DocumentFact, + generation: u64, +) -> Result { + let path = fact.path; + match fact.state { + DocumentState::Missing => Ok(OptionalDocumentSnapshot { + bytes: None, + revision: None, + generation, + }), + DocumentState::Bytes(bytes) if !fact.truncated => { + let revision = Some(document_revision(&bytes)); + Ok(OptionalDocumentSnapshot { + bytes: Some(bytes), + revision, + generation, + }) + } + DocumentState::Bytes(_) => Err(AppError::ExecutionFailed { + message: format!("document exceeds its read limit: {path}"), + }), + DocumentState::NotFile | DocumentState::Unreadable => Err(AppError::Path { + message: format!("document is not readable: {path}"), + }), + } +} + +fn document_revision(bytes: &[u8]) -> String { + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +fn remaining_document_time( + deadline: tokio::time::Instant, +) -> Result { + deadline + .checked_duration_since(tokio::time::Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| DocumentWriteFailure::not_published(AppError::WslCommandTimedOut)) +} + +fn document_write_response_error( + message: environment_protocol::Message, + expected: &str, +) -> AppError { + document_write_response_failure(message, expected).into_error() +} + +fn document_write_response_failure( + message: environment_protocol::Message, + expected: &str, +) -> DocumentWriteFailure { + match message { + environment_protocol::Message::Error { code, phase, .. } if code == "documentConflict" => { + DocumentWriteFailure::not_published_at( + document_write_phase(&phase), + AppError::StaleTarget, + ) + } + environment_protocol::Message::Error { code, phase, .. } if code == "deadlineExceeded" => { + DocumentWriteFailure::not_published_at( + document_write_phase(&phase), + AppError::WslCommandTimedOut, + ) + } + environment_protocol::Message::Error { code, phase, .. } + if matches!( + code.as_str(), + "invalidRequest" | "invalidTarget" | "invalidTransfer" | "cancelled" + ) => + { + DocumentWriteFailure::not_published_at( + document_write_phase(&phase), + AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + ) + } + environment_protocol::Message::Error { code, phase, .. } + if code == "documentPublishedUnconfirmed" => + { + DocumentWriteFailure { + error: AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + phase: document_write_phase(&phase), + publication: PublicationState::PublishedUnconfirmed, + } + } + environment_protocol::Message::Error { code, phase, .. } + if code == "documentOutcomeUnknown" => + { + DocumentWriteFailure::unknown_at( + document_write_phase(&phase), + AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + ) + } + environment_protocol::Message::Error { code, phase, .. } => { + DocumentWriteFailure::unknown_at( + document_write_phase(&phase), + AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + ) + } + _ => DocumentWriteFailure::unknown(AppError::ConfigurationCorrupted { + message: format!("WSL Worker returned an invalid {expected} response"), + }), + } +} + +fn document_write_phase(phase: &str) -> crate::storage::atomic_document::WritePhase { + use crate::storage::atomic_document::WritePhase; + + match phase { + "writing" => WritePhase::Writing, + "beforePublish" => WritePhase::BeforePublish, + "publishing" => WritePhase::Publishing, + "confirming" => WritePhase::Confirming, + _ => WritePhase::Preparing, + } +} + +#[cfg(test)] +mod tests { + use super::document_write_response_failure; + use crate::error::AppError; + use crate::storage::atomic_document::{PublicationState, WritePhase}; + + #[test] + fn document_conflict_maps_to_stale_target() { + let failure = document_write_response_failure( + environment_protocol::Message::Error { + code: "documentConflict".to_string(), + phase: "documentWrite".to_string(), + parameters: Vec::new(), + }, + "DocumentWritten", + ); + + assert_eq!(failure.error, AppError::StaleTarget); + assert_eq!(failure.publication, PublicationState::NotPublished); + } + + #[test] + fn worker_publication_codes_retain_their_commit_state() { + for (code, publication) in [ + ( + "documentPublishedUnconfirmed", + PublicationState::PublishedUnconfirmed, + ), + ("documentOutcomeUnknown", PublicationState::OutcomeUnknown), + ] { + let failure = document_write_response_failure( + environment_protocol::Message::Error { + code: code.to_string(), + phase: "confirming".to_string(), + parameters: Vec::new(), + }, + "DocumentWritten", + ); + + assert_eq!(failure.publication, publication); + assert_eq!(failure.phase, WritePhase::Confirming); + } + } +} diff --git a/src-tauri/src/environment/wsl/operations/entry.rs b/src-tauri/src/environment/wsl/operations/entry.rs index 96aaa304..bd03157e 100644 --- a/src-tauri/src/environment/wsl/operations/entry.rs +++ b/src-tauri/src/environment/wsl/operations/entry.rs @@ -1,19 +1,14 @@ +use std::future::Future; +use std::time::Duration; + use sha2::{Digest, Sha256}; -use tokio::time::Duration; use crate::core::mutation::CancellationSignal; use crate::environment::runtime::EntryFingerprint; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "1"; -pub(crate) const ENTRY_STATE_SCRIPT: &str = include_str!("../scripts/entry.sh"); -const ENTRY_STATE_OPERATION: WslOperationDescriptor = - wsl_operation("entry-state", "inspect", ENTRY_STATE_SCRIPT); +const ENTRY_DEADLINE_MILLIS: u64 = 10_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PosixEntryKind { @@ -34,7 +29,7 @@ pub struct PosixEntryState { } pub async fn inspect_entries( - session: &WslSession, + workspace: &WslWorkspace, paths: &[String], cancellation: Option, ) -> Result, AppError> { @@ -44,195 +39,223 @@ pub async fn inspect_entries( message: "WSL entry inspection requires absolute paths".to_string(), }); } - let output = WslOperationExecutor::execute( - &ENTRY_STATE_OPERATION, - WslOperationRequest { - session: session.clone(), - args: paths.to_vec(), - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: paths.len().saturating_mul(1024).saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_entry_states(&output.stdout, paths.len()) + inspect_entries_with(paths, cancellation, |request, cancellation| async move { + #[cfg(target_os = "linux")] + { + let _ = workspace; + linux_entry_response(&request.paths, cancellation.as_ref()) + } + #[cfg(not(target_os = "linux"))] + { + let message = environment_protocol::Message::InspectEntries { request }; + match cancellation { + Some(cancellation) => { + workspace + .request_worker_payload_with_cancellation(message, cancellation) + .await + } + None => workspace.request_worker_payload(message).await, + } + } + }) + .await } -pub fn parse_entry_states(bytes: &[u8], expected: usize) -> Result, AppError> { - let mut fields = bytes.split(|byte| *byte == 0); - if text(fields.next())? != PROTOCOL_VERSION { - return Err(protocol_error()); - } - let mut states = Vec::with_capacity(expected); - while let Some(tag) = fields.next() { - if tag.is_empty() { - continue; - } - if text(Some(tag))? != "S" { - return Err(protocol_error()); +async fn inspect_entries_with( + paths: &[String], + cancellation: Option, + mut send: Send, +) -> Result, AppError> +where + Send: FnMut(environment_protocol::EntryFactsRequest, Option) -> SendFuture, + SendFuture: Future>, +{ + let deadline = tokio::time::Instant::now() + Duration::from_millis(ENTRY_DEADLINE_MILLIS); + let mut entries = Vec::with_capacity(paths.len()); + for (batch_index, batch) in paths + .chunks(environment_protocol::MAX_INSPECTION_ROOTS) + .enumerate() + { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let deadline_millis = u64::try_from(remaining.as_millis()) + .unwrap_or(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS) + .min(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS); + if deadline_millis == 0 { + return Err(AppError::WslCommandTimedOut); } - let index = parse::(fields.next())?; - let kind = match text(fields.next())? { - "missing" => PosixEntryKind::Missing, - "file" => PosixEntryKind::File, - "directory" => PosixEntryKind::Directory, - "symlink" => PosixEntryKind::Symlink, - "brokenLink" => PosixEntryKind::BrokenLink, - "other" => PosixEntryKind::Other, - _ => return Err(protocol_error()), - }; - let device = text(fields.next())?; - let inode = text(fields.next())?; - let mode = text(fields.next())?; - let size = text(fields.next())?; - let mtime_seconds = text(fields.next())?; - let mtime_nanos = text(fields.next())?; - let link_target = optional_text(fields.next())?; - if index as usize != states.len() { + let response = tokio::time::timeout_at( + deadline, + send( + environment_protocol::EntryFactsRequest { + paths: batch.to_vec(), + deadline_millis, + }, + cancellation.clone(), + ), + ) + .await + .map_err(|_| AppError::WslCommandTimedOut)??; + if response.facts.len() != batch.len() { return Err(protocol_error()); } - let fingerprint = if kind == PosixEntryKind::Missing { - if [device, inode, mode, size, mtime_seconds, mtime_nanos] - .iter() - .any(|value| !value.is_empty()) - || link_target.is_some() - { - return Err(protocol_error()); - } - EntryFingerprint("entry-v1-missing".to_string()) - } else { - if [device, inode, mode, size, mtime_seconds, mtime_nanos] - .iter() - .any(|value| value.is_empty()) - { - return Err(protocol_error()); - } - let mut hasher = Sha256::new(); - hasher.update(b"skill-deck-wsl-entry-v1\0"); - for value in [device, inode, mode, size, mtime_seconds, mtime_nanos] { - hasher.update(value.as_bytes()); - hasher.update([0]); - } - if let Some(target) = &link_target { - hasher.update(target.as_bytes()); - } - EntryFingerprint(format!("entry-v1-{:x}", hasher.finalize())) - }; - states.push(PosixEntryState { - index, - kind, - fingerprint, - link_target, - }); + let offset = batch_index * environment_protocol::MAX_INSPECTION_ROOTS; + let mut batch_entries = response + .facts + .into_iter() + .enumerate() + .map(|(index, fact)| { + let kind = match fact.kind { + environment_protocol::EntryFactKind::Missing => PosixEntryKind::Missing, + environment_protocol::EntryFactKind::File => PosixEntryKind::File, + environment_protocol::EntryFactKind::Directory => PosixEntryKind::Directory, + environment_protocol::EntryFactKind::Symlink => PosixEntryKind::Symlink, + environment_protocol::EntryFactKind::BrokenLink => PosixEntryKind::BrokenLink, + environment_protocol::EntryFactKind::Other => PosixEntryKind::Other, + }; + let link_target = fact + .link_target + .map(String::from_utf8) + .transpose() + .map_err(|_| protocol_error())?; + let fingerprint = if kind == PosixEntryKind::Missing { + if fact.metadata.is_some() || link_target.is_some() { + return Err(protocol_error()); + } + EntryFingerprint("entry-v1-missing".to_string()) + } else { + let metadata = fact.metadata.ok_or_else(protocol_error)?; + let values = [ + metadata.device.to_string(), + metadata.inode.to_string(), + format!("{:x}", metadata.mode), + metadata.size.to_string(), + metadata.mtime_seconds.to_string(), + format!("{:09}", metadata.mtime_nanos), + ]; + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-wsl-entry-v1\0"); + for value in values { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + if let Some(target) = &link_target { + hasher.update(target.as_bytes()); + } + EntryFingerprint(format!("entry-v1-{:x}", hasher.finalize())) + }; + Ok(PosixEntryState { + index: (offset + index) as u32, + kind, + fingerprint, + link_target, + }) + }) + .collect::, AppError>>()?; + entries.append(&mut batch_entries); } - if states.len() != expected { - return Err(protocol_error()); - } - Ok(states) -} - -fn text(field: Option<&[u8]>) -> Result<&str, AppError> { - std::str::from_utf8(field.ok_or_else(protocol_error)?).map_err(|_| protocol_error()) + Ok(entries) } -fn optional_text(field: Option<&[u8]>) -> Result, AppError> { - let value = text(field)?; - Ok((!value.is_empty()).then(|| value.to_string())) -} +#[cfg(target_os = "linux")] +fn linux_entry_response( + paths: &[String], + cancellation: Option<&CancellationSignal>, +) -> Result { + use std::os::unix::ffi::OsStrExt; -fn parse(field: Option<&[u8]>) -> Result { - text(field)?.parse().map_err(|_| protocol_error()) + let response = environment_engine::entry::inspect_entries_with_cancel( + &environment_engine::entry::EntryRequest { + paths: paths.iter().map(Into::into).collect(), + }, + || cancellation.is_some_and(CancellationSignal::is_cancelled), + ) + .map_err(|error| AppError::ExecutionFailed { + message: format!("Linux entry inspection failed: {error}"), + })?; + Ok(environment_protocol::EntryFactsResponse { + facts: response + .facts + .into_iter() + .map(|fact| environment_protocol::EntryFact { + kind: match fact.kind { + environment_engine::entry::EntryKind::Missing => { + environment_protocol::EntryFactKind::Missing + } + environment_engine::entry::EntryKind::File => { + environment_protocol::EntryFactKind::File + } + environment_engine::entry::EntryKind::Directory => { + environment_protocol::EntryFactKind::Directory + } + environment_engine::entry::EntryKind::Symlink => { + environment_protocol::EntryFactKind::Symlink + } + environment_engine::entry::EntryKind::BrokenLink => { + environment_protocol::EntryFactKind::BrokenLink + } + environment_engine::entry::EntryKind::Other => { + environment_protocol::EntryFactKind::Other + } + }, + metadata: fact + .metadata + .map(|metadata| environment_protocol::EntryMetadata { + device: metadata.device, + inode: metadata.inode, + mode: metadata.mode, + size: metadata.size, + mtime_seconds: metadata.mtime_seconds, + mtime_nanos: metadata.mtime_nanos, + }), + link_target: fact + .link_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL entry state protocol response".to_string(), + message: "invalid WSL Worker entry response".to_string(), } } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "目录项协议测试需要直接运行待验证的 shell 测试脚本" -)] +#[cfg(test)] mod tests { - use std::fs; - use std::os::unix::fs::symlink; - use std::process::Command; - - use tempfile::tempdir; + use std::sync::{Arc, Mutex}; use super::*; - fn inspect(paths: &[String]) -> Vec { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ENTRY_STATE_SCRIPT) - .arg("--") - .arg("inspect") - .args(paths) - .output() - .expect("entry state script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_entry_states(&output.stdout, paths.len()).expect("parse") - } - - #[test] - fn fingerprint_is_stable_for_missing_and_changes_with_file_state() { - let temp = tempdir().expect("temp"); - let missing = temp.path().join("missing"); - let file = temp.path().join("file"); - fs::write(&file, b"first").unwrap(); - let paths = vec![ - missing.to_string_lossy().into_owned(), - file.to_string_lossy().into_owned(), - ]; - let before = inspect(&paths); - - assert_eq!(before[0].kind, PosixEntryKind::Missing); - assert_eq!(before[0].fingerprint.0, "entry-v1-missing"); - assert_eq!(before[1].kind, PosixEntryKind::File); - - fs::write(&file, b"second-longer").unwrap(); - let after = inspect(&paths); - assert_ne!(before[1].fingerprint, after[1].fingerprint); - } - - #[test] - fn final_symlink_is_fingerprinted_without_following_its_target() { - let temp = tempdir().expect("temp"); - let target = temp.path().join("target"); - let link = temp.path().join("link"); - fs::write(&target, b"first").unwrap(); - symlink(&target, &link).unwrap(); - let path = link.to_string_lossy().into_owned(); - let before = inspect(std::slice::from_ref(&path)); - - fs::write(&target, b"target changed").unwrap(); - let after = inspect(&[path]); - - assert_eq!(before[0].kind, PosixEntryKind::Symlink); - assert_eq!( - before[0].link_target.as_deref(), - Some(target.as_path().to_str().unwrap()) - ); - assert_eq!(before[0].fingerprint, after[0].fingerprint); - } - - #[test] - fn broken_final_symlink_has_an_explicit_kind() { - let temp = tempdir().expect("temp"); - let link = temp.path().join("broken"); - symlink(temp.path().join("missing-target"), &link).unwrap(); - - let state = inspect(&[link.to_string_lossy().into_owned()]); + #[tokio::test] + async fn entry_inspection_batches_preserve_order_and_the_transport_limit() { + let paths = (0..513) + .map(|index| format!("/tmp/skill-{index}")) + .collect::>(); + let batch_sizes = Arc::new(Mutex::new(Vec::new())); + let observed = batch_sizes.clone(); + let entries = inspect_entries_with(&paths, None, move |request, _| { + observed.lock().unwrap().push(request.paths.len()); + async move { + Ok(environment_protocol::EntryFactsResponse { + facts: request + .paths + .into_iter() + .map(|_| environment_protocol::EntryFact { + kind: environment_protocol::EntryFactKind::Missing, + metadata: None, + link_target: None, + }) + .collect(), + }) + } + }) + .await + .unwrap(); - assert_eq!(state[0].kind, PosixEntryKind::BrokenLink); + assert_eq!(*batch_sizes.lock().unwrap(), vec![256, 256, 1]); + assert_eq!(entries.len(), paths.len()); + assert_eq!(entries[256].index, 256); + assert_eq!(entries[512].index, 512); } } diff --git a/src-tauri/src/environment/wsl/operations/eve.rs b/src-tauri/src/environment/wsl/operations/eve.rs deleted file mode 100644 index 5548a9f7..00000000 --- a/src-tauri/src/environment/wsl/operations/eve.rs +++ /dev/null @@ -1,70 +0,0 @@ -use tokio::time::Duration; - -use crate::environment::wsl::protocol::{ - decode_nul_records, wsl_operation, WslOperationDescriptor, WslOperationExecutor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; -use crate::error::AppError; - -const EVE_PROJECT_SCRIPT: &str = include_str!("../scripts/eve.sh"); -const EVE_PROJECT_OPERATION: WslOperationDescriptor = - wsl_operation("eve-project", "inspect", EVE_PROJECT_SCRIPT); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EveProjectTargets { - pub has_eve: bool, - pub subagents: Vec, -} - -pub async fn inspect_eve_project( - session: &WslSession, - project_path: &str, -) -> Result { - let output = WslOperationExecutor::execute( - &EVE_PROJECT_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![project_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 1024 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - parse_eve_project(&output.stdout) -} - -pub fn parse_eve_project(bytes: &[u8]) -> Result { - let records = decode_nul_records(bytes); - if records.first().map(String::as_str) == Some("0") { - return Ok(EveProjectTargets { - has_eve: false, - subagents: Vec::new(), - }); - } - if records.first().map(String::as_str) != Some("1") { - return Err(protocol_error()); - } - let package: serde_json::Value = - serde_json::from_str(records.get(1).ok_or_else(protocol_error)?)?; - let has_eve = ["dependencies", "devDependencies"] - .into_iter() - .any(|section| { - package - .get(section) - .and_then(serde_json::Value::as_object) - .is_some_and(|dependencies| dependencies.contains_key("eve")) - }); - let mut subagents = records.into_iter().skip(2).collect::>(); - subagents.sort(); - Ok(EveProjectTargets { has_eve, subagents }) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL Eve project protocol response".to_string(), - } -} diff --git a/src-tauri/src/environment/wsl/operations/inspection.rs b/src-tauri/src/environment/wsl/operations/inspection.rs index 1c2759ca..409eac14 100644 --- a/src-tauri/src/environment/wsl/operations/inspection.rs +++ b/src-tauri/src/environment/wsl/operations/inspection.rs @@ -1,28 +1,31 @@ +use std::future::Future; +use std::time::Duration; + use crate::environment::inspection::{ - FilesystemEntryKind, FilesystemInspector, InspectionFuture, RawFilesystemSnapshot, RawPathFact, - ReadPlan, ReadRootPurpose, + FilesystemEntryKind, FilesystemInspector, InspectionFuture, MetadataFuture, + RawFilesystemSnapshot, RawPathFact, RawSkillMetadata, ReadPlan, ReadRootPurpose, + SkillMetadataSource, }; use crate::environment::types::{same_environment_identity, EnvironmentRef}; -use crate::environment::wsl::operations::scan::{ - self, ScanRequest, ScanResponse, ScannedEntryKind, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; +const INSPECTION_DEADLINE_MILLIS: u64 = 30_000; + pub struct WslInspector { - session: WslSession, + workspace: WslWorkspace, } impl WslInspector { - pub fn new(session: WslSession) -> Self { - Self { session } + pub fn new(workspace: WslWorkspace) -> Self { + Self { workspace } } } impl FilesystemInspector for WslInspector { fn environment(&self) -> EnvironmentRef { EnvironmentRef::Wsl { - distro_name: self.session.distro_name.clone(), + distro_name: self.workspace.distro_name().to_string(), } } @@ -31,65 +34,245 @@ impl FilesystemInspector for WslInspector { plan: &'a ReadPlan, ) -> InspectionFuture<'a, Result> { Box::pin(async move { - let environment = self.environment(); + let environment = FilesystemInspector::environment(self); if !same_environment_identity(&plan.context.environment, &environment) { return Err(AppError::StorageUnsupported { path: "wslInspector".to_string(), }); } - let response = scan::scan( - &self.session, - ScanRequest { - roots: plan - .roots - .iter() - .map(|root| root.locator.native_path.clone()) - .collect(), - stat_only_root_indexes: plan - .roots + let response = + inspect_plan_with(plan, |request| self.workspace.inspect_filesystem(request)) + .await?; + snapshot_from_inspection_response(environment, response, plan.roots.len()) + }) + } +} + +impl SkillMetadataSource for WslInspector { + fn read<'a>( + &'a self, + locators: &'a [crate::environment::types::ResourceLocator], + per_file_limit: u32, + ) -> MetadataFuture<'a, Result, AppError>> { + Box::pin(async move { + let environment = ::environment(self); + if per_file_limit == 0 + || locators + .iter() + .any(|locator| !same_environment_identity(&locator.environment, &environment)) + { + return Err(AppError::StorageUnsupported { + path: "wslSkillMetadata".to_string(), + }); + } + let facts = self + .workspace + .inspect_path_metadata( + locators .iter() - .enumerate() - .filter(|(_, root)| { - root.purposes.len() == 1 - && root.purposes.contains(&ReadRootPurpose::Context) + .map(|locator| { + crate::environment::wsl::operations::path_metadata::PathMetadataQuery { + path: locator.native_path.clone(), + content_limit: Some(per_file_limit), + } }) - .map(|(index, _)| index as u32) .collect(), - recursive: false, - per_file_limit: plan.per_file_limit, - aggregate_limit: plan.aggregate_limit, - }, - None, - ) - .await?; - snapshot_from_scan_response(environment, response) + ) + .await?; + Ok(locators + .iter() + .cloned() + .zip(facts) + .map(|(locator, fact)| { + use crate::environment::wsl::operations::path_metadata::{ + PathMetadataContent, PathMetadataKind, + }; + let readable_file = matches!( + fact.content, + PathMetadataContent::Bytes(_) | PathMetadataContent::Empty + ); + let (bytes, error_code) = match fact.content { + PathMetadataContent::Bytes(bytes) => (bytes, None), + PathMetadataContent::Empty => (Vec::new(), None), + PathMetadataContent::Unreadable => { + (Vec::new(), Some("readFailed".to_string())) + } + PathMetadataContent::NotRequested => { + (Vec::new(), Some("invalidMetadataResponse".to_string())) + } + }; + let error_code = error_code.or_else(|| match fact.kind { + PathMetadataKind::Missing => Some("missing".to_string()), + PathMetadataKind::Inaccessible => Some("pathUnavailable".to_string()), + PathMetadataKind::Directory + | PathMetadataKind::SymlinkDirectory + | PathMetadataKind::SymlinkOther + | PathMetadataKind::BrokenLink => Some("notFile".to_string()), + PathMetadataKind::Other if !readable_file => Some("notFile".to_string()), + PathMetadataKind::Other => None, + }); + RawSkillMetadata { + locator, + bytes, + truncated: fact.truncated, + error_code, + } + }) + .collect()) }) } } -pub fn snapshot_from_scan_response( +async fn inspect_plan_with( + plan: &ReadPlan, + mut send: Send, +) -> Result +where + Send: FnMut(environment_protocol::InspectionRequest) -> SendFuture, + SendFuture: Future>, +{ + if plan.roots.len() > plan.aggregate_limit as usize { + return Err(AppError::CapabilityUnavailable { + capability: "wslInspectionRootCapacity".to_string(), + path: None, + }); + } + let deadline = tokio::time::Instant::now() + Duration::from_millis(INSPECTION_DEADLINE_MILLIS); + let mut facts = Vec::new(); + let mut remaining_content = plan.aggregate_limit; + for (batch_index, batch) in plan + .roots + .chunks(environment_protocol::MAX_INSPECTION_ROOTS) + .enumerate() + { + let offset = batch_index * environment_protocol::MAX_INSPECTION_ROOTS; + let remaining_roots = plan.roots.len() - offset; + let batch_content = ((u64::from(remaining_content) * batch.len() as u64) + / remaining_roots as u64) + .max(1) as u32; + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let deadline_millis = u64::try_from(remaining.as_millis()) + .unwrap_or(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS) + .min(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS); + if deadline_millis == 0 { + return Err(AppError::WslCommandTimedOut); + } + let mut response = tokio::time::timeout_at( + deadline, + send(environment_protocol::InspectionRequest { + read_content: false, + roots: batch + .iter() + .map(|root| environment_protocol::InspectionRoot { + path: root.locator.native_path.clone(), + stat_only: root.purposes.len() == 1 + && root.purposes.contains(&ReadRootPurpose::Context), + }) + .collect(), + per_file_limit: plan.per_file_limit.min(batch_content), + aggregate_limit: batch_content, + deadline_millis, + }), + ) + .await + .map_err(|_| AppError::WslCommandTimedOut)??; + if response.total_content_bytes > remaining_content + || response + .facts + .iter() + .any(|fact| fact.root_index as usize >= batch.len()) + { + return Err(worker_protocol_error( + "worker inspection batch exceeded its request contract", + )); + } + remaining_content -= response.total_content_bytes; + for fact in &mut response.facts { + fact.root_index += offset as u32; + } + facts.append(&mut response.facts); + } + Ok(environment_protocol::InspectionResponse { + total_content_bytes: plan.aggregate_limit - remaining_content, + facts, + }) +} + +pub fn snapshot_from_inspection_response( environment: EnvironmentRef, - response: ScanResponse, + response: environment_protocol::InspectionResponse, + root_count: usize, ) -> Result { + let total_content_bytes = response + .facts + .iter() + .map(|fact| fact.content_bytes.len()) + .sum::(); + if total_content_bytes != response.total_content_bytes as usize { + return Err(worker_protocol_error( + "worker inspection content total does not match its facts", + )); + } let facts = response - .entries + .facts .into_iter() - .map(|entry| RawPathFact { - root_index: entry.root_index, - relative_path: entry.relative_path, - kind: match entry.kind { - ScannedEntryKind::Missing => FilesystemEntryKind::Missing, - ScannedEntryKind::File => FilesystemEntryKind::File, - ScannedEntryKind::Directory => FilesystemEntryKind::Directory, - ScannedEntryKind::Symlink => FilesystemEntryKind::Symlink, - ScannedEntryKind::Other | ScannedEntryKind::Error => FilesystemEntryKind::Other, - }, - resolved_target: entry.resolved_target, - frontmatter_bytes: entry.content_bytes, - truncated: entry.truncated, - error_code: entry.error_code, + .map(|fact| { + if fact.root_index as usize >= root_count { + return Err(worker_protocol_error( + "worker inspection root index is out of range", + )); + } + let relative_path = String::from_utf8(fact.relative_path) + .map_err(|_| worker_protocol_error("worker inspection path is not UTF-8"))?; + if relative_path.starts_with('/') + || relative_path.contains('\\') + || relative_path.split('/').any(|component| component == "..") + { + return Err(worker_protocol_error( + "worker inspection returned an unsafe relative path", + )); + } + let resolved_target = fact + .resolved_target + .map(String::from_utf8) + .transpose() + .map_err(|_| worker_protocol_error("worker link target is not UTF-8"))?; + Ok(RawPathFact { + root_index: fact.root_index, + relative_path, + kind: match fact.kind { + environment_protocol::InspectionEntryKind::Missing => { + FilesystemEntryKind::Missing + } + environment_protocol::InspectionEntryKind::File => FilesystemEntryKind::File, + environment_protocol::InspectionEntryKind::Directory => { + FilesystemEntryKind::Directory + } + environment_protocol::InspectionEntryKind::Symlink => { + FilesystemEntryKind::Symlink + } + environment_protocol::InspectionEntryKind::Other => FilesystemEntryKind::Other, + }, + resolved_target, + fingerprint: fact + .fingerprint + .map(crate::environment::runtime::EntryFingerprint), + frontmatter_bytes: fact.content_bytes, + truncated: fact.truncated, + error_code: fact.error_code.map(|code| match code { + environment_protocol::InspectionErrorCode::PathUnavailable => { + "pathUnavailable".to_string() + } + environment_protocol::InspectionErrorCode::ReadFailed => { + "readFailed".to_string() + } + environment_protocol::InspectionErrorCode::ReadLinkFailed => { + "readLinkFailed".to_string() + } + }), + }) }) - .collect(); + .collect::, AppError>>()?; Ok(RawFilesystemSnapshot { environment, facts, @@ -97,95 +280,269 @@ pub fn snapshot_from_scan_response( }) } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "检查协议测试需要直接运行待验证的 shell 测试脚本" -)] +fn worker_protocol_error(message: &str) -> AppError { + AppError::ConfigurationCorrupted { + message: message.to_string(), + } +} + +#[cfg(test)] mod tests { use std::collections::BTreeSet; - use std::fs; - use std::process::Command; - - use tempfile::tempdir; + use std::sync::{Arc, Mutex}; use super::*; - use crate::core::agent_definition::AgentId; - use crate::environment::inspection::{FilesystemInspector, ReadPlanBuilder, ReadRootPurpose}; - use crate::environment::native::inspection::NativeInspector; - use crate::environment::runtime::ContextSnapshotRevision; - use crate::environment::types::{ - EnvironmentRef, ResourceLocator, SkillLocation, SkillLocationRef, - }; - use crate::environment::wsl::operations::scan::{ - parse_scan_response, ScanRequest, SCAN_SCRIPT, - }; + + #[test] + fn worker_snapshot_projects_bounded_filesystem_facts() { + let content = b"---\nname: demo\n---\n".to_vec(); + let response = environment_protocol::InspectionResponse { + facts: vec![environment_protocol::InspectionFact { + root_index: 0, + relative_path: b"demo/SKILL.md".to_vec(), + kind: environment_protocol::InspectionEntryKind::File, + resolved_target: None, + fingerprint: Some("entry-v1-demo".to_string()), + content_bytes: content.clone(), + truncated: false, + error_code: None, + }], + total_content_bytes: content.len() as u32, + }; + + let snapshot = snapshot_from_inspection_response(EnvironmentRef::Native, response, 1) + .expect("snapshot"); + + assert_eq!(snapshot.facts.len(), 1); + assert_eq!(snapshot.facts[0].relative_path, "demo/SKILL.md"); + assert_eq!(snapshot.facts[0].kind, FilesystemEntryKind::File); + assert_eq!(snapshot.total_content_bytes, content.len() as u32); + } + + #[test] + fn worker_snapshot_rejects_unsafe_relative_paths() { + let response = environment_protocol::InspectionResponse { + facts: vec![environment_protocol::InspectionFact { + root_index: 0, + relative_path: b"../escape".to_vec(), + kind: environment_protocol::InspectionEntryKind::File, + resolved_target: None, + fingerprint: None, + content_bytes: Vec::new(), + truncated: false, + error_code: None, + }], + total_content_bytes: 0, + }; + + assert!(snapshot_from_inspection_response(EnvironmentRef::Native, response, 1).is_err()); + } #[tokio::test] - async fn native_and_posix_protocol_project_the_same_filesystem_facts() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("skills"); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); - let missing = temp.path().join("missing"); - let context = SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, + async fn inspection_batches_preserve_global_root_indices_and_content_budget() { + use crate::environment::runtime::ContextSnapshotRevision; + use crate::environment::types::{ResourceLocator, SkillLocation, SkillLocationRef}; + + let environment = EnvironmentRef::Wsl { + distro_name: "Ubuntu".to_string(), }; - let mut builder = ReadPlanBuilder::new( - context, - "registry-1", - "environment-1", - ContextSnapshotRevision::parse("context-v1-parity").unwrap(), + let plan = ReadPlan { + context: SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + roots: (0..513) + .map(|index| crate::environment::inspection::ReadRoot { + locator: ResourceLocator { + environment: environment.clone(), + native_path: format!("/tmp/root-{index}"), + }, + purposes: BTreeSet::from([ReadRootPurpose::Private]), + consumer_agent_ids: BTreeSet::new(), + }) + .collect(), + registry_revision: "registry-v1".to_string(), + environment_revision: "environment-v1".to_string(), + context_revision: ContextSnapshotRevision::parse("context-v1").unwrap(), + per_file_limit: 1024, + aggregate_limit: 4096, + }; + let batches = Arc::new(Mutex::new(Vec::new())); + let observed = batches.clone(); + let response = inspect_plan_with(&plan, move |request| { + observed + .lock() + .unwrap() + .push((request.roots.len(), request.aggregate_limit)); + async move { + let total_content_bytes = request.aggregate_limit; + Ok(environment_protocol::InspectionResponse { + facts: request + .roots + .into_iter() + .enumerate() + .map(|(root_index, _)| environment_protocol::InspectionFact { + root_index: root_index as u32, + relative_path: Vec::new(), + kind: environment_protocol::InspectionEntryKind::Missing, + resolved_target: None, + fingerprint: None, + content_bytes: Vec::new(), + truncated: false, + error_code: None, + }) + .collect(), + total_content_bytes, + }) + } + }) + .await + .unwrap(); + + let batches = batches.lock().unwrap(); + assert_eq!( + batches.iter().map(|batch| batch.0).collect::>(), + vec![256, 256, 1] ); - for (index, path) in [&missing, &root].into_iter().enumerate() { - builder - .add_root( - ResourceLocator { - environment: EnvironmentRef::Native, - native_path: path.to_string_lossy().into_owned(), + assert!(batches.iter().map(|batch| batch.1).sum::() <= plan.aggregate_limit); + assert_eq!(response.facts.len(), plan.roots.len()); + assert_eq!(response.facts[256].root_index, 256); + assert_eq!(response.facts[512].root_index, 512); + } + + #[cfg(target_os = "windows")] + #[tokio::test] + #[ignore = "requires SKILL_DECK_TEST_WSL_DISTRO and a matching real WSL Worker"] + async fn real_wsl_worker_inspects_more_than_one_root_batch() { + use crate::environment::runtime::ContextSnapshotRevision; + use crate::environment::types::{ResourceLocator, SkillLocation, SkillLocationRef}; + use crate::environment::wsl::WslRuntime; + + let distro_name = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").expect("set SKILL_DECK_TEST_WSL_DISTRO"); + let runtime = WslRuntime::for_wsl_test(); + runtime.connect(&distro_name).await.expect("connect Worker"); + let environment = EnvironmentRef::Wsl { + distro_name: distro_name.clone(), + }; + let plan = ReadPlan { + context: SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + roots: (0..513) + .map(|index| crate::environment::inspection::ReadRoot { + locator: ResourceLocator { + environment: environment.clone(), + native_path: format!("/tmp/skill-deck-root-batch-{index}"), }, - ReadRootPurpose::Detection, - Some(AgentId::parse(format!("agent-{index}")).unwrap()), - ) - .unwrap(); - } - let plan = builder.build().unwrap(); - let native = NativeInspector::new(EnvironmentRef::Native) + purposes: BTreeSet::from([ReadRootPurpose::Private]), + consumer_agent_ids: BTreeSet::new(), + }) + .collect(), + registry_revision: "registry-v1".to_string(), + environment_revision: "environment-v1".to_string(), + context_revision: ContextSnapshotRevision::parse("context-v1").unwrap(), + per_file_limit: 1024, + aggregate_limit: 4096, + }; + + let snapshot = WslInspector::new(runtime.workspace(&distro_name).unwrap()) .inspect(&plan) .await - .expect("native inspect"); + .expect("inspect all roots"); - let request = ScanRequest { - roots: plan - .roots - .iter() - .map(|root| root.locator.native_path.clone()) - .collect(), - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: plan.per_file_limit, - aggregate_limit: plan.aggregate_limit, + assert_eq!(snapshot.facts.len(), plan.roots.len()); + assert_eq!(snapshot.facts[256].root_index, 256); + assert_eq!(snapshot.facts[512].root_index, 512); + } + + #[cfg(target_os = "windows")] + // Run from the repository root on Windows after preparing the exact Worker build: + // `$env:SKILL_DECK_TEST_WSL_DISTRO='Ubuntu'; cargo test --manifest-path src-tauri/Cargo.toml environment::wsl::operations::inspection::tests::real_wsl_worker_executes_a_skill_read_plan -- --ignored --exact` + #[tokio::test] + #[ignore = "requires SKILL_DECK_TEST_WSL_DISTRO and a real WSL 2 distribution"] + async fn real_wsl_worker_executes_a_skill_read_plan() { + use std::time::Duration; + + use crate::environment::inspection::{ + FilesystemInspector, ReadPlanBuilder, ReadRootPurpose, + }; + use crate::environment::runtime::ContextSnapshotRevision; + use crate::environment::types::{ResourceLocator, SkillLocation, SkillLocationRef}; + use crate::environment::wsl::protocol::{ + WslCommandRequest, WslCommandRunner, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, + }; + use crate::environment::wsl::WslRuntime; + + let distro_name = std::env::var("SKILL_DECK_TEST_WSL_DISTRO") + .expect("set SKILL_DECK_TEST_WSL_DISTRO to an installed WSL 2 distribution"); + let root = format!("/tmp/skill-deck-worker-inspection-{}", uuid::Uuid::new_v4()); + let runtime = WslRuntime::for_wsl_test(); + let session = runtime + .connect(&distro_name) + .await + .expect("connect WSL Worker"); + let setup = WslCommandRunner::run(WslCommandRequest { + session: session.clone(), + script: "set -eu\nroot=$1\nmkdir -p \"$root/demo\"\ncat > \"$root/demo/SKILL.md\"\n", + args: vec![root.clone()], + stdin: b"---\nname: demo\ndescription: Worker fixture\n---\n".to_vec(), + timeout: Duration::from_secs(10), + stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, + stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + cancellation: None, + }) + .await + .expect("create fixture"); + assert_eq!(setup.exit_code, Some(0)); + + let environment = EnvironmentRef::Wsl { + distro_name: distro_name.clone(), }; - let output = Command::new("/bin/sh") - .arg("-c") - .arg(SCAN_SCRIPT) - .arg("--") - .arg("scan") - .arg(request.per_file_limit.to_string()) - .arg(request.aggregate_limit.to_string()) - .arg("") - .arg("0") - .args(&request.roots) - .output() + let mut builder = ReadPlanBuilder::new( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + "registry-worker-test", + "environment-worker-test", + ContextSnapshotRevision::parse("context-v1-worker-test").unwrap(), + ); + builder + .add_root( + ResourceLocator { + environment, + native_path: root.clone(), + }, + ReadRootPurpose::Private, + None, + ) .unwrap(); - assert!(output.status.success()); - let posix = snapshot_from_scan_response( - EnvironmentRef::Native, - parse_scan_response(&output.stdout, &request).unwrap(), - ) - .expect("project response"); + let snapshot = WslInspector::new(runtime.workspace(&distro_name).unwrap()) + .inspect(&builder.build().unwrap()) + .await; + + let cleanup = WslCommandRunner::run(WslCommandRequest { + session, + script: "set -eu\ncase $1 in /tmp/skill-deck-worker-inspection-*) rm -rf -- \"$1\" ;; *) exit 64 ;; esac\n", + args: vec![root], + stdin: Vec::new(), + timeout: Duration::from_secs(10), + stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, + stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + cancellation: None, + }) + .await; - assert_eq!(native, posix); + let snapshot = snapshot.expect("read fixture through WSL Worker"); + cleanup.expect("clean fixture"); + let document = snapshot + .facts + .iter() + .find(|fact| fact.relative_path == "demo/SKILL.md") + .expect("Skill document fact"); + assert_eq!(document.kind, FilesystemEntryKind::File); + assert!(document.frontmatter_bytes.starts_with(b"---\nname: demo\n")); } } diff --git a/src-tauri/src/environment/wsl/operations/library.rs b/src-tauri/src/environment/wsl/operations/library.rs new file mode 100644 index 00000000..fd9f9163 --- /dev/null +++ b/src-tauri/src/environment/wsl/operations/library.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +use crate::environment::types::EnvironmentRef; +use crate::environment::wsl::WslWorkspace; +use crate::error::AppError; + +const LIBRARY_DEADLINE_MILLIS: u64 = 60_000; + +pub struct LibraryCatalogSnapshot { + pub generation: u64, + pub bytes: Option>, + pub revision: Option, +} + +impl WslWorkspace { + pub(crate) async fn list_library_applications( + &self, + ) -> Result { + let response: environment_protocol::LibraryApplicationIndex = self + .request_worker_payload(environment_protocol::Message::ListLibraryApplications { + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }) + .await?; + if response.project_ids.len() > environment_protocol::MAX_DIRECTORY_COUNT_LIMIT as usize + || response.problem_keys.len() + > environment_protocol::MAX_DIRECTORY_COUNT_LIMIT as usize + || response + .project_ids + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || response + .problem_keys + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || response.complete != response.problem_keys.is_empty() + { + return Err(protocol_error("LibraryApplicationIndex")); + } + Ok(response) + } + + pub(crate) async fn read_library_catalog(&self) -> Result>, AppError> { + let response: environment_protocol::LibraryCatalogResponse = self + .request_worker_payload(environment_protocol::Message::ReadLibraryCatalog { + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }) + .await?; + validate_catalog_response(&response)?; + Ok(response.present.then_some(response.bytes)) + } + + pub(crate) async fn read_library_catalog_once( + &self, + ) -> Result { + let (generation, response): (u64, environment_protocol::LibraryCatalogResponse) = self + .request_worker_payload_once( + environment_protocol::Message::ReadLibraryCatalog { + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }, + environment_protocol::MAX_RESPONSE_TRANSFER_BYTES, + None, + Duration::from_millis(LIBRARY_DEADLINE_MILLIS), + ) + .await?; + validate_catalog_response(&response)?; + Ok(LibraryCatalogSnapshot { + generation, + bytes: response.present.then_some(response.bytes), + revision: response.revision, + }) + } + + pub(crate) async fn execute_library_operation( + &self, + generation: u64, + request: environment_protocol::LibraryOperationRequest, + ) -> Result { + if request.deadline_millis != LIBRARY_DEADLINE_MILLIS { + return Err(AppError::Validation { + field: Some("libraryOperation".to_string()), + message: "invalid WSL Library operation deadline".to_string(), + }); + } + let expected_revision = format!("sha256:{:x}", Sha256::digest(&request.catalog_bytes)); + let payload = environment_protocol::encode_payload(&request).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("failed to encode WSL Library operation: {error}"), + } + })?; + if payload.is_empty() || payload.len() > environment_protocol::MAX_MUTATION_TRANSFER_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslLibraryOperationSize".to_string(), + path: None, + }); + } + let digest = format!("sha256:{:x}", Sha256::digest(&payload)); + let response = self + .request_worker_control_for_generation( + generation, + environment_protocol::Message::PrepareLibraryOperation { + request: environment_protocol::LibraryOperationPreparation { + total_bytes: payload.len() as u64, + sha256: digest, + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }, + }, + None, + Duration::from_millis(LIBRARY_DEADLINE_MILLIS), + ) + .await?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => return Err(response_error(self.distro_name(), message, "TransferReady")), + }; + let response = self + .send_worker_transfer_for_generation( + generation, + transfer_id, + &payload, + environment_protocol::MAX_MUTATION_TRANSFER_BYTES, + Duration::from_millis(LIBRARY_DEADLINE_MILLIS), + ) + .await?; + match response { + environment_protocol::Message::LibraryOperationCompleted { catalog_revision } + if catalog_revision == expected_revision => + { + Ok(catalog_revision) + } + message => Err(response_error( + self.distro_name(), + message, + "LibraryOperationCompleted", + )), + } + } +} + +fn validate_catalog_response( + response: &environment_protocol::LibraryCatalogResponse, +) -> Result<(), AppError> { + if response.present == response.bytes.is_empty() + || response.present != response.revision.is_some() + { + Err(protocol_error("LibraryCatalogResponse")) + } else { + Ok(()) + } +} + +fn response_error( + distro_name: &str, + message: environment_protocol::Message, + expected: &str, +) -> AppError { + match message { + environment_protocol::Message::Error { code, .. } if code == "staleTarget" => { + AppError::StaleTarget + } + environment_protocol::Message::Error { code, .. } if code == "stalePayload" => { + AppError::StalePayload + } + environment_protocol::Message::Error { code, .. } if code == "deadlineExceeded" => { + AppError::WslCommandTimedOut + } + environment_protocol::Message::Error { code, .. } + if code == "libraryRecoveryIncomplete" => + { + AppError::LibraryRecoveryIncomplete { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL Skill Library recovery is incomplete".to_string(), + } + } + environment_protocol::Message::Error { code, phase, .. } => AppError::ExecutionFailed { + message: format!("WSL Library operation failed during {phase}: {code}"), + }, + _ => protocol_error(expected), + } +} + +fn protocol_error(expected: &str) -> AppError { + AppError::ConfigurationCorrupted { + message: format!("WSL Worker returned an invalid {expected}"), + } +} diff --git a/src-tauri/src/environment/wsl/operations/library_content.rs b/src-tauri/src/environment/wsl/operations/library_content.rs deleted file mode 100644 index b6f13c0b..00000000 --- a/src-tauri/src/environment/wsl/operations/library_content.rs +++ /dev/null @@ -1,338 +0,0 @@ -use std::time::Duration; - -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; -use crate::error::AppError; - -const SCRIPT: &str = include_str!("../scripts/library-content.sh"); -const RECOVER: WslOperationDescriptor = wsl_operation("library-content", "recover", SCRIPT); -const REPLACE: WslOperationDescriptor = wsl_operation("library-content", "replace", SCRIPT); -const DELETE: WslOperationDescriptor = wsl_operation("library-content", "delete", SCRIPT); -const PREPARE_CATALOG: WslOperationDescriptor = - wsl_operation("library-content", "prepare-catalog", SCRIPT); -const FINALIZE_CATALOG: WslOperationDescriptor = - wsl_operation("library-content", "finalize-catalog", SCRIPT); -const ENSURE_LIBRARIES: WslOperationDescriptor = - wsl_operation("library-content", "ensure-libraries", SCRIPT); -const REMOVE_LIBRARY: WslOperationDescriptor = - wsl_operation("library-content", "remove-library", SCRIPT); -const REMOVE_APPLICATION: WslOperationDescriptor = - wsl_operation("library-content", "remove-application", SCRIPT); - -pub async fn recover_library_content(session: &WslSession) -> Result<(), AppError> { - run(&RECOVER, session, Vec::new(), Vec::new()).await -} - -pub async fn replace_library_skill( - session: &WslSession, - library_id: &str, - skill_name: &str, - archive: Vec, -) -> Result<(), AppError> { - validate_component(library_id)?; - validate_component(skill_name)?; - run( - &REPLACE, - session, - vec![ - library_id.to_string(), - skill_name.to_string(), - uuid::Uuid::new_v4().simple().to_string(), - ], - archive, - ) - .await -} - -pub async fn stage_library_skill_deletion( - session: &WslSession, - library_id: &str, - skill_name: &str, -) -> Result<(), AppError> { - validate_component(library_id)?; - validate_component(skill_name)?; - run( - &DELETE, - session, - vec![ - library_id.to_string(), - skill_name.to_string(), - uuid::Uuid::new_v4().simple().to_string(), - ], - Vec::new(), - ) - .await -} - -pub async fn prepare_library_catalog( - session: &WslSession, - catalog_hash: &str, -) -> Result<(), AppError> { - validate_hash(catalog_hash)?; - run( - &PREPARE_CATALOG, - session, - vec![catalog_hash.to_string()], - Vec::new(), - ) - .await -} - -pub async fn finalize_library_catalog( - session: &WslSession, - catalog_hash: &str, -) -> Result<(), AppError> { - validate_hash(catalog_hash)?; - run( - &FINALIZE_CATALOG, - session, - vec![catalog_hash.to_string()], - Vec::new(), - ) - .await -} - -pub async fn ensure_library_roots( - session: &WslSession, - library_ids: &[String], -) -> Result<(), AppError> { - for library_id in library_ids { - validate_component(library_id)?; - } - run(&ENSURE_LIBRARIES, session, library_ids.to_vec(), Vec::new()).await -} - -pub async fn remove_library(session: &WslSession, library_id: &str) -> Result<(), AppError> { - validate_component(library_id)?; - run( - &REMOVE_LIBRARY, - session, - vec![library_id.to_string()], - Vec::new(), - ) - .await -} - -pub async fn remove_library_application( - session: &WslSession, - project_id: &str, -) -> Result<(), AppError> { - validate_component(project_id)?; - run( - &REMOVE_APPLICATION, - session, - vec![project_id.to_string()], - Vec::new(), - ) - .await -} - -async fn run( - operation: &WslOperationDescriptor, - session: &WslSession, - args: Vec, - stdin: Vec, -) -> Result<(), AppError> { - let output = WslOperationExecutor::execute( - operation, - WslOperationRequest { - session: session.clone(), - args, - stdin, - timeout: Duration::from_secs(120), - stdout_limit: 32, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - if output.stdout == b"1\0" { - Ok(()) - } else { - Err(AppError::ConfigurationCorrupted { - message: "invalid WSL Skill Library content response".to_string(), - }) - } -} - -fn validate_component(value: &str) -> Result<(), AppError> { - if value.is_empty() || matches!(value, "." | "..") || value.contains(['/', '\\', '\0']) { - return Err(AppError::Validation { - field: Some("libraryStorageComponent".to_string()), - message: "invalid Skill Library storage component".to_string(), - }); - } - Ok(()) -} - -fn validate_hash(value: &str) -> Result<(), AppError> { - if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - Ok(()) - } else { - Err(AppError::Validation { - field: Some("catalogHash".to_string()), - message: "invalid Skill Library catalog hash".to_string(), - }) - } -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "WSL library protocol tests execute the shipped POSIX shell script directly" -)] -mod tests { - use sha2::{Digest, Sha256}; - use std::io::Write; - use std::process::{Command, Stdio}; - - use super::SCRIPT; - - #[test] - fn script_replaces_and_recovers_library_content_inside_the_managed_root() { - let temp = tempfile::tempdir().unwrap(); - let first = archive(b"---\nname: demo\ndescription: First\n---\nfirst\n"); - let second = archive(b"---\nname: demo\ndescription: Second\n---\nsecond\n"); - - run(temp.path(), "ensure-libraries", &["lib-empty"], &[]); - assert!(temp - .path() - .join(".skill-deck/skill-libraries/libraries/lib-empty/skills") - .is_dir()); - - run(temp.path(), "replace", &["lib-1", "demo", "op-1"], &first); - commit_catalog(temp.path(), br#"{"schemaVersion":1,"libraries":["first"]}"#); - let skill = temp - .path() - .join(".skill-deck/skill-libraries/libraries/lib-1/skills/demo/SKILL.md"); - assert_eq!( - std::fs::read(&skill).unwrap(), - b"---\nname: demo\ndescription: First\n---\nfirst\n" - ); - - run(temp.path(), "replace", &["lib-1", "demo", "op-2"], &second); - commit_catalog( - temp.path(), - br#"{"schemaVersion":1,"libraries":["second"]}"#, - ); - assert_eq!( - std::fs::read(&skill).unwrap(), - b"---\nname: demo\ndescription: Second\n---\nsecond\n" - ); - run( - temp.path(), - "replace", - &["lib-1", "demo", "op-rollback"], - &first, - ); - run(temp.path(), "recover", &[], &[]); - assert_eq!( - std::fs::read(&skill).unwrap(), - b"---\nname: demo\ndescription: Second\n---\nsecond\n" - ); - run( - temp.path(), - "delete", - &["lib-1", "demo", "delete-rollback"], - &[], - ); - run(temp.path(), "recover", &[], &[]); - assert!(skill.exists()); - - run( - temp.path(), - "delete", - &["lib-1", "demo", "delete-commit"], - &[], - ); - commit_catalog(temp.path(), br#"{"schemaVersion":1,"libraries":[]}"#); - assert!(!skill.exists()); - - run(temp.path(), "replace", &["lib-1", "demo", "op-3"], &first); - commit_catalog(temp.path(), br#"{"schemaVersion":1,"libraries":["third"]}"#); - run(temp.path(), "remove-library", &["lib-1"], &[]); - assert!(!temp - .path() - .join(".skill-deck/skill-libraries/libraries/lib-1") - .exists()); - - let orphan = temp - .path() - .join(".skill-deck/skill-libraries/.transactions/orphan"); - std::fs::create_dir_all(&orphan).unwrap(); - run_failure(temp.path(), "recover", &[], &[]); - assert!(orphan.exists()); - } - - fn archive(content: &[u8]) -> Vec { - let mut builder = tar::Builder::new(Vec::new()); - let mut directory = tar::Header::new_gnu(); - directory.set_path("stage").unwrap(); - directory.set_entry_type(tar::EntryType::Directory); - directory.set_mode(0o755); - directory.set_size(0); - directory.set_cksum(); - builder.append(&directory, std::io::empty()).unwrap(); - let mut header = tar::Header::new_gnu(); - header.set_path("stage/SKILL.md").unwrap(); - header.set_entry_type(tar::EntryType::Regular); - header.set_mode(0o644); - header.set_size(content.len() as u64); - header.set_cksum(); - builder.append(&header, content).unwrap(); - builder.finish().unwrap(); - builder.into_inner().unwrap() - } - - fn commit_catalog(home: &std::path::Path, content: &[u8]) { - let hash = format!("{:x}", Sha256::digest(content)); - run(home, "prepare-catalog", &[&hash], &[]); - let root = home.join(".skill-deck/skill-libraries"); - std::fs::write(root.join("catalog.json"), content).unwrap(); - run(home, "finalize-catalog", &[&hash], &[]); - } - - fn run(home: &std::path::Path, subcommand: &str, args: &[&str], stdin: &[u8]) { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(SCRIPT) - .arg("--") - .arg(subcommand) - .args(args) - .env("HOME", home) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(stdin).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - } - - fn run_failure(home: &std::path::Path, subcommand: &str, args: &[&str], stdin: &[u8]) { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(SCRIPT) - .arg("--") - .arg(subcommand) - .args(args) - .env("HOME", home) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(stdin).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!(!output.status.success()); - } -} diff --git a/src-tauri/src/environment/wsl/operations/materialize.rs b/src-tauri/src/environment/wsl/operations/materialize.rs index 1a6e619f..e5a23105 100644 --- a/src-tauri/src/environment/wsl/operations/materialize.rs +++ b/src-tauri/src/environment/wsl/operations/materialize.rs @@ -1,181 +1,36 @@ use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; use tokio::time::Duration; -use crate::application::mutation::coordinator::{BoxFuture, PreparedEntryExecutor}; +use crate::application::mutation::coordinator::{ + BoxFuture, PreparedUnitExecutor, UnitTransactionReceipt, +}; use crate::application::mutation::plan::{ ExecutionUnit, PreparedEntryAction, PreparedEntryMutation, }; -use crate::application::mutation::result::MutationWarning; +use crate::application::mutation::result::{MutationWarning, MutationWarningCode}; use crate::application::payload_session::{PayloadLocalSource, PinnedPayloadLease}; use crate::core::mutation::CancellationSignal; -use crate::core::skill_payload::{PayloadEntryKind, PayloadId, SkillPayloadManifest}; +use crate::core::skill_payload::{PayloadId, SkillPayloadManifest}; use crate::environment::content_manifest::ContentManifestHash; use crate::environment::recovery::{ RecoveryEntryPhase, RecoveryExpectedEntryState, RecoveryMarker, RecoveryMarkerEntry, - RecoveryMarkerKind, RecoveryMarkerRef, RecoveryMarkerStore, RecoverySubject, - RECOVERY_MARKER_SCHEMA_VERSION, + RecoveryMarkerKind, RecoverySubject, RECOVERY_MARKER_SCHEMA_VERSION, }; use crate::environment::runtime::posix_relative_target; -use crate::environment::runtime::{EntryFingerprint, ExecutionBackend}; +use crate::environment::runtime::{EntryFingerprint, ExecutionBackend, PhysicalParentIdentity}; use crate::environment::types::{EnvironmentRef, ResourceLocator}; -use crate::environment::wsl::operations::content_manifest::inspect_path as inspect_content_manifest; -use crate::environment::wsl::operations::entry::inspect_entries; -use crate::environment::wsl::operations::recovery::WslRecoveryMarkerStore; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; use crate::environment::wsl::WslSession; use crate::error::{AppError, RecoveryResourceId}; - -const MATERIALIZE_SCRIPT: &str = include_str!("../scripts/materialize.sh"); -const STAGE_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "stage", MATERIALIZE_SCRIPT); -const SWAP_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "swap", MATERIALIZE_SCRIPT); -const VERIFY_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "verify", MATERIALIZE_SCRIPT); -const RESTORE_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "restore", MATERIALIZE_SCRIPT); -const CLEANUP_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "cleanup", MATERIALIZE_SCRIPT); -const MAX_MATERIALIZE_ENTRY_COUNT: usize = 100_000; -const MAX_MATERIALIZE_RECORD_COUNT: usize = 1_000_000; -const MAX_MATERIALIZE_STAGE_REQUEST_BYTES: usize = 16 * 1024 * 1024; - -fn materialize_request_size_error() -> AppError { - AppError::CapabilityUnavailable { - capability: "wslMaterializeRequestSize".to_string(), - path: None, - } -} - -fn append_stage_record(request: &mut Vec, fields: [&str; 7]) -> Result<(), AppError> { - if fields.iter().any(|field| field.as_bytes().contains(&0)) { - return Err(AppError::Validation { - field: Some("materializeStageRequest".to_string()), - message: "WSL materialize stage fields must not contain NUL".to_string(), - }); - } - let record_bytes = fields.iter().try_fold(0usize, |total, field| { - total.checked_add(field.len())?.checked_add(1) - }); - if record_bytes - .and_then(|record_bytes| request.len().checked_add(record_bytes)) - .is_none_or(|total| total > MAX_MATERIALIZE_STAGE_REQUEST_BYTES) - { - return Err(materialize_request_size_error()); - } - for field in fields { - request.extend_from_slice(field.as_bytes()); - request.push(0); - } - Ok(()) -} - -fn materialize_stage_request(entries: &[WslEntryMutation]) -> Result, AppError> { - if entries.len() > MAX_MATERIALIZE_ENTRY_COUNT { - return Err(materialize_request_size_error()); - } - let manifest_records = entries - .iter() - .try_fold(0usize, |count, entry| { - count.checked_add(match &entry.action { - WslEntryAction::Materialize { manifest, .. } => manifest.entries.len(), - _ => 0, - }) - }) - .ok_or_else(materialize_request_size_error)?; - let total_records = 1usize - .checked_add(entries.len()) - .and_then(|count| count.checked_add(manifest_records)) - .ok_or_else(materialize_request_size_error)?; - if total_records > MAX_MATERIALIZE_RECORD_COUNT { - return Err(materialize_request_size_error()); - } - let total_records = total_records.to_string(); - let entry_count = entries.len().to_string(); - let mut request = Vec::new(); - append_stage_record( - &mut request, - ["H", "1", &total_records, &entry_count, "", "", ""], - )?; - for (index, entry) in entries.iter().enumerate() { - let index = format!("{index:06}"); - let (action, source, manifest) = match &entry.action { - WslEntryAction::Keep => ("keep", "", None), - WslEntryAction::Materialize { - payload_root, - manifest, - } => ("materialize", payload_root.as_str(), Some(manifest)), - WslEntryAction::Symlink { target } => ("symlink", target.as_str(), None), - WslEntryAction::Remove => ("remove", "", None), - }; - let manifest_count = manifest - .map_or(0, |manifest| manifest.entries.len()) - .to_string(); - append_stage_record( - &mut request, - [ - "E", - &index, - &entry.destination, - action, - source, - &entry.expected_fingerprint.0, - &manifest_count, - ], - )?; - if let Some(manifest) = manifest { - for manifest_entry in &manifest.entries { - let (kind, blob_id) = match manifest_entry.kind { - PayloadEntryKind::Directory => ("directory", ""), - PayloadEntryKind::File => { - ("file", manifest_entry.blob_id.as_deref().unwrap_or("")) - } - }; - let executable = if manifest_entry.executable { "1" } else { "0" }; - let expected_size = manifest_entry.size.to_string(); - append_stage_record( - &mut request, - [ - "M", - &index, - kind, - &manifest_entry.relative_path, - blob_id, - executable, - &expected_size, - ], - )?; - } - } - } - Ok(request) -} - -fn parse_unit_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0") - .then_some(()) - .ok_or_else(|| AppError::ConfigurationCorrupted { - message: "invalid WSL entry-set protocol response".to_string(), - }) -} +use crate::storage::lock_plan::{LockCommitReceipt, PreparedLockMutation}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WslEntryAction { Keep, - Materialize { - payload_root: String, - manifest: SkillPayloadManifest, - }, - Symlink { - target: String, - }, + Materialize, + Symlink { target: String }, Remove, } @@ -188,21 +43,6 @@ pub struct WslEntryMutation { pub action: WslEntryAction, } -struct PartitionedWslEntrySet { - observations: Vec, - mutations: Vec, -} - -fn partition_entry_set(entries: Vec) -> PartitionedWslEntrySet { - let (observations, mutations) = entries - .into_iter() - .partition(|entry| matches!(entry.action, WslEntryAction::Keep)); - PartitionedWslEntrySet { - observations, - mutations, - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct WslPayloadBinding { pub source: PayloadLocalSource, @@ -213,6 +53,7 @@ pub fn prepare_wsl_mutations( unit: &ExecutionUnit, payloads: &BTreeMap, distro_name: &str, + worker_generation: u64, ) -> Result, AppError> { let environment_matches = matches!( &unit.target.environment, @@ -265,22 +106,27 @@ pub fn prepare_wsl_mutations( if binding.manifest.payload_id() != payload_id { return Err(AppError::StalePayload); } - let payload_root = match &binding.source { + match &binding.source { PayloadLocalSource::WslManaged { distro_name: source_distro, - payload_root, - } if source_distro.eq_ignore_ascii_case(distro_name) => payload_root.clone(), + worker_generation: source_generation, + .. + } if source_distro.eq_ignore_ascii_case(distro_name) + && *source_generation == worker_generation => {} + PayloadLocalSource::WslManaged { + distro_name: source_distro, + .. + } if source_distro.eq_ignore_ascii_case(distro_name) => { + return Err(AppError::StalePayload); + } _ => { return Err(AppError::CapabilityUnavailable { capability: "backendLocalPayload".to_string(), path: None, }) } - }; - WslEntryAction::Materialize { - payload_root, - manifest: binding.manifest.clone(), } + WslEntryAction::Materialize } PreparedEntryAction::Replace { requested_mode: crate::models::InstallMode::Symlink, @@ -389,7 +235,7 @@ pub fn recovery_marker_for_entry_set( }), expected_state: match entry.action { WslEntryAction::Remove => RecoveryExpectedEntryState::Missing, - WslEntryAction::Materialize { .. } | WslEntryAction::Symlink { .. } => { + WslEntryAction::Materialize | WslEntryAction::Symlink { .. } => { RecoveryExpectedEntryState::Present } WslEntryAction::Keep => unreachable!("Keep entries are filtered"), @@ -398,7 +244,7 @@ pub fn recovery_marker_for_entry_set( phase: RecoveryEntryPhase::Staged, }) }) - .collect(); + .collect::>(); Ok(RecoveryMarker { schema_version: RECOVERY_MARKER_SCHEMA_VERSION, resource_id, @@ -412,6 +258,36 @@ pub fn recovery_marker_for_entry_set( }) } +fn add_lock_recovery_evidence( + marker: &mut RecoveryMarker, + entries: &[WslEntryMutation], + environment: &EnvironmentRef, +) -> Result<(), AppError> { + if !marker.entries.is_empty() { + return Ok(()); + } + let evidence = entries.first().ok_or_else(|| AppError::Validation { + field: Some("entrySet".to_string()), + message: "WSL lock mutation requires target evidence".to_string(), + })?; + marker.entries.push(RecoveryMarkerEntry { + physical_target_digest: evidence.physical_target_digest.clone(), + destination: ResourceLocator { + environment: environment.clone(), + native_path: evidence.destination.clone(), + }, + backup: None, + expected_state: if evidence.expected_fingerprint.0 == "entry-v1-missing" { + RecoveryExpectedEntryState::Missing + } else { + RecoveryExpectedEntryState::Present + }, + original_fingerprint: evidence.expected_fingerprint.0.clone(), + phase: RecoveryEntryPhase::Staged, + }); + Ok(()) +} + fn validate_wsl_entry(entry: &PreparedEntryMutation, distro_name: &str) -> Result<(), AppError> { let backend_matches = matches!( &entry.key.backend, @@ -429,63 +305,48 @@ fn validate_wsl_entry(entry: &PreparedEntryMutation, distro_name: &str) -> Resul Ok(()) } -pub struct WslPreparedEntrySet { - session: WslSession, - owner_id: String, - operation_root: String, - entries: Vec, - recovery_store: Arc, - recovery_marker: Mutex>, - recovery_ref: Option, -} - -pub struct WslPreparedEntryExecutor { +pub struct WslPreparedUnitExecutor { session: WslSession, + workspace: crate::environment::wsl::WslWorkspace, operation_id: String, operation_kind: crate::core::mutation::MutationKind, - recovery_store: Arc, } -impl WslPreparedEntryExecutor { - pub fn for_operation( - session: WslSession, - operation_id: impl Into, - operation_kind: crate::core::mutation::MutationKind, - ) -> Self { - let recovery_store = Arc::new(WslRecoveryMarkerStore::from_active_session(session.clone())); - Self::with_recovery_store_for_operation( - session, - operation_id, - operation_kind, - recovery_store, - ) - } +pub struct PreparedWslUnit { + generation: u64, + resource_id: String, + request: environment_protocol::MutationUnitRequest, +} - pub fn with_recovery_store_for_operation( +impl WslPreparedUnitExecutor { + pub fn for_operation( session: WslSession, + workspace: crate::environment::wsl::WslWorkspace, operation_id: impl Into, operation_kind: crate::core::mutation::MutationKind, - recovery_store: Arc, ) -> Self { Self { session, + workspace, operation_id: operation_id.into(), operation_kind, - recovery_store, } } } -impl PreparedEntryExecutor for WslPreparedEntryExecutor { - type Staged = WslPreparedEntrySet; +impl PreparedUnitExecutor for WslPreparedUnitExecutor { + type Prepared = PreparedWslUnit; - fn stage<'a>( + fn prepare<'a>( &'a self, unit: &'a ExecutionUnit, payloads: &'a BTreeMap, cancellation: CancellationSignal, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } let bindings = payloads .iter() .map(|(id, lease)| { @@ -498,354 +359,374 @@ impl PreparedEntryExecutor for WslPreparedEntryExecutor { )) }) .collect::, AppError>>()?; - let mutations = prepare_wsl_mutations(unit, &bindings, &self.session.distro_name)?; - stage_entry_set( - &self.session, + let mutations = prepare_wsl_mutations( + unit, + &bindings, + &self.session.distro_name, + self.session.runtime_generation, + )?; + let resource_id = operation_owner_id(&self.operation_id, &unit.id); + let mut marker = recovery_marker_for_entry_set( &self.operation_id, &unit.id, + &resource_id, + &unit.target.environment, RecoverySubject { operation_kind: self.operation_kind, skill_name: unit.skill_name.clone(), context: unit.target.clone(), }, - mutations, - cancellation, - Arc::clone(&self.recovery_store), - ) - .await + &mutations, + now_epoch_ms(), + )?; + if marker.entries.is_empty() && unit.lock_mutation.is_some() { + add_lock_recovery_evidence(&mut marker, &mutations, &unit.target.environment)?; + } + let initial_marker_json = if marker.entries.is_empty() { + Vec::new() + } else { + serde_json::to_vec_pretty(&marker)? + }; + let entries = mutations + .iter() + .map(|mutation| { + let planned = unit + .primary_entry + .iter() + .chain(&unit.additional_entries) + .find(|entry| entry.destination.native_path == mutation.destination) + .ok_or(AppError::StaleTarget)?; + let (expected_anchor_device, expected_anchor_inode) = + match &planned.key.physical_parent { + PhysicalParentIdentity::Wsl { + distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&self.session.distro_name) => { + (*device, *inode) + } + _ => return Err(AppError::StaleTarget), + }; + let action = match &mutation.action { + WslEntryAction::Keep => environment_protocol::MutationEntryAction::Keep, + WslEntryAction::Remove => environment_protocol::MutationEntryAction::Remove, + WslEntryAction::Symlink { target } => { + environment_protocol::MutationEntryAction::Symlink { + target: target.clone(), + } + } + WslEntryAction::Materialize => { + let PreparedEntryAction::Replace { payload_id, .. } = &planned.action + else { + return Err(AppError::StalePayload); + }; + let binding = bindings.get(payload_id).ok_or(AppError::StalePayload)?; + let payload_id = match &binding.source { + PayloadLocalSource::WslManaged { + worker_generation, + worker_payload_id, + .. + } if *worker_generation == self.session.runtime_generation => { + *worker_payload_id + } + _ => return Err(AppError::StalePayload), + }; + environment_protocol::MutationEntryAction::Materialize { payload_id } + } + }; + Ok(environment_protocol::MutationEntry { + destination: mutation.destination.clone(), + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint: mutation.expected_fingerprint.0.clone(), + expected_content_hash: mutation + .expected_content_manifest_hash + .as_ref() + .map(|hash| hash.as_str().to_string()), + action, + }) + }) + .collect::, AppError>>()?; + let write_destinations = entries + .iter() + .filter(|entry| { + !matches!( + entry.action, + environment_protocol::MutationEntryAction::Keep + ) + }) + .map(|entry| entry.destination.clone()) + .collect::>(); + if !write_destinations.is_empty() { + let expected_count = + u32::try_from(write_destinations.len()).map_err(|_| AppError::StaleTarget)?; + let response: environment_protocol::WriteProbeResponse = self + .workspace + .request_worker_payload_for_generation( + self.session.runtime_generation, + environment_protocol::Message::ProbeWriteTargets { + request: environment_protocol::WriteProbeRequest { + destinations: write_destinations, + deadline_millis: 10_000, + }, + }, + 1024, + Some(cancellation), + Duration::from_secs(10), + ) + .await?; + if response.checked_count != expected_count { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL write preflight response".to_string(), + }); + } + } + Ok(PreparedWslUnit { + generation: self.session.runtime_generation, + resource_id: resource_id.clone(), + request: environment_protocol::MutationUnitRequest { + resource_id, + operation_id: self.operation_id.clone(), + unit_id: unit.id.clone(), + initial_marker_json, + entries, + lock: None, + deadline_millis: 120_000, + }, + }) }) } - fn recheck_entries<'a>( - &'a self, - staged: &'a Self::Staged, - ) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.recheck().await }) - } - - fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.swap().await }) - } - - fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.verify().await }) - } - - fn restore<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.restore().await }) - } - - fn cleanup<'a>( + fn execute<'a>( &'a self, - staged: Self::Staged, - ) -> BoxFuture<'a, Result, AppError>> { + mut prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { Box::pin(async move { - staged.cleanup().await?; - Ok(Vec::new()) + prepared.request.lock = lock + .map(|lock| wire_lock(lock, &self.session.distro_name)) + .transpose()?; + let outcome = self + .workspace + .execute_worker_mutation( + prepared.generation, + &prepared.resource_id, + &prepared.request, + cancellation, + ) + .await?; + match outcome { + environment_protocol::MutationUnitOutcome::Succeeded { lock, cleanup } => { + let mut warnings = Vec::new(); + if let Some(cleanup) = cleanup { + let acknowledged = self + .workspace + .request_worker_control_for_generation( + prepared.generation, + environment_protocol::Message::AcknowledgeMutationUnit { + cleanup: cleanup.clone(), + }, + None, + Duration::from_secs(30), + ) + .await; + if !matches!( + acknowledged, + Ok(environment_protocol::Message::MutationAcknowledged { + ref resource_id + }) if resource_id == &cleanup.resource_id + ) { + warnings.push(MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: acknowledged + .as_ref() + .err() + .map(ToString::to_string), + }); + warnings.push(MutationWarning { + code: MutationWarningCode::CleanupMarkerRetained, + parameters: BTreeMap::new(), + technical_details: None, + }); + } + } + Ok(UnitTransactionReceipt { + lock: lock.map(host_lock_receipt).transpose()?, + warnings, + }) + } + environment_protocol::MutationUnitOutcome::Failed { + code, + phase, + parameters, + message, + } => Err(match code.as_str() { + "staleTarget" => AppError::StaleTarget, + "stalePayload" => AppError::StalePayload, + "deadlineExceeded" => AppError::WslCommandTimedOut, + "lockConflictSkill" => mutation_parameter(¶meters, "skillName") + .map(|skill_name| AppError::LockConflict { + target: crate::error::LockConflictTarget::Skill { + skill_name: skill_name.to_string(), + }, + }) + .unwrap_or_else(invalid_mutation_response), + "lockConflictRoot" => mutation_parameter(¶meters, "field") + .map(|field| AppError::LockConflict { + target: crate::error::LockConflictTarget::RootField { + field: field.to_string(), + }, + }) + .unwrap_or_else(invalid_mutation_response), + _ => AppError::ExecutionFailed { + message: format!("WSL mutation failed during {phase}: {message}"), + }, + }), + environment_protocol::MutationUnitOutcome::Cancelled => { + Err(AppError::MutationCancelled) + } + environment_protocol::MutationUnitOutcome::RecoveryRequired { + resource_id, + message, + } => Err(AppError::RecoveryRequired { + recovery_resource_id: RecoveryResourceId::parse(resource_id).map_err( + |error| AppError::ConfigurationCorrupted { + message: error.to_string(), + }, + )?, + message, + }), + } }) } } -pub async fn stage_entry_set( - session: &WslSession, - operation_id: &str, - unit_id: &str, - subject: RecoverySubject, - entries: Vec, - cancellation: CancellationSignal, - recovery_store: Arc, -) -> Result { - if entries.is_empty() { - return Err(AppError::Validation { - field: Some("entrySet".to_string()), - message: "WSL entry set must not be empty".to_string(), - }); - } - if let Some(entry) = entries - .iter() - .find(|entry| !entry.destination.starts_with('/')) +fn wire_lock( + lock: &PreparedLockMutation, + distro_name: &str, +) -> Result { + if !matches!( + &lock.target.environment, + EnvironmentRef::Wsl { distro_name: target } + if target.eq_ignore_ascii_case(distro_name) + ) || !lock.target.native_path.starts_with('/') + || lock.legacy_target.as_ref().is_some_and(|target| { + !matches!( + &target.environment, + EnvironmentRef::Wsl { distro_name: legacy } + if legacy.eq_ignore_ascii_case(distro_name) + ) || !target.native_path.starts_with('/') + }) { - return Err(AppError::UnsafePath { - path: entry.destination.clone(), - reason: "WSL destination must be an absolute POSIX path".to_string(), - }); - } - let partitioned = partition_entry_set(entries); - recheck_observed_entries( - session, - &partitioned.observations, - Some(cancellation.clone()), - ) - .await?; - let owner_id = operation_owner_id(operation_id, unit_id); - let operation_root = format!("/tmp/skill-deck-operation-{owner_id}"); - if partitioned.mutations.is_empty() { - return Ok(WslPreparedEntrySet { - session: session.clone(), - owner_id, - operation_root, - entries: partitioned.observations, - recovery_store, - recovery_marker: Mutex::new(None), - recovery_ref: None, - }); + return Err(AppError::StaleEnvironment); } - recheck_content_manifests(session, &partitioned.mutations, Some(cancellation.clone())).await?; - let request = materialize_stage_request(&partitioned.mutations)?; - let marker = recovery_marker_for_entry_set( - operation_id, - unit_id, - &owner_id, - &EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + let entry = match &lock.entry { + crate::storage::lock_plan::LockEntryMutation::Replace { key, replacement } => { + environment_protocol::MutationLockEntry::Replace { + key: key.clone(), + replacement_json: serde_json::to_vec(replacement)?, + } + } + crate::storage::lock_plan::LockEntryMutation::Remove { key } => { + environment_protocol::MutationLockEntry::Remove { key: key.clone() } + } + crate::storage::lock_plan::LockEntryMutation::MoveAndReplace { + from, + to, + replacement, + } => environment_protocol::MutationLockEntry::MoveAndReplace { + from: from.clone(), + to: to.clone(), + replacement_json: serde_json::to_vec(replacement)?, }, - subject, - &partitioned.mutations, - now_epoch_ms(), - )?; - let marker_ref = recovery_store.create(&marker).await?; - let entries = partitioned - .observations - .into_iter() - .chain(partitioned.mutations) - .collect(); - let prepared = WslPreparedEntrySet { - session: session.clone(), - owner_id, - operation_root, - entries, - recovery_store, - recovery_marker: Mutex::new(Some(marker)), - recovery_ref: Some(marker_ref), }; - if let Err(error) = run( - &prepared.session, - &STAGE_OPERATION, - vec![prepared.operation_root.clone(), prepared.owner_id.clone()], - request, - Duration::from_secs(60), - Some(cancellation), - ) - .await - { - let _ = prepared.cleanup().await; - return Err(error); - } - Ok(prepared) -} - -impl WslPreparedEntrySet { - pub async fn recheck(&self) -> Result<(), AppError> { - recheck_observed_entries(&self.session, &self.entries, None).await - } - - pub async fn swap(&mut self) -> Result<(), AppError> { - self.run_static(&SWAP_OPERATION).await?; - self.update_recovery( - RecoveryMarkerKind::InProgress, - Some(RecoveryEntryPhase::Swapped), - ) - .await - } - - pub async fn verify(&self) -> Result<(), AppError> { - self.run_static(&VERIFY_OPERATION).await?; - let keep = self - .entries - .iter() - .filter(|entry| matches!(entry.action, WslEntryAction::Keep)) - .cloned() - .collect::>(); - recheck_observed_entries(&self.session, &keep, None).await?; - self.update_recovery( - RecoveryMarkerKind::InProgress, - Some(RecoveryEntryPhase::Verified), - ) - .await - } - - pub async fn restore(&mut self) -> Result<(), AppError> { - match self.run_static(&RESTORE_OPERATION).await { - Ok(()) => { - self.update_recovery(RecoveryMarkerKind::CleanupOnly, None) - .await + Ok(environment_protocol::MutationLock { + target: lock.target.native_path.clone(), + legacy_target: lock + .legacy_target + .as_ref() + .map(|target| target.native_path.clone()), + schema: match lock.schema { + crate::core::lossless_lock::LockSchema::Global => { + environment_protocol::MutationLockSchema::Global } - Err(primary) => { - if self - .update_recovery( - RecoveryMarkerKind::RecoveryRequired, - Some(RecoveryEntryPhase::RestoreFailed), - ) - .await - .is_ok() - { - let resource_id = RecoveryResourceId::parse(self.owner_id.clone()) - .expect("operation owner is a SHA-256 recovery ID"); - Err(AppError::RecoveryRequired { - recovery_resource_id: resource_id, - message: primary.to_string(), - }) - } else { - Err(AppError::RestoreFailed { - message: primary.to_string(), - }) - } + crate::core::lossless_lock::LockSchema::Project => { + environment_protocol::MutationLockSchema::Project } - } - } - - pub async fn cleanup(self) -> Result<(), AppError> { - self.update_recovery(RecoveryMarkerKind::CleanupOnly, None) - .await?; - self.cleanup_files().await?; - if let Some(marker_ref) = &self.recovery_ref { - self.recovery_store.remove(marker_ref).await?; - } - Ok(()) - } - - async fn run_static(&self, operation: &WslOperationDescriptor) -> Result<(), AppError> { - if self.recovery_ref.is_none() { - return Ok(()); - } - run( - &self.session, - operation, - vec![self.operation_root.clone(), self.owner_id.clone()], - Vec::new(), - Duration::from_secs(30), - None, - ) - .await - } - - async fn cleanup_files(&self) -> Result<(), AppError> { - self.run_static(&CLEANUP_OPERATION).await - } - - async fn update_recovery( - &self, - kind: RecoveryMarkerKind, - phase: Option, - ) -> Result<(), AppError> { - let Some(marker_ref) = &self.recovery_ref else { - return Ok(()); - }; - let marker = self - .recovery_marker - .lock() - .map_err(|_| AppError::Io { - message: "WSL recovery marker state is unavailable".to_string(), - })? - .clone(); - let Some(marker) = marker else { - return Ok(()); - }; - let updated = next_recovery_marker(&marker, kind, phase); - self.recovery_store.update(marker_ref, &updated).await?; - *self.recovery_marker.lock().map_err(|_| AppError::Io { - message: "WSL recovery marker state is unavailable".to_string(), - })? = Some(updated); - Ok(()) - } + }, + entry, + root_replacements_json: lock + .root_replacements + .iter() + .map(|(field, value)| Ok((field.clone(), serde_json::to_vec(value)?))) + .collect::>()?, + expected_entries_json: lock + .expected + .entry_snapshots + .iter() + .map(|(key, snapshot)| { + Ok(( + key.clone(), + snapshot.value().map(serde_json::to_vec).transpose()?, + )) + }) + .collect::>()?, + expected_roots_json: lock + .expected + .root_snapshots + .iter() + .map(|(field, snapshot)| { + Ok(( + field.clone(), + snapshot.value().map(serde_json::to_vec).transpose()?, + )) + }) + .collect::>()?, + }) } -fn validate_observed_entries( - expected: &[WslEntryMutation], - actual: &[crate::environment::wsl::operations::entry::PosixEntryState], -) -> Result<(), AppError> { - if expected.len() != actual.len() - || actual.iter().enumerate().any(|(index, actual)| { - usize::try_from(actual.index).ok() != Some(index) - || actual.fingerprint != expected[index].expected_fingerprint - }) - { - return Err(AppError::StaleTarget); - } - Ok(()) +fn host_lock_receipt( + receipt: environment_protocol::MutationLockReceipt, +) -> Result { + Ok(LockCommitReceipt { + entry_snapshots: receipt + .entries_json + .into_iter() + .map(|(key, value)| { + value + .map(|bytes| serde_json::from_slice(&bytes)) + .transpose() + .map(crate::core::lossless_lock::LockEntrySnapshot::from_value) + .map(|snapshot| (key, snapshot)) + }) + .collect::>()?, + root_snapshots: receipt + .roots_json + .into_iter() + .map(|(field, value)| { + value + .map(|bytes| serde_json::from_slice(&bytes)) + .transpose() + .map(crate::core::lossless_lock::LockRootSnapshot::from_value) + .map(|snapshot| (field, snapshot)) + }) + .collect::>()?, + }) } -async fn recheck_observed_entries( - session: &WslSession, - entries: &[WslEntryMutation], - cancellation: Option, -) -> Result<(), AppError> { - if entries.is_empty() { - return Ok(()); - } - let paths = entries +fn mutation_parameter<'a>(parameters: &'a [(String, String)], name: &str) -> Option<&'a str> { + parameters .iter() - .map(|entry| entry.destination.clone()) - .collect::>(); - let actual = inspect_entries(session, &paths, cancellation.clone()).await?; - validate_observed_entries(entries, &actual)?; - recheck_content_manifests(session, entries, cancellation).await -} - -async fn recheck_content_manifests( - session: &WslSession, - entries: &[WslEntryMutation], - cancellation: Option, -) -> Result<(), AppError> { - for entry in entries { - let Some(expected) = &entry.expected_content_manifest_hash else { - continue; - }; - let actual = inspect_content_manifest(session, &entry.destination, cancellation.clone()) - .await - .map_err(|_| AppError::StaleTarget)?; - validate_content_manifest_hash(expected, actual.hash())?; - } - Ok(()) + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) } -fn validate_content_manifest_hash( - expected: &ContentManifestHash, - actual: &ContentManifestHash, -) -> Result<(), AppError> { - if actual != expected { - return Err(AppError::StaleTarget); - } - Ok(()) -} - -fn next_recovery_marker( - marker: &RecoveryMarker, - kind: RecoveryMarkerKind, - phase: Option, -) -> RecoveryMarker { - let mut updated = marker.clone(); - updated.kind = kind; - if let Some(phase) = phase { - for entry in &mut updated.entries { - entry.phase = phase; - } +fn invalid_mutation_response() -> AppError { + AppError::ConfigurationCorrupted { + message: "WSL Worker mutation response is missing an error parameter".to_string(), } - updated -} - -async fn run( - session: &WslSession, - operation: &WslOperationDescriptor, - args: Vec, - stdin: Vec, - timeout: Duration, - cancellation: Option, -) -> Result<(), AppError> { - let output = WslOperationExecutor::execute( - operation, - WslOperationRequest { - session: session.clone(), - args, - stdin, - timeout, - stdout_limit: 32, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_unit_response(&output.stdout) } fn operation_owner_id(operation_id: &str, unit_id: &str) -> String { @@ -865,1001 +746,236 @@ fn now_epoch_ms() -> u64 { } #[cfg(test)] +mod tests { + use super::*; + use crate::core::mutation::MutationKind; + use crate::environment::types::{SkillLocation, SkillLocationRef}; + + #[test] + fn lock_only_unit_uses_one_keep_entry_as_recovery_evidence() { + let environment = EnvironmentRef::Wsl { + distro_name: "Ubuntu".to_string(), + }; + let subject = RecoverySubject { + operation_kind: MutationKind::Install, + skill_name: "demo".to_string(), + context: SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + }; + let entries = vec![WslEntryMutation { + physical_target_digest: "target-1".to_string(), + destination: "/home/alice/.agents/skills/demo".to_string(), + expected_fingerprint: EntryFingerprint("entry-v1-current".to_string()), + expected_content_manifest_hash: None, + action: WslEntryAction::Keep, + }]; + + let mut marker = recovery_marker_for_entry_set( + "operation-1", + "unit-1", + &"f".repeat(64), + &environment, + subject, + &entries, + 1, + ) + .unwrap(); + + assert!(marker.entries.is_empty()); + add_lock_recovery_evidence(&mut marker, &entries, &environment).unwrap(); + assert_eq!(marker.entries.len(), 1); + assert!(marker.entries[0].backup.is_none()); + assert_eq!( + marker.entries[0].expected_state, + RecoveryExpectedEntryState::Present + ); + } +} + +#[cfg(all(test, target_os = "windows"))] #[allow( clippy::disallowed_methods, - reason = "内容落盘协议测试需要直接运行待验证的 shell 测试脚本" + reason = "真实 WSL 2 Mutation 门禁需要同步启动 wsl.exe 清理测试 fixture" )] -mod tests { +mod windows_worker_mutation_tests { use std::collections::BTreeMap; - use std::fs; - #[cfg(target_os = "linux")] - use std::io::Write; - #[cfg(target_os = "linux")] - use std::process::{Command, Output, Stdio}; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use tempfile::tempdir; + use std::process::Stdio; - use super::*; + use crate::application::mutation::coordinator::PreparedUnitExecutor; use crate::application::mutation::plan::{ ExecutionUnit, ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, RuntimeRevisions, }; - use crate::application::payload_session::PayloadLocalSource; - use crate::core::agent_definition::AgentId; - use crate::core::skill_payload::{build_skill_payload, SkillPayload}; - use crate::environment::recovery::RecoveryMarkerKind; - use crate::environment::recovery::{RecoveryFuture, RecoveryMarkerLoad}; + use crate::core::mutation::{CancellationSignal, MutationKind}; use crate::environment::runtime::{ ContextSnapshotRevision, ExecutionBackend, PhysicalParentIdentity, PhysicalTargetKey, }; use crate::environment::types::{ - EnvironmentRef, ResourceLocator, SkillLocation, SkillLocationRef, + normalized_wsl_distro_name, EnvironmentRef, ResourceLocator, SkillLocation, + SkillLocationRef, }; - #[cfg(target_os = "linux")] - use crate::environment::wsl::operations::entry::{parse_entry_states, ENTRY_STATE_SCRIPT}; - use crate::models::InstallMode; + use crate::environment::wsl::operations::entry::inspect_entries; + use crate::environment::wsl::operations::projection::project_targets; + use crate::environment::wsl::WslRuntime; - fn recovery_subject(environment: EnvironmentRef) -> RecoverySubject { - RecoverySubject { - operation_kind: crate::core::mutation::MutationKind::Install, + #[tokio::test] + #[ignore = "requires Windows with a WSL 2 distribution"] + async fn real_wsl2_worker_executes_and_acknowledges_one_remove_transaction() { + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let fixture = format!( + "/tmp/skill-deck-mutation-gate-{}", + uuid::Uuid::new_v4().simple() + ); + run_fixture( + &distro, + "set -eu; mkdir -p \"$1/demo\"; printf old > \"$1/demo/SKILL.md\"; chmod 500 \"$1\"", + &fixture, + ) + .await; + let _cleanup = FixtureCleanup { + distro: distro.clone(), + path: fixture.clone(), + }; + let runtime = WslRuntime::for_wsl_test(); + let workspace = runtime.workspace(&distro).unwrap(); + let session = runtime.connect(&distro).await.unwrap(); + let destination = format!("{fixture}/demo"); + let projection = project_targets(&workspace, std::slice::from_ref(&destination), None) + .await + .unwrap() + .remove(0); + let fingerprint = inspect_entries(&workspace, std::slice::from_ref(&destination), None) + .await + .unwrap() + .remove(0) + .fingerprint; + let key = PhysicalTargetKey { + backend: ExecutionBackend::WslPosix { + distro_name: normalized_wsl_distro_name(&distro), + }, + physical_parent: PhysicalParentIdentity::Wsl { + distro_name: normalized_wsl_distro_name(&distro), + device: projection.anchor_device, + inode: projection.anchor_inode, + }, + normalized_final_child_name: "demo".to_string(), + }; + let environment = EnvironmentRef::Wsl { + distro_name: distro.clone(), + }; + let unit = ExecutionUnit { + id: "remove-demo".to_string(), skill_name: "demo".to_string(), - context: SkillLocationRef { - environment, + source: None, + target: SkillLocationRef { + environment: environment.clone(), scope: SkillLocation::Global, }, - } - } - - fn payload_fixture(root: &std::path::Path) -> (SkillPayload, std::path::PathBuf) { - let source = root.join("source"); - fs::create_dir_all(source.join("scripts")).unwrap(); - fs::write(source.join("SKILL.md"), b"new").unwrap(); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); - let payload = build_skill_payload(&source).unwrap(); - let managed = root.join("payload"); - fs::create_dir_all(managed.join("blobs")).unwrap(); - for (id, blob) in &payload.blobs { - fs::write(managed.join("blobs").join(id), blob).unwrap(); - } - (payload, managed) - } - - #[cfg(target_os = "linux")] - fn fingerprint(path: &std::path::Path) -> String { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ENTRY_STATE_SCRIPT) - .arg("--") - .arg("inspect") - .arg(path) - .output() - .unwrap(); - parse_entry_states(&output.stdout, 1).unwrap()[0] - .fingerprint - .0 - .clone() - } - - #[cfg(target_os = "linux")] - fn run(script: &str, args: &[String], stdin: &[u8]) -> Output { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(script) - .arg("--") - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(stdin).unwrap(); - child.wait_with_output().unwrap() - } - - #[test] - fn materialize_stage_request_uses_fixed_width_tagged_records() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let request = materialize_stage_request(&[WslEntryMutation { - physical_target_digest: "target-v1-demo".to_string(), - destination: "/home/alice/.agents/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), + expected_revisions: RuntimeRevisions { + registry: "test".to_string(), + environment: "test".to_string(), + context: ContextSnapshotRevision::parse("context-v1-test").unwrap(), }, - }]) - .expect("encode stage request"); - let mut fields = request - .split(|byte| *byte == 0) - .map(|field| String::from_utf8(field.to_vec()).unwrap()) - .collect::>(); - assert_eq!(fields.pop().as_deref(), Some("")); - - assert_eq!(fields[0], "H"); - assert_eq!(fields[1], "1"); - assert_eq!(fields[3], "1"); - assert_eq!(fields.len() % 7, 0); - assert_eq!(fields[7], "E"); - assert_eq!(fields[8], "000000"); - let (records, remainder) = fields.as_chunks::<7>(); - assert!(remainder.is_empty()); - assert!(records.iter().skip(2).all(|record| record[0] == "M")); - } - - #[test] - fn materialize_stage_request_rejects_oversized_stdin_before_transport() { - let destination = format!("/{}", "a".repeat(16 * 1024 * 1024)); - - let result = materialize_stage_request(&[WslEntryMutation { - physical_target_digest: "target-v1-oversized".to_string(), - destination, - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Remove, - }]); - let error = match result { - Err(error) => error, - Ok(_) => panic!("oversized stage request was accepted"), - }; - - assert!(matches!( - error, - AppError::CapabilityUnavailable { capability, path: None } - if capability == "wslMaterializeRequestSize" - )); - } - - #[cfg(target_os = "linux")] - #[test] - fn materialize_stage_rejects_a_truncated_fixed_width_header() { - let temp = tempdir().unwrap(); - let operation_root = temp.path().join("skill-deck-operation-truncated"); - initialize_operation_root(&operation_root, "truncated"); - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "truncated"), - b"H\0\x31\0\x31\0\x30\0", - ); - - assert!( - !output.status.success(), - "truncated fixed-width header was accepted" - ); - } - - #[tokio::test] - async fn oversized_stage_request_does_not_create_a_recovery_marker() { - let store = Arc::new(CountingRecoveryStore::default()); - let recovery_store: Arc = store.clone(); - let destination = format!("/{}", "a".repeat(16 * 1024 * 1024)); - - let result = stage_entry_set( - &test_session(), - "oversized-operation", - "oversized-unit", - recovery_subject(EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), + primary_entry: Some(PreparedEntryMutation { + key: key.clone(), + destination: ResourceLocator { + environment, + native_path: destination.clone(), + }, + action: PreparedEntryAction::Remove, + reader_agent_ids: Vec::new(), }), - vec![WslEntryMutation { - physical_target_digest: "target-v1-oversized".to_string(), - destination, - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), + additional_entries: Vec::new(), + lock_mutation: None, + expected_targets: vec![ExpectedTargetEntry { + key, + fingerprint, expected_content_manifest_hash: None, - action: WslEntryAction::Remove, }], - CancellationSignal::default(), - recovery_store, - ) - .await; - let error = match result { - Err(error) => error, - Ok(_) => panic!("oversized stage request was accepted"), - }; - - assert!(matches!( - error, - AppError::CapabilityUnavailable { capability, path: None } - if capability == "wslMaterializeRequestSize" - )); - assert_eq!(store.create_count.load(Ordering::SeqCst), 0); - } - - #[cfg(target_os = "linux")] - #[test] - fn entry_set_stages_swaps_verifies_restores_and_cleans_full_payload() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let destination = temp.path().join("targets/demo"); - fs::create_dir_all(&destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - let operation_root = temp.path().join("skill-deck-operation-op-1"); - initialize_operation_root(&operation_root, "op-1"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-demo".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-1"), - &materialize_stage_request(&entries).unwrap(), - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-1"), - &[] - ) - .status - .success()); - let verify_output = run( - MATERIALIZE_SCRIPT, - &operation_args("verify", &operation_root, "op-1"), - &[], - ); - assert!( - verify_output.status.success(), - "{}", - String::from_utf8_lossy(&verify_output.stderr) - ); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"new"); - assert_eq!( - fs::read(destination.join("scripts/run.sh")).unwrap(), - b"#!/bin/sh\n" - ); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("restore", &operation_root, "op-1"), - &[] - ) - .status - .success()); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("cleanup", &operation_root, "op-1"), - &[] - ) - .status - .success()); - assert!(!operation_root.exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn stale_entry_recheck_prevents_every_final_write_in_the_set() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let operation_root = temp.path().join("skill-deck-operation-op-2"); - let destinations = [ - temp.path().join("targets/first"), - temp.path().join("targets/second"), - ]; - for destination in &destinations { - fs::create_dir_all(destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - } - initialize_operation_root(&operation_root, "op-2"); - let entries = destinations - .iter() - .enumerate() - .map(|(index, destination)| WslEntryMutation { - physical_target_digest: format!("target-v1-{index}"), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }) - .collect::>(); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-2"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - fs::write(destinations[1].join("external"), b"changed").unwrap(); - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-2"), - &[], - ); - - assert!(!output.status.success()); - for destination in &destinations { - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn existing_child_content_change_is_detected_before_wsl_swap() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let destination = temp.path().join("targets/demo"); - fs::create_dir_all(&destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - let operation_root = temp.path().join("skill-deck-operation-op-content"); - initialize_operation_root(&operation_root, "op-content"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-content".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-content"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - fs::write(destination.join("SKILL.md"), b"locally changed").unwrap(); - - assert!(!run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-content"), - &[] - ) - .status - .success()); - assert_eq!( - fs::read(destination.join("SKILL.md")).unwrap(), - b"locally changed" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn tampered_stage_is_rejected_before_any_wsl_final_write() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let operation_root = temp.path().join("skill-deck-operation-op-tampered"); - let destination = temp.path().join("targets/demo"); - fs::create_dir_all(&destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - initialize_operation_root(&operation_root, "op-tampered"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-demo".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-tampered"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - let stage = destination - .parent() - .unwrap() - .join(".skill-deck-stage-op-tampered-000000"); - fs::write(stage.join("unexpected.txt"), b"tampered").unwrap(); - - assert!(!run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-tampered"), - &[] - ) - .status - .success()); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - assert!(!destination - .parent() - .unwrap() - .join(".skill-deck-backup-op-tampered-000000") - .exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn symlink_and_remove_share_the_same_swap_and_restore_boundary() { - let temp = tempdir().unwrap(); - let operation_root = temp.path().join("skill-deck-operation-op-3"); - let canonical = temp.path().join("canonical/demo"); - let link = temp.path().join("agents/demo"); - let removed = temp.path().join("duplicates/demo"); - fs::create_dir_all(&canonical).unwrap(); - fs::create_dir_all(link.parent().unwrap()).unwrap(); - fs::create_dir_all(&removed).unwrap(); - fs::write(canonical.join("SKILL.md"), b"canonical").unwrap(); - fs::write(removed.join("SKILL.md"), b"private").unwrap(); - initialize_operation_root(&operation_root, "op-3"); - let entries = vec![ - WslEntryMutation { - physical_target_digest: "target-v1-link".to_string(), - destination: link.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&link)), - expected_content_manifest_hash: None, - action: WslEntryAction::Symlink { - target: "../canonical/demo".to_string(), - }, - }, - WslEntryMutation { - physical_target_digest: "target-v1-remove".to_string(), - destination: removed.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&removed)), - expected_content_manifest_hash: None, - action: WslEntryAction::Remove, - }, - ]; - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-3"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-3"), - &[] - ) - .status - .success()); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("verify", &operation_root, "op-3"), - &[] - ) - .status - .success()); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - assert_eq!(fs::read(link.join("SKILL.md")).unwrap(), b"canonical"); - assert!(!removed.exists()); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("restore", &operation_root, "op-3"), - &[] - ) - .status - .success()); - assert!(link.symlink_metadata().is_err()); - assert_eq!(fs::read(removed.join("SKILL.md")).unwrap(), b"private"); - } - - #[cfg(target_os = "linux")] - #[test] - fn stage_creates_a_missing_configured_root_before_staging_the_skill_child() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let destination = temp.path().join(".custom/skills/demo"); - let operation_root = temp.path().join("skill-deck-operation-op-missing-root"); - initialize_operation_root(&operation_root, "op-missing-root"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-missing-root".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-missing-root"), - &materialize_stage_request(&entries).unwrap(), - ); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(destination.parent().unwrap().is_dir()); - assert!(!destination.exists()); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-missing-root"), - &[] - ) - .status - .success()); - assert!(destination.join("SKILL.md").is_file()); - } - - #[test] - fn generic_unit_maps_to_one_wsl_entry_set_without_backend_branches_in_services() { - let temp = tempdir().unwrap(); - let (payload, _) = payload_fixture(temp.path()); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Keep, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Replace { - payload_id: payload.payload_id.clone(), - requested_mode: InstallMode::Symlink, - }, - &environment, - ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "unit-1".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment: environment.clone(), - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-test").unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; - let bindings = BTreeMap::from([( - payload.payload_id.clone(), - WslPayloadBinding { - source: PayloadLocalSource::WslManaged { - distro_name: "Ubuntu".to_string(), - payload_root: "/tmp/skill-deck-source-session/payload-demo".to_string(), - }, - manifest: payload.manifest(), - }, - )]); - - let mapped = prepare_wsl_mutations(&unit, &bindings, "Ubuntu").unwrap(); - - assert_eq!(mapped.len(), 2); - assert!(matches!(mapped[0].action, WslEntryAction::Keep)); - assert_eq!( - mapped[1].action, - WslEntryAction::Symlink { - target: "../../.agents/skills/demo".to_string(), - } - ); - let recovery = recovery_marker_for_entry_set( - "operation-1", - "unit-1", - "recovery-id", - &environment, - recovery_subject(environment.clone()), - &mapped, - 123, - ) - .unwrap(); - assert_eq!(recovery.kind, RecoveryMarkerKind::InProgress); - assert_eq!(recovery.entries.len(), 1); - assert!(recovery.entries.iter().all(|entry| { - entry.backup.as_ref().is_some_and(|backup| { - backup - .native_path - .contains(".skill-deck-backup-recovery-id-") - }) - })); - let verified = next_recovery_marker( - &recovery, - RecoveryMarkerKind::InProgress, - Some(RecoveryEntryPhase::Verified), - ); - let cleanup = next_recovery_marker(&verified, RecoveryMarkerKind::CleanupOnly, None); - assert_eq!(cleanup.kind, RecoveryMarkerKind::CleanupOnly); - assert!(cleanup - .entries - .iter() - .all(|entry| entry.phase == RecoveryEntryPhase::Verified)); - } - - #[test] - fn keep_entries_are_observed_without_entering_the_materialize_stage() { - let keep = WslEntryMutation { - physical_target_digest: "target-v1-keep".to_string(), - destination: "/home/alice/.agents/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-keep".to_string()), - expected_content_manifest_hash: Some( - crate::environment::content_manifest::ContentManifest::from_records(Vec::new()) - .unwrap() - .hash() - .clone(), - ), - action: WslEntryAction::Keep, - }; - let remove = WslEntryMutation { - physical_target_digest: "target-v1-remove".to_string(), - destination: "/home/alice/.claude/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-remove".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Remove, - }; - - let prepared = partition_entry_set(vec![keep.clone(), remove.clone()]); - - assert_eq!(prepared.observations, vec![keep]); - assert_eq!(prepared.mutations, vec![remove]); - let keep_only = partition_entry_set(prepared.observations.clone()); - assert!(keep_only.mutations.is_empty()); - } - - #[test] - fn replaced_keep_entry_is_rejected_as_stale() { - let expected = WslEntryMutation { - physical_target_digest: "target-v1-keep".to_string(), - destination: "/home/alice/.agents/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-before".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Keep, - }; - let actual = crate::environment::wsl::operations::entry::PosixEntryState { - index: 0, - kind: crate::environment::wsl::operations::entry::PosixEntryKind::Directory, - fingerprint: EntryFingerprint("entry-v1-after".to_string()), - link_target: None, - }; - - assert_eq!( - validate_observed_entries(std::slice::from_ref(&expected), &[actual]), - Err(AppError::StaleTarget) - ); - } - - #[test] - fn changed_keep_content_manifest_is_rejected_as_stale() { - let expected = - crate::environment::content_manifest::ContentManifest::from_records(Vec::new()) - .unwrap(); - let actual = crate::environment::content_manifest::ContentManifest::from_records(vec![ - crate::environment::content_manifest::ContentManifestRecord::directory("scripts") - .unwrap(), - ]) - .unwrap(); - - assert_eq!( - validate_content_manifest_hash(expected.hash(), actual.hash()), - Err(AppError::StaleTarget) - ); - } - - #[test] - fn generic_unit_maps_an_explicit_managed_directory_link() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Link { - target: ResourceLocator { - environment: environment.clone(), - native_path: "/home/alice/.local/share/skill-deck/libraries/lib-1/skills/demo" - .to_string(), - }, - }, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Keep, - &environment, + let executor = super::WslPreparedUnitExecutor::for_operation( + session, + workspace.clone(), + "mutation-gate", + MutationKind::Remove, ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "unit-explicit-link".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment: environment.clone(), - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-explicit-link").unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; - let mapped = prepare_wsl_mutations(&unit, &BTreeMap::new(), "Ubuntu").unwrap(); - - assert_eq!( - mapped[0].action, - WslEntryAction::Symlink { - target: "../../.local/share/skill-deck/libraries/lib-1/skills/demo".to_string(), - } + let blocked = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await; + assert!(blocked.is_err()); + assert_ne!( + inspect_entries(&workspace, std::slice::from_ref(&destination), None) + .await + .unwrap()[0] + .kind, + crate::environment::wsl::operations::entry::PosixEntryKind::Missing ); - } + run_fixture(&distro, "chmod 700 \"$1\"", &fixture).await; - #[test] - fn explicit_managed_directory_link_rejects_another_wsl_environment() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Link { - target: ResourceLocator { - environment: EnvironmentRef::Wsl { - distro_name: "Debian".to_string(), - }, - native_path: "/home/alice/.local/share/skill-deck/libraries/lib-1/skills/demo" - .to_string(), - }, - }, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Keep, - &environment, - ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "unit-cross-environment-link".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment, - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-cross-environment") - .unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await + .unwrap(); + let receipt = executor + .execute(prepared, None, CancellationSignal::default()) + .await + .unwrap(); + assert!(receipt.warnings.is_empty()); assert!(matches!( - prepare_wsl_mutations(&unit, &BTreeMap::new(), "Ubuntu"), - Err(AppError::StaleEnvironment) + inspect_entries(&workspace, &[destination], None) + .await + .unwrap()[0] + .kind, + crate::environment::wsl::operations::entry::PosixEntryKind::Missing )); } - #[test] - fn remove_unit_stages_agent_entry_before_primary_entry() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Remove, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Remove, - &environment, - ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "remove-demo".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment, - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-remove").unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; - - let mapped = prepare_wsl_mutations(&unit, &BTreeMap::new(), "Ubuntu").unwrap(); - - assert_eq!(mapped.len(), 2); - assert_eq!(mapped[0].destination, "/home/alice/.claude/skills/demo"); - assert_eq!(mapped[1].destination, "/home/alice/.agents/skills/demo"); - } - - fn mutation( - name: &str, - path: &str, - action: PreparedEntryAction, - environment: &EnvironmentRef, - ) -> PreparedEntryMutation { - PreparedEntryMutation { - key: PhysicalTargetKey { - backend: ExecutionBackend::WslPosix { - distro_name: "Ubuntu".to_string(), - }, - physical_parent: PhysicalParentIdentity::Wsl { - distro_name: "Ubuntu".to_string(), - device: 1, - inode: if name == "canonical" { 1 } else { 2 }, - }, - normalized_final_child_name: name.to_string(), - }, - destination: ResourceLocator { - environment: environment.clone(), - native_path: path.to_string(), - }, - action, - reader_agent_ids: vec![AgentId::parse("claude-code").unwrap()], - } - } - - #[cfg(target_os = "linux")] - fn initialize_operation_root(root: &std::path::Path, id: &str) { - fs::create_dir_all(root).unwrap(); - fs::write(root.join(".skill-deck-owner"), format!("1\n{id}\n")).unwrap(); - fs::write(root.join("recovery.json"), b"{}").unwrap(); - } - - #[cfg(target_os = "linux")] - fn operation_args(subcommand: &str, root: &std::path::Path, id: &str) -> Vec { - vec![ - subcommand.to_string(), - root.to_string_lossy().into_owned(), - id.to_string(), - ] - } - - fn test_session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - #[derive(Default)] - struct CountingRecoveryStore { - create_count: AtomicUsize, - } - - impl RecoveryMarkerStore for CountingRecoveryStore { - fn environment(&self) -> EnvironmentRef { - EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - } - } - - fn create<'a>( - &'a self, - marker: &'a RecoveryMarker, - ) -> RecoveryFuture<'a, Result> { - self.create_count.fetch_add(1, Ordering::SeqCst); - Box::pin(async move { - Ok(RecoveryMarkerRef { - resource_id: marker.resource_id.clone(), - environment: marker.environment.clone(), - managed_root: ResourceLocator { - environment: marker.environment.clone(), - native_path: format!( - "/tmp/skill-deck-operation-{}", - marker.resource_id.as_str() - ), - }, - }) - }) - } - - fn update<'a>( - &'a self, - _marker_ref: &'a RecoveryMarkerRef, - _marker: &'a RecoveryMarker, - ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) - } - - fn enumerate<'a>( - &'a self, - ) -> RecoveryFuture<'a, Result, AppError>> { - Box::pin(async { Ok(Vec::new()) }) - } - - fn remove<'a>( - &'a self, - _marker_ref: &'a RecoveryMarkerRef, - ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) - } - - fn cleanup<'a>( - &'a self, - _marker_ref: &'a RecoveryMarkerRef, - _marker: &'a RecoveryMarker, - ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) + async fn run_fixture(distro: &str, script: &str, path: &str) { + let status = crate::environment::wsl::wsl_command() + .args([ + "--distribution", + distro, + "--exec", + "/bin/sh", + "-c", + script, + "--", + path, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status() + .await + .unwrap(); + assert!(status.success()); + } + + struct FixtureCleanup { + distro: String, + path: String, + } + + impl Drop for FixtureCleanup { + fn drop(&mut self) { + let _ = std::process::Command::new("wsl.exe") + .args([ + "--distribution", + &self.distro, + "--exec", + "/bin/rm", + "-rf", + "--", + &self.path, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } } } diff --git a/src-tauri/src/environment/wsl/operations/mod.rs b/src-tauri/src/environment/wsl/operations/mod.rs index 42b104b5..9f80d099 100644 --- a/src-tauri/src/environment/wsl/operations/mod.rs +++ b/src-tauri/src/environment/wsl/operations/mod.rs @@ -2,12 +2,11 @@ pub mod acquire; pub mod atomic_file; pub mod content_manifest; pub mod directory_count; +pub mod document; pub mod entry; -pub mod eve; pub mod inspection; -pub mod library_content; +pub mod library; pub mod materialize; -pub mod path; pub mod path_metadata; pub mod projection; pub mod projects; diff --git a/src-tauri/src/environment/wsl/operations/path.rs b/src-tauri/src/environment/wsl/operations/path.rs deleted file mode 100644 index c4d0e39c..00000000 --- a/src-tauri/src/environment/wsl/operations/path.rs +++ /dev/null @@ -1,158 +0,0 @@ -use tokio::time::Duration; - -use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; -use crate::error::AppError; - -const MAP_HOST_PATH_SCRIPT: &str = include_str!("../scripts/path.sh"); -const MAP_STORAGE_PATH_TO_HOST_SCRIPT: &str = include_str!("../scripts/path.sh"); -const MAP_HOST_PATH_OPERATION: WslOperationDescriptor = - wsl_operation("path", "map-host", MAP_HOST_PATH_SCRIPT); -const MAP_STORAGE_PATH_TO_HOST_OPERATION: WslOperationDescriptor = - wsl_operation("path", "map-storage-host", MAP_STORAGE_PATH_TO_HOST_SCRIPT); - -pub async fn map_host_bridge_path( - session: &WslSession, - host_path: &str, - cancellation: Option, -) -> Result { - if host_path.is_empty() || host_path.contains('\0') { - return Err(AppError::Validation { - field: Some("bridgePath".to_string()), - message: "Host bridge path is invalid".to_string(), - }); - } - let output = WslOperationExecutor::execute( - &MAP_HOST_PATH_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![host_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 16 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_mapped_path(&output.stdout) -} - -pub fn parse_mapped_path(bytes: &[u8]) -> Result { - let fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.len() != 3 || fields[0] != b"1" || !fields[2].is_empty() { - return Err(protocol_error()); - } - let path = std::str::from_utf8(fields[1]).map_err(|_| protocol_error())?; - if !path.starts_with('/') || path.contains('\0') { - return Err(protocol_error()); - } - Ok(path.to_string()) -} - -pub async fn map_storage_path_to_host( - session: &WslSession, - storage_path: &str, - cancellation: Option, -) -> Result { - if !storage_path.starts_with('/') || storage_path.contains('\0') { - return Err(AppError::Validation { - field: Some("storagePath".to_string()), - message: "WSL storage path must be absolute".to_string(), - }); - } - let output = WslOperationExecutor::execute( - &MAP_STORAGE_PATH_TO_HOST_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![storage_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 16 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_host_storage_path(&output.stdout) -} - -pub fn parse_host_storage_path(bytes: &[u8]) -> Result { - let fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.len() != 3 || fields[0] != b"1" || !fields[2].is_empty() || fields[1].is_empty() { - return Err(protocol_error()); - } - let path = std::str::from_utf8(fields[1]).map_err(|_| protocol_error())?; - if path.contains('\0') { - return Err(protocol_error()); - } - Ok(path.to_string()) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL Host bridge path response".to_string(), - } -} - -#[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "路径映射协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - #[cfg(target_os = "linux")] - use std::fs; - #[cfg(target_os = "linux")] - use std::process::Command; - - #[cfg(target_os = "linux")] - use tempfile::tempdir; - - use super::*; - - #[cfg(target_os = "linux")] - #[test] - fn host_path_mapping_returns_only_a_versioned_absolute_posix_path() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempdir().unwrap(); - let tool = temp.path().join("wslpath"); - fs::write(&tool, "#!/bin/sh\nprintf '/custom/c/Bridge Path\\n'\n").unwrap(); - fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(MAP_HOST_PATH_SCRIPT) - .arg("--") - .arg("map-host") - .arg(r"C:\Temp\Bridge Path") - .env("PATH", temp.path()) - .output() - .unwrap(); - assert!(output.status.success()); - assert_eq!( - parse_mapped_path(&output.stdout).unwrap(), - "/custom/c/Bridge Path" - ); - assert!(parse_mapped_path(b"1\0relative\0").is_err()); - assert!(parse_mapped_path(b"2\0/mnt/c/bridge\0").is_err()); - } - - #[test] - fn storage_path_mapping_returns_only_one_versioned_windows_path() { - assert_eq!( - parse_host_storage_path(b"1\0C:\\Code\\App\0").unwrap(), - r"C:\Code\App" - ); - assert_eq!( - parse_host_storage_path(b"1\0\\\\wsl.localhost\\Ubuntu\\home\\me\\app\0").unwrap(), - r"\\wsl.localhost\Ubuntu\home\me\app" - ); - assert!(parse_host_storage_path(b"2\0C:\\Code\\App\0").is_err()); - assert!(parse_host_storage_path(b"1\0\0").is_err()); - } -} diff --git a/src-tauri/src/environment/wsl/operations/path_metadata.rs b/src-tauri/src/environment/wsl/operations/path_metadata.rs index 00ca3b5c..1207d2fb 100644 --- a/src-tauri/src/environment/wsl/operations/path_metadata.rs +++ b/src-tauri/src/environment/wsl/operations/path_metadata.rs @@ -1,21 +1,17 @@ -use tokio::time::Duration; +use std::time::Duration; -use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - decode_nul_records, wsl_operation, WslOperationDescriptor, WslOperationExecutor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::{WslSession, WslWorkspace}; +use futures_util::{stream, StreamExt}; + +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -pub(crate) const PATH_METADATA_SCRIPT: &str = include_str!("../scripts/path-metadata.sh"); -const PATH_METADATA_OPERATION: WslOperationDescriptor = - wsl_operation("path-metadata", "inspect", PATH_METADATA_SCRIPT); +const PATH_METADATA_DEADLINE_MILLIS: u64 = 20_000; +const PATH_METADATA_AGGREGATE_LIMIT: u32 = 8 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PathMetadataQuery { pub path: String, - pub inspect_content: bool, + pub content_limit: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,104 +38,176 @@ pub struct PathMetadataFact { pub path: String, pub kind: PathMetadataKind, pub content: PathMetadataContent, -} - -pub async fn inspect( - session: &WslSession, - queries: &[PathMetadataQuery], - cancellation: Option, -) -> Result, AppError> { - if queries.is_empty() || queries.iter().any(|query| !query.path.starts_with('/')) { - return Err(AppError::Validation { - field: Some("pathMetadata.queries".to_string()), - message: "WSL path metadata requires absolute paths".to_string(), - }); - } - let mut args = Vec::with_capacity(queries.len() * 2); - for query in queries { - args.push(query.path.clone()); - args.push(if query.inspect_content { "1" } else { "0" }.to_string()); - } - let output = WslOperationExecutor::execute( - &PATH_METADATA_OPERATION, - WslOperationRequest { - session: session.clone(), - args, - stdin: Vec::new(), - timeout: Duration::from_secs(20), - stdout_limit: queries.len().saturating_mul(1024 * 1024 + 1024), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - let facts = parse_path_metadata(&output.stdout)?; - if facts.len() != queries.len() - || facts - .iter() - .zip(queries) - .any(|(fact, query)| fact.path != query.path) - { - return Err(protocol_error()); - } - Ok(facts) + pub truncated: bool, } impl WslWorkspace { pub(crate) async fn inspect_path_metadata( &self, queries: Vec, - cancellation: Option, ) -> Result, AppError> { - self.with_session_retry(move |session| { - let queries = queries.clone(); - let cancellation = cancellation.clone(); - async move { inspect(&session, &queries, cancellation).await } - }) + if queries.is_empty() + || queries.iter().any(|query| { + !query.path.starts_with('/') + || query.content_limit.is_some_and(|limit| { + limit == 0 || limit > environment_protocol::MAX_PATH_CONTENT_BYTES_PER_FILE + }) + }) + { + return Err(AppError::Validation { + field: Some("pathMetadata.queries".to_string()), + message: "WSL path metadata requires absolute paths".to_string(), + }); + } + let deadline = + tokio::time::Instant::now() + Duration::from_millis(PATH_METADATA_DEADLINE_MILLIS); + let mut pages = Vec::new(); + let mut page_start = 0; + while page_start < queries.len() { + let page_end = metadata_page_end(&queries, page_start); + pages.push(queries[page_start..page_end].to_vec()); + page_start = page_end; + } + let mut responses = stream::iter(pages.into_iter().enumerate().map(|(index, batch)| { + let workspace = self.clone(); + async move { inspect_metadata_page(&workspace, index, batch, deadline).await } + })) + .buffer_unordered(environment_protocol::MAX_CONCURRENT_READ_REQUESTS) + .collect::>() .await + .into_iter() + .collect::, AppError>>()?; + responses.sort_by_key(|(index, _)| *index); + let facts = responses.into_iter().flat_map(|(_, facts)| facts).collect(); + Ok(facts) } } -pub fn parse_path_metadata(bytes: &[u8]) -> Result, AppError> { - let records = decode_nul_records(bytes); - if records.first().map(String::as_str) != Some("1") { +async fn inspect_metadata_page( + workspace: &WslWorkspace, + index: usize, + batch: Vec, + deadline: tokio::time::Instant, +) -> Result<(usize, Vec), AppError> { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let deadline_millis = u64::try_from(remaining.as_millis()) + .unwrap_or(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS) + .min(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS); + if deadline_millis == 0 { + return Err(AppError::WslCommandTimedOut); + } + let response: environment_protocol::PathMetadataResponse = tokio::time::timeout_at( + deadline, + workspace.request_worker_payload(environment_protocol::Message::InspectPaths { + request: environment_protocol::PathMetadataRequest { + queries: batch + .iter() + .map(|query| environment_protocol::PathMetadataQuery { + path: query.path.clone(), + content_limit: query.content_limit, + }) + .collect(), + aggregate_content_limit: PATH_METADATA_AGGREGATE_LIMIT, + deadline_millis, + }, + }), + ) + .await + .map_err(|_| AppError::WslCommandTimedOut)??; + if response.facts.len() != batch.len() + || response + .facts + .iter() + .zip(&batch) + .any(|(fact, query)| fact.path != query.path) + { return Err(protocol_error()); } - let mut facts = Vec::new(); - let mut index = 1; - while index < records.len() { - if records.get(index).map(String::as_str) != Some("path") || index + 4 >= records.len() { - return Err(protocol_error()); + Ok(( + index, + response + .facts + .into_iter() + .map(|fact| PathMetadataFact { + path: fact.path, + kind: match fact.kind { + environment_protocol::PathMetadataKind::Missing => PathMetadataKind::Missing, + environment_protocol::PathMetadataKind::Directory => { + PathMetadataKind::Directory + } + environment_protocol::PathMetadataKind::SymlinkDirectory => { + PathMetadataKind::SymlinkDirectory + } + environment_protocol::PathMetadataKind::SymlinkOther => { + PathMetadataKind::SymlinkOther + } + environment_protocol::PathMetadataKind::Other => PathMetadataKind::Other, + environment_protocol::PathMetadataKind::BrokenLink => { + PathMetadataKind::BrokenLink + } + environment_protocol::PathMetadataKind::Inaccessible => { + PathMetadataKind::Inaccessible + } + }, + content: match fact.content { + environment_protocol::PathMetadataContent::NotRequested => { + PathMetadataContent::NotRequested + } + environment_protocol::PathMetadataContent::Empty => PathMetadataContent::Empty, + environment_protocol::PathMetadataContent::Unreadable => { + PathMetadataContent::Unreadable + } + environment_protocol::PathMetadataContent::Bytes(bytes) => { + PathMetadataContent::Bytes(bytes) + } + }, + truncated: fact.content_truncated, + }) + .collect(), + )) +} + +fn metadata_page_end(queries: &[PathMetadataQuery], start: usize) -> usize { + let mut end = start; + let mut content_budget = 0u32; + while end < queries.len() && end - start < environment_protocol::MAX_INSPECTION_ROOTS { + let requested = queries[end].content_limit.unwrap_or_default(); + if end > start && content_budget.saturating_add(requested) > PATH_METADATA_AGGREGATE_LIMIT { + break; } - let kind = match records[index + 2].as_str() { - "missing" => PathMetadataKind::Missing, - "directory" => PathMetadataKind::Directory, - "symlink-directory" => PathMetadataKind::SymlinkDirectory, - "symlink-other" => PathMetadataKind::SymlinkOther, - "other" => PathMetadataKind::Other, - "broken-link" => PathMetadataKind::BrokenLink, - "inaccessible" => PathMetadataKind::Inaccessible, - _ => return Err(protocol_error()), - }; - let content = match records[index + 3].as_str() { - "none" => PathMetadataContent::NotRequested, - "eve-unreadable" => PathMetadataContent::Unreadable, - "eve-empty" => PathMetadataContent::Empty, - "eve" => PathMetadataContent::Bytes(records[index + 4].as_bytes().to_vec()), - _ => return Err(protocol_error()), - }; - facts.push(PathMetadataFact { - path: records[index + 1].clone(), - kind, - content, - }); - index += 5; + content_budget = content_budget.saturating_add(requested); + end += 1; } - Ok(facts) + end } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL path metadata protocol response".to_string(), + message: "invalid WSL Worker path metadata response".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn queries(count: usize, content_limit: Option) -> Vec { + (0..count) + .map(|index| PathMetadataQuery { + path: format!("/tmp/{index}"), + content_limit, + }) + .collect() + } + + #[test] + fn metadata_pages_respect_count_and_worst_case_content_limits() { + let frontmatter = queries(100, Some(256 * 1024)); + assert_eq!(metadata_page_end(&frontmatter, 0), 32); + assert_eq!(metadata_page_end(&frontmatter, 32), 64); + + let facts_only = queries(300, None); + assert_eq!(metadata_page_end(&facts_only, 0), 256); + assert_eq!(metadata_page_end(&facts_only, 256), 300); } } diff --git a/src-tauri/src/environment/wsl/operations/projection.rs b/src-tauri/src/environment/wsl/operations/projection.rs index 7fe7ec2b..07336621 100644 --- a/src-tauri/src/environment/wsl/operations/projection.rs +++ b/src-tauri/src/environment/wsl/operations/projection.rs @@ -1,17 +1,11 @@ -use tokio::time::Duration; +use std::future::Future; +use std::time::Duration; use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "2"; -pub(crate) const PROJECT_TARGETS_SCRIPT: &str = include_str!("../scripts/projection.sh"); -const PROJECT_TARGETS_OPERATION: WslOperationDescriptor = - wsl_operation("projection", "project-targets", PROJECT_TARGETS_SCRIPT); +const PROJECTION_DEADLINE_MILLIS: u64 = 10_000; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectedPosixTarget { @@ -24,7 +18,7 @@ pub struct ProjectedPosixTarget { } pub async fn project_targets( - session: &WslSession, + workspace: &WslWorkspace, destinations: &[String], cancellation: Option, ) -> Result, AppError> { @@ -34,209 +28,178 @@ pub async fn project_targets( message: "WSL target projection requires absolute destinations".to_string(), }); } - let output = WslOperationExecutor::execute( - &PROJECT_TARGETS_OPERATION, - WslOperationRequest { - session: session.clone(), - args: destinations.to_vec(), - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: destinations - .len() - .saturating_mul(16 * 1024) - .saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, + project_targets_with( + destinations, + cancellation, + |request, cancellation| async move { + let message = environment_protocol::Message::ProjectTargets { request }; + match cancellation { + Some(cancellation) => { + workspace + .request_worker_payload_with_cancellation(message, cancellation) + .await + } + None => workspace.request_worker_payload(message).await, + } }, ) - .await?; - parse_projected_targets(&output.stdout, destinations.len()) + .await } -pub fn parse_projected_targets( - bytes: &[u8], - expected: usize, -) -> Result, AppError> { - let mut fields = bytes.split(|byte| *byte == 0); - if text(fields.next())? != PROTOCOL_VERSION { - return Err(protocol_error()); - } - let mut targets = Vec::with_capacity(expected); - while let Some(tag) = fields.next() { - if tag.is_empty() { - continue; - } - if text(Some(tag))? != "P" { - return Err(protocol_error()); +async fn project_targets_with( + destinations: &[String], + cancellation: Option, + mut send: Send, +) -> Result, AppError> +where + Send: FnMut(environment_protocol::ProjectionRequest, Option) -> SendFuture, + SendFuture: Future>, +{ + let deadline = tokio::time::Instant::now() + Duration::from_millis(PROJECTION_DEADLINE_MILLIS); + let mut projected = Vec::with_capacity(destinations.len()); + for (batch_index, batch) in destinations + .chunks(environment_protocol::MAX_INSPECTION_ROOTS) + .enumerate() + { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let deadline_millis = u64::try_from(remaining.as_millis()) + .unwrap_or(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS) + .min(environment_protocol::MAX_REQUEST_DEADLINE_MILLIS); + if deadline_millis == 0 { + return Err(AppError::WslCommandTimedOut); } - let index = parse(fields.next())?; - let anchor_device = parse(fields.next())?; - let anchor_inode = parse(fields.next())?; - let physical_destination = text(fields.next())?.to_string(); - let relative = text(fields.next())?; - let storage_projection = text(fields.next())?.to_string(); - let relative_components = relative.split('/').map(str::to_string).collect::>(); - if index as usize != targets.len() - || !physical_destination.starts_with('/') - || storage_projection.trim().is_empty() - || relative_components.is_empty() - || relative_components - .iter() - .any(|component| component.is_empty() || matches!(component.as_str(), "." | "..")) - { + let response = tokio::time::timeout_at( + deadline, + send( + environment_protocol::ProjectionRequest { + destinations: batch.to_vec(), + deadline_millis, + }, + cancellation.clone(), + ), + ) + .await + .map_err(|_| AppError::WslCommandTimedOut)??; + if response.targets.len() != batch.len() { return Err(protocol_error()); } - targets.push(ProjectedPosixTarget { - index, - anchor_device, - anchor_inode, - physical_destination, - relative_components, - storage_projection, - }); - } - if targets.len() != expected { - return Err(protocol_error()); + let offset = batch_index * environment_protocol::MAX_INSPECTION_ROOTS; + let mut batch_targets = response + .targets + .into_iter() + .enumerate() + .map(|(index, target)| { + let physical_destination = + String::from_utf8(target.physical_destination).map_err(|_| protocol_error())?; + let relative_components = target + .relative_components + .into_iter() + .map(String::from_utf8) + .collect::, _>>() + .map_err(|_| protocol_error())?; + if !physical_destination.starts_with('/') + || target.storage_projection.trim().is_empty() + || relative_components.is_empty() + || relative_components.iter().any(|component| { + component.is_empty() + || matches!(component.as_str(), "." | "..") + || component.contains('/') + }) + { + return Err(protocol_error()); + } + Ok(ProjectedPosixTarget { + index: (offset + index) as u32, + anchor_device: target.anchor_device, + anchor_inode: target.anchor_inode, + physical_destination, + relative_components, + storage_projection: target.storage_projection, + }) + }) + .collect::, AppError>>()?; + projected.append(&mut batch_targets); } - Ok(targets) -} - -fn text(field: Option<&[u8]>) -> Result<&str, AppError> { - std::str::from_utf8(field.ok_or_else(protocol_error)?).map_err(|_| protocol_error()) -} - -fn parse(field: Option<&[u8]>) -> Result { - text(field)?.parse().map_err(|_| protocol_error()) + Ok(projected) } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL target projection protocol response".to_string(), + message: "invalid WSL Worker target projection response".to_string(), } } #[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "路径投影协议测试需要直接调用 wslpath 并运行 shell 测试脚本" -)] mod tests { - #[cfg(target_os = "linux")] - use std::fs; - #[cfg(target_os = "linux")] - use std::process::{Command, Stdio}; - - #[cfg(target_os = "linux")] - use tempfile::tempdir; + use std::sync::{Arc, Mutex}; use super::*; - #[cfg(target_os = "linux")] - fn wslpath_available() -> bool { - Command::new("wslpath") - .args(["-w", "/"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) - } - - #[test] - fn parser_preserves_host_and_wsl_storage_projection_evidence() { - let response = [ - "2", - "P", - "0", - "7", - "11", - "/mnt/c/work/Foo", - "Foo", - r"C:\work", - "P", - "1", - "8", - "12", - "/home/alice/foo", - "foo", - r"\\wsl.localhost\Ubuntu\home\alice", - ] - .join("\0"); - - let projected = parse_projected_targets(response.as_bytes(), 2).unwrap(); - - assert_eq!(projected[0].storage_projection, r"C:\work"); - assert_eq!( - projected[1].storage_projection, - r"\\wsl.localhost\Ubuntu\home\alice" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn protocol_projects_missing_roots_from_the_resolved_existing_ancestor() { - if !wslpath_available() { - return; - } - let temp = tempdir().unwrap(); - let destination = temp.path().join(".custom/skills/demo"); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(PROJECT_TARGETS_SCRIPT) - .arg("--") - .arg("project-targets") - .arg(&destination) - .output() - .unwrap(); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(output.stdout.starts_with(b"2\0")); - let projected = parse_projected_targets(&output.stdout, 1).unwrap(); - assert_eq!(projected.len(), 1); - assert_eq!( - projected[0].physical_destination, - destination.to_string_lossy() - ); - assert_eq!( - projected[0].relative_components, - vec![".custom", "skills", "demo"] - ); - assert!(!projected[0].storage_projection.is_empty()); - assert!(!destination.parent().unwrap().exists()); + #[tokio::test] + async fn projection_batches_preserve_order_and_the_transport_limit() { + let destinations = (0..513) + .map(|index| format!("/tmp/skill-{index}")) + .collect::>(); + let batch_sizes = Arc::new(Mutex::new(Vec::new())); + let observed = batch_sizes.clone(); + let projected = project_targets_with(&destinations, None, move |request, _| { + observed.lock().unwrap().push(request.destinations.len()); + async move { + Ok(environment_protocol::ProjectionResponse { + targets: request + .destinations + .into_iter() + .map(|destination| environment_protocol::ProjectedTarget { + anchor_device: 1, + anchor_inode: 2, + physical_destination: destination.as_bytes().to_vec(), + relative_components: vec![destination + .rsplit('/') + .next() + .unwrap() + .as_bytes() + .to_vec()], + storage_projection: r"\\wsl.localhost\Ubuntu\tmp".to_string(), + }) + .collect(), + }) + } + }) + .await + .unwrap(); + + assert_eq!(*batch_sizes.lock().unwrap(), vec![256, 256, 1]); + assert_eq!(projected.len(), destinations.len()); + assert_eq!(projected[256].index, 256); + assert_eq!(projected[512].physical_destination, destinations[512]); } - #[cfg(target_os = "linux")] - #[test] - fn protocol_resolves_an_existing_symlink_before_appending_missing_components() { - if !wslpath_available() { - return; - } - use std::os::unix::fs::symlink; - - let temp = tempdir().unwrap(); - let physical = temp.path().join("physical"); - let logical = temp.path().join("logical"); - fs::create_dir(&physical).unwrap(); - symlink(&physical, &logical).unwrap(); - let destination = logical.join("skills/demo"); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(PROJECT_TARGETS_SCRIPT) - .arg("--") - .arg("project-targets") - .arg(&destination) - .output() - .unwrap(); - - assert!(output.status.success()); - let projected = parse_projected_targets(&output.stdout, 1).unwrap(); - assert_eq!( - projected[0].physical_destination, - physical.join("skills/demo").to_string_lossy() - ); - assert_eq!(projected[0].relative_components, vec!["skills", "demo"]); + #[cfg(target_os = "windows")] + #[tokio::test] + #[ignore = "requires SKILL_DECK_TEST_WSL_DISTRO and a matching real WSL Worker"] + async fn real_wsl_worker_projects_and_inspects_more_than_one_transport_batch() { + use crate::environment::wsl::operations::entry::inspect_entries; + use crate::environment::wsl::WslRuntime; + + let distro_name = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").expect("set SKILL_DECK_TEST_WSL_DISTRO"); + let runtime = WslRuntime::for_wsl_test(); + runtime.connect(&distro_name).await.expect("connect Worker"); + let workspace = runtime.workspace(&distro_name).unwrap(); + let paths = (0..513) + .map(|index| format!("/tmp/skill-deck-batch-{index}")) + .collect::>(); + + let projected = project_targets(&workspace, &paths, None) + .await + .expect("project all paths"); + let entries = inspect_entries(&workspace, &paths, None) + .await + .expect("inspect all paths"); + + assert_eq!(projected.len(), paths.len()); + assert_eq!(entries.len(), paths.len()); + assert_eq!(projected[512].index, 512); + assert_eq!(entries[512].index, 512); } } diff --git a/src-tauri/src/environment/wsl/operations/projects.rs b/src-tauri/src/environment/wsl/operations/projects.rs index 2fe7b210..c21c9bf8 100644 --- a/src-tauri/src/environment/wsl/operations/projects.rs +++ b/src-tauri/src/environment/wsl/operations/projects.rs @@ -1,51 +1,45 @@ -use tokio::time::Duration; - use crate::core::projects::{ProjectPathSemantics, ProjectsFile}; use crate::environment::path_mapping::{windows_storage_owner, WindowsStorageOwner}; use crate::environment::types::{ - EnvironmentRef, ProjectInfo, ProjectStorageInfo, RegisteredProject, ResourceLocator, - StorageAccess, -}; -use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, + EnvironmentRef, ProjectInfo, ProjectStorageInfo, RegisteredProject, StorageAccess, }; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -use crate::storage::atomic_document::AtomicDocumentIo; -const PROJECT_STORAGE_SCRIPT: &str = include_str!("../scripts/projects.sh"); -const PROJECT_STORAGE_OPERATION: WslOperationDescriptor = - wsl_operation("projects", "project-storage", PROJECT_STORAGE_SCRIPT); +const PROJECT_STORAGE_DEADLINE_MILLIS: u64 = 10_000; + +pub struct ProjectsSnapshot { + pub projects: Vec, + pub revision: Option, + pub generation: u64, +} pub async fn project_infos( session: &WslSession, + workspace: &WslWorkspace, bindings: Vec, ) -> Result, AppError> { if bindings.is_empty() { return Ok(Vec::new()); } - let output = WslOperationExecutor::execute( - &PROJECT_STORAGE_OPERATION, - WslOperationRequest { - session: session.clone(), - args: bindings - .iter() - .map(|binding| binding.native_path.clone()) - .collect(), - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: bindings.len().saturating_mul(16 * 1024).saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; + let response: environment_protocol::MapWindowsPathsResponse = workspace + .request_worker_payload(environment_protocol::Message::MapPathsToWindows { + request: environment_protocol::MapWindowsPathsRequest { + paths: bindings + .iter() + .map(|binding| binding.native_path.clone()) + .collect(), + deadline_millis: PROJECT_STORAGE_DEADLINE_MILLIS, + }, + }) + .await?; let environment = EnvironmentRef::Wsl { distro_name: session.distro_name.clone(), }; - let storage = parse_project_storage(&environment, bindings.len(), &output.stdout)?; + if response.mapped.len() != bindings.len() { + return Err(protocol_error()); + } + let storage = project_storage_from_mapped(&environment, response.mapped)?; Ok(bindings .into_iter() .zip(storage) @@ -53,93 +47,98 @@ pub async fn project_infos( .collect()) } -pub fn parse_project_storage( +fn project_storage_from_mapped( environment: &EnvironmentRef, - project_count: usize, - bytes: &[u8], + mapped_paths: Vec>, ) -> Result, AppError> { let EnvironmentRef::Wsl { distro_name } = environment else { return Err(AppError::StaleEnvironment); }; - let mut fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.last().is_some_and(|field| field.is_empty()) { - fields.pop(); - } - if fields.first().copied() != Some(b"1".as_slice()) || fields.len() != 1 + project_count * 2 { - return Err(protocol_error()); - } - let (records, remainder) = fields[1..].as_chunks::<2>(); - debug_assert!(remainder.is_empty()); - records - .iter() - .map(|record| match record[0] { - b"error" => Ok(ProjectStorageInfo { + mapped_paths + .into_iter() + .map(|mapped| match mapped { + None => Ok(ProjectStorageInfo { access: StorageAccess::Unsupported, owner: None, }), - b"ok" => { - let mapped = std::str::from_utf8(record[1]).map_err(|_| protocol_error())?; - Ok(match windows_storage_owner(mapped) { - WindowsStorageOwner::Windows => ProjectStorageInfo { - access: StorageAccess::CrossStorage, - owner: Some(EnvironmentRef::Native), - }, - WindowsStorageOwner::Wsl { distro_name: owner } - if owner.eq_ignore_ascii_case(distro_name) => - { - ProjectStorageInfo { - access: StorageAccess::Native, - owner: Some(environment.clone()), - } + Some(mapped) => Ok(match windows_storage_owner(&mapped) { + WindowsStorageOwner::Windows => ProjectStorageInfo { + access: StorageAccess::CrossStorage, + owner: Some(EnvironmentRef::Native), + }, + WindowsStorageOwner::Wsl { distro_name: owner } + if owner.eq_ignore_ascii_case(distro_name) => + { + ProjectStorageInfo { + access: StorageAccess::Native, + owner: Some(environment.clone()), } - WindowsStorageOwner::Wsl { distro_name: owner } => ProjectStorageInfo { - access: StorageAccess::CrossStorage, - owner: Some(EnvironmentRef::Wsl { distro_name: owner }), - }, - WindowsStorageOwner::Unknown => ProjectStorageInfo { - access: StorageAccess::Unsupported, - owner: None, - }, - }) - } - _ => Err(protocol_error()), + } + WindowsStorageOwner::Wsl { distro_name: owner } => ProjectStorageInfo { + access: StorageAccess::CrossStorage, + owner: Some(EnvironmentRef::Wsl { distro_name: owner }), + }, + WindowsStorageOwner::Unknown => ProjectStorageInfo { + access: StorageAccess::Unsupported, + owner: None, + }, + }), }) .collect() } -pub async fn read_projects(session: &WslSession) -> Result, AppError> { - let target = projects_locator(session); - let io = WslAtomicDocumentIo::from_active_session(session.clone()); - match io.read_optional(&target).await? { - Some(bytes) => Ok(serde_json::from_slice::(&bytes)?.projects), - None => Ok(Vec::new()), - } +pub async fn read_projects( + session: &WslSession, + workspace: &WslWorkspace, +) -> Result, AppError> { + Ok(read_projects_snapshot(session, workspace).await?.projects) +} + +pub async fn read_projects_snapshot( + session: &WslSession, + workspace: &WslWorkspace, +) -> Result { + let snapshot = workspace + .read_optional_document_snapshot_once( + projects_path(session), + environment_protocol::MAX_DOCUMENT_BYTES, + ) + .await?; + let projects = match snapshot.bytes { + Some(bytes) => serde_json::from_slice::(&bytes)?.projects, + None => Vec::new(), + }; + Ok(ProjectsSnapshot { + projects, + revision: snapshot.revision, + generation: snapshot.generation, + }) } pub async fn write_projects( session: &WslSession, + workspace: &WslWorkspace, projects: Vec, -) -> Result, AppError> { + generation: u64, + expected_revision: Option, +) -> Result<(), AppError> { let file = ProjectsFile::new(projects, ProjectPathSemantics::Posix); - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic( - &projects_locator(session), + workspace + .write_document_atomic( + generation, + projects_path(session), + expected_revision, serde_json::to_vec_pretty(&file)?, ) .await?; - read_projects(session).await + Ok(()) } -fn projects_locator(session: &WslSession) -> ResourceLocator { - ResourceLocator { - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), - }, - native_path: format!( - "{}/.skill-deck/projects.json", - session.home.trim_end_matches('/') - ), - } +fn projects_path(session: &WslSession) -> String { + format!( + "{}/.skill-deck/projects.json", + session.home.trim_end_matches('/') + ) } fn protocol_error() -> AppError { @@ -157,10 +156,13 @@ mod tests { let environment = EnvironmentRef::Wsl { distro_name: "Ubuntu".to_string(), }; - let storage = parse_project_storage( + let storage = project_storage_from_mapped( &environment, - 3, - b"1\0ok\0C:\\Code\\app\0ok\0\\\\wsl.localhost\\Ubuntu\\home\\alice\\app\0error\0\0", + vec![ + Some(r"C:\Code\app".to_string()), + Some(r"\\wsl.localhost\Ubuntu\home\alice\app".to_string()), + None, + ], ) .unwrap(); assert_eq!(storage[0].access, StorageAccess::CrossStorage); diff --git a/src-tauri/src/environment/wsl/operations/recovery.rs b/src-tauri/src/environment/wsl/operations/recovery.rs index 1db8a26c..69fa8eba 100644 --- a/src-tauri/src/environment/wsl/operations/recovery.rs +++ b/src-tauri/src/environment/wsl/operations/recovery.rs @@ -1,239 +1,58 @@ -use tokio::time::Duration; +use sha2::{Digest, Sha256}; use crate::environment::recovery::{ - validate_recovery_marker, RecoveryFuture, RecoveryMarker, RecoveryMarkerLoad, - RecoveryMarkerRef, RecoveryMarkerStore, + validate_recovery_marker, RecoveryFuture, RecoveryMarker, RecoveryMarkerKind, + RecoveryMarkerLoad, RecoveryMarkerRef, RecoveryMarkerStore, }; -use crate::environment::types::{same_environment_identity, EnvironmentRef, ResourceLocator}; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, -}; -use crate::environment::wsl::{WslSession, WslWorkspace}; -use crate::error::{AppError, RecoveryResourceId}; - -const ENUMERATE_SCRIPT: &str = include_str!("../scripts/recovery.sh"); - -const WRITE_MARKER_SCRIPT: &str = include_str!("../scripts/recovery.sh"); - -const REMOVE_MARKER_SCRIPT: &str = include_str!("../scripts/recovery.sh"); - -const CLEANUP_RECOVERY_SCRIPT: &str = include_str!("../scripts/recovery.sh"); -const ENUMERATE_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "enumerate", ENUMERATE_SCRIPT); -const WRITE_MARKER_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "write-marker", WRITE_MARKER_SCRIPT); -const REMOVE_MARKER_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "remove-marker", REMOVE_MARKER_SCRIPT); -const CLEANUP_RECOVERY_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "cleanup", CLEANUP_RECOVERY_SCRIPT); - -pub fn parse_enumeration( - bytes: &[u8], - environment: EnvironmentRef, -) -> Result, AppError> { - let mut fields = bytes.split(|byte| *byte == 0); - if text(fields.next())? != "1" { - return Err(protocol_error()); - } - let mut loads = Vec::new(); - while let Some(tag) = fields.next() { - if tag.is_empty() { - continue; - } - if text(Some(tag))? != "R" { - return Err(protocol_error()); - } - let root = text(fields.next())?.to_string(); - let status = text(fields.next())?; - let content = fields.next().ok_or_else(protocol_error)?; - let managed_root = ResourceLocator { - environment: environment.clone(), - native_path: root.clone(), - }; - let parsed = (|| { - if status != "present" { - return Err(AppError::ConfigurationCorrupted { - message: format!("WSL recovery marker is {status}"), - }); - } - let marker: RecoveryMarker = serde_json::from_slice(content)?; - validate_recovery_marker(&marker)?; - let expected_id = root - .rsplit('/') - .next() - .and_then(|name| name.strip_prefix("skill-deck-operation-")) - .ok_or_else(protocol_error)?; - if !same_environment_identity(&marker.environment, &environment) - || marker.resource_id.as_str() != expected_id - { - return Err(AppError::ConfigurationCorrupted { - message: "WSL recovery marker does not match its managed root".to_string(), - }); - } - Ok(marker) - })(); - match parsed { - Ok(marker) => loads.push(RecoveryMarkerLoad::Valid { - marker_ref: RecoveryMarkerRef { - resource_id: marker.resource_id.clone(), - environment: environment.clone(), - managed_root, - }, - marker, - }), - Err(error) => loads.push(RecoveryMarkerLoad::Invalid { - managed_root, - error, - }), - } - } - Ok(loads) -} - -fn text(field: Option<&[u8]>) -> Result<&str, AppError> { - std::str::from_utf8(field.ok_or_else(protocol_error)?).map_err(|_| protocol_error()) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL recovery enumeration response".to_string(), - } -} +use crate::environment::types::{EnvironmentRef, ResourceLocator}; +use crate::environment::wsl::WslWorkspace; +use crate::error::AppError; +#[derive(Clone)] pub struct WslRecoveryMarkerStore { - access: WslRecoveryAccess, - namespace: String, -} - -enum WslRecoveryAccess { - Workspace(WslWorkspace), - ActiveSession(WslSession), + workspace: WslWorkspace, } impl WslRecoveryMarkerStore { pub fn new(workspace: WslWorkspace) -> Self { - Self { - access: WslRecoveryAccess::Workspace(workspace), - namespace: "/tmp".to_string(), - } - } - - pub(crate) fn from_active_session(session: WslSession) -> Self { - Self { - access: WslRecoveryAccess::ActiveSession(session), - namespace: "/tmp".to_string(), - } - } - - fn distro_name(&self) -> &str { - match &self.access { - WslRecoveryAccess::Workspace(workspace) => workspace.distro_name(), - WslRecoveryAccess::ActiveSession(session) => &session.distro_name, - } + Self { workspace } } fn environment_ref(&self) -> EnvironmentRef { EnvironmentRef::Wsl { - distro_name: self.distro_name().to_string(), + distro_name: self.workspace.distro_name().to_string(), } } - fn root(&self, id: &RecoveryResourceId) -> String { - format!("{}/skill-deck-operation-{}", self.namespace, id.as_str()) - } - - fn marker_ref(&self, id: RecoveryResourceId) -> RecoveryMarkerRef { - let environment = self.environment_ref(); + fn marker_ref(&self, marker: &RecoveryMarker, managed_root: String) -> RecoveryMarkerRef { RecoveryMarkerRef { + resource_id: marker.resource_id.clone(), + environment: self.environment_ref(), managed_root: ResourceLocator { - environment: environment.clone(), - native_path: self.root(&id), + environment: self.environment_ref(), + native_path: managed_root, }, - resource_id: id, - environment, - } - } - - fn verify_ref(&self, marker_ref: &RecoveryMarkerRef) -> Result<(), AppError> { - let expected_environment = self.environment_ref(); - if !same_environment_identity(&marker_ref.environment, &expected_environment) - || !same_environment_identity( - &marker_ref.managed_root.environment, - &expected_environment, - ) - || marker_ref.managed_root.native_path != self.root(&marker_ref.resource_id) - { - return Err(AppError::UnsafePath { - path: marker_ref.managed_root.native_path.clone(), - reason: "WSL recovery root is outside the managed namespace".to_string(), - }); } - Ok(()) } - async fn run( - &self, - operation: &WslOperationDescriptor, - args: Vec, - stdin: Vec, - stdout_limit: usize, - ) -> Result, AppError> { - let execute = |session, args: Vec, stdin: Vec| async move { - WslOperationExecutor::execute( - operation, - WslOperationRequest { - session, - args, - stdin, - timeout: Duration::from_secs(30), - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) + async fn list(&self) -> Result { + self.workspace + .request_worker_payload(environment_protocol::Message::ListMutationRecovery) .await - .map(|output| output.stdout) - }; - match &self.access { - WslRecoveryAccess::Workspace(workspace) => { - workspace - .with_session_retry(move |session| { - let args = args.clone(); - let stdin = stdin.clone(); - execute(session, args, stdin) - }) - .await - } - WslRecoveryAccess::ActiveSession(session) => { - execute(session.clone(), args, stdin).await - } - } } - async fn write( - &self, - marker: &RecoveryMarker, - mode: &str, - ) -> Result { - validate_recovery_marker(marker)?; - if !same_environment_identity(&marker.environment, &self.environment_ref()) { - return Err(AppError::StaleEnvironment); + fn validate_ref(&self, marker_ref: &RecoveryMarkerRef) -> Result<(), AppError> { + if marker_ref.environment != self.environment_ref() + || marker_ref.managed_root.environment != self.environment_ref() + || marker_ref.managed_root.native_path + != format!( + "/tmp/skill-deck-operation-{}", + marker_ref.resource_id.as_str() + ) + { + return Err(AppError::StaleTarget); } - let response = self - .run( - &WRITE_MARKER_OPERATION, - vec![ - self.namespace.clone(), - marker.resource_id.as_str().to_string(), - mode.to_string(), - ], - serde_json::to_vec(marker)?, - 32, - ) - .await?; - parse_write_response(&response)?; - Ok(self.marker_ref(marker.resource_id.clone())) + Ok(()) } } @@ -243,18 +62,10 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { } fn validate_managed_root(&self, root: &ResourceLocator) -> Result<(), AppError> { - let expected_environment = self.environment_ref(); - let Some(name) = root.native_path.strip_prefix("/tmp/skill-deck-operation-") else { - return Err(AppError::UnsafePath { - path: root.native_path.clone(), - reason: "WSL recovery root is outside the managed namespace".to_string(), - }); - }; - if !same_environment_identity(&root.environment, &expected_environment) - || name.is_empty() - || !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + if root.environment != self.environment_ref() + || !root.native_path.starts_with("/tmp/skill-deck-operation-") + || root.native_path.contains("/../") + || root.native_path.ends_with("/..") { return Err(AppError::UnsafePath { path: root.native_path.clone(), @@ -266,37 +77,85 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { fn create<'a>( &'a self, - marker: &'a RecoveryMarker, + _marker: &'a RecoveryMarker, ) -> RecoveryFuture<'a, Result> { - Box::pin(async move { self.write(marker, "create").await }) + Box::pin(async { + Err(AppError::CapabilityUnavailable { + capability: "hostCreatedWslMutationRecovery".to_string(), + path: None, + }) + }) } fn update<'a>( &'a self, - marker_ref: &'a RecoveryMarkerRef, - marker: &'a RecoveryMarker, + _marker_ref: &'a RecoveryMarkerRef, + _marker: &'a RecoveryMarker, ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.verify_ref(marker_ref)?; - if marker.resource_id != marker_ref.resource_id { - return Err(AppError::StaleTarget); - } - self.write(marker, "update").await?; - Ok(()) + Box::pin(async { + Err(AppError::CapabilityUnavailable { + capability: "hostUpdatedWslMutationRecovery".to_string(), + path: None, + }) }) } fn enumerate<'a>(&'a self) -> RecoveryFuture<'a, Result, AppError>> { Box::pin(async move { - let response = self - .run( - &ENUMERATE_OPERATION, - vec![self.namespace.clone()], - Vec::new(), - DEFAULT_WSL_STDOUT_LIMIT, - ) - .await?; - parse_enumeration(&response, self.environment_ref()) + let response = self.list().await?; + Ok(response + .records + .into_iter() + .map(|record| { + let managed_root = ResourceLocator { + environment: self.environment_ref(), + native_path: record.managed_root.clone(), + }; + match record.state { + environment_protocol::MutationRecoveryState::Present => { + let parsed = + serde_json::from_slice::(&record.marker_bytes) + .map_err(AppError::from) + .and_then(|marker| { + validate_recovery_marker(&marker)?; + if marker.resource_id.as_str() != record.resource_id + || marker.environment != self.environment_ref() + { + return Err(AppError::StaleTarget); + } + Ok(marker) + }); + match parsed { + Ok(marker) => RecoveryMarkerLoad::Valid { + marker_ref: self.marker_ref(&marker, record.managed_root), + marker, + }, + Err(error) => RecoveryMarkerLoad::Invalid { + managed_root, + error, + }, + } + } + environment_protocol::MutationRecoveryState::Unreadable => { + RecoveryMarkerLoad::Invalid { + managed_root, + error: AppError::ConfigurationCorrupted { + message: "WSL recovery marker is unreadable".to_string(), + }, + } + } + environment_protocol::MutationRecoveryState::Unsafe => { + RecoveryMarkerLoad::Invalid { + managed_root, + error: AppError::UnsafePath { + path: record.managed_root, + reason: "WSL recovery root is unsafe".to_string(), + }, + } + } + } + }) + .collect()) }) } @@ -305,19 +164,40 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { marker_ref: &'a RecoveryMarkerRef, ) -> RecoveryFuture<'a, Result<(), AppError>> { Box::pin(async move { - self.verify_ref(marker_ref)?; - let response = self - .run( - &REMOVE_MARKER_OPERATION, - vec![ - self.namespace.clone(), - marker_ref.resource_id.as_str().to_string(), - ], - Vec::new(), - 32, + self.validate_ref(marker_ref)?; + let response = self.list().await?; + let record = response + .records + .into_iter() + .find(|record| record.resource_id == marker_ref.resource_id.as_str()) + .ok_or(AppError::StaleTarget)?; + let marker: RecoveryMarker = serde_json::from_slice(&record.marker_bytes)?; + validate_recovery_marker(&marker)?; + if marker.kind != RecoveryMarkerKind::CleanupOnly { + return Err(AppError::StaleTarget); + } + let cleanup = environment_protocol::MutationCleanupToken { + resource_id: record.resource_id, + marker_sha256: format!("sha256:{:x}", Sha256::digest(&record.marker_bytes)), + }; + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::AcknowledgeMutationUnit { + cleanup: cleanup.clone(), + }, + None, + std::time::Duration::from_secs(30), ) .await?; - parse_write_response(&response) + match response { + environment_protocol::Message::MutationAcknowledged { resource_id } + if resource_id == cleanup.resource_id => + { + Ok(()) + } + _ => Err(AppError::StaleTarget), + } }) } @@ -327,338 +207,49 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { marker: &'a RecoveryMarker, ) -> RecoveryFuture<'a, Result<(), AppError>> { Box::pin(async move { - self.verify_ref(marker_ref)?; + self.validate_ref(marker_ref)?; validate_recovery_marker(marker)?; if marker.resource_id != marker_ref.resource_id - || !same_environment_identity(&marker.environment, &marker_ref.environment) - || marker.kind != crate::environment::recovery::RecoveryMarkerKind::CleanupOnly + || marker.kind != RecoveryMarkerKind::CleanupOnly { return Err(AppError::StaleTarget); } - let mut args = vec![ - self.namespace.clone(), - marker_ref.resource_id.as_str().to_string(), - ]; - args.extend( - marker - .entries - .iter() - .filter_map(|entry| entry.backup.as_ref()) - .map(|backup| backup.native_path.clone()), - ); - let response = self - .run( - &CLEANUP_RECOVERY_OPERATION, - args, - serde_json::to_vec(marker)?, - 32, + let response = self.list().await?; + let record = response + .records + .into_iter() + .find(|record| record.resource_id == marker.resource_id.as_str()) + .ok_or(AppError::StaleTarget)?; + let stored: RecoveryMarker = serde_json::from_slice(&record.marker_bytes)?; + if stored != *marker { + return Err(AppError::StaleTarget); + } + let backups = marker + .entries + .iter() + .filter_map(|entry| entry.backup.as_ref()) + .map(|backup| backup.native_path.clone()) + .collect(); + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::CleanupMutationRecovery { + resource_id: marker.resource_id.as_str().to_string(), + expected_marker_json: record.marker_bytes, + backups, + }, + None, + std::time::Duration::from_secs(30), ) .await?; - parse_write_response(&response) + match response { + environment_protocol::Message::MutationRecoveryCleaned { resource_id } + if resource_id == marker.resource_id.as_str() => + { + Ok(()) + } + _ => Err(AppError::StaleTarget), + } }) } } - -fn parse_write_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0").then_some(()).ok_or_else(protocol_error) -} - -#[cfg(test)] -mod capability_tests { - use std::collections::BTreeMap; - - use super::*; - use crate::environment::wsl::{WslRuntime, WslSession}; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - #[tokio::test] - async fn recovery_store_from_an_old_cycle_cannot_enumerate_after_reenable() { - let runtime = WslRuntime::default(); - runtime.insert(session()); - let store = - WslRecoveryMarkerStore::new(runtime.workspace("Ubuntu").expect("enabled workspace")); - - let disable = runtime.begin_disable().expect("begin disable"); - disable - .wait_for_quiescence(Duration::from_secs(1)) - .await - .expect("quiescent runtime"); - disable.commit_disabled(); - runtime - .begin_enable() - .expect("begin enable") - .commit_enabled(); - - let error = store.enumerate().await.expect_err("stale recovery store"); - - assert!(matches!(error, AppError::EnvironmentUnavailable { .. })); - } -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "恢复协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - use std::fs; - use std::io::Write; - use std::process::{Command, Stdio}; - - use tempfile::tempdir; - - use super::*; - use crate::environment::recovery::{ - RecoveryEntryPhase, RecoveryMarker, RecoveryMarkerEntry, RecoveryMarkerKind, - RecoveryMarkerLoad, RECOVERY_MARKER_SCHEMA_VERSION, - }; - use crate::environment::types::{EnvironmentRef, ResourceLocator}; - use crate::error::RecoveryResourceId; - - fn marker(environment: EnvironmentRef, id: &str) -> RecoveryMarker { - RecoveryMarker { - schema_version: RECOVERY_MARKER_SCHEMA_VERSION, - resource_id: RecoveryResourceId::parse(id).unwrap(), - kind: RecoveryMarkerKind::RecoveryRequired, - environment: environment.clone(), - operation_id: "operation-1".to_string(), - unit_id: "unit-1".to_string(), - subject: Some(crate::environment::recovery::RecoverySubject { - operation_kind: crate::core::mutation::MutationKind::Install, - skill_name: "demo".to_string(), - context: crate::environment::types::SkillLocationRef { - environment: environment.clone(), - scope: crate::environment::types::SkillLocation::Global, - }, - }), - created_at_epoch_ms: 1, - entries: vec![RecoveryMarkerEntry { - physical_target_digest: "target-1".to_string(), - destination: ResourceLocator { - environment: environment.clone(), - native_path: "/home/alice/.agents/skills/demo".to_string(), - }, - backup: Some(ResourceLocator { - environment, - native_path: "/home/alice/.agents/skills/.skill-deck-backup-demo".to_string(), - }), - expected_state: crate::environment::recovery::RecoveryExpectedEntryState::Present, - original_fingerprint: "entry-v1-original".to_string(), - phase: RecoveryEntryPhase::RestoreFailed, - }], - } - } - - #[test] - fn restart_enumeration_keeps_valid_marker_and_isolates_invalid_sibling() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let valid_root = temp.path().join("skill-deck-operation-valid-id"); - let invalid_root = temp.path().join("skill-deck-operation-invalid-id"); - fs::create_dir(&valid_root).unwrap(); - fs::create_dir(&invalid_root).unwrap(); - fs::write( - valid_root.join("recovery.json"), - serde_json::to_vec(&marker(environment.clone(), "valid-id")).unwrap(), - ) - .unwrap(); - fs::write(invalid_root.join("recovery.json"), b"{broken").unwrap(); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ENUMERATE_SCRIPT) - .arg("--") - .arg("enumerate") - .arg(temp.path()) - .output() - .unwrap(); - assert!(output.status.success()); - let loads = parse_enumeration(&output.stdout, environment).unwrap(); - - assert_eq!(loads.len(), 2); - assert_eq!( - loads - .iter() - .filter(|load| matches!(load, RecoveryMarkerLoad::Valid { .. })) - .count(), - 1 - ); - assert_eq!( - loads - .iter() - .filter(|load| matches!(load, RecoveryMarkerLoad::Invalid { .. })) - .count(), - 1 - ); - } - - #[test] - fn marker_write_requires_matching_operation_owner_and_updates_atomically() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let root = temp.path().join("skill-deck-operation-owned-id"); - let mut initial = marker(environment, "owned-id"); - - let output = run_write(temp.path(), "owned-id", "create", &initial); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - assert_eq!( - serde_json::from_slice::( - &fs::read(root.join("recovery.json")).unwrap() - ) - .unwrap(), - initial - ); - - initial.kind = RecoveryMarkerKind::CleanupOnly; - assert!(run_write(temp.path(), "owned-id", "update", &initial) - .status - .success()); - assert_eq!( - serde_json::from_slice::( - &fs::read(root.join("recovery.json")).unwrap() - ) - .unwrap(), - initial - ); - - fs::write(root.join(".skill-deck-owner"), b"1\nother-id\n").unwrap(); - assert!(!run_write(temp.path(), "owned-id", "update", &initial) - .status - .success()); - } - - #[test] - fn marker_create_initializes_the_owned_operation_root() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let value = marker(environment, "prepared-id"); - - let output = run_write(temp.path(), "prepared-id", "create", &value); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let root = temp.path().join("skill-deck-operation-prepared-id"); - assert_eq!( - fs::read(root.join(".skill-deck-owner")).unwrap(), - b"1\nprepared-id\n" - ); - assert_eq!( - serde_json::from_slice::( - &fs::read(root.join("recovery.json")).unwrap() - ) - .unwrap(), - value - ); - } - - #[test] - fn confirmed_cleanup_removes_owned_backups_and_the_managed_marker_root() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let root = temp.path().join("skill-deck-operation-cleanup-id"); - fs::create_dir(&root).unwrap(); - fs::write(root.join(".skill-deck-owner"), b"1\ncleanup-id\n").unwrap(); - let backup = temp.path().join("targets/.skill-deck-backup-demo"); - fs::create_dir_all(&backup).unwrap(); - fs::write(backup.join("SKILL.md"), b"backup").unwrap(); - let destination = temp.path().join("targets/demo"); - let mut value = marker(environment, "cleanup-id"); - value.kind = RecoveryMarkerKind::CleanupOnly; - value.entries[0].destination.native_path = destination.to_string_lossy().into_owned(); - value.entries[0].backup = Some(ResourceLocator { - environment: value.environment.clone(), - native_path: backup.to_string_lossy().into_owned(), - }); - fs::write( - root.join("recovery.json"), - serde_json::to_vec(&value).unwrap(), - ) - .unwrap(); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(CLEANUP_RECOVERY_SCRIPT) - .arg("--") - .arg("cleanup") - .arg(temp.path()) - .arg("cleanup-id") - .arg(&backup) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&serde_json::to_vec(&value).unwrap()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - assert!(!backup.exists()); - assert!(!root.exists()); - } - - fn run_write( - namespace: &std::path::Path, - id: &str, - mode: &str, - marker: &RecoveryMarker, - ) -> std::process::Output { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(WRITE_MARKER_SCRIPT) - .arg("--") - .arg("write-marker") - .arg(namespace) - .arg(id) - .arg(mode) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&serde_json::to_vec(marker).unwrap()) - .unwrap(); - child.wait_with_output().unwrap() - } -} diff --git a/src-tauri/src/environment/wsl/operations/scan.rs b/src-tauri/src/environment/wsl/operations/scan.rs index c37ebdd5..e1da08b9 100644 --- a/src-tauri/src/environment/wsl/operations/scan.rs +++ b/src-tauri/src/environment/wsl/operations/scan.rs @@ -1,19 +1,14 @@ use std::collections::BTreeSet; +use std::time::Duration; use serde::{Deserialize, Serialize}; -use tokio::time::Duration; use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::operations::source_acquisition::WslNativeSource; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "2"; -pub(crate) const SCAN_SCRIPT: &str = include_str!("../scripts/scan.sh"); -const SCAN_OPERATION: WslOperationDescriptor = wsl_operation("scan", "scan", SCAN_SCRIPT); +const SCAN_RESPONSE_METADATA_ALLOWANCE: usize = 4 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -62,19 +57,24 @@ pub struct ScanResponse { } pub async fn scan( - session: &WslSession, + workspace: &WslWorkspace, + source: &WslNativeSource, request: ScanRequest, cancellation: Option, ) -> Result { - let mode = if request.recursive { "1" } else { "0" }; - execute_scan(session, request, mode, cancellation).await + execute_scan( + workspace, + source, + request, + environment_protocol::SourceScanMode::Recursive, + cancellation, + ) + .await } -/// 扫描 plugin manifest 声明目录的直接子 Skill。 -/// -/// 该 mode 不改变通用 ScanRequest,也不会回传目录中的普通 payload metadata。 pub async fn scan_priority_directories( - session: &WslSession, + workspace: &WslWorkspace, + source: &WslNativeSource, request: ScanRequest, cancellation: Option, ) -> Result { @@ -84,113 +84,117 @@ pub async fn scan_priority_directories( message: "priority directory scan must not enable recursive mode".to_string(), }); } - execute_scan(session, request, "2", cancellation).await + execute_scan( + workspace, + source, + request, + environment_protocol::SourceScanMode::PriorityDirectories, + cancellation, + ) + .await } async fn execute_scan( - session: &WslSession, + workspace: &WslWorkspace, + source: &WslNativeSource, request: ScanRequest, - mode: &str, + mode: environment_protocol::SourceScanMode, cancellation: Option, ) -> Result { validate_request(&request)?; - let mut args = vec![ - request.per_file_limit.to_string(), - request.aggregate_limit.to_string(), - stat_only_indexes_arg(&request.stat_only_root_indexes), - mode.to_string(), - ]; - args.extend(request.roots.iter().cloned()); - let metadata_allowance = 4usize * 1024 * 1024; - let stdout_limit = usize::try_from(request.aggregate_limit) - .unwrap_or(usize::MAX) - .saturating_add(metadata_allowance); - let output = WslOperationExecutor::execute( - &SCAN_OPERATION, - WslOperationRequest { - session: session.clone(), - args, - stdin: Vec::new(), - timeout: Duration::from_secs(30), - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + let handle = source.handle(); + let roots = request + .roots + .iter() + .enumerate() + .map(|(index, root)| { + Ok(environment_protocol::SourceScanRoot { + relative_path: relative_source_path(source.native_root(), root)?.into_bytes(), + stat_only: request + .stat_only_root_indexes + .contains(&u32::try_from(index).unwrap_or(u32::MAX)), + }) + }) + .collect::, AppError>>()?; + let response: environment_protocol::SourceScanResponse = workspace + .request_worker_payload_for_generation( + handle.generation, + environment_protocol::Message::ScanSource { + request: environment_protocol::SourceScanRequest { + source_id: handle.id, + roots, + mode, + per_file_limit: request.per_file_limit, + aggregate_limit: request.aggregate_limit, + deadline_millis: 30_000, + }, + }, + usize::try_from(request.aggregate_limit) + .unwrap_or(usize::MAX) + .saturating_add(SCAN_RESPONSE_METADATA_ALLOWANCE), cancellation, - }, - ) - .await?; - parse_scan_response(&output.stdout, &request) + Duration::from_secs(35), + ) + .await?; + Ok(ScanResponse { + entries: response + .entries + .into_iter() + .map(map_entry) + .collect::>()?, + root_count: u32::try_from(request.roots.len()).unwrap_or(u32::MAX), + total_content_bytes: response.total_content_bytes, + }) } -pub fn parse_scan_response(bytes: &[u8], request: &ScanRequest) -> Result { - validate_request(request)?; - let mut cursor = 0; - if read_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error("unsupported scan protocol version")); - } - let mut entries = Vec::new(); - let mut total_content_bytes = 0usize; - while cursor < bytes.len() { - if read_text_field(bytes, &mut cursor)? != "E" { - return Err(protocol_error("invalid scan record tag")); - } - let root_index = parse_field::(bytes, &mut cursor, "root index")?; - if usize::try_from(root_index).unwrap_or(usize::MAX) >= request.roots.len() { - return Err(protocol_error("scan root index is out of range")); - } - let relative_path = read_text_field(bytes, &mut cursor)?.to_string(); - validate_relative_path(&relative_path)?; - let kind = match read_text_field(bytes, &mut cursor)? { - "missing" => ScannedEntryKind::Missing, - "file" => ScannedEntryKind::File, - "directory" => ScannedEntryKind::Directory, - "symlink" => ScannedEntryKind::Symlink, - "other" => ScannedEntryKind::Other, - "error" => ScannedEntryKind::Error, - _ => return Err(protocol_error("invalid scanned entry kind")), - }; - let target = read_text_field(bytes, &mut cursor)?; - let resolved_target = (!target.is_empty()).then(|| target.to_string()); - let size = parse_field::(bytes, &mut cursor, "entry size")?; - let mode = parse_field::(bytes, &mut cursor, "entry mode")?; - let modified_seconds = parse_field::(bytes, &mut cursor, "entry modification time")?; - let truncated = match read_text_field(bytes, &mut cursor)? { - "0" => false, - "1" => true, - _ => return Err(protocol_error("invalid truncation flag")), - }; - let error = read_text_field(bytes, &mut cursor)?; - let error_code = (!error.is_empty()).then(|| error.to_string()); - let content_len = parse_field::(bytes, &mut cursor, "content length")?; - if content_len > request.per_file_limit as usize - || total_content_bytes.saturating_add(content_len) > request.aggregate_limit as usize - || cursor.saturating_add(content_len) >= bytes.len() - { - return Err(protocol_error("scan content length exceeds its boundary")); - } - let content_bytes = bytes[cursor..cursor + content_len].to_vec(); - cursor += content_len; - if bytes.get(cursor) != Some(&0) { - return Err(protocol_error("scan content terminator is missing")); - } - cursor += 1; - total_content_bytes += content_len; - entries.push(ScannedEntry { - root_index, - relative_path, - kind, - resolved_target, - size, - mode, - modified_seconds, - content_bytes, - truncated, - error_code, - }); - } - Ok(ScanResponse { - entries, - root_count: request.roots.len() as u32, - total_content_bytes: total_content_bytes as u32, +fn relative_source_path(source_root: &str, requested: &str) -> Result { + if requested == source_root { + return Ok(String::new()); + } + requested + .strip_prefix(source_root) + .and_then(|relative| relative.strip_prefix('/')) + .filter(|relative| { + !relative + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + }) + .map(str::to_string) + .ok_or_else(|| AppError::UnsafePath { + path: requested.to_string(), + reason: "WSL scan root is outside its Source handle".to_string(), + }) +} + +fn map_entry(entry: environment_protocol::SourceEntry) -> Result { + Ok(ScannedEntry { + root_index: entry.root_index, + relative_path: String::from_utf8(entry.relative_path).map_err(|_| protocol_error())?, + kind: match entry.kind { + environment_protocol::SourceEntryKind::Missing => ScannedEntryKind::Missing, + environment_protocol::SourceEntryKind::File => ScannedEntryKind::File, + environment_protocol::SourceEntryKind::Directory => ScannedEntryKind::Directory, + environment_protocol::SourceEntryKind::Symlink => ScannedEntryKind::Symlink, + environment_protocol::SourceEntryKind::Other => ScannedEntryKind::Other, + }, + resolved_target: entry + .link_target + .map(String::from_utf8) + .transpose() + .map_err(|_| protocol_error())?, + size: 0, + mode: 0, + modified_seconds: 0, + content_bytes: entry.content_bytes, + truncated: entry.truncated, + error_code: entry.error_code.map(|error| { + match error { + environment_protocol::SourceEntryErrorCode::PathUnavailable => "pathUnavailable", + environment_protocol::SourceEntryErrorCode::ReadFailed => "readFailed", + environment_protocol::SourceEntryErrorCode::ReadLinkFailed => "readLinkFailed", + } + .to_string() + }), }) } @@ -202,472 +206,34 @@ fn validate_request(request: &ScanRequest) -> Result<(), AppError> { || request .stat_only_root_indexes .iter() - .any(|index| *index as usize >= request.roots.len()) + .any(|index| usize::try_from(*index).unwrap_or(usize::MAX) >= request.roots.len()) { return Err(AppError::Validation { field: Some("scanRequest".to_string()), - message: "invalid bounded scan request".to_string(), + message: "invalid WSL scan request".to_string(), }); } Ok(()) } -fn stat_only_indexes_arg(indexes: &BTreeSet) -> String { - indexes - .iter() - .map(u32::to_string) - .collect::>() - .join(",") -} - -fn read_text_field<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a str, AppError> { - let remaining = bytes - .get(*cursor..) - .ok_or_else(|| protocol_error("scan cursor is out of range"))?; - let length = remaining - .iter() - .position(|byte| *byte == 0) - .ok_or_else(|| protocol_error("scan field terminator is missing"))?; - let field = std::str::from_utf8(&remaining[..length]) - .map_err(|_| protocol_error("scan field is not UTF-8"))?; - *cursor += length + 1; - Ok(field) -} - -fn parse_field(bytes: &[u8], cursor: &mut usize, name: &str) -> Result -where - T: std::str::FromStr, -{ - read_text_field(bytes, cursor)? - .parse() - .map_err(|_| protocol_error(&format!("invalid {name}"))) -} - -fn validate_relative_path(path: &str) -> Result<(), AppError> { - if path.starts_with('/') - || path.split('/').any(|component| component == "..") - || path.contains('\\') - { - return Err(protocol_error("unsafe relative path in scan response")); - } - Ok(()) -} - -fn protocol_error(message: &str) -> AppError { +fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: message.to_string(), + message: "WSL Worker scan response contains a non-UTF-8 path".to_string(), } } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "扫描协议测试需要直接运行待验证的 shell 测试脚本" -)] +#[cfg(test)] mod tests { - use std::fs; - use std::process::Command; - - use tempfile::tempdir; - - use super::*; - - fn run_script_with_mode(request: &ScanRequest, mode: &str) -> ScanResponse { - let mut command = Command::new("/bin/sh"); - command - .arg("-c") - .arg(SCAN_SCRIPT) - .arg("--") - .arg("scan") - .arg(request.per_file_limit.to_string()) - .arg(request.aggregate_limit.to_string()) - .arg(stat_only_indexes_arg(&request.stat_only_root_indexes)) - .arg(mode) - .args(&request.roots); - let output = command.output().expect("run scan script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_scan_response(&output.stdout, request).expect("parse") - } - - fn run_script(request: &ScanRequest) -> ScanResponse { - run_script_with_mode(request, if request.recursive { "1" } else { "0" }) - } - - #[test] - fn one_batch_isolates_missing_root_and_reads_binary_skill_content() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("skills"); - fs::create_dir_all(root.join("demo")).expect("demo"); - fs::write(root.join("demo/SKILL.md"), [b'a', 0, b'b', b'c']).expect("skill"); - let request = ScanRequest { - roots: vec![ - temp.path().join("missing").to_string_lossy().into_owned(), - root.to_string_lossy().into_owned(), - ], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 16, - aggregate_limit: 32, - }; - - let response = run_script(&request); - - assert_eq!(response.root_count, 2); - assert!(response - .entries - .iter() - .any(|entry| { entry.root_index == 0 && entry.kind == ScannedEntryKind::Missing })); - let skill = response - .entries - .iter() - .find(|entry| entry.root_index == 1 && entry.relative_path == "demo/SKILL.md") - .expect("skill entry"); - assert_eq!(skill.content_bytes, [b'a', 0, b'b', b'c']); - assert!(!skill.truncated); - } - - #[cfg(unix)] #[test] - fn default_scan_reads_skill_document_through_a_direct_child_directory_symlink() { - use std::os::unix::fs::symlink; - - let temp = tempdir().expect("temp"); - let canonical = temp.path().join("canonical/toolkit"); - let agent_root = temp.path().join("agent-skills"); - fs::create_dir_all(&canonical).expect("canonical Skill"); - fs::create_dir_all(&agent_root).expect("Agent Skill directory"); - let document = b"---\nname: toolkit\ndescription: Toolkit\n---\n"; - fs::write(canonical.join("SKILL.md"), document).expect("Skill document"); - symlink(&canonical, agent_root.join("toolkit")).expect("Skill directory link"); - let request = ScanRequest { - roots: vec![agent_root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }; - - let response = run_script(&request); - - let linked_directory = response - .entries - .iter() - .find(|entry| entry.relative_path == "toolkit") - .expect("linked Skill directory"); - assert_eq!(linked_directory.kind, ScannedEntryKind::Symlink); - let skill_document = response - .entries - .iter() - .find(|entry| entry.relative_path == "toolkit/SKILL.md") - .expect("Skill document through directory link"); - assert_eq!(skill_document.kind, ScannedEntryKind::File); - assert_eq!(skill_document.content_bytes, document); - } - - #[cfg(unix)] - #[test] - fn default_scan_does_not_read_through_a_broken_child_directory_symlink() { - use std::os::unix::fs::symlink; - - let temp = tempdir().expect("temp"); - let agent_root = temp.path().join("agent-skills"); - fs::create_dir_all(&agent_root).expect("Agent Skill directory"); - symlink(temp.path().join("missing"), agent_root.join("toolkit")) - .expect("broken Skill directory link"); - let request = ScanRequest { - roots: vec![agent_root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }; - - let response = run_script(&request); - - assert!(response.entries.iter().any(|entry| { - entry.relative_path == "toolkit" && entry.kind == ScannedEntryKind::Symlink - })); - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "toolkit/SKILL.md")); - } - - #[test] - fn per_file_and_aggregate_limits_are_enforced_by_the_protocol() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("skills"); - fs::create_dir_all(root.join("first")).expect("first"); - fs::create_dir_all(root.join("second")).expect("second"); - fs::write(root.join("first/SKILL.md"), b"1234567890").expect("first skill"); - fs::write(root.join("second/SKILL.md"), b"abcdefghij").expect("second skill"); - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 6, - aggregate_limit: 8, - }); - let contents = response - .entries - .iter() - .filter(|entry| entry.relative_path.ends_with("SKILL.md")) - .collect::>(); + fn scan_roots_must_belong_to_the_source_handle() { assert_eq!( - contents - .iter() - .map(|entry| entry.content_bytes.len()) - .sum::(), - 8 + super::relative_source_path("/home/alice/repo", "/home/alice/repo/skills/demo") + .unwrap(), + "skills/demo" + ); + assert!(super::relative_source_path("/home/alice/repo", "/home/alice/other").is_err()); + assert!( + super::relative_source_path("/home/alice/repo", "/home/alice/repo/../other").is_err() ); - assert!(contents.iter().all(|entry| entry.truncated)); - assert_eq!(response.total_content_bytes, 8); - } - - #[test] - fn stat_only_root_does_not_enumerate_children_or_consume_content_budget() { - let temp = tempdir().expect("temp"); - fs::create_dir_all(temp.path().join("unrelated")).expect("unrelated"); - fs::write( - temp.path().join("unrelated/SKILL.md"), - b"---\nname: unrelated\ndescription: Unrelated\n---\n", - ) - .expect("skill"); - let response = run_script(&ScanRequest { - roots: vec![temp.path().to_string_lossy().into_owned()], - stat_only_root_indexes: [0].into_iter().collect(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert_eq!(response.entries.len(), 1); - assert_eq!(response.entries[0].relative_path, ""); - assert_eq!(response.total_content_bytes, 0); - } - - #[test] - fn recursive_scan_finds_nested_skill_files_without_changing_default_scan() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("packages/tools/demo")).expect("nested skill"); - fs::write( - root.join("packages/tools/demo/SKILL.md"), - b"---\nname: demo\ndescription: Demo\n---\n", - ) - .expect("skill"); - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(response - .entries - .iter() - .any(|entry| entry.relative_path == "packages/tools/demo/SKILL.md")); - } - - #[test] - fn recursive_scan_returns_only_discovery_documents_and_reads_local_lock() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("skills/demo/scripts")).expect("skill tree"); - fs::create_dir_all(root.join(".claude-plugin")).expect("plugin directory"); - fs::write( - root.join("skills/demo/SKILL.md"), - b"---\nname: demo\ndescription: Demo\n---\n", - ) - .expect("skill"); - fs::write(root.join("skills/demo/scripts/run.sh"), b"#!/bin/sh\n").expect("script"); - fs::write( - root.join(".claude-plugin/plugin.json"), - br#"{"name":"demo","skills":["./skills/demo"]}"#, - ) - .expect("plugin"); - let lock = br#"{"version":1,"skills":{"demo":{}}}"#; - fs::write(root.join("skills-lock.json"), lock).expect("lock"); - - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "skills/demo/scripts/run.sh")); - let lock_entry = response - .entries - .iter() - .find(|entry| entry.relative_path == "skills-lock.json") - .expect("lock entry"); - assert_eq!(lock_entry.content_bytes, lock); - } - - #[test] - fn recursive_scan_includes_skill_directory_at_cli_depth_five() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - let skill = root.join("one/two/three/four/five"); - fs::create_dir_all(&skill).expect("deep skill"); - fs::write( - skill.join("SKILL.md"), - b"---\nname: deep\ndescription: Deep\n---\n", - ) - .expect("skill"); - - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(response - .entries - .iter() - .any(|entry| entry.relative_path == "one/two/three/four/five/SKILL.md")); - } - - #[test] - fn priority_directory_scan_reads_only_direct_child_skill_documents() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("plugin-catalog"); - fs::create_dir_all(root.join("direct/scripts")).expect("direct skill"); - fs::create_dir_all(root.join("category/nested")).expect("nested skill"); - fs::write( - root.join("direct/SKILL.md"), - b"---\nname: direct\ndescription: Direct\n---\n", - ) - .expect("direct document"); - fs::write(root.join("direct/scripts/run.sh"), b"#!/bin/sh\n").expect("ordinary file"); - fs::write( - root.join("category/nested/SKILL.md"), - b"---\nname: nested\ndescription: Nested\n---\n", - ) - .expect("nested document"); - let request = ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }; - - let response = run_script_with_mode(&request, "2"); - - assert!(response - .entries - .iter() - .any(|entry| entry.relative_path == "direct/SKILL.md")); - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "direct/scripts")); - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "category/nested/SKILL.md")); - assert!(response.entries.iter().all(|entry| { - entry.relative_path.is_empty() - || entry - .relative_path - .rsplit('/') - .next() - .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) - })); - } - - #[cfg(unix)] - #[test] - fn recursive_scan_reports_plugin_documents_without_payload_metadata() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - fs::create_dir_all(root.join(".claude-plugin")).expect("plugin directory"); - fs::create_dir_all(root.join("skills/demo")).expect("Skill directory"); - let executable = root.join("skills/demo/run.sh"); - fs::write(&executable, b"#!/bin/sh\n").expect("script"); - fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)) - .expect("executable mode"); - fs::write( - root.join("skills/demo/SKILL.md"), - b"---\nname: demo\ndescription: Demo\n---\n", - ) - .expect("Skill"); - let plugin_document = br#"{"name":"demo-plugin","skills":["./skills/demo"]}"#; - fs::write(root.join(".claude-plugin/plugin.json"), plugin_document).expect("plugin"); - - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "skills/demo/run.sh")); - let plugin = response - .entries - .iter() - .find(|entry| entry.relative_path == ".claude-plugin/plugin.json") - .expect("plugin manifest entry"); - assert_eq!(plugin.content_bytes, plugin_document); - } - - #[test] - fn parser_rejects_unknown_protocol_and_truncated_content() { - let request = ScanRequest { - roots: vec!["/tmp".to_string()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 10, - aggregate_limit: 10, - }; - assert!(parse_scan_response(b"99\0", &request).is_err()); - assert!(parse_scan_response( - b"1\0E\0\x30\0SKILL.md\0file\0\x31\x30\0\x31\0\x35\0abc", - &request, - ) - .is_err()); - } -} - -#[cfg(all(test, not(target_os = "linux")))] -mod portable_tests { - use std::collections::BTreeSet; - - use super::{parse_scan_response, ScanRequest}; - - #[test] - fn parser_rejects_unknown_protocol_and_truncated_content() { - let request = ScanRequest { - roots: vec!["/tmp".to_string()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 10, - aggregate_limit: 10, - }; - assert!(parse_scan_response(b"99\0", &request).is_err()); - assert!(parse_scan_response( - b"1\0E\0\x30\0SKILL.md\0file\0\x31\x30\0\x31\0\x35\0abc", - &request, - ) - .is_err()); } } diff --git a/src-tauri/src/environment/wsl/operations/skill_content.rs b/src-tauri/src/environment/wsl/operations/skill_content.rs index 9cd231ee..e68bee3e 100644 --- a/src-tauri/src/environment/wsl/operations/skill_content.rs +++ b/src-tauri/src/environment/wsl/operations/skill_content.rs @@ -1,46 +1,18 @@ -use tokio::time::Duration; - -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const READ_SKILL_MARKDOWN_SCRIPT: &str = include_str!("../scripts/skill-content.sh"); -const SKILL_CONTENT_OPERATION: WslOperationDescriptor = - wsl_operation("skill-content", "read", READ_SKILL_MARKDOWN_SCRIPT); +const SKILL_MARKDOWN_LIMIT: u32 = 4 * 1024 * 1024; pub async fn read_skill_markdown( - session: &WslSession, + workspace: &WslWorkspace, canonical_path: &str, ) -> Result { - let output = match WslOperationExecutor::execute( - &SKILL_CONTENT_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![canonical_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 4 * 1024 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await - { - Ok(output) => output.stdout, - Err(AppError::WslCommandFailed { - exit_code: Some(44), - .. - }) => { - return Err(AppError::PathNotFound { - path: format!("{}/SKILL.md", canonical_path.trim_end_matches('/')), - }); - } - Err(error) => return Err(error), - }; - String::from_utf8(output).map_err(|error| AppError::InvalidSkillMd { + let path = format!("{}/SKILL.md", canonical_path.trim_end_matches('/')); + let bytes = workspace + .read_optional_document(path.clone(), SKILL_MARKDOWN_LIMIT) + .await? + .ok_or(AppError::PathNotFound { path })?; + String::from_utf8(bytes).map_err(|error| AppError::InvalidSkillMd { message: error.to_string(), }) } diff --git a/src-tauri/src/environment/wsl/operations/source_acquisition.rs b/src-tauri/src/environment/wsl/operations/source_acquisition.rs index 6f0b8b34..786fc161 100644 --- a/src-tauri/src/environment/wsl/operations/source_acquisition.rs +++ b/src-tauri/src/environment/wsl/operations/source_acquisition.rs @@ -1,20 +1,10 @@ use tokio::time::Duration; -use uuid::Uuid; use crate::core::classify_git_failure; use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - no_wsl_exit_mapping, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, GIT_OUTPUT_CAPTURE, -}; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -const WSL_SOURCE_ACQUISITION_SCRIPT: &str = include_str!("../scripts/source-acquisition.sh"); -// 宿主监督额外覆盖发行版启动及超时后的进程清理,不改变 Git 的用户配置时限。 -const WSL_GIT_TRANSPORT_GRACE: Duration = Duration::from_secs(30); -const WSL_GIT_TIMEOUT_EXIT_CODE: i32 = 72; - #[derive(Debug, Clone, PartialEq, Eq)] pub enum WslAcquisitionSource { Git { @@ -26,130 +16,20 @@ pub enum WslAcquisitionSource { }, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WslAcquisitionPlan { - pub script: &'static str, - pub subcommand: &'static str, - pub positional_args: Vec, - pub transport_timeout: Duration, - pub git_timeout: Duration, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WslNativeSourcePlan { - native_root: String, - operation: Option, - cleanup_root: Option, -} - -fn build_wsl_native_source_plan( - session: &WslSession, - source: WslAcquisitionSource, - managed_repo_path: &str, - git_timeout: Duration, - proxy: Option<&str>, -) -> Result { - match source { - WslAcquisitionSource::Git { url, git_ref } => Ok(WslNativeSourcePlan { - native_root: managed_repo_path.to_string(), - operation: Some(WslAcquisitionPlan { - script: WSL_SOURCE_ACQUISITION_SCRIPT, - subcommand: "git", - positional_args: vec![ - url, - managed_repo_path.to_string(), - git_ref.unwrap_or_default(), - session.distro_name.clone(), - git_timeout.as_secs().to_string(), - if proxy.is_some() { - "inject" - } else { - "preserve" - } - .to_string(), - proxy.unwrap_or_default().to_string(), - ], - transport_timeout: git_timeout.saturating_add(WSL_GIT_TRANSPORT_GRACE), - git_timeout, - }), - cleanup_root: Some(managed_repo_path.to_string()), - }), - WslAcquisitionSource::Local { native_path } => { - if !native_path.starts_with('/') { - return Err(AppError::UnsafePath { - path: native_path, - reason: "WSL local Source must use an absolute POSIX path".to_string(), - }); - } - Ok(WslNativeSourcePlan { - native_root: native_path, - operation: None, - cleanup_root: None, - }) - } - } -} - -fn acquisition_cancelled() -> AppError { - AppError::MutationCancelled -} - -fn map_wsl_git_acquisition_error(error: AppError, source_url: &str, timeout: Duration) -> AppError { - match error { - AppError::WslCommandFailed { - exit_code: Some(WSL_GIT_TIMEOUT_EXIT_CODE), - .. - } => AppError::GitTimeout { - timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), - }, - AppError::WslCommandFailed { exit_code, stderr } => { - classify_git_failure(&stderr, source_url, "clone", exit_code) - } - AppError::WslCommandTimedOut => AppError::GitTimeout { - timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), - }, - other => other, - } -} - -async fn run_wsl_acquisition_plan_with( - session: WslSession, - plan: WslAcquisitionPlan, - cancellation: CancellationSignal, - runner: F, -) -> Result, AppError> -where - F: FnOnce( - WslSession, - &'static str, - &'static str, - Vec, - Duration, - CancellationSignal, - ) -> Fut, - Fut: std::future::Future, AppError>>, -{ - if cancellation.is_cancelled() { - return Err(acquisition_cancelled()); - } - runner( - session, - plan.script, - plan.subcommand, - plan.positional_args, - plan.transport_timeout, - cancellation, - ) - .await +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WorkerSourceHandle { + pub generation: u64, + pub id: u64, } #[derive(Debug)] pub struct WslNativeSource { workspace: WslWorkspace, + handle: Option, native_root: String, - cleanup_root: Option, managed_owner_registered: bool, ref_revision: Option, + managed_bytes: u64, } impl WslNativeSource { @@ -160,803 +40,624 @@ impl WslNativeSource { pub fn ref_revision(&self) -> Option<&str> { self.ref_revision.as_deref() } + + pub fn managed_bytes(&self) -> u64 { + self.managed_bytes + } + + pub(crate) fn handle(&self) -> WorkerSourceHandle { + self.handle + .expect("active WSL source must own a Worker handle") + } } impl Drop for WslNativeSource { fn drop(&mut self) { - let Some(native_root) = self.cleanup_root.take() else { - return; - }; - self.workspace.defer_source_cleanup(native_root); + if let Some(handle) = self.handle.take() { + self.workspace.defer_worker_source_release(handle); + } if self.managed_owner_registered { self.workspace.release_source_owner(); } } } -pub(crate) async fn cleanup_wsl_source( - session: &WslSession, - native_root: &str, -) -> Result<(), AppError> { - let descriptor = WslOperationDescriptor { - subcommand: "cleanup", - script: WSL_SOURCE_ACQUISITION_SCRIPT, - map_exit: no_wsl_exit_mapping, - }; - WslOperationExecutor::execute( - &descriptor, - WslOperationRequest { - session: session.clone(), - args: vec![native_root.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 64, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - Ok(()) -} - pub async fn acquire_wsl_source_native( workspace: WslWorkspace, - session: &WslSession, + _session: &WslSession, source: WslAcquisitionSource, git_timeout: Duration, proxy: Option, cancellation: CancellationSignal, ) -> Result { - let managed_root = format!("/tmp/skill-deck-discovery-{}/repo", Uuid::new_v4().simple()); - let source_for_plan = source.clone(); - let initial_plan = build_wsl_native_source_plan( - session, - source_for_plan, - &managed_root, - git_timeout, - proxy.as_deref(), - )?; - let ref_revision = if let Some(operation) = initial_plan.operation.clone() { - let source_url = operation - .positional_args - .first() - .cloned() - .ok_or_else(acquisition_protocol_error)?; - let response = run_wsl_acquisition_plan_with( - session.clone(), - operation, - cancellation.clone(), - |session, script, subcommand, positional_args, timeout, cancellation| async move { - let descriptor = WslOperationDescriptor { - subcommand, - script, - map_exit: no_wsl_exit_mapping, - }; - WslOperationExecutor::execute_with_output_capture( - &descriptor, - WslOperationRequest { - session, - args: positional_args, - stdin: Vec::new(), - timeout, - stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: Some(cancellation), - }, - GIT_OUTPUT_CAPTURE, - ) - .await - .map(|output| output.stdout) + let (message, source_url) = match source { + WslAcquisitionSource::Git { url, git_ref } => ( + environment_protocol::Message::AcquireGitSource { + request: environment_protocol::GitSourceRequest { + url: url.clone(), + git_ref, + proxy, + deadline_millis: duration_millis(git_timeout), + }, }, + Some(url), + ), + WslAcquisitionSource::Local { native_path } => { + if !native_path.starts_with('/') { + return Err(AppError::UnsafePath { + path: native_path, + reason: "WSL local Source must use an absolute POSIX path".to_string(), + }); + } + ( + environment_protocol::Message::OpenLocalSource { + request: environment_protocol::OpenLocalSourceRequest { path: native_path }, + }, + None, + ) + } + }; + let (generation, response) = workspace + .request_worker_control_once( + message, + Some(cancellation), + git_timeout.saturating_add(Duration::from_secs(5)), ) .await - .map_err(|error| map_wsl_git_acquisition_error(error, &source_url, git_timeout))?; - Some(parse_wsl_git_acquisition_response(&response)?) - } else if cancellation.is_cancelled() { - return Err(acquisition_cancelled()); - } else { - None + .map_err(|error| map_transport_timeout(error, git_timeout))?; + let (id, native_root, ref_revision, managed_bytes) = match response { + environment_protocol::Message::SourceOpened { + source_id, + root, + revision, + managed_bytes, + } => (source_id, root, revision, managed_bytes), + environment_protocol::Message::Error { + code, + phase, + parameters, + } if source_url.is_some() => { + return Err(map_git_error( + &code, + &phase, + ¶meters, + source_url.as_deref().unwrap_or_default(), + "clone", + git_timeout, + )); + } + environment_protocol::Message::Error { code, phase, .. } => { + return Err(AppError::ExecutionFailed { + message: format!("WSL Worker source request failed during {phase}: {code}"), + }); + } + _ => return Err(protocol_error("invalid WSL Worker SourceOpened response")), }; - let managed_owner_registered = initial_plan.cleanup_root.is_some(); - if managed_owner_registered { - workspace.register_source_owner()?; - } + workspace.register_source_owner()?; Ok(WslNativeSource { workspace, - native_root: initial_plan.native_root, - cleanup_root: initial_plan.cleanup_root, - managed_owner_registered, + handle: Some(WorkerSourceHandle { generation, id }), + native_root, + managed_owner_registered: true, ref_revision, + managed_bytes, }) } -fn parse_wsl_git_acquisition_response(bytes: &[u8]) -> Result { - let fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.len() != 3 || fields[0] != b"1" || !fields[2].is_empty() { - return Err(acquisition_protocol_error()); - } - let revision = std::str::from_utf8(fields[1]).map_err(|_| acquisition_protocol_error())?; - if !matches!(revision.len(), 40 | 64) || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err(acquisition_protocol_error()); - } - Ok(revision.to_ascii_lowercase()) -} - -fn acquisition_protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL source acquisition response".to_string(), - } -} - pub(crate) async fn probe_wsl_git_connection( - session: &WslSession, + workspace: &WslWorkspace, url: &str, proxy: Option, timeout: Duration, cancellation: CancellationSignal, ) -> Result<(), AppError> { - let source_url = url.to_string(); - let descriptor = WslOperationDescriptor { - subcommand: "git-probe", - script: WSL_SOURCE_ACQUISITION_SCRIPT, - map_exit: no_wsl_exit_mapping, - }; - let response = WslOperationExecutor::execute_with_output_capture( - &descriptor, - WslOperationRequest { - session: session.clone(), - args: vec![ - source_url.clone(), - session.distro_name.clone(), - timeout.as_secs().max(1).to_string(), - if proxy.is_some() { - "inject" - } else { - "preserve" - } - .to_string(), - proxy.clone().unwrap_or_default(), - ], - stdin: Vec::new(), - timeout, - stdout_limit: 256, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: Some(cancellation), - }, - GIT_OUTPUT_CAPTURE, - ) - .await - .map(|output| output.stdout) - .map_err(|error| map_wsl_git_acquisition_error(error, &source_url, timeout))?; - parse_wsl_git_acquisition_response(&response)?; - Ok(()) + probe_wsl_git_ref(workspace, url, None, proxy, timeout, cancellation) + .await + .map(|_| ()) } -#[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "acquisition 流程测试需要直接调用真实 Git 并运行 shell 测试脚本" -)] -mod tests { - use super::{ - build_wsl_native_source_plan, run_wsl_acquisition_plan_with, WslAcquisitionPlan, - WslAcquisitionSource, WslNativeSource, - }; - use crate::core::mutation::CancellationSignal; - use crate::environment::wsl::{WslRuntime, WslSession}; - use std::collections::BTreeMap; - #[cfg(unix)] - use std::fs; - #[cfg(target_os = "linux")] - use std::os::unix::fs::PermissionsExt; - #[cfg(unix)] - use std::process::Command; - - #[cfg(target_os = "linux")] - fn git(cwd: &std::path::Path, args: &[&str]) -> String { - let output = Command::new("git") - .current_dir(cwd) - .args(args) - .output() - .expect("git command"); - assert!( - output.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap().trim().to_string() +pub(crate) async fn probe_wsl_git_ref( + workspace: &WslWorkspace, + url: &str, + git_ref: Option<&str>, + proxy: Option, + timeout: Duration, + cancellation: CancellationSignal, +) -> Result { + let (_, response) = workspace + .request_worker_control_once( + environment_protocol::Message::ProbeGit { + request: environment_protocol::GitSourceRequest { + url: url.to_string(), + git_ref: git_ref.map(str::to_string), + proxy, + deadline_millis: duration_millis(timeout), + }, + }, + Some(cancellation), + timeout.saturating_add(Duration::from_secs(5)), + ) + .await + .map_err(|error| map_transport_timeout(error, timeout))?; + match response { + environment_protocol::Message::GitProbed { revision } => Ok(revision), + environment_protocol::Message::Error { + code, + phase, + parameters, + } => Err(map_git_error( + &code, + &phase, + ¶meters, + url, + "ls-remote", + timeout, + )), + _ => Err(protocol_error("invalid WSL Worker GitProbed response")), } +} - #[test] - fn source_acquisition_response_requires_versioned_git_head() { - let revision_40 = "a".repeat(40); - let revision_64 = "b".repeat(64); - assert_eq!( - super::parse_wsl_git_acquisition_response(format!("1\0{revision_40}\0").as_bytes()) - .unwrap(), - revision_40 - ); - assert_eq!( - super::parse_wsl_git_acquisition_response(format!("1\0{revision_64}\0").as_bytes()) - .unwrap(), - revision_64 +fn map_git_error( + code: &str, + phase: &str, + parameters: &[(String, String)], + url: &str, + operation: &str, + timeout: Duration, +) -> AppError { + if code == "deadlineExceeded" { + return AppError::GitTimeout { + timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), + }; + } + let parameter = |name: &str| { + parameters + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + if code == "gitFailed" { + let exit_code = parameter("exitCode").and_then(|value| value.parse().ok()); + return classify_git_failure( + parameter("stderr").unwrap_or_default(), + url, + operation, + exit_code, ); - for invalid in [ - b"2\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0".as_slice(), - b"1\0short\0".as_slice(), - b"1\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0extra\0".as_slice(), - b"1\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".as_slice(), - ] { - assert!(super::parse_wsl_git_acquisition_response(invalid).is_err()); - } } - - #[test] - fn dropping_native_source_only_defers_cleanup_to_the_runtime() { - let runtime = WslRuntime::default(); - let workspace = runtime.workspace("Ubuntu").expect("enabled workspace"); - let source = WslNativeSource { - workspace: workspace.clone(), - native_root: "/tmp/skill-deck-source/repo".to_string(), - cleanup_root: Some("/tmp/skill-deck-source".to_string()), - managed_owner_registered: false, - ref_revision: None, + if code == "gitUnavailable" { + return AppError::GitCloneFailed { + message: parameter("message") + .unwrap_or("Git is not available in the selected WSL distribution") + .to_string(), }; - - drop(source); - - assert_eq!(workspace.deferred_source_cleanup_count(), 1); } - - #[cfg(target_os = "linux")] - #[test] - fn git_acquisition_reports_cloned_head_even_if_source_advances_after_clone() { - let temp = tempfile::tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir(&source).expect("source"); - git(&source, &["init", "-b", "main"]); - git(&source, &["config", "user.email", "test@example.com"]); - git(&source, &["config", "user.name", "Skill Deck Test"]); - fs::write(source.join("SKILL.md"), b"first").expect("first"); - git(&source, &["add", "SKILL.md"]); - git(&source, &["commit", "-m", "first"]); - let cloned_revision = git(&source, &["rev-parse", "HEAD"]); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg(&source) - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("30") - .arg("preserve") - .arg("") - .output() - .expect("acquisition script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let reported = super::parse_wsl_git_acquisition_response(&output.stdout).unwrap(); - - fs::write(source.join("SKILL.md"), b"second").expect("second"); - git(&source, &["add", "SKILL.md"]); - git(&source, &["commit", "-m", "second"]); - let advanced_revision = git(&source, &["rev-parse", "HEAD"]); - - assert_eq!(reported, cloned_revision); - assert_ne!(reported, advanced_revision); - fs::remove_dir_all(managed_root).expect("cleanup managed source"); + AppError::ExecutionFailed { + message: format!("WSL Worker Git request failed during {phase}: {code}"), } +} - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu-24.04".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } +fn duration_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()) + .unwrap_or(u64::MAX) + .max(1) +} - fn configured_git_timeout() -> tokio::time::Duration { - tokio::time::Duration::from_secs(300) +fn map_transport_timeout(error: AppError, timeout: Duration) -> AppError { + if error == AppError::WslCommandTimedOut { + AppError::GitTimeout { + timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), + } + } else { + error } +} - fn preserve_proxy() -> Option<&'static str> { - None +fn protocol_error(message: &str) -> AppError { + AppError::ConfigurationCorrupted { + message: message.to_string(), } +} - fn git_operation() -> WslAcquisitionPlan { - build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "https://github.com/example/repo".to_string(), - git_ref: None, - }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - preserve_proxy(), - ) - .expect("build Git source plan") - .operation - .expect("Git Source requires acquisition") - } +#[cfg(all(test, target_os = "windows"))] +#[allow( + clippy::disallowed_methods, + reason = "真实 WSL 2 门禁的 Drop guard 需要同步启动 wsl.exe 清理测试 fixture" +)] +mod windows_wsl2_tests { + use std::process::Stdio; - #[test] - fn wsl_git_plan_keeps_source_and_ref_as_positional_arguments() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "$(touch /tmp/not-shell-source)".to_string(), - git_ref: Some("feature; echo unsafe".to_string()), - }, - "/mnt/c/Temp/sd-1/repo", - configured_git_timeout(), - preserve_proxy(), - ) - .expect("build git plan"); + use crate::application::payload_session::{PayloadSessionStorage, PayloadStorageKey}; + use crate::core::mutation::CancellationSignal; + use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; + use crate::environment::wsl::operations::scan::{scan, ScanRequest}; + use crate::environment::wsl::WslRuntime; - let operation = plan.operation.expect("Git Source requires acquisition"); - assert_eq!( - operation.positional_args[0], - "$(touch /tmp/not-shell-source)" + #[tokio::test] + #[ignore = "requires Windows with an Ubuntu WSL 2 distribution"] + async fn real_wsl2_worker_completes_git_scan_payload_and_release() { + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let fixture = format!( + "/tmp/skill-deck-worker-gate-{}", + uuid::Uuid::new_v4().simple() ); - assert_eq!(operation.positional_args[1], "/mnt/c/Temp/sd-1/repo"); - assert_eq!(operation.positional_args[2], "feature; echo unsafe"); - assert!(!operation.script.contains("$(touch /tmp/not-shell-source)")); - assert!(!operation.script.contains("feature; echo unsafe")); - } + run_fixture_command( + &distro, + r#"set -eu +root=$1 +mkdir -p "$root" +git init -b main "$root" +git -C "$root" config user.email test@example.com +git -C "$root" config user.name 'Skill Deck Test' +printf '%s\n' '---' 'name: worker-gate' 'description: Worker gate' '---' > "$root/SKILL.md" +mkdir -p "$root/one/two/three/four/five/six/deep" +printf '%s\n' '---' 'name: deep-gate' 'description: Deep gate' '---' > "$root/one/two/three/four/five/six/deep/skill.md" +ln -s one/two/three/four/five/six "$root/alias" +git -C "$root" add . +git -C "$root" commit -m fixture +git -C "$root" tag -a release -m release +git -C "$root" commit --allow-empty -m branch +git -C "$root" branch release +"#, + &fixture, + ) + .await; + let _fixture_cleanup = FixtureCleanup { + distro: distro.clone(), + fixture: fixture.clone(), + }; - #[test] - fn wsl_git_plan_uses_the_configured_git_timeout() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "https://github.com/example/repo".to_string(), + let runtime = WslRuntime::for_wsl_test(); + let workspace = runtime.workspace(&distro).unwrap(); + let session = runtime.connect(&distro).await.unwrap(); + let source = super::acquire_wsl_source_native( + workspace.clone(), + &session, + super::WslAcquisitionSource::Git { + url: fixture.clone(), git_ref: None, }, - "/mnt/c/Temp/sd-1/repo", - configured_git_timeout(), - preserve_proxy(), + std::time::Duration::from_secs(30), + None, + CancellationSignal::default(), ) - .expect("build Git source plan"); - - let operation = plan.operation.expect("Git Source requires acquisition"); - assert_eq!(operation.git_timeout, configured_git_timeout()); - assert_eq!( - operation.transport_timeout, - configured_git_timeout() + super::WSL_GIT_TRANSPORT_GRACE - ); - assert_eq!(operation.positional_args[4], "300"); - } - - #[test] - fn wsl_git_failures_use_shared_git_error_semantics() { - let url = "https://alice:secret@github.com/acme/private.git?token=query-secret"; - let git_failure = super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(68), - stderr: format!( - "fatal: failed to clone {url}\nAuthorization: Bearer header-secret" - ), - }, - url, - configured_git_timeout(), - ); - let rendered = git_failure.to_string(); - assert!(matches!( - git_failure, - crate::error::AppError::GitCloneFailed { .. } - )); - assert!(rendered.contains(url)); - assert!(rendered.contains("Authorization: Bearer header-secret")); - - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(68), - stderr: "Could not resolve host: github.com".to_string(), - }, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitNetworkError { .. } - )); - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandTimedOut, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitTimeout { timeout_secs: 300 } - )); - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(72), - stderr: String::new(), - }, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitTimeout { timeout_secs: 300 } - )); - } - - #[test] - fn wsl_git_proxy_failures_use_the_shared_network_error() { - for stderr in [ - "Could not resolve proxy: proxy.example", - "Failed to connect to 127.0.0.1 port 7890: Couldn't connect to server", - ] { - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(68), - stderr: stderr.to_string(), - }, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitNetworkError { .. } - )); - } - } - - #[test] - fn wsl_local_source_is_read_directly_without_a_managed_copy() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Local { - native_path: "/home/alice/code/skills".to_string(), - }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - preserve_proxy(), + .await + .unwrap(); + let branch = super::probe_wsl_git_ref( + &workspace, + &fixture, + Some("release"), + None, + std::time::Duration::from_secs(30), + CancellationSignal::default(), ) - .expect("build local plan"); - - assert_eq!(plan.native_root, "/home/alice/code/skills"); - assert!(plan.operation.is_none()); - assert!(plan.cleanup_root.is_none()); - } - - #[test] - fn wsl_local_source_requires_an_absolute_posix_path() { - let error = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Local { - native_path: "relative/skills".to_string(), - }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - preserve_proxy(), + .await + .unwrap(); + let tag = super::probe_wsl_git_ref( + &workspace, + &fixture, + Some("refs/tags/release"), + None, + std::time::Duration::from_secs(30), + CancellationSignal::default(), ) - .expect_err("relative WSL Source must be rejected"); - - assert!(matches!(error, crate::error::AppError::UnsafePath { .. })); - } - - #[test] - fn wsl_git_plan_injects_proxy_only_for_the_current_command() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "https://github.com/example/repo".to_string(), - git_ref: None, + .await + .unwrap(); + assert_eq!(source.ref_revision(), Some(branch.as_str())); + assert_ne!(branch, tag); + assert!(source.managed_bytes() > 0); + let inventory = scan( + &workspace, + &source, + ScanRequest { + roots: vec![source.native_root().to_string()], + stat_only_root_indexes: Default::default(), + recursive: true, + per_file_limit: 256 * 1024, + aggregate_limit: 1024 * 1024, }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - Some("http://127.0.0.1:7890"), + None, ) - .expect("build proxied Git source plan"); - - let operation = plan.operation.expect("Git Source requires acquisition"); - assert_eq!(operation.positional_args[5], "inject"); - assert_eq!(operation.positional_args[6], "http://127.0.0.1:7890"); - assert!(operation - .script - .contains("git -c \"http.proxy=$proxy_url\"")); - } - - #[cfg(unix)] - #[test] - fn git_acquisition_rejects_an_unmanaged_destination_before_deleting_it() { - let temp = tempfile::tempdir().expect("temp"); - let destination = temp.path().join("existing/repo"); - fs::create_dir_all(&destination).expect("destination"); - fs::write(destination.join("keep"), b"keep").expect("marker"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("/missing/source") - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("30") - .output() - .expect("acquisition script"); - - assert!(!output.status.success()); - assert!(destination.join("keep").is_file()); - } - - #[cfg(target_os = "linux")] - #[test] - fn failed_git_acquisition_removes_its_managed_temporary_root() { - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("/missing/source") - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("30") - .output() - .expect("acquisition script"); - - assert!(!output.status.success()); - assert!(!managed_root.exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn git_timeout_starts_when_the_clone_process_starts() { - let temp = tempfile::tempdir().expect("fake Git temp dir"); - let fake_git = temp.path().join("git"); - fs::write(&fake_git, "#!/bin/sh\nsleep 10\n").expect("fake Git"); - let mut permissions = fs::metadata(&fake_git) - .expect("fake Git metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&fake_git, permissions).expect("make fake Git executable"); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").unwrap() + .await + .unwrap(); + assert!(inventory + .entries + .iter() + .any(|entry| entry.relative_path == "SKILL.md")); + + let storage = WslPayloadSessionStorage::for_source(workspace, &source); + let deep = storage + .read_source_skill_md( + &format!("{}/one/two/three/four/five/six/deep", source.native_root()), + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!(String::from_utf8(deep).unwrap().contains("name: deep-gate")); + assert!(matches!(storage.read_source_skill_md( + &format!("{}/alias/deep", source.native_root()), CancellationSignal::default() + ).await, Err(crate::error::AppError::CapabilityUnavailable { capability, .. }) if capability == "sourceDirectoryLinks")); + let key = PayloadStorageKey::new("worker-gate", "SKILL.md"); + let acquired = storage + .acquire_from_source_path(&key, source.native_root(), None) + .await + .unwrap(); + assert_eq!( + storage.verify(&key).await.unwrap().unwrap(), + acquired.manifest ); - let started = std::time::Instant::now(); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("https://github.com/example/repo") - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("1") - .arg("preserve") - .arg("") - .env("PATH", path) - .output() - .expect("acquisition script"); - - assert_eq!(output.status.code(), Some(72)); - assert!(started.elapsed() < std::time::Duration::from_secs(4)); - assert!(!managed_root.exists()); + let blob_id = acquired + .manifest + .entries + .iter() + .find_map(|entry| entry.blob_id.as_deref()) + .unwrap(); + assert!(!storage + .read_blob(&key, blob_id) + .await + .unwrap() + .unwrap() + .is_empty()); + storage.remove(&key).await.unwrap(); + storage.remove_session(key.session_id()).await.unwrap(); + crate::application::payload_session::RetainedSourceCleanup::remove(&storage) + .await + .unwrap(); + drop(source); } - #[cfg(target_os = "linux")] - #[test] - fn git_probe_uses_a_stable_diagnostic_locale() { - let temp = tempfile::tempdir().expect("fake Git temp dir"); - let fake_git = temp.path().join("git"); - fs::write( - &fake_git, - "#!/bin/sh\n[ \"${LC_ALL-}\" = C ] || exit 73\n[ \"${GIT_ALLOW_PROTOCOL-}\" = https:http:ssh:git:file ] || exit 74\nprintf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\tHEAD\\n'\n", - ) - .expect("fake Git"); - let mut permissions = fs::metadata(&fake_git) - .expect("fake Git metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&fake_git, permissions).expect("make fake Git executable"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").expect("PATH") - ); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git-probe") - .arg("https://github.com/example/repo") - .arg("Ubuntu") - .arg("3") - .arg("preserve") - .arg("") - .env("PATH", path) - .output() - .expect("acquisition script"); + #[tokio::test] + #[ignore = "requires Windows with an Ubuntu WSL 2 distribution"] + async fn real_windows_http_payload_executes_in_wsl_after_source_release() { + use crate::application::mutation::coordinator::PreparedUnitExecutor; + use crate::application::mutation::plan::{ + ExecutionUnit, ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, + RuntimeRevisions, + }; + use crate::application::payload_session::{ + DiscoverySourceLocation, PayloadSessionLimits, PayloadSessionManager, + }; + use crate::application::source_acquisition::{ + retain_discovered_source, AcquireSelectedPayloadsRequest, InternalSkillVisibility, + ManagedDownloadedDirectory, RetainedSourceOptions, SelectedPayloadAcquisitionService, + }; + use crate::environment::content_manifest::{ContentManifestReader, ContentManifestTarget}; + use crate::environment::planning::{RuntimeTargetFactResolver, TargetFactResolver}; + use crate::environment::runtime::ContextSnapshotRevision; + use crate::environment::types::{ + EnvironmentRef, ResourceLocator, SkillLocation, SkillLocationRef, + }; + use std::collections::BTreeMap; + use std::sync::Arc; - assert!( - output.status.success(), - "git probe failed: {}", - String::from_utf8_lossy(&output.stderr) + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let url = format!( + "http://{}/.well-known/agent-skills/index.json?scope=test", + server.server_addr() ); - assert_eq!( - output.stdout, - b"1\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn git_acquisition_reports_missing_git_before_creating_a_managed_root() { - let empty_path = tempfile::tempdir().expect("empty PATH"); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() + let server_task = std::thread::spawn(move || { + for _ in 0..2 { + let request = server + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap() + .unwrap(); + let body = if request.url().contains("index.json?scope=test") { + r#"{"skills":[{"name":"http-gate","description":"HTTP gate","files":["SKILL.md"]}]}"# + } else { + assert_eq!( + request.url(), + "/.well-known/agent-skills/http-gate/SKILL.md" + ); + "---\nname: http-gate\ndescription: HTTP gate\n---\nprepared content" + }; + request + .respond(tiny_http::Response::from_string(body)) + .unwrap(); + } + }); + let settings = Arc::new(crate::runtime::proxy_settings::ProxySettingsStore::new( + crate::models::NetworkProxySettings::default(), )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("https://github.com/example/repo.git") - .arg(&destination) - .arg("") - .arg("Ubuntu-24.04") - .arg("5") - .arg("preserve") - .arg("") - .env("PATH", empty_path.path()) - .output() - .expect("acquisition script"); - - assert_eq!(output.status.code(), Some(127)); - assert!(String::from_utf8_lossy(&output.stderr).contains("install Git")); - assert!(!managed_root.exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn git_acquisition_blocks_ext_protocol_before_running_its_helper() { - let temp = tempfile::tempdir().expect("temp"); - let marker = temp.path().join("ext-helper-ran"); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", + let http = crate::runtime::http_transport::HttpTransport::new(settings); + let fetched = + crate::runtime::wellknown_protocol::fetch_selected_wellknown_skills_with_client( + &http, + &url, + Some(&["http-gate".to_string()]), + &CancellationSignal::default(), + ) + .await + .unwrap(); + server_task.join().unwrap(); + let native_root = fetched.repo_path.clone(); + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let fixture = format!( + "/tmp/skill-deck-http-update-gate-{}", uuid::Uuid::new_v4().simple() + ); + run_fixture_command( + &distro, + "mkdir -p \"$1/http-gate\"\nprintf old > \"$1/http-gate/SKILL.md\"", + &fixture, + ) + .await; + let _cleanup = FixtureCleanup { + distro: distro.clone(), + fixture: fixture.clone(), + }; + let runtime = Arc::new(WslRuntime::for_wsl_test()); + let workspace = runtime.workspace(&distro).unwrap(); + let session = runtime.connect(&distro).await.unwrap(); + let environment = EnvironmentRef::Wsl { + distro_name: distro, + }; + let storage = Arc::new(WslPayloadSessionStorage::new(workspace.clone())); + let manager = Arc::new(PayloadSessionManager::new( + storage.clone(), + PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 2_000_000, + }, + || 1_000, )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg(format!("ext::touch {}", marker.display())) - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("5") - .arg("preserve") - .arg("") - .output() - .expect("acquisition script"); - - assert_eq!(output.status.code(), Some(68)); - assert!(!marker.exists()); - assert!(!managed_root.exists()); - } - - #[tokio::test] - async fn cancelled_acquisition_does_not_start_wsl_command() { - let plan = git_operation(); - let cancellation = CancellationSignal::default(); - cancellation.cancel(); - let ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let ran_by_command = ran.clone(); - - let error = run_wsl_acquisition_plan_with( - session(), - plan, - cancellation, - move |_, _, _, _, _, _| async move { - ran_by_command.store(true, std::sync::atomic::Ordering::Release); - Ok(Vec::new()) + let parsed = crate::models::ParsedSource { + source_type: crate::models::SourceType::WellKnown, + ..crate::core::parse_source(&url).unwrap() + }; + let discovery = retain_discovered_source( + manager.clone(), + environment.clone(), + parsed, + url, + DiscoverySourceLocation::Native { + root: native_root.clone(), + ref_revision: None, + }, + native_root.clone(), + ManagedDownloadedDirectory::new(native_root.clone()), + RetainedSourceOptions { + storage: Some(storage.clone()), + trust_metadata: Some(fetched.trust_metadata), + full_depth: true, + internal_skill_visibility: InternalSkillVisibility::All, + ..Default::default() }, ) .await - .expect_err("cancelled acquisition must fail"); - - assert!(error.to_string().contains("cancelled")); - assert!(!ran.load(std::sync::atomic::Ordering::Acquire)); - } - - #[tokio::test] - async fn cancellation_stops_waiting_for_running_wsl_command() { - let plan = git_operation(); - let cancellation = CancellationSignal::default(); - let cancellation_request = cancellation.clone(); - let run = run_wsl_acquisition_plan_with( - session(), - plan, - cancellation, - |_, _, _, _, _, cancellation| async move { - while !cancellation.is_cancelled() { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - Err(crate::error::AppError::MutationCancelled) - }, - ); - let cancel = async move { - tokio::time::sleep(std::time::Duration::from_millis(30)).await; - cancellation_request.cancel(); + .unwrap() + .discovery_session; + let handle = SelectedPayloadAcquisitionService::new(manager.clone()) + .acquire(AcquireSelectedPayloadsRequest { + discovery_session: discovery.clone(), + skill_paths: vec!["http-gate/SKILL.md".into()], + }) + .await + .unwrap() + .remove(0); + let lease = manager.pin_verified(&handle).await.unwrap(); + let expected = lease.manifest().payload_root_hash.clone(); + let payload_id = lease.manifest().payload_id().clone(); + manager.release_source_snapshot(&discovery).await.unwrap(); + assert!(!native_root.exists()); + let target = ResourceLocator { + environment: environment.clone(), + native_path: format!("{fixture}/http-gate"), }; - - let (result, ()) = tokio::join!(run, cancel); - - assert!(result - .expect_err("running acquisition must observe cancellation") - .to_string() - .contains("cancelled")); - } - - #[tokio::test] - async fn cancellation_waits_for_runner_cleanup_before_returning() { - let plan = git_operation(); - let cancellation = CancellationSignal::default(); - let cancellation_request = cancellation.clone(); - let cleanup_finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let cleanup_from_runner = cleanup_finished.clone(); - let run = run_wsl_acquisition_plan_with( - session(), - plan, - cancellation, - move |_, _, _, _, _, cancellation| async move { - while !cancellation.is_cancelled() { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - cleanup_from_runner.store(true, std::sync::atomic::Ordering::Release); - Err(crate::error::AppError::MutationCancelled) + let targets = RuntimeTargetFactResolver::new(runtime); + let fact = targets + .resolve_environment(&environment, std::slice::from_ref(&target), None) + .await + .unwrap() + .remove(0); + let manifest = targets + .read(&ContentManifestTarget { + key: fact.key.clone(), + location: fact.destination.clone(), + }) + .await + .unwrap(); + let unit = ExecutionUnit { + id: "http-update-gate".into(), + skill_name: "http-gate".into(), + source: None, + target: SkillLocationRef { + environment, + scope: SkillLocation::Global, }, - ); - let cancel = async move { - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - cancellation_request.cancel(); + expected_revisions: RuntimeRevisions { + registry: "test".into(), + environment: "test".into(), + context: ContextSnapshotRevision::parse("context-v1-http-gate").unwrap(), + }, + primary_entry: Some(PreparedEntryMutation { + key: fact.key.clone(), + destination: fact.destination, + action: PreparedEntryAction::Replace { + payload_id: payload_id.clone(), + requested_mode: crate::models::InstallMode::Copy, + }, + reader_agent_ids: Vec::new(), + }), + additional_entries: Vec::new(), + lock_mutation: None, + expected_targets: vec![ExpectedTargetEntry { + key: fact.key, + fingerprint: fact.fingerprint, + expected_content_manifest_hash: Some(manifest.hash().clone()), + }], }; - - let (result, ()) = tokio::join!(run, cancel); - - assert_eq!(result, Err(crate::error::AppError::MutationCancelled)); - assert!(cleanup_finished.load(std::sync::atomic::Ordering::Acquire)); + let payloads = BTreeMap::from([(payload_id, lease)]); + let executor = crate::environment::wsl::operations::materialize::WslPreparedUnitExecutor::for_operation(session, workspace.clone(), "http-update-gate", crate::core::mutation::MutationKind::Update); + let prepared = executor + .prepare(&unit, &payloads, CancellationSignal::default()) + .await + .unwrap(); + executor + .execute(prepared, None, CancellationSignal::default()) + .await + .unwrap(); + let verification_key = PayloadStorageKey::new( + format!("verify-{}", uuid::Uuid::new_v4().simple()), + "installed", + ); + let verification = storage + .acquire_from_path(&verification_key, &target.native_path, None) + .await + .unwrap(); + assert_eq!(verification.manifest.payload_root_hash, expected); + storage + .remove_session(verification_key.session_id()) + .await + .unwrap(); + drop(payloads); + manager.retire_wsl_sessions(); + manager.cleanup().await.unwrap(); + } + + async fn run_fixture_command(distro: &str, script: &str, fixture: &str) { + let status = crate::environment::wsl::wsl_command() + .args([ + "--distribution", + distro, + "--exec", + "/bin/sh", + "-c", + script, + "--", + fixture, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status() + .await + .unwrap(); + assert!(status.success()); + } + + struct FixtureCleanup { + distro: String, + fixture: String, + } + + impl Drop for FixtureCleanup { + fn drop(&mut self) { + let _ = std::process::Command::new("wsl.exe") + .args([ + "--distribution", + &self.distro, + "--exec", + "/bin/rm", + "-rf", + "--", + &self.fixture, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } } } diff --git a/src-tauri/src/environment/wsl/protocol.rs b/src-tauri/src/environment/wsl/protocol.rs index f0f9c69e..1874912c 100644 --- a/src-tauri/src/environment/wsl/protocol.rs +++ b/src-tauri/src/environment/wsl/protocol.rs @@ -1,6 +1,5 @@ #![cfg_attr(not(target_os = "windows"), allow(dead_code))] -use std::collections::VecDeque; use std::future::pending; #[cfg(target_os = "windows")] use std::process::Stdio; @@ -11,8 +10,6 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio::time::{Duration, Instant}; -#[cfg(target_os = "windows")] -use crate::background_process::tokio_command; use crate::core::mutation::CancellationSignal; use crate::environment::types::EnvironmentRef; use crate::environment::wsl::WslSession; @@ -24,67 +21,9 @@ const CANCELLATION_POLL_INTERVAL: Duration = Duration::from_millis(25); const WSL_BOOTSTRAP_SCRIPT: &str = r#"printf 'skill-deck-wsl-shell-started-v1\n' >&2; script=$1; shift; exec /bin/sh -c "$script" -- "$@""#; const WSL_SHELL_STARTED_MARKER: &[u8] = b"skill-deck-wsl-shell-started-v1\n"; -#[derive(Clone, Copy)] -pub(super) enum OutputOverflowBehavior { - Fail, - PreservePrefixAndTail { prefix_bytes: usize }, -} - -#[derive(Clone, Copy)] -pub(super) struct OutputCapturePolicy { - pub(super) stdout: OutputOverflowBehavior, - pub(super) stderr: OutputOverflowBehavior, -} - -const STRICT_OUTPUT_CAPTURE: OutputCapturePolicy = OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::Fail, -}; - -pub(super) const GIT_OUTPUT_CAPTURE: OutputCapturePolicy = OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::PreservePrefixAndTail { - prefix_bytes: WSL_SHELL_STARTED_MARKER.len(), - }, -}; - -struct WslCommandRequest { - pub session: WslSession, - pub script: &'static str, - pub args: Vec, - pub stdin: Vec, - pub timeout: Duration, - pub stdout_limit: usize, - pub stderr_limit: usize, - pub cancellation: Option, -} - -pub(super) type WslExitMapper = fn(Option, &str) -> Option; - -pub(super) fn no_wsl_exit_mapping(_: Option, _: &str) -> Option { - None -} - -pub(super) struct WslOperationDescriptor { - pub(super) subcommand: &'static str, - pub(super) script: &'static str, - pub(super) map_exit: WslExitMapper, -} - -pub(super) const fn wsl_operation( - _name: &'static str, - subcommand: &'static str, - script: &'static str, -) -> WslOperationDescriptor { - WslOperationDescriptor { - subcommand, - script, - map_exit: no_wsl_exit_mapping, - } -} - -pub(super) struct WslOperationRequest { +pub(super) struct WslCommandRequest { pub(super) session: WslSession, + pub(super) script: &'static str, pub(super) args: Vec, pub(super) stdin: Vec, pub(super) timeout: Duration, @@ -100,9 +39,7 @@ pub(super) struct WslCommandOutput { pub(super) exit_code: Option, } -struct WslCommandRunner; - -pub(super) struct WslOperationExecutor; +pub(super) struct WslCommandRunner; pub(crate) fn build_wsl_exec_args( distro_name: &str, @@ -125,14 +62,6 @@ pub(crate) fn build_wsl_exec_args( args } -pub(super) fn decode_nul_records(bytes: &[u8]) -> Vec { - bytes - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()) - .map(|record| String::from_utf8_lossy(record).into_owned()) - .collect() -} - fn build_wsl_runner_exec_args(request: &WslCommandRequest) -> Vec { let mut args = Vec::with_capacity(request.args.len() + 1); args.push(request.script.to_string()); @@ -145,89 +74,31 @@ fn build_wsl_runner_exec_args(request: &WslCommandRequest) -> Vec { ) } -fn operation_command_request( - descriptor: &WslOperationDescriptor, - request: WslOperationRequest, -) -> WslCommandRequest { - let mut args = - Vec::with_capacity(request.args.len() + usize::from(!descriptor.subcommand.is_empty())); - if !descriptor.subcommand.is_empty() { - args.push(descriptor.subcommand.to_string()); - } - args.extend(request.args); - WslCommandRequest { - session: request.session, - script: descriptor.script, - args, - stdin: request.stdin, - timeout: request.timeout, - stdout_limit: request.stdout_limit, - stderr_limit: request.stderr_limit, - cancellation: request.cancellation, - } -} - async fn read_output( mut reader: R, stream: &'static str, limit: usize, - overflow_behavior: OutputOverflowBehavior, limit_tx: mpsc::Sender, ) -> Result, AppError> where R: AsyncRead + Unpin, { - let prefix_capacity = match overflow_behavior { - OutputOverflowBehavior::Fail => limit, - OutputOverflowBehavior::PreservePrefixAndTail { prefix_bytes } => prefix_bytes.min(limit), - }; - let tail_capacity = limit.saturating_sub(prefix_capacity); - let mut output = Vec::with_capacity(prefix_capacity.min(8 * 1024)); - let mut tail = VecDeque::with_capacity(tail_capacity.min(8 * 1024)); - let mut tail_truncated = false; + let mut output = Vec::with_capacity(limit.min(8 * 1024)); let mut chunk = [0u8; 8 * 1024]; loop { let read = reader.read(&mut chunk).await?; if read == 0 { - if tail_truncated { - while let Some(byte) = tail.pop_front() { - if byte == b'\n' { - break; - } - } - } - output.extend(tail); return Ok(output); } - - match overflow_behavior { - OutputOverflowBehavior::Fail => { - if output.len().saturating_add(read) > limit { - let error = AppError::WslOutputLimitExceeded { - stream: stream.to_string(), - limit: u32::try_from(limit).unwrap_or(u32::MAX), - }; - let _ = limit_tx.send(error.clone()).await; - return Err(error); - } - output.extend_from_slice(&chunk[..read]); - } - OutputOverflowBehavior::PreservePrefixAndTail { .. } => { - let prefix_remaining = prefix_capacity.saturating_sub(output.len()); - let prefix_bytes = prefix_remaining.min(read); - output.extend_from_slice(&chunk[..prefix_bytes]); - if tail_capacity == 0 { - continue; - } - for byte in &chunk[prefix_bytes..read] { - if tail.len() == tail_capacity { - tail.pop_front(); - tail_truncated = true; - } - tail.push_back(*byte); - } - } + if output.len().saturating_add(read) > limit { + let error = AppError::WslOutputLimitExceeded { + stream: stream.to_string(), + limit: u32::try_from(limit).unwrap_or(u32::MAX), + }; + let _ = limit_tx.send(error.clone()).await; + return Err(error); } + output.extend_from_slice(&chunk[..read]); } } @@ -263,7 +134,6 @@ async fn supervise_child( timeout_duration: Duration, stdout_limit: usize, stderr_limit: usize, - output_capture: OutputCapturePolicy, cancellation: Option, ) -> Result { let mut stdin = child.stdin.take().ok_or_else(|| AppError::Custom { @@ -284,16 +154,9 @@ async fn supervise_child( stdout, "stdout", stdout_limit, - output_capture.stdout, limit_tx.clone(), )); - let mut stderr_reader = tokio::spawn(read_output( - stderr, - "stderr", - stderr_limit, - output_capture.stderr, - limit_tx, - )); + let mut stderr_reader = tokio::spawn(read_output(stderr, "stderr", stderr_limit, limit_tx)); let deadline = Instant::now() + timeout_duration; let deadline_sleep = tokio::time::sleep_until(deadline); tokio::pin!(deadline_sleep); @@ -410,48 +273,11 @@ fn interpret_wsl_transport_output( Ok(output) } -fn interpret_wsl_operation_output( - output: WslCommandOutput, - map_exit: F, -) -> Result -where - F: Fn(Option, &str) -> Option, -{ - if output.exit_code != Some(0) { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if let Some(error) = map_exit(output.exit_code, &stderr) { - return Err(error); - } - return Err(AppError::WslCommandFailed { - exit_code: output.exit_code, - stderr, - }); - } - Ok(output) -} - -#[cfg(test)] -fn interpret_wsl_command_output( - session: &WslSession, - output: WslCommandOutput, -) -> Result { - let output = interpret_wsl_transport_output(session, output)?; - interpret_wsl_operation_output(output, |_, _| None) -} - impl WslCommandRunner { #[cfg(target_os = "windows")] pub async fn run(request: WslCommandRequest) -> Result { - Self::run_with_output_capture(request, STRICT_OUTPUT_CAPTURE).await - } - - #[cfg(target_os = "windows")] - async fn run_with_output_capture( - request: WslCommandRequest, - output_capture: OutputCapturePolicy, - ) -> Result { let args = build_wsl_runner_exec_args(&request); - let mut command = tokio_command("wsl.exe"); + let mut command = super::wsl_command(); command .args(args) .stdin(Stdio::piped()) @@ -473,7 +299,6 @@ impl WslCommandRunner { request.timeout, request.stdout_limit, request.stderr_limit, - output_capture, request.cancellation, ) .await?; @@ -482,14 +307,6 @@ impl WslCommandRunner { #[cfg(not(target_os = "windows"))] pub async fn run(request: WslCommandRequest) -> Result { - Self::run_with_output_capture(request, STRICT_OUTPUT_CAPTURE).await - } - - #[cfg(not(target_os = "windows"))] - async fn run_with_output_capture( - request: WslCommandRequest, - _output_capture: OutputCapturePolicy, - ) -> Result { Err(AppError::EnvironmentUnavailable { environment: EnvironmentRef::Wsl { distro_name: request.session.distro_name, @@ -499,29 +316,6 @@ impl WslCommandRunner { } } -impl WslOperationExecutor { - pub(super) async fn execute( - descriptor: &WslOperationDescriptor, - request: WslOperationRequest, - ) -> Result { - let output = WslCommandRunner::run(operation_command_request(descriptor, request)).await?; - interpret_wsl_operation_output(output, descriptor.map_exit) - } - - pub(super) async fn execute_with_output_capture( - descriptor: &WslOperationDescriptor, - request: WslOperationRequest, - output_capture: OutputCapturePolicy, - ) -> Result { - let output = WslCommandRunner::run_with_output_capture( - operation_command_request(descriptor, request), - output_capture, - ) - .await?; - interpret_wsl_operation_output(output, descriptor.map_exit) - } -} - #[cfg(test)] #[allow( clippy::disallowed_methods, @@ -533,10 +327,8 @@ mod tests { use tokio::time::Duration; use super::{ - build_wsl_exec_args, decode_nul_records, interpret_wsl_command_output, - interpret_wsl_operation_output, interpret_wsl_transport_output, operation_command_request, - WslCommandOutput, WslCommandRequest, WslCommandRunner, WslOperationDescriptor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, + build_wsl_exec_args, interpret_wsl_transport_output, WslCommandOutput, WslCommandRequest, + WslCommandRunner, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, WSL_SHELL_STARTED_MARKER, }; use crate::core::mutation::CancellationSignal; @@ -551,7 +343,7 @@ mod tests { use tokio::process::{Child, Command}; use tokio::time::timeout; - use super::super::{supervise_child, OutputCapturePolicy, OutputOverflowBehavior}; + use super::super::supervise_child; use super::*; fn spawn_shell(script: &str) -> (Child, u32) { @@ -580,17 +372,10 @@ mod tests { #[tokio::test] async fn supervisor_collects_stdout_stderr_and_exit_code() { let (child, _) = spawn_shell("printf stdout; printf stderr >&2; exit 7"); - let output = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 1024, - 1024, - super::super::STRICT_OUTPUT_CAPTURE, - None, - ) - .await - .expect("supervise child"); + let output = + supervise_child(child, Vec::new(), Duration::from_secs(1), 1024, 1024, None) + .await + .expect("supervise child"); assert_eq!(output.stdout, b"stdout"); assert_eq!(output.stderr, b"stderr"); @@ -603,17 +388,9 @@ mod tests { [("printf 12345", "stdout"), ("printf 12345 >&2", "stderr")] { let (child, _) = spawn_shell(script); - let error = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 4, - 4, - super::super::STRICT_OUTPUT_CAPTURE, - None, - ) - .await - .expect_err("output must be bounded"); + let error = supervise_child(child, Vec::new(), Duration::from_secs(1), 4, 4, None) + .await + .expect_err("output must be bounded"); assert!(matches!( error, @@ -623,67 +400,6 @@ mod tests { } } - #[tokio::test] - async fn supervisor_preserves_marker_and_stderr_tail_for_git_output() { - let script = format!( - "printf '{}\\n' >&2; printf 'discard-me-1234567890\\n' >&2; printf 'useful-tail\\n' >&2", - String::from_utf8_lossy(WSL_SHELL_STARTED_MARKER).trim_end() - ); - let (child, _) = spawn_shell(&script); - - let output = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 64, - 48, - OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::PreservePrefixAndTail { - prefix_bytes: WSL_SHELL_STARTED_MARKER.len(), - }, - }, - None, - ) - .await - .expect("Git stderr should retain a bounded tail"); - - assert!(output.stderr.starts_with(WSL_SHELL_STARTED_MARKER)); - assert!(output.stderr.ends_with(b"useful-tail\n")); - assert!(output.stderr.len() <= 48); - } - - #[tokio::test] - async fn supervisor_discards_a_truncated_sensitive_stderr_line() { - let script = format!( - "printf '{}\\nAuthorization: Bearer ' >&2; printf '%080d' 0 >&2; printf '\\nvisible diagnostic\\n' >&2", - String::from_utf8_lossy(WSL_SHELL_STARTED_MARKER).trim_end() - ); - let (child, _) = spawn_shell(&script); - - let output = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 64, - 64, - OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::PreservePrefixAndTail { - prefix_bytes: WSL_SHELL_STARTED_MARKER.len(), - }, - }, - None, - ) - .await - .expect("Git stderr should retain complete diagnostic lines"); - - assert_eq!( - &output.stderr[WSL_SHELL_STARTED_MARKER.len()..], - b"visible diagnostic\n" - ); - } - #[tokio::test] async fn supervisor_kills_and_reaps_a_timed_out_child_and_writer() { let (child, pid) = spawn_shell("exec sleep 5"); @@ -693,7 +409,6 @@ mod tests { Duration::from_millis(25), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, None, ) .await @@ -719,7 +434,6 @@ mod tests { Duration::from_secs(1), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, Some(cancellation), ) .await @@ -740,7 +454,6 @@ mod tests { Duration::from_secs(1), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, None, ), ) @@ -759,7 +472,6 @@ mod tests { Duration::from_millis(25), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, None, ) .await @@ -808,14 +520,6 @@ mod tests { assert_eq!(args[9], "$(touch /tmp/pwned)"); } - #[test] - fn decodes_nul_delimited_versioned_records() { - assert_eq!( - decode_nul_records(b"1\0first\0second\0"), - vec!["1", "first", "second"] - ); - } - #[test] fn command_request_carries_bounded_defaults_and_cancellation() { let cancellation = CancellationSignal::default(); @@ -836,12 +540,12 @@ mod tests { } #[test] - fn shell_start_marker_distinguishes_session_and_business_failures() { + fn shell_start_marker_distinguishes_launcher_and_bootstrap_failures() { let session = test_session(); let environment = EnvironmentRef::Wsl { distro_name: "Ubuntu".to_string(), }; - let unavailable = interpret_wsl_command_output( + let unavailable = interpret_wsl_transport_output( &session, WslCommandOutput { stdout: Vec::new(), @@ -860,7 +564,7 @@ mod tests { let mut marked_stderr = WSL_SHELL_STARTED_MARKER.to_vec(); marked_stderr.extend_from_slice(b"permission denied"); - let business_failure = interpret_wsl_command_output( + let bootstrap_failure = interpret_wsl_transport_output( &session, WslCommandOutput { stdout: Vec::new(), @@ -868,14 +572,9 @@ mod tests { exit_code: Some(13), }, ) - .expect_err("business failure"); - assert!(matches!( - business_failure, - AppError::WslCommandFailed { - exit_code: Some(13), - stderr, - } if stderr == "permission denied" - )); + .expect("the bootstrap shell started"); + assert_eq!(bootstrap_failure.exit_code, Some(13)); + assert_eq!(bootstrap_failure.stderr, b"permission denied"); } #[test] @@ -898,78 +597,12 @@ mod tests { assert_eq!(output.stderr, b"permission denied"); } - #[test] - fn operation_exit_mapping_is_local_and_unknown_codes_fall_back() { - let map_exit = |exit_code, _stderr: &str| match exit_code { - Some(44) => Some(AppError::PathNotFound { - path: "/tmp/SKILL.md".to_string(), - }), - _ => None, - }; - - let known = interpret_wsl_operation_output( - WslCommandOutput { - stdout: Vec::new(), - stderr: Vec::new(), - exit_code: Some(44), - }, - map_exit, - ) - .expect_err("known operation error"); - assert!(matches!(known, AppError::PathNotFound { .. })); - - let unknown = interpret_wsl_operation_output( - WslCommandOutput { - stdout: Vec::new(), - stderr: b"unexpected".to_vec(), - exit_code: Some(99), - }, - map_exit, - ) - .expect_err("unknown operation error"); - assert!(matches!( - unknown, - AppError::WslCommandFailed { - exit_code: Some(99), - stderr, - } if stderr == "unexpected" - )); - } - - #[test] - fn operation_descriptor_prepends_its_fixed_subcommand() { - fn no_mapping(_: Option, _: &str) -> Option { - None - } - - static DESCRIPTOR: WslOperationDescriptor = WslOperationDescriptor { - subcommand: "stage", - script: "printf ok", - map_exit: no_mapping, - }; - let operation = WslOperationRequest { - session: test_session(), - args: vec!["root".to_string()], - stdin: b"request".to_vec(), - timeout: Duration::from_secs(5), - stdout_limit: 32, - stderr_limit: 64, - cancellation: None, - }; - - let command = operation_command_request(&DESCRIPTOR, operation); - - assert_eq!(command.script, "printf ok"); - assert_eq!(command.args, vec!["stage", "root"]); - assert_eq!(command.stdin, b"request"); - } - #[test] fn shell_start_marker_is_removed_from_successful_stderr() { let mut stderr = WSL_SHELL_STARTED_MARKER.to_vec(); stderr.extend_from_slice(b"warning"); - let output = interpret_wsl_command_output( + let output = interpret_wsl_transport_output( &test_session(), WslCommandOutput { stdout: b"ok".to_vec(), diff --git a/src-tauri/src/environment/wsl/scripts/acquire.sh b/src-tauri/src/environment/wsl/scripts/acquire.sh deleted file mode 100644 index 189c62fa..00000000 --- a/src-tauri/src/environment/wsl/scripts/acquire.sh +++ /dev/null @@ -1,523 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - source-revision) - repository_root=$1 - skill_path=$2 - case "$repository_root" in /*) ;; *) exit 61 ;; esac - case "$skill_path" in /*|..|../*|*/..|*/../*) exit 62 ;; esac - physical_root=$(cd -P -- "$repository_root" 2>/dev/null && pwd -P) || exit 63 - if [ -n "$skill_path" ]; then - revision_spec=HEAD:$skill_path - else - revision_spec='HEAD^{tree}' - fi - revision=$(git -C "$physical_root" rev-parse --verify "$revision_spec" 2>/dev/null) || exit 64 - case "${#revision}" in 40|64) ;; *) exit 65 ;; esac - case "$revision" in *[!0-9a-fA-F]*) exit 66 ;; esac - printf '1\0%s\0' "$revision" - ;; - fingerprint) - source_root=$1 - physical_root=$(cd -P -- "$source_root" 2>/dev/null && pwd -P) || exit 61 - [ -d "$physical_root" ] || exit 62 - - # Reject links that cannot be resolved inside the selected Skill. - # shellcheck disable=SC2016 - find "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -type l -exec /bin/sh -c ' - root=$1 - shift - for link do - target=$(readlink -f -- "$link" 2>/dev/null) || exit 1 - case "$target" in "$root"/*) ;; *) exit 1 ;; esac - done - ' sh "$physical_root" {} + || exit 63 - - # shellcheck disable=SC2016 - fingerprint=$( - find "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - ! -name metadata.json -print0 \ - | LC_ALL=C sort -z \ - | xargs -0 -r /bin/sh -c ' - root=$1 - shift - for path do - relative=${path#"$root"/} - if [ -L "$path" ]; then - kind=link - target=$(readlink -- "$path") || exit 1 - elif [ -d "$path" ]; then - kind=directory - target= - elif [ -f "$path" ]; then - kind=file - target= - else - kind=other - target= - fi - size=$(stat -c %s -- "$path") || exit 1 - modified=$(stat -c %Y -- "$path") || exit 1 - mode=$(stat -c %a -- "$path") || exit 1 - printf "%s\0%s\0%s\0%s\0%s\0%s\0" \ - "$relative" "$kind" "$size" "$modified" "$mode" "$target" || exit 1 - done - ' sh "$physical_root" \ - | sha256sum - ) || exit 64 - fingerprint=${fingerprint%% *} - [ "${#fingerprint}" -eq 64 ] || exit 65 - case "$fingerprint" in *[!0-9a-f]*) exit 66 ;; esac - printf '1\0%s\0' "$fingerprint" - ;; - acquire) - - source_root=$1 - session_root=$2 - payload_root=$3 - session_id=$4 - physical_root=$(cd -P -- "$source_root" 2>/dev/null && pwd -P) || exit 61 - [ -d "$physical_root" ] || exit 62 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 63 - case "$payload_name" in payload-*) ;; *) exit 64 ;; esac - case "$payload_name" in */*) exit 65 ;; esac - umask 077 - if [ ! -e "$session_root" ] && [ ! -L "$session_root" ]; then - mkdir -- "$session_root" || exit 66 - marker_tmp=$session_root/.skill-deck-owner.$$ - trap 'rm -rf -- "${stage-}" "$marker_tmp"' EXIT HUP INT TERM - printf '1\n%s\n' "$session_id" > "$marker_tmp" || exit 67 - mv -- "$marker_tmp" "$session_root/.skill-deck-owner" || exit 68 - else - trap 'rm -rf -- "${stage-}"' EXIT HUP INT TERM - fi - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 69 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 70 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 71 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 72 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 73 - [ ! -e "$payload_root" ] && [ ! -L "$payload_root" ] || exit 74 - stage=$session_root/.stage-$payload_name-$$ - mkdir -- "$stage" || exit 75 - mkdir -- "$stage/blobs" || exit 76 - - # shellcheck disable=SC2016 - find "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -type l -exec /bin/sh -c ' - root=$1 - stage=$2 - shift 2 - for link do - target=$(readlink -f -- "$link" 2>/dev/null) || { : > "$stage/.failed"; continue; } - case "$target" in "$root"/*) ;; *) : > "$stage/.failed" ;; esac - done - ' sh "$physical_root" "$stage" {} + || exit 77 - [ ! -e "$stage/.failed" ] || exit 78 - - # shellcheck disable=SC2016 - cli_hash=$( - find -L "$physical_root" -mindepth 1 \ - \( -name .git -o -name node_modules -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -type f ! -name metadata.json -print0 \ - | LC_ALL=C sort -z -f \ - | xargs -0 -r /bin/sh -c ' - root=$1 - shift - for path do - relative=${path#"$root"/} - printf %s "$relative" || exit 1 - cat -- "$path" || exit 1 - done - ' sh "$physical_root" \ - | sha256sum - ) || exit 77 - cli_hash=${cli_hash%% *} - [ "${#cli_hash}" -eq 64 ] || exit 78 - case "$cli_hash" in *[!0-9a-f]*) exit 79 ;; esac - printf '1\0H\0%s\0' "$cli_hash" - # shellcheck disable=SC2016 - find -L "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -exec /bin/sh -c ' - root=$1 - stage=$2 - shift 2 - for path do - relative=${path#"$root"/} - if [ -d "$path" ]; then - printf "E\\0%s\\0%s\\0%s\\0%s\\0%s\\0" \ - directory "$relative" "" 0 0 - continue - fi - [ -f "$path" ] || continue - [ "${path##*/}" != metadata.json ] || continue - digest_line=$(sha256sum -- "$path" 2>/dev/null) || { - : > "$stage/.failed" - continue - } - blob_id=${digest_line%% *} - size=$(wc -c < "$path" 2>/dev/null) || { : > "$stage/.failed"; continue; } - mode=$(stat -c %a -- "$path" 2>/dev/null) || { : > "$stage/.failed"; continue; } - executable=$(( (0$mode & 0111) != 0 )) - blob=$stage/blobs/$blob_id - if [ ! -e "$blob" ]; then - cp -- "$path" "$blob" 2>/dev/null || { : > "$stage/.failed"; continue; } - chmod 600 -- "$blob" 2>/dev/null || { : > "$stage/.failed"; continue; } - fi - digest_line=$(sha256sum -- "$blob" 2>/dev/null) || { - : > "$stage/.failed" - continue - } - [ "${digest_line%% *}" = "$blob_id" ] || { - : > "$stage/.failed" - continue - } - printf "E\\0%s\\0%s\\0%s\\0%s\\0%s\\0" \ - file "$relative" "$blob_id" "$size" "$executable" - done - ' sh "$physical_root" "$stage" {} + || exit 80 - [ ! -e "$stage/.failed" ] || exit 81 - find "$stage/blobs" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' \ - | LC_ALL=C sort > "$stage/blob-list" || exit 82 - mv -- "$stage" "$payload_root" || exit 83 - stage= - trap - EXIT HUP INT TERM - - ;; - store-begin) - - session_root=$1 - payload_root=$2 - session_id=$3 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 61 - case "$payload_name" in payload-*) ;; *) exit 62 ;; esac - case "$payload_name" in */*) exit 63 ;; esac - stage=$payload_root.upload - umask 077 - if [ ! -e "$session_root" ] && [ ! -L "$session_root" ]; then - mkdir -- "$session_root" || exit 64 - marker_tmp=$session_root/.skill-deck-owner.$$ - trap 'rm -rf -- "$stage" "$marker_tmp"' EXIT HUP INT TERM - printf '1\n%s\n' "$session_id" > "$marker_tmp" || exit 65 - mv -- "$marker_tmp" "$session_root/.skill-deck-owner" || exit 66 - else - trap 'rm -rf -- "$stage"' EXIT HUP INT TERM - fi - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 67 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 68 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 69 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 70 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 71 - [ ! -e "$payload_root" ] && [ ! -L "$payload_root" ] || exit 72 - [ ! -e "$stage" ] && [ ! -L "$stage" ] || exit 73 - mkdir -- "$stage" || exit 74 - mkdir -- "$stage/blobs" || exit 75 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - store-blob) - - session_root=$1 - payload_root=$2 - session_id=$3 - blob_id=$4 - [ "${#blob_id}" -eq 64 ] || exit 61 - case "$blob_id" in *[!0-9a-f]*) exit 62 ;; esac - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 63 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 64 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 67 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 68 - case "$payload_name" in payload-*) ;; *) exit 69 ;; esac - case "$payload_name" in */*) exit 70 ;; esac - stage=$payload_root.upload - [ -d "$stage" ] && [ ! -L "$stage" ] || exit 71 - [ -d "$stage/blobs" ] && [ ! -L "$stage/blobs" ] || exit 72 - blob=$stage/blobs/$blob_id - [ ! -e "$blob" ] && [ ! -L "$blob" ] || exit 73 - tmp=$stage/.blob.$$ - trap 'rm -f -- "$tmp"' EXIT HUP INT TERM - cat > "$tmp" || exit 74 - [ "$(sha256sum -- "$tmp" | awk '{print $1}')" = "$blob_id" ] || exit 75 - chmod 600 -- "$tmp" || exit 76 - sync "$tmp" 2>/dev/null || true - mv -- "$tmp" "$blob" || exit 77 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - store-finalize) - - session_root=$1 - payload_root=$2 - session_id=$3 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ ! -e "$payload_root" ] && [ ! -L "$payload_root" ] || exit 69 - stage=$payload_root.upload - [ -d "$stage" ] && [ ! -L "$stage" ] || exit 70 - [ -d "$stage/blobs" ] && [ ! -L "$stage/blobs" ] || exit 71 - find "$stage/blobs" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' \ - | LC_ALL=C sort > "$stage/blob-list" || exit 72 - IFS= read -r expected_count || exit 73 - case "$expected_count" in ''|*[!0-9]*) exit 74 ;; esac - exec 3< "$stage/blob-list" - index=0 - while [ "$index" -lt "$expected_count" ]; do - IFS= read -r expected_id || exit 75 - IFS= read -r actual_id <&3 || exit 76 - [ "$expected_id" = "$actual_id" ] || exit 77 - blob=$stage/blobs/$actual_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 78 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$actual_id" ] || exit 79 - index=$((index + 1)) - done - if IFS= read -r _ <&3; then exit 80; fi - manifest_tmp=$stage/.manifest.$$ - trap 'rm -f -- "$manifest_tmp"' EXIT HUP INT TERM - cat > "$manifest_tmp" || exit 81 - [ -s "$manifest_tmp" ] || exit 82 - sync "$manifest_tmp" 2>/dev/null || true - mv -- "$manifest_tmp" "$stage/manifest.json" || exit 83 - mv -- "$stage" "$payload_root" || exit 84 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - finalize) - - session_root=$1 - payload_root=$2 - session_id=$3 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ -d "$payload_root" ] && [ ! -L "$payload_root" ] || exit 69 - [ -f "$payload_root/blob-list" ] && [ ! -L "$payload_root/blob-list" ] || exit 70 - [ -d "$payload_root/blobs" ] && [ ! -L "$payload_root/blobs" ] || exit 71 - [ ! -e "$payload_root/manifest.json" ] && [ ! -L "$payload_root/manifest.json" ] || exit 72 - IFS= read -r expected_count || exit 73 - case "$expected_count" in ''|*[!0-9]*) exit 74 ;; esac - exec 3< "$payload_root/blob-list" - index=0 - while [ "$index" -lt "$expected_count" ]; do - IFS= read -r expected_id || exit 75 - IFS= read -r actual_id <&3 || exit 76 - [ "$expected_id" = "$actual_id" ] || exit 77 - blob=$payload_root/blobs/$actual_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 78 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$actual_id" ] || exit 79 - index=$((index + 1)) - done - if IFS= read -r _ <&3; then exit 80; fi - manifest_tmp=$payload_root/.manifest.$$ - trap 'rm -f -- "$manifest_tmp"' EXIT HUP INT TERM - cat > "$manifest_tmp" || exit 81 - [ -s "$manifest_tmp" ] || exit 82 - sync "$manifest_tmp" 2>/dev/null || true - mv -- "$manifest_tmp" "$payload_root/manifest.json" || exit 83 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - verify) - - session_root=$1 - payload_root=$2 - session_id=$3 - validate_expected=0 - if [ "${4-}" = --expected ]; then - validate_expected=1 - fi - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ -d "$payload_root" ] && [ ! -L "$payload_root" ] || exit 69 - manifest=$payload_root/manifest.json - blob_list=$payload_root/blob-list - blobs=$payload_root/blobs - [ -f "$manifest" ] && [ ! -L "$manifest" ] || exit 70 - [ -f "$blob_list" ] && [ ! -L "$blob_list" ] || exit 71 - [ -d "$blobs" ] && [ ! -L "$blobs" ] || exit 72 - if [ "$validate_expected" -eq 1 ]; then - cmp -s - "$blob_list" || exit 78 - fi - actual_count=0 - while IFS= read -r blob_id || [ -n "$blob_id" ]; do - [ -n "$blob_id" ] || exit 73 - [ "${#blob_id}" -eq 64 ] || exit 74 - case "$blob_id" in *[!0-9a-f]*) exit 75 ;; esac - blob=$blobs/$blob_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 76 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$blob_id" ] || exit 77 - actual_count=$((actual_count + 1)) - done < "$blob_list" - cat -- "$manifest" - - ;; - read-blob) - - session_root=$1 - payload_root=$2 - session_id=$3 - blob_id=$4 - [ "${#blob_id}" -eq 64 ] || exit 61 - case "$blob_id" in *[!0-9a-f]*) exit 62 ;; esac - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 63 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 64 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 67 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 68 - case "$payload_name" in payload-*) ;; *) exit 69 ;; esac - case "$payload_name" in */*) exit 70 ;; esac - [ -d "$payload_root/blobs" ] && [ ! -L "$payload_root" ] && [ ! -L "$payload_root/blobs" ] || exit 71 - blob=$payload_root/blobs/$blob_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 72 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$blob_id" ] || exit 73 - cat -- "$blob" - - ;; - remove-payload) - - session_root=$1 - payload_root=$2 - session_id=$3 - [ ! -e "$session_root" ] && [ ! -L "$session_root" ] && exit 0 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ ! -L "$payload_root" ] || exit 69 - [ ! -e "$payload_root" ] && exit 0 - [ -d "$payload_root" ] || exit 70 - rm -rf -- "$payload_root" - - ;; - remove-session) - - session_root=$1 - session_id=$2 - expected_root=/tmp/skill-deck-source-$session_id - [ "$session_root" = "$expected_root" ] || exit 61 - [ ! -e "$session_root" ] && [ ! -L "$session_root" ] && exit 0 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 62 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 63 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 64 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 65 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 66 - rm -rf -- "$session_root" - - ;; - sweep-orphans) - - base=$1 - shift - case "$base" in /*) ;; *) exit 61 ;; esac - removed=0 - protected=0 - external=0 - blocked=0 - printf '1\0' - for root in "$base"/skill-deck-source-*; do - [ -e "$root" ] || [ -L "$root" ] || continue - candidate=${root##*/} - retain() { - code=$1 - blocked=1 - size=$(du -sb -- "$root" 2>/dev/null | awk '{print $1}') - case "$size" in ''|*[!0-9]*) - printf 'W\0sizeUnavailable\0%s\0-\0' "$candidate" - ;; - *) external=$((external + size)) ;; - esac - printf 'W\0%s\0%s\0-\0' "$code" "$candidate" - } - if [ ! -d "$root" ] || [ -L "$root" ]; then - retain boundaryRejected - continue - fi - marker=$root/.skill-deck-owner - if [ ! -f "$marker" ] || [ -L "$marker" ] || [ "$(wc -l < "$marker" 2>/dev/null)" -ne 2 ]; then - retain invalidMarker - continue - fi - version=$(sed -n '1p' "$marker") - session_id=$(sed -n '2p' "$marker") - if [ "$version" != 1 ]; then - retain futureMarkerVersion - continue - fi - if [ -z "$session_id" ] || [ "$root" != "$base/skill-deck-source-$session_id" ]; then - retain invalidMarker - continue - fi - is_protected=0 - for protected_id do - if [ "$protected_id" = "$session_id" ]; then - is_protected=1 - break - fi - done - if [ "$is_protected" -eq 1 ]; then - protected=$((protected + 1)) - continue - fi - if rm -rf -- "$root"; then - removed=$((removed + 1)) - else - retain deleteFailed - fi - done - printf 'S\0%s\0%s\0%s\0%s\0' "$removed" "$protected" "$external" "$blocked" - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/atomic-file.sh b/src-tauri/src/environment/wsl/scripts/atomic-file.sh deleted file mode 100644 index 793e6320..00000000 --- a/src-tauri/src/environment/wsl/scripts/atomic-file.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - read) - if [ -f "$1" ]; then printf '1\0'; printf '1\0'; cat -- "$1"; elif [ ! -e "$1" ]; then printf '1\0'; printf '0\0'; else exit 66; fi - ;; - write) - - path=$1 - dir=${path%/*} - [ "$dir" != "$path" ] || dir=. - mkdir -p -- "$dir" || exit 67 - tmp=$(mktemp "$dir/.skill-deck-document.XXXXXX") || exit 68 - trap 'rm -f -- "$tmp"' EXIT HUP INT TERM - cat > "$tmp" || exit 69 - sync "$tmp" 2>/dev/null || exit 70 - rm -f -- "$path.bak" || exit 71 - sync "$dir" 2>/dev/null || exit 72 - mv -f -- "$tmp" "$path" || exit 73 - tmp= - sync "$path" 2>/dev/null || exit 74 - sync "$dir" 2>/dev/null || exit 75 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/content-manifest.sh b/src-tauri/src/environment/wsl/scripts/content-manifest.sh deleted file mode 100644 index 32bcd8a3..00000000 --- a/src-tauri/src/environment/wsl/scripts/content-manifest.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - root=$1 - case "$root" in - /*) ;; - *) exit 64 ;; - esac - [ -d "$root" ] && [ ! -L "$root" ] && [ -r "$root" ] && [ -x "$root" ] || exit 66 - [ "$root" = / ] || root=${root%/} - - work_dir=$(mktemp -d "${TMPDIR:-/tmp}/skill-deck-manifest.XXXXXX") || exit 67 - trap 'rm -rf -- "$work_dir"' EXIT HUP INT TERM - records=$work_dir/records - counts=$work_dir/counts - : > "$records" || exit 67 - : > "$counts" || exit 67 - - if ! LC_ALL=C find "$root" -mindepth 1 -exec sh -c ' - root=$1 - records=$2 - counts=$3 - shift 3 - for path do - relative=${path#"$root"/} - [ "$relative" != "$path" ] || exit 68 - path_length=$(LC_ALL=C printf %s "$relative" | wc -c) || exit 69 - case "$path_length" in ""|*[!0-9]*) exit 69 ;; esac - - executable=0 - data= - data_file= - if [ -L "$path" ]; then - kind=l - data_file=$(mktemp "${records}.data.XXXXXX") || exit 70 - readlink -n -- "$path" > "$data_file" || exit 70 - elif [ -d "$path" ]; then - kind=d - elif [ -f "$path" ]; then - kind=f - digest_line=$(sha256sum -- "$path") || exit 71 - digest_line=${digest_line#\\} - data=${digest_line%% *} - mode=$(stat -Lc %a -- "$path") || exit 72 - case "$mode" in *[1357]*) executable=1 ;; esac - else - exit 73 - fi - if [ -n "$data_file" ]; then - data_length=$(wc -c < "$data_file") || exit 69 - else - data_length=$(LC_ALL=C printf %s "$data" | wc -c) || exit 69 - fi - case "$data_length" in ""|*[!0-9]*) exit 69 ;; esac - printf "R %s %s %s %s\n" "$kind" "$executable" "$path_length" "$data_length" >> "$records" || exit 74 - printf %s "$relative" >> "$records" || exit 74 - if [ -n "$data_file" ]; then - cat "$data_file" >> "$records" || exit 74 - rm -f -- "$data_file" || exit 74 - else - printf %s "$data" >> "$records" || exit 74 - fi - printf "1\n" >> "$counts" || exit 74 - done - ' sh "$root" "$records" "$counts" {} +; then - exit 75 - fi - - count=$(wc -l < "$counts") || exit 76 - case "$count" in ""|*[!0-9]*) exit 76 ;; esac - printf 'SDCM 1\n' - cat "$records" || exit 77 - printf 'E %s\n' "$count" - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/directory-count.sh b/src-tauri/src/environment/wsl/scripts/directory-count.sh deleted file mode 100644 index d3d31924..00000000 --- a/src-tauri/src/environment/wsl/scripts/directory-count.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - limit=$1 - shift - max=$((limit + 1)) - work_dir=$(mktemp -d "${TMPDIR:-/tmp}/skill-deck-count.XXXXXX") || exit 67 - trap 'rm -rf -- "$work_dir"' EXIT HUP INT TERM - printf '1\0' - index=0 - for path do - if [ ! -d "$path" ] || [ ! -r "$path" ] || [ ! -x "$path" ]; then - printf 'path\0%s\0none\0%s\0%s\0' "$path" 0 0 - continue - fi - entries="$work_dir/entries.$index" - index=$((index + 1)) - if ! LC_ALL=C find "$path" -mindepth 1 -maxdepth 1 -print0 > "$entries" 2>/dev/null; then - printf 'path\0%s\0none\0%s\0%s\0' "$path" 0 0 - continue - fi - count=$(LC_ALL=C tr -cd '\000' < "$entries" | head -c "$max" | wc -c) - case "$count" in - ""|*[!0-9]*) printf 'path\0%s\0none\0%s\0%s\0' "$path" 0 0; continue ;; - esac - truncated=0 - if [ "$count" -gt "$limit" ]; then - count=$limit - truncated=1 - fi - printf 'path\0%s\0count\0%s\0%s\0' "$path" "$count" "$truncated" - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/entry.sh b/src-tauri/src/environment/wsl/scripts/entry.sh deleted file mode 100644 index e77d2162..00000000 --- a/src-tauri/src/environment/wsl/scripts/entry.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - printf '1\0' - index=0 - for path do - kind=missing - device= - inode= - mode= - size= - mtime_seconds= - mtime_nanos= - link_target= - if [ -e "$path" ] || [ -L "$path" ]; then - if [ -L "$path" ]; then - kind=symlink - link_target=$(readlink -- "$path") || exit 61 - [ -e "$path" ] || kind=brokenLink - elif [ -d "$path" ]; then - kind=directory - elif [ -f "$path" ]; then - kind='file' - else - kind=other - fi - device=$(stat -c %d -- "$path") || exit 62 - inode=$(stat -c %i -- "$path") || exit 63 - mode=$(stat -c %f -- "$path") || exit 64 - size=$(stat -c %s -- "$path") || exit 65 - mtime_seconds=$(stat -c %Y -- "$path") || exit 66 - mtime_text=$(stat -c %y -- "$path") || exit 67 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - fi - printf 'S\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0' \ - "$index" "$kind" "$device" "$inode" "$mode" "$size" \ - "$mtime_seconds" "$mtime_nanos" "$link_target" - index=$((index + 1)) - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/eve.sh b/src-tauri/src/environment/wsl/scripts/eve.sh deleted file mode 100644 index 78914dc3..00000000 --- a/src-tauri/src/environment/wsl/scripts/eve.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - project=$1 - if [ ! -d "$project/agent" ] || [ ! -f "$project/package.json" ]; then - printf '0\0' - exit 0 - fi - printf '1\0' - cat -- "$project/package.json" - printf '\0' - for dir in "$project/agent/subagents"/*; do - [ -d "$dir" ] || continue - printf '%s\0' "${dir##*/}" - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/library-content.sh b/src-tauri/src/environment/wsl/scripts/library-content.sh deleted file mode 100644 index 3500e7a2..00000000 --- a/src-tauri/src/environment/wsl/scripts/library-content.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/bin/sh -set -eu - -subcommand=$1 -shift -root=${HOME%/}/.skill-deck/skill-libraries -transactions=$root/.transactions - -write_state() { - target=$1 - value=$2 - temporary=$target.tmp.$$ - printf '%s' "$value" > "$temporary" - mv -f -- "$temporary" "$target" -} - -catalog_hash() { - if [ -f "$root/catalog.json" ]; then - sha256sum -- "$root/catalog.json" | awk '{ print $1 }' - fi -} - -rollback_content() { - destination=$1 - backup=$2 - rm -rf -- "$destination" - if [ -d "$backup" ]; then - mv -- "$backup" "$destination" || exit 72 - fi -} - -recover_transactions() { - [ -d "$transactions" ] || return 0 - for transaction in "$transactions"/*; do - [ -d "$transaction" ] || continue - [ -f "$transaction/destination" ] && [ -f "$transaction/phase" ] || exit 75 - destination=$(cat -- "$transaction/destination") - phase=$(cat -- "$transaction/phase") - [ -f "$transaction/desired-presence" ] || exit 75 - desired_presence=$(cat -- "$transaction/desired-presence") - stage=$transaction/stage - backup=$transaction/backup - case "$phase" in - preparing) - ;; - staged) - ;; - backedUp) - if [ ! -e "$destination" ] && [ -d "$backup" ]; then - mv -- "$backup" "$destination" || exit 72 - elif [ -e "$destination" ] && [ ! -e "$backup" ]; then - : - elif [ -e "$destination" ] && [ -d "$backup" ] && [ ! -e "$stage" ]; then - : - else - exit 73 - fi - ;; - activated) - rollback_content "$destination" "$backup" - ;; - catalogPrepared) - if [ "$desired_presence" = 1 ]; then - [ -d "$destination" ] || exit 74 - else - [ ! -e "$destination" ] || exit 74 - fi - [ -f "$transaction/expected-catalog-hash" ] || exit 75 - expected=$(cat -- "$transaction/expected-catalog-hash") - current=$(catalog_hash) - if [ "$current" != "$expected" ]; then - rollback_content "$destination" "$backup" - fi - ;; - catalogCommitted) - if [ "$desired_presence" = 1 ]; then - [ -d "$destination" ] || exit 74 - else - [ ! -e "$destination" ] || exit 74 - fi - ;; - *) exit 75 ;; - esac - rm -rf -- "$stage" "$backup" - rm -rf -- "$transaction" - done -} - -mkdir -p -- "$root" "$transactions" -case "$subcommand" in - prepare-catalog|finalize-catalog) ;; - *) recover_transactions ;; -esac - -case "$subcommand" in - recover) - printf '1\0' - ;; - ensure-libraries) - for library_id in "$@"; do - mkdir -p -- "$root/libraries/$library_id/skills" - done - printf '1\0' - ;; - replace) - library_id=$1 - skill_name=$2 - operation_id=$3 - destination=$root/libraries/$library_id/skills/$skill_name - transaction=$transactions/$operation_id - stage=$transaction/stage - backup=$transaction/backup - mkdir -p -- "$transaction" "${destination%/*}" - write_state "$transaction/destination" "$destination" - write_state "$transaction/desired-presence" '1' - write_state "$transaction/phase" 'preparing' - tar -xf - -C "$transaction" --no-same-owner || exit 76 - [ -d "$stage" ] || exit 77 - [ -f "$stage/SKILL.md" ] || exit 78 - write_state "$transaction/phase" 'staged' - if [ -e "$destination" ]; then - write_state "$transaction/phase" 'backedUp' - mv -- "$destination" "$backup" || exit 79 - fi - write_state "$transaction/phase" 'activated' - mv -- "$stage" "$destination" || exit 80 - [ -f "$destination/SKILL.md" ] || exit 81 - printf '1\0' - ;; - delete) - library_id=$1 - skill_name=$2 - operation_id=$3 - destination=$root/libraries/$library_id/skills/$skill_name - transaction=$transactions/$operation_id - backup=$transaction/backup - [ -e "$destination" ] || exit 85 - mkdir -p -- "$transaction" - write_state "$transaction/destination" "$destination" - write_state "$transaction/desired-presence" '0' - write_state "$transaction/phase" 'preparing' - write_state "$transaction/phase" 'backedUp' - mv -- "$destination" "$backup" || exit 79 - write_state "$transaction/phase" 'activated' - printf '1\0' - ;; - prepare-catalog) - expected=$1 - for transaction in "$transactions"/*; do - [ -d "$transaction" ] || continue - [ -f "$transaction/phase" ] || continue - if [ "$(cat -- "$transaction/phase")" = 'activated' ]; then - write_state "$transaction/expected-catalog-hash" "$expected" - write_state "$transaction/phase" 'catalogPrepared' - fi - done - printf '1\0' - ;; - finalize-catalog) - expected=$1 - current=$(catalog_hash) - [ "$current" = "$expected" ] || exit 89 - for transaction in "$transactions"/*; do - [ -d "$transaction" ] || continue - [ -f "$transaction/phase" ] || continue - if [ "$(cat -- "$transaction/phase")" = 'catalogPrepared' ]; then - [ -f "$transaction/expected-catalog-hash" ] || exit 90 - [ "$(cat -- "$transaction/expected-catalog-hash")" = "$expected" ] || exit 91 - write_state "$transaction/phase" 'catalogCommitted' - rm -rf -- "$transaction/backup" "$transaction" - fi - done - printf '1\0' - ;; - remove-library) - library_id=$1 - destination=$root/libraries/$library_id - if [ -e "$destination" ]; then - rm -rf -- "$destination" - fi - printf '1\0' - ;; - remove-application) - project_id=$1 - rm -f -- "$root/applications/projects/$project_id.json" - printf '1\0' - ;; - *) exit 64 ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/materialize.sh b/src-tauri/src/environment/wsl/scripts/materialize.sh deleted file mode 100644 index 71814307..00000000 --- a/src-tauri/src/environment/wsl/scripts/materialize.sh +++ /dev/null @@ -1,749 +0,0 @@ -#!/bin/sh - -fingerprint_content() { - root=$1 - [ -d "$root" ] && [ ! -L "$root" ] || return 1 - { - printf 'skill-deck-wsl-content-v1\0' - # 变量需要在逐项执行的子 shell 中展开,不能由外层 shell 提前替换。 - # shellcheck disable=SC2016 - LC_ALL=C find "$root" -mindepth 1 -print0 | LC_ALL=C sort -z | xargs -0 -r -n1 /bin/sh -c ' - root=$1 - path=$2 - relative=${path#"$root"/} - [ "$relative" != "$path" ] || exit 1 - if [ -L "$path" ]; then - printf "l\0%s\0" "$relative" - readlink -n -- "$path" || exit 1 - printf "\0" - elif [ -d "$path" ]; then - printf "d\0%s\0" "$relative" - elif [ -f "$path" ]; then - digest_line=$(sha256sum -- "$path") || exit 1 - digest_line=${digest_line#\\} - mode=$(stat -Lc %a -- "$path") || exit 1 - executable=0 - case "$mode" in *[1357]*) executable=1 ;; esac - printf "f\0%s\0%s\0%s\0" "$relative" "${digest_line%% *}" "$executable" - else - exit 1 - fi - ' sh "$root" - } | sha256sum | { read -r digest _; printf %s "$digest"; } -} - -subcommand=$1 -shift -case "$subcommand" in - stage) - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || exit 62 - owner=$operation_root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 63 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 64 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 65 - [ "$(sed -n '2p' "$owner")" = "$operation_id" ] || exit 66 - [ -f "$operation_root/recovery.json" ] && [ ! -L "$operation_root/recovery.json" ] || exit 67 - request_root=$operation_root/request - [ ! -e "$request_root" ] && [ ! -L "$request_root" ] || exit 68 - umask 077 - mkdir -- "$request_root" || exit 69 - - cleanup_stage() { - for entry_root in "$operation_root"/entry-*; do - if [ ! -d "$entry_root" ] || [ -L "$entry_root" ]; then continue; fi - if [ -f "$entry_root/destination" ] && [ ! -L "$entry_root/destination" ]; then - destination=$(cat -- "$entry_root/destination") || destination= - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - rm -rf -- "$parent/.skill-deck-probe-$operation_id-"* "$parent/.skill-deck-probe-renamed-$operation_id-"* - fi - if [ -f "$entry_root/stage" ] && [ ! -L "$entry_root/stage" ]; then - stage_path=$(cat -- "$entry_root/stage") || stage_path= - if [ -n "$stage_path" ]; then - name=${stage_path##*/} - case "$name" in .skill-deck-stage-$operation_id-*) rm -rf -- "$stage_path" ;; esac - fi - fi - rm -rf -- "$entry_root" - done - rm -rf -- "$request_root" - } - trap cleanup_stage EXIT HUP INT TERM - export request_root - - # shellcheck disable=SC2016 - if ! xargs -0 -r -n7 /bin/sh -c ' - [ "$#" -eq 7 ] || exit 255 - tag=$1 - second=$2 - third=$3 - fourth=$4 - fifth=$5 - sixth=$6 - seventh=$7 - case "$tag" in - H) - [ "$second" = 1 ] || exit 255 - for value in "$third" "$fourth"; do - case "$value" in ""|*[!0-9]*) exit 255 ;; esac - done - [ -z "$fifth" ] && [ -z "$sixth" ] && [ -z "$seventh" ] || exit 255 - [ ! -e "$request_root/header" ] || exit 255 - printf "%s\n%s\n" "$third" "$fourth" > "$request_root/header" || exit 255 - ;; - E) - [ -f "$request_root/header" ] || exit 255 - case "$second" in ??????) ;; *) exit 255 ;; esac - case "$second" in *[!0-9]*) exit 255 ;; esac - case "$third" in /*) ;; *) exit 255 ;; esac - case "$fourth" in keep|materialize|symlink|remove) ;; *) exit 255 ;; esac - case "$sixth" in entry-v1-*) ;; *) exit 255 ;; esac - case "$seventh" in ""|*[!0-9]*) exit 255 ;; esac - case "$fourth" in - keep|remove) [ -z "$fifth" ] && [ "$seventh" = 0 ] || exit 255 ;; - symlink) case "$fifth" in ""|/*|*\\*) exit 255 ;; esac; [ "$seventh" = 0 ] || exit 255 ;; - materialize) case "$fifth" in /*) ;; *) exit 255 ;; esac ;; - esac - entry=$request_root/entry-$second - [ ! -e "$entry" ] && mkdir -- "$entry" || exit 255 - printf %s "$third" > "$entry/destination" || exit 255 - printf %s "$fourth" > "$entry/action" || exit 255 - printf %s "$fifth" > "$entry/source" || exit 255 - printf %s "$sixth" > "$entry/expected" || exit 255 - printf %s "$seventh" > "$entry/manifest-count" || exit 255 - : > "$entry/manifest" - : > "$entry/manifest-seen" - ;; - M) - [ -f "$request_root/header" ] || exit 255 - entry=$request_root/entry-$second - [ -d "$entry" ] && [ ! -L "$entry" ] || exit 255 - case "$third" in directory|file) ;; *) exit 255 ;; esac - case "$fourth" in ""|/*|*\\*|..|../*|*/..|*/../*|.|./*|*/.|*/./*|*//* ) exit 255 ;; esac - case "$third" in - directory) [ -z "$fifth" ] && [ "$sixth" = 0 ] && [ "$seventh" = 0 ] || exit 255 ;; - file) - [ "${#fifth}" -eq 64 ] || exit 255 - case "$fifth" in *[!0-9a-f]*) exit 255 ;; esac - case "$sixth" in 0|1) ;; *) exit 255 ;; esac - case "$seventh" in ""|*[!0-9]*) exit 255 ;; esac - ;; - esac - manifest_key=$(printf %s "$fourth" | sha256sum) || exit 255 - manifest_key=${manifest_key%% *} - [ ! -e "$entry/manifest-key-$manifest_key" ] || exit 255 - : > "$entry/manifest-key-$manifest_key" || exit 255 - printf "%s\0%s\0%s\0%s\0%s\0" "$third" "$fourth" "$fifth" "$sixth" "$seventh" >> "$entry/manifest" || exit 255 - printf "1\n" >> "$entry/manifest-seen" || exit 255 - ;; - *) exit 255 ;; - esac - printf "1\n" >> "$request_root/records-seen" || exit 255 - ' sh; then - exit 70 - fi - - [ -f "$request_root/header" ] || exit 71 - expected_records=$(sed -n '1p' "$request_root/header") || exit 72 - expected_entries=$(sed -n '2p' "$request_root/header") || exit 73 - actual_records=$(wc -l < "$request_root/records-seen") || exit 74 - [ "$actual_records" = "$expected_records" ] || exit 75 - actual_entries=$(find "$request_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | wc -l) || exit 76 - [ "$actual_entries" = "$expected_entries" ] || exit 77 - - entry_number=0 - while [ "$entry_number" -lt "$expected_entries" ]; do - entry_index=$(printf '%06d' "$entry_number") || exit 78 - request_entry=$request_root/entry-$entry_index - [ -d "$request_entry" ] && [ ! -L "$request_entry" ] || exit 79 - expected_manifest=$(cat -- "$request_entry/manifest-count") || exit 80 - actual_manifest=$(wc -l < "$request_entry/manifest-seen") || exit 81 - [ "$actual_manifest" = "$expected_manifest" ] || exit 82 - - destination=$(cat -- "$request_entry/destination") || exit 83 - action=$(cat -- "$request_entry/action") || exit 84 - source=$(cat -- "$request_entry/source") || exit 85 - expected=$(cat -- "$request_entry/expected") || exit 86 - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - mkdir -p -- "$parent" || exit 87 - [ -d "$parent" ] && [ ! -L "$parent" ] || exit 88 - parent_identity=$(stat -Lc '%d:%i' -- "$parent") || exit 89 - entry_root=$operation_root/entry-$entry_index - [ ! -e "$entry_root" ] && [ ! -L "$entry_root" ] || exit 90 - mkdir -- "$entry_root" || exit 91 - stage_path=$parent/.skill-deck-stage-$operation_id-$entry_index - backup=$parent/.skill-deck-backup-$operation_id-$entry_index - [ ! -e "$stage_path" ] && [ ! -L "$stage_path" ] || exit 92 - [ ! -e "$backup" ] && [ ! -L "$backup" ] || exit 93 - printf %s "$destination" > "$entry_root/destination" || exit 94 - printf %s "$action" > "$entry_root/action" || exit 95 - printf %s "$expected" > "$entry_root/expected" || exit 96 - printf %s "$stage_path" > "$entry_root/stage" || exit 97 - printf %s "$backup" > "$entry_root/backup" || exit 98 - printf %s "$parent_identity" > "$entry_root/parent-identity" || exit 99 - printf %s "$source" > "$entry_root/source" || exit 100 - printf %s "$expected_manifest" > "$entry_root/manifest-count" || exit 101 - cp -- "$request_entry/manifest" "$entry_root/manifest" || exit 102 - if [ -d "$destination" ] && [ ! -L "$destination" ]; then - fingerprint_content "$destination" > "$entry_root/expected-content" || exit 122 - fi - - case "$action" in - keep|remove) - printf %s '' > "$entry_root/stage" || exit 103 - ;; - symlink) - ln -s -- "$source" "$stage_path" || exit 104 - [ -L "$stage_path" ] || exit 105 - [ "$(readlink -- "$stage_path")" = "$source" ] || exit 106 - ;; - materialize) - [ -d "$source/blobs" ] && [ ! -L "$source" ] && [ ! -L "$source/blobs" ] || exit 107 - mkdir -- "$stage_path" || exit 108 - export stage_path source - # shellcheck disable=SC2016 - if ! xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$stage_path/$relative - case "$kind" in - directory) mkdir -p -- "$path" || exit 255 ;; - file) - blob=$source/blobs/$blob_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 255 - [ "$(wc -c < "$blob")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$blob") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - mkdir -p -- "${path%/*}" || exit 255 - cp -- "$blob" "$path" || exit 255 - if [ "$executable" = 1 ]; then chmod +x -- "$path"; else chmod -x -- "$path"; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$request_entry/manifest"; then - exit 109 - fi - export stage_path - # shellcheck disable=SC2016 - if ! xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$stage_path/$relative - case "$kind" in - directory) [ -d "$path" ] && [ ! -L "$path" ] || exit 255 ;; - file) - [ -f "$path" ] && [ ! -L "$path" ] || exit 255 - [ "$(wc -c < "$path")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$path") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - if [ "$executable" = 1 ]; then [ -x "$path" ]; else [ ! -x "$path" ]; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$request_entry/manifest"; then - exit 110 - fi - actual_stage_entries=$(find "$stage_path" -mindepth 1 -printf . | wc -c) || exit 111 - [ "$actual_stage_entries" = "$expected_manifest" ] || exit 112 - ;; - *) exit 113 ;; - esac - - probe=$parent/.skill-deck-probe-$operation_id-$entry_index - renamed_probe=$parent/.skill-deck-probe-renamed-$operation_id-$entry_index - [ ! -e "$probe" ] && [ ! -L "$probe" ] || exit 114 - [ ! -e "$renamed_probe" ] && [ ! -L "$renamed_probe" ] || exit 115 - mkdir -- "$probe" || exit 116 - printf 'stage-preflight-v1\n' > "$probe/.skill-deck-owner" || exit 117 - mv -- "$probe" "$renamed_probe" || { rm -rf -- "$probe" "$renamed_probe"; exit 118; } - mv -- "$renamed_probe" "$probe" || { rm -rf -- "$probe" "$renamed_probe"; exit 119; } - rm -rf -- "$probe" || exit 120 - entry_number=$((entry_number + 1)) - done - - rm -rf -- "$request_root" || exit 121 - printf '1\0' - trap - EXIT HUP INT TERM - ;; - swap) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - validate_stage() { - entry_root=$1 - action=$(cat -- "$entry_root/action") || return 1 - stage_path=$(cat -- "$entry_root/stage") || return 1 - case "$action" in - keep|remove) [ -z "$stage_path" ] || return 1 ;; - symlink) - expected_target=$(cat -- "$entry_root/source") || return 1 - case "$expected_target" in ""|/*|*\\*) return 1 ;; esac - [ -L "$stage_path" ] || return 1 - [ "$(readlink -- "$stage_path")" = "$expected_target" ] || return 1 - ;; - materialize) - [ -d "$stage_path" ] && [ ! -L "$stage_path" ] || return 1 - export stage_path - # shellcheck disable=SC2016 - xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$stage_path/$relative - case "$kind" in - directory) [ -d "$path" ] && [ ! -L "$path" ] || exit 255 ;; - file) - [ -f "$path" ] && [ ! -L "$path" ] || exit 255 - [ "$(wc -c < "$path")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$path") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - if [ "$executable" = 1 ]; then [ -x "$path" ]; else [ ! -x "$path" ]; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$entry_root/manifest" || return 1 - expected_count=$(cat -- "$entry_root/manifest-count") || return 1 - actual_count=$(find "$stage_path" -mindepth 1 -printf . | wc -c) || return 1 - [ "$actual_count" = "$expected_count" ] || return 1 - ;; - *) return 1 ;; - esac - } - - preflight_atomic_replace() { - parent=$1 - entry_name=$2 - probe=$parent/.skill-deck-probe-$operation_id-$entry_name - renamed_probe=$parent/.skill-deck-probe-renamed-$operation_id-$entry_name - [ ! -e "$probe" ] && [ ! -L "$probe" ] || return 1 - [ ! -e "$renamed_probe" ] && [ ! -L "$renamed_probe" ] || return 1 - mkdir -- "$probe" || return 1 - printf 'stage-preflight-v1\n' > "$probe/.skill-deck-owner" || { - rm -rf -- "$probe" "$renamed_probe" - return 1 - } - mv -- "$probe" "$renamed_probe" || { - rm -rf -- "$probe" "$renamed_probe" - return 1 - } - mv -- "$renamed_probe" "$probe" || { - rm -rf -- "$probe" "$renamed_probe" - return 1 - } - rm -rf -- "$probe" || return 1 - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - entries=$(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort) - [ -n "$entries" ] || exit 62 - for entry_root in $entries; do - destination=$(cat -- "$entry_root/destination") || exit 63 - expected=$(cat -- "$entry_root/expected") || exit 64 - expected_parent=$(cat -- "$entry_root/parent-identity") || exit 65 - backup=$(cat -- "$entry_root/backup") || exit 66 - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - actual_parent=$(stat -Lc '%d:%i' -- "$parent") || exit 67 - [ "$actual_parent" = "$expected_parent" ] || exit 68 - actual=$(fingerprint_entry "$destination") || exit 69 - [ "$actual" = "$expected" ] || exit 70 - if [ -f "$entry_root/expected-content" ] && [ ! -L "$entry_root/expected-content" ]; then - expected_content=$(cat -- "$entry_root/expected-content") || exit 81 - actual_content=$(fingerprint_content "$destination") || exit 82 - [ "$actual_content" = "$expected_content" ] || exit 83 - fi - [ ! -e "$backup" ] && [ ! -L "$backup" ] || exit 71 - validate_stage "$entry_root" || exit 72 - preflight_atomic_replace "$parent" "${entry_root##*/}" || exit 73 - done - for entry_root in $entries; do - destination=$(cat -- "$entry_root/destination") || { restore_all; exit 74; } - stage=$(cat -- "$entry_root/stage") || { restore_all; exit 75; } - backup=$(cat -- "$entry_root/backup") || { restore_all; exit 76; } - action=$(cat -- "$entry_root/action") || { restore_all; exit 77; } - [ "$action" = keep ] && continue - if [ -e "$backup" ] || [ -L "$backup" ]; then - restore_all - exit 78 - fi - if [ -e "$destination" ] || [ -L "$destination" ]; then - mv -- "$destination" "$backup" || { restore_all; exit 71; } - : > "$entry_root/backed-up" - fi - if [ "$action" != remove ]; then - mv -- "$stage" "$destination" || { restore_all || exit 90; exit 72; } - : > "$entry_root/installed" - fi - done - printf '1\0' - - ;; - verify) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - verify_materialized_destination() { - entry_root=$1 - destination=$2 - export destination - # shellcheck disable=SC2016 - xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$destination/$relative - case "$kind" in - directory) [ -d "$path" ] && [ ! -L "$path" ] || exit 255 ;; - file) - [ -f "$path" ] && [ ! -L "$path" ] || exit 255 - [ "$(wc -c < "$path")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$path") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - if [ "$executable" = 1 ]; then [ -x "$path" ]; else [ ! -x "$path" ]; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$entry_root/manifest" || return 1 - expected_count=$(cat -- "$entry_root/manifest-count") || return 1 - actual_count=$(find "$destination" -mindepth 1 -printf . | wc -c) || return 1 - [ "$actual_count" = "$expected_count" ] - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort); do - destination=$(cat -- "$entry_root/destination") || exit 62 - action=$(cat -- "$entry_root/action") || exit 63 - case "$action" in - keep) - expected=$(cat -- "$entry_root/expected") || exit 64 - actual=$(fingerprint_entry "$destination") || exit 65 - [ "$actual" = "$expected" ] || exit 66 - ;; - materialize) - [ -d "$destination" ] && [ ! -L "$destination" ] || exit 64 - verify_materialized_destination "$entry_root" "$destination" || exit 65 - ;; - symlink) - [ -L "$destination" ] || exit 66 - source=$(cat -- "$entry_root/source") || exit 67 - [ "$(readlink -- "$destination")" = "$source" ] || exit 68 - ;; - remove) [ ! -e "$destination" ] && [ ! -L "$destination" ] || exit 69 ;; - *) exit 70 ;; - esac - done - printf '1\0' - - ;; - restore) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - restore_all || exit 90 - printf '1\0' - - ;; - cleanup) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort); do - stage=$(cat -- "$entry_root/stage") || exit 62 - backup=$(cat -- "$entry_root/backup") || exit 63 - path=$stage - if [ -n "$path" ]; then - name=${path##*/} - case "$name" in .skill-deck-stage-$operation_id-*) ;; - *) exit 64 ;; - esac - remove_no_follow "$path" || exit 65 - fi - if [ -f "$entry_root/backed-up" ]; then - name=${backup##*/} - case "$name" in .skill-deck-backup-$operation_id-*) ;; - *) exit 66 ;; - esac - remove_no_follow "$backup" || exit 67 - fi - done - rm -rf -- "$operation_root" || exit 68 - printf '1\0' - - ;; - *) - printf 'unknown Skill Deck WSL materialize operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/path-metadata.sh b/src-tauri/src/environment/wsl/scripts/path-metadata.sh deleted file mode 100644 index ee65d466..00000000 --- a/src-tauri/src/environment/wsl/scripts/path-metadata.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - missing_kind() { - probe=${1%/*} - [ -n "$probe" ] || probe=/ - while [ "$probe" != / ] && [ ! -e "$probe" ] && [ ! -L "$probe" ]; do - next=${probe%/*} - [ -n "$next" ] || next=/ - [ "$next" != "$probe" ] || break - probe=$next - done - if [ -d "$probe" ] && [ ! -x "$probe" ]; then - printf inaccessible - else - printf missing - fi - } - - printf '1\0' - while [ "$#" -ge 2 ]; do - path=$1 - inspect_content=$2 - shift 2 - if [ -L "$path" ]; then - if [ ! -e "$path" ]; then - kind=broken-link - elif [ -d "$path" ]; then - kind=symlink-directory - else - kind=symlink-other - fi - elif [ -d "$path" ]; then - kind=directory - elif [ -e "$path" ]; then - kind=other - else - kind=$(missing_kind "$path") - fi - printf 'path\0%s\0%s\0' "$path" "$kind" - if [ "$inspect_content" = 1 ] && [ -f "$path" ]; then - if payload=$(dd if="$path" bs=1048576 count=1 2>/dev/null); then - if [ -n "$payload" ]; then - printf 'eve\0%s\0' "$payload" - else - printf 'eve-empty\0-\0' - fi - else - printf 'eve-unreadable\0-\0' - fi - else - printf 'none\0-\0' - fi - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/path.sh b/src-tauri/src/environment/wsl/scripts/path.sh deleted file mode 100644 index 2ff26d43..00000000 --- a/src-tauri/src/environment/wsl/scripts/path.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - map-host) - mapped=$(wslpath -u -- "$1") || exit $?; case "$mapped" in /*) ;; *) exit 61 ;; esac; printf '1\0%s\0' "$mapped" - ;; - map-storage-host) - mapped=$(wslpath -w -- "$1") || exit $?; [ -n "$mapped" ] || exit 61; printf '1\0%s\0' "$mapped" - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/projection.sh b/src-tauri/src/environment/wsl/scripts/projection.sh deleted file mode 100644 index e61bfdd9..00000000 --- a/src-tauri/src/environment/wsl/scripts/projection.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - project-targets) - - printf '2\0' - index=0 - for destination do - case "$destination" in /*) ;; *) exit 61 ;; esac - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - relative=${destination##*/} - case "$relative" in ''|.|..) exit 62 ;; esac - while [ ! -e "$parent" ] && [ ! -L "$parent" ]; do - component=${parent##*/} - case "$component" in ''|.|..) exit 63 ;; esac - relative=$component/$relative - next=${parent%/*} - [ -n "$next" ] || next=/ - [ "$next" != "$parent" ] || exit 64 - parent=$next - done - [ -d "$parent" ] || exit 65 - resolved=$(realpath -e -- "$parent") || exit 66 - identity=$(stat -Lc '%d %i' -- "$parent") || exit 67 - storage_projection=$(wslpath -w -- "$resolved") || exit 68 - device=${identity%% *} - inode=${identity#* } - if [ "$resolved" = / ]; then - physical=/$relative - else - physical=$resolved/$relative - fi - printf 'P\0%s\0%s\0%s\0%s\0%s\0%s\0' \ - "$index" "$device" "$inode" "$physical" "$relative" "$storage_projection" - index=$((index + 1)) - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/projects.sh b/src-tauri/src/environment/wsl/scripts/projects.sh deleted file mode 100644 index a17b277d..00000000 --- a/src-tauri/src/environment/wsl/scripts/projects.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - project-storage) - printf '1\0'; for path do if mapped=$(wslpath -w -- "$path" 2>/dev/null); then printf 'ok\0%s\0' "$mapped"; else printf 'error\0\0'; fi; done - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/recovery.sh b/src-tauri/src/environment/wsl/scripts/recovery.sh deleted file mode 100644 index 3bed56b6..00000000 --- a/src-tauri/src/environment/wsl/scripts/recovery.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - enumerate) - - namespace=$1 - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 61 - printf '1\0' - for root in "$namespace"/skill-deck-operation-*; do - [ -e "$root" ] || [ -L "$root" ] || continue - status=missing - content= - if [ -d "$root" ] && [ ! -L "$root" ]; then - marker=$root/recovery.json - if [ -f "$marker" ] && [ ! -L "$marker" ]; then - status=present - content=$(cat -- "$marker") || status=unreadable - fi - else - status=unsafe - fi - printf 'R\0%s\0%s\0%s\0' "$root" "$status" "$content" - done - - ;; - write-marker) - - namespace=$1 - resource_id=$2 - mode=$3 - case "$resource_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 62 - root=$namespace/skill-deck-operation-$resource_id - created_root=0 - case "$mode" in - create) - [ ! -e "$root" ] && [ ! -L "$root" ] || exit 63 - umask 077 - mkdir -- "$root" || exit 64 - created_root=1 - printf '1\n%s\n' "$resource_id" > "$root/.skill-deck-owner" || exit 65 - ;; - update) - [ -d "$root" ] && [ ! -L "$root" ] || exit 63 - ;; - *) exit 70 ;; - esac - owner=$root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 64 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$owner")" = "$resource_id" ] || exit 67 - marker=$root/recovery.json - case "$mode" in - create) [ ! -e "$marker" ] && [ ! -L "$marker" ] || exit 68 ;; - update) [ -f "$marker" ] && [ ! -L "$marker" ] || exit 69 ;; - *) exit 70 ;; - esac - tmp=$root/.recovery.$$ - trap 'rm -f -- "$tmp"; if [ "$created_root" = 1 ]; then rm -rf -- "$root"; fi' EXIT HUP INT TERM - umask 077 - cat > "$tmp" || exit 71 - [ -s "$tmp" ] || exit 72 - sync "$tmp" 2>/dev/null || true - mv -- "$tmp" "$marker" || exit 73 - created_root=0 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - remove-marker) - - namespace=$1 - resource_id=$2 - case "$resource_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 62 - root=$namespace/skill-deck-operation-$resource_id - [ ! -e "$root" ] && [ ! -L "$root" ] && { printf '1\0'; exit 0; } - [ -d "$root" ] && [ ! -L "$root" ] || exit 63 - owner=$root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 64 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$owner")" = "$resource_id" ] || exit 67 - rm -rf -- "$root" || exit 68 - printf '1\0' - - ;; - cleanup) - - namespace=$1 - resource_id=$2 - shift 2 - case "$resource_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 62 - root=$namespace/skill-deck-operation-$resource_id - [ -d "$root" ] && [ ! -L "$root" ] || exit 63 - owner=$root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 64 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$owner")" = "$resource_id" ] || exit 67 - marker=$root/recovery.json - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 68 - expected=$root/.cleanup-expected.$$ - trap 'rm -f -- "$expected"' EXIT HUP INT TERM - umask 077 - cat > "$expected" || exit 69 - [ -s "$expected" ] || exit 70 - cmp -s -- "$expected" "$marker" || exit 71 - for backup in "$@"; do - case "$backup" in - /*) ;; - *) exit 72 ;; - esac - case "$backup" in - */../*|*/./*|*/..|*/.) exit 73 ;; - esac - name=${backup##*/} - case "$name" in .skill-deck-backup-*) ;; *) exit 74 ;; esac - [ "$backup" != "$root" ] || exit 75 - rm -rf -- "$backup" || exit 76 - done - rm -f -- "$expected" || exit 77 - trap - EXIT HUP INT TERM - rm -rf -- "$root" || exit 78 - printf '1\0' - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/scan.sh b/src-tauri/src/environment/wsl/scripts/scan.sh deleted file mode 100644 index 39aa4c0f..00000000 --- a/src-tauri/src/environment/wsl/scripts/scan.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - scan) - - per_file=$1 - aggregate=$2 - stat_only=$3 - recursive=$4 - shift 4 - total=0 - root_index=0 - printf '2\0' - emit_entry() { - path=$1 - relative=$2 - kind=other - target= - size=0 - mode=0 - modified=0 - truncated=0 - error= - content_len=0 - if [ -L "$path" ]; then - kind=symlink - target=$(readlink -- "$path" 2>/dev/null) || error=readLinkFailed - if [ -f "$path" ]; then - size=$(wc -c < "$path" 2>/dev/null) || { size=0; error=readFailed; } - fi - elif [ -d "$path" ]; then - kind=directory - elif [ -f "$path" ]; then - kind='file' - size=$(wc -c < "$path" 2>/dev/null) || { size=0; error=readFailed; } - case "$relative" in - SKILL.md|*/SKILL.md|skills-lock.json) - remaining=$((aggregate - total)) - [ "$remaining" -lt 0 ] && remaining=0 - content_len=$size - [ "$content_len" -gt "$per_file" ] && content_len=$per_file - [ "$content_len" -gt "$remaining" ] && content_len=$remaining - [ "$content_len" -lt "$size" ] && truncated=1 - ;; - esac - elif [ ! -e "$path" ]; then - kind=missing - fi - if [ "$kind" != missing ]; then - mode_text=$(stat -c %a -- "$path" 2>/dev/null) || { mode_text=0; error=statFailed; } - mode=$((0$mode_text)) - modified=$(stat -c %Y -- "$path" 2>/dev/null) || { modified=0; error=statFailed; } - fi - case "$relative" in - .claude-plugin/marketplace.json|.claude-plugin/plugin.json) - remaining=$((aggregate - total)) - [ "$remaining" -lt 0 ] && remaining=0 - content_len=$size - [ "$content_len" -gt "$per_file" ] && content_len=$per_file - [ "$content_len" -gt "$remaining" ] && content_len=$remaining - [ "$content_len" -lt "$size" ] && truncated=1 - ;; - esac - if [ -z "$relative" ] && [ "${path##*/}" = skills-lock.json ]; then - remaining=$((aggregate - total)) - [ "$remaining" -lt 0 ] && remaining=0 - content_len=$size - [ "$content_len" -gt "$per_file" ] && content_len=$per_file - [ "$content_len" -gt "$remaining" ] && content_len=$remaining - [ "$content_len" -lt "$size" ] && truncated=1 - fi - printf 'E\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0' \ - "$root_index" "$relative" "$kind" "$target" "$size" "$mode" "$modified" "$truncated" "$error" "$content_len" - if [ "$content_len" -gt 0 ]; then - dd if="$path" bs=1 count="$content_len" status=none || exit 71 - total=$((total + content_len)) - fi - printf '\0' - } - is_stat_only() { - case ",$stat_only," in - *",$1,"*) return 0 ;; - *) return 1 ;; - esac - } - for root do - emit_entry "$root" '' - if ! is_stat_only "$root_index" && [ -d "$root" ] && [ ! -L "$root" ]; then - if [ "$recursive" = 1 ]; then - find "$root" -mindepth 1 -maxdepth 6 \ - \( -name .git -o -name node_modules -o -name dist -o -name build -o -name __pycache__ -o -name __pypackages__ \) -prune \ - -o \( \ - \( -type f -o -type l \) -a \( -iname SKILL.md -o -path "$root/.claude-plugin/marketplace.json" -o -path "$root/.claude-plugin/plugin.json" -o -path "$root/skills-lock.json" \) \ - \) -print | while IFS= read -r path; do - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - continue - fi - relative=${path#"$root"/} - emit_entry "$path" "$relative" - done - elif [ "$recursive" = 2 ]; then - for path in "$root"/* "$root"/.[!.]* "$root"/..?*; do - if [ ! -d "$path" ] || [ -L "$path" ]; then - continue - fi - find "$path" -mindepth 1 -maxdepth 1 \( -type f -o -type l \) -iname SKILL.md -print | while IFS= read -r skill_path; do - relative=${skill_path#"$root"/} - emit_entry "$skill_path" "$relative" - done - done - else - for path in "$root"/* "$root"/.[!.]* "$root"/..?*; do - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - continue - fi - relative=${path#"$root"/} - emit_entry "$path" "$relative" - if [ -d "$path" ]; then - skill_path=$path/SKILL.md - if [ -e "$skill_path" ] || [ -L "$skill_path" ]; then - emit_entry "$skill_path" "$relative/SKILL.md" - fi - fi - done - fi - fi - root_index=$((root_index + 1)) - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/session.sh b/src-tauri/src/environment/wsl/scripts/session.sh index 8a52876f..b0023eb8 100644 --- a/src-tauri/src/environment/wsl/scripts/session.sh +++ b/src-tauri/src/environment/wsl/scripts/session.sh @@ -3,50 +3,10 @@ subcommand=$1 shift case "$subcommand" in session) - fail() { - printf '%s\n' "$1" >&2 - exit 69 - } - - git --version >/dev/null 2>&1 || fail 'Skill Deck requires Git in the WSL environment' - timeout --kill-after=1s 1s /bin/sh -c 'exit 0' >/dev/null 2>&1 \ - || fail 'Skill Deck requires GNU timeout in the WSL environment' - - probe_root=${TMPDIR:-/tmp}/skill-deck-session-probe-$$ - probe_root_created=0 - if mkdir -- "$probe_root" 2>/dev/null; then - probe_root_created=1 - trap 'rm -rf -- "$probe_root"' EXIT HUP INT TERM - printf 'a\nb\n' > "$probe_root/xargs-expected" - if ! printf 'a\0b\0' | xargs -0 -r -n1 printf '%s\n' > "$probe_root/xargs-actual" 2>/dev/null; then - fail 'Skill Deck requires xargs with -0 and -r support in the WSL environment' - fi - if ! cmp -s "$probe_root/xargs-expected" "$probe_root/xargs-actual"; then - fail 'Skill Deck requires xargs with -0 and -r support in the WSL environment' - fi - printf 'a\0B\0' > "$probe_root/sort-expected" - if ! printf 'B\0a\0' | LC_ALL=C sort -z -f > "$probe_root/sort-actual" 2>/dev/null; then - fail 'Skill Deck requires sort with -z and -f support in the WSL environment' - fi - if ! cmp -s "$probe_root/sort-expected" "$probe_root/sort-actual"; then - fail 'Skill Deck requires sort with -z and -f support in the WSL environment' - fi - : > "$probe_root/sha256-empty" - else - fail 'Skill Deck could not create a WSL capability probe directory' - fi - [ "$(sha256sum -- "$probe_root/sha256-empty" 2>/dev/null | awk '{print $1}')" = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ] \ - || fail 'Skill Deck requires sha256sum with -- support in the WSL environment' - [ "$(readlink -f -- / 2>/dev/null)" = / ] \ - || fail 'Skill Deck requires readlink with -f and -- support in the WSL environment' - stat -Lc '%d:%i:%f:%s:%Y:%y' -- / >/dev/null 2>&1 \ - || fail 'Skill Deck requires GNU-compatible stat in the WSL environment' - printf '4\0'; id -un | tr -d '\n'; printf '\0'; id -u | tr -d '\n'; printf '\0' + user=$(id -un) || exit 69 + uid=$(id -u) || exit 69 + printf '4\0%s\0%s\0' "$user" "$uid" printf '%s\0' "$HOME" "${XDG_STATE_HOME:-}" "${XDG_CONFIG_HOME:-$HOME/.config}" "${CODEX_HOME:-}" "${CLAUDE_CONFIG_DIR:-}" "${VIBE_HOME:-}" "${HERMES_HOME:-}" "${AUTOHAND_HOME:-}" "${GROK_HOME:-}" - if [ "$probe_root_created" = 1 ]; then - rm -rf -- "$probe_root" 2>/dev/null || true - fi - trap - EXIT HUP INT TERM ;; *) printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 diff --git a/src-tauri/src/environment/wsl/scripts/skill-content.sh b/src-tauri/src/environment/wsl/scripts/skill-content.sh deleted file mode 100644 index bcb39f0e..00000000 --- a/src-tauri/src/environment/wsl/scripts/skill-content.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - read) - - dir=$1 - [ -d "$dir" ] || exit 44 - for candidate in "$dir"/*; do - [ -f "$candidate" ] || continue - base=${candidate##*/} - lower=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') - if [ "$lower" = 'skill.md' ]; then - cat -- "$candidate" - exit 0 - fi - done - exit 44 - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/source-acquisition.sh b/src-tauri/src/environment/wsl/scripts/source-acquisition.sh deleted file mode 100644 index e8a4b730..00000000 --- a/src-tauri/src/environment/wsl/scripts/source-acquisition.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/sh -LC_ALL=C -GIT_ALLOW_PROTOCOL=https:http:ssh:git:file -export LC_ALL GIT_ALLOW_PROTOCOL - -subcommand=$1 -shift -case "$subcommand" in - cleanup) - root=$1 - case "$root" in /tmp/skill-deck-discovery-*/repo) ;; *) exit 64 ;; esac - parent=${root%/repo} - case "$parent" in /tmp/skill-deck-discovery-*) ;; *) exit 64 ;; esac - rm -rf -- "$parent" - ;; - git) - url=$1 - dest=$2 - git_ref=$3 - distro=$4 - git_timeout_secs=$5 - proxy_mode=$6 - proxy_url=$7 - case "$dest" in /tmp/skill-deck-discovery-*/repo) ;; *) exit 65 ;; esac - parent=${dest%/repo} - case "$parent" in /tmp/skill-deck-discovery-*) ;; *) exit 65 ;; esac - [ ! -e "$parent" ] && [ ! -L "$parent" ] || exit 66 - command -v git >/dev/null 2>&1 || { - printf "Git is not available in WSL distro '%s'. Please install Git in that distro and try again.\n" "$distro" >&2 - exit 127 - } - command -v timeout >/dev/null 2>&1 || { - printf "GNU timeout is not available in WSL distro '%s'. Please install GNU coreutils in that distro and try again.\n" "$distro" >&2 - exit 127 - } - case "$git_timeout_secs" in ''|*[!0-9]*) exit 65 ;; esac - case "$proxy_mode" in inject|preserve) ;; *) exit 65 ;; esac - mkdir -- "$parent" || exit 67 - cleanup_parent=1 - trap '[ "$cleanup_parent" = 0 ] || rm -rf -- "$parent"' EXIT HUP INT TERM - if [ "$proxy_mode" = inject ] && [ -n "$git_ref" ]; then - timeout --kill-after=2s "${git_timeout_secs}s" \ - git -c "http.proxy=$proxy_url" clone --depth 1 --progress --branch "$git_ref" -- "$url" "$dest" - elif [ "$proxy_mode" = inject ]; then - timeout --kill-after=2s "${git_timeout_secs}s" \ - git -c "http.proxy=$proxy_url" clone --depth 1 --progress -- "$url" "$dest" - elif [ -n "$git_ref" ]; then - timeout --kill-after=2s "${git_timeout_secs}s" \ - git clone --depth 1 --progress --branch "$git_ref" -- "$url" "$dest" - else - timeout --kill-after=2s "${git_timeout_secs}s" \ - git clone --depth 1 --progress -- "$url" "$dest" - fi - clone_status=$? - case "$clone_status" in 0) ;; 124|137) exit 72 ;; *) exit 68 ;; esac - ref_revision=$(git -C "$dest" rev-parse --verify HEAD) || exit 69 - case "${#ref_revision}" in 40|64) ;; *) exit 69 ;; esac - case "$ref_revision" in *[!0-9a-f]*) exit 69 ;; esac - cleanup_parent=0 - trap - EXIT HUP INT TERM - printf '1\0%s\0' "$ref_revision" || exit 70 - ;; - git-probe) - url=$1 - distro=$2 - git_timeout_secs=$3 - proxy_mode=$4 - proxy_url=$5 - command -v git >/dev/null 2>&1 || { - printf "Git is not available in WSL distro '%s'. Please install Git in that distro and try again.\n" "$distro" >&2 - exit 127 - } - command -v timeout >/dev/null 2>&1 || exit 127 - case "$git_timeout_secs" in ''|*[!0-9]*) exit 65 ;; esac - case "$proxy_mode" in inject|preserve) ;; *) exit 65 ;; esac - if [ "$proxy_mode" = inject ]; then - ref_revision=$(timeout --kill-after=2s "${git_timeout_secs}s" \ - git -c "http.proxy=$proxy_url" ls-remote --exit-code -- "$url" HEAD) - else - ref_revision=$(timeout --kill-after=2s "${git_timeout_secs}s" \ - git ls-remote --exit-code -- "$url" HEAD) - fi - probe_status=$? - case "$probe_status" in 0) ;; 124|137) exit 72 ;; *) exit 68 ;; esac - ref_revision=${ref_revision%%[[:space:]]*} - case "${#ref_revision}" in 40|64) ;; *) exit 69 ;; esac - case "$ref_revision" in *[!0-9a-f]*) exit 69 ;; esac - printf '1\0%s\0' "$ref_revision" || exit 70 - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/worker.rs b/src-tauri/src/environment/wsl/worker.rs new file mode 100644 index 00000000..2ffdfce1 --- /dev/null +++ b/src-tauri/src/environment/wsl/worker.rs @@ -0,0 +1,2256 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::Path; +#[cfg(target_os = "windows")] +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use environment_protocol::{ + codec, decode, spawn_writer, Envelope, Message, PathKind, ProtocolWriter, WireRecord, + MAX_PAYLOAD_CHUNK_BYTES, MAX_RESPONSE_TRANSFER_BYTES, +}; +use futures_util::StreamExt; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +#[cfg(target_os = "windows")] +use tokio::io::AsyncReadExt; +use tokio::io::{AsyncRead, AsyncWrite}; +#[cfg(target_os = "windows")] +use tokio::process::Child; +use tokio::sync::{oneshot, watch}; +use tokio::task::AbortHandle; +use tokio::time::Duration; +use tokio_util::codec::FramedRead; + +use crate::core::mutation::CancellationSignal; +use crate::environment::types::EnvironmentRef; +#[cfg(target_os = "windows")] +use crate::environment::wsl::protocol::{ + WslCommandRequest, WslCommandRunner, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, +}; +use crate::error::AppError; + +const TOMBSTONE_LIMIT: usize = 256; +const WORKER_TARGET: &str = "x86_64-unknown-linux-musl"; +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(target_os = "windows")] +const WORKER_START_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(target_os = "windows")] +const WORKER_STDERR_LIMIT: usize = 64 * 1024; +const BOOTSTRAP_SCRIPT: &str = r#"set -eu +expected=$1 +target="$HOME/.skill-deck/runtime/wsl-worker/current/worker" +directory=${target%/*} +temporary="$target.tmp.$$" +mkdir -p "$directory" +umask 077 +if [ -e "$target" ] || [ -L "$target" ]; then + if [ ! -f "$target" ] || [ -L "$target" ]; then + printf '%s\n' 'WSL worker target is not a regular file' >&2 + exit 71 + fi + actual=$(sha256sum "$target" | awk '{print $1}') + if [ "sha256:$actual" = "$expected" ]; then + cat >/dev/null + chmod 700 "$target" + exit 0 + fi +fi +trap 'rm -f "$temporary"' EXIT HUP INT TERM +cat > "$temporary" +actual=$(sha256sum "$temporary" | awk '{print $1}') +if [ "sha256:$actual" != "$expected" ]; then + printf '%s\n' 'WSL worker digest mismatch' >&2 + exit 70 +fi +chmod 700 "$temporary" +if [ -e "$target" ] || [ -L "$target" ]; then + if [ ! -f "$target" ] || [ -L "$target" ]; then + printf '%s\n' 'WSL worker target changed type during deployment' >&2 + exit 71 + fi +fi +mv -f "$temporary" "$target" +trap - EXIT HUP INT TERM +"#; + +#[derive(Debug)] +struct WorkerArtifact { + bytes: Vec, + build_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkerManifest { + build_id: String, + sha256: String, + target: String, +} + +impl WorkerArtifact { + fn load_from(directory: &Path) -> Result { + let bytes = std::fs::read(directory.join("worker")).map_err(|error| error.to_string())?; + let manifest: WorkerManifest = serde_json::from_slice( + &std::fs::read(directory.join("manifest.json")).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let actual = format!("sha256:{:x}", Sha256::digest(&bytes)); + if manifest.target != WORKER_TARGET { + return Err(format!( + "unsupported WSL worker target: {}", + manifest.target + )); + } + if manifest.build_id != manifest.sha256 || manifest.sha256 != actual { + return Err("WSL worker bytes do not match the manifest".to_string()); + } + Ok(Self { + bytes, + build_id: manifest.build_id, + }) + } +} + +#[derive(Clone)] +pub(super) struct WorkerSession { + inner: Arc, +} + +struct WorkerSessionInner { + distro_name: String, + next_request_id: AtomicU64, + routes: Arc>, + writer: ProtocolWriter, + closed: watch::Sender, + reader_task: AbortHandle, + writer_task: AbortHandle, + #[cfg(target_os = "windows")] + stderr_task: Option, + #[cfg(target_os = "windows")] + child: Mutex>, +} + +#[derive(Default)] +struct ResponseRoutes { + pending: HashMap, + active_transfer: Option, + tombstones: VecDeque, + tombstone_set: HashSet, +} + +struct PendingResponse { + expected: ExpectedResponse, + max_payload_bytes: usize, + accepted_resource: Option>>>, + sender: oneshot::Sender>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ExpectedResponse { + Control, + Payload, + Mutation, +} + +#[derive(Debug)] +pub(super) struct MutationSessionError { + pub error: AppError, + pub recovery_resource_id: Option, +} + +enum RoutedResponse { + Control(Message), + Payload(Vec), +} + +struct IncomingTransfer { + transfer_id: u64, + owner_request_id: u64, + total_bytes: usize, + expected_sha256: String, + received_bytes: usize, + hasher: Sha256, + payload: Option>, +} + +impl WorkerSession { + fn from_io(reader: R, writer: W, distro_name: String) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let routes = Arc::new(Mutex::new(ResponseRoutes::default())); + let (closed, _) = watch::channel(false); + let (writer, writer_task) = spawn_writer(writer); + let reader_routes = Arc::clone(&routes); + let reader_distro = distro_name.clone(); + let reader_closed = closed.clone(); + let reader_task = tokio::spawn(async move { + let mut reader = FramedRead::new(reader, codec()); + while let Some(frame) = reader.next().await { + let record = match frame + .map_err(|error| error.to_string()) + .and_then(|bytes| decode(&bytes).map_err(|error| error.to_string())) + { + Ok(record) => record, + Err(error) => { + fail_pending(&reader_routes, unavailable(&reader_distro, error)); + let _ = reader_closed.send(true); + return; + } + }; + if let Err(error) = route_record(&reader_routes, record) { + fail_pending(&reader_routes, unavailable(&reader_distro, error)); + let _ = reader_closed.send(true); + return; + } + } + fail_pending( + &reader_routes, + unavailable(&reader_distro, "worker closed its output"), + ); + let _ = reader_closed.send(true); + }); + Self { + inner: Arc::new(WorkerSessionInner { + distro_name, + next_request_id: AtomicU64::new(1), + routes, + writer, + closed, + reader_task: reader_task.abort_handle(), + writer_task: writer_task.abort_handle(), + #[cfg(target_os = "windows")] + stderr_task: None, + #[cfg(target_os = "windows")] + child: Mutex::new(None), + }), + } + } + + #[cfg(target_os = "windows")] + fn from_child(mut child: Child, distro_name: String) -> Result { + let stdin = child + .stdin + .take() + .ok_or_else(|| unavailable(&distro_name, "worker stdin was not piped"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| unavailable(&distro_name, "worker stdout was not piped"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| unavailable(&distro_name, "worker stderr was not piped"))?; + let stderr_distro = distro_name.clone(); + let stderr_task = tokio::spawn(async move { + drain_worker_stderr(stderr, &stderr_distro).await; + }); + let mut session = Self::from_io(stdout, stdin, distro_name); + let inner = Arc::get_mut(&mut session.inner).expect("new worker session is uniquely owned"); + inner.stderr_task = Some(stderr_task.abort_handle()); + *inner.child.lock().expect("worker child lock poisoned") = Some(child); + Ok(session) + } + + async fn request(&self, message: Message, limit: Duration) -> Result { + match self + .request_response(message, limit, ExpectedResponse::Control, None) + .await? + { + RoutedResponse::Control(message) => Ok(message), + RoutedResponse::Payload(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned a payload for a control request", + )), + } + } + + pub(super) async fn request_control_with_cancellation( + &self, + message: Message, + limit: Duration, + cancellation: Option, + ) -> Result { + match self + .request_response(message, limit, ExpectedResponse::Control, cancellation) + .await? + { + RoutedResponse::Control(message) => Ok(message), + RoutedResponse::Payload(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned a payload for a control request", + )), + } + } + + pub(super) async fn request_payload( + &self, + message: Message, + limit: Duration, + ) -> Result, AppError> { + match self + .request_response(message, limit, ExpectedResponse::Payload, None) + .await? + { + RoutedResponse::Payload(payload) => Ok(payload), + RoutedResponse::Control(Message::Error { code, phase, .. }) => Err( + payload_control_error(&self.inner.distro_name, &code, &phase), + ), + RoutedResponse::Control(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a payload request", + )), + } + } + + pub(super) async fn request_payload_with_limit( + &self, + message: Message, + limit: Duration, + max_payload_bytes: usize, + cancellation: Option, + ) -> Result, AppError> { + let write_probe = matches!(&message, Message::ProbeWriteTargets { .. }); + let response = self + .request_response_with_payload_limit( + message, + limit, + ExpectedResponse::Payload, + cancellation, + max_payload_bytes, + ) + .await; + let probe_may_be_running = write_probe + && (matches!( + &response, + Err(AppError::WslCommandTimedOut | AppError::EnvironmentUnavailable { .. }) + ) || *self.inner.closed.borrow()); + if probe_may_be_running { + self.retire_and_wait("worker write probe did not reach a terminal result") + .await; + } + match response? { + RoutedResponse::Payload(payload) => Ok(payload), + RoutedResponse::Control(Message::Error { code, phase, .. }) => Err( + payload_control_error(&self.inner.distro_name, &code, &phase), + ), + RoutedResponse::Control(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a payload request", + )), + } + } + + pub(super) async fn send_prepared_transfer( + &self, + transfer_id: u64, + payload: &[u8], + max_payload_bytes: usize, + limit: Duration, + ) -> Result { + let deadline = tokio::time::Instant::now() + limit; + let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let (response_tx, response_rx) = oneshot::channel(); + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .insert( + request_id, + PendingResponse { + expected: ExpectedResponse::Control, + max_payload_bytes: 0, + accepted_resource: None, + sender: response_tx, + }, + ); + match tokio::time::timeout_at( + deadline, + self.inner.writer.send_transfer_with_limit( + request_id, + transfer_id, + payload, + max_payload_bytes, + ), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(unavailable(&self.inner.distro_name, error.to_string())); + } + Err(_) => { + self.retire("worker transfer did not finish before its deadline"); + return Err(AppError::WslCommandTimedOut); + } + } + match tokio::time::timeout_at(deadline, response_rx).await { + Ok(Ok(Ok(RoutedResponse::Control(message)))) => Ok(message), + Ok(Ok(Ok(RoutedResponse::Payload(_)))) => Err(unavailable( + &self.inner.distro_name, + "worker returned a payload for an inbound transfer", + )), + Ok(Ok(Err(error))) => Err(error), + Ok(Err(_)) => Err(unavailable( + &self.inner.distro_name, + "worker response router stopped", + )), + Err(_) => { + self.retire("worker transfer response exceeded its deadline"); + Err(AppError::WslCommandTimedOut) + } + } + } + + pub(super) async fn send_prepared_mutation( + &self, + transfer_id: u64, + resource_id: &str, + payload: &[u8], + cancellation: CancellationSignal, + limit: Duration, + ) -> Result { + let deadline = tokio::time::Instant::now() + limit; + let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let accepted_resource = Arc::new(Mutex::new(None)); + let (response_tx, response_rx) = oneshot::channel(); + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .insert( + request_id, + PendingResponse { + expected: ExpectedResponse::Mutation, + max_payload_bytes: environment_protocol::MAX_MUTATION_TRANSFER_BYTES, + accepted_resource: Some(Arc::clone(&accepted_resource)), + sender: response_tx, + }, + ); + match tokio::time::timeout_at( + deadline, + self.inner.writer.send_transfer_with_limit( + request_id, + transfer_id, + payload, + environment_protocol::MAX_MUTATION_TRANSFER_BYTES, + ), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(MutationSessionError { + error: unavailable(&self.inner.distro_name, error.to_string()), + recovery_resource_id: None, + }); + } + Err(_) => { + self.retire("worker mutation transfer did not finish before its deadline"); + return Err(MutationSessionError { + error: AppError::WslCommandTimedOut, + recovery_resource_id: None, + }); + } + } + let mut response_rx = response_rx; + let mut cancel_sent = false; + let waited = tokio::select! { + response = &mut response_rx => Some(response), + _ = tokio::time::sleep_until(deadline) => None, + _ = cancellation.cancelled() => { + cancel_sent = true; + let cancel_request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let _ = tokio::time::timeout_at( + deadline, + self.inner.writer.send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })), + ) + .await; + tokio::time::timeout_at(deadline, &mut response_rx) + .await + .ok() + }, + }; + let result = match waited { + Some(Ok(Ok(RoutedResponse::Payload(payload)))) => { + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker mutation response: {error}"), + } + }) + } + Some(Ok(Ok(RoutedResponse::Control(Message::Error { code, phase, .. })))) => { + Err(AppError::ExecutionFailed { + message: format!("worker mutation failed during {phase}: {code}"), + }) + } + Some(Ok(Ok(RoutedResponse::Control(_)))) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a mutation request", + )), + Some(Ok(Err(error))) => Err(error), + Some(Err(_)) => Err(unavailable( + &self.inner.distro_name, + "worker response router stopped", + )), + None => { + self.retire("worker mutation did not reach a terminal result before its deadline"); + let cancelled = { + let mut routes = self + .inner + .routes + .lock() + .expect("worker response routes lock poisoned"); + let cancelled = routes.cancel_request(request_id); + if cancelled { + routes.remember_tombstone(request_id); + } + cancelled + }; + if cancelled && !cancel_sent { + let cancel_request_id = + self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let _ = self + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })) + .await; + } + Err(if cancellation.is_cancelled() { + AppError::MutationCancelled + } else { + AppError::WslCommandTimedOut + }) + } + }; + result.map_err(|error| MutationSessionError { + error, + recovery_resource_id: accepted_resource + .lock() + .expect("worker mutation accepted state lock poisoned") + .clone() + .or_else(|| Some(resource_id.to_string())), + }) + } + + pub(super) async fn request_payload_with_cancellation( + &self, + message: Message, + limit: Duration, + cancellation: CancellationSignal, + ) -> Result, AppError> { + match self + .request_response( + message, + limit, + ExpectedResponse::Payload, + Some(cancellation), + ) + .await? + { + RoutedResponse::Payload(payload) => Ok(payload), + RoutedResponse::Control(Message::Error { code, .. }) if code == "deadlineExceeded" => { + Err(AppError::WslCommandTimedOut) + } + RoutedResponse::Control(Message::Error { code, phase, .. }) => { + Err(AppError::ExecutionFailed { + message: format!("worker request failed during {phase}: {code}"), + }) + } + RoutedResponse::Control(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a payload request", + )), + } + } + + async fn request_response( + &self, + message: Message, + limit: Duration, + expected: ExpectedResponse, + cancellation: Option, + ) -> Result { + self.request_response_with_payload_limit( + message, + limit, + expected, + cancellation, + MAX_RESPONSE_TRANSFER_BYTES, + ) + .await + } + + async fn request_response_with_payload_limit( + &self, + message: Message, + limit: Duration, + expected: ExpectedResponse, + cancellation: Option, + max_payload_bytes: usize, + ) -> Result { + let deadline = tokio::time::Instant::now() + limit; + let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let (response_tx, response_rx) = oneshot::channel(); + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .insert( + request_id, + PendingResponse { + expected, + max_payload_bytes, + accepted_resource: None, + sender: response_tx, + }, + ); + match tokio::time::timeout_at( + deadline, + self.inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(unavailable(&self.inner.distro_name, error.to_string())); + } + Err(_) => { + self.retire("worker control request did not send before its deadline"); + return Err(AppError::WslCommandTimedOut); + } + } + + enum WaitResult { + Response(T), + TimedOut, + Cancelled { terminal_observed: bool }, + } + let mut response_rx = response_rx; + let waited = if let Some(cancellation) = cancellation { + tokio::select! { + response = &mut response_rx => WaitResult::Response(response), + _ = tokio::time::sleep_until(deadline) => WaitResult::TimedOut, + _ = cancellation.cancelled() => { + let cancel_request_id = + self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let sent = tokio::time::timeout_at( + deadline, + self.inner.writer.send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })), + ).await; + let terminal_observed = matches!(sent, Ok(Ok(()))) + && tokio::time::timeout_at(deadline, &mut response_rx) + .await + .is_ok(); + WaitResult::Cancelled { terminal_observed } + }, + } + } else { + match tokio::time::timeout_at(deadline, response_rx).await { + Ok(response) => WaitResult::Response(response), + Err(_) => WaitResult::TimedOut, + } + }; + match waited { + WaitResult::Response(Ok(response)) => response, + WaitResult::Response(Err(_)) => Err(unavailable( + &self.inner.distro_name, + "worker response router stopped", + )), + WaitResult::TimedOut => { + let cancelled = { + let mut routes = self + .inner + .routes + .lock() + .expect("worker response routes lock poisoned"); + let cancelled = routes.cancel_request(request_id); + if cancelled { + routes.remember_tombstone(request_id); + } + cancelled + }; + if cancelled { + let cancel_request_id = + self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let _ = self + .inner + .writer + .try_send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })); + } + Err(AppError::WslCommandTimedOut) + } + WaitResult::Cancelled { terminal_observed } => { + let cancelled = { + let mut routes = self + .inner + .routes + .lock() + .expect("worker response routes lock poisoned"); + let cancelled = routes.cancel_request(request_id); + if cancelled { + routes.remember_tombstone(request_id); + } + cancelled + }; + if cancelled && !terminal_observed { + self.retire("worker cancellation did not reach a terminal result"); + } + Err(AppError::MutationCancelled) + } + } + } + + pub(super) fn closed_receiver(&self) -> watch::Receiver { + self.inner.closed.subscribe() + } + + fn retire(&self, message: &str) { + fail_pending( + &self.inner.routes, + unavailable(&self.inner.distro_name, message), + ); + self.inner.closed.send_replace(true); + self.inner.reader_task.abort(); + self.inner.writer_task.abort(); + #[cfg(target_os = "windows")] + { + if let Some(task) = &self.inner.stderr_task { + task.abort(); + } + if let Ok(mut child) = self.inner.child.lock() { + if let Some(child) = child.as_mut() { + let _ = child.start_kill(); + } + } + } + } + + async fn retire_and_wait(&self, message: &str) { + self.retire(message); + #[cfg(target_os = "windows")] + { + let child = self + .inner + .child + .lock() + .ok() + .and_then(|mut child| child.take()); + if let Some(mut child) = child { + let _ = child.wait().await; + } + } + } + + async fn handshake( + &self, + expected: &super::WslSession, + build_id: &str, + ) -> Result<(), AppError> { + match self + .request( + Message::Handshake { + build_id: build_id.to_string(), + }, + HANDSHAKE_TIMEOUT, + ) + .await? + { + Message::HandshakeResult { + build_id: actual_build, + distro, + user, + uid, + home, + } if actual_build == build_id + && distro.eq_ignore_ascii_case(&expected.distro_name) + && user == expected.user + && uid == expected.uid + && home == expected.home => {} + Message::Error { code, phase, .. } => { + return Err(unavailable( + &expected.distro_name, + format!("worker handshake failed during {phase}: {code}"), + )); + } + _ => { + return Err(unavailable( + &expected.distro_name, + "worker identity does not match the WSL session", + )); + } + } + + match self + .request( + Message::ObservePath { + path: expected.home.clone(), + }, + HANDSHAKE_TIMEOUT, + ) + .await? + { + Message::PathObserved { + kind: PathKind::Directory | PathKind::SymlinkDirectory, + } => Ok(()), + Message::Error { code, phase, .. } => Err(unavailable( + &expected.distro_name, + format!("worker HOME observation failed during {phase}: {code}"), + )), + _ => Err(unavailable( + &expected.distro_name, + "WSL HOME is not an accessible directory", + )), + } + } +} + +impl Drop for WorkerSessionInner { + fn drop(&mut self) { + let _ = self.closed.send(true); + self.reader_task.abort(); + self.writer_task.abort(); + #[cfg(target_os = "windows")] + { + if let Some(task) = &self.stderr_task { + task.abort(); + } + if let Ok(mut child) = self.child.lock() { + if let Some(child) = child.as_mut() { + let _ = child.start_kill(); + } + } + } + } +} + +impl ResponseRoutes { + fn cancel_request(&mut self, request_id: u64) -> bool { + let cancelled = self.pending.remove(&request_id).is_some(); + if cancelled { + if let Some(transfer) = self + .active_transfer + .as_mut() + .filter(|transfer| transfer.owner_request_id == request_id) + { + transfer.payload = None; + } + } + cancelled + } + + fn remember_tombstone(&mut self, request_id: u64) { + if self.tombstone_set.insert(request_id) { + self.tombstones.push_back(request_id); + } + while self.tombstones.len() > TOMBSTONE_LIMIT { + if let Some(expired) = self.tombstones.pop_front() { + self.tombstone_set.remove(&expired); + } + } + } +} + +fn route_record(routes: &Mutex, record: WireRecord) -> Result<(), String> { + let mut routes = routes.lock().expect("worker response routes lock poisoned"); + match record { + WireRecord::Control(envelope) => route_control(&mut routes, envelope), + WireRecord::PayloadChunk { transfer_id, bytes } => { + route_payload_chunk(&mut routes, transfer_id, bytes) + } + } +} + +fn route_control(routes: &mut ResponseRoutes, envelope: Envelope) -> Result<(), String> { + match envelope.message { + Message::BeginTransfer { + transfer_id, + total_bytes, + sha256, + owner_request_id, + } => begin_transfer( + routes, + envelope.request_id, + transfer_id, + total_bytes, + sha256, + owner_request_id, + ), + Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + } => complete_transfer( + routes, + envelope.request_id, + transfer_id, + total_bytes, + sha256, + ), + Message::Progress { .. } => { + if routes.pending.contains_key(&envelope.request_id) + || routes.tombstone_set.contains(&envelope.request_id) + { + Ok(()) + } else { + Err("worker progress has no owning request".to_string()) + } + } + message => route_control_response(routes, envelope.request_id, message), + } +} + +fn begin_transfer( + routes: &mut ResponseRoutes, + envelope_request_id: u64, + transfer_id: u64, + total_bytes: u64, + sha256: String, + owner_request_id: u64, +) -> Result<(), String> { + if routes.active_transfer.is_some() || envelope_request_id != owner_request_id { + return Err("invalid worker transfer declaration".to_string()); + } + let payload = if let Some(pending) = routes.pending.get(&owner_request_id) { + if !matches!( + pending.expected, + ExpectedResponse::Payload | ExpectedResponse::Mutation + ) || total_bytes > pending.max_payload_bytes as u64 + { + return Err("worker payload does not match the pending response type".to_string()); + } + Some(Vec::with_capacity(total_bytes as usize)) + } else if routes.tombstone_set.contains(&owner_request_id) { + None + } else { + return Err("worker transfer has no owning request".to_string()); + }; + routes.active_transfer = Some(IncomingTransfer { + transfer_id, + owner_request_id, + total_bytes: total_bytes as usize, + expected_sha256: sha256, + received_bytes: 0, + hasher: Sha256::new(), + payload, + }); + Ok(()) +} + +fn route_payload_chunk( + routes: &mut ResponseRoutes, + transfer_id: u64, + bytes: Vec, +) -> Result<(), String> { + let transfer = routes + .active_transfer + .as_mut() + .ok_or_else(|| "worker payload has no active transfer".to_string())?; + if transfer.transfer_id != transfer_id + || bytes.len() > MAX_PAYLOAD_CHUNK_BYTES + || transfer.received_bytes.saturating_add(bytes.len()) > transfer.total_bytes + { + return Err("worker payload chunk exceeds its transfer boundary".to_string()); + } + transfer.received_bytes += bytes.len(); + transfer.hasher.update(&bytes); + if let Some(payload) = transfer.payload.as_mut() { + payload.extend_from_slice(&bytes); + } + Ok(()) +} + +fn complete_transfer( + routes: &mut ResponseRoutes, + envelope_request_id: u64, + transfer_id: u64, + total_bytes: u64, + sha256: String, +) -> Result<(), String> { + let transfer = routes + .active_transfer + .take() + .ok_or_else(|| "worker completed a transfer that is not active".to_string())?; + let actual_sha256 = format!("sha256:{:x}", transfer.hasher.finalize()); + if transfer.transfer_id != transfer_id + || transfer.owner_request_id != envelope_request_id + || transfer.total_bytes != total_bytes as usize + || transfer.received_bytes != transfer.total_bytes + || transfer.expected_sha256 != sha256 + || actual_sha256 != sha256 + { + return Err("worker transfer completion does not match its declaration".to_string()); + } + if let Some(payload) = transfer.payload { + let pending = routes + .pending + .remove(&transfer.owner_request_id) + .ok_or_else(|| "worker transfer owner is no longer pending".to_string())?; + if !matches!( + pending.expected, + ExpectedResponse::Payload | ExpectedResponse::Mutation + ) { + return Err("worker payload does not match the pending response type".to_string()); + } + let _ = pending.sender.send(Ok(RoutedResponse::Payload(payload))); + } else { + forget_tombstone(routes, transfer.owner_request_id); + } + Ok(()) +} + +fn route_control_response( + routes: &mut ResponseRoutes, + request_id: u64, + message: Message, +) -> Result<(), String> { + if let Message::MutationAccepted { resource_id } = &message { + let pending = routes + .pending + .get(&request_id) + .ok_or_else(|| "worker mutation acceptance has no owning request".to_string())?; + if pending.expected != ExpectedResponse::Mutation { + return Err( + "worker mutation acceptance does not match its pending request".to_string(), + ); + } + let accepted = pending + .accepted_resource + .as_ref() + .ok_or_else(|| "worker mutation acceptance state is unavailable".to_string())?; + let mut accepted = accepted + .lock() + .map_err(|_| "worker mutation acceptance state is poisoned".to_string())?; + if accepted.replace(resource_id.clone()).is_some() { + return Err("worker accepted one mutation more than once".to_string()); + } + return Ok(()); + } + if routes + .active_transfer + .as_ref() + .is_some_and(|transfer| transfer.owner_request_id == request_id) + { + return Err("worker control response interrupted an active transfer".to_string()); + } + if routes.tombstone_set.contains(&request_id) { + forget_tombstone(routes, request_id); + return Ok(()); + } + let pending = routes + .pending + .remove(&request_id) + .ok_or_else(|| "worker response has no owning request".to_string())?; + if pending.expected != ExpectedResponse::Control && !matches!(message, Message::Error { .. }) { + return Err("worker control does not match the pending response type".to_string()); + } + let _ = pending.sender.send(Ok(RoutedResponse::Control(message))); + Ok(()) +} + +fn forget_tombstone(routes: &mut ResponseRoutes, request_id: u64) { + routes.tombstone_set.remove(&request_id); + routes + .tombstones + .retain(|tombstone| *tombstone != request_id); +} + +fn fail_pending(routes: &Mutex, error: AppError) { + let pending = { + let mut routes = routes.lock().expect("worker response routes lock poisoned"); + routes.active_transfer = None; + std::mem::take(&mut routes.pending) + }; + for pending in pending.into_values() { + let _ = pending.sender.send(Err(error.clone())); + } +} + +fn unavailable(distro_name: &str, message: impl Into) -> AppError { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: message.into(), + } +} + +fn payload_control_error(distro_name: &str, code: &str, phase: &str) -> AppError { + match code { + "deadlineExceeded" => AppError::WslCommandTimedOut, + "sourceDirectoryLinks" if phase == "source" => AppError::CapabilityUnavailable { + capability: "sourceDirectoryLinks".into(), + path: None, + }, + "pathUnavailable" if phase == "pathMapping" => AppError::CapabilityUnavailable { + capability: "wslPathMapping".to_string(), + path: None, + }, + "libraryRecoveryIncomplete" => AppError::LibraryRecoveryIncomplete { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL Skill Library recovery is incomplete".to_string(), + }, + _ => AppError::ExecutionFailed { + message: format!("worker request failed during {phase}: {code}"), + }, + } +} + +#[cfg(target_os = "windows")] +pub(super) async fn connect_worker( + session: &super::WslSession, + artifact_directory: &Path, +) -> Result { + let artifact = WorkerArtifact::load_from(artifact_directory) + .map_err(|message| unavailable(&session.distro_name, message))?; + deploy_worker(session, &artifact).await?; + + let worker_path = worker_path(&session.home); + let mut command = super::wsl_command(); + command + .args([ + "--distribution", + &session.distro_name, + "--user", + &session.user, + "--exec", + &worker_path, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let child = command + .spawn() + .map_err(|error| unavailable(&session.distro_name, error.to_string()))?; + let worker = WorkerSession::from_child(child, session.distro_name.clone())?; + worker.handshake(session, &artifact.build_id).await?; + Ok(worker) +} + +#[cfg(not(target_os = "windows"))] +pub(super) async fn connect_worker( + session: &super::WslSession, + _artifact_directory: &Path, +) -> Result { + Err(unavailable( + &session.distro_name, + "WSL worker is only available on Windows", + )) +} + +#[cfg(target_os = "windows")] +async fn deploy_worker( + session: &super::WslSession, + artifact: &WorkerArtifact, +) -> Result<(), AppError> { + let output = WslCommandRunner::run(WslCommandRequest { + session: session.clone(), + script: BOOTSTRAP_SCRIPT, + args: vec![artifact.build_id.clone()], + stdin: artifact.bytes.clone(), + timeout: WORKER_START_TIMEOUT, + stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, + stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + cancellation: None, + }) + .await?; + if output.exit_code != Some(0) { + return Err(unavailable( + &session.distro_name, + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn worker_path(home: &str) -> String { + if home == "/" { + "/.skill-deck/runtime/wsl-worker/current/worker".to_string() + } else { + format!( + "{}/.skill-deck/runtime/wsl-worker/current/worker", + home.trim_end_matches('/') + ) + } +} + +#[cfg(target_os = "windows")] +async fn drain_worker_stderr(mut stderr: tokio::process::ChildStderr, distro_name: &str) { + let mut retained = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + match stderr.read(&mut buffer).await { + Ok(0) => break, + Ok(read) if retained.len() < WORKER_STDERR_LIMIT => { + let keep = read.min(WORKER_STDERR_LIMIT - retained.len()); + retained.extend_from_slice(&buffer[..keep]); + } + Ok(_) => {} + Err(error) => { + log::warn!("failed to drain WSL worker stderr for {distro_name}: {error}"); + return; + } + } + } + if !retained.is_empty() { + log::warn!( + "WSL worker stderr for {distro_name}: {}", + String::from_utf8_lossy(&retained).trim() + ); + } +} + +#[cfg(test)] +#[allow( + clippy::disallowed_methods, + reason = "Worker bootstrap 测试需要直接运行受控的 POSIX shell" +)] +mod tests { + use std::fs; + #[cfg(unix)] + use std::io::Write; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use std::path::Path; + #[cfg(unix)] + use std::process::{Command, Stdio}; + + use environment_protocol::{ + codec, decode, spawn_writer, Envelope, InspectionRequest, Message, WireRecord, + }; + use futures_util::StreamExt; + use sha2::{Digest, Sha256}; + use tokio::io::{duplex, split}; + use tokio::time::Duration; + use tokio_util::codec::FramedRead; + + use super::{payload_control_error, WorkerSession}; + use crate::core::mutation::CancellationSignal; + use crate::environment::wsl::WslSession; + + #[test] + fn library_recovery_payload_error_preserves_its_typed_contract() { + assert!(matches!( + payload_control_error("Ubuntu", "libraryRecoveryIncomplete", "libraryRead"), + crate::error::AppError::LibraryRecoveryIncomplete { + environment: crate::environment::types::EnvironmentRef::Wsl { distro_name }, + .. + } if distro_name == "Ubuntu" + )); + } + + #[test] + fn path_mapping_payload_error_preserves_its_typed_contract() { + assert_eq!( + payload_control_error("Ubuntu", "pathUnavailable", "pathMapping"), + crate::error::AppError::CapabilityUnavailable { + capability: "wslPathMapping".to_string(), + path: None, + } + ); + } + + #[test] + fn source_directory_link_error_preserves_the_support_limit() { + assert!( + matches!(payload_control_error("Ubuntu", "sourceDirectoryLinks", "source"), crate::error::AppError::CapabilityUnavailable { capability, .. } if capability == "sourceDirectoryLinks") + ); + } + + #[test] + fn artifact_loader_rejects_bytes_that_do_not_match_the_manifest() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("worker"), b"worker").unwrap(); + fs::write( + directory.path().join("manifest.json"), + r#"{ + "buildId": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "sha256": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "target": "x86_64-unknown-linux-musl" + }"#, + ) + .unwrap(); + + let artifact = super::WorkerArtifact::load_from(directory.path()).unwrap(); + assert_eq!(artifact.bytes, b"worker"); + assert_eq!( + artifact.build_id, + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e" + ); + + fs::write(directory.path().join("worker"), b"damaged").unwrap(); + let error = super::WorkerArtifact::load_from(directory.path()).unwrap_err(); + assert!(error.contains("do not match"), "{error}"); + } + + #[test] + fn artifact_loader_does_not_search_sibling_directories() { + let root = tempfile::tempdir().unwrap(); + let sibling = root.path().join("sibling"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join("worker"), b"worker").unwrap(); + fs::write( + sibling.join("manifest.json"), + r#"{ + "buildId": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "sha256": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "target": "x86_64-unknown-linux-musl" + }"#, + ) + .unwrap(); + + assert!(super::WorkerArtifact::load_from(&root.path().join("explicit")).is_err()); + } + + #[cfg(unix)] + #[test] + fn bootstrap_script_verifies_and_atomically_installs_worker() { + use std::os::unix::fs::MetadataExt; + + let home = tempfile::tempdir().unwrap(); + assert!(run_bootstrap( + home.path(), + b"worker", + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + ) + .success()); + + let installed = home + .path() + .join(".skill-deck/runtime/wsl-worker/current/worker"); + assert_eq!(fs::read(&installed).unwrap(), b"worker"); + assert_eq!( + fs::metadata(&installed).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + let inode = fs::metadata(&installed).unwrap().ino(); + assert!(run_bootstrap( + home.path(), + b"worker", + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + ) + .success()); + assert_eq!(fs::metadata(&installed).unwrap().ino(), inode); + + assert!(!run_bootstrap( + home.path(), + b"damaged", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .success()); + assert_eq!(fs::read(&installed).unwrap(), b"worker"); + assert_eq!( + fs::read_dir(installed.parent().unwrap()).unwrap().count(), + 1 + ); + } + + #[cfg(unix)] + #[test] + fn bootstrap_switches_worker_builds_in_both_directions() { + let home = tempfile::tempdir().unwrap(); + let worker = home + .path() + .join(".skill-deck/runtime/wsl-worker/current/worker"); + for (bytes, digest) in [ + ( + b"worker-a".as_slice(), + "sha256:6a65e237ae44c42895b5049031466616173469f513b00240e9db90df66906a49", + ), + ( + b"worker-b".as_slice(), + "sha256:0482c4aea1af397e54200017866d778ce32e1daac473f2cd09a9159d2e69b6ef", + ), + ( + b"worker-a".as_slice(), + "sha256:6a65e237ae44c42895b5049031466616173469f513b00240e9db90df66906a49", + ), + ] { + assert!(run_bootstrap(home.path(), bytes, digest).success()); + assert_eq!(fs::read(&worker).unwrap(), bytes); + } + } + + #[cfg(unix)] + #[test] + fn bootstrap_rejects_non_file_targets_without_following_them() { + use std::os::unix::fs::symlink; + + for target_kind in ["directory", "symlink"] { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".skill-deck/runtime/wsl-worker/current"); + fs::create_dir_all(&directory).unwrap(); + let worker = directory.join("worker"); + if target_kind == "directory" { + fs::create_dir(&worker).unwrap(); + } else { + let external = home.path().join("external"); + fs::write(&external, b"external").unwrap(); + symlink(&external, &worker).unwrap(); + } + + assert!(!run_bootstrap( + home.path(), + b"worker", + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + ) + .success()); + assert!(fs::symlink_metadata(&worker).is_ok()); + assert_eq!( + fs::read_dir(&directory) + .unwrap() + .filter_map(Result::ok) + .count(), + 1 + ); + } + } + + #[cfg(unix)] + fn run_bootstrap(home: &Path, bytes: &[u8], digest: &str) -> std::process::ExitStatus { + let mut child = Command::new("/bin/sh") + .args(["-c", super::BOOTSTRAP_SCRIPT, "--", digest]) + .env("HOME", home) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(bytes).unwrap(); + child.wait().unwrap() + } + + #[tokio::test] + async fn concurrent_requests_receive_responses_by_request_id() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let first = next_envelope(&mut reader).await; + let second = next_envelope(&mut reader).await; + for request in [second, first] { + writer + .send_control(WireRecord::Control(Envelope { + request_id: request.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + } + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let first = session.request( + Message::ObservePath { + path: "/home/alice".to_string(), + }, + Duration::from_secs(1), + ); + let second = session.request( + Message::ObservePath { + path: "/tmp".to_string(), + }, + Duration::from_secs(1), + ); + let (first, second) = tokio::join!(first, second); + + assert!(matches!(first.unwrap(), Message::PathObserved { .. })); + assert!(matches!(second.unwrap(), Message::PathObserved { .. })); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn control_request_deadline_includes_a_blocked_send() { + let (client, _server) = duplex(1); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + tokio::time::timeout(Duration::from_secs(1), async { + for request_id in 1..=65 { + session + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::ObservePath { + path: "/tmp".to_string(), + }, + })) + .await + .unwrap(); + } + }) + .await + .expect("control queue fixture must fill without blocking"); + + let result = tokio::time::timeout( + Duration::from_millis(500), + session.request( + Message::ObservePath { + path: "/home/alice".to_string(), + }, + Duration::from_millis(25), + ), + ) + .await + .expect("the request deadline must include control queue backpressure"); + + assert_eq!( + result.unwrap_err(), + crate::error::AppError::WslCommandTimedOut + ); + assert!(*session.closed_receiver().borrow()); + } + + #[tokio::test] + async fn payload_response_is_bounded_reassembled_and_verified() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let payload = vec![0x5a; 3073]; + let expected = payload.clone(); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let request = next_envelope(&mut reader).await; + assert!(matches!(request.message, Message::InspectFilesystem { .. })); + writer + .send_transfer(request.request_id, 91, &payload) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let actual = session + .request_payload(inspection_request(), Duration::from_secs(1)) + .await + .unwrap(); + + assert_eq!(actual, expected); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn payload_response_uses_the_pending_requests_specific_limit() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let request = next_envelope(&mut reader).await; + writer + .send_transfer(request.request_id, 94, b"four") + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let error = session + .request_payload_with_limit(inspection_request(), Duration::from_secs(1), 3, None) + .await + .unwrap_err(); + + assert!(matches!( + error, + crate::error::AppError::EnvironmentUnavailable { .. } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn prepared_inbound_transfer_uses_a_fresh_owner_request() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + assert!(matches!( + declaration.message, + Message::BeginTransfer { + transfer_id: 700, + owner_request_id: owner, + .. + } if owner == owner_request_id + )); + assert!(matches!( + decode(&reader.next().await.unwrap().unwrap()).unwrap(), + WireRecord::PayloadChunk { + transfer_id: 700, + .. + } + )); + let completion = next_envelope(&mut reader).await; + assert_eq!(completion.request_id, owner_request_id); + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::PayloadBlobUploaded { + upload_id: 7, + blob_id: "a".repeat(64), + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let response = session + .send_prepared_transfer(700, b"blob", 16, Duration::from_secs(1)) + .await + .unwrap(); + + assert!(matches!( + response, + Message::PayloadBlobUploaded { upload_id: 7, .. } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn prepared_transfer_deadline_includes_a_blocked_send() { + let (client, _server) = duplex(1); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let payload = vec![0x5a; environment_protocol::MAX_PAYLOAD_CHUNK_BYTES * 16]; + + let result = tokio::time::timeout( + Duration::from_millis(500), + session.send_prepared_transfer(700, &payload, payload.len(), Duration::from_millis(25)), + ) + .await + .expect("the transfer deadline must include writer backpressure"); + + assert_eq!( + result.unwrap_err(), + crate::error::AppError::WslCommandTimedOut + ); + assert!(*session.closed_receiver().borrow()); + } + + #[tokio::test] + async fn mutation_acceptance_keeps_the_pending_route_until_terminal_payload() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + assert!(matches!(declaration.message, Message::BeginTransfer { .. })); + assert!(matches!( + decode(&reader.next().await.unwrap().unwrap()).unwrap(), + WireRecord::PayloadChunk { .. } + )); + let _completion = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::MutationAccepted { + resource_id: "resource-1".to_string(), + }, + })) + .await + .unwrap(); + let outcome = environment_protocol::encode_payload( + &environment_protocol::MutationUnitOutcome::Cancelled, + ) + .unwrap(); + writer + .send_transfer(owner_request_id, 701, &outcome) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let outcome = session + .send_prepared_mutation( + 700, + "resource-1", + b"request", + CancellationSignal::default(), + Duration::from_secs(1), + ) + .await + .map_err(|error| error.error) + .unwrap(); + + assert_eq!( + outcome, + environment_protocol::MutationUnitOutcome::Cancelled + ); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn mutation_deadline_includes_a_blocked_send() { + let (client, _server) = duplex(1); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let payload = vec![0x5a; environment_protocol::MAX_PAYLOAD_CHUNK_BYTES * 16]; + + let result = tokio::time::timeout( + Duration::from_millis(500), + session.send_prepared_mutation( + 700, + "resource-requested", + &payload, + CancellationSignal::default(), + Duration::from_millis(25), + ), + ) + .await + .expect("the mutation deadline must include writer backpressure") + .unwrap_err(); + + assert_eq!(result.error, crate::error::AppError::WslCommandTimedOut); + assert_eq!(result.recovery_resource_id, None); + assert!(*session.closed_receiver().borrow()); + } + + #[tokio::test] + async fn mutation_disconnect_reports_whether_the_worker_accepted_it() { + for accepted in [false, true] { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = + WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + let _chunk = reader.next().await; + let _completion = next_envelope(&mut reader).await; + if accepted { + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::MutationAccepted { + resource_id: "resource-accepted".to_string(), + }, + })) + .await + .unwrap(); + } + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let error = session + .send_prepared_mutation( + 710, + "resource-requested", + b"request", + CancellationSignal::default(), + Duration::from_secs(1), + ) + .await + .unwrap_err(); + + assert_eq!( + error.recovery_resource_id.as_deref(), + Some(if accepted { + "resource-accepted" + } else { + "resource-requested" + }) + ); + server_task.await.unwrap(); + } + } + + #[tokio::test] + async fn mutation_cancellation_waits_for_the_worker_terminal_outcome() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + let _chunk = reader.next().await; + let _completion = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::MutationAccepted { + resource_id: "resource-cancelled".to_string(), + }, + })) + .await + .unwrap(); + let cancel = next_envelope(&mut reader).await; + assert!(matches!( + cancel.message, + Message::Cancel { target_request_id } if target_request_id == owner_request_id + )); + let outcome = environment_protocol::encode_payload( + &environment_protocol::MutationUnitOutcome::Cancelled, + ) + .unwrap(); + writer + .send_transfer(owner_request_id, 712, &outcome) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + let cancellation = CancellationSignal::default(); + let cancellation_task = { + let cancellation = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + cancellation.cancel(); + }) + }; + + let outcome = session + .send_prepared_mutation( + 711, + "resource-cancelled", + b"request", + cancellation, + Duration::from_secs(1), + ) + .await + .map_err(|error| error.error) + .unwrap(); + + assert_eq!( + outcome, + environment_protocol::MutationUnitOutcome::Cancelled + ); + cancellation_task.await.unwrap(); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn mismatched_payload_digest_invalidates_the_session() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let mut closed = session.closed_receiver(); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let request = next_envelope(&mut reader).await; + writer + .send_binary(WireRecord::Control(Envelope { + request_id: request.request_id, + message: Message::BeginTransfer { + transfer_id: 92, + total_bytes: 3, + sha256: "sha256:incorrect".to_string(), + owner_request_id: request.request_id, + }, + })) + .await + .unwrap(); + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 92, + bytes: b"abc".to_vec(), + }) + .await + .unwrap(); + writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id: request.request_id, + message: Message::TransferCompleted { + transfer_id: 92, + total_bytes: 3, + sha256: "sha256:incorrect".to_string(), + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let error = session + .request_payload(inspection_request(), Duration::from_secs(1)) + .await + .unwrap_err(); + + assert!(matches!( + error, + crate::error::AppError::EnvironmentUnavailable { .. } + )); + tokio::time::timeout(Duration::from_secs(1), closed.changed()) + .await + .unwrap() + .unwrap(); + assert!(*closed.borrow()); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn timed_out_payload_transfer_is_drained_before_the_session_is_reused() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let expired = next_envelope(&mut reader).await; + let digest = format!("sha256:{:x}", Sha256::digest(b"abcdef")); + writer + .send_binary(WireRecord::Control(Envelope { + request_id: expired.request_id, + message: Message::BeginTransfer { + transfer_id: 93, + total_bytes: 6, + sha256: digest.clone(), + owner_request_id: expired.request_id, + }, + })) + .await + .unwrap(); + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 93, + bytes: b"abc".to_vec(), + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + let cancel = next_envelope(&mut reader).await; + assert!(matches!( + cancel.message, + Message::Cancel { target_request_id } if target_request_id == expired.request_id + )); + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 93, + bytes: b"def".to_vec(), + }) + .await + .unwrap(); + writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id: expired.request_id, + message: Message::TransferCompleted { + transfer_id: 93, + total_bytes: 6, + sha256: digest, + }, + })) + .await + .unwrap(); + let current = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: current.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + assert_eq!( + session + .request_payload(inspection_request(), Duration::from_millis(10)) + .await + .unwrap_err(), + crate::error::AppError::WslCommandTimedOut, + ); + assert!(matches!( + session + .request( + Message::ObservePath { + path: "/current".to_string(), + }, + Duration::from_secs(1), + ) + .await + .unwrap(), + Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn write_probe_timeout_retires_the_worker_session() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let closed = session.closed_receiver(); + let (server_reader, _server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let requesting = session.clone(); + let request = tokio::spawn(async move { + requesting + .request_payload_with_limit( + Message::ProbeWriteTargets { + request: environment_protocol::WriteProbeRequest { + destinations: vec!["/tmp/skills/demo".to_string()], + deadline_millis: 1_000, + }, + }, + Duration::from_millis(50), + 1024, + None, + ) + .await + }); + assert!(matches!( + next_envelope(&mut reader).await.message, + Message::ProbeWriteTargets { .. } + )); + let result = request.await.unwrap(); + + assert_eq!(result, Err(crate::error::AppError::WslCommandTimedOut)); + assert!( + *closed.borrow(), + "a timed-out write probe must not leave a reusable Worker session" + ); + } + + #[tokio::test] + async fn timed_out_request_is_cancelled_and_its_late_response_is_ignored() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let expired = next_envelope(&mut reader).await; + let cancel = next_envelope(&mut reader).await; + assert!(matches!( + cancel.message, + Message::Cancel { target_request_id } if target_request_id == expired.request_id + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: expired.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Missing, + }, + })) + .await + .unwrap(); + let current = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: current.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + assert_eq!( + session + .request( + Message::ObservePath { + path: "/expired".to_string(), + }, + Duration::from_millis(10), + ) + .await + .unwrap_err(), + crate::error::AppError::WslCommandTimedOut, + ); + assert!(matches!( + session + .request( + Message::ObservePath { + path: "/current".to_string(), + }, + Duration::from_secs(1), + ) + .await + .unwrap(), + Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn handshake_confirms_identity_and_home_directory() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let handshake = next_envelope(&mut reader).await; + assert!(matches!( + handshake.message, + Message::Handshake { ref build_id } if build_id == "sha256:build" + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: handshake.request_id, + message: Message::HandshakeResult { + build_id: "sha256:build".to_string(), + distro: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: "/home/alice".to_string(), + }, + })) + .await + .unwrap(); + let home = next_envelope(&mut reader).await; + assert!(matches!( + home.message, + Message::ObservePath { ref path } if path == "/home/alice" + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: home.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + let expected = WslSession { + distro_name: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: "/home/alice".to_string(), + xdg_state_home: None, + config_home: "/home/alice/.config".to_string(), + environment: Default::default(), + runtime_generation: 0, + }; + + session.handshake(&expected, "sha256:build").await.unwrap(); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn closing_worker_output_notifies_the_session_owner() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let mut closed = session.closed_receiver(); + + drop(server); + + tokio::time::timeout(Duration::from_secs(1), closed.changed()) + .await + .unwrap() + .unwrap(); + assert!(*closed.borrow()); + } + + async fn next_envelope( + reader: &mut FramedRead, + ) -> Envelope + where + R: tokio::io::AsyncRead + Unpin, + { + match decode(&reader.next().await.unwrap().unwrap()).unwrap() { + WireRecord::Control(envelope) => envelope, + WireRecord::PayloadChunk { .. } => panic!("unexpected payload chunk"), + } + } + + fn inspection_request() -> Message { + Message::InspectFilesystem { + request: InspectionRequest { + read_content: true, + roots: Vec::new(), + per_file_limit: 1, + aggregate_limit: 1, + deadline_millis: 1_000, + }, + } + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 0515fdfb..35df1721 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -37,7 +37,7 @@ pub enum AgentSelectionInvalidReason { ResultNotAllowed, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] pub enum SourceAcquisitionFailureReason { @@ -47,6 +47,7 @@ pub enum SourceAcquisitionFailureReason { Network, LimitExceeded, InvalidContent, + DirectoryLinksUnsupported, Unavailable, } @@ -92,6 +93,7 @@ impl SourceAcquisitionFailureReason { Self::Network => "network", Self::LimitExceeded => "limitExceeded", Self::InvalidContent => "invalidContent", + Self::DirectoryLinksUnsupported => "directoryLinksUnsupported", Self::Unavailable => "unavailable", } } @@ -235,9 +237,6 @@ pub enum AppError { #[error("Direct download failed: {reason:?}")] DirectDownloadFailed { reason: DirectDownloadFailureReason }, - #[error("Direct download sources only support new installs")] - DirectDownloadUnsupportedOperation, - #[error("Direct download cannot replace an existing target: {target}")] DirectDownloadConflict { target: String }, @@ -320,7 +319,10 @@ pub enum AppError { WslOutputLimitExceeded { stream: String, limit: u32 }, #[error("WSL command failed with exit code {exit_code:?}: {stderr}")] - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + #[allow( + dead_code, + reason = "保留已发布的 AppError IPC 形状;WSL 业务 shell 已由 typed Worker request 替代" + )] WslCommandFailed { #[serde(rename = "exitCode")] exit_code: Option, @@ -342,6 +344,9 @@ pub enum AppError { #[error("Native project migration failed: {message}")] ProjectMigrationFailed { message: String }, + #[error("The Environment home directory cannot be added as a Project")] + ProjectMatchesEnvironmentHome, + #[error("{target}")] LockConflict { target: LockConflictTarget }, @@ -441,6 +446,9 @@ pub enum AppError { #[error("Configuration is corrupted: {message}")] ConfigurationCorrupted { message: String }, + #[error("Configuration write could not be confirmed; reload the document before continuing")] + ConfigurationWriteUnconfirmed, + #[error( "Agent runtime changed before mutation (registry {expected_registry_revision} -> {actual_registry_revision}, environment {expected_environment_revision} -> {actual_environment_revision})" )] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e8a4e8c..e4cf30a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,12 @@ mod git_fixture; #[cfg(test)] #[path = "test_support/native_workflow.rs"] mod native_workflow_integration_support; +#[cfg(test)] +#[path = "test_support/payload_storage.rs"] +mod payload_storage_test_support; +#[cfg(all(test, target_os = "windows"))] +#[path = "test_support/wsl_update_workflow.rs"] +mod wsl_update_workflow; fn specta_builder() -> Builder { Builder::::new() @@ -107,15 +113,28 @@ pub fn run() { .build(), ) .invoke_handler(builder.invoke_handler()) + .on_window_event(|window, event| { + if matches!(event, tauri::WindowEvent::Destroyed) { + if let Some(runtime) = window.try_state::() { + runtime.update_preparations().close_window(window.label()); + } + } + }) .setup(move |app| { let payload_cache_root = app.path().app_cache_dir()?.join("payload-sessions"); let recovery_root = app.path().app_local_data_dir()?.join("recovery"); let library_root = crate::core::get_skill_library_root()?; + #[cfg(target_os = "windows")] + let worker_artifact_directory = + Some(app.path().resource_dir()?.join("wsl-worker/current")); + #[cfg(not(target_os = "windows"))] + let worker_artifact_directory = None; let runtime = RuntimeServiceGraph::new( &payload_cache_root, recovery_root, library_root, agent_registry.clone(), + worker_artifact_directory, )?; let environments = runtime.wsl_arc(); let maintenance = runtime.maintenance().clone(); diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 752676a4..55349794 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -64,6 +64,67 @@ pub struct NetworkProxySettings { pub wsl_git: std::collections::BTreeMap, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[specta(tag = "kind", rename_all = "camelCase")] +pub enum ProxySettingsTarget { + All, + Http, + NativeGit, + WslGit { distro: Option }, +} + +impl ProxySettingsTarget { + pub(crate) fn includes(&self, target: &Self) -> bool { + self == &Self::All + || self == target + || matches!( + (self, target), + (Self::WslGit { distro: None }, Self::WslGit { .. }) + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum ProxySettingsIssueCode { + InvalidFormat, + UnsupportedFormat, + InvalidProxyUrl, + UnreadableDocument, + InvalidDocument, + WriteUnconfirmed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct ProxySettingsIssue { + pub target: ProxySettingsTarget, + pub code: ProxySettingsIssueCode, + pub using_previous: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct ProxySettingsSnapshot { + pub settings: NetworkProxySettings, + pub issues: Vec, + pub config_path: Option, + pub repairable: bool, +} + +impl ProxySettingsSnapshot { + pub(crate) fn is_available(&self, target: &ProxySettingsTarget) -> bool { + !self.issues.iter().any(|issue| { + !issue.using_previous + && (issue.target.includes(target) || target.includes(&issue.target)) + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProxySettingsValidationError { code: &'static str, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 856392fc..adbcec05 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -3,7 +3,8 @@ mod install; mod source; pub use config::{ - GitProxyScope, NativeGitProxySettings, NetworkProxySettings, ProxyMode, SkillDeckConfig, + GitProxyScope, NativeGitProxySettings, NetworkProxySettings, ProxyMode, ProxySettingsIssue, + ProxySettingsIssueCode, ProxySettingsSnapshot, ProxySettingsTarget, SkillDeckConfig, WslGitProxySettings, }; pub use install::*; diff --git a/src-tauri/src/runtime/application_updater.rs b/src-tauri/src/runtime/application_updater.rs index bd1b8a35..afdb43de 100644 --- a/src-tauri/src/runtime/application_updater.rs +++ b/src-tauri/src/runtime/application_updater.rs @@ -26,7 +26,7 @@ impl TauriApplicationUpdater { &self, timeout: Duration, ) -> Result<(tauri_plugin_updater::Updater, &'static str), AppError> { - let proxy_url = self.settings.proxy_url().map_err(updater_error)?; + let proxy_url = self.settings.proxy_url()?; let builder = self.app.updater_builder().timeout(timeout); let builder = match &proxy_url { None => builder.no_proxy(), @@ -177,6 +177,21 @@ fn updater_error(error: impl std::fmt::Display) -> AppError { #[cfg(test)] mod tests { + #[tokio::test] + async fn invalid_proxy_configuration_stops_the_updater_before_connecting() { + let app = test_app(&[url::Url::parse("http://127.0.0.1:9/latest.json").unwrap()]); + let updater = adapter_with_settings( + &app, + NetworkProxySettings { + mode: ProxyMode::Custom, + custom_proxy_url: None, + ..Default::default() + }, + ); + let result = updater.check(test_limits()).await; + assert!(matches!(result, Err(AppError::InvalidProxySettings { .. }))); + } + use std::sync::Mutex; use std::thread; diff --git a/src-tauri/src/runtime/copy_service.rs b/src-tauri/src/runtime/copy_service.rs index 194bba16..f985aba2 100644 --- a/src-tauri/src/runtime/copy_service.rs +++ b/src-tauri/src/runtime/copy_service.rs @@ -12,10 +12,13 @@ use crate::application::payload_session::PayloadSessionManager; use crate::application::planning_facts::ScopePlanningSnapshot; use crate::environment::path_mapping::{windows_storage_owner, WindowsStorageOwner}; use crate::environment::planning::{ - resolve_native_targets, ResolvedTargetFact, RuntimeTargetFactResolver, + resolve_native_targets, ResolvedTargetFact, RuntimeTargetFactResolver, TargetEntryKind, + TargetFactResolver, }; -use crate::environment::types::{EnvironmentRef, ResourceLocator, StorageAccess}; -use crate::environment::wsl::operations::path::map_storage_path_to_host; +use crate::environment::types::{ + same_environment_identity, EnvironmentRef, ResourceLocator, StorageAccess, +}; +use crate::environment::wsl::operations::projection::project_targets; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::runtime::plan_runner::{RuntimeExecutionDependencies, RuntimePlanExecutor}; @@ -31,7 +34,7 @@ impl RuntimeCopyProjectComparator { Self { environments } } - async fn resolve_project_to_native( + async fn resolve_project( &self, facts: &ScopePlanningSnapshot, ) -> Result { @@ -40,18 +43,30 @@ impl RuntimeCopyProjectComparator { .project .as_ref() .ok_or(AppError::StaleContext)?; - let native_path = match &facts.resolved_context.context.environment { - EnvironmentRef::Native => project.native_path.clone(), + let environment = &facts.resolved_context.context.environment; + let location = match environment { + EnvironmentRef::Native => native_project_location(Path::new(&project.native_path))?, + EnvironmentRef::Wsl { .. } => ResourceLocator { + environment: environment.clone(), + native_path: project.native_path.clone(), + }, + }; + let mut fact = match &location.environment { + EnvironmentRef::Native => resolve_native_targets(&[location])? + .pop() + .ok_or(AppError::StaleTarget)?, EnvironmentRef::Wsl { distro_name } => { - self.map_wsl_project_to_host(distro_name, &project.native_path) + self.resolve_wsl_project(distro_name, &location.native_path) .await? } }; - let mut resolved = resolve_native_targets(&[ResourceLocator { - environment: EnvironmentRef::Native, - native_path, - }])?; - resolved.pop().ok_or(AppError::StaleTarget) + if fact.entry_kind != TargetEntryKind::Directory { + return Err(AppError::StaleTarget); + } + if !same_environment_identity(environment, &fact.destination.environment) { + fact.storage_access = StorageAccess::CrossStorage; + } + Ok(fact) } async fn compare_runtime( @@ -59,50 +74,67 @@ impl RuntimeCopyProjectComparator { source: &ResolvedTargetFact, target: &ScopePlanningSnapshot, ) -> Result { - let target_project = target - .resolved_context - .project - .as_ref() - .ok_or(AppError::StaleContext)?; - let target_identity = self.resolve_project_to_native(target).await?; + let target_identity = self.resolve_project(target).await?; let physical_identity = compare_resolved_projects(source, &target_identity)?; Ok(ProjectComparison { physical_identity, - target_storage_access: self - .storage_access( - &target.resolved_context.context.environment, - &target_project.native_path, - ) - .await, + target_storage_access: target_identity.storage_access, }) } - async fn map_wsl_project_to_host( + async fn resolve_wsl_project( &self, distro_name: &str, native_path: &str, - ) -> Result { - let native_path = native_path.to_string(); - self.environments - .with_session_retry(distro_name, move |session| { - let native_path = native_path.clone(); - async move { map_storage_path_to_host(&session, &native_path, None).await } - }) - .await - } - - async fn storage_access(&self, environment: &EnvironmentRef, path: &str) -> StorageAccess { - match environment { - EnvironmentRef::Native => native_storage_access(path), - EnvironmentRef::Wsl { distro_name } => match self - .map_wsl_project_to_host(distro_name, path) - .await - .map(|host| wsl_storage_access(distro_name, &host)) - { - Ok(access) => access, - Err(_) => StorageAccess::Unsupported, - }, + ) -> Result { + let workspace = self.environments.workspace(distro_name)?; + // 投影解析父目录链接;此子路径只用于观察项目根,不创建或写入。 + let probe = format!( + "{}/.skill-deck-project-identity", + native_path.trim_end_matches('/') + ); + let projected = project_targets(&workspace, &[probe], None) + .await? + .pop() + .ok_or(AppError::StaleTarget)?; + if projected.relative_components.len() != 1 { + return Err(AppError::StaleTarget); } + let environment = EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }; + let location = match windows_storage_owner(&projected.storage_projection) { + WindowsStorageOwner::Windows => { + native_project_location(Path::new(&projected.storage_projection))? + } + WindowsStorageOwner::Wsl { distro_name: owner } + if owner.eq_ignore_ascii_case(distro_name) => + { + let (root, _) = projected + .physical_destination + .rsplit_once('/') + .ok_or(AppError::StaleTarget)?; + ResourceLocator { + environment: environment.clone(), + native_path: if root.is_empty() { + "/".to_string() + } else { + root.to_string() + }, + } + } + _ => { + return Err(AppError::StorageMappingUnsupported { + path: native_path.to_string(), + environment, + }) + } + }; + RuntimeTargetFactResolver::new(self.environments.clone()) + .resolve_environment(&location.environment, std::slice::from_ref(&location), None) + .await? + .pop() + .ok_or(AppError::StaleTarget) } } @@ -111,7 +143,7 @@ impl CopyProjectComparator for RuntimeCopyProjectComparator { &'a self, source: &'a ScopePlanningSnapshot, ) -> CopyFuture<'a, Result> { - Box::pin(async move { self.resolve_project_to_native(source).await }) + Box::pin(async move { self.resolve_project(source).await }) } fn compare<'a>( @@ -123,32 +155,56 @@ impl CopyProjectComparator for RuntimeCopyProjectComparator { } } -fn native_storage_access(path: &str) -> StorageAccess { - if !cfg!(target_os = "windows") { - return if Path::new(path).is_absolute() { - StorageAccess::Native - } else { - StorageAccess::Unknown - }; - } - match windows_storage_owner(path) { - WindowsStorageOwner::Windows => StorageAccess::Native, - WindowsStorageOwner::Wsl { .. } => StorageAccess::CrossStorage, - WindowsStorageOwner::Unknown => StorageAccess::Unknown, +fn native_project_location(path: &Path) -> Result { + let physical = std::fs::canonicalize(path)?; + #[cfg(windows)] + if let Some(location) = wsl_project_from_native(&physical) { + return Ok(location); } + Ok(ResourceLocator { + environment: EnvironmentRef::Native, + native_path: physical.to_str().ok_or(AppError::StaleTarget)?.to_string(), + }) } -fn wsl_storage_access(distro_name: &str, host_path: &str) -> StorageAccess { - match windows_storage_owner(host_path) { - WindowsStorageOwner::Windows => StorageAccess::CrossStorage, - WindowsStorageOwner::Wsl { distro_name: owner } - if owner.eq_ignore_ascii_case(distro_name) => - { - StorageAccess::Native +#[cfg(windows)] +fn wsl_project_from_native(path: &Path) -> Option { + use std::path::{Component, Prefix}; + + let mut components = path.components(); + let Component::Prefix(prefix) = components.next()? else { + return None; + }; + let (server, distro) = match prefix.kind() { + Prefix::UNC(server, distro) | Prefix::VerbatimUNC(server, distro) => { + (server.to_str()?, distro.to_str()?) } - WindowsStorageOwner::Wsl { .. } => StorageAccess::CrossStorage, - WindowsStorageOwner::Unknown => StorageAccess::Unsupported, + _ => return None, + }; + if !server.eq_ignore_ascii_case("wsl.localhost") && !server.eq_ignore_ascii_case("wsl$") { + return None; } + let mut native_path = String::new(); + for component in components { + match component { + Component::RootDir => {} + Component::Normal(value) => { + native_path.push('/'); + native_path.push_str(value.to_str()?); + } + _ => return None, + } + } + Some(ResourceLocator { + environment: EnvironmentRef::Wsl { + distro_name: distro.to_string(), + }, + native_path: if native_path.is_empty() { + "/".to_string() + } else { + native_path + }, + }) } pub type RuntimeCopyService = CopyService< @@ -181,23 +237,33 @@ pub fn build_runtime_copy_service( ) } -#[cfg(test)] +#[cfg(all(test, windows))] mod tests { use super::*; #[test] - fn storage_access_is_derived_from_physical_owner_not_execution_environment() { - assert_eq!( - wsl_storage_access("Ubuntu", r"C:\Code\App"), - StorageAccess::CrossStorage - ); - assert_eq!( - wsl_storage_access("Ubuntu", r"\\wsl.localhost\Ubuntu\home\alice\app"), - StorageAccess::Native - ); - assert_eq!( - wsl_storage_access("Ubuntu", r"\\wsl.localhost\Debian\home\alice\app"), - StorageAccess::CrossStorage - ); + fn copy_project_recognizes_wsl_unc_and_verbatim_unc() { + for path in [ + r"\\wsl.localhost\Ubuntu\home\alice\项目", + r"\\wsl$\Ubuntu\home\alice\项目", + r"\\?\UNC\wsl.localhost\Ubuntu\home\alice\项目", + ] { + assert_eq!( + wsl_project_from_native(Path::new(path)), + Some(ResourceLocator { + environment: EnvironmentRef::Wsl { + distro_name: "Ubuntu".into() + }, + native_path: "/home/alice/项目".into(), + }) + ); + } + for path in [ + r"C:\Code\App", + r"\\?\C:\Code\App", + r"\\server\share\project", + ] { + assert!(wsl_project_from_native(Path::new(path)).is_none()); + } } } diff --git a/src-tauri/src/runtime/download.rs b/src-tauri/src/runtime/download.rs index fb8df046..67ed91bc 100644 --- a/src-tauri/src/runtime/download.rs +++ b/src-tauri/src/runtime/download.rs @@ -68,6 +68,7 @@ pub(crate) struct DownloadFetchResult { fn map_network_error(error: HttpTransportError) -> AppError { match error { + HttpTransportError::Settings(error) => error.into(), HttpTransportError::Request { reason: "cancelled", .. @@ -91,6 +92,29 @@ fn map_network_error(error: HttpTransportError) -> AppError { #[cfg(test)] mod tests { + #[tokio::test] + async fn proxy_configuration_failure_is_reported_without_a_network_error() { + let http = crate::runtime::http_transport::HttpTransport::new(std::sync::Arc::new( + crate::runtime::proxy_settings::ProxySettingsStore::new( + crate::models::NetworkProxySettings { + mode: crate::models::ProxyMode::Custom, + custom_proxy_url: None, + ..Default::default() + }, + ), + )); + let result = super::RuntimeDownloadAccess::new(http) + .fetch( + "http://127.0.0.1:9/skill.zip", + &crate::core::mutation::CancellationSignal::default(), + ) + .await; + assert!(matches!( + result, + Err(crate::error::AppError::InvalidProxySettings { .. }) + )); + } + use std::io::Write; use std::sync::Arc; use std::thread; diff --git a/src-tauri/src/runtime/git_source.rs b/src-tauri/src/runtime/git_source.rs index 76a04ce5..26bf7e8d 100644 --- a/src-tauri/src/runtime/git_source.rs +++ b/src-tauri/src/runtime/git_source.rs @@ -4,8 +4,7 @@ use std::time::{Duration, Instant}; use crate::application::git_transport::GitSourceTransport; use crate::core::mutation::CancellationSignal; use crate::core::{ - clone_repo_with_progress_options, probe_remote_ref_revision_options, - resolve_clone_timeout_secs, CloneProgress, CloneResult, + clone_repo_with_progress_options, probe_remote_ref_revision_options, CloneProgress, CloneResult, }; use crate::error::AppError; use crate::runtime::proxy_settings::ProxySettingsStore; @@ -33,7 +32,7 @@ impl ProcessGitTransport { cancellation: CancellationSignal, timeout: Duration, ) -> Result { - let proxy = self.settings.native_git_proxy(url); + let proxy = self.settings.native_git_proxy(url)?; probe_remote_ref_revision_options(url, git_ref, cancellation, proxy.as_deref(), timeout) } } @@ -46,10 +45,10 @@ impl GitSourceTransport for ProcessGitTransport { on_progress: &(dyn Fn(CloneProgress) + Send + Sync), cancellation: CancellationSignal, ) -> Result { - let timeout_secs = resolve_clone_timeout_secs(); + let timeout_secs = self.settings.clone_timeout_secs(); let started_at = Instant::now(); let timeout = Duration::from_secs(timeout_secs); - let proxy = self.settings.native_git_proxy(url); + let proxy = self.settings.native_git_proxy(url)?; clone_repo_with_progress_options( url, git_ref, @@ -75,7 +74,7 @@ impl GitSourceTransport for ProcessGitTransport { url, git_ref, cancellation, - Duration::from_secs(resolve_clone_timeout_secs()), + Duration::from_secs(self.settings.clone_timeout_secs()), ) } } @@ -102,19 +101,22 @@ mod tests { assert_eq!( transport .settings - .native_git_proxy("http://example.com/repo.git"), + .native_git_proxy("http://example.com/repo.git") + .expect("valid proxy settings"), Some("http://proxy.example:7890".to_string()) ); assert_eq!( transport .settings - .native_git_proxy("https://example.com/repo.git"), + .native_git_proxy("https://example.com/repo.git") + .expect("valid proxy settings"), Some("http://proxy.example:7890".to_string()) ); assert_eq!( transport .settings - .native_git_proxy("git@example.com:repo.git"), + .native_git_proxy("git@example.com:repo.git") + .expect("valid proxy settings"), None ); } @@ -129,7 +131,9 @@ mod tests { }); assert_eq!( - existing.native_git_proxy("https://example.com/repo.git"), + existing + .native_git_proxy("https://example.com/repo.git") + .expect("valid proxy settings"), None ); } diff --git a/src-tauri/src/runtime/github_client.rs b/src-tauri/src/runtime/github_client.rs index f1eb61fb..7cf9d5cd 100644 --- a/src-tauri/src/runtime/github_client.rs +++ b/src-tauri/src/runtime/github_client.rs @@ -14,7 +14,9 @@ use crate::core::{ GithubTreeSnapshot, GithubTreeSnapshotEntry, }; use crate::models::{NetworkProxySettings, ProxyMode}; -use crate::runtime::http_transport::{HttpGetRequest, HttpResponse, HttpTransport}; +use crate::runtime::http_transport::{ + HttpGetRequest, HttpResponse, HttpTransport, HttpTransportError, +}; use crate::runtime::proxy_settings::ProxySettingsStore; const RATE_LIMIT_FALLBACK_MS: u64 = 5 * 60 * 1_000; @@ -127,12 +129,12 @@ impl GithubApiClient { let token = self.token_provider.token(); let mut response = match self.send_request(&url, validation, token.as_deref()).await { Ok(response) => response, - Err(_) => return GithubTreeFetchOutcome::Failed(GithubTreeFailure::Network), + Err(error) => return github_transport_failure(error), }; if response.status == reqwest::StatusCode::UNAUTHORIZED && token.is_some() { response = match self.send_request(&url, validation, None).await { Ok(response) => response, - Err(_) => return GithubTreeFetchOutcome::Failed(GithubTreeFailure::Network), + Err(error) => return github_transport_failure(error), }; } let status = response.status; @@ -243,6 +245,13 @@ impl GithubApiClient { } } +fn github_transport_failure(error: HttpTransportError) -> GithubTreeFetchOutcome { + GithubTreeFetchOutcome::Failed(match error { + HttpTransportError::Settings(_) => GithubTreeFailure::ProxyConfiguration, + _ => GithubTreeFailure::Network, + }) +} + fn github_headers() -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert( @@ -354,6 +363,30 @@ fn get_github_env_token() -> Option { #[cfg(test)] mod tests { + #[tokio::test] + async fn proxy_configuration_failure_is_distinct_from_a_github_network_failure() { + let client = super::GithubApiClient { + http: crate::runtime::http_transport::HttpTransport::new(std::sync::Arc::new( + crate::runtime::proxy_settings::ProxySettingsStore::new( + crate::models::NetworkProxySettings { + mode: crate::models::ProxyMode::Custom, + custom_proxy_url: None, + ..Default::default() + }, + ), + )), + api_base: "http://127.0.0.1:9".into(), + token_provider: std::sync::Arc::new(super::EmptyGithubTokenProvider), + }; + let result = client.fetch_tree("owner/repo", "HEAD", None).await; + assert!(matches!( + result, + crate::core::GithubTreeFetchOutcome::Failed( + crate::core::GithubTreeFailure::ProxyConfiguration + ) + )); + } + use std::collections::VecDeque; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/src-tauri/src/runtime/http_transport.rs b/src-tauri/src/runtime/http_transport.rs index 845d566a..7df243b9 100644 --- a/src-tauri/src/runtime/http_transport.rs +++ b/src-tauri/src/runtime/http_transport.rs @@ -140,7 +140,7 @@ impl HttpTransport { let proxy_url = match self.settings.proxy_url() { Ok(proxy_url) => proxy_url, - Err(error) => return self.fail_before_request(&request.operation_id, error.into()), + Err(error) => return Err(error.into()), }; log::debug!( "Network settings read: operation_id={}, proxy={}", diff --git a/src-tauri/src/runtime/maintenance.rs b/src-tauri/src/runtime/maintenance.rs index f8031877..671f1046 100644 --- a/src-tauri/src/runtime/maintenance.rs +++ b/src-tauri/src/runtime/maintenance.rs @@ -5,6 +5,9 @@ use std::sync::{Arc, Mutex}; use tokio::sync::watch; +use crate::application::library_membership::{ + LibraryMembershipModule, MembershipScopeState, RetiredCleanupState, +}; use crate::application::payload_session::{ PayloadCleanupReport, PayloadSessionMaintenance, PayloadSessionManager, }; @@ -147,10 +150,12 @@ struct MaintenanceEntry { pub struct RuntimeMaintenanceCoordinator { payloads: Arc, backend: Arc, + membership: Option>, entries: Mutex>, } impl RuntimeMaintenanceCoordinator { + #[cfg(test)] pub fn new( payloads: Arc, backend: Arc, @@ -158,6 +163,20 @@ impl RuntimeMaintenanceCoordinator { Self { payloads, backend, + membership: None, + entries: Mutex::new(HashMap::new()), + } + } + + pub fn with_membership( + payloads: Arc, + backend: Arc, + membership: Arc, + ) -> Self { + Self { + payloads, + backend, + membership: Some(membership), entries: Mutex::new(HashMap::new()), } } @@ -258,6 +277,47 @@ impl RuntimeMaintenanceCoordinator { }; let outcome = self.backend.run(&environment).await; + if let Some(membership) = &self.membership { + match membership + .resume( + environment.clone(), + None, + crate::core::mutation::CancellationSignal::default(), + ) + .await + { + Ok(result) => { + if let Some(error) = result.snapshot_error { + log::warn!( + "Library membership inventory is incomplete for {environment:?}: {error}" + ); + } + for scope in result.scopes { + if !matches!(scope.state, MembershipScopeState::Synced) { + log::warn!( + "Library membership resume for {:?} remains {:?}: {:?}", + scope.context, + scope.state, + scope.error + ); + } + } + for cleanup in result.cleanup { + if cleanup.state == RetiredCleanupState::Failed { + log::warn!( + "Retired Library member cleanup failed for {}/{}: {:?}", + cleanup.library_id.as_str(), + cleanup.member_name, + cleanup.error + ); + } + } + } + Err(error) => { + log::warn!("Library membership resume failed for {environment:?}: {error}"); + } + } + } let mut issues = Vec::new(); match outcome.payload { Ok(report) => { diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index a4b0a430..cc01b670 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -13,11 +13,13 @@ use crate::application::library_application::LibraryApplicationModule; use crate::application::library_candidates::{ LibraryCandidateSource, RepositoryLibraryCandidateSource, }; +use crate::application::library_membership::LibraryMembershipModule; use crate::application::payload_session::{PayloadSessionLimits, PayloadSessionManager}; use crate::application::runtime_admission::RuntimeAdmissionCoordinator; use crate::application::skill_libraries::{LibraryUsageProvider, SkillLibraryModule}; use crate::application::wellknown_access::WellKnownAccess; use crate::application::wsl_source_access::WslSourceAccess; +use crate::core::app_config::ConfigStore; use crate::core::projects::ProjectMigrationRegistry; use crate::core::GithubTokenProvider; use crate::environment::native::acquire::NativePayloadSessionStorage; @@ -83,8 +85,8 @@ struct RuntimeNetworkServices { } impl RuntimeNetworkServices { - fn new(settings: crate::models::NetworkProxySettings) -> Self { - let proxy_settings = Arc::new(ProxySettingsStore::new(settings)); + fn new(config: Arc) -> Self { + let proxy_settings = Arc::new(ProxySettingsStore::from_config(config)); let http = HttpTransport::new(proxy_settings.clone()); let discovery = DiscoveryGateway::new(http.clone()); let wellknown = Arc::new(wellknown::RuntimeWellKnownAccess::new(http.clone())); @@ -116,6 +118,7 @@ impl RuntimeNetworkServices { } pub struct RuntimeServiceGraph { + config: Arc, wsl: Arc, agents: ManagedAgentRegistry, projects: Arc, @@ -129,6 +132,7 @@ pub struct RuntimeServiceGraph { library_update_check: crate::runtime::update_service::RuntimeLibraryUpdateCheckService, library_update: RuntimeLibraryUpdateService, update: RuntimeUpdateService, + update_preparations: crate::application::update_preparation::UpdatePreparations, remove: RuntimeRemoveService, manage_agents: RuntimeManageAgentsService, copy: RuntimeCopyService, @@ -147,6 +151,7 @@ pub struct RuntimeServiceGraph { RuntimePlanExecutor, >, >, + library_membership: Arc, connection_probe: network_connection::RuntimeNetworkConnectionProbe, } @@ -156,16 +161,19 @@ impl RuntimeServiceGraph { recovery_root: std::path::PathBuf, library_root: std::path::PathBuf, agents: ManagedAgentRegistry, + worker_artifact_directory: Option, ) -> Result { - let config = crate::core::read_config()?; - let wsl_integration_enabled = cfg!(target_os = "windows") && config.wsl_integration_enabled; - let network_services = RuntimeNetworkServices::new(config.network_proxy); + let config = Arc::new(ConfigStore::open(crate::core::get_config_path()?)); + let wsl_integration_enabled = + cfg!(target_os = "windows") && config.config().wsl_integration_enabled; + let network_services = RuntimeNetworkServices::new(config.clone()); let http = network_services.http_client(); let download = download::RuntimeDownloadAccess::new(http.clone()); let git_source = network_services.git_source(); - let wsl = Arc::new(WslRuntime::new_with_support( + let wsl = Arc::new(WslRuntime::new_with_worker_artifact_directory( cfg!(target_os = "windows"), wsl_integration_enabled, + worker_artifact_directory, )); let connection_probe = network_connection::RuntimeNetworkConnectionProbe::new(wsl.clone()); let (payloads, native_payload_storage) = build_payload_session_manager(payload_cache_root)?; @@ -220,10 +228,6 @@ impl RuntimeServiceGraph { wsl.clone(), admission.clone(), )); - let maintenance = Arc::new(RuntimeMaintenanceCoordinator::new( - payloads.clone(), - maintenance_backend, - )); let update_evidence = build_runtime_source_evidence_coordinator( payloads.clone(), source_snapshots.clone(), @@ -238,14 +242,28 @@ impl RuntimeServiceGraph { let library_update_check = crate::runtime::update_service::build_runtime_library_update_check_service( library_repository.clone(), - agent_selection_targets.clone(), update_evidence.clone(), ); - let library_application = Arc::new(LibraryApplicationModule::new( + let library_application = Arc::new(LibraryApplicationModule::with_recovery_status( library_repository.clone(), agent_selection_facts.clone(), agent_selection_targets.clone(), execution.executor(wsl.clone(), Arc::new(agent_selection_facts.clone())), + Arc::new(execution.recovery_service()), + )); + let library_membership = Arc::new(LibraryMembershipModule::new( + library_repository.clone(), + library_application.clone(), + library_repository.clone(), + admission.clone(), + skill_libraries.clone(), + payloads.clone(), + Arc::new(agent_selection_targets.clone()), + )); + let maintenance = Arc::new(RuntimeMaintenanceCoordinator::with_membership( + payloads.clone(), + maintenance_backend, + library_membership.clone(), )); let library_candidates: Arc = Arc::new(RepositoryLibraryCandidateSource::new( @@ -264,6 +282,7 @@ impl RuntimeServiceGraph { wsl.clone(), registry.clone(), update_evidence.clone(), + library_repository.clone(), ); let skill_source = RuntimeSkillSourceModule::new( payloads.clone(), @@ -275,7 +294,7 @@ impl RuntimeServiceGraph { ); let library_update = build_runtime_library_update_service( payloads.clone(), - library_repository, + library_repository.clone(), agent_selection_targets.clone(), skill_source.clone(), skill_libraries.clone(), @@ -286,6 +305,7 @@ impl RuntimeServiceGraph { registry.clone(), execution.clone(), skill_source, + library_repository.clone(), ); let remove = build_runtime_remove_service( wsl.clone(), @@ -300,7 +320,8 @@ impl RuntimeServiceGraph { execution.clone(), library_candidates.clone(), ); - let resources = build_runtime_resource_service(wsl.clone(), registry.clone()); + let resources = + build_runtime_resource_service(wsl.clone(), registry.clone(), library_repository); let copy = build_runtime_copy_service( payloads.clone(), wsl.clone(), @@ -316,6 +337,7 @@ impl RuntimeServiceGraph { }), ); Ok(Self { + config, wsl, agents, projects, @@ -329,6 +351,7 @@ impl RuntimeServiceGraph { library_update_check, library_update, update, + update_preparations: Default::default(), remove, manage_agents, copy, @@ -341,6 +364,7 @@ impl RuntimeServiceGraph { skill_libraries, library_usages, library_application, + library_membership, connection_probe, }) } @@ -365,6 +389,12 @@ impl RuntimeServiceGraph { self.admission.as_ref() } + pub fn update_preparations( + &self, + ) -> &crate::application::update_preparation::UpdatePreparations { + &self.update_preparations + } + pub fn install_wizard(&self) -> &Arc { &self.install_wizard } @@ -431,10 +461,8 @@ impl RuntimeServiceGraph { &self.agent_selection_targets } - pub(crate) fn activate_network_settings(&self, settings: crate::models::NetworkProxySettings) { - self.network_services - .proxy_settings - .replace_settings(settings); + pub(crate) fn config(&self) -> &ConfigStore { + &self.config } pub(crate) fn source_discovery(&self) -> &SourceDiscoveryService { @@ -459,6 +487,10 @@ impl RuntimeServiceGraph { self.library_application.as_ref() } + pub(crate) fn library_membership(&self) -> &LibraryMembershipModule { + self.library_membership.as_ref() + } + pub(crate) fn connection_probe(&self) -> &network_connection::RuntimeNetworkConnectionProbe { &self.connection_probe } @@ -505,11 +537,14 @@ fn build_payload_session_manager( #[cfg(test)] mod tests { - use super::RuntimeNetworkServices; + use super::{ConfigStore, RuntimeNetworkServices}; + use std::sync::Arc; #[test] fn runtime_network_services_inject_one_shared_http_pool() { - let services = RuntimeNetworkServices::new(crate::models::NetworkProxySettings::default()); + let services = RuntimeNetworkServices::new(Arc::new(ConfigStore::from_proxy_settings( + Default::default(), + ))); let discovery_http = services.http_client(); let source_http = services.http_client(); diff --git a/src-tauri/src/runtime/network_connection.rs b/src-tauri/src/runtime/network_connection.rs index 13edb36b..2485c608 100644 --- a/src-tauri/src/runtime/network_connection.rs +++ b/src-tauri/src/runtime/network_connection.rs @@ -138,9 +138,15 @@ async fn test_wsl_git( ) -> ProxyConnectionProbe { let started_at = Instant::now(); let deadline = started_at + TEST_TIMEOUT; - let probe = wsl.with_session(&distro, |session| { + let workspace = match wsl.workspace(&distro) { + Ok(workspace) => workspace, + Err(error) => return git_probe(started_at, Err(error)), + }; + let probe = wsl.with_session(&distro, |_session| { let proxy = settings.wsl_git_proxy(&distro, TEST_GIT_URL); + let workspace = workspace.clone(); async move { + let proxy = proxy?; let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return Err(AppError::GitTimeout { @@ -148,7 +154,7 @@ async fn test_wsl_git( }); } probe_wsl_git_connection( - &session, + &workspace, TEST_GIT_URL, proxy, remaining, diff --git a/src-tauri/src/runtime/plan_runner.rs b/src-tauri/src/runtime/plan_runner.rs index f29b80de..3e6c425c 100644 --- a/src-tauri/src/runtime/plan_runner.rs +++ b/src-tauri/src/runtime/plan_runner.rs @@ -9,50 +9,64 @@ use crate::application::mutation::plan::{MutationPlan, RuntimeRevisions}; use crate::application::mutation::result::{ErrorReport, MutationUnitResult, MutationUnitStatus}; use crate::core::mutation::CancellationSignal; use crate::environment::native::atomic_file::NativeAtomicDocumentIo; -use crate::environment::native::materialize::NativePreparedEntryExecutor; +use crate::environment::native::materialize::{ + NativePreparedEntryExecutor, NativePreparedUnitExecutor, +}; use crate::environment::recovery::RecoveryMarkerStore; use crate::environment::runtime::ExecutionBackend; use crate::environment::types::{ normalized_wsl_distro_name, same_environment_identity, EnvironmentKey, EnvironmentRef, SkillLocationRef, }; -use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::operations::materialize::WslPreparedEntryExecutor; +use crate::environment::wsl::operations::materialize::WslPreparedUnitExecutor; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::runtime::recovery::{RuntimeRecoveryGraph, RuntimeRecoveryService}; +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentWriteFailure}; use crate::storage::lock_plan::{LockCommitReceipt, LockPlanCommitter, PreparedLockMutation}; -pub struct RuntimeLockCommitter { - environments: Arc, +pub struct RuntimeLockCommitter { + io: Arc, +} + +impl Default for RuntimeLockCommitter { + fn default() -> Self { + Self::new() + } } impl RuntimeLockCommitter { - pub fn new(environments: Arc) -> Self { - Self { environments } + pub fn new() -> Self { + Self { + io: Arc::new(NativeAtomicDocumentIo), + } } } -impl PreparedLockCommitter for RuntimeLockCommitter { +impl RuntimeLockCommitter { + #[cfg(test)] + pub(crate) fn with_io(io: Arc) -> Self { + Self { io } + } +} + +impl PreparedLockCommitter for RuntimeLockCommitter +where + I: AtomicDocumentIo, +{ fn commit<'a>( &'a self, mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { - match &mutation.target.environment { - EnvironmentRef::Native => { - LockPlanCommitter::new(Arc::new(NativeAtomicDocumentIo)) - .commit(mutation.clone()) - .await - } - EnvironmentRef::Wsl { distro_name } => { - let mutation = mutation.clone(); - let workspace = self.environments.workspace(distro_name)?; - LockPlanCommitter::new(Arc::new(WslAtomicDocumentIo::new(workspace))) - .commit(mutation) - .await - } + if mutation.target.environment != EnvironmentRef::Native { + return Err(DocumentWriteFailure::not_published( + AppError::StaleEnvironment, + )); } + LockPlanCommitter::new(Arc::clone(&self.io)) + .commit(mutation.clone()) + .await }) } } @@ -81,7 +95,7 @@ impl RuntimeExecutionDependencies { environments.clone(), recovery_root, )?), - locks: Arc::new(RuntimeLockCommitter::new(environments)), + locks: Arc::new(RuntimeLockCommitter::new()), }) } @@ -162,8 +176,7 @@ impl RuntimePlanExecutor { Arc::clone(&self.native_recovery), ); MutationCoordinator::new( - entries, - SharedLocks(Arc::clone(&self.locks)), + NativePreparedUnitExecutor::new(entries, SharedLocks(Arc::clone(&self.locks))), SharedRevisions(Arc::clone(&self.revisions)), ) .execute_with_observer(plan, cancellation, observer) @@ -182,7 +195,6 @@ impl RuntimePlanExecutor { } let failure_units = failure_units(&plan); let plan = Arc::new(Mutex::new(Some(plan))); - let locks = Arc::clone(&self.locks); let revisions = Arc::clone(&self.revisions); let recovery_graph = self.recovery_graph.clone(); let cancellation_for_run = cancellation.clone(); @@ -192,7 +204,6 @@ impl RuntimePlanExecutor { .with_session(&distro_name, move |session| { let workspace = workspace.clone(); let plan = plan.lock().expect("WSL plan handoff lock poisoned").take(); - let locks = Arc::clone(&locks); let revisions = Arc::clone(&revisions); let recovery_graph = recovery_graph.clone(); let cancellation = cancellation_for_run.clone(); @@ -202,29 +213,19 @@ impl RuntimePlanExecutor { message: "WSL mutation plan was consumed more than once" .to_string(), })?; - let entries = match &recovery_graph { - Some(graph) => { - let store = graph.active_wsl_store(session.clone())?; - WslPreparedEntryExecutor::with_recovery_store_for_operation( - session, - plan.operation_id.clone(), - plan.kind, - store, - ) - } - None => WslPreparedEntryExecutor::for_operation( - session, - plan.operation_id.clone(), - plan.kind, - ), - }; - let results = MutationCoordinator::new( - entries, - SharedLocks(locks), - SharedRevisions(revisions), - ) - .execute_with_observer(plan, cancellation, observer) - .await; + if let Some(graph) = &recovery_graph { + let _ = graph.active_wsl_store(workspace.clone())?; + } + let units = WslPreparedUnitExecutor::for_operation( + session, + workspace.clone(), + plan.operation_id.clone(), + plan.kind, + ); + let results = + MutationCoordinator::new(units, SharedRevisions(revisions)) + .execute_with_observer(plan, cancellation, observer) + .await; if let Some(graph) = recovery_graph { graph.wsl_store(workspace)?; } @@ -266,7 +267,7 @@ impl PreparedLockCommitter for SharedLocks { fn commit<'a>( &'a self, mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { self.0.commit(mutation) } } @@ -427,7 +428,7 @@ mod tests { fn commit<'a>( &'a self, _mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async { panic!("test plan has no lock mutation") }) } } @@ -580,7 +581,7 @@ mod tests { root_replacements: BTreeMap::new(), expected: LockExpectedState::capture(&document, ["demo"], std::iter::empty::<&str>()), }; - let committer = RuntimeLockCommitter::new(Arc::new(WslRuntime::default())); + let committer = RuntimeLockCommitter::new(); committer.commit(&mutation).await.unwrap(); diff --git a/src-tauri/src/runtime/planning_facts.rs b/src-tauri/src/runtime/planning_facts.rs index 708cd4b5..5baf458b 100644 --- a/src-tauri/src/runtime/planning_facts.rs +++ b/src-tauri/src/runtime/planning_facts.rs @@ -17,6 +17,7 @@ use crate::core::agent_registry::AgentRegistrySnapshot; use crate::core::projects::{ProjectPathSemantics, ProjectsFile}; use crate::core::skill_lock; use crate::core::{get_config_path, paths::PATHS}; +use crate::environment::agent_environment::inspect_eve_project; use crate::environment::agent_environment::{AgentEnvironmentResolver, EnvironmentContext}; use crate::environment::context_resolver::ContextResolver; use crate::environment::context_resolver::ResolvedContext; @@ -30,7 +31,6 @@ use crate::environment::types::{ EnvironmentRef, EnvironmentStatus, ResourceLocator, SkillLocation, SkillLocationRef, }; use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::operations::eve::inspect_eve_project; use crate::environment::wsl::{WslRuntime, WslSession}; use crate::error::AppError; use crate::models::InstallTargetInfo; @@ -105,7 +105,7 @@ impl RuntimePlanningFactSource { let registry = Arc::clone(®istry); let workspace = self.environments.workspace(distro_name)?; self.environments - .with_session_retry(distro_name, move |session| { + .with_session_read_retry(distro_name, move |session| { let context = context.clone(); let registry = Arc::clone(®istry); let workspace = workspace.clone(); @@ -251,12 +251,13 @@ async fn capture_wsl_base( session: WslSession, workspace: crate::environment::wsl::WslWorkspace, ) -> Result { - let io = WslAtomicDocumentIo::from_active_session(session.clone()); + let io = WslAtomicDocumentIo::from_active_session(session.clone(), workspace.clone()); let (resolved, project_schema_version) = resolve_wsl_context_from_io(&io, context, &session).await?; - let environment = wsl_environment_context(&resolved, session.clone(), workspace); + let environment = wsl_environment_context(&resolved, session.clone(), workspace.clone()); let targets = resolve_wsl_targets( &session, + &workspace, &[ resolved.skill_root.native_path.clone(), resolved.lock.native_path.clone(), @@ -266,12 +267,8 @@ async fn capture_wsl_base( .await?; let eve_targets = match resolved.project.as_ref() { Some(project) => { - let snapshot = inspect_eve_project(&session, &project.native_path).await?; - if snapshot.has_eve { - crate::core::eve::eve_install_targets(&project.native_path, snapshot.subagents) - } else { - Vec::new() - } + let snapshot = inspect_eve_project(&workspace, &project.native_path).await?; + snapshot.install_targets(&project.native_path) } None => Vec::new(), }; @@ -481,7 +478,11 @@ where I: AtomicDocumentIo + ?Sized, { let current = ProjectsFile::new(Vec::new(), semantics); - let Some(bytes) = io.read_optional(target).await? else { + let Some(bytes) = io + .observe(target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await? + .bytes + else { return Ok(current); }; let parsed: ProjectsFile = serde_json::from_slice(&bytes)?; @@ -572,7 +573,9 @@ mod tests { use crate::core::agent_registry::AgentRegistrySnapshot; use crate::environment::types::{EnvironmentRef, SkillLocation, SkillLocationRef}; use crate::environment::wsl::{WslRuntime, WslSession}; - use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; + use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentCommitReceipt, DocumentSnapshot, DocumentWriteFailure, IoFuture, + }; struct StaticRegistry(Arc); @@ -588,21 +591,34 @@ mod tests { } impl AtomicDocumentIo for RecordingDocumentIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a crate::environment::types::ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { + _max_bytes: u64, + ) -> IoFuture<'a, Result> { Box::pin(async move { self.reads.lock().unwrap().push(target.native_path.clone()); - Ok(Some(self.bytes.clone())) + Ok(DocumentSnapshot { + bytes: Some(self.bytes.clone()), + generation: None, + }) }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, _target: &'a crate::environment::types::ResourceLocator, + _expected: DocumentSnapshot, _bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { + ) -> IoFuture<'a, Result> { + Box::pin(async { panic!("context capture is read-only") }) + } + + fn remove<'a>( + &'a self, + _target: &'a crate::environment::types::ResourceLocator, + _expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { Box::pin(async { panic!("context capture is read-only") }) } } diff --git a/src-tauri/src/runtime/proxy_settings.rs b/src-tauri/src/runtime/proxy_settings.rs index 9c66695b..a2920350 100644 --- a/src-tauri/src/runtime/proxy_settings.rs +++ b/src-tauri/src/runtime/proxy_settings.rs @@ -1,73 +1,125 @@ -use std::sync::RwLock; +use std::sync::Arc; +use crate::core::app_config::ConfigStore; +use crate::error::AppError; use crate::models::{ - GitProxyScope, NativeGitProxySettings, NetworkProxySettings, ProxyMode, WslGitProxySettings, + GitProxyScope, NativeGitProxySettings, NetworkProxySettings, ProxyMode, ProxySettingsSnapshot, + ProxySettingsTarget, WslGitProxySettings, }; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub(crate) enum ProxySettingsError { - #[error("invalid proxy settings")] + #[error("proxy configuration must be repaired before this operation can connect")] InvalidProxySettings, } +impl From for AppError { + fn from(_: ProxySettingsError) -> Self { + Self::InvalidProxySettings { + code: "configurationUnavailable".into(), + } + } +} + pub(crate) struct ProxySettingsStore { - settings: RwLock, + config: Arc, } impl ProxySettingsStore { + /// Isolated settings for a draft connection test or a transport fixture. pub(crate) fn new(settings: NetworkProxySettings) -> Self { - Self { - settings: RwLock::new(settings), - } + Self::from_config(Arc::new(ConfigStore::from_proxy_settings(settings))) + } + + pub(crate) fn from_config(config: Arc) -> Self { + Self { config } } + #[cfg(test)] pub(crate) fn replace_settings(&self, settings: NetworkProxySettings) { - *self - .settings - .write() - .expect("network settings lock poisoned") = settings; + self.config + .save_proxy(settings) + .expect("valid test settings"); + } + + pub(crate) fn clone_timeout_secs(&self) -> u64 { + crate::core::git::normalize_clone_timeout_secs(self.config.git_clone_timeout_secs().into()) } pub(crate) fn proxy_url(&self) -> Result, ProxySettingsError> { - let settings = self - .settings - .read() - .expect("network settings lock poisoned"); - match settings.mode { + let snapshot = self.config.proxy_snapshot(); + require_available(&snapshot, &ProxySettingsTarget::Http)?; + match snapshot.settings.mode { ProxyMode::Direct => Ok(None), - ProxyMode::Custom => settings + ProxyMode::Custom => snapshot + .settings .custom_proxy_url - .clone() .map(Some) .ok_or(ProxySettingsError::InvalidProxySettings), } } - pub(crate) fn native_git_proxy(&self, target: &str) -> Option { - let settings = self - .settings - .read() - .expect("network settings lock poisoned"); - native_git_proxy_for_target(&settings.native_git, target) + pub(crate) fn native_git_proxy( + &self, + target: &str, + ) -> Result, ProxySettingsError> { + if !is_http_remote(target) { + return Ok(None); + } + select_native_proxy(&self.config.proxy_snapshot(), target) } - pub(crate) fn wsl_git_proxy(&self, distro: &str, target: &str) -> Option { - let settings = self - .settings - .read() - .expect("network settings lock poisoned"); - match settings.wsl_git.get(distro) { - Some(WslGitProxySettings::FollowNativeGit) => { - native_git_proxy_for_target(&settings.native_git, target) - } - None | Some(WslGitProxySettings::UseExistingGitConfig) => None, + pub(crate) fn wsl_git_proxy( + &self, + distro: &str, + target: &str, + ) -> Result, ProxySettingsError> { + if !is_http_remote(target) { + return Ok(None); + } + let snapshot = self.config.proxy_snapshot(); + require_available( + &snapshot, + &ProxySettingsTarget::WslGit { + distro: Some(distro.to_string()), + }, + )?; + match snapshot.settings.wsl_git.get(distro) { + Some(WslGitProxySettings::FollowNativeGit) => select_native_proxy(&snapshot, target), + None | Some(WslGitProxySettings::UseExistingGitConfig) => Ok(None), Some(WslGitProxySettings::UseProxy { proxy_url, scope }) => { - git_proxy_for_target(proxy_url, *scope, target) + Ok(git_proxy_for_target(proxy_url, *scope, target)) } } } } +fn require_available( + snapshot: &ProxySettingsSnapshot, + target: &ProxySettingsTarget, +) -> Result<(), ProxySettingsError> { + if snapshot.is_available(target) { + Ok(()) + } else { + Err(ProxySettingsError::InvalidProxySettings) + } +} + +fn select_native_proxy( + snapshot: &ProxySettingsSnapshot, + target: &str, +) -> Result, ProxySettingsError> { + require_available(snapshot, &ProxySettingsTarget::NativeGit)?; + Ok(native_git_proxy_for_target( + &snapshot.settings.native_git, + target, + )) +} + +fn is_http_remote(target: &str) -> bool { + url::Url::parse(target).is_ok_and(|target| matches!(target.scheme(), "http" | "https")) +} + fn native_git_proxy_for_target(settings: &NativeGitProxySettings, target: &str) -> Option { match settings { NativeGitProxySettings::UseExistingGitConfig => None, @@ -97,12 +149,89 @@ fn git_proxy_for_target(proxy_url: &str, scope: GitProxyScope, target: &str) -> #[cfg(test)] mod tests { + use crate::core::app_config::ConfigStore; use crate::models::{ GitProxyScope, NativeGitProxySettings, NetworkProxySettings, ProxyMode, WslGitProxySettings, }; + use std::sync::Arc; use super::ProxySettingsStore; + #[test] + fn invalid_native_git_blocks_affected_remotes_without_disabling_http_or_ssh() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.json"); + std::fs::write(&path, br#"{"networkProxy":{"mode":"custom","customProxyUrl":"http://127.0.0.1:7890","nativeGit":"useProxy"}}"#).unwrap(); + let settings = ProxySettingsStore::from_config(Arc::new(ConfigStore::open(path))); + + assert_eq!( + settings.proxy_url().unwrap().as_deref(), + Some("http://127.0.0.1:7890") + ); + assert!(settings + .native_git_proxy("https://github.com/owner/repo") + .is_err()); + assert_eq!( + settings + .native_git_proxy("git@github.com:owner/repo.git") + .unwrap(), + None + ); + } + + #[test] + fn wsl_proxy_errors_follow_the_actual_configuration_dependencies() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.json"); + std::fs::write(&path, br#"{"networkProxy":{"mode":"direct","nativeGit":"useProxy","wslGit":{"Ubuntu":{"behavior":"followNativeGit"},"Debian":{"behavior":"useProxy","proxyUrl":"http://127.0.0.1:7891","scope":"allHttpHttps"},"Broken":"useProxy"}}}"#).unwrap(); + let settings = ProxySettingsStore::from_config(Arc::new(ConfigStore::open(path))); + + assert!(settings + .wsl_git_proxy("Ubuntu", "https://github.com/owner/repo") + .is_err()); + assert!(settings + .wsl_git_proxy("Broken", "https://github.com/owner/repo") + .is_err()); + assert_eq!( + settings + .wsl_git_proxy("Debian", "https://github.com/owner/repo") + .unwrap() + .as_deref(), + Some("http://127.0.0.1:7891") + ); + assert_eq!( + settings + .wsl_git_proxy("Other", "https://github.com/owner/repo") + .unwrap(), + None + ); + } + + #[test] + fn network_operations_observe_saved_settings_and_timeout_from_the_shared_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.json"); + let config = Arc::new(ConfigStore::open(path.clone())); + let settings = ProxySettingsStore::from_config(config.clone()); + config + .save_proxy(NetworkProxySettings { + mode: ProxyMode::Custom, + custom_proxy_url: Some("http://127.0.0.1:7890".into()), + ..Default::default() + }) + .unwrap(); + config + .update(|config| config.git_clone_timeout_secs = 300) + .unwrap(); + std::fs::write(path, b"corrupt external change").unwrap(); + + assert_eq!( + settings.proxy_url().unwrap().as_deref(), + Some("http://127.0.0.1:7890") + ); + assert_eq!(settings.clone_timeout_secs(), 300); + } + #[test] fn replacing_settings_changes_the_value_read_by_later_operations() { let policy = ProxySettingsStore::new(NetworkProxySettings::default()); @@ -136,11 +265,15 @@ mod tests { }); assert_eq!( - policy.wsl_git_proxy("Ubuntu", "http://github.com/owner/repo.git"), + policy + .wsl_git_proxy("Ubuntu", "http://github.com/owner/repo.git") + .expect("valid proxy settings"), Some("http://wsl.example:7890".to_string()) ); assert_eq!( - policy.wsl_git_proxy("Ubuntu", "git@github.com:owner/repo.git"), + policy + .wsl_git_proxy("Ubuntu", "git@github.com:owner/repo.git") + .expect("valid proxy settings"), None ); } @@ -156,15 +289,21 @@ mod tests { }); assert_eq!( - policy.native_git_proxy("https://github.com/owner/repo.git"), + policy + .native_git_proxy("https://github.com/owner/repo.git") + .expect("valid proxy settings"), Some("http://native.proxy:7890".to_string()) ); assert_eq!( - policy.native_git_proxy("https://gitlab.example.cn/owner/repo.git"), + policy + .native_git_proxy("https://gitlab.example.cn/owner/repo.git") + .expect("valid proxy settings"), None ); assert_eq!( - policy.native_git_proxy("git@github.com:owner/repo.git"), + policy + .native_git_proxy("git@github.com:owner/repo.git") + .expect("valid proxy settings"), None ); } @@ -192,19 +331,27 @@ mod tests { }); assert_eq!( - policy.wsl_git_proxy("Ubuntu", "https://github.com/owner/repo.git"), + policy + .wsl_git_proxy("Ubuntu", "https://github.com/owner/repo.git") + .expect("valid proxy settings"), Some("http://native.proxy:7890".to_string()) ); assert_eq!( - policy.wsl_git_proxy("Ubuntu", "https://gitlab.example.cn/owner/repo.git"), + policy + .wsl_git_proxy("Ubuntu", "https://gitlab.example.cn/owner/repo.git") + .expect("valid proxy settings"), None ); assert_eq!( - policy.wsl_git_proxy("Debian", "https://gitlab.example.cn/owner/repo.git"), + policy + .wsl_git_proxy("Debian", "https://gitlab.example.cn/owner/repo.git") + .expect("valid proxy settings"), Some("http://debian.proxy:7890".to_string()) ); assert_eq!( - policy.wsl_git_proxy("Fedora", "https://github.com/owner/repo.git"), + policy + .wsl_git_proxy("Fedora", "https://github.com/owner/repo.git") + .expect("valid proxy settings"), None ); } diff --git a/src-tauri/src/runtime/recovery.rs b/src-tauri/src/runtime/recovery.rs index ce60264d..8c4e91e2 100644 --- a/src-tauri/src/runtime/recovery.rs +++ b/src-tauri/src/runtime/recovery.rs @@ -12,7 +12,7 @@ use crate::environment::recovery::{ use crate::environment::types::EnvironmentRef; use crate::environment::wsl::operations::entry::{inspect_entries, PosixEntryKind}; use crate::environment::wsl::operations::recovery::WslRecoveryMarkerStore; -use crate::environment::wsl::{WslRuntime, WslSession, WslWorkspace}; +use crate::environment::wsl::{WslRuntime, WslWorkspace}; use crate::error::AppError; use crate::storage::recovery_repository::{ RecoveryConsistency, RecoveryConsistencyChecker, RecoveryRepository, @@ -44,14 +44,8 @@ impl RuntimeRecoveryConsistencyChecker { } EnvironmentRef::Wsl { distro_name } => { let marker = marker.clone(); - match self - .environments - .with_session(distro_name, move |session| { - let marker = marker.clone(); - async move { check_wsl_marker(&session, &marker).await } - }) - .await - { + let workspace = self.environments.workspace(distro_name)?; + match check_wsl_marker(&workspace, &marker).await { Ok(consistency) => Ok(consistency), Err(error) if environment_unavailable(&error) => { Ok(RecoveryConsistency::EnvironmentUnavailable) @@ -92,7 +86,7 @@ fn check_native_marker(marker: &RecoveryMarker) -> Result Result { for entry in &marker.entries { @@ -100,7 +94,7 @@ async fn check_wsl_marker( if let Some(backup) = &entry.backup { paths.push(backup.native_path.clone()); } - let states = inspect_entries(session, &paths, None).await?; + let states = inspect_entries(workspace, &paths, None).await?; let destination = &states[0].fingerprint.0; let backup_exists = states .get(1) @@ -197,13 +191,13 @@ impl RuntimeRecoveryGraph { pub(crate) fn active_wsl_store( &self, - session: WslSession, + workspace: WslWorkspace, ) -> Result, AppError> { let environment = EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + distro_name: workspace.distro_name().to_string(), }; let underlying: Arc = - Arc::new(WslRecoveryMarkerStore::from_active_session(session)); + Arc::new(WslRecoveryMarkerStore::new(workspace)); self.repository.register_store(underlying)?; Ok(Arc::new(RepositoryRecoveryMarkerStore::new( environment, diff --git a/src-tauri/src/runtime/resource_service.rs b/src-tauri/src/runtime/resource_service.rs index 4416a779..dbf8e119 100644 --- a/src-tauri/src/runtime/resource_service.rs +++ b/src-tauri/src/runtime/resource_service.rs @@ -1,11 +1,18 @@ use std::sync::Arc; use crate::application::agent_registry_source::AgentRegistrySnapshotSource; +use crate::application::agent_selection::build_agent_selection_catalog; +use crate::application::installed_skill_resolver::SkillDirectoryName; +use crate::application::library_candidates::ResolvedLibraryCandidateIndex; use crate::application::planning_facts::ScopePlanningSnapshotSource; use crate::application::resources::{ AuthorizedResourceOpener, AuthorizedResourceReader, ResolvedResourceContext, - ResourceContextSource, ResourceFuture, ResourceService, + ResourceContextSource, ResourceFuture, ResourceService, SkillIdentity, }; +use crate::application::scope_skill_placements::{ + observe_scope_skill_placements, representative_direct_placement, +}; +use crate::application::skill_libraries::SkillLibraryRepository; use crate::environment::opener::SystemResourceOpener; use crate::environment::planning::RuntimeTargetFactResolver; use crate::environment::types::{EnvironmentRef, ResourceLocator, SkillLocationRef}; @@ -16,15 +23,65 @@ use crate::runtime::planning_facts::RuntimePlanningFactSource; #[derive(Clone)] pub struct RuntimeResourceContextSource { facts: RuntimePlanningFactSource, + targets: RuntimeTargetFactResolver, + libraries: Arc, } impl RuntimeResourceContextSource { - pub fn new(facts: RuntimePlanningFactSource) -> Self { - Self { facts } + pub fn new( + facts: RuntimePlanningFactSource, + targets: RuntimeTargetFactResolver, + libraries: Arc, + ) -> Self { + Self { + facts, + targets, + libraries, + } } } impl ResourceContextSource for RuntimeResourceContextSource { + fn resolve_skill<'a>( + &'a self, + identity: &'a SkillIdentity, + ) -> ResourceFuture<'a, Result> { + Box::pin(async move { + let facts = self.facts.snapshot(&identity.context).await?; + let catalog = build_agent_selection_catalog( + &identity.context, + &facts.agent_runtime, + &facts.eve_targets, + &facts.resolved_context.skill_root, + &self.targets, + ) + .await?; + let skill = SkillDirectoryName::try_from(identity.skill_name.as_str())?; + let libraries = ResolvedLibraryCandidateIndex::load_known( + self.libraries.as_ref(), + &self.targets, + &identity.context.environment, + &std::collections::BTreeSet::from([skill]), + ) + .await?; + let observed = observe_scope_skill_placements( + &self.targets, + &identity.context, + &identity.skill_name, + &facts, + &catalog, + ) + .await?; + let placements = observed.describe(&catalog, &libraries)?; + Ok(representative_direct_placement(&placements) + .ok_or(AppError::StaleTarget)? + .entry + .fact + .destination + .clone()) + }) + } + fn resolve<'a>( &'a self, context: &'a SkillLocationRef, @@ -77,19 +134,13 @@ impl AuthorizedResourceReader for RuntimeResourceReader { } EnvironmentRef::Wsl { distro_name } => { let path = target.native_path; - self.environments - .with_session_retry(distro_name, move |session| { - let path = path.clone(); - async move { - let markdown = crate::environment::wsl::operations::skill_content::read_skill_markdown( - &session, - &path, - ) - .await?; - Ok(crate::core::skill::skill_content_from_markdown(&markdown)) - } - }) - .await + let workspace = self.environments.workspace(distro_name)?; + let markdown = + crate::environment::wsl::operations::skill_content::read_skill_markdown( + &workspace, &path, + ) + .await?; + Ok(crate::core::skill::skill_content_from_markdown(&markdown)) } } }) @@ -106,11 +157,13 @@ pub type RuntimeResourceService = ResourceService< pub fn build_runtime_resource_service( environments: Arc, registry: Arc, + libraries: Arc, ) -> RuntimeResourceService { let facts = RuntimePlanningFactSource::for_current_user(registry, environments.clone()); + let targets = RuntimeTargetFactResolver::new(environments.clone()); ResourceService::new( - RuntimeResourceContextSource::new(facts), - RuntimeTargetFactResolver::new(environments.clone()), + RuntimeResourceContextSource::new(facts, targets.clone(), libraries), + targets, SystemResourceOpener, RuntimeResourceReader::new(environments), ) diff --git a/src-tauri/src/runtime/skill_libraries.rs b/src-tauri/src/runtime/skill_libraries.rs index 8324ea0a..a98dfbea 100644 --- a/src-tauri/src/runtime/skill_libraries.rs +++ b/src-tauri/src/runtime/skill_libraries.rs @@ -9,29 +9,32 @@ use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; use crate::application::collection_records::CollectionRecordReader; use crate::application::installed_skill_resolver::InstalledSkillResolver; use crate::application::library_application::{ - library_usage_state, LibraryApplicationFuture, LibraryApplicationRecord, - LibraryApplicationRepository, LibraryUsageAccumulator, LIBRARY_APPLICATION_SCHEMA_VERSION, + library_usage_state, validate_application_record, ApplicationInventory, + ApplicationInventoryProblem, ApplicationRegistry, LibraryApplicationFuture, + LibraryApplicationRecord, LibraryApplicationResources, LibraryUsageAccumulator, + VersionedApplicationRecord, +}; +use crate::application::library_membership::{apply_membership_change, MembershipChange}; +use crate::application::payload_session::{ + PayloadLocalSource, PayloadSessionStorage, PayloadStorageKey, }; use crate::application::skill_libraries::{ validate_catalog, CommitLibraryMemberRequest, LibraryCatalog, LibraryFuture, LibraryId, - LibraryMemberMutation, LibraryUsage, LibraryUsageProjection, LibraryUsageProvider, - LibraryUsageState, SkillLibraryRepository, + LibraryMemberMutation, LibraryUsage, LibraryUsageProvider, LibraryUsageSnapshot, + LibraryUsageState, PurgeRetiredLibraryMemberRequest, SkillLibraryRepository, }; use crate::application::skill_paths::{ResolvedSkillRoot, SkillPathObserver}; use crate::core::projects::ProjectMigrationRegistry; use crate::core::skill_payload::SkillPayload; use crate::environment::native::atomic_file::NativeAtomicDocumentIo; use crate::environment::native::entry::{materialize_payload, verify_materialized_payload}; +use crate::environment::runtime::PhysicalParentIdentity; use crate::environment::types::{ EnvironmentKey, EnvironmentRef, ProjectInfo, RegisteredProject, ResourceLocator, }; use crate::environment::types::{SkillLocation, SkillLocationRef}; +use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::operations::library_content::{ - ensure_library_roots, finalize_library_catalog, prepare_library_catalog, - recover_library_content, remove_library as remove_wsl_library, remove_library_application, - replace_library_skill, stage_library_skill_deletion, -}; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::storage::atomic_document::AtomicDocumentIo; @@ -143,6 +146,19 @@ impl RuntimeSkillLibraryRepository { ); Ok(candidates) } + + async fn usage_metadata( + &self, + environment: &EnvironmentRef, + ) -> HashMap> { + match self.usage_candidates(environment).await { + Ok(candidates) => candidates.into_iter().collect(), + Err(error) => { + log::warn!("Skill Library project metadata is unavailable: {error}"); + HashMap::new() + } + } + } } impl SkillLibraryRepository for RuntimeSkillLibraryRepository { @@ -167,7 +183,7 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { let distro_name = distro_name.clone(); let library_id = library_id.as_str().to_string(); self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let library_id = library_id.clone(); async move { Ok(( @@ -207,23 +223,17 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { })?? } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); self.wsl - .with_session_retry(&distro_name, |session| async move { - recover_wsl_library_content(&session).await?; - let target = wsl_catalog_locator(&session); - WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(&target) - .await - }) + .workspace(distro_name)? + .read_library_catalog() .await? } }; - let catalog = bytes - .map(|bytes| serde_json::from_slice(&bytes).map_err(AppError::from)) - .unwrap_or_else(|| Ok(LibraryCatalog::default()))?; - validate_catalog(&catalog)?; - Ok(catalog) + bytes + .as_deref() + .map(parse_library_catalog) + .transpose() + .map(|catalog| catalog.unwrap_or_default()) }) } @@ -235,7 +245,6 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { Box::pin(async move { let _io = self.io.acquire(environment).await; let bytes = serde_json::to_vec_pretty(catalog)?; - let catalog_hash = bytes_sha256(&bytes); match environment { EnvironmentRef::Native => { let root = self.native_root.clone(); @@ -253,37 +262,27 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { })? } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); - self.wsl - .with_session_retry(&distro_name, move |session| { - let bytes = bytes.clone(); - let catalog_hash = catalog_hash.clone(); - let library_ids = catalog - .libraries - .iter() - .map(|library| library.id.as_str().to_string()) - .collect::>(); - async move { - let result = async { - recover_wsl_library_content(&session).await?; - ensure_library_roots(&session, &library_ids).await?; - prepare_library_catalog(&session, &catalog_hash).await?; - let target = wsl_catalog_locator(&session); - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic(&target, bytes) - .await?; - finalize_library_catalog(&session, &catalog_hash).await - } - .await; - let result = if result.is_err() { - recover_wsl_library_content(&session).await.and(result) - } else { - result - }; - result - } - }) + let workspace = self.wsl.workspace(distro_name)?; + let snapshot = workspace.read_library_catalog_once().await?; + workspace + .execute_library_operation( + snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: snapshot.revision, + catalog_bytes: bytes, + action: environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: catalog + .libraries + .iter() + .map(|library| library.id.as_str().to_string()) + .collect(), + }, + deadline_millis: 60_000, + }, + ) .await + .map(|_| ()) } } }) @@ -307,15 +306,47 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { return Err(AppError::StaleEnvironment); }; let distro_name = distro_name.clone(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let request = request.clone(); - async move { commit_wsl_member(&session, request).await } + let workspace = workspace.clone(); + async move { commit_wsl_member(&session, &workspace, request).await } }) .await }) } + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + let _io = self.io.acquire(&request.environment).await; + match &request.environment { + EnvironmentRef::Native => { + let root = self.native_root.clone(); + tokio::task::spawn_blocking(move || purge_native_retired(&root, request)) + .await + .map_err(|error| AppError::ExecutionFailed { + message: format!("retired Library member purge task failed: {error}"), + })? + } + EnvironmentRef::Wsl { distro_name } => { + let distro_name = distro_name.clone(); + let workspace = self.wsl.workspace(&distro_name)?; + self.wsl + .with_session(&distro_name, move |session| { + let workspace = workspace.clone(); + let request = request.clone(); + async move { purge_wsl_retired(&session, &workspace, request).await } + }) + .await + } + } + }) + } + fn delete_library<'a>( &'a self, environment: &'a EnvironmentRef, @@ -337,10 +368,14 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let library_id = library_id.as_str().to_string(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let library_id = library_id.clone(); - async move { delete_wsl_library(&session, &library_id).await } + let workspace = workspace.clone(); + async move { + delete_wsl_library(&session, &workspace, &library_id).await + } }) .await } @@ -373,22 +408,28 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { let distro_name = distro_name.clone(); let library_id = library_id.as_str().to_string(); let skill_name = install_dir_name; - self.wsl - .with_session_retry(&distro_name, move |session| { + let path = self + .wsl + .with_session_read_retry(&distro_name, move |session| { let library_id = library_id.clone(); let skill_name = skill_name.clone(); async move { - let path = format!( + Ok(format!( "{}/.skill-deck/skill-libraries/libraries/{}/skills/{}", session.home.trim_end_matches('/'), library_id, skill_name - ); - let markdown = crate::environment::wsl::operations::skill_content::read_skill_markdown(&session, &path).await?; - Ok(crate::core::skill::skill_content_from_markdown(&markdown)) + )) } }) - .await + .await?; + let workspace = self.wsl.workspace(&distro_name)?; + let markdown = + crate::environment::wsl::operations::skill_content::read_skill_markdown( + &workspace, &path, + ) + .await?; + Ok(crate::core::skill::skill_content_from_markdown(&markdown)) } } }) @@ -403,12 +444,16 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { ) -> LibraryFuture<'a, Result, AppError>> { Box::pin(async move { let mut usages = Vec::new(); - for (context, project) in self.usage_candidates(environment).await? { - let record = self.load_application(&context).await?; - if let Some(state) = library_usage_state(&record, library_id) { + let inventory = self.enumerate(environment).await?; + report_inventory_problems(&inventory); + require_complete_inventory(&inventory)?; + let projects = self.usage_metadata(environment).await; + for application in inventory.records { + if let Some(state) = library_usage_state(&application.record, library_id) { + let context = application.context; usages.push(LibraryUsage { + project: projects.get(&context).cloned().flatten(), context, - project, state, }); } @@ -420,13 +465,21 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { fn usage_projection<'a>( &'a self, environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>> { + ) -> LibraryFuture<'a, Result> { Box::pin(async move { let mut accumulator = LibraryUsageAccumulator::default(); - for (context, _) in self.usage_candidates(environment).await? { - accumulator.observe(&self.load_application(&context).await?); + let inventory = self.enumerate(environment).await?; + report_inventory_problems(&inventory); + let inventory_complete = inventory.complete; + let problem_count = u32::try_from(inventory.problems.len()).unwrap_or(u32::MAX); + for application in inventory.records { + accumulator.observe(&application.record); } - Ok(accumulator.finish()) + Ok(LibraryUsageSnapshot { + projections: accumulator.finish(), + inventory_complete, + problem_count, + }) }) } @@ -437,11 +490,19 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { ) -> LibraryFuture<'a, Result, AppError>> { Box::pin(async move { let mut usages = Vec::new(); - for (context, project) in self.usage_candidates(environment).await? { - let record = self.load_application(&context).await?; - let state = if record.current.selected_agent_ids.contains(agent_id) { + let inventory = self.enumerate(environment).await?; + report_inventory_problems(&inventory); + require_complete_inventory(&inventory)?; + let projects = self.usage_metadata(environment).await; + for application in inventory.records { + let state = if application + .record + .current + .selected_agent_ids + .contains(agent_id) + { Some(LibraryUsageState::Confirmed) - } else if record.pending_operation.as_ref().is_some_and(|pending| { + } else if application.record.pending.as_ref().is_some_and(|pending| { pending .before_application .selected_agent_ids @@ -456,9 +517,10 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { None }; if let Some(state) = state { + let context = application.context; usages.push(LibraryUsage { + project: projects.get(&context).cloned().flatten(), context, - project, state, }); } @@ -468,80 +530,194 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { } } -impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { +fn report_inventory_problems(inventory: &ApplicationInventory) { + if inventory.complete { + return; + } + for problem in &inventory.problems { + log::warn!( + "Skill Library application inventory skipped {}: {}", + problem.storage_key, + problem.error + ); + } +} + +fn require_complete_inventory(inventory: &ApplicationInventory) -> Result<(), AppError> { + if inventory.complete { + Ok(()) + } else { + Err(AppError::ConfigurationCorrupted { + message: "Skill Library application inventory is incomplete".to_string(), + }) + } +} + +impl ApplicationRegistry for RuntimeSkillLibraryRepository { fn load_application<'a>( &'a self, context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { - let bytes = match &context.environment { + let (target, snapshot) = match &context.environment { EnvironmentRef::Native => { - NativeAtomicDocumentIo - .read_optional(&self.native_application(context)?) - .await? + let target = self.native_application(context)?; + let snapshot = NativeAtomicDocumentIo + .observe(&target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await?; + (target, snapshot) } EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let context = context.clone(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let context = context.clone(); + let workspace = workspace.clone(); async move { let target = wsl_application_locator(&session, &context.scope)?; - WslAtomicDocumentIo::from_active_session(session) - .read_optional(&target) - .await + let snapshot = + WslAtomicDocumentIo::from_active_session(session, workspace) + .observe( + &target, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), + ) + .await?; + Ok((target, snapshot)) } }) .await? } }; - let record = bytes - .map(|bytes| serde_json::from_slice(&bytes).map_err(AppError::from)) - .unwrap_or_else(|| Ok(LibraryApplicationRecord::empty(context.clone())))?; - if record.schema_version != LIBRARY_APPLICATION_SCHEMA_VERSION - || record.target != *context - { - return Err(AppError::ConfigurationCorrupted { - message: "invalid Skill Library application record".to_string(), - }); - } - Ok(record) + let record = snapshot + .bytes + .as_deref() + .map(parse_library_application_record) + .unwrap_or_else(|| Ok(LibraryApplicationRecord::empty()))?; + Ok(VersionedApplicationRecord { + context: context.clone(), + record, + target, + snapshot, + }) }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { + validate_application_record(record)?; let bytes = serde_json::to_vec_pretty(record)?; - match &record.target.environment { + let snapshot = match &observed.context.environment { + EnvironmentRef::Native => NativeAtomicDocumentIo + .replace(&observed.target, observed.snapshot.clone(), bytes) + .await + .map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error)?, + EnvironmentRef::Wsl { distro_name } => { + let workspace = self.wsl.workspace(distro_name)?; + WslAtomicDocumentIo::new(workspace) + .replace(&observed.target, observed.snapshot.clone(), bytes) + .await + .map_err( + crate::storage::atomic_document::DocumentWriteFailure::into_error, + )? + } + }; + Ok(VersionedApplicationRecord { + context: observed.context.clone(), + record: record.clone(), + target: observed.target.clone(), + snapshot, + }) + }) + } + + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + let contexts = match environment { EnvironmentRef::Native => { - NativeAtomicDocumentIo - .write_atomic(&self.native_application(&record.target)?, bytes) + let root = self.native_root.join("applications"); + tokio::task::spawn_blocking(move || native_application_contexts(&root)) .await + .map_err(|error| AppError::ExecutionFailed { + message: format!("application inventory task failed: {error}"), + })?? } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); - let scope = record.target.scope.clone(); - self.wsl - .with_session_retry(&distro_name, move |session| { - let bytes = bytes.clone(); - let scope = scope.clone(); - async move { - let target = wsl_application_locator(&session, &scope)?; - WslAtomicDocumentIo::from_active_session(session) - .write_atomic(&target, bytes) - .await - } + let index = self + .wsl + .workspace(distro_name)? + .list_library_applications() + .await?; + let mut scopes = vec![("global".to_string(), SkillLocation::Global)]; + let mut problems = index + .problem_keys + .into_iter() + .map(|storage_key| ApplicationInventoryProblem { + storage_key, + error: AppError::ConfigurationCorrupted { + message: "invalid Skill Library application Scope key".to_string(), + }, }) - .await + .collect::>(); + for project_id in index.project_ids { + if let Err(error) = validate_storage_component(&project_id) { + problems.push(ApplicationInventoryProblem { + storage_key: format!("projects/{project_id}.json"), + error, + }); + continue; + } + scopes.push(( + format!("projects/{project_id}.json"), + SkillLocation::Project { project_id }, + )); + } + if !index.complete && problems.is_empty() { + problems.push(ApplicationInventoryProblem { + storage_key: "applications".to_string(), + error: AppError::ConfigurationCorrupted { + message: "incomplete Skill Library application inventory" + .to_string(), + }, + }); + } + ApplicationContexts { scopes, problems } + } + }; + let mut records = Vec::new(); + let mut problems = contexts.problems; + for (storage_key, scope) in contexts.scopes { + let context = SkillLocationRef { + environment: environment.clone(), + scope, + }; + match self.load_application(&context).await { + Ok(record) => records.push(record), + Err(error) => problems.push(ApplicationInventoryProblem { storage_key, error }), } } + records.sort_by(|left, right| { + application_scope_key(&left.context.scope) + .cmp(&application_scope_key(&right.context.scope)) + }); + Ok(ApplicationInventory { + complete: problems.is_empty(), + records, + problems, + }) }) } +} +impl LibraryApplicationResources for RuntimeSkillLibraryRepository { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -564,7 +740,7 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { let library_id = library_id.as_str().to_string(); let skill_name = install_dir_name; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let library_id = library_id.clone(); let skill_name = skill_name.clone(); async move { @@ -594,42 +770,31 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { Box::pin(async move { SkillLibraryRepository::load(self, &context.environment).await }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { - let SkillLocation::Project { project_id } = &context.scope else { + let SkillLocation::Project { project_id } = &observed.context.scope else { return Err(AppError::Validation { field: Some("context".to_string()), message: "only Project Skill Library applications can be removed".to_string(), }); }; validate_storage_component(project_id)?; - match &context.environment { + let result = match &observed.context.environment { EnvironmentRef::Native => { - let path = PathBuf::from(self.native_application(context)?.native_path); - tokio::task::spawn_blocking(move || match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(AppError::from(error)), - }) - .await - .map_err(|error| AppError::ExecutionFailed { - message: format!("Skill Library application cleanup task failed: {error}"), - })? + NativeAtomicDocumentIo + .remove(&observed.target, observed.snapshot.clone()) + .await } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); - let project_id = project_id.clone(); - self.wsl - .with_session_retry(&distro_name, move |session| { - let project_id = project_id.clone(); - async move { remove_library_application(&session, &project_id).await } - }) + WslAtomicDocumentIo::new(self.wsl.workspace(distro_name)?) + .remove(&observed.target, observed.snapshot.clone()) .await } - } + }; + result.map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error) }) } } @@ -671,6 +836,26 @@ fn load_native_catalog_bytes(root: &Path) -> Result>, AppError> { Ok(bytes) } +fn parse_library_catalog(bytes: &[u8]) -> Result { + let catalog = serde_json::from_slice(bytes) + .map_err(|error| invalid_library_document("catalog", error))?; + validate_catalog(&catalog)?; + Ok(catalog) +} + +fn parse_library_application_record(bytes: &[u8]) -> Result { + let record = serde_json::from_slice(bytes) + .map_err(|error| invalid_library_document("application record", error))?; + validate_application_record(&record)?; + Ok(record) +} + +fn invalid_library_document(kind: &str, error: serde_json::Error) -> AppError { + AppError::ConfigurationCorrupted { + message: format!("Skill Library {kind} does not match the current data format: {error}"), + } +} + fn save_native_catalog( root: &Path, library_ids: &[LibraryId], @@ -690,6 +875,20 @@ fn save_native_catalog( crate::environment::native::atomic_file::write_native_atomic(&root.join("catalog.json"), bytes) } +fn save_native_catalog_if_unchanged( + root: &Path, + expected: Option<&[u8]>, + bytes: &[u8], +) -> Result<(), AppError> { + environment_engine::atomic_document::replace_if_unchanged( + &root.join("catalog.json"), + expected, + bytes, + ) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error) +} + fn delete_native_library(root: &Path, library_id: &LibraryId) -> Result { let original_bytes = match fs::read(root.join("catalog.json")) { Ok(bytes) => bytes, @@ -702,56 +901,93 @@ fn delete_native_library(root: &Path, library_id: &LibraryId) -> Result Ok(catalog), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(catalog), - Err(error) => { - crate::environment::native::atomic_file::write_native_atomic( - &root.join("catalog.json"), - &original_bytes, - )?; - Err(error.into()) + let metadata = match fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => Some(metadata), + Ok(_) => return Err(AppError::StaleTarget), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + if metadata.is_none() { + save_native_catalog_if_unchanged(root, Some(&original_bytes), &updated_bytes)?; + return Ok(catalog); + } + let catalog_hash = bytes_sha256(&updated_bytes); + let commit = (|| { + stage_native_skill_deletion(root, &destination)?; + prepare_native_catalog_commit(root, &catalog_hash)?; + save_native_catalog_if_unchanged(root, Some(&original_bytes), &updated_bytes)?; + finalize_native_catalog_commit(root, &catalog_hash) + })(); + if let Err(error) = commit { + let current_hash = fs::read(root.join("catalog.json")) + .ok() + .map(|bytes| bytes_sha256(&bytes)); + recover_native_library_transactions(root, current_hash.as_deref())?; + if current_hash.as_deref() != Some(&catalog_hash) { + return Err(error); } } + Ok(catalog) } async fn delete_wsl_library( session: &crate::environment::wsl::WslSession, + workspace: &crate::environment::wsl::WslWorkspace, library_id: &str, ) -> Result { - recover_wsl_library_content(session).await?; - let target = wsl_catalog_locator(session); - let io = WslAtomicDocumentIo::from_active_session(session.clone()); - let original_bytes = - io.read_optional(&target) - .await? - .ok_or_else(|| AppError::PathNotFound { - path: library_id.to_string(), - })?; - let mut catalog: LibraryCatalog = serde_json::from_slice(&original_bytes)?; - validate_catalog(&catalog)?; + let snapshot = workspace.read_library_catalog_once().await?; + if snapshot.generation != session.runtime_generation { + return Err(AppError::StaleEnvironment); + } + let original_bytes = snapshot.bytes.ok_or_else(|| AppError::PathNotFound { + path: library_id.to_string(), + })?; + let mut catalog = parse_library_catalog(&original_bytes)?; remove_catalog_library(&mut catalog, library_id)?; let updated_bytes = serde_json::to_vec_pretty(&catalog)?; - let updated_hash = bytes_sha256(&updated_bytes); - prepare_library_catalog(session, &updated_hash).await?; - io.write_atomic(&target, updated_bytes).await?; - finalize_library_catalog(session, &updated_hash).await?; - if let Err(error) = remove_wsl_library(session, library_id).await { - let original_hash = bytes_sha256(&original_bytes); - prepare_library_catalog(session, &original_hash).await?; - io.write_atomic(&target, original_bytes).await?; - finalize_library_catalog(session, &original_hash).await?; - return Err(error); - } + let destination = format!( + "{}/.skill-deck/skill-libraries/libraries/{library_id}", + session.home.trim_end_matches('/'), + ); + let target = crate::environment::planning::resolve_wsl_targets( + session, + workspace, + std::slice::from_ref(&destination), + None, + ) + .await? + .pop() + .ok_or(AppError::StaleTarget)?; + let (expected_anchor_device, expected_anchor_inode) = match &target.key.physical_parent { + PhysicalParentIdentity::Wsl { + distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) => (*device, *inode), + _ => return Err(AppError::StaleTarget), + }; + workspace + .execute_library_operation( + snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: snapshot.revision, + catalog_bytes: updated_bytes, + action: environment_protocol::LibraryOperationAction::DeleteLibrary { + library_id: library_id.to_string(), + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint: target.fingerprint.0, + expected_content_hash: None, + }, + deadline_millis: 60_000, + }, + ) + .await?; Ok(catalog) } @@ -768,69 +1004,281 @@ fn remove_catalog_library(catalog: &mut LibraryCatalog, library_id: &str) -> Res Ok(()) } -async fn commit_wsl_member( +fn retired_member<'a>( + catalog: &'a LibraryCatalog, + request: &PurgeRetiredLibraryMemberRequest, +) -> Result<&'a crate::application::skill_libraries::RetiredLibrarySkillRecord, AppError> { + let retired = catalog + .libraries + .iter() + .find(|library| library.id == request.library_id) + .and_then(|library| { + library + .retired_skills + .iter() + .find(|retired| retired.member.name == request.skill_name) + }) + .ok_or_else(|| AppError::PathNotFound { + path: request.skill_name.clone(), + })?; + if retired.retirement_id != request.retirement_id { + return Err(AppError::StaleTarget); + } + Ok(retired) +} + +fn purge_native_retired( + root: &Path, + request: PurgeRetiredLibraryMemberRequest, +) -> Result<(), AppError> { + let original_bytes = fs::read(root.join("catalog.json"))?; + let original_hash = bytes_sha256(&original_bytes); + recover_native_library_transactions(root, Some(&original_hash))?; + let mut catalog = parse_library_catalog(&original_bytes)?; + let expected_hash = retired_member(&catalog, &request)? + .member + .content_manifest_hash + .clone(); + let destination = root + .join("libraries") + .join(request.library_id.as_str()) + .join("skills") + .join(InstalledSkillResolver::install_dir_name( + &request.skill_name, + )?); + let locator = ResourceLocator { + environment: EnvironmentRef::Native, + native_path: destination.to_string_lossy().into_owned(), + }; + let target = crate::environment::planning::resolve_native_targets(&[locator])? + .pop() + .ok_or(AppError::StaleTarget)?; + match target.entry_kind { + crate::environment::planning::TargetEntryKind::Directory => { + let actual = + crate::environment::native::content_manifest::read_directory(&destination)?; + if actual.hash().as_str() != expected_hash { + return Err(AppError::StaleTarget); + } + } + crate::environment::planning::TargetEntryKind::Missing => {} + _ => return Err(AppError::StaleTarget), + } + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; + let catalog_hash = bytes_sha256(&catalog_bytes); + if target.entry_kind == crate::environment::planning::TargetEntryKind::Missing { + return save_native_catalog_if_unchanged(root, Some(&original_bytes), &catalog_bytes); + } + let commit = (|| { + stage_native_skill_deletion(root, &destination)?; + prepare_native_catalog_commit(root, &catalog_hash)?; + save_native_catalog_if_unchanged(root, Some(&original_bytes), &catalog_bytes)?; + finalize_native_catalog_commit(root, &catalog_hash) + })(); + if let Err(error) = commit { + let current_hash = fs::read(root.join("catalog.json")) + .ok() + .map(|bytes| bytes_sha256(&bytes)); + recover_native_library_transactions(root, current_hash.as_deref())?; + if current_hash.as_deref() == Some(&catalog_hash) { + return Ok(()); + } + return Err(error); + } + Ok(()) +} + +async fn purge_wsl_retired( session: &crate::environment::wsl::WslSession, - request: CommitLibraryMemberRequest, + workspace: &crate::environment::wsl::WslWorkspace, + request: PurgeRetiredLibraryMemberRequest, ) -> Result<(), AppError> { - recover_wsl_library_content(session).await?; - validate_storage_component(request.library_id.as_str())?; - let install_dir_name = InstalledSkillResolver::install_dir_name(&request.skill_name)?; + let snapshot = workspace.read_library_catalog_once().await?; + if snapshot.generation != session.runtime_generation { + return Err(AppError::StaleEnvironment); + } + let mut catalog = snapshot + .bytes + .as_deref() + .map(parse_library_catalog) + .transpose()? + .ok_or_else(|| AppError::PathNotFound { + path: request.library_id.as_str().to_string(), + })?; + let expected_hash = retired_member(&catalog, &request)? + .member + .content_manifest_hash + .clone(); let destination = format!( "{}/.skill-deck/skill-libraries/libraries/{}/skills/{}", session.home.trim_end_matches('/'), request.library_id.as_str(), - install_dir_name, + InstalledSkillResolver::install_dir_name(&request.skill_name)?, ); let target = crate::environment::planning::resolve_wsl_targets( session, + workspace, std::slice::from_ref(&destination), None, ) .await? .pop() .ok_or(AppError::StaleTarget)?; - let manifest = if target.entry_kind == crate::environment::planning::TargetEntryKind::Directory - { - Some( - crate::environment::wsl::operations::content_manifest::inspect( - session, + let expected_content_hash = match target.entry_kind { + crate::environment::planning::TargetEntryKind::Directory => { + let manifest = crate::environment::wsl::operations::content_manifest::inspect( + workspace, &crate::environment::content_manifest::ContentManifestTarget { key: target.key.clone(), location: target.destination.clone(), }, None, ) - .await? - .hash() - .clone(), - ) - } else { - None + .await?; + if manifest.hash().as_str() != expected_hash { + return Err(AppError::StaleTarget); + } + Some(expected_hash) + } + crate::environment::planning::TargetEntryKind::Missing => None, + _ => return Err(AppError::StaleTarget), }; - let (target_revision, content_revision) = - crate::application::skill_paths::SkillPathObserver::revisions_for_observation( - &target, manifest, - )?; - if target_revision != request.expected.target_revision - || content_revision != request.expected.content_revision - { - return Err(AppError::StaleTarget); - } - - let catalog_target = wsl_catalog_locator(session); - let bytes = WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(&catalog_target) - .await?; - let mut catalog = bytes - .as_deref() - .map(serde_json::from_slice) - .transpose()? - .unwrap_or_default(); - validate_catalog(&catalog)?; - let snapshot = crate::application::collection_records::LibraryCatalogRecordReader::new( - &catalog, + apply_membership_change( + &mut catalog, &request.library_id, - ) + &request.skill_name, + MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; + let action = if target.entry_kind == crate::environment::planning::TargetEntryKind::Missing { + environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: catalog + .libraries + .iter() + .map(|library| library.id.as_str().to_string()) + .collect(), + } + } else { + let (expected_anchor_device, expected_anchor_inode) = match target.key.physical_parent { + PhysicalParentIdentity::Wsl { + ref distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) => (device, inode), + _ => return Err(AppError::StaleTarget), + }; + environment_protocol::LibraryOperationAction::CommitMember { + library_id: request.library_id.as_str().to_string(), + skill_name: InstalledSkillResolver::install_dir_name(&request.skill_name)?, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint: target.fingerprint.0, + expected_content_hash, + mutation: environment_protocol::LibraryMemberAction::Delete, + } + }; + workspace + .execute_library_operation( + snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: snapshot.revision, + catalog_bytes, + action, + deadline_millis: 60_000, + }, + ) + .await + .map(|_| ()) +} + +async fn commit_wsl_member( + session: &crate::environment::wsl::WslSession, + workspace: &crate::environment::wsl::WslWorkspace, + request: CommitLibraryMemberRequest, +) -> Result<(), AppError> { + validate_storage_component(request.library_id.as_str())?; + let install_dir_name = InstalledSkillResolver::install_dir_name(&request.skill_name)?; + let destination = format!( + "{}/.skill-deck/skill-libraries/libraries/{}/skills/{}", + session.home.trim_end_matches('/'), + request.library_id.as_str(), + install_dir_name, + ); + let target = crate::environment::planning::resolve_wsl_targets( + session, + workspace, + std::slice::from_ref(&destination), + None, + ) + .await? + .pop() + .ok_or(AppError::StaleTarget)?; + let manifest = if target.entry_kind == crate::environment::planning::TargetEntryKind::Directory + { + Some( + crate::environment::wsl::operations::content_manifest::inspect( + workspace, + &crate::environment::content_manifest::ContentManifestTarget { + key: target.key.clone(), + location: target.destination.clone(), + }, + None, + ) + .await? + .hash() + .clone(), + ) + } else { + None + }; + let (target_revision, content_revision) = + crate::application::skill_paths::SkillPathObserver::revisions_for_observation( + &target, + manifest.clone(), + )?; + if target_revision != request.expected.target_revision + || content_revision != request.expected.content_revision + { + return Err(AppError::StaleTarget); + } + + let (expected_anchor_device, expected_anchor_inode) = match &target.key.physical_parent { + PhysicalParentIdentity::Wsl { + distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) => (*device, *inode), + _ => return Err(AppError::StaleTarget), + }; + let expected_fingerprint = target.fingerprint.0.clone(); + let expected_content_hash = manifest + .as_ref() + .map(|manifest| manifest.as_str().to_string()); + let catalog_snapshot = workspace.read_library_catalog_once().await?; + if catalog_snapshot.generation != session.runtime_generation { + return Err(AppError::StaleEnvironment); + } + let mut catalog = catalog_snapshot + .bytes + .as_deref() + .map(parse_library_catalog) + .transpose()? + .unwrap_or_default(); + let snapshot = crate::application::collection_records::LibraryCatalogRecordReader::new( + &catalog, + &request.library_id, + ) .load_snapshot(std::collections::BTreeSet::from([request .skill_name .clone()]))?; @@ -839,103 +1287,107 @@ async fn commit_wsl_member( return Err(AppError::StaleTarget); } let _document_changed = snapshot.document_revision != request.expected.document_revision; - let library = catalog - .libraries - .iter_mut() - .find(|library| library.id == request.library_id) - .ok_or_else(|| AppError::PathNotFound { - path: request.library_id.as_str().to_string(), - })?; match &request.mutation { LibraryMemberMutation::Upsert { record, .. } => { - let mut record = (**record).clone(); - if let Some(current) = library - .skills - .iter() - .find(|skill| skill.name == request.skill_name) - { - record.extra = current.extra.clone(); - crate::application::skill_libraries::merge_unknown_source_fields( - &mut record.source_record, - ¤t.source_record, - ); - } - if let Some(current) = library - .skills - .iter_mut() - .find(|skill| skill.name == request.skill_name) - { - *current = record; - } else { - library.skills.push(record); - library - .skills - .sort_by(|left, right| left.name.cmp(&right.name)); - } + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Upsert((**record).clone()), + )?; } - LibraryMemberMutation::Delete => { - let before = library.skills.len(); - library - .skills - .retain(|skill| skill.name != request.skill_name); - if before == library.skills.len() { - return Err(AppError::StaleTarget); - } + LibraryMemberMutation::Retire { + retirement_id, + retired_at, + } => { + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Retire { + retirement_id: retirement_id.clone(), + retired_at: retired_at.clone(), + }, + )?; } } let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; - let catalog_hash = bytes_sha256(&catalog_bytes); - let commit = async { - match &request.mutation { - LibraryMemberMutation::Upsert { content, .. } => { - replace_library_skill( - session, - request.library_id.as_str(), - &install_dir_name, - payload_archive(content)?, - ) - .await?; - } - LibraryMemberMutation::Delete => { - stage_library_skill_deletion( - session, - request.library_id.as_str(), - &install_dir_name, - ) + if matches!(request.mutation, LibraryMemberMutation::Retire { .. }) { + return workspace + .execute_library_operation( + catalog_snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: catalog_snapshot.revision, + catalog_bytes, + action: environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: catalog + .libraries + .iter() + .map(|library| library.id.as_str().to_string()) + .collect(), + }, + deadline_millis: 60_000, + }, + ) + .await + .map(|_| ()); + } + let payload_storage = WslPayloadSessionStorage::new(workspace.clone()); + let payload_key = PayloadStorageKey::new( + format!("library-{}", uuid::Uuid::new_v4().simple()), + install_dir_name.clone(), + ); + let mutation = match &request.mutation { + LibraryMemberMutation::Upsert { content, .. } => { + payload_storage + .store(&payload_key, (**content).clone()) .await?; + match payload_storage.local_source(&payload_key)? { + PayloadLocalSource::WslManaged { + distro_name, + worker_generation, + worker_payload_id, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) + && worker_generation == catalog_snapshot.generation => + { + environment_protocol::LibraryMemberAction::Upsert { + payload_id: worker_payload_id, + } + } + _ => { + let _ = payload_storage.remove(&payload_key).await; + return Err(AppError::StalePayload); + } } } - prepare_library_catalog(session, &catalog_hash).await?; - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic(&catalog_target, catalog_bytes) - .await?; - finalize_library_catalog(session, &catalog_hash).await - } - .await; - if let Err(error) = commit { - recover_wsl_library_content(session).await?; - let current = WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(&catalog_target) - .await?; - if current.as_deref().map(bytes_sha256).as_deref() == Some(&catalog_hash) { - return Ok(()); - } - return Err(error); - } - Ok(()) -} - -async fn recover_wsl_library_content( - session: &crate::environment::wsl::WslSession, -) -> Result<(), AppError> { - recover_library_content(session) - .await - .map_err(|error| AppError::LibraryRecoveryIncomplete { - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + LibraryMemberMutation::Retire { .. } => unreachable!("retire returns before payload I/O"), + }; + let result = workspace + .execute_library_operation( + catalog_snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: catalog_snapshot.revision, + catalog_bytes, + action: environment_protocol::LibraryOperationAction::CommitMember { + library_id: request.library_id.as_str().to_string(), + skill_name: install_dir_name, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + mutation, + }, + deadline_millis: 60_000, }, - message: error.to_string(), - }) + ) + .await + .map(|_| ()); + if matches!(request.mutation, LibraryMemberMutation::Upsert { .. }) { + let _ = payload_storage.remove(&payload_key).await; + } + result } fn commit_native_member(root: &Path, request: CommitLibraryMemberRequest) -> Result<(), AppError> { @@ -949,10 +1401,9 @@ fn commit_native_member(root: &Path, request: CommitLibraryMemberRequest) -> Res let mut catalog = current_catalog_bytes .as_deref() - .map(serde_json::from_slice) + .map(parse_library_catalog) .transpose()? .unwrap_or_default(); - validate_catalog(&catalog)?; let snapshot = crate::application::collection_records::LibraryCatalogRecordReader::new( &catalog, &request.library_id, @@ -997,67 +1448,50 @@ fn commit_native_member(root: &Path, request: CommitLibraryMemberRequest) -> Res } let _document_changed = snapshot.document_revision != request.expected.document_revision; - let library = catalog - .libraries - .iter_mut() - .find(|library| library.id == request.library_id) - .ok_or_else(|| AppError::PathNotFound { - path: request.library_id.as_str().to_string(), - })?; match &request.mutation { LibraryMemberMutation::Upsert { record, .. } => { - let mut record = (**record).clone(); - if let Some(current) = library - .skills - .iter() - .find(|skill| skill.name == request.skill_name) - { - record.extra = current.extra.clone(); - crate::application::skill_libraries::merge_unknown_source_fields( - &mut record.source_record, - ¤t.source_record, - ); - } - if let Some(current) = library - .skills - .iter_mut() - .find(|skill| skill.name == request.skill_name) - { - *current = record; - } else { - library.skills.push(record); - library - .skills - .sort_by(|left, right| left.name.cmp(&right.name)); - } + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Upsert((**record).clone()), + )?; } - LibraryMemberMutation::Delete => { - let before = library.skills.len(); - library - .skills - .retain(|skill| skill.name != request.skill_name); - if before == library.skills.len() { - return Err(AppError::StaleTarget); - } + LibraryMemberMutation::Retire { + retirement_id, + retired_at, + } => { + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Retire { + retirement_id: retirement_id.clone(), + retired_at: retired_at.clone(), + }, + )?; } } let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; let catalog_hash = bytes_sha256(&catalog_bytes); + if matches!(request.mutation, LibraryMemberMutation::Retire { .. }) { + return save_native_catalog_if_unchanged( + root, + current_catalog_bytes.as_deref(), + &catalog_bytes, + ); + } + let commit = (|| { match &request.mutation { LibraryMemberMutation::Upsert { content, .. } => { replace_native_skill(root, &destination, content)?; } - LibraryMemberMutation::Delete => { - stage_native_skill_deletion(root, &destination)?; - } + LibraryMemberMutation::Retire { .. } => unreachable!("retire returns before staging"), } prepare_native_catalog_commit(root, &catalog_hash)?; - crate::environment::native::atomic_file::write_native_atomic( - &root.join("catalog.json"), - &catalog_bytes, - )?; + save_native_catalog_if_unchanged(root, current_catalog_bytes.as_deref(), &catalog_bytes)?; finalize_native_catalog_commit(root, &catalog_hash) })(); if let Err(error) = commit { @@ -1230,6 +1664,18 @@ fn recover_native_transactions(root: &Path, catalog_hash: Option<&str>) -> Resul let destination = PathBuf::from(record.destination); let stage = transaction.join("stage"); let backup = transaction.join("backup"); + if matches!( + record.phase, + NativeLibraryTransactionPhase::CatalogCommitted + ) { + if let Err(error) = cleanup_native_committed_transaction(&transaction) { + log::warn!( + "Skill Library committed transaction cleanup remains pending at {}: {error}", + transaction.display() + ); + } + continue; + } match record.phase { NativeLibraryTransactionPhase::Preparing => {} NativeLibraryTransactionPhase::Staged if destination.exists() || stage.exists() => {} @@ -1240,6 +1686,14 @@ fn recover_native_transactions(root: &Path, catalog_hash: Option<&str>) -> Resul { fs::rename(&backup, &destination)?; } + NativeLibraryTransactionPhase::BackedUp + if !record.desired_presence + && !destination.exists() + && backup.exists() + && !stage.exists() => + { + fs::rename(&backup, &destination)?; + } NativeLibraryTransactionPhase::BackedUp if destination.exists() && backup.exists() && !stage.exists() => {} NativeLibraryTransactionPhase::Activated => { @@ -1257,8 +1711,6 @@ fn recover_native_transactions(root: &Path, catalog_hash: Option<&str>) -> Resul rollback_native_library_content(&destination, &backup)?; } } - NativeLibraryTransactionPhase::CatalogCommitted - if destination.exists() == record.desired_presence => {} _ => { return Err(AppError::ConfigurationCorrupted { message: format!( @@ -1327,16 +1779,30 @@ fn finalize_native_catalog_commit(root: &Path, catalog_hash: &str) -> Result<(), record.desired_presence, record.expected_catalog_hash, )?; - let backup = transaction.join("backup"); - if backup.exists() { - fs::remove_dir_all(backup)?; + if let Err(error) = cleanup_native_committed_transaction(&transaction) { + log::warn!( + "Skill Library commit succeeded but cleanup remains pending at {}: {error}", + transaction.display() + ); } - fs::remove_dir_all(transaction)?; } } Ok(()) } +fn cleanup_native_committed_transaction(transaction: &Path) -> Result<(), AppError> { + let stage = transaction.join("stage"); + if stage.exists() { + fs::remove_dir_all(stage)?; + } + let backup = transaction.join("backup"); + if backup.exists() { + fs::remove_dir_all(backup)?; + } + fs::remove_dir_all(transaction)?; + Ok(()) +} + fn native_transaction_directories(root: &Path) -> Result, AppError> { let transactions = root.join(".transactions"); let Ok(entries) = fs::read_dir(transactions) else { @@ -1356,35 +1822,6 @@ fn bytes_sha256(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } -fn payload_archive(payload: &SkillPayload) -> Result, AppError> { - crate::core::skill_payload::verify_skill_payload_integrity(payload)?; - let mut builder = tar::Builder::new(Vec::new()); - for entry in &payload.entries { - let mut header = tar::Header::new_gnu(); - header.set_path(Path::new("stage").join(&entry.relative_path))?; - match entry.kind { - crate::core::skill_payload::PayloadEntryKind::Directory => { - header.set_entry_type(tar::EntryType::Directory); - header.set_mode(0o755); - header.set_size(0); - header.set_cksum(); - builder.append(&header, std::io::empty())?; - } - crate::core::skill_payload::PayloadEntryKind::File => { - let blob_id = entry.blob_id.as_deref().ok_or(AppError::StalePayload)?; - let content = payload.blobs.get(blob_id).ok_or(AppError::StalePayload)?; - header.set_entry_type(tar::EntryType::Regular); - header.set_mode(if entry.executable { 0o755 } else { 0o644 }); - header.set_size(content.len() as u64); - header.set_cksum(); - builder.append(&header, content.as_slice())?; - } - } - } - builder.finish()?; - Ok(builder.into_inner()?) -} - fn validate_storage_component(value: &str) -> Result<(), AppError> { if value.is_empty() || matches!(value, "." | "..") || value.contains(['/', '\\', '\0']) { return Err(AppError::Validation { @@ -1395,18 +1832,6 @@ fn validate_storage_component(value: &str) -> Result<(), AppError> { Ok(()) } -fn wsl_catalog_locator(session: &crate::environment::wsl::WslSession) -> ResourceLocator { - ResourceLocator { - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), - }, - native_path: format!( - "{}/.skill-deck/skill-libraries/catalog.json", - session.home.trim_end_matches('/') - ), - } -} - fn application_relative_path(scope: &SkillLocation) -> Result { match scope { SkillLocation::Global => Ok(PathBuf::from("global.json")), @@ -1417,6 +1842,113 @@ fn application_relative_path(scope: &SkillLocation) -> Result } } +struct ApplicationContexts { + scopes: Vec<(String, SkillLocation)>, + problems: Vec, +} + +fn native_application_contexts(root: &Path) -> Result { + let mut scopes = vec![("global".to_string(), SkillLocation::Global)]; + let mut problems = Vec::new(); + match fs::read_dir(root) { + Ok(entries) => { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + problems.push(ApplicationInventoryProblem { + storage_key: "applications".to_string(), + error: error.into(), + }); + continue; + } + }; + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "global.json" || name == "projects" { + continue; + } + problems.push(ApplicationInventoryProblem { + storage_key: name, + error: AppError::ConfigurationCorrupted { + message: "unknown Skill Library application entry".to_string(), + }, + }); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ApplicationContexts { scopes, problems }); + } + Err(error) => { + problems.push(ApplicationInventoryProblem { + storage_key: "applications".to_string(), + error: error.into(), + }); + return Ok(ApplicationContexts { scopes, problems }); + } + } + + let projects = root.join("projects"); + match fs::read_dir(&projects) { + Ok(entries) => { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + problems.push(ApplicationInventoryProblem { + storage_key: "projects".to_string(), + error: error.into(), + }); + continue; + } + }; + let name = entry.file_name(); + let display = name.to_string_lossy().into_owned(); + let project_id = Path::new(&name) + .file_stem() + .and_then(|value| value.to_str()) + .filter(|_| { + Path::new(&name) + .extension() + .is_some_and(|value| value == "json") + }); + let valid = entry.file_type().is_ok_and(|kind| kind.is_file()) + && project_id + .is_some_and(|project_id| validate_storage_component(project_id).is_ok()); + if let Some(project_id) = project_id.filter(|_| valid) { + scopes.push(( + format!("projects/{display}"), + SkillLocation::Project { + project_id: project_id.to_string(), + }, + )); + } else { + problems.push(ApplicationInventoryProblem { + storage_key: format!("projects/{display}"), + error: AppError::ConfigurationCorrupted { + message: "invalid Skill Library application Scope key".to_string(), + }, + }); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => problems.push(ApplicationInventoryProblem { + storage_key: "projects".to_string(), + error: error.into(), + }), + } + scopes.sort_by(|left, right| left.0.cmp(&right.0)); + problems.sort_by(|left, right| left.storage_key.cmp(&right.storage_key)); + Ok(ApplicationContexts { scopes, problems }) +} + +fn application_scope_key(scope: &SkillLocation) -> String { + match scope { + SkillLocation::Global => "global".to_string(), + SkillLocation::Project { project_id } => format!("project:{project_id}"), + } +} + fn wsl_application_locator( session: &crate::environment::wsl::WslSession, scope: &SkillLocation, @@ -1440,9 +1972,10 @@ mod tests { use super::*; use crate::application::library_application::{ - LibraryApplicationRecord, LibraryApplicationRepository, LibraryApplicationState, + ApplicationRegistry, LibraryApplicationRecord, LibraryApplicationState, }; use crate::application::skill_libraries::SkillLibraryModule; + use crate::application::skill_libraries::LIBRARY_SCHEMA_VERSION; use crate::application::skill_paths::{SkillPathObserver, SkillTargetRequest}; use crate::core::projects::ProjectMigrationState; use crate::core::skill_payload::build_skill_payload; @@ -1582,6 +2115,38 @@ mod tests { assert!(fs::metadata(temp.path().join("libraries/catalog.json")).is_ok()); } + #[tokio::test] + async fn native_repository_rejects_catalog_without_retired_members() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("catalog.json"), + br#"{ + "schemaVersion": 3, + "libraries": [{ + "id": "backend", + "name": "Backend", + "skills": [] + }] + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + + let error = repository.load(&EnvironmentRef::Native).await.unwrap_err(); + assert!(matches!( + error, + AppError::ConfigurationCorrupted { message } + if message.contains("Skill Library catalog") + && message.contains("retiredSkills") + )); + } + #[tokio::test] async fn native_repository_deletes_one_library_as_a_single_intent() { let temp = tempfile::tempdir().unwrap(); @@ -1641,6 +2206,157 @@ mod tests { assert!(library_path.is_file()); } + #[cfg(unix)] + #[tokio::test] + async fn native_library_delete_stays_committed_when_backup_cleanup_is_blocked() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = Arc::new(RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + )); + let created = SkillLibraryModule::new(repository.clone()) + .create(EnvironmentRef::Native, "Backend".to_string()) + .await + .unwrap(); + let library_id = created.libraries[0].id.clone(); + let library_path = root.join("libraries").join(library_id.as_str()); + let locked = library_path.join("skills/locked"); + fs::create_dir_all(&locked).unwrap(); + fs::write(locked.join("SKILL.md"), b"content").unwrap(); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o500)).unwrap(); + + let result = repository + .delete_library(&EnvironmentRef::Native, &library_id) + .await; + + assert!(result.is_ok(), "delete must stay committed: {result:?}"); + assert!(repository + .load(&EnvironmentRef::Native) + .await + .expect("load committed catalog") + .libraries + .is_empty()); + assert!(!library_path.exists()); + + if let Ok(entries) = fs::read_dir(root.join(".transactions")) { + for entry in entries.flatten() { + let locked = entry.path().join("backup/skills/locked"); + if locked.exists() { + fs::set_permissions(locked, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + } + } + + #[tokio::test] + async fn committed_cleanup_does_not_depend_on_a_later_destination_state() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let transaction = root.join(".transactions/committed-cleanup"); + let destination = root.join("libraries/lib-one/skills/demo"); + fs::create_dir_all(transaction.join("backup")).unwrap(); + fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + fs::write( + transaction.join("transaction.json"), + serde_json::to_vec(&NativeLibraryTransaction { + destination: destination.to_string_lossy().into_owned(), + phase: NativeLibraryTransactionPhase::CatalogCommitted, + desired_presence: true, + expected_catalog_hash: Some("committed".to_string()), + }) + .unwrap(), + ) + .unwrap(); + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: Vec::new(), + extra: serde_json::Map::new(), + }; + fs::write( + root.join("catalog.json"), + serde_json::to_vec_pretty(&catalog).unwrap(), + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + + let loaded = repository.load(&EnvironmentRef::Native).await; + + assert!(loaded.is_ok()); + assert!(!transaction.exists()); + } + + #[cfg(windows)] + #[tokio::test] + async fn native_library_delete_keeps_the_original_while_a_member_file_is_open() { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING, + }; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = Arc::new(RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + )); + let created = SkillLibraryModule::new(repository.clone()) + .create(EnvironmentRef::Native, "Backend".to_string()) + .await + .unwrap(); + let library_id = created.libraries[0].id.clone(); + let library_path = root.join("libraries").join(library_id.as_str()); + let member_file = library_path.join("skills/locked/SKILL.md"); + fs::create_dir_all(member_file.parent().unwrap()).unwrap(); + fs::write(&member_file, b"content").unwrap(); + let wide = member_file + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + assert_ne!(handle, INVALID_HANDLE_VALUE); + + let result = repository + .delete_library(&EnvironmentRef::Native, &library_id) + .await; + + assert!(result.is_err(), "open member must block the directory move"); + let catalog = repository + .load(&EnvironmentRef::Native) + .await + .expect("load original catalog"); + assert_eq!(catalog.libraries.len(), 1); + assert_eq!(catalog.libraries[0].id, library_id); + assert!(member_file.exists()); + + unsafe { CloseHandle(handle) }; + repository + .delete_library(&EnvironmentRef::Native, &library_id) + .await + .expect("delete after releasing the file"); + assert!(!library_path.exists()); + } + #[tokio::test] async fn native_repository_rejects_deleting_an_unknown_library() { let temp = tempfile::tempdir().unwrap(); @@ -1809,7 +2525,7 @@ mod tests { } #[tokio::test] - async fn native_conditional_commit_upserts_and_deletes_one_complete_member() { + async fn native_conditional_commit_upserts_and_retires_one_complete_member() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("libraries"); let repository = Arc::new(RuntimeSkillLibraryRepository::new( @@ -1829,6 +2545,11 @@ mod tests { b"---\nname: demo\ndescription: Demo\n---\nbody\n", ) .unwrap(); + let manifest_hash = crate::environment::native::content_manifest::read_directory(&source) + .unwrap() + .hash() + .as_str() + .to_string(); let payload = build_skill_payload(&source).unwrap(); repository @@ -1854,7 +2575,7 @@ mod tests { "pluginName": null, "wellKnown": null }), - content_manifest_hash: "hash".to_string(), + content_manifest_hash: manifest_hash, updated_at: None, extra: serde_json::Map::new(), }), @@ -1884,7 +2605,28 @@ mod tests { library_id: library_id.clone(), skill_name: "demo".to_string(), expected: native_member_expectation(repository.as_ref(), &library_id, "demo").await, - mutation: LibraryMemberMutation::Delete, + mutation: LibraryMemberMutation::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + }) + .await + .unwrap(); + assert!(destination.join("SKILL.md").is_file()); + let catalog = repository.load(&EnvironmentRef::Native).await.unwrap(); + assert!(catalog.libraries[0].skills.is_empty()); + assert_eq!(catalog.libraries[0].retired_skills.len(), 1); + + repository + .purge_retired(PurgeRetiredLibraryMemberRequest { + environment: EnvironmentRef::Native, + library_id: library_id.clone(), + skill_name: "demo".to_string(), + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), }) .await .unwrap(); @@ -1894,7 +2636,7 @@ mod tests { .await .unwrap() .libraries[0] - .skills + .retired_skills .is_empty()); } @@ -1951,27 +2693,325 @@ mod tests { project_id: "project-1".to_string(), }, }; - let mut global_record = LibraryApplicationRecord::empty(global.clone()); + let mut global_record = LibraryApplicationRecord::empty(); global_record.current = LibraryApplicationState { ordered_library_ids: vec![LibraryId::parse("global-library")], selected_agent_ids: Vec::new(), }; - let mut project_record = LibraryApplicationRecord::empty(project.clone()); + let mut project_record = LibraryApplicationRecord::empty(); project_record.current = LibraryApplicationState { ordered_library_ids: vec![LibraryId::parse("project-library")], selected_agent_ids: Vec::new(), }; - repository.save_application(&global_record).await.unwrap(); - repository.save_application(&project_record).await.unwrap(); + let global_snapshot = repository.load_application(&global).await.unwrap(); + repository + .save_application_if(&global_snapshot, &global_record) + .await + .unwrap(); + let project_snapshot = repository.load_application(&project).await.unwrap(); + repository + .save_application_if(&project_snapshot, &project_record) + .await + .unwrap(); assert_eq!( - repository.load_application(&global).await.unwrap(), + repository.load_application(&global).await.unwrap().record, global_record ); assert_eq!( - repository.load_application(&project).await.unwrap(), + repository.load_application(&project).await.unwrap().record, project_record ); } + + #[tokio::test] + async fn project_application_removal_is_bound_to_the_loaded_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + temp.path().join("libraries"), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let project = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Project { + project_id: "project-1".to_string(), + }, + }; + let missing = repository.load_application(&project).await.unwrap(); + let saved = repository + .save_application_if(&missing, &LibraryApplicationRecord::empty()) + .await + .unwrap(); + fs::write(&saved.target.native_path, b"external change").unwrap(); + + let error = repository.remove_application_if(&saved).await.unwrap_err(); + + assert!(matches!(error, AppError::StaleTarget)); + assert_eq!( + fs::read(&saved.target.native_path).unwrap(), + b"external change" + ); + } + + #[test] + fn existing_schema_one_application_record_is_accepted() { + let record = parse_library_application_record( + br#"{ + "schemaVersion": 1, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "checkpoint": { "members": [] }, + "pending": null + }"#, + ) + .expect("schema 1 is the current application record format"); + + assert_eq!(record.schema_version, 1); + } + + #[tokio::test] + async fn application_record_accepts_unknown_route_fields_and_saves_canonical_schema() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + fs::create_dir_all(&applications).unwrap(); + fs::write( + applications.join("global.json"), + br#"{ + "schemaVersion": 1, + "target": { + "environment": { "kind": "wsl", "distro_name": "Ubuntu" }, + "scope": { "scope": "global" } + }, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "checkpoint": { "members": [] }, + "pending": null + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + + let record = repository.load_application(&context).await.unwrap(); + assert_eq!(record.current, LibraryApplicationState::default()); + repository + .save_application_if(&record, &record.record) + .await + .unwrap(); + + let stored: serde_json::Value = + serde_json::from_slice(&fs::read(applications.join("global.json")).unwrap()).unwrap(); + assert!(stored.get("target").is_none()); + } + + #[tokio::test] + async fn application_record_without_checkpoint_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + fs::create_dir_all(&applications).unwrap(); + fs::write( + applications.join("global.json"), + br#"{ + "schemaVersion": 1, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "pendingOperation": null + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + + let error = repository.load_application(&context).await.unwrap_err(); + assert!(matches!( + error, + AppError::ConfigurationCorrupted { message } + if message.contains("Skill Library application record") + && message.contains("checkpoint") + )); + } + + #[tokio::test] + async fn application_record_with_incomplete_pending_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + fs::create_dir_all(&applications).unwrap(); + fs::write( + applications.join("global.json"), + br#"{ + "schemaVersion": 1, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "checkpoint": { "members": [] }, + "pending": { + "reconciliationId": "", + "attention": "pending", + "reasons": [], + "beforeApplication": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "targetApplication": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "recognizedMembers": [], + "targetMembers": [{ "libraryId": "library-a", "memberName": "demo" }] + } + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + + assert!(matches!( + repository.load_application(&context).await, + Err(AppError::ConfigurationCorrupted { .. }) + )); + } + + #[tokio::test] + async fn application_commit_rejects_a_changed_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + let observed = repository.load_application(&context).await.unwrap(); + let external = LibraryApplicationRecord::empty(); + let path = root.join("applications/global.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, serde_json::to_vec_pretty(&external).unwrap()).unwrap(); + let mut replacement = LibraryApplicationRecord::empty(); + replacement.current.ordered_library_ids = vec![LibraryId::parse("library-a")]; + + assert!(matches!( + repository + .save_application_if(&observed, &replacement) + .await, + Err(AppError::StaleTarget) + )); + assert_eq!( + serde_json::from_slice::(&fs::read(path).unwrap()).unwrap(), + external + ); + } + + #[tokio::test] + async fn native_application_inventory_includes_orphans_and_reports_problems() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + let project_records = applications.join("projects"); + fs::create_dir_all(&project_records).unwrap(); + let bytes = serde_json::to_vec_pretty(&LibraryApplicationRecord::empty()).unwrap(); + fs::write(applications.join("global.json"), &bytes).unwrap(); + fs::write(project_records.join("registered.json"), &bytes).unwrap(); + fs::write(project_records.join("orphan.json"), &bytes).unwrap(); + fs::write(project_records.join("broken.json"), b"{").unwrap(); + let mut future: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + future["schemaVersion"] = serde_json::json!( + crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION + 1 + ); + fs::write( + project_records.join("future.json"), + serde_json::to_vec_pretty(&future).unwrap(), + ) + .unwrap(); + fs::create_dir(project_records.join("not-a-record.json")).unwrap(); + fs::write(applications.join("unexpected.json"), &bytes).unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + + let inventory = repository.enumerate(&EnvironmentRef::Native).await.unwrap(); + + assert!(!inventory.complete); + assert_eq!(inventory.records.len(), 3); + assert!(inventory + .records + .iter() + .any(|entry| { matches!(entry.context.scope, SkillLocation::Global) })); + assert!(inventory.records.iter().any(|entry| { + matches!( + &entry.context.scope, + SkillLocation::Project { project_id } if project_id == "registered" + ) + })); + assert!(inventory.records.iter().any(|entry| { + matches!( + &entry.context.scope, + SkillLocation::Project { project_id } if project_id == "orphan" + ) + })); + assert_eq!(inventory.problems.len(), 4); + + let usage = repository + .usage_projection(&EnvironmentRef::Native) + .await + .unwrap(); + assert!(!usage.inventory_complete); + assert_eq!(usage.problem_count, 4); + } + + #[tokio::test] + async fn native_repository_restores_a_retired_member_if_delete_crashes_after_rename() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + repository + .save(&EnvironmentRef::Native, &LibraryCatalog::default()) + .await + .unwrap(); + let destination = root.join("libraries/library-1/skills/demo"); + let transaction = root.join(".transactions/interrupted-delete"); + let backup = transaction.join("backup"); + fs::create_dir_all(destination.parent().unwrap()).unwrap(); + fs::create_dir_all(&backup).unwrap(); + fs::write(backup.join("SKILL.md"), b"retired content").unwrap(); + write_native_transaction( + &transaction.join("transaction.json"), + &destination, + NativeLibraryTransactionPhase::BackedUp, + false, + None, + ) + .unwrap(); + + repository.load(&EnvironmentRef::Native).await.unwrap(); + + assert_eq!( + fs::read(destination.join("SKILL.md")).unwrap(), + b"retired content" + ); + assert!(!transaction.exists()); + } } diff --git a/src-tauri/src/runtime/source_acquisition.rs b/src-tauri/src/runtime/source_acquisition.rs index a45941f3..0bf7b4af 100644 --- a/src-tauri/src/runtime/source_acquisition.rs +++ b/src-tauri/src/runtime/source_acquisition.rs @@ -247,6 +247,9 @@ impl SourceDiscoveryService { ManagedDownloadedDirectory::new(root), RetainedSourceOptions { trust_metadata: Some(fetched.trust_metadata), + redirected_download_host: fetched.redirected_download_host, + redirected_download_hosts: fetched.redirected_download_hosts, + member_failures: fetched.member_failures, internal_skill_visibility: well_known_visibility, ..Default::default() }, @@ -284,6 +287,8 @@ impl SourceDiscoveryService { parsed, requested_source, SourceDiscoveryPolicy { + allow_empty_catalog: false, + selected_skill_names: None, full_depth: false, internal_skill_visibility, }, @@ -299,6 +304,8 @@ impl SourceDiscoveryService { parsed, requested_source, SourceDiscoveryPolicy { + allow_empty_catalog: false, + selected_skill_names: None, full_depth: false, internal_skill_visibility, }, @@ -436,6 +443,8 @@ mod tests { .expect("well-known root"), trust_metadata: HashMap::::new(), redirected_download_host: None, + redirected_download_hosts: Vec::new(), + member_failures: std::collections::BTreeMap::new(), }) }) } diff --git a/src-tauri/src/runtime/update_service.rs b/src-tauri/src/runtime/update_service.rs index 10fb41f3..96994f23 100644 --- a/src-tauri/src/runtime/update_service.rs +++ b/src-tauri/src/runtime/update_service.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::sync::Arc; use crate::application::agent_registry_source::AgentRegistrySnapshotSource; @@ -16,7 +16,7 @@ use crate::application::source_acquisition::{ SelectedPayloadAcquisitionService, SourceDiscoveryPolicy, }; use crate::application::source_evidence::{ - RemoteSnapshotId, SkillRevision, SourceEvidenceCoordinator, SourceSnapshotFacts, + RemoteSnapshotId, SkillRevision, SourceEvidenceCoordinator, }; use crate::application::source_evidence_provider::RuntimeSourceEvidenceDetector; use crate::application::source_snapshot_reuse::SourceSnapshotReuseIndex; @@ -26,9 +26,10 @@ use crate::application::update::{ }; use crate::application::update_check::UpdateCheckService; use crate::application::update_planner::ConcreteUpdatePlanner; -use crate::application::update_subjects::{ - InstalledUpdateSubjectProvider, LibraryUpdateSubjectProvider, +use crate::application::update_records::{ + InstalledUpdateRecordProvider, LibraryUpdateRecordProvider, }; +use crate::application::update_subjects::LibraryUpdateSubjectProvider; #[cfg(test)] use crate::application::wellknown_access::UnavailableWellKnownAccess; use crate::application::wellknown_access::WellKnownAccess; @@ -63,11 +64,11 @@ enum RetainedSnapshotAction { } fn retained_snapshot_action( - environment: &EnvironmentRef, + _environment: &EnvironmentRef, retained_revision: Option<&str>, probe: Result<&str, &AppError>, ) -> RetainedSnapshotAction { - if !matches!(environment, EnvironmentRef::Native) || retained_revision.is_none() { + if retained_revision.is_none() { return RetainedSnapshotAction::Reacquire; } match probe { @@ -79,10 +80,6 @@ fn retained_snapshot_action( } } -fn snapshot_reuse_eligible(environment: &EnvironmentRef) -> bool { - matches!(environment, EnvironmentRef::Native) -} - impl RuntimeSkillSourceModule { pub fn new( payloads: Arc, @@ -128,36 +125,43 @@ impl RuntimeSkillSourceModule { return Err(AppError::MutationCancelled); } let provider = group.evidence_key.remote.provider(); - let reusable = if snapshot_reuse_eligible(&group.environment) { + let reusable = if provider != &SourceProvider::WellKnown { self.snapshots.candidate(&group.key, self.payloads.as_ref()) } else { None }; let discovery_session = match reusable { - Some((_retained_revision, discovery)) if provider == &SourceProvider::WellKnown => { - discovery - } Some((retained_revision, discovery)) => { let probe_source = group.descriptor.source().to_string(); let probe_ref = group.descriptor.git_ref().map(ToString::to_string); let probe_cancellation = cancellation.clone(); let git_transport = Arc::clone(&self.git_transport); - let probed = tokio::task::spawn_blocking(move || { - git_transport.probe_ref_revision( - &probe_source, - probe_ref.as_deref(), - probe_cancellation, - ) - }) - .await; - let action = match probed { - Ok(result) => retained_snapshot_action( - &group.environment, - Some(&retained_revision), - result.as_ref().map(String::as_str), - ), - Err(_) => RetainedSnapshotAction::Reacquire, + let probed = match &group.environment { + EnvironmentRef::Native => tokio::task::spawn_blocking(move || { + git_transport.probe_ref_revision( + &probe_source, + probe_ref.as_deref(), + probe_cancellation, + ) + }) + .await + .map_err(|_| AppError::StaleEnvironment)?, + EnvironmentRef::Wsl { distro_name } => { + self.wsl_source + .probe_ref( + distro_name, + &probe_source, + probe_ref.as_deref(), + probe_cancellation, + ) + .await + } }; + let action = retained_snapshot_action( + &group.environment, + Some(&retained_revision), + probed.as_ref().map(String::as_str), + ); match action { RetainedSnapshotAction::Reuse => discovery, RetainedSnapshotAction::Cancelled => return Err(AppError::MutationCancelled), @@ -169,106 +173,171 @@ impl RuntimeSkillSourceModule { } None => self.discover_group(group, cancellation.clone()).await?, }; - if cancellation.is_cancelled() { - return Err(AppError::MutationCancelled); - } - let retained = self.payloads.source_snapshot(&discovery_session)?; - let catalog = retained - .skills() - .map(|skill| snapshot_skill_key(provider, &skill.skill_name, &skill.relative_path)) - .collect::>(); - let mut selected_paths = Vec::with_capacity(group.skills.len()); - let mut selected_skills = Vec::with_capacity(group.skills.len()); - let mut skill_errors = Vec::new(); - for locked in &group.skills { - let available = retained.skills().find(|available| { - retained_skill_matches( - provider, - &locked.name, - locked.skill_path(), - &available.skill_name, - &available.relative_path, - ) - }); - if let Some(available) = available { - selected_paths.push(available.relative_path.clone()); - selected_skills.push(locked); - continue; + let result = async { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); } - if let Some(renamed) = retained.skills().find(|available| { - normalize_skill_folder_path(&available.relative_path) - == normalize_skill_folder_path(locked.skill_path()) - }) { - skill_errors.push(( - locked.name.clone(), - AppError::UpstreamSkillNameChanged { - expected_name: locked.name.clone(), - actual_name: renamed.skill_name.clone(), - }, - )); - } else { - skill_errors.push(( - locked.name.clone(), - AppError::UpstreamSkillDeleted { - skill_name: locked.name.clone(), - }, - )); + let selected = SelectedPayloadAcquisitionService::new(self.payloads.clone()); + let mut skill_errors = Vec::new(); + if provider != &SourceProvider::WellKnown { + for skill in &group.skills { + if let Err(error) = selected + .ensure_saved_path( + &discovery_session, + skill.skill_path(), + cancellation.clone(), + ) + .await + { + if error == AppError::MutationCancelled { + return Err(error); + } + let error = if matches!(error, AppError::PathNotFound { .. }) { + AppError::UpstreamSkillDeleted { + skill_name: skill.name.clone(), + } + } else { + error + }; + skill_errors.push((skill.name.clone(), error)); + } + } } - } - let handles = if selected_paths.is_empty() { - Vec::new() - } else { - SelectedPayloadAcquisitionService::new(self.payloads.clone()) - .acquire(AcquireSelectedPayloadsRequest { - discovery_session: discovery_session.clone(), - skill_paths: selected_paths, - }) - .await? - }; - if cancellation.is_cancelled() { - return Err(AppError::MutationCancelled); - } - if handles.len() != selected_skills.len() { - return Err(AppError::StalePayload); - } - let ref_revision = if provider == &SourceProvider::WellKnown { - discovery_session.source_fingerprint.clone() - } else { - source_ref_revision(self.payloads.as_ref(), &discovery_session).await? - }; - let facts = SourceSnapshotFacts { - discovery_session, - snapshot_id: RemoteSnapshotId::new( + let retained = self.payloads.source_snapshot(&discovery_session)?; + let mut selected_paths = Vec::with_capacity(group.skills.len()); + let mut selected_skills = Vec::with_capacity(group.skills.len()); + for locked in &group.skills { + if skill_errors.iter().any(|(name, _)| name == &locked.name) { + continue; + } + if let Some(reason) = retained.member_failure(&locked.name) { + skill_errors.push(( + locked.name.clone(), + AppError::WellKnownSourceFailed { reason }, + )); + continue; + } + let available = retained.skills().find(|available| { + retained_skill_matches( + provider, + &locked.name, + locked.skill_path(), + &available.skill_name, + &available.relative_path, + ) + }); + if let Some(available) = available { + selected_paths.push(available.relative_path.clone()); + selected_skills.push(locked); + continue; + } + if let Some(renamed) = retained.skills().find(|available| { + normalize_skill_folder_path(&available.relative_path) + == normalize_skill_folder_path(locked.skill_path()) + }) { + skill_errors.push(( + locked.name.clone(), + AppError::UpstreamSkillNameChanged { + expected_name: locked.name.clone(), + actual_name: renamed.skill_name.clone(), + }, + )); + } else { + skill_errors.push(( + locked.name.clone(), + AppError::UpstreamSkillDeleted { + skill_name: locked.name.clone(), + }, + )); + } + } + let mut handles = Vec::new(); + let mut acquired_skills = Vec::new(); + let mut leases = Vec::new(); + let mut skill_revisions = BTreeMap::new(); + for (locked, path) in selected_skills.into_iter().zip(selected_paths) { + let acquired = async { + let handle = SelectedPayloadAcquisitionService::new(self.payloads.clone()) + .acquire(AcquireSelectedPayloadsRequest { + discovery_session: discovery_session.clone(), + skill_paths: vec![path], + }) + .await? + .into_iter() + .next() + .ok_or(AppError::StalePayload)?; + let lease = self.payloads.pin_verified(&handle).await?; + let revision = acquisition_skill_revision(provider, lease.planning_metadata())?; + Ok::<_, AppError>((handle, lease, revision)) + } + .await; + match acquired { + Ok((handle, lease, revision)) => { + skill_revisions.insert( + snapshot_skill_key(provider, &locked.name, locked.skill_path()), + revision, + ); + acquired_skills.push(locked.name.clone()); + handles.push(handle); + leases.push(Arc::new(lease)); + } + Err(AppError::MutationCancelled) => return Err(AppError::MutationCancelled), + Err(error) => skill_errors.push((locked.name.clone(), error)), + } + } + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let ref_revision = if provider == &SourceProvider::WellKnown { + crate::application::mutation::plan::stable_digest(&skill_revisions)? + } else { + source_ref_revision(self.payloads.as_ref(), &discovery_session).await? + }; + let snapshot_id = RemoteSnapshotId::new( group.key.normalized_ref.clone(), resolved_ref(&group.key.normalized_ref), ref_revision, - ), - complete_skill_path_catalog: catalog, - }; - let mut skill_revisions = BTreeMap::new(); - for (locked, handle) in selected_skills.iter().zip(&handles) { - let lease = self.payloads.pin_verified(handle).await?; - skill_revisions.insert( - snapshot_skill_key(provider, &locked.name, locked.skill_path()), - acquisition_skill_revision(provider, lease.planning_metadata())?, ); + if let Err(error) = self.evidence.record_acquisition( + group.evidence_key.clone(), + group.key.environment.clone(), + snapshot_id.clone(), + skill_revisions, + ) { + log::warn!("Could not persist acquired source evidence: {error}"); + } + let redirected_download_hosts = retained.download_hosts(); + drop(retained); + if provider == &SourceProvider::WellKnown { + if let Err(error) = self + .payloads + .release_source_snapshot(&discovery_session) + .await + { + log::warn!( + "Prepared content retained; source cleanup will be retried: {error}" + ); + } + } else { + self.snapshots.remember( + group.key.clone(), + snapshot_id.commit_revision, + discovery_session.clone(), + ); + } + Ok(AcquiredUpdateSource { + discovery_session: discovery_session.clone(), + redirected_download_hosts, + _leases: leases, + payloads: acquired_skills.into_iter().zip(handles).collect(), + skill_errors, + }) } - self.evidence.record_acquisition( - group.evidence_key.clone(), - group.key.clone(), - facts.clone(), - skill_revisions, - )?; - Ok(AcquiredUpdateSource { - facts, - redirected_download_host: retained.descriptor().redirected_download_host.clone(), - payloads: selected_skills - .into_iter() - .map(|skill| skill.name.clone()) - .zip(handles) - .collect(), - skill_errors, - }) + .await; + if let Err(error) = self.payloads.make_source_optional(&discovery_session).await { + log::warn!("Source retention cleanup will be retried: {error}"); + } + result } async fn discover_group( @@ -281,11 +350,20 @@ impl RuntimeSkillSourceModule { .descriptor .parsed_source(group.evidence_key.remote.provider()); if group.evidence_key.remote.provider() == &SourceProvider::WellKnown { + let selected_names = group + .skills + .iter() + .map(|skill| skill.name.clone()) + .collect::>(); return match &group.environment { EnvironmentRef::Native => { - let fetched = self.wellknown.fetch(&source, &cancellation).await.map_err( - crate::application::wellknown_access::WellKnownFetchError::into_error, - )?; + let fetched = self + .wellknown + .fetch_selected(&source, &selected_names, &cancellation) + .await + .map_err( + crate::application::wellknown_access::WellKnownFetchError::into_error, + )?; let root = fetched.repo_path.clone(); let owner = ManagedDownloadedDirectory::new(root.clone()); retain_discovered_source( @@ -302,6 +380,8 @@ impl RuntimeSkillSourceModule { RetainedSourceOptions { trust_metadata: Some(fetched.trust_metadata), redirected_download_host: fetched.redirected_download_host, + redirected_download_hosts: fetched.redirected_download_hosts, + member_failures: fetched.member_failures, full_depth: true, internal_skill_visibility: InternalSkillVisibility::All, ..Default::default() @@ -317,6 +397,8 @@ impl RuntimeSkillSourceModule { parsed, source, SourceDiscoveryPolicy { + allow_empty_catalog: true, + selected_skill_names: Some(selected_names), full_depth: true, internal_skill_visibility: InternalSkillVisibility::All, }, @@ -336,6 +418,8 @@ impl RuntimeSkillSourceModule { parsed, source, SourceDiscoveryPolicy { + allow_empty_catalog: true, + selected_skill_names: None, full_depth: true, internal_skill_visibility: InternalSkillVisibility::All, }, @@ -398,35 +482,37 @@ impl SkillSourceModule for RuntimeSkillSourceModule { cancellation: crate::core::mutation::CancellationSignal, ) -> UpdateFuture<'a, Result, AppError>> { Box::pin(async move { - let mut acquisitions = Vec::with_capacity(groups.len()); - for group in groups { - let result = self.acquire_group(group, cancellation.clone()).await; - acquisitions.push(UpdateSourceAcquisition { - source_result_id: group.source_result_id.clone(), - source: group.source.clone(), - skill_names: group - .skills - .iter() - .map(|skill| skill.name.clone()) - .collect(), - result, - }); - if cancellation.is_cancelled() { - for pending in &groups[acquisitions.len()..] { - acquisitions.push(UpdateSourceAcquisition { - source_result_id: pending.source_result_id.clone(), - source: pending.source.clone(), - skill_names: pending + use futures_util::StreamExt; + let jobs = groups + .iter() + .map(|group| { + let cancellation = cancellation.clone(); + let source = self.clone(); + let owned_group = group.clone(); + Box::pin(async move { + let result = tokio::spawn(async move { + source.acquire_group(&owned_group, cancellation).await + }) + .await + .unwrap_or_else(|error| { + Err(AppError::ExecutionFailed { + message: error.to_string(), + }) + }); + UpdateSourceAcquisition { + source_result_id: group.source_result_id.clone(), + source: group.source.clone(), + skill_names: group .skills .iter() .map(|skill| skill.name.clone()) .collect(), - result: Err(AppError::MutationCancelled), - }); - } - break; - } - } + result, + } + }) as UpdateFuture<'_, UpdateSourceAcquisition> + }) + .collect::>(); + let acquisitions = futures_util::stream::iter(jobs).buffered(4).collect().await; Ok(acquisitions) }) } @@ -468,13 +554,11 @@ pub type RuntimeUpdateService = UpdateService< >; pub type RuntimeUpdateCheckService = UpdateCheckService< - InstalledUpdateSubjectProvider, + InstalledUpdateRecordProvider, >; pub type RuntimeLibraryUpdateCheckService = - crate::application::update_check::LibraryUpdateCheckService< - LibraryUpdateSubjectProvider, - >; + crate::application::update_check::LibraryUpdateCheckService; pub type RuntimeLibraryUpdateService = crate::application::library_update::LibraryUpdateService< LibraryUpdateSubjectProvider, @@ -501,9 +585,8 @@ pub fn build_runtime_source_evidence_coordinator( let home = dirs::home_dir().ok_or_else(|| AppError::Path { message: "无法确定用户主目录,不能初始化更新检查状态".to_string(), })?; - SourceEvidenceCoordinator::with_snapshot_reuse_and_state_path( + SourceEvidenceCoordinator::with_state_path( detector, - snapshots, home.join(".skill-deck/state/update-check.json"), ) } @@ -512,21 +595,25 @@ pub fn build_runtime_update_check_service( environments: Arc, registry: Arc, evidence: SourceEvidenceCoordinator, + libraries: Arc, ) -> RuntimeUpdateCheckService { let facts = RuntimePlanningFactSource::for_current_user(registry, environments.clone()); UpdateCheckService::new( - InstalledUpdateSubjectProvider::new(facts, RuntimeTargetFactResolver::new(environments)), + InstalledUpdateRecordProvider::new( + facts, + RuntimeTargetFactResolver::new(environments), + libraries, + ), evidence, ) } pub fn build_runtime_library_update_check_service( repository: Arc, - targets: RuntimeTargetFactResolver, evidence: SourceEvidenceCoordinator, ) -> RuntimeLibraryUpdateCheckService { crate::application::update_check::LibraryUpdateCheckService::new( - LibraryUpdateSubjectProvider::new(repository, targets.clone()), + LibraryUpdateRecordProvider::new(repository), evidence, ) } @@ -553,12 +640,14 @@ pub fn build_runtime_update_service( registry: Arc, execution: RuntimeExecutionDependencies, skill_source: RuntimeSkillSourceModule, + libraries: Arc, ) -> RuntimeUpdateService { let facts = RuntimePlanningFactSource::for_current_user(registry, environments.clone()); let planner = ConcreteUpdatePlanner::new( facts.clone(), RuntimeTargetFactResolver::new(environments.clone()), payloads.clone(), + libraries, || { chrono::Utc::now() .format("%Y-%m-%dT%H:%M:%S%.3fZ") @@ -638,7 +727,7 @@ mod tests { } #[test] - fn failed_probe_or_wsl_snapshot_forces_environment_local_reacquisition() { + fn failed_probe_reacquires_the_source() { let probe_error = AppError::GitCloneFailed { message: "probe unavailable".to_string(), }; @@ -650,20 +739,6 @@ mod tests { ), RetainedSnapshotAction::Reacquire ); - assert!(snapshot_reuse_eligible(&EnvironmentRef::Native)); - assert!(!snapshot_reuse_eligible(&EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - },)); - assert_eq!( - retained_snapshot_action( - &EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }, - Some("revision-1"), - Ok("revision-1"), - ), - RetainedSnapshotAction::Reacquire - ); } #[test] diff --git a/src-tauri/src/runtime/wellknown.rs b/src-tauri/src/runtime/wellknown.rs index 4a5d9177..cd379161 100644 --- a/src-tauri/src/runtime/wellknown.rs +++ b/src-tauri/src/runtime/wellknown.rs @@ -16,6 +16,22 @@ impl RuntimeWellKnownAccess { } impl WellKnownAccess for RuntimeWellKnownAccess { + fn fetch_selected<'a>( + &'a self, + url: &'a str, + skill_names: &'a [String], + cancellation: &'a CancellationSignal, + ) -> WellKnownFetchFuture<'a> { + Box::pin( + crate::runtime::wellknown_protocol::fetch_selected_wellknown_skills_with_client( + &self.http, + url, + Some(skill_names), + cancellation, + ), + ) + } + fn fetch<'a>( &'a self, url: &'a str, diff --git a/src-tauri/src/runtime/wellknown_protocol.rs b/src-tauri/src/runtime/wellknown_protocol.rs index 45193c89..5b746e57 100644 --- a/src-tauri/src/runtime/wellknown_protocol.rs +++ b/src-tauri/src/runtime/wellknown_protocol.rs @@ -9,11 +9,11 @@ use crate::application::wellknown_access::{ extract_hostname, WellKnownFetchError, WellKnownFetchResult, WellKnownTrustMetadata, }; use crate::core::mutation::CancellationSignal; -use crate::error::AppError; +use crate::error::{AppError, SourceAcquisitionFailureReason}; use crate::runtime::http_transport::{HttpGetRequest, HttpTransport, HttpTransportError}; use serde::Deserialize; use sha2::{Digest, Sha256}; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs; use std::path::Path; use std::time::Duration; @@ -25,6 +25,7 @@ const MAX_ARCHIVE_FILES: usize = 1000; const WELL_KNOWN_PATHS: &[&str] = &[".well-known/agent-skills", ".well-known/skills"]; const INDEX_FILE: &str = "index.json"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const LEGACY_MEMBER_TIMEOUT: Duration = Duration::from_secs(120); // --------------------------------------------------------------------------- // Data structures @@ -93,6 +94,10 @@ impl NormalizedWellKnownEntry { struct FetchedWellKnownIndex { index_url: String, entries: Vec, + complete_skill_catalog: BTreeSet, + catalog_complete: bool, + member_failures: BTreeMap, + redirected_hosts: BTreeSet, } // --------------------------------------------------------------------------- @@ -227,6 +232,7 @@ fn validate_skill_entry(entry: &WellKnownSkillEntry) -> Result<(), AppError> { }); } + let mut paths = BTreeSet::new(); for file in &entry.files { if file.trim().is_empty() { return Err(AppError::InvalidSource { @@ -249,6 +255,16 @@ fn validate_skill_entry(entry: &WellKnownSkillEntry) -> Result<(), AppError> { value: format!("Path traversal not allowed: {file}"), }); } + if file.contains(':') + || normalized + .split('/') + .any(|part| part.is_empty() || part == ".") + || !paths.insert(normalized.to_ascii_lowercase()) + { + return Err(AppError::InvalidSource { + value: "Invalid or conflicting Well-known file path".to_string(), + }); + } } Ok(()) @@ -257,69 +273,87 @@ fn validate_skill_entry(entry: &WellKnownSkillEntry) -> Result<(), AppError> { fn normalize_wellknown_index( raw: &serde_json::Value, index_url: &str, -) -> Option> { +) -> Option { let object = raw.as_object()?; let skills = object.get("skills")?.as_array()?; let schema = object.get("$schema").and_then(|value| value.as_str()); - - if schema == Some(DISCOVERY_SCHEMA_V2) { - if skills.is_empty() { - return Some(Vec::new()); - } - let mut entries = Vec::new(); - for value in skills { - let object = value.as_object()?; - let name = object.get("name")?.as_str()?.to_string(); - let description = object.get("description")?.as_str()?.to_string(); - let entry_type = object.get("type")?.as_str()?.to_string(); - let url = object.get("url")?.as_str()?; - let digest = object.get("digest")?.as_str()?.to_string(); - - if validate_skill_name(&name).is_err() - || description.trim().is_empty() - || description.len() > 1024 - || !matches!(entry_type.as_str(), "skill-md" | "archive") - || !is_valid_sha256_digest(&digest) - { - continue; - } - - let artifact_url = Url::parse(index_url).ok()?.join(url).ok()?.to_string(); - entries.push(NormalizedWellKnownEntry::V2 { - name, - entry_type, - artifact_url, - digest, - }); - } - return (!entries.is_empty()).then_some(entries); - } - - if schema.is_some() { + if schema.is_some_and(|schema| schema != DISCOVERY_SCHEMA_V2) { return None; } - - if skills.is_empty() { - return Some(Vec::new()); - } - - let base_url = index_url - .trim_end_matches(&format!("/{INDEX_FILE}")) - .to_string(); - let mut entries = Vec::new(); + let base = Url::parse(index_url).ok()?; + let mut catalog = FetchedWellKnownIndex { + index_url: index_url.to_string(), + entries: Vec::new(), + complete_skill_catalog: BTreeSet::new(), + catalog_complete: true, + member_failures: BTreeMap::new(), + redirected_hosts: BTreeSet::new(), + }; for value in skills { - let entry: WellKnownSkillEntry = serde_json::from_value(value.clone()).ok()?; - if validate_skill_entry(&entry).is_err() { - return None; + let Some(name) = value + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|name| validate_skill_name(name).is_ok()) + else { + catalog.catalog_complete = false; + continue; + }; + if !catalog.complete_skill_catalog.insert(name.to_string()) { + catalog.entries.retain(|entry| entry.name() != name); + catalog.member_failures.insert( + name.to_string(), + SourceAcquisitionFailureReason::InvalidContent, + ); + continue; + } + let entry = (|| { + if schema == Some(DISCOVERY_SCHEMA_V2) { + let description = value.get("description")?.as_str()?; + let entry_type = value.get("type")?.as_str()?; + let digest = value.get("digest")?.as_str()?; + if description.trim().is_empty() + || description.len() > 1024 + || !matches!(entry_type, "skill-md" | "archive") + || !is_valid_sha256_digest(digest) + { + return None; + } + let artifact = base.join(value.get("url")?.as_str()?).ok()?; + if !matches!(artifact.scheme(), "http" | "https") { + return None; + } + Some(NormalizedWellKnownEntry::V2 { + name: name.to_string(), + entry_type: entry_type.to_string(), + artifact_url: artifact.to_string(), + digest: digest.to_string(), + }) + } else { + let mut entry: WellKnownSkillEntry = serde_json::from_value(value.clone()).ok()?; + entry.files = entry + .files + .into_iter() + .map(|path| path.replace('\\', "/")) + .collect(); + validate_skill_entry(&entry).ok()?; + Some(NormalizedWellKnownEntry::Legacy { + name: entry.name, + files: entry.files, + base_url: base.join("./").ok()?.to_string(), + }) + } + })(); + match entry { + Some(entry) => catalog.entries.push(entry), + None => { + catalog.member_failures.insert( + name.to_string(), + SourceAcquisitionFailureReason::InvalidContent, + ); + } } - entries.push(NormalizedWellKnownEntry::Legacy { - name: entry.name, - files: entry.files, - base_url: base_url.clone(), - }); } - - (!entries.is_empty()).then_some(entries) + Some(catalog) } fn is_valid_sha256_digest(value: &str) -> bool { @@ -432,6 +466,15 @@ pub(crate) async fn fetch_wellknown_skills_attempt_with_client( http: &HttpTransport, url: &str, cancellation: &CancellationSignal, +) -> Result { + fetch_selected_wellknown_skills_with_client(http, url, None, cancellation).await +} + +pub(crate) async fn fetch_selected_wellknown_skills_with_client( + http: &HttpTransport, + url: &str, + skill_names: Option<&[String]>, + cancellation: &CancellationSignal, ) -> Result { let operation_id = uuid::Uuid::new_v4().simple().to_string(); @@ -444,44 +487,72 @@ pub(crate) async fn fetch_wellknown_skills_attempt_with_client( WellKnownFetchError::unproven(error) } })?; - materialize_fetched_index(http, fetched_index, cancellation, &operation_id) - .await - .map_err(WellKnownFetchError::catalog_established) + materialize_fetched_index( + http, + fetched_index, + skill_names, + cancellation, + &operation_id, + ) + .await + .map_err(WellKnownFetchError::catalog_established) } async fn materialize_fetched_index( http: &HttpTransport, - fetched_index: FetchedWellKnownIndex, + mut fetched_index: FetchedWellKnownIndex, + skill_names: Option<&[String]>, cancellation: &CancellationSignal, operation_id: &str, ) -> Result { - let entries = fetched_index.entries; - - if entries.is_empty() { - return Err(AppError::NoSkillsFound); + if let Some(names) = skill_names { + fetched_index + .entries + .retain(|entry| names.iter().any(|name| name == entry.name())); + if !fetched_index.catalog_complete { + for name in names { + if !fetched_index.complete_skill_catalog.contains(name) { + fetched_index + .member_failures + .insert(name.clone(), SourceAcquisitionFailureReason::InvalidContent); + } + } + } } + if fetched_index.entries.is_empty() { + return Err( + if fetched_index.member_failures.is_empty() && fetched_index.catalog_complete { + AppError::NoSkillsFound + } else { + AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::InvalidContent, + } + }, + ); + } + let entries = fetched_index.entries; - let temp_path = tempfile::TempDir::new()?.keep(); + let temp = tempfile::TempDir::new()?; + let temp_path = temp.path(); let mut trust_metadata = HashMap::new(); - let mut redirected_download_host = None; + let mut redirected_download_hosts = fetched_index.redirected_hosts; + let mut member_failures = fetched_index.member_failures; + let index_host = extract_hostname(&fetched_index.index_url); let download = WellKnownDownloadContext { http, - temp_path: &temp_path, + temp_path, cancellation, operation_id, }; for entry in &entries { - match entry { + let downloaded = match entry { NormalizedWellKnownEntry::Legacy { name, files, base_url, .. - } => { - let redirected = download_legacy_entry(&download, name, files, base_url).await?; - redirected_download_host = redirected_download_host.or(redirected); - } + } => download_legacy_entry(&download, name, files, base_url).await, NormalizedWellKnownEntry::V2 { name, entry_type, @@ -489,9 +560,27 @@ async fn materialize_fetched_index( digest, .. } => { - let redirected = - download_v2_entry(&download, name, entry_type, artifact_url, digest).await?; - redirected_download_host = redirected_download_host.or(redirected); + if extract_hostname(artifact_url) != index_host { + if let Some(host) = extract_hostname(artifact_url) { + redirected_download_hosts.insert(host); + } + } + download_v2_entry(&download, name, entry_type, artifact_url, digest).await + } + }; + match downloaded { + Ok(hosts) => redirected_download_hosts.extend(hosts), + Err(error @ (AppError::MutationCancelled | AppError::InvalidProxySettings { .. })) => { + return Err(error) + } + Err(error) => { + member_failures.insert(entry.name().to_string(), member_failure_reason(&error)); + match fs::remove_dir_all(temp_path.join(entry.name())) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + continue; } } let digest = match entry { @@ -503,10 +592,22 @@ async fn materialize_fetched_index( trust_metadata.insert(entry.name().to_string(), entry.trust_metadata(digest)); } + if trust_metadata.is_empty() { + return Err(AppError::WellKnownSourceFailed { + reason: member_failures + .values() + .next() + .copied() + .unwrap_or(SourceAcquisitionFailureReason::InvalidContent), + }); + } + Ok(WellKnownFetchResult { - repo_path: temp_path, + repo_path: temp.keep(), trust_metadata, - redirected_download_host, + redirected_download_host: redirected_download_hosts.iter().next().cloned(), + redirected_download_hosts: redirected_download_hosts.into_iter().collect(), + member_failures, }) } @@ -519,9 +620,9 @@ pub(crate) async fn check_wellknown_updates_with_client( let operation_id = uuid::Uuid::new_v4().simple().to_string(); let fetched = fetch_index(http, url, cancellation, &operation_id).await?; let complete_skill_catalog = fetched - .entries + .complete_skill_catalog .iter() - .map(|entry| entry.name().to_string()) + .cloned() .collect::>(); let requested = skill_names .iter() @@ -535,10 +636,8 @@ pub(crate) async fn check_wellknown_updates_with_client( operation_id: &operation_id, }; let mut digests = HashMap::new(); + let mut member_failures = fetched.member_failures; for entry in &fetched.entries { - if !requested.contains(entry.name()) { - continue; - } match entry { NormalizedWellKnownEntry::V2 { name, digest, .. } => { digests.insert(name.clone(), digest.clone()); @@ -549,10 +648,33 @@ pub(crate) async fn check_wellknown_updates_with_client( base_url, .. } => { - download_legacy_entry(&download, name, files, base_url).await?; - digests.insert( - name.clone(), - compute_legacy_skill_digest(&temp.path().join(name))?, + if !requested.contains(name.as_str()) { + continue; + } + match download_legacy_entry(&download, name, files, base_url).await { + Ok(_) => { + digests.insert( + name.clone(), + compute_legacy_skill_digest(&temp.path().join(name))?, + ); + } + Err( + error @ (AppError::MutationCancelled + | AppError::InvalidProxySettings { .. }), + ) => return Err(error), + Err(error) => { + member_failures.insert(name.clone(), member_failure_reason(&error)); + } + } + } + } + } + if !fetched.catalog_complete { + for name in requested { + if !fetched.complete_skill_catalog.contains(name) { + member_failures.insert( + name.to_string(), + SourceAcquisitionFailureReason::InvalidContent, ); } } @@ -562,6 +684,8 @@ pub(crate) async fn check_wellknown_updates_with_client( index_url: fetched.index_url, complete_skill_catalog, digests, + catalog_complete: fetched.catalog_complete, + member_failures, }, ) } @@ -606,25 +730,49 @@ async fn download_legacy_entry( name: &str, files: &[String], base_url: &str, -) -> Result, AppError> { +) -> Result, AppError> { + let deadline = std::time::Instant::now() + LEGACY_MEMBER_TIMEOUT; let skill_dir = context.temp_path.join(name); fs::create_dir_all(&skill_dir)?; - let mut skill_ok = true; - let mut redirected_download_host = None; + let mut redirected_download_hosts = BTreeSet::new(); + let mut total_bytes = 0_u64; + if files.len() > MAX_ARCHIVE_FILES { + return Err(AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::LimitExceeded, + }); + } for file_path in files { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err(AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::Timeout, + }); + } if context.cancellation.is_cancelled() { return Err(AppError::MutationCancelled); } - let file_url = format!("{base_url}/{name}/{file_path}"); + let mut file_url = Url::parse(base_url) + .and_then(|base| base.join(&format!("{name}/"))) + .map_err(|_| AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::InvalidContent, + })?; + file_url + .path_segments_mut() + .map_err(|_| AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::InvalidContent, + })? + .pop_if_empty() + .extend(file_path.split('/')); + let file_url = file_url.to_string(); let response = match context .http .get( HttpGetRequest::new( &file_url, - REQUEST_TIMEOUT, + REQUEST_TIMEOUT.min(remaining), MAX_ARCHIVE_UNPACKED_BYTES as usize, ) .operation_id(context.operation_id) @@ -636,25 +784,33 @@ async fn download_legacy_entry( Err(error) if network_request_was_cancelled(&error) => { return Err(AppError::MutationCancelled); } - _ => { - if file_path.eq_ignore_ascii_case("SKILL.md") { - skill_ok = false; - break; - } - continue; + Err(HttpTransportError::Settings(error)) => return Err(error.into()), + Ok(response) => { + return Err(AppError::WellKnownSourceFailed { + reason: source_failure_reason_from_status(response.status.as_u16()), + }) } + Err(error) => return Err(map_network_error(error, "无法下载 Well-known 成员文件")), }; + total_bytes = total_bytes.saturating_add(response.body.len() as u64); + if total_bytes > MAX_ARCHIVE_UNPACKED_BYTES { + return Err(AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::LimitExceeded, + }); + } - redirected_download_host = redirected_download_host.or_else(|| { - crate::application::source_acquisition::redirected_host( - &file_url, - response.final_url.as_str(), - ) - }); + if let Some(host) = crate::application::source_acquisition::redirected_host( + &file_url, + response.final_url.as_str(), + ) { + redirected_download_hosts.insert(host); + } let target_path = skill_dir.join(file_path); if !target_path.starts_with(&skill_dir) { - continue; + return Err(AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::InvalidContent, + }); } if let Some(parent) = target_path.parent() { @@ -664,11 +820,23 @@ async fn download_legacy_entry( fs::write(&target_path, &response.body)?; } - if !skill_ok { - let _ = fs::remove_dir_all(&skill_dir); + if std::time::Instant::now() >= deadline { + return Err(AppError::WellKnownSourceFailed { + reason: SourceAcquisitionFailureReason::Timeout, + }); } - Ok(redirected_download_host) + Ok(redirected_download_hosts) +} + +fn member_failure_reason(error: &AppError) -> SourceAcquisitionFailureReason { + match error { + AppError::WellKnownSourceFailed { reason } => *reason, + AppError::InvalidSource { .. } + | AppError::InvalidSkillMd { .. } + | AppError::DirectDownloadFailed { .. } => SourceAcquisitionFailureReason::InvalidContent, + _ => SourceAcquisitionFailureReason::Unavailable, + } } async fn download_v2_entry( @@ -677,7 +845,7 @@ async fn download_v2_entry( entry_type: &str, artifact_url: &str, digest: &str, -) -> Result, AppError> { +) -> Result, AppError> { let response = context .http .get( @@ -709,7 +877,9 @@ async fn download_v2_entry( Ok(crate::application::source_acquisition::redirected_host( artifact_url, response.final_url.as_str(), - )) + ) + .into_iter() + .collect()) } "archive" => { let format = detect_archive_format(&bytes, artifact_url, ""); @@ -722,7 +892,9 @@ async fn download_v2_entry( Ok(crate::application::source_acquisition::redirected_host( artifact_url, response.final_url.as_str(), - )) + ) + .into_iter() + .collect()) } other => Err(AppError::InvalidSource { value: format!("Unsupported well-known entry type: {other}"), @@ -774,6 +946,7 @@ async fn fetch_index( Err(error) if network_request_was_cancelled(&error) => { return Err(AppError::MutationCancelled); } + Err(HttpTransportError::Settings(error)) => return Err(error.into()), Err(error) => { log::warn!("Well-known index request failed: {error}"); transport_failure @@ -792,7 +965,13 @@ async fn fetch_index( Err(_) => continue, }; - if let Some(entries) = normalize_wellknown_index(&raw, resp.final_url.as_str()) { + if let Some(mut catalog) = normalize_wellknown_index(&raw, resp.final_url.as_str()) { + if let Some(host) = crate::application::source_acquisition::redirected_host( + url, + resp.final_url.as_str(), + ) { + catalog.redirected_hosts.insert(host); + } if let Some(scope) = &plan.scope { if candidate_index >= scope.scoped_candidate_count { return Err(AppError::WellKnownScopeNotFound { @@ -800,18 +979,12 @@ async fn fetch_index( root_url: scope.root_url.clone(), }); } - if entries.is_empty() { - empty_scoped_catalog = Some(FetchedWellKnownIndex { - index_url: resp.final_url.to_string(), - entries, - }); + if catalog.complete_skill_catalog.is_empty() && catalog.catalog_complete { + empty_scoped_catalog = Some(catalog); continue; } } - return Ok(FetchedWellKnownIndex { - index_url: resp.final_url.to_string(), - entries, - }); + return Ok(catalog); } } @@ -838,12 +1011,14 @@ async fn fetch_index( } fn map_network_error(error: HttpTransportError, message: &str) -> AppError { - if network_request_was_cancelled(&error) { - AppError::MutationCancelled - } else { - log::warn!("{message}: {error}"); - AppError::WellKnownSourceFailed { - reason: source_failure_reason_from_http_error(&error), + match error { + HttpTransportError::Settings(error) => error.into(), + error if network_request_was_cancelled(&error) => AppError::MutationCancelled, + error => { + log::warn!("{message}: {error}"); + AppError::WellKnownSourceFailed { + reason: source_failure_reason_from_http_error(&error), + } } } } @@ -858,9 +1033,8 @@ fn source_failure_reason_from_http_error( reason: "timeout", .. } => SourceAcquisitionFailureReason::Timeout, HttpTransportError::ResponseTooLarge => SourceAcquisitionFailureReason::LimitExceeded, - HttpTransportError::Settings(_) | HttpTransportError::Request { .. } => { - SourceAcquisitionFailureReason::Network - } + HttpTransportError::Settings(_) => SourceAcquisitionFailureReason::Unavailable, + HttpTransportError::Request { .. } => SourceAcquisitionFailureReason::Network, } } @@ -890,6 +1064,30 @@ fn network_request_was_cancelled(error: &HttpTransportError) -> bool { #[cfg(test)] mod tests { + #[tokio::test] + async fn proxy_configuration_failure_stops_wellknown_candidate_requests() { + let http = crate::runtime::http_transport::HttpTransport::new(std::sync::Arc::new( + crate::runtime::proxy_settings::ProxySettingsStore::new( + crate::models::NetworkProxySettings { + mode: crate::models::ProxyMode::Custom, + custom_proxy_url: None, + ..Default::default() + }, + ), + )); + let error = super::fetch_wellknown_skills_with_client( + &http, + "http://127.0.0.1:9/skills", + &crate::core::mutation::CancellationSignal::default(), + ) + .await + .expect_err("invalid proxy configuration"); + assert!(matches!( + error, + crate::error::AppError::InvalidProxySettings { .. } + )); + } + use super::*; use crate::models::{NetworkProxySettings, ProxyMode}; use crate::runtime::proxy_settings::ProxySettingsStore; @@ -898,6 +1096,171 @@ mod tests { use std::sync::Arc; use std::thread; + fn direct_http() -> HttpTransport { + HttpTransport::new(Arc::new(ProxySettingsStore::new(NetworkProxySettings { + mode: ProxyMode::Direct, + ..Default::default() + }))) + } + + #[tokio::test] + async fn selected_member_download_does_not_request_an_unselected_artifact() { + use crate::application::wellknown_access::WellKnownAccess; + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}/index.json", server.server_addr()); + let content = "---\nname: chosen\ndescription: Chosen\n---\n"; + let worker = thread::spawn(move || { + let mut paths = Vec::new(); + for _ in 0..3 { + let Some(request) = server.recv_timeout(Duration::from_secs(2)).unwrap() else { + break; + }; + paths.push(request.url().to_string()); + let (status, body) = match request.url() { + "/index.json" => (200, serde_json::json!({ + "$schema": DISCOVERY_SCHEMA_V2, + "skills": [ + {"name":"chosen","description":"Chosen","type":"skill-md","url":"chosen.md","digest":compute_digest(content.as_bytes())}, + {"name":"other","description":"Other","type":"skill-md","url":"broken.md","digest":compute_digest(b"other")} + ] + }).to_string()), + "/chosen.md" => (200, content.to_string()), + _ => (404, "missing".to_string()), + }; + request + .respond(tiny_http::Response::from_string(body).with_status_code(status)) + .unwrap(); + } + paths + }); + let access = crate::runtime::wellknown::RuntimeWellKnownAccess::new(direct_http()); + let result = access + .fetch_selected( + &url, + &["chosen".to_string()], + &CancellationSignal::default(), + ) + .await; + let paths = worker.join().unwrap(); + let fetched = result.expect("an unrelated broken artifact must not block selected content"); + assert!(fetched.repo_path.join("chosen/SKILL.md").is_file()); + fs::remove_dir_all(fetched.repo_path).unwrap(); + assert_eq!(paths, vec!["/index.json", "/chosen.md"]); + } + + #[tokio::test] + async fn invalid_named_index_member_is_not_reported_as_deleted() { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}/index.json", server.server_addr()); + let worker = thread::spawn(move || { + let request = server + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + request.respond(tiny_http::Response::from_string(serde_json::json!({ + "$schema": DISCOVERY_SCHEMA_V2, + "skills": [ + {"name":"good","description":"Good","type":"skill-md","url":"good/SKILL.md","digest":format!("sha256:{}", "0".repeat(64))}, + {"name":"bad","description":"Bad","type":"skill-md","url":"bad/SKILL.md","digest":"invalid"} + ] + }).to_string())).unwrap(); + }); + let evidence = check_wellknown_updates_with_client( + &direct_http(), + &url, + &["good".into(), "bad".into()], + &CancellationSignal::default(), + ) + .await + .unwrap(); + worker.join().unwrap(); + assert!(evidence.catalog_complete); + assert!(evidence.complete_skill_catalog.contains(&"bad".to_string())); + assert_eq!( + evidence.member_failures.get("bad"), + Some(&crate::error::SourceAcquisitionFailureReason::InvalidContent) + ); + assert!(evidence.digests.contains_key("good")); + } + + #[tokio::test] + async fn legacy_download_requires_every_declared_file() { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}/index.json", server.server_addr()); + let worker = thread::spawn(move || { + for _ in 0..3 { + let Some(request) = server.recv_timeout(Duration::from_secs(2)).unwrap() else { + break; + }; + let (status, body) = match request.url() { + "/index.json" => ( + 200, + r#"{"skills":[{"name":"legacy","description":"Legacy","files":["SKILL.md","scripts/run.sh"]}]}"#, + ), + "/legacy/SKILL.md" => (200, "---\nname: legacy\ndescription: Legacy\n---\n"), + _ => (404, "missing"), + }; + request + .respond(tiny_http::Response::from_string(body).with_status_code(status)) + .unwrap(); + } + }); + let result = fetch_wellknown_skills_with_client( + &direct_http(), + &url, + &CancellationSignal::default(), + ) + .await; + worker.join().unwrap(); + if let Ok(fetched) = &result { + fs::remove_dir_all(&fetched.repo_path).unwrap(); + } + assert!( + result.is_err(), + "incomplete legacy content must not be published" + ); + } + + #[tokio::test] + async fn queried_legacy_index_uses_the_index_parent_for_file_urls() { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let url = format!( + "http://{}/catalog/index.json?channel=stable", + server.server_addr() + ); + let worker = thread::spawn(move || { + for _ in 0..2 { + let request = server + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + let (status, body) = match request.url() { + "/catalog/index.json?channel=stable" => ( + 200, + r#"{"skills":[{"name":"legacy","description":"Legacy","files":["SKILL.md"]}]}"#, + ), + "/catalog/legacy/SKILL.md" => { + (200, "---\nname: legacy\ndescription: Legacy\n---\n") + } + _ => (404, "wrong resource URL"), + }; + request + .respond(tiny_http::Response::from_string(body).with_status_code(status)) + .unwrap(); + } + }); + let result = fetch_wellknown_skills_with_client( + &direct_http(), + &url, + &CancellationSignal::default(), + ) + .await; + worker.join().unwrap(); + let fetched = result.expect("query must not become part of the resource directory"); + assert!(fetched.repo_path.join("legacy/SKILL.md").is_file()); + fs::remove_dir_all(fetched.repo_path).unwrap(); + } + #[test] fn persisted_index_url_is_requested_directly() { let index_url = "https://example.com/catalog/index.json"; @@ -922,14 +1285,14 @@ mod tests { .expect("legacy index should normalize"); assert!(matches!( - &entries[0], + &entries.entries[0], NormalizedWellKnownEntry::Legacy { name, files, .. } if name == "legacy" && files == &vec!["SKILL.md".to_string()] )); } #[test] - fn test_legacy_index_rejects_all_entries_when_any_entry_invalid() { + fn test_legacy_index_isolates_an_invalid_member() { let raw: serde_json::Value = serde_json::from_str( r#"{ "skills": [ @@ -945,7 +1308,10 @@ mod tests { "https://example.com/.well-known/agent-skills/index.json", ); - assert!(entries.is_none()); + let index = entries.expect("valid catalogue identities remain available"); + assert_eq!(index.entries.len(), 1); + assert!(index.catalog_complete); + assert!(index.member_failures.contains_key("bad")); } #[test] @@ -972,7 +1338,7 @@ mod tests { .expect("v2 index should normalize"); assert!(matches!( - &entries[0], + &entries.entries[0], NormalizedWellKnownEntry::V2 { name, entry_type, @@ -1007,7 +1373,10 @@ mod tests { "https://example.com/.well-known/agent-skills/index.json", ); - assert!(entries.is_none()); + let index = entries.expect("invalid digest is a member error"); + assert!(index.entries.is_empty()); + assert!(index.catalog_complete); + assert!(index.member_failures.contains_key("demo")); } #[test] @@ -1032,7 +1401,9 @@ mod tests { "https://example.com/.well-known/agent-skills/index.json", ); - assert!(entries.is_none()); + let index = entries.expect("catalogue completeness is retained"); + assert!(index.entries.is_empty()); + assert!(!index.catalog_complete); } #[test] diff --git a/src-tauri/src/runtime/wsl_source.rs b/src-tauri/src/runtime/wsl_source.rs index 4a4e3823..ebd6f8ab 100644 --- a/src-tauri/src/runtime/wsl_source.rs +++ b/src-tauri/src/runtime/wsl_source.rs @@ -21,8 +21,7 @@ use crate::core::mutation::CancellationSignal; use crate::core::plugin_manifest::get_relative_plugin_search_dirs; use crate::core::skill_paths::normalize_skill_folder_path; use crate::core::{ - resolve_clone_timeout_secs, select_discovered_skills, DiscoverOptions, DiscoveryDocument, - DiscoveryInventory, + select_discovered_skills, DiscoverOptions, DiscoveryDocument, DiscoveryInventory, }; use crate::environment::types::EnvironmentRef; use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; @@ -65,6 +64,38 @@ impl RuntimeWslSourceAccess { } impl WslSourceAccess for RuntimeWslSourceAccess { + fn probe_ref<'a>( + &'a self, + distro_name: &'a str, + source: &'a str, + git_ref: Option<&'a str>, + cancellation: CancellationSignal, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + let workspace = self.environments.workspace(distro_name)?; + let timeout = Duration::from_secs(self.settings.clone_timeout_secs()); + self.environments + .with_session(distro_name, |_| { + let proxy = self.settings.wsl_git_proxy(distro_name, source); + let workspace = workspace.clone(); + let cancellation = cancellation.clone(); + async move { + crate::environment::wsl::operations::source_acquisition::probe_wsl_git_ref( + &workspace, + source, + git_ref, + proxy?, + timeout, + cancellation, + ) + .await + } + }) + .await + }) + } + fn discover<'a>( &'a self, distro_name: &'a str, @@ -76,6 +107,18 @@ impl WslSourceAccess for RuntimeWslSourceAccess { Box::pin(async move { match parsed.source_type { SourceType::WellKnown => { + if policy.selected_skill_names.is_some() { + return self + .discover_wellknown( + distro_name, + parsed, + requested_source, + policy, + cancellation, + ) + .await + .map_err(WellKnownFetchError::into_error); + } attempt_wellknown_then_download( self.discover_wellknown( distro_name, @@ -135,7 +178,14 @@ impl RuntimeWslSourceAccess { .environments .workspace(distro_name) .map_err(WellKnownFetchError::unproven)?; - let fetched = self.wellknown.fetch(&parsed.url, &cancellation).await?; + let fetched = match policy.selected_skill_names.as_ref() { + Some(names) => { + self.wellknown + .fetch_selected(&parsed.url, names, &cancellation) + .await? + } + None => self.wellknown.fetch(&parsed.url, &cancellation).await?, + }; let root = fetched.repo_path.clone(); let owner = ManagedDownloadedDirectory::new(root.clone()); let environment = EnvironmentRef::Wsl { @@ -162,6 +212,9 @@ impl RuntimeWslSourceAccess { RetainedSourceOptions { storage: Some(storage), trust_metadata: Some(fetched.trust_metadata), + redirected_download_host: fetched.redirected_download_host, + redirected_download_hosts: fetched.redirected_download_hosts, + member_failures: fetched.member_failures, full_depth: policy.full_depth, internal_skill_visibility: policy.internal_skill_visibility, ..Default::default() @@ -240,9 +293,9 @@ impl RuntimeWslSourceAccess { let sessions = self.sessions.clone(); let settings = self.settings.clone(); let proxy_distro = distro_name.to_string(); - let git_timeout = Duration::from_secs(resolve_clone_timeout_secs()); + let git_timeout = Duration::from_secs(self.settings.clone_timeout_secs()); self.environments - .with_session_retry(distro_name, move |session| { + .with_session(distro_name, move |session| { let workspace = workspace.clone(); let parsed = parsed.clone(); let requested_source = requested_source.clone(); @@ -262,7 +315,7 @@ impl RuntimeWslSourceAccess { }; let proxy = match &acquisition { WslAcquisitionSource::Git { url, .. } => { - settings.wsl_git_proxy(&proxy_distro, url) + settings.wsl_git_proxy(&proxy_distro, url)? } WslAcquisitionSource::Local { .. } => None, }; @@ -353,7 +406,8 @@ async fn prepare_native_wsl_source( stat_only_root_indexes.insert(1); } let mut response = scan( - &session, + &workspace, + &native, ScanRequest { roots, stat_only_root_indexes, @@ -370,7 +424,8 @@ async fn prepare_native_wsl_source( ); if !plugin_search_dirs.is_empty() { let priority = scan_priority_directories( - &session, + &workspace, + &native, ScanRequest { roots: plugin_search_dirs .iter() @@ -398,16 +453,15 @@ async fn prepare_native_wsl_source( &policy.internal_skill_visibility, policy.full_depth, )?; - let storage = Arc::new(WslPayloadSessionStorage::new(workspace)); + let storage = Arc::new(WslPayloadSessionStorage::for_source(workspace, &native)); for skill in catalog.values_mut() { let source_root = format!( "{}/{}", native.native_root().trim_end_matches('/'), normalize_skill_folder_path(&skill.relative_path) ); - skill.source_metadata_fingerprint = storage - .source_metadata_fingerprint_in_active_session(&session, &source_root) - .await?; + skill.source_metadata_fingerprint = + storage.source_metadata_fingerprint(&source_root).await?; } let descriptor = DiscoverySourceDescriptor { source: source_identifier(&parsed, &requested_source), @@ -417,6 +471,7 @@ async fn prepare_native_wsl_source( redirected_download_host: None, }; let source_fingerprint = snapshot_fingerprint(&descriptor, &catalog); + let managed_bytes = native.managed_bytes(); let retained = RetainedDiscoverySource::new( DiscoverySourceLocation::WslNative { distro_name: session.distro_name.clone(), @@ -426,7 +481,8 @@ async fn prepare_native_wsl_source( descriptor, catalog, native, - ); + ) + .with_managed_storage(managed_bytes, storage.clone()); Ok(PreparedWslDiscovery { source_fingerprint, storage, @@ -613,6 +669,69 @@ pub(crate) fn wsl_acquisition_source( mod tests { use super::*; + #[tokio::test] + async fn saved_wellknown_source_does_not_fall_back_to_direct_download() { + struct MissingIndex; + impl WellKnownAccess for MissingIndex { + fn fetch<'a>( + &'a self, + _url: &'a str, + _cancellation: &'a CancellationSignal, + ) -> crate::application::wellknown_access::WellKnownFetchFuture<'a> { + Box::pin(async { Err(WellKnownFetchError::unproven(AppError::NoSkillsFound)) }) + } + } + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}/catalog", server.server_addr()); + let requests = std::thread::spawn(move || { + let request = server.recv_timeout(Duration::from_millis(300)).unwrap(); + if let Some(request) = request { + request.respond(tiny_http::Response::empty(404)).unwrap(); + true + } else { + false + } + }); + let settings = Arc::new(ProxySettingsStore::new( + crate::models::NetworkProxySettings::default(), + )); + let access = RuntimeWslSourceAccess::new( + Arc::new(PayloadSessionManager::in_memory( + crate::application::payload_session::PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 1_000_000, + }, + || 1_000, + )), + Arc::new(WslRuntime::new_with_support(true, true)), + settings.clone(), + Arc::new(MissingIndex), + RuntimeDownloadAccess::new(crate::runtime::http_transport::HttpTransport::new( + settings, + )), + ); + let mut parsed = crate::core::parse_source(&url).unwrap(); + parsed.source_type = SourceType::WellKnown; + let error = access + .discover( + "Ubuntu", + parsed, + url, + SourceDiscoveryPolicy { + allow_empty_catalog: true, + full_depth: true, + internal_skill_visibility: InternalSkillVisibility::All, + selected_skill_names: Some(vec!["demo".into()]), + }, + CancellationSignal::default(), + ) + .await + .unwrap_err(); + assert_eq!(error, AppError::NoSkillsFound); + assert!(!requests.join().unwrap()); + } + #[test] fn wsl_proxy_selection_is_isolated_by_distro_and_transport() { let policy = ProxySettingsStore::new(crate::models::NetworkProxySettings { @@ -638,19 +757,27 @@ mod tests { }); assert_eq!( - policy.wsl_git_proxy("Ubuntu", "https://github.com/owner/repo.git"), + policy + .wsl_git_proxy("Ubuntu", "https://github.com/owner/repo.git") + .expect("valid proxy settings"), Some("http://ubuntu.proxy:7890".to_string()) ); assert_eq!( - policy.wsl_git_proxy("Debian", "http://github.com/owner/repo.git"), + policy + .wsl_git_proxy("Debian", "http://github.com/owner/repo.git") + .expect("valid proxy settings"), Some("https://debian.proxy:7890".to_string()) ); assert_eq!( - policy.wsl_git_proxy("Ubuntu", "git@github.com:owner/repo.git"), + policy + .wsl_git_proxy("Ubuntu", "git@github.com:owner/repo.git") + .expect("valid proxy settings"), None ); assert_eq!( - policy.wsl_git_proxy("Fedora", "https://github.com/owner/repo.git"), + policy + .wsl_git_proxy("Fedora", "https://github.com/owner/repo.git") + .expect("valid proxy settings"), None ); } diff --git a/src-tauri/src/storage/atomic_document.rs b/src-tauri/src/storage/atomic_document.rs index 7b980d09..dad0455f 100644 --- a/src-tauri/src/storage/atomic_document.rs +++ b/src-tauri/src/storage/atomic_document.rs @@ -1,20 +1,96 @@ use std::future::Future; use std::pin::Pin; +pub use environment_engine::atomic_document::{PublicationState, WritePhase}; + use crate::environment::types::ResourceLocator; use crate::error::AppError; pub type IoFuture<'a, T> = Pin + Send + 'a>>; +/// Full bytes and the runtime session in which they were observed. A generation +/// is never a persisted document version and must not be written into JSON. +#[derive(Debug, Clone)] +pub struct DocumentSnapshot { + pub bytes: Option>, + pub generation: Option, +} + +pub type DocumentCommitReceipt = DocumentSnapshot; + +#[derive(Debug)] +pub struct DocumentWriteFailure { + pub error: AppError, + pub phase: WritePhase, + pub publication: PublicationState, +} + +impl DocumentWriteFailure { + pub fn not_published(error: AppError) -> Self { + Self::not_published_at(WritePhase::Preparing, error) + } + + pub fn not_published_at(phase: WritePhase, error: AppError) -> Self { + Self { + error, + phase, + publication: PublicationState::NotPublished, + } + } + + pub fn unknown(error: AppError) -> Self { + Self::unknown_at(WritePhase::Publishing, error) + } + + pub fn unknown_at(phase: WritePhase, error: AppError) -> Self { + Self { + error, + phase, + publication: PublicationState::OutcomeUnknown, + } + } + + pub fn into_error(self) -> AppError { + self.error + } + + pub(crate) fn from_engine( + error: environment_engine::atomic_document::AtomicWriteError, + ) -> Self { + let publication = error.publication; + let phase = error.phase; + let error = if error.is_conflict() { + AppError::StaleTarget + } else { + AppError::Io { + message: error.to_string(), + } + }; + Self { + error, + phase, + publication, + } + } +} + pub trait AtomicDocumentIo: Send + Sync { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>>; + max_bytes: u64, + ) -> IoFuture<'a, Result>; - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>>; + ) -> IoFuture<'a, Result>; + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>>; } diff --git a/src-tauri/src/storage/lock_plan.rs b/src-tauri/src/storage/lock_plan.rs index 25d511d5..9a0e62e9 100644 --- a/src-tauri/src/storage/lock_plan.rs +++ b/src-tauri/src/storage/lock_plan.rs @@ -9,7 +9,7 @@ use crate::core::lossless_lock::{ }; use crate::environment::types::ResourceLocator; use crate::error::AppError; -use crate::storage::atomic_document::AtomicDocumentIo; +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentWriteFailure}; #[derive(Debug, Clone)] pub struct LockCommitReceipt { @@ -78,13 +78,6 @@ pub enum LockEntryMutation { } impl LockEntryMutation { - fn affected_keys(&self) -> Vec<&str> { - match self { - Self::Replace { key, .. } | Self::Remove { key } => vec![key], - Self::MoveAndReplace { from, to, .. } => vec![from, to], - } - } - #[cfg(test)] pub fn target_key(&self) -> &str { match self { @@ -141,96 +134,118 @@ where pub async fn commit( &self, prepared: PreparedLockMutation, - ) -> Result { - let mut latest = self.load_latest(&prepared).await?; - for key in prepared.entry.affected_keys() { - let expected = prepared.expected.entry_snapshots.get(key).ok_or_else(|| { - AppError::InvalidSource { - value: format!("lock plan did not capture Skill '{key}'"), - } - })?; - latest.validate_entry_snapshot(key, expected)?; - } - match prepared.entry { - LockEntryMutation::Replace { key, replacement } => latest.replace_entry( - prepared.schema, - &key, - prepared - .expected - .entry_snapshots - .get(&key) - .expect("validated snapshot"), - replacement, - )?, - LockEntryMutation::Remove { key } => latest.remove_entry( - &key, - prepared - .expected - .entry_snapshots - .get(&key) - .expect("validated snapshot"), - )?, + ) -> Result { + let current = self + .io + .observe( + &prepared.target, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), + ) + .await + .map_err(DocumentWriteFailure::not_published)?; + let legacy = match (¤t.bytes, &prepared.legacy_target) { + (None, Some(target)) => { + self.io + .observe(target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await + .map_err(DocumentWriteFailure::not_published)? + .bytes + } + _ => None, + }; + let applied = environment_engine::lock::apply( + current.bytes.as_deref(), + legacy.as_deref(), + &engine_mutation(&prepared), + ) + .map_err(map_engine_error) + .map_err(DocumentWriteFailure::not_published)?; + self.io + .replace(&prepared.target, current, applied.bytes) + .await?; + Ok(LockCommitReceipt { + entry_snapshots: applied + .receipt + .entries + .into_iter() + .map(|(key, value)| (key, LockEntrySnapshot::from_value(value))) + .collect(), + root_snapshots: applied + .receipt + .roots + .into_iter() + .map(|(field, value)| (field, LockRootSnapshot::from_value(value))) + .collect(), + }) + } +} + +fn engine_mutation(prepared: &PreparedLockMutation) -> environment_engine::lock::LockMutation { + use environment_engine::lock::{EntryMutation, LockMutation, LockSchema as EngineSchema}; + + LockMutation { + schema: match prepared.schema { + LockSchema::Global => EngineSchema::Global, + LockSchema::Project => EngineSchema::Project, + }, + entry: match &prepared.entry { + LockEntryMutation::Replace { key, replacement } => EntryMutation::Replace { + key: key.clone(), + replacement: replacement.clone(), + }, + LockEntryMutation::Remove { key } => EntryMutation::Remove { key: key.clone() }, LockEntryMutation::MoveAndReplace { from, to, replacement, - } => latest.move_and_replace_entry( - prepared.schema, - &from, - &to, - prepared - .expected - .entry_snapshots - .get(&from) - .expect("validated snapshot"), - prepared - .expected - .entry_snapshots - .get(&to) - .expect("validated snapshot"), - replacement, - )?, - } - for (field, replacement) in &prepared.root_replacements { - let expected = prepared.expected.root_snapshots.get(field).ok_or_else(|| { - AppError::InvalidSource { - value: format!("lock plan did not capture root field '{field}'"), - } - })?; - latest.replace_root(field, expected, replacement.clone())?; - } - - let receipt = LockCommitReceipt { - entry_snapshots: prepared - .expected - .entry_snapshots - .keys() - .map(|name| (name.clone(), latest.entry_snapshot(name))) - .collect(), - root_snapshots: prepared - .expected - .root_snapshots - .keys() - .map(|field| (field.clone(), latest.root_snapshot(field))) - .collect(), - }; - self.io - .write_atomic(&prepared.target, latest.to_pretty_bytes()?) - .await?; - Ok(receipt) + } => EntryMutation::MoveAndReplace { + from: from.clone(), + to: to.clone(), + replacement: replacement.clone(), + }, + }, + root_replacements: prepared.root_replacements.clone(), + expected_entries: prepared + .expected + .entry_snapshots + .iter() + .map(|(key, snapshot)| (key.clone(), snapshot.value().cloned())) + .collect(), + expected_roots: prepared + .expected + .root_snapshots + .iter() + .map(|(field, snapshot)| (field.clone(), snapshot.value().cloned())) + .collect(), } +} - async fn load_latest( - &self, - prepared: &PreparedLockMutation, - ) -> Result { - load_lock_document( - self.io.as_ref(), - &prepared.target, - prepared.legacy_target.as_ref(), - prepared.schema, - ) - .await +fn map_engine_error(error: environment_engine::lock::LockError) -> AppError { + match error { + environment_engine::lock::LockError::EntryConflict { key } => AppError::LockConflict { + target: crate::error::LockConflictTarget::Skill { skill_name: key }, + }, + environment_engine::lock::LockError::RootConflict { field } => AppError::LockConflict { + target: crate::error::LockConflictTarget::RootField { field }, + }, + environment_engine::lock::LockError::MissingExpectedEntry { key } => { + AppError::InvalidSource { + value: format!("lock plan did not capture Skill '{key}'"), + } + } + environment_engine::lock::LockError::MissingExpectedRoot { field } => { + AppError::InvalidSource { + value: format!("lock plan did not capture root field '{field}'"), + } + } + environment_engine::lock::LockError::UnsupportedSchema { version, supported } => { + AppError::ConfigurationCorrupted { + message: format!("lock schema version {version} is newer than {supported}"), + } + } + environment_engine::lock::LockError::InvalidDocument { message } => { + AppError::Json { message } + } } } @@ -243,14 +258,22 @@ pub async fn load_lock_document( where I: AtomicDocumentIo + ?Sized, { - if let Some(bytes) = io.read_optional(target).await? { + if let Some(bytes) = io + .observe(target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await? + .bytes + { ensure_supported_schema(&bytes, schema)?; return LosslessLockDocument::parse(&bytes); } let Some(legacy) = legacy_target else { return Ok(LosslessLockDocument::empty(schema)); }; - let Some(bytes) = io.read_optional(legacy).await? else { + let Some(bytes) = io + .observe(legacy, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await? + .bytes + else { return Ok(LosslessLockDocument::empty(schema)); }; let document = LosslessLockDocument::parse(&bytes)?; @@ -289,7 +312,9 @@ mod tests { use super::*; use crate::environment::types::EnvironmentRef; - use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; + use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentCommitReceipt, DocumentSnapshot, DocumentWriteFailure, IoFuture, + }; #[derive(Default)] struct FakeIo { @@ -297,25 +322,49 @@ mod tests { } impl AtomicDocumentIo for FakeIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { - Box::pin( - async move { Ok(self.files.lock().unwrap().get(&target.native_path).cloned()) }, - ) + _max_bytes: u64, + ) -> IoFuture<'a, Result> { + Box::pin(async move { + Ok(DocumentSnapshot { + bytes: self.files.lock().unwrap().get(&target.native_path).cloned(), + generation: None, + }) + }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { + ) -> IoFuture<'a, Result> { Box::pin(async move { - self.files - .lock() - .unwrap() - .insert(target.native_path.clone(), bytes); + let mut files = self.files.lock().unwrap(); + if files.get(&target.native_path) != expected.bytes.as_ref() { + return Err(DocumentWriteFailure::not_published(AppError::StaleTarget)); + } + files.insert(target.native_path.clone(), bytes.clone()); + Ok(DocumentSnapshot { + bytes: Some(bytes), + generation: None, + }) + }) + } + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { + Box::pin(async move { + let mut files = self.files.lock().unwrap(); + if files.get(&target.native_path) != expected.bytes.as_ref() { + return Err(DocumentWriteFailure::not_published(AppError::StaleTarget)); + } + files.remove(&target.native_path); Ok(()) }) } @@ -402,7 +451,11 @@ mod tests { ); assert!(matches!( committer.commit(mutation(expected)).await, - Err(AppError::LockConflict { .. }) + Err(DocumentWriteFailure { + error: AppError::LockConflict { .. }, + publication: crate::storage::atomic_document::PublicationState::NotPublished, + .. + }) )); } @@ -431,7 +484,11 @@ mod tests { assert!(matches!( committer.commit(prepared).await, - Err(AppError::LockConflict { .. }) + Err(DocumentWriteFailure { + error: AppError::LockConflict { .. }, + publication: crate::storage::atomic_document::PublicationState::NotPublished, + .. + }) )); } @@ -531,7 +588,14 @@ mod tests { }) .await; - assert!(matches!(result, Err(AppError::LockConflict { .. }))); + assert!(matches!( + result, + Err(DocumentWriteFailure { + error: AppError::LockConflict { .. }, + publication: crate::storage::atomic_document::PublicationState::NotPublished, + .. + }) + )); assert_eq!( io.files.lock().unwrap()[&locator().native_path], external_bytes diff --git a/src-tauri/src/test_support/native_workflow.rs b/src-tauri/src/test_support/native_workflow.rs index 256baf48..56f81a37 100644 --- a/src-tauri/src/test_support/native_workflow.rs +++ b/src-tauri/src/test_support/native_workflow.rs @@ -13,14 +13,16 @@ use crate::application::copy::{ CopyExecutionRequest, CopyPreviewOutcome, CopyRequest, CopyService, }; use crate::application::install::{ - InstallFuture, InstallOperation, InstallPreviewOutcome, InstallRequest, InstallService, + InstallFuture, InstallPreviewOutcome, InstallRequest, InstallService, }; use crate::application::install_planner::ConcreteInstallPlanner; use crate::application::installed_skill_payload::InstalledSkillPayloadAcquirer; use crate::application::installed_skill_resolver::SkillDirectoryName; use crate::application::library_application::{ - ApplyLibraryApplicationRequest, LibraryApplicationDraft, LibraryApplicationFuture, - LibraryApplicationModule, LibraryApplicationRecord, LibraryApplicationRepository, + ApplicationInventory, ApplicationRegistry, ApplyLibraryApplicationRequest, + LibraryApplicationBackend, LibraryApplicationDraft, LibraryApplicationFuture, + LibraryApplicationModule, LibraryApplicationRecord, LibraryApplicationResources, + VersionedApplicationRecord, }; use crate::application::library_candidates::LibraryCandidateSet; use crate::application::library_candidates::{ @@ -31,7 +33,7 @@ use crate::application::manage_agents::{ ManageAgentsPreviewRequest, ManageAgentsRequest, ManageAgentsService, }; use crate::application::mutation::coordinator::{ - BoxFuture, MutationCoordinator, PreparedEntryExecutor, PreparedLockCommitter, + BoxFuture, MutationCoordinator, PreparedEntryTestDriver, PreparedLockCommitter, RuntimeRevisionSource, }; use crate::application::mutation::executor::MutationPlanExecutor; @@ -53,10 +55,23 @@ use crate::application::skill_libraries::{ LibraryCatalog, LibraryId, LibrarySkillRecord, SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, }; use crate::application::skill_source::SkillSourceModule; -use crate::application::source_evidence::{RemoteSnapshotId, SourceSnapshotFacts}; + +pub(crate) fn update_library_repository( + root: &std::path::Path, +) -> Arc { + Arc::new( + crate::runtime::skill_libraries::RuntimeSkillLibraryRepository::new( + root.join("library-records"), + Arc::new(WslRuntime::default()), + Arc::new(crate::core::projects::ProjectMigrationRegistry::new( + crate::core::projects::ProjectMigrationState::NotNeeded, + )), + ), + ) +} use crate::application::update::{ - AcquiredUpdateSource, UpdateAcquisitionGroup, UpdateExecutionRequest, UpdateFuture, - UpdateRequest, UpdateService, UpdateSourceAcquisition, + AcquiredUpdateSource, UpdateAcquisitionGroup, UpdateFuture, UpdateRequest, UpdateService, + UpdateSourceAcquisition, }; use crate::application::update_planner::ConcreteUpdatePlanner; use crate::core::agent_definition::{ @@ -85,6 +100,7 @@ use crate::models::InstallMode; use crate::runtime::copy_service::RuntimeCopyProjectComparator; use crate::runtime::plan_runner::{RuntimeExecutionDependencies, RuntimePlanExecutor}; use crate::runtime::planning_facts::{NativeRuntimeSnapshot, RuntimePlanningFactSource}; +use crate::storage::atomic_document::DocumentWriteFailure; use crate::storage::lock_plan::{LockCommitReceipt, PreparedLockMutation}; pub(crate) struct StaticRegistry(pub(crate) Arc); @@ -130,30 +146,60 @@ async fn observe_skill( .map_err(|error| error.into_app_error()) } -struct MemoryLibraryApplicationRepository { +struct MemoryApplicationRegistry { record: Mutex, catalog: LibraryCatalog, members_root: PathBuf, } -impl LibraryApplicationRepository for MemoryLibraryApplicationRepository { +impl ApplicationRegistry for MemoryApplicationRegistry { fn load_application<'a>( &'a self, - _context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { Ok(self.record.lock().expect("library record lock").clone()) }) + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + context.clone(), + self.record.lock().expect("library record lock").clone(), + )) + }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { *self.record.lock().expect("library record lock") = record.clone(); - Ok(()) + Ok(VersionedApplicationRecord::in_memory( + observed.context.clone(), + record.clone(), + )) + }) + } + + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(ApplicationInventory { + records: vec![VersionedApplicationRecord::in_memory( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + self.record.lock().expect("library record lock").clone(), + )], + problems: Vec::new(), + complete: true, + }) }) } +} +impl LibraryApplicationResources for MemoryApplicationRegistry { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -186,13 +232,12 @@ impl LibraryApplicationRepository for MemoryLibraryApplicationRepository { Box::pin(async move { Ok(self.catalog.clone()) }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - context: &'a SkillLocationRef, + _observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { - *self.record.lock().expect("library record lock") = - LibraryApplicationRecord::empty(context.clone()); + *self.record.lock().expect("library record lock") = LibraryApplicationRecord::empty(); Ok(()) }) } @@ -217,7 +262,7 @@ struct SelectiveVerifyStaged { fail_verify: bool, } -impl PreparedEntryExecutor for SelectiveVerifyFailureEntryExecutor { +impl PreparedEntryTestDriver for SelectiveVerifyFailureEntryExecutor { type Staged = SelectiveVerifyStaged; fn stage<'a>( @@ -269,7 +314,7 @@ impl PreparedEntryExecutor for SelectiveVerifyFailureEntryExecutor { } } -impl PreparedEntryExecutor for VerifyFailureEntryExecutor { +impl PreparedEntryTestDriver for VerifyFailureEntryExecutor { type Staged = NativePreparedEntrySet; fn stage<'a>( @@ -314,13 +359,11 @@ impl PreparedEntryExecutor for VerifyFailureEntryExecutor { } struct VerifyFailurePlanExecutor { - environments: Arc, facts: RuntimePlanningFactSource, recovery_root: PathBuf, } struct SelectiveVerifyFailurePlanExecutor { - environments: Arc, facts: RuntimePlanningFactSource, recovery_root: PathBuf, failing_skill: String, @@ -340,13 +383,15 @@ impl PreparedLockCommitter for RejectingLockCommitter { fn commit<'a>( &'a self, _mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { self.attempted .store(true, std::sync::atomic::Ordering::SeqCst); Box::pin(async { - Err(AppError::ExecutionFailed { - message: "injected Manage Agents lock failure".to_string(), - }) + Err(DocumentWriteFailure::not_published( + AppError::ExecutionFailed { + message: "injected Manage Agents lock failure".to_string(), + }, + )) }) } } @@ -371,7 +416,7 @@ impl MutationPlanExecutor for LockFailurePlanExecutor { plan.operation_id.clone(), recovery, ); - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, RejectingLockCommitter { attempted: Arc::clone(&self.attempted), @@ -406,9 +451,9 @@ impl MutationPlanExecutor for VerifyFailurePlanExecutor { recovery, ), }; - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, - crate::runtime::plan_runner::RuntimeLockCommitter::new(self.environments.clone()), + crate::runtime::plan_runner::RuntimeLockCommitter::new(), self.facts.clone(), ) .execute(plan, cancellation) @@ -440,9 +485,9 @@ impl MutationPlanExecutor for SelectiveVerifyFailurePlanExecutor { ), failing_skill: self.failing_skill.clone(), }; - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, - crate::runtime::plan_runner::RuntimeLockCommitter::new(self.environments.clone()), + crate::runtime::plan_runner::RuntimeLockCommitter::new(), self.facts.clone(), ) .execute(plan, cancellation) @@ -470,23 +515,16 @@ impl SkillSourceModule for FixedUpdateAcquirer { source: group.source.clone(), skill_names: vec!["demo".to_string()], result: Ok(AcquiredUpdateSource { - facts: SourceSnapshotFacts { - discovery_session: DiscoverySessionHandle { - session_id: handle.session_id.clone(), - environment: handle.environment.clone(), - source_fingerprint: handle.source_fingerprint.clone(), - expires_at_epoch_ms: handle.expires_at_epoch_ms, - }, - snapshot_id: RemoteSnapshotId::new( - group.key.normalized_ref.clone(), - "main", - "revision-v2", - ), - complete_skill_path_catalog: BTreeSet::from(["skills/demo".to_string()]), + redirected_download_hosts: Vec::new(), + _leases: Vec::new(), + discovery_session: DiscoverySessionHandle { + session_id: handle.session_id.clone(), + environment: handle.environment.clone(), + source_fingerprint: handle.source_fingerprint.clone(), + expires_at_epoch_ms: handle.expires_at_epoch_ms, }, payloads: vec![("demo".to_string(), handle)], skill_errors: Vec::new(), - redirected_download_host: None, }), }]) }) @@ -647,15 +685,12 @@ async fn run_native_workflow_integration() -> Result<(), AppError> { }; let InstallPreviewOutcome::Ready { preview: install_preview, - } = install - .preview(InstallOperation::Install, &install_request) - .await? + } = install.preview(&install_request).await? else { panic!("expected ready install preview"); }; let installed = install .execute( - InstallOperation::Install, &install_request, install_preview.token, CancellationSignal::default(), @@ -694,7 +729,13 @@ async fn run_native_workflow_integration() -> Result<(), AppError> { let update = UpdateService::new( payloads.clone(), - ConcreteUpdatePlanner::new(facts.clone(), targets.clone(), payloads.clone(), fixed_time), + ConcreteUpdatePlanner::new( + facts.clone(), + targets.clone(), + payloads.clone(), + update_library_repository(&source_project), + fixed_time, + ), FixedUpdateAcquirer { handle: handle_v2 }, executor(&execution, &environments, &facts), ); @@ -702,21 +743,26 @@ async fn run_native_workflow_integration() -> Result<(), AppError> { context: source_context.clone(), skill_names: vec!["demo".to_string()], }; - let update_preview = update.preview(&update_request).await?; - let overwrite_private_entries = update_preview.skills[0] + let prepared = update + .prepare(&update_request, CancellationSignal::default()) + .await?; + let overwrite_private_entries = prepared.preview.skills[0] .overwrite_private_entries .iter() .map(|entry| entry.entry_id.clone()) - .collect(); - let update_execution = UpdateExecutionRequest { - request: update_request, - overwrite_private_entries, - }; + .chain( + prepared.preview.skills[0] + .targets + .iter() + .filter_map(|target| target.selectable_entry_id.clone()), + ) + .collect::>(); let updated = update - .execute( - &update_execution, - update_preview.token, + .execute_prepared( + prepared, + &overwrite_private_entries, CancellationSignal::default(), + |_| {}, ) .await?; assert_succeeded( @@ -827,7 +873,6 @@ async fn run_native_workflow_integration() -> Result<(), AppError> { payloads.clone(), InstalledSkillPayloadAcquirer::new(payloads.clone(), environments.clone()), VerifyFailurePlanExecutor { - environments: environments.clone(), facts: facts.clone(), recovery_root: recovery_root.clone(), }, @@ -1021,20 +1066,15 @@ async fn run_native_workflow_integration() -> Result<(), AppError> { context: source_context.clone(), skill_names: vec!["demo".to_string()], }; - let update_after_removal_preview = update.preview(&update_after_removal_request).await?; - assert!(update_after_removal_preview.skills[0] + let prepared = update + .prepare(&update_after_removal_request, CancellationSignal::default()) + .await?; + assert!(prepared.preview.skills[0] .adapter_targets .iter() .all(|reader| reader.agent_id.as_str() != "eve")); let update_after_removal = update - .execute( - &UpdateExecutionRequest { - request: update_after_removal_request, - overwrite_private_entries: Vec::new(), - }, - update_after_removal_preview.token, - CancellationSignal::default(), - ) + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) .await?; assert_succeeded( &update_after_removal @@ -1375,6 +1415,19 @@ async fn native_scope_version_election_survives_a_continuous_product_workflow() .expect("native Scope version-election workflow"); } +#[cfg(test)] +#[tokio::test] +async fn native_scope_version_election_installs_direct_symlinks_over_library_links() { + let temp = tempfile::tempdir().expect("direct symlink install fixture"); + run_native_scope_version_election_workflow_at( + temp.path(), + &["minimax-code"], + InstallMode::Symlink, + ) + .await + .expect("direct symlink install over applied library links"); +} + #[cfg(all(test, unix))] #[tokio::test] async fn native_scope_version_election_recognizes_library_links_across_path_aliases() { @@ -1386,19 +1439,43 @@ async fn native_scope_version_election_recognizes_library_links_across_path_alia fs::create_dir(&physical_root).expect("physical root"); symlink(&physical_root, &logical_root).expect("logical root alias"); - run_native_scope_version_election_workflow_at(&logical_root) + run_native_scope_version_election_workflow_at(&logical_root, &[], InstallMode::Copy) .await .expect("native Scope version-election workflow through a path alias"); } +#[cfg(all(test, unix))] +#[tokio::test] +async fn native_scope_version_election_installs_direct_symlinks_across_path_aliases() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("direct symlink path alias fixture"); + let physical_root = temp.path().join("physical"); + let logical_root = temp.path().join("logical"); + fs::create_dir(&physical_root).expect("physical root"); + symlink(&physical_root, &logical_root).expect("logical root alias"); + + run_native_scope_version_election_workflow_at( + &logical_root, + &["minimax-code"], + InstallMode::Symlink, + ) + .await + .expect("direct symlink install over library links through a path alias"); +} + #[cfg(test)] async fn run_native_scope_version_election_workflow() -> Result<(), AppError> { let temp = tempfile::tempdir()?; - run_native_scope_version_election_workflow_at(temp.path()).await + run_native_scope_version_election_workflow_at(temp.path(), &[], InstallMode::Copy).await } #[cfg(test)] -async fn run_native_scope_version_election_workflow_at(root: &Path) -> Result<(), AppError> { +async fn run_native_scope_version_election_workflow_at( + root: &Path, + direct_agent_ids: &[&str], + direct_install_mode: InstallMode, +) -> Result<(), AppError> { let project_path = root.join("project"); let projects_path = root.join("state/projects.json"); let global_lock_path = root.join("state/global-lock.json"); @@ -1427,19 +1504,18 @@ async fn run_native_scope_version_election_workflow_at(root: &Path) -> Result<() let context = project_context("version-election"); let first_id = LibraryId::parse("library-one"); let second_id = LibraryId::parse("library-two"); - let repository: Arc = - Arc::new(MemoryLibraryApplicationRepository { - record: Mutex::new(LibraryApplicationRecord::empty(context.clone())), - catalog: LibraryCatalog { - schema_version: LIBRARY_SCHEMA_VERSION, - libraries: vec![ - test_library_record(first_id.clone(), "Library One", "library-one"), - test_library_record(second_id.clone(), "Library Two", "library-two"), - ], - extra: serde_json::Map::new(), - }, - members_root, - }); + let repository: Arc = Arc::new(MemoryApplicationRegistry { + record: Mutex::new(LibraryApplicationRecord::empty()), + catalog: LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![ + test_library_record(first_id.clone(), "Library One", "library-one"), + test_library_record(second_id.clone(), "Library Two", "library-two"), + ], + extra: serde_json::Map::new(), + }, + members_root, + }); let registry = Arc::new(StaticRegistry(Arc::new(test_registry()))); let environments = Arc::new(WslRuntime::default()); let facts = RuntimePlanningFactSource::with_native_snapshot( @@ -1538,8 +1614,8 @@ async fn run_native_scope_version_election_workflow_at(root: &Path) -> Result<() &selection_facts.eve_targets, &selection_facts.resolved_context.skill_root, &targets, - &[], - InstallMode::Copy, + direct_agent_ids, + direct_install_mode.clone(), ) .await; let install_request = InstallRequest { @@ -1564,15 +1640,12 @@ async fn run_native_scope_version_election_workflow_at(root: &Path) -> Result<() ); let InstallPreviewOutcome::Ready { preview: install_preview, - } = install - .preview(InstallOperation::Install, &install_request) - .await? + } = install.preview(&install_request).await? else { panic!("expected ready direct install preview"); }; let installed = install .execute( - InstallOperation::Install, &install_request, install_preview.token, CancellationSignal::default(), @@ -1581,7 +1654,14 @@ async fn run_native_scope_version_election_workflow_at(root: &Path) -> Result<() assert_succeeded(&installed.units); assert_payload_tree(&canonical, "direct")?; assert_resolves_to(&agent_a_entry, &first_member)?; - assert_resolves_to(&agent_b_entry, &first_member)?; + if direct_agent_ids.contains(&agent_b.as_str()) { + assert_payload_tree(&agent_b_entry, "direct")?; + if direct_install_mode == InstallMode::Symlink { + assert_resolves_to(&agent_b_entry, &canonical)?; + } + } else { + assert_resolves_to(&agent_b_entry, &first_member)?; + } let manage = ManageAgentsService::new( facts.clone(), @@ -1738,6 +1818,7 @@ fn test_library_record(id: LibraryId, name: &str, version: &str) -> SkillLibrary updated_at: Some(fixed_time()), extra: serde_json::Map::new(), }], + retired_skills: Vec::new(), extra: serde_json::Map::new(), } } @@ -2049,7 +2130,6 @@ async fn direct_download_flows_from_http_discovery_through_install_without_lock( Arc::new(EmptyLibraryCandidateSource), ), SelectiveVerifyFailurePlanExecutor { - environments: environments.clone(), facts: facts.clone(), recovery_root: recovery_root.clone(), failing_skill: "beta".to_string(), @@ -2073,7 +2153,7 @@ async fn direct_download_flows_from_http_discovery_through_install_without_lock( let InstallPreviewOutcome::Ready { preview: stale_preview, } = install - .preview(InstallOperation::Install, &request) + .preview(&request) .await .expect("preview direct download") else { @@ -2083,18 +2163,13 @@ async fn direct_download_flows_from_http_discovery_through_install_without_lock( fs::remove_dir_all(project_path.join(".agents/skills/alpha")).unwrap(); assert!(matches!( install - .execute( - InstallOperation::Install, - &request, - stale_preview.token, - CancellationSignal::default(), - ) + .execute(&request, stale_preview.token, CancellationSignal::default(),) .await, Err(AppError::StaleContext) )); let InstallPreviewOutcome::Ready { preview } = install - .preview(InstallOperation::Install, &request) + .preview(&request) .await .expect("refresh direct-download preview") else { @@ -2105,12 +2180,7 @@ async fn direct_download_flows_from_http_discovery_through_install_without_lock( .iter() .all(|skill| skill.blocking_reasons.is_empty())); let response = install - .execute( - InstallOperation::Install, - &request, - preview.token, - CancellationSignal::default(), - ) + .execute(&request, preview.token, CancellationSignal::default()) .await .expect("execute direct-download batch"); assert_eq!(response.units.len(), 2); @@ -2135,13 +2205,13 @@ mod update_lifecycle { use super::*; use crate::application::install::InstallFuture; use crate::application::mutation::coordinator::{ - BoxFuture, MutationCoordinator, PreparedEntryExecutor, + BoxFuture, MutationCoordinator, PreparedEntryTestDriver, }; use crate::application::mutation::executor::MutationPlanExecutor; use crate::application::mutation::plan::{ExecutionUnit, MutationPlan}; use crate::application::mutation::result::{MutationUnitStatus, MutationWarning}; use crate::application::payload_session::{PayloadSessionManager, PinnedPayloadLease}; - use crate::application::resources::SkillIdentity; + use crate::application::resources::{ResourceService, SkillIdentity}; use crate::application::source_acquisition::{ AcquireSelectedPayloadsRequest, SelectedPayloadAcquisitionService, }; @@ -2151,12 +2221,11 @@ mod update_lifecycle { use crate::application::source_evidence_provider::RuntimeSourceEvidenceDetector; use crate::application::source_snapshot_reuse::SourceSnapshotReuseIndex; use crate::application::update::{ - UpdateCheckMode, UpdateCheckRequest, UpdateCheckSelection, UpdateExecutionRequest, - UpdateOutcome, UpdatePreview, UpdateRequest, UpdateResponse, UpdateSourceStatus, - UpdateWarningCode, + PreparedUpdate, UpdateCheckMode, UpdateCheckRequest, UpdateCheckSelection, UpdateOutcome, + UpdateRequest, UpdateResponse, UpdateSourceStatus, UpdateWarningCode, }; use crate::application::update_check::UpdateCheckService; - use crate::application::update_subjects::InstalledUpdateSubjectProvider; + use crate::application::update_records::InstalledUpdateRecordProvider; use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::PayloadId; use crate::environment::native::materialize::{ @@ -2168,6 +2237,7 @@ mod update_lifecycle { use crate::error::AppError; use crate::models::{ParsedSource, SourceType}; use crate::runtime::plan_runner::{RuntimeLockCommitter, RuntimePlanExecutor}; + use crate::runtime::resource_service::{RuntimeResourceContextSource, RuntimeResourceReader}; use crate::runtime::source_acquisition::SourceDiscoveryService; use crate::runtime::update_service::{RuntimeSkillSourceModule, RuntimeUpdateService}; @@ -2194,7 +2264,7 @@ mod update_lifecycle { private_root: PathBuf, } - impl PreparedEntryExecutor for StageFailureEntryExecutor { + impl PreparedEntryTestDriver for StageFailureEntryExecutor { type Staged = NativePreparedEntrySet; fn stage<'a>( @@ -2256,7 +2326,6 @@ mod update_lifecycle { } struct StageFailurePlanExecutor { - environments: Arc, facts: RuntimePlanningFactSource, recovery_root: PathBuf, private_root: PathBuf, @@ -2286,9 +2355,9 @@ mod update_lifecycle { failing_skill: "beta".to_string(), private_root: self.private_root.clone(), }; - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, - RuntimeLockCommitter::new(self.environments.clone()), + RuntimeLockCommitter::new(), self.facts.clone(), ) .execute(plan, cancellation) @@ -2382,8 +2451,7 @@ mod update_lifecycle { ), calls: detector_calls.clone(), }); - let evidence = - SourceEvidenceCoordinator::with_snapshot_reuse(detector, snapshots.clone()); + let evidence = SourceEvidenceCoordinator::new(detector); let execution = RuntimeExecutionDependencies::new(environments.clone(), recovery_root.clone()) .expect("lifecycle execution dependencies"); @@ -2407,10 +2475,132 @@ mod update_lifecycle { } } + async fn install_with_cli( + &self, + skills: &[&str], + agents: &[&str], + copy: bool, + subagents: &[&str], + ) { + let cli = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repository root") + .join("node_modules/skills/bin/cli.mjs"); + assert!( + cli.is_file(), + "run pnpm install --frozen-lockfile before CLI tests" + ); + let mut node: tokio::process::Command = + crate::background_process::std_command("node").into(); + let resolved_node = tokio::time::timeout( + std::time::Duration::from_secs(10), + node.args(["-p", "process.execPath"]) + .kill_on_drop(true) + .output(), + ) + .await + .expect("Node resolution timed out") + .expect("resolve the Node executable before isolating home"); + assert!(resolved_node.status.success()); + let node = String::from_utf8(resolved_node.stdout).expect("Node executable path"); + let home = self._root.path().join("home"); + let git_config = home.join("cli-git-config"); + fs::write( + &git_config, + format!( + "[url \"{}\"]\n\tinsteadOf = {}\n[protocol \"file\"]\n\tallow = always\n", + self.remote.local_source(), + self.remote.source(), + ), + ) + .expect("write isolated CLI Git URL mapping"); + let mut command: tokio::process::Command = + crate::background_process::std_command(node.trim()).into(); + command + .arg(cli) + .arg("add") + .arg(self.remote.source()) + .arg("--skill") + .args(skills) + .arg("--agent") + .args(agents) + .arg("--yes") + .current_dir(&self.project_path) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("XDG_STATE_HOME", home.join(".local").join("state")) + .env("GIT_CONFIG_GLOBAL", &git_config) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("DISABLE_TELEMETRY", "1") + .env("DO_NOT_TRACK", "1") + .env("NO_COLOR", "1") + .kill_on_drop(true); + if copy { + command.arg("--copy"); + } + if !subagents.is_empty() { + command.arg("--subagent").args(subagents); + } + let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output()) + .await + .expect("CLI installation timed out") + .expect("run pinned Skills CLI"); + assert!( + output.status.success(), + "CLI installation failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + fn context(&self) -> SkillLocationRef { project_context("source") } + async fn read_installed( + &self, + document: Option<&crate::core::lossless_lock::LosslessLockDocument>, + ) -> Result { + let facts = ScopePlanningSnapshotSource::snapshot(&self.facts, &self.context()).await?; + let eve_targets = facts + .eve_targets + .into_iter() + .map(|target| crate::models::SkillInstallTargetInfo { + target_id: target.target_id, + agent: target.agent, + display_name: target.display_name, + subagent: target.subagent, + path: target.path, + }) + .collect::>(); + let mut plan = crate::application::skill_read::build_skill_read_plan( + &facts.resolved_context, + &facts.agent_runtime, + &eve_targets, + )?; + let bytes = document + .map(|document| document.to_pretty_bytes()) + .transpose()?; + plan.set_project_lock(bytes.as_deref()); + let inspector = crate::environment::native::inspection::NativeInspector::new( + EnvironmentRef::Native, + ); + let snapshot = crate::environment::inspection::FilesystemInspector::inspect( + &inspector, + &plan.read_plan, + ) + .await?; + crate::application::skill_read::project_direct_skill_snapshot( + &plan, + snapshot, + &facts.agent_runtime, + update_library_repository(self._root.path()).as_ref(), + &self.targets, + &inspector, + ) + .await + } + fn executor(&self) -> RuntimePlanExecutor { executor(&self.execution, &self.environments, &self.facts) } @@ -2422,6 +2612,7 @@ mod update_lifecycle { self.facts.clone(), self.targets.clone(), self.payloads.clone(), + update_library_repository(self._root.path()), fixed_time, ), RuntimeSkillSourceModule::with_git_transport( @@ -2437,10 +2628,22 @@ mod update_lifecycle { fn check_service( &self, ) -> UpdateCheckService< - InstalledUpdateSubjectProvider, + InstalledUpdateRecordProvider, > { UpdateCheckService::new( - InstalledUpdateSubjectProvider::new(self.facts.clone(), self.targets.clone()), + InstalledUpdateRecordProvider::new( + self.facts.clone(), + self.targets.clone(), + Arc::new( + crate::runtime::skill_libraries::RuntimeSkillLibraryRepository::new( + self._root.path().join("library-records"), + self.environments.clone(), + Arc::new(crate::core::projects::ProjectMigrationRegistry::new( + crate::core::projects::ProjectMigrationState::NotNeeded, + )), + ), + ), + ), self.evidence.clone(), ) } @@ -2512,19 +2715,14 @@ mod update_lifecycle { acknowledge_redirect: true, }; let preview_outcome = install - .preview(InstallOperation::Install, &request) + .preview(&request) .await .expect("preview lifecycle install"); let InstallPreviewOutcome::Ready { preview } = preview_outcome else { panic!("expected ready lifecycle install preview"); }; let installed = install - .execute( - InstallOperation::Install, - &request, - preview.token, - CancellationSignal::default(), - ) + .execute(&request, preview.token, CancellationSignal::default()) .await .expect("execute lifecycle install"); assert_succeeded(&installed.units); @@ -2594,22 +2792,25 @@ mod update_lifecycle { assert_eq!(self.detector_calls(), 2); } - async fn preview(&self, skill_names: [&str; N]) -> UpdatePreview { + async fn prepare(&self, skill_names: [&str; N]) -> PreparedUpdate { let before = self.clone_count(); let preview = self .update_service() - .preview(&UpdateRequest { - context: self.context(), - skill_names: skill_names.into_iter().map(ToString::to_string).collect(), - }) + .prepare( + &UpdateRequest { + context: self.context(), + skill_names: skill_names.into_iter().map(ToString::to_string).collect(), + }, + CancellationSignal::default(), + ) .await - .expect("preview lifecycle update"); + .expect("prepare lifecycle update"); assert_eq!(self.clone_count(), before); self.preview_clone_count.store(before, Ordering::SeqCst); preview } - async fn cancel(&self, preview: UpdatePreview) { + async fn cancel(&self, preview: PreparedUpdate) { drop(preview); assert_eq!( self.clone_count(), @@ -2626,20 +2827,28 @@ mod update_lifecycle { skill_names: skill_names.into_iter().map(ToString::to_string).collect(), }; let service = self.update_service(); - let preview = service - .preview(&request) + let prepared = service + .prepare(&request, CancellationSignal::default()) .await - .expect("confirm update preview"); - assert_eq!(preview.skills[0].clean_copy_count, 2); - assert_eq!(preview.skills[0].overwrite_private_entries.len(), 1); + .expect("prepare update confirmation"); + assert_eq!(prepared.preview.skills[0].clean_copy_count, 2); + assert_eq!( + prepared.preview.skills[0].overwrite_private_entries.len(), + 1 + ); + let selected_copies = prepared + .preview + .skills + .iter() + .flat_map(|skill| &skill.targets) + .filter_map(|target| target.selectable_entry_id.clone()) + .collect::>(); let response = service - .execute( - &UpdateExecutionRequest { - request, - overwrite_private_entries: Vec::new(), - }, - preview.token, + .execute_prepared( + prepared, + &selected_copies, CancellationSignal::default(), + |_| {}, ) .await .expect("confirm lifecycle update"); @@ -2679,18 +2888,23 @@ mod update_lifecycle { skill_names: vec!["alpha".to_string(), "beta".to_string()], }; let service = self.update_service(); - let preview = service - .preview(&request) + let prepared = service + .prepare(&request, CancellationSignal::default()) .await - .expect("source partial preview"); + .expect("source partial preparation"); + let selected_copies = prepared + .preview + .skills + .iter() + .flat_map(|skill| &skill.targets) + .filter_map(|target| target.selectable_entry_id.clone()) + .collect::>(); let response = service - .execute( - &UpdateExecutionRequest { - request, - overwrite_private_entries: Vec::new(), - }, - preview.token, + .execute_prepared( + prepared, + &selected_copies, CancellationSignal::default(), + |_| {}, ) .await .expect("source partial response"); @@ -2741,6 +2955,7 @@ mod update_lifecycle { self.facts.clone(), self.targets.clone(), self.payloads.clone(), + update_library_repository(self._root.path()), fixed_time, ), RuntimeSkillSourceModule::with_git_transport( @@ -2750,36 +2965,42 @@ mod update_lifecycle { self.git_transport.clone(), ), StageFailurePlanExecutor { - environments: self.environments.clone(), facts: self.facts.clone(), recovery_root: self.recovery_root.clone(), private_root: self.project_path.join(".custom/skills"), }, ); - let preview = service - .preview(&request) + let prepared = service + .prepare(&request, CancellationSignal::default()) .await - .expect("staging partial preview"); - let alpha = preview + .expect("staging partial preparation"); + let alpha = prepared + .preview .skills .iter() .find(|skill| skill.skill_name == "alpha") .expect("alpha update preview"); - let beta = preview + let beta = prepared + .preview .skills .iter() .find(|skill| skill.skill_name == "beta") .expect("beta update preview"); assert_eq!(alpha.overwrite_private_entries.len(), 2); assert_eq!(beta.clean_copy_count, 3); + let selected_copies = prepared + .preview + .skills + .iter() + .flat_map(|skill| &skill.targets) + .filter_map(|target| target.selectable_entry_id.clone()) + .collect::>(); let response = service - .execute( - &UpdateExecutionRequest { - request, - overwrite_private_entries: Vec::new(), - }, - preview.token, + .execute_prepared( + prepared, + &selected_copies, CancellationSignal::default(), + |_| {}, ) .await .expect("staging partial response"); @@ -2829,6 +3050,1467 @@ mod update_lifecycle { } } + #[tokio::test] + #[ignore = "requires Node and skills@1.5.23 from pnpm install; run this filter with --ignored"] + async fn native_update_private_only_cli_installation_in_place() { + assert_private_cli_update(&["codebuddy"]).await; + assert_private_cli_update(&["codebuddy", "minimax-code"]).await; + } + + async fn assert_private_cli_update(agents: &[&str]) { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture + .install_with_cli(&["alpha"], agents, agents.len() > 1, &[]) + .await; + let canonical = fixture + .project_path + .join(".agents") + .join("skills") + .join("alpha"); + let private = fixture + .project_path + .join(".codebuddy") + .join("skills") + .join("alpha"); + assert!(!canonical.exists()); + assert!(private.join("SKILL.md").is_file()); + let facts = ScopePlanningSnapshotSource::snapshot(&fixture.facts, &fixture.context()) + .await + .unwrap(); + let read_plan = crate::application::skill_read::build_skill_read_plan( + &facts.resolved_context, + &facts.agent_runtime, + &[], + ) + .unwrap(); + let inspector = + crate::environment::native::inspection::NativeInspector::new(EnvironmentRef::Native); + let snapshot = crate::environment::inspection::FilesystemInspector::inspect( + &inspector, + &read_plan.read_plan, + ) + .await + .unwrap(); + let listed = crate::application::skill_read::project_direct_skill_snapshot( + &read_plan, + snapshot, + &facts.agent_runtime, + update_library_repository(fixture._root.path()).as_ref(), + &fixture.targets, + &inspector, + ) + .await + .unwrap(); + assert_eq!(listed.skills.len(), 1); + assert_eq!( + fs::canonicalize(&listed.skills[0].canonical_path).unwrap(), + fs::canonicalize(&private).unwrap() + ); + let identity = SkillIdentity { + context: fixture.context(), + skill_name: "alpha".to_string(), + }; + let check = fixture + .check_service() + .check(&UpdateCheckRequest { + context: fixture.context(), + mode: UpdateCheckMode::Force, + selection: UpdateCheckSelection::Skills(vec![identity.clone()]), + }) + .await + .expect("check a CLI private-only installation"); + assert_eq!(check.skills.len(), 1, "{check:#?}"); + assert!(check.skills[0].capability.can_run_update); + let resources = ResourceService::new( + RuntimeResourceContextSource::new( + fixture.facts.clone(), + fixture.targets.clone(), + update_library_repository(fixture._root.path()), + ), + fixture.targets.clone(), + crate::environment::opener::SystemResourceOpener, + RuntimeResourceReader::new(fixture.environments.clone()), + ); + resources + .read_skill(&identity) + .await + .expect("read the actual private installation"); + + fixture.remote.publish_change("alpha"); + let service = fixture.update_service(); + let prepared = service + .prepare( + &UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".to_string()], + }, + CancellationSignal::default(), + ) + .await + .expect("prepare a CLI private-only update"); + let base = prepared + .preview + .path_base + .as_ref() + .expect("preview path base"); + let expected = crate::environment::types::display_locator( + &crate::environment::types::ResourceLocator { + environment: EnvironmentRef::Native, + native_path: fs::canonicalize(&fixture.project_path) + .unwrap() + .to_string_lossy() + .into_owned(), + }, + ); + assert_eq!(base.physical_root, Some(expected)); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + assert_eq!(prepared.preview.skills.len(), 1); + assert_eq!(prepared.preview.skills[0].targets.len(), agents.len()); + let prepared_clone_count = fixture.clone_count(); + let selected_copies = prepared + .preview + .skills + .iter() + .flat_map(|skill| &skill.targets) + .filter_map(|target| target.selectable_entry_id.clone()) + .collect::>(); + let response = service + .execute_prepared( + prepared, + &selected_copies, + CancellationSignal::default(), + |_| {}, + ) + .await + .expect("execute the prepared private-only update"); + assert_eq!(response.outcome, UpdateOutcome::Succeeded, "{response:#?}"); + assert_eq!(fixture.clone_count(), prepared_clone_count); + assert_eq!( + fs::read(private.join("references").join("guide.md")).expect("updated private content"), + fs::read( + fixture + .remote + .work + .join("skills") + .join("alpha") + .join("references") + .join("guide.md") + ) + .expect("upstream content"), + ); + assert!(!canonical.exists()); + for unused in [".minimax", ".custom"] { + if unused == ".minimax" && agents.contains(&"minimax-code") { + assert_eq!( + fs::read( + fixture + .project_path + .join(".minimax/skills/alpha/references/guide.md") + ) + .unwrap(), + fs::read(private.join("references/guide.md")).unwrap() + ); + continue; + } + assert!(!fixture + .project_path + .join(unused) + .join("skills") + .join("alpha") + .exists()); + } + let lock = + read_json(&fixture.project_path.join("skills-lock.json")).expect("updated CLI lock"); + assert_eq!( + lock["skills"]["alpha"]["computedHash"], + fixture.remote.computed_hash("alpha") + ); + assert_no_staging_leaks(fixture._root.path()).expect("no staging leaks"); + } + + #[tokio::test] + #[ignore = "requires Node and skills@1.5.23 from pnpm install; run this filter with --ignored"] + async fn native_eve_update_restores_recorded_missing_installation() { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + let research = fixture + .project_path + .join("agent/subagents/research/skills/alpha"); + let writer = fixture + .project_path + .join("agent/subagents/writer/skills/alpha"); + for name in ["research", "writer"] { + fs::create_dir_all(fixture.project_path.join("agent/subagents").join(name)).unwrap(); + } + write_json( + &fixture.project_path.join("package.json"), + &json!({"dependencies":{"eve":"*"}}), + ) + .unwrap(); + fixture + .install_with_cli(&["alpha"], &["eve"], false, &["research", "writer"]) + .await; + assert!(!fixture.project_path.join(".agents/skills/alpha").exists()); + assert!(!fs::read_to_string(research.join("SKILL.md")) + .unwrap() + .contains("name:")); + let facts = ScopePlanningSnapshotSource::snapshot(&fixture.facts, &fixture.context()) + .await + .unwrap(); + let listed = fixture + .read_installed(Some(&facts.lock_document)) + .await + .unwrap(); + assert_eq!(listed.skills.len(), 1); + assert_eq!(listed.skills[0].name, "alpha"); + assert_eq!( + listed.skills[0].associated_agents, + vec![AgentId::parse("eve").unwrap()] + ); + assert!(fixture + .read_installed(None) + .await + .unwrap() + .skills + .is_empty()); + let mut ambiguous = facts.lock_document.clone().into_value(); + ambiguous["skills"]["ALPHA"] = ambiguous["skills"]["alpha"].clone(); + let ambiguous = crate::core::lossless_lock::LosslessLockDocument::parse( + &serde_json::to_vec(&ambiguous).unwrap(), + ) + .unwrap(); + assert!( + matches!(fixture.read_installed(Some(&ambiguous)).await, Err(AppError::CapabilityUnavailable { capability, .. }) if capability == "installedSkillSourceAmbiguous") + ); + + fs::remove_dir_all(&writer).unwrap(); + fixture.remote.publish_change("alpha"); + let service = fixture.update_service(); + let request = UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + assert_eq!( + prepared.preview.skills[0] + .targets + .iter() + .filter(|t| t.restoring) + .count(), + 1 + ); + drop(prepared); + assert!(!writer.exists()); + + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + let original = build_skill_payload(&research).unwrap(); + fs::create_dir_all(&writer).unwrap(); + fs::write(writer.join("SKILL.md"), b"newly created content").unwrap(); + let stale = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_ne!(stale.outcome, UpdateOutcome::Succeeded); + assert_eq!(build_skill_payload(&research).unwrap(), original); + assert_eq!( + fs::read(writer.join("SKILL.md")).unwrap(), + b"newly created content" + ); + fs::remove_dir_all(&writer).unwrap(); + + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + let selected_copies = prepared + .preview + .skills + .iter() + .flat_map(|skill| &skill.targets) + .filter_map(|target| target.selectable_entry_id.clone()) + .collect::>(); + let result = service + .execute_prepared( + prepared, + &selected_copies, + CancellationSignal::default(), + |_| {}, + ) + .await + .unwrap(); + assert_eq!(result.outcome, UpdateOutcome::Succeeded, "{result:#?}"); + for path in [&research, &writer] { + assert!(!fs::read_to_string(path.join("SKILL.md")) + .unwrap() + .contains("name:")); + assert_eq!( + fs::read(path.join("references/guide.md")).unwrap(), + fs::read(fixture.remote.work.join("skills/alpha/references/guide.md")).unwrap() + ); + } + let lock_path = fixture.project_path.join("skills-lock.json"); + let lock = fs::read(&lock_path).unwrap(); + assert_eq!( + serde_json::from_slice::(&lock).unwrap()["skills"]["alpha"]["subagents"], + json!(["research", "writer"]) + ); + let original_research = fs::read(research.join("SKILL.md")).unwrap(); + let mut edited = original_research.clone(); + edited.extend_from_slice(b"\nlocal Eve edit\n"); + fs::write(research.join("SKILL.md"), &edited).unwrap(); + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!(prepared.preview.skills[0].targets.is_empty()); + assert_eq!( + prepared.preview.skills[0].overwrite_private_entries.len(), + 2 + ); + let preserved = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert!(preserved.skills[0].mutation.is_none()); + assert_eq!(fs::read(research.join("SKILL.md")).unwrap(), edited); + assert_eq!(fs::read(&lock_path).unwrap(), lock); + fs::write(research.join("SKILL.md"), original_research).unwrap(); + + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + fs::write(writer.with_extension("md"), b"new single-file target").unwrap(); + let stale = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_ne!(stale.outcome, UpdateOutcome::Succeeded); + assert_eq!(fs::read(&lock_path).unwrap(), lock); + assert!( + matches!(fixture.read_installed(Some(&facts.lock_document)).await, Err(AppError::CapabilityUnavailable { capability, .. }) if capability == "installedSkillSourceAmbiguous") + ); + fs::remove_file(writer.with_extension("md")).unwrap(); + fs::remove_dir_all(&writer).unwrap(); + let single = writer.with_extension("md"); + fs::write(&single, b"preserve single-file Eve content").unwrap(); + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert_eq!(prepared.preview.blocked.len(), 1); + assert!(prepared.preview.skills.is_empty()); + assert_eq!( + fs::read(&single).unwrap(), + b"preserve single-file Eve content" + ); + assert_eq!(fs::read(&lock_path).unwrap(), lock); + let listed = fixture + .read_installed(Some(&facts.lock_document)) + .await + .unwrap(); + assert_eq!(listed.skills.len(), 1); + assert!( + matches!(&listed.skills[0].maintenance_error, Some(AppError::CapabilityUnavailable { capability, .. }) if capability == "eveSingleFile") + ); + drop(prepared); + + fs::remove_file(single).unwrap(); + fs::remove_dir_all(writer.parent().unwrap().parent().unwrap()).unwrap(); + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert_eq!(prepared.preview.blocked.len(), 1); + assert!(!writer.parent().unwrap().parent().unwrap().exists()); + fs::remove_dir_all(&research).unwrap(); + assert!(fixture + .read_installed(Some(&facts.lock_document)) + .await + .unwrap() + .skills + .is_empty()); + let checked = fixture + .check_service() + .check(&UpdateCheckRequest { + context: fixture.context(), + mode: UpdateCheckMode::Force, + selection: UpdateCheckSelection::Skills(vec![SkillIdentity { + context: fixture.context(), + skill_name: "alpha".into(), + }]), + }) + .await + .unwrap(); + assert!(checked.skills.is_empty()); + } + + #[tokio::test] + async fn native_copy_project_identity_resolves_directory_links_and_rejects_missing_roots() { + use crate::application::copy::CopyProjectComparator; + use crate::environment::runtime::PhysicalIdentityComparison; + + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + let facts = ScopePlanningSnapshotSource::snapshot(&fixture.facts, &fixture.context()) + .await + .unwrap(); + let comparator = RuntimeCopyProjectComparator::new(fixture.environments.clone()); + let source = comparator.capture_source(&facts).await.unwrap(); + let alias = fixture._root.path().join("project-alias"); + #[cfg(unix)] + std::os::unix::fs::symlink(&fixture.project_path, &alias).unwrap(); + #[cfg(windows)] + junction::create(&fixture.project_path, &alias).unwrap(); + let nested = fixture.project_path.join("nested"); + let sibling = fixture._root.path().join("project-sibling"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir_all(&sibling).unwrap(); + for (path, expected) in [ + ( + fixture.project_path.clone(), + PhysicalIdentityComparison::Same, + ), + (alias.clone(), PhysicalIdentityComparison::Same), + (alias.join("nested"), PhysicalIdentityComparison::Same), + (nested, PhysicalIdentityComparison::Same), + ( + fixture._root.path().to_path_buf(), + PhysicalIdentityComparison::Same, + ), + (sibling, PhysicalIdentityComparison::Different), + ] { + let mut target = facts.clone(); + target + .resolved_context + .project + .as_mut() + .unwrap() + .native_path = path.to_str().unwrap().into(); + assert_eq!( + comparator + .compare(&source, &target) + .await + .unwrap() + .physical_identity, + expected, + "{path:?}" + ); + } + let mut missing = facts.clone(); + missing + .resolved_context + .project + .as_mut() + .unwrap() + .native_path = fixture + ._root + .path() + .join("missing-project") + .to_str() + .unwrap() + .into(); + assert!(comparator.capture_source(&missing).await.is_err()); + assert!(comparator.compare(&source, &missing).await.is_err()); + } + + #[tokio::test] + async fn native_private_only_copy_manage_and_remove_preserve_source_layout() { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let context = fixture.context(); + let standard = fixture.project_path.join(".agents/skills/alpha"); + let source = fixture.project_path.join(".codebuddy/skills/alpha"); + let minimax = fixture.project_path.join(".minimax/skills/alpha"); + for path in [ + &standard, + &minimax, + &fixture.project_path.join(".custom/skills/alpha"), + ] { + fs::remove_dir_all(path).unwrap(); + } + let markdown = fs::read(fixture.remote.work.join("skills/alpha/SKILL.md")).unwrap(); + fs::write(source.join("SKILL.md"), &markdown).unwrap(); + let destination = fixture._root.path().join("copy-target"); + fs::create_dir_all(&destination).unwrap(); + write_json(&fixture._root.path().join("state/projects.json"), &json!({"schemaVersion":1, "projects":[ + project("source", &fixture.project_path, "Source"), project("copy-target", &destination, "Target") + ]})).unwrap(); + let copy = CopyService::new( + fixture.facts.clone(), + fixture.targets.clone(), + fixture.payloads.clone(), + InstalledSkillPayloadAcquirer::new( + fixture.payloads.clone(), + fixture.environments.clone(), + ), + fixture.executor(), + RuntimeCopyProjectComparator::new(fixture.environments.clone()), + Arc::new(EmptyLibraryCandidateSource), + ); + let selection = copy.selection(&context, "alpha").await.unwrap().selection; + let mut copy_request = CopyRequest { + skill_name: "alpha".into(), + source: context.clone(), + target_environment: EnvironmentRef::Native, + target_project_ids: vec!["copy-target".into()], + agent_selection: crate::application::agent_selection::AgentSelectionSubmission { + revision: selection.revision, + selected_option_ids: selection.baseline_selected_option_ids, + requested_mode: InstallMode::Copy, + }, + }; + let CopyPreviewOutcome::Ready { preview } = copy.preview(©_request).await.unwrap() + else { + panic!("copy preview"); + }; + let captured = fixture + .payloads + .copy_source_snapshot(&preview.payload) + .unwrap(); + assert_eq!( + fs::canonicalize(&captured.standard_identity.destination.native_path).unwrap(), + fs::canonicalize(&source).unwrap() + ); + let mut changed = markdown.clone(); + changed.extend_from_slice(b"\nchanged after preview\n"); + fs::write(source.join("SKILL.md"), &changed).unwrap(); + assert!(copy + .execute( + &CopyExecutionRequest { + request: copy_request.clone(), + token: preview.token, + payload: preview.payload + }, + CancellationSignal::default() + ) + .await + .is_err()); + assert!(!destination.join(".agents/skills/alpha").exists()); + fs::write(source.join("SKILL.md"), &markdown).unwrap(); + let CopyPreviewOutcome::Ready { preview } = copy.preview(©_request).await.unwrap() + else { + panic!("copy preview"); + }; + let copied = copy + .execute( + &CopyExecutionRequest { + request: copy_request.clone(), + token: preview.token, + payload: preview.payload, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert_succeeded(&copied.units); + assert_eq!( + fs::read(destination.join(".agents/skills/alpha/SKILL.md")).unwrap(), + markdown + ); + assert!(!standard.exists()); + + let manage = ManageAgentsService::new( + fixture.facts.clone(), + ScopeSkillPlacementResolver::new(fixture.targets.clone()), + fixture.targets.clone(), + fixture.payloads.clone(), + InstalledSkillPayloadAcquirer::new( + fixture.payloads.clone(), + fixture.environments.clone(), + ), + fixture.executor(), + Arc::new(EmptyLibraryCandidateSource), + ); + let snapshot = manage.selection(&context, "alpha").await.unwrap(); + let mut selection = manage_submission( + &snapshot, + |option| { + option + .agent_ids + .iter() + .any(|id| matches!(id.as_str(), "codebuddy" | "minimax-code")) + }, + InstallMode::Symlink, + ); + let mut request = ManageAgentsPreviewRequest { + context: context.clone(), + skill_name: "alpha".into(), + agent_selection: selection.clone(), + }; + assert!( + matches!(manage.preview(&request).await, Err(AppError::CapabilityUnavailable { capability, .. }) if capability == "sharedSkillLinkSource") + ); + selection.requested_mode = InstallMode::Copy; + request.agent_selection = selection.clone(); + let preview = ready_manage_preview(manage.preview(&request).await.unwrap()); + let added = manage + .execute( + &ManageAgentsRequest { + token: preview.token, + context: context.clone(), + skill_name: "alpha".into(), + agent_selection: selection, + confirm_entity_directories: false, + original_payload: preview.original_payload, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert_succeeded(&added.units); + assert_eq!(fs::read(minimax.join("SKILL.md")).unwrap(), markdown); + assert!(!standard.exists()); + + let selection = copy.selection(&context, "alpha").await.unwrap().selection; + copy_request.agent_selection.revision = selection.revision; + copy_request.agent_selection.selected_option_ids = selection.baseline_selected_option_ids; + let CopyPreviewOutcome::Ready { preview } = copy.preview(©_request).await.unwrap() + else { + panic!("copy preview"); + }; + let hidden = source.with_file_name("alpha-moved"); + fs::rename(&source, &hidden).unwrap(); + assert!(copy + .execute( + &CopyExecutionRequest { + request: copy_request.clone(), + token: preview.token, + payload: preview.payload + }, + CancellationSignal::default() + ) + .await + .is_err()); + assert_eq!( + fs::read(destination.join(".agents/skills/alpha/SKILL.md")).unwrap(), + markdown + ); + fs::rename(&hidden, &source).unwrap(); + + fs::write(minimax.join("SKILL.md"), &changed).unwrap(); + let selection = copy.selection(&context, "alpha").await.unwrap().selection; + copy_request.agent_selection.revision = selection.revision; + copy_request.agent_selection.selected_option_ids = selection.baseline_selected_option_ids; + assert!( + matches!(copy.preview(©_request).await, Err(AppError::CapabilityUnavailable { capability, .. }) if capability == "installedSkillSourceAmbiguous") + ); + let snapshot = manage.selection(&context, "alpha").await.unwrap(); + let add = manage_submission(&snapshot, |_| true, InstallMode::Copy); + assert!( + matches!(manage.preview(&ManageAgentsPreviewRequest { context: context.clone(), skill_name: "alpha".into(), agent_selection: add }).await, Err(AppError::CapabilityUnavailable { capability, .. }) if capability == "installedSkillSourceAmbiguous") + ); + let selection = manage_submission( + &snapshot, + |option| option.agent_ids.iter().any(|id| id.as_str() == "codebuddy"), + InstallMode::Copy, + ); + let preview = ready_manage_preview( + manage + .preview(&ManageAgentsPreviewRequest { + context: context.clone(), + skill_name: "alpha".into(), + agent_selection: selection.clone(), + }) + .await + .unwrap(), + ); + assert!(preview.original_payload.is_none()); + let removed = manage + .execute( + &ManageAgentsRequest { + token: preview.token, + context: context.clone(), + skill_name: "alpha".into(), + agent_selection: selection, + confirm_entity_directories: true, + original_payload: None, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert_succeeded(&removed.units); + assert!(!minimax.exists()); + assert_eq!(fs::read(source.join("SKILL.md")).unwrap(), markdown); + assert!(!standard.exists()); + + let remove = RemoveService::new( + fixture.facts.clone(), + fixture.targets.clone(), + fixture.executor(), + Arc::new(EmptyLibraryCandidateSource), + ); + let preview = remove.preview(&context, "alpha").await.unwrap(); + assert_eq!( + serde_json::to_value(&preview).unwrap().get("standardPath"), + Some(&serde_json::Value::Null), + ); + assert_eq!(preview.physical_entries.len(), 1); + let result = remove + .execute( + &RemoveRequest { + token: preview.token, + context, + skill_name: "alpha".into(), + intent: RemoveIntent::FullSkill, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert_succeeded(&result.units); + assert!(!source.exists()); + assert!(!standard.exists()); + let lock = read_json(&fixture.project_path.join("skills-lock.json")).unwrap(); + assert!(lock["skills"]["alpha"].is_null()); + assert!(lock["skills"]["beta"].is_object()); + assert!(fixture + .project_path + .join(".agents/skills/beta/SKILL.md") + .exists()); + assert_eq!(fixture.clone_count(), 1); + } + + #[tokio::test] + async fn native_update_private_copies_respects_selection_and_new_standard_directory() { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let standard = fixture.project_path.join(".agents/skills/alpha"); + fs::remove_dir_all(&standard).unwrap(); + let private = [".codebuddy", ".minimax", ".custom"] + .map(|root| fixture.project_path.join(root).join("skills/alpha")); + let before = private + .iter() + .map(|path| build_skill_payload(path).unwrap()) + .collect::>(); + let lock_path = fixture.project_path.join("skills-lock.json"); + let lock_before = fs::read(&lock_path).unwrap(); + fixture.remote.publish_change("alpha"); + fixture.remote.publish_change("beta"); + let service = fixture.update_service(); + let request = UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!(prepared.preview.skills[0].targets.is_empty()); + assert_eq!( + prepared.preview.skills[0].overwrite_private_entries.len(), + 3 + ); + let kept = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert!(kept.skills[0].mutation.is_none()); + assert_eq!(fs::read(&lock_path).unwrap(), lock_before); + for (path, payload) in private.iter().zip(&before) { + assert_eq!(&build_skill_payload(path).unwrap(), payload); + } + + let batch = UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into(), "beta".into()], + }; + let prepared = service + .prepare(&batch, CancellationSignal::default()) + .await + .unwrap(); + let selected_copies = prepared + .preview + .skills + .iter() + .flat_map(|skill| &skill.targets) + .filter_map(|target| target.selectable_entry_id.clone()) + .collect::>(); + let response = service + .execute_prepared( + prepared, + &selected_copies, + CancellationSignal::default(), + |_| {}, + ) + .await + .unwrap(); + assert_eq!(response.outcome, UpdateOutcome::Partial); + assert!(response + .skills + .iter() + .find(|s| s.skill_identity.skill_name == "alpha") + .unwrap() + .mutation + .is_none()); + assert_eq!( + read_json(&lock_path).unwrap()["skills"]["alpha"], + serde_json::from_slice::(&lock_before).unwrap()["skills"]["alpha"] + ); + + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + let selected = prepared.preview.skills[0] + .overwrite_private_entries + .iter() + .find(|entry| { + entry + .readers + .iter() + .any(|r| r.agent_id.as_str() == "custom-test") + }) + .unwrap() + .entry_id + .clone(); + let response = service + .execute_prepared(prepared, &[selected], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(response.outcome, UpdateOutcome::Succeeded); + assert_eq!(build_skill_payload(&private[0]).unwrap(), before[0]); + assert_eq!(build_skill_payload(&private[1]).unwrap(), before[1]); + assert_eq!( + fs::read(private[2].join("references/guide.md")).unwrap(), + fs::read(fixture.remote.work.join("skills/alpha/references/guide.md")).unwrap() + ); + assert!(!standard.exists()); + + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + let selected = prepared.preview.skills[0].overwrite_private_entries[0] + .entry_id + .clone(); + let contents = private + .iter() + .map(|path| build_skill_payload(path).unwrap()) + .collect::>(); + let lock_before = fs::read(&lock_path).unwrap(); + fs::create_dir_all(&standard).unwrap(); + fs::write( + standard.join("SKILL.md"), + b"newly created by another process", + ) + .unwrap(); + let stale = service + .execute_prepared(prepared, &[selected], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_ne!(stale.outcome, UpdateOutcome::Succeeded); + for (path, payload) in private.iter().zip(&contents) { + assert_eq!(&build_skill_payload(path).unwrap(), payload); + } + assert_eq!(fs::read(lock_path).unwrap(), lock_before); + assert_eq!( + fs::read(standard.join("SKILL.md")).unwrap(), + b"newly created by another process" + ); + } + + #[tokio::test] + async fn native_project_update_excludes_library_members_through_leaf_and_root_links() { + use crate::application::skill_libraries::{LibraryId, LIBRARY_SCHEMA_VERSION}; + + for (relative, link_root) in [ + (".agents/skills/alpha", false), + (".agents/skills", true), + (".codebuddy/skills", true), + ] { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let libraries = update_library_repository(fixture._root.path()); + let catalog = serde_json::from_value(json!({ + "schemaVersion": LIBRARY_SCHEMA_VERSION, + "libraries": [{"id":"owned-library", "name":"Owned library", "skills":[{ + "name":"alpha", "description":"Library content", "sourceRecord":{}, "contentManifestHash":"library-baseline" + }], "retiredSkills":[]}] + })).unwrap(); + libraries + .save(&EnvironmentRef::Native, &catalog) + .await + .unwrap(); + let library_root = PathBuf::from( + libraries + .resolve_collection(&EnvironmentRef::Native, &LibraryId::parse("owned-library")) + .await + .unwrap() + .root + .native_path, + ); + let member = library_root.join("alpha"); + fs::create_dir_all(&member).unwrap(); + fs::write( + member.join("SKILL.md"), + b"---\nname: alpha\ndescription: Library content\n---\nKeep library content\n", + ) + .unwrap(); + fs::write(member.join("library-only.txt"), b"must remain unchanged").unwrap(); + let before = build_skill_payload(&member).unwrap(); + let entry = fixture.project_path.join(relative); + fs::remove_dir_all(&entry).unwrap(); + let target = if link_root { &library_root } else { &member }; + #[cfg(unix)] + std::os::unix::fs::symlink(target, &entry).unwrap(); + #[cfg(windows)] + junction::create(target, &entry).unwrap(); + + let listed = fixture.read_installed(None).await.unwrap(); + let listed_alpha = listed + .skills + .iter() + .find(|skill| skill.name == "alpha") + .unwrap(); + assert_ne!( + fs::canonicalize(&listed_alpha.canonical_path).unwrap(), + fs::canonicalize(&member).unwrap() + ); + + fixture.remote.publish_change("alpha"); + let service = fixture.update_service(); + let prepared = service + .prepare( + &UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{relative}: {:#?}", + prepared.preview + ); + let preview = &prepared.preview.skills[0]; + let physical_library = fs::canonicalize(&library_root).unwrap(); + for path in preview + .targets + .iter() + .map(|target| &target.display_path) + .chain( + preview + .overwrite_private_entries + .iter() + .map(|target| &target.display_path), + ) + { + assert!( + !fs::canonicalize(&path.native_path) + .unwrap() + .starts_with(&physical_library), + "{relative}: library content entered update preview" + ); + } + let selected = preview + .overwrite_private_entries + .iter() + .map(|entry| entry.entry_id.clone()) + .chain( + preview + .targets + .iter() + .filter_map(|target| target.selectable_entry_id.clone()), + ) + .collect::>(); + let result = service + .execute_prepared(prepared, &selected, CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!( + result.outcome, + UpdateOutcome::Succeeded, + "{relative}: {result:#?}" + ); + assert_eq!( + build_skill_payload(&member).unwrap(), + before, + "{relative}: library content was overwritten" + ); + } + } + + #[tokio::test] + async fn native_project_update_respects_resolved_scope_boundaries() { + for layout in [ + "external-agent-root", + "internal-agent-root", + "project-alias", + ] { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let (logical, actual) = match layout { + "external-agent-root" => ( + fixture.project_path.join(".codebuddy"), + fixture._root.path().join("external-agent-content"), + ), + "internal-agent-root" => ( + fixture.project_path.join(".codebuddy"), + fixture.project_path.join("agent-content"), + ), + _ => ( + fixture.project_path.clone(), + fixture._root.path().join("actual-project"), + ), + }; + fs::rename(&logical, &actual).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&actual, &logical).unwrap(); + #[cfg(windows)] + junction::create(&actual, &logical).unwrap(); + let link_before = fs::read_link(&logical).unwrap(); + let member = if layout == "project-alias" { + actual.join(".codebuddy/skills/alpha") + } else { + actual.join("skills/alpha") + }; + let before = build_skill_payload(&member).unwrap(); + fixture.remote.publish_change("alpha"); + let service = fixture.update_service(); + let prepared = service + .prepare( + &UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{layout}: {:#?}", + prepared.preview + ); + let preview = &prepared.preview.skills[0]; + let physical_member = fs::canonicalize(&member).unwrap(); + let includes_member = preview + .targets + .iter() + .map(|target| &target.display_path) + .chain( + preview + .overwrite_private_entries + .iter() + .map(|target| &target.display_path), + ) + .any(|path| fs::canonicalize(&path.native_path).unwrap() == physical_member); + let preserves_member = preview.preserved_targets.as_ref().is_some_and(|targets| { + targets.iter().any(|target| { + fs::canonicalize(&target.display_path.native_path).unwrap() == physical_member + }) + }); + let selected = preview + .overwrite_private_entries + .iter() + .map(|entry| entry.entry_id.clone()) + .chain( + preview + .targets + .iter() + .filter_map(|target| target.selectable_entry_id.clone()), + ) + .collect::>(); + let result = service + .execute_prepared(prepared, &selected, CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!( + result.outcome, + UpdateOutcome::Succeeded, + "{layout}: {result:#?}" + ); + assert_eq!(fs::read_link(&logical).unwrap(), link_before); + let changed = build_skill_payload(&member).unwrap() != before; + if layout == "external-agent-root" { + assert!(result.skills[0].warnings.is_empty()); + assert!( + !includes_member && !changed && preserves_member, + "external in update: {includes_member}; external changed: {changed}; shown as preserved: {preserves_member}" + ); + } else { + assert!(includes_member && changed && !preserves_member, "{layout}"); + } + } + } + + #[tokio::test] + async fn native_update_preview_groups_sources_and_exposes_read_only_link_locations() { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let common = fixture.project_path.join(".agents/skills/alpha"); + let link = fixture.project_path.join(".codebuddy/skills/alpha"); + fs::remove_dir_all(&link).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&common, &link).unwrap(); + #[cfg(windows)] + junction::create(&common, &link).unwrap(); + let before = fixture.clone_count(); + let prepared = fixture + .update_service() + .prepare( + &UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into(), "beta".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + assert_eq!(prepared.preview.sources.len(), 1); + let group = &prepared.preview.sources[0]; + assert_eq!(group.skill_names, vec!["alpha", "beta"]); + assert_eq!(fixture.clone_count(), before + 1); + assert!(prepared + .preview + .skills + .iter() + .all(|skill| skill.source_key.as_ref() == Some(&group.source_key))); + let alpha = &prepared.preview.skills[0]; + let standard = alpha + .targets + .iter() + .find(|target| target.is_standard == Some(true)) + .unwrap(); + assert_eq!(alpha.linked_targets.len(), 1); + let linked = &alpha.linked_targets[0]; + let backend = if cfg!(windows) { + ExecutionBackend::NativeWindows + } else { + ExecutionBackend::NativeUnix + }; + let physical_key = |path: &Path| { + crate::environment::native::tree::project_target(path, backend.clone()) + .unwrap() + .key + }; + assert_eq!( + physical_key(Path::new(&standard.display_path.native_path)), + physical_key(&common) + ); + assert_eq!( + physical_key(Path::new(&linked.display_path.native_path)), + physical_key(&link) + ); + assert_eq!( + physical_key(Path::new(&linked.target_path.native_path)), + physical_key(&common) + ); + assert!(linked.target_copy_entry_id.is_none()); + assert!(alpha.overwrite_private_entries.is_empty()); + assert!(!alpha.targets.iter().any(|target| physical_key(Path::new( + &target.display_path.native_path + )) == physical_key(&link))); + } + + #[tokio::test] + async fn native_update_optional_copies_skip_content_and_can_be_updated_later() { + use crate::application::update::UpdateCoverage; + for keep_standard in [true, false] { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let standard = fixture.project_path.join(".agents/skills/alpha"); + let copy = fixture.project_path.join(".minimax/skills/alpha"); + let link = fixture.project_path.join(".codebuddy/skills/alpha"); + fs::remove_dir_all(&link).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(©, &link).unwrap(); + #[cfg(windows)] + junction::create(©, &link).unwrap(); + fs::remove_dir_all(fixture.project_path.join(".custom/skills/alpha")).unwrap(); + if !keep_standard { + fs::remove_dir_all(&standard).unwrap(); + } + let before = build_skill_payload(©).unwrap(); + let lock_path = fixture.project_path.join("skills-lock.json"); + let lock_before = fs::read(&lock_path).unwrap(); + fixture.remote.publish_change("alpha"); + let service = fixture.update_service(); + let request = UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into()], + }; + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + let target = prepared.preview.skills[0] + .targets + .iter() + .find(|target| target.selectable_entry_id.is_some()) + .unwrap(); + assert_eq!( + prepared.preview.skills[0].linked_targets[0].target_copy_entry_id, + target.selectable_entry_id + ); + assert!(target + .readers + .iter() + .all(|reader| reader.agent_id.as_str() != "codebuddy")); + assert!(prepared.preview.skills[0] + .targets + .iter() + .filter(|target| target.is_standard == Some(true)) + .all(|target| target.selectable_entry_id.is_none())); + let result = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!( + build_skill_payload(©).unwrap(), + before, + "unchecked matching copy was overwritten" + ); + assert_eq!(build_skill_payload(&link).unwrap(), before); + assert_eq!( + result.skills[0].skipped_copy_paths.as_ref().unwrap().len(), + 1 + ); + if keep_standard { + assert_eq!(result.outcome, UpdateOutcome::Succeeded); + assert_eq!( + result.skills[0].coverage, + UpdateCoverage::UpdatedWithSkippedCopies + ); + assert_ne!(build_skill_payload(&standard).unwrap(), before); + } else { + assert!(result.skills[0].mutation.is_none()); + assert_eq!(fs::read(&lock_path).unwrap(), lock_before); + } + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + let preview = &prepared.preview.skills[0]; + let selected = preview + .targets + .iter() + .filter_map(|target| target.selectable_entry_id.clone()) + .chain( + preview + .overwrite_private_entries + .iter() + .map(|entry| entry.entry_id.clone()), + ) + .collect::>(); + assert_eq!(selected.len(), 1); + let result = service + .execute_prepared(prepared, &selected, CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(result.outcome, UpdateOutcome::Succeeded, "{result:#?}"); + assert_ne!(build_skill_payload(©).unwrap(), before); + assert_eq!( + build_skill_payload(©).unwrap(), + build_skill_payload(&link).unwrap() + ); + } + } + + #[tokio::test] + async fn native_project_update_with_only_external_content_does_not_acquire_or_write() { + use crate::application::mutation::result::OperationErrorCode; + use crate::application::update::UpdateCoverage; + + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let logical = fixture.project_path.join(".codebuddy"); + let external = fixture._root.path().join("external-agent-content"); + fs::rename(&logical, &external).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&external, &logical).unwrap(); + #[cfg(windows)] + junction::create(&external, &logical).unwrap(); + for root in [".agents/skills", ".minimax/skills", ".custom/skills"] { + let path = fixture.project_path.join(root).join("alpha"); + if path.exists() { + fs::remove_dir_all(path).unwrap(); + } + } + let lock_path = fixture.project_path.join("skills-lock.json"); + let lock_before = fs::read(&lock_path).unwrap(); + let member = external.join("skills/alpha"); + let content_before = build_skill_payload(&member).unwrap(); + let clones_before = fixture.clone_count(); + let service = fixture.update_service(); + let prepared = service + .prepare( + &UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + let preview = &prepared.preview.skills[0]; + assert!(preview.targets.is_empty()); + assert!(preview.overwrite_private_entries.is_empty()); + assert_eq!( + preview.blocking_reasons, + vec![OperationErrorCode::NoUpdateTargets] + ); + assert!(!preview.preserved_targets.as_ref().unwrap().is_empty()); + assert_eq!(fixture.clone_count(), clones_before); + let result = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert!(matches!( + &result.skills[0].coverage, + UpdateCoverage::NotUpdated { error } if error.code == OperationErrorCode::NoUpdateTargets + )); + assert!(result.skills[0].mutation.is_none()); + assert_eq!(fs::read(lock_path).unwrap(), lock_before); + assert_eq!(build_skill_payload(&member).unwrap(), content_before); + } + + #[tokio::test] + async fn native_project_update_rejects_a_retargeted_agent_root_after_confirmation() { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let logical = fixture.project_path.join(".codebuddy"); + let actual = fixture.project_path.join("agent-content"); + let external = fixture._root.path().join("external-content"); + fs::rename(&logical, &actual).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&actual, &logical).unwrap(); + #[cfg(windows)] + junction::create(&actual, &logical).unwrap(); + fs::create_dir_all(external.join("skills/alpha")).unwrap(); + fs::write(external.join("skills/alpha/SKILL.md"), b"external content").unwrap(); + let actual_before = build_skill_payload(&actual.join("skills/alpha")).unwrap(); + let canonical = fixture.project_path.join(".agents/skills/alpha"); + let canonical_before = build_skill_payload(&canonical).unwrap(); + let lock_before = fs::read(fixture.project_path.join("skills-lock.json")).unwrap(); + fixture.remote.publish_change("alpha"); + let service = fixture.update_service(); + let prepared = service + .prepare( + &UpdateRequest { + context: fixture.context(), + skill_names: vec!["alpha".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + let selected = prepared.preview.skills[0] + .overwrite_private_entries + .iter() + .map(|entry| entry.entry_id.clone()) + .collect::>(); + #[cfg(unix)] + fs::remove_file(&logical).unwrap(); + #[cfg(windows)] + fs::remove_dir(&logical).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&external, &logical).unwrap(); + #[cfg(windows)] + junction::create(&external, &logical).unwrap(); + let result = service + .execute_prepared(prepared, &selected, CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_ne!(result.outcome, UpdateOutcome::Succeeded, "{result:#?}"); + assert_eq!( + build_skill_payload(&actual.join("skills/alpha")).unwrap(), + actual_before + ); + assert_eq!(build_skill_payload(&canonical).unwrap(), canonical_before); + assert_eq!( + fs::read(fixture.project_path.join("skills-lock.json")).unwrap(), + lock_before + ); + assert_eq!( + fs::read(external.join("skills/alpha/SKILL.md")).unwrap(), + b"external content" + ); + } + + #[tokio::test] + async fn native_update_replaces_equal_content_with_a_different_existing_marker() { + let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; + fixture.install().await; + let path = fixture.project_path.join(".agents/skills/beta"); + let content = build_skill_payload(&path).unwrap(); + let lock_path = fixture.project_path.join("skills-lock.json"); + let mut lock = read_json(&lock_path).unwrap(); + lock["skills"]["beta"]["computedHash"] = json!("existing-marker-from-another-algorithm"); + write_json(&lock_path, &lock).unwrap(); + let checked = fixture + .check_service() + .check(&UpdateCheckRequest { + context: fixture.context(), + mode: UpdateCheckMode::Force, + selection: UpdateCheckSelection::Skills(vec![SkillIdentity { + context: fixture.context(), + skill_name: "beta".into(), + }]), + }) + .await + .unwrap(); + assert!(checked.skills[0].has_update); + let service = fixture.update_service(); + let prepared = service + .prepare( + &UpdateRequest { + context: fixture.context(), + skill_names: vec!["beta".into()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + let result = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(result.outcome, UpdateOutcome::Succeeded); + assert_eq!(build_skill_payload(&path).unwrap(), content); + assert_eq!( + read_json(&lock_path).unwrap()["skills"]["beta"]["computedHash"], + fixture.remote.computed_hash("beta") + ); + } + #[tokio::test] async fn native_update_lifecycle_reuses_source_work_and_preserves_conflicts() { let fixture = UpdateLifecycleFixture::new(["alpha", "beta"]).await; @@ -2840,7 +4522,7 @@ mod update_lifecycle { fixture.publish_change("alpha").await; fixture.check_force_for("alpha").await; - let preview = fixture.preview(["alpha"]).await; + let preview = fixture.prepare(["alpha"]).await; fixture.cancel(preview).await; let before = fixture.clone_count(); let response = fixture.confirm_preserving_conflicts(["alpha"]).await; @@ -2849,7 +4531,7 @@ mod update_lifecycle { assert_eq!(response.outcome, UpdateOutcome::Succeeded); assert!(response.skills[0] .warnings - .contains(&UpdateWarningCode::PreservedConflictingCopy)); + .contains(&UpdateWarningCode::SkippedCopy)); fixture.assert_source_partial().await; fixture.assert_per_skill_staging_partial().await; diff --git a/src-tauri/src/test_support/payload_storage.rs b/src-tauri/src/test_support/payload_storage.rs new file mode 100644 index 00000000..345c6807 --- /dev/null +++ b/src-tauri/src/test_support/payload_storage.rs @@ -0,0 +1,120 @@ +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{Notify, Semaphore}; + +use crate::application::payload_session::{ + PayloadSessionStorage, PayloadStorageFuture, PayloadStorageKey, +}; +use crate::core::skill_payload::{SkillPayload, SkillPayloadManifest}; +use crate::error::AppError; + +pub(crate) struct PayloadIoPause { + started: Notify, + resume: Semaphore, +} + +impl Default for PayloadIoPause { + fn default() -> Self { + Self { + started: Notify::new(), + resume: Semaphore::new(0), + } + } +} + +impl PayloadIoPause { + pub async fn wait_until_started(&self) { + tokio::time::timeout(Duration::from_secs(5), self.started.notified()) + .await + .expect("payload I/O did not start"); + } + + pub fn resume(&self) { + self.resume.add_permits(1); + } + + pub async fn pause(&self) { + self.started.notify_one(); + tokio::time::timeout(Duration::from_secs(5), self.resume.acquire()) + .await + .expect("payload I/O was not resumed") + .expect("resume semaphore") + .forget(); + } +} + +pub(crate) struct PausedPayloadStorage { + pub inner: Arc, + pub store_pause: PayloadIoPause, + pub verify_pause: Option, + pub stores: AtomicUsize, + pub fail_after_store: AtomicBool, +} + +impl PausedPayloadStorage { + pub fn new(inner: Arc) -> Self { + Self { + inner, + store_pause: PayloadIoPause::default(), + verify_pause: None, + stores: AtomicUsize::new(0), + fail_after_store: AtomicBool::new(false), + } + } +} + +impl PayloadSessionStorage for PausedPayloadStorage { + fn store<'a>( + &'a self, + key: &'a PayloadStorageKey, + payload: SkillPayload, + ) -> PayloadStorageFuture<'a, Result> { + Box::pin(async move { + self.stores.fetch_add(1, Ordering::SeqCst); + self.store_pause.pause().await; + let bytes = self.inner.store(key, payload).await?; + if self.fail_after_store.swap(false, Ordering::SeqCst) { + return Err(AppError::Io { + message: "injected store failure".to_string(), + }); + } + Ok(bytes) + }) + } + + fn verify<'a>( + &'a self, + key: &'a PayloadStorageKey, + ) -> PayloadStorageFuture<'a, Result, AppError>> { + Box::pin(async move { + if let Some(pause) = &self.verify_pause { + pause.pause().await; + } + self.inner.verify(key).await + }) + } + + fn read_blob<'a>( + &'a self, + key: &'a PayloadStorageKey, + blob_id: &'a str, + ) -> PayloadStorageFuture<'a, Result>, AppError>> { + self.inner.read_blob(key, blob_id) + } + + fn remove<'a>( + &'a self, + key: &'a PayloadStorageKey, + ) -> PayloadStorageFuture<'a, Result<(), AppError>> { + self.inner.remove(key) + } + + fn remove_session<'a>( + &'a self, + session_id: &'a str, + ) -> PayloadStorageFuture<'a, Result<(), AppError>> { + self.inner.remove_session(session_id) + } +} diff --git a/src-tauri/src/test_support/wsl_update_workflow.rs b/src-tauri/src/test_support/wsl_update_workflow.rs new file mode 100644 index 00000000..7ea4c3d7 --- /dev/null +++ b/src-tauri/src/test_support/wsl_update_workflow.rs @@ -0,0 +1,1304 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::application::agent_selection::build_agent_selection_catalog; +use crate::application::library_candidates::ResolvedLibraryCandidateIndex; +use crate::application::mutation::coordinator::{BoxFuture, RuntimeRevisionSource}; +use crate::application::mutation::plan::RuntimeRevisions; +use crate::application::payload_session::{ + PayloadPlanningMetadata, PayloadSessionLimits, PayloadSessionManager, +}; +use crate::application::planning_facts::{ + ScopePlanningFuture, ScopePlanningSnapshot, ScopePlanningSnapshotSource, +}; +use crate::application::resources::AuthorizedResourceReader; +use crate::application::scope_skill_placements::{ + observe_scope_skill_placements, representative_direct_placement, +}; +use crate::application::skill_libraries::*; +use crate::application::skill_source::{ + AcquiredSavedSkillSource, SavedSkillSourceAcquisition, SavedSkillSourceGroup, + SkillSourceFuture, SkillSourceModule, +}; +use crate::application::update::{UpdateOutcome, UpdateRequest, UpdateService}; +use crate::application::update_planner::ConcreteUpdatePlanner; +use crate::application::update_records::{ + InstalledUpdateRecordProvider, InstalledUpdateRecordSnapshots, UpdateRecordState, +}; +use crate::core::lossless_lock::LockSchema; +use crate::core::mutation::CancellationSignal; +use crate::core::skill_payload::{build_skill_payload, compute_cli_project_hash_from_payload}; +use crate::environment::agent_environment::{AgentEnvironmentResolver, EnvironmentContext}; +use crate::environment::context_resolver::ResolvedContext; +use crate::environment::inspection::FilesystemInspector; +use crate::environment::native::recovery::NativeRecoveryMarkerStore; +use crate::environment::planning::RuntimeTargetFactResolver; +use crate::environment::runtime::ContextSnapshotRevision; +use crate::environment::types::{ + EnvironmentRef, EnvironmentStatus, RegisteredProject, ResourceLocator, SkillLocation, + SkillLocationRef, +}; +use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; +use crate::environment::wsl::operations::inspection::WslInspector; +use crate::environment::wsl::{WslRuntime, WslWorkspace}; +use crate::error::AppError; +use crate::git_fixture::BareSkillRepo; +use crate::runtime::plan_runner::{RuntimeLockCommitter, RuntimePlanExecutor}; +use crate::runtime::resource_service::RuntimeResourceReader; +use crate::storage::lock_plan::load_lock_document; + +// 只替换与本用例无关的库数据;目标观察、内容存储和事务仍使用真实 Worker。 +struct EmptyLibraries; + +impl SkillLibraryRepository for EmptyLibraries { + fn load<'a>( + &'a self, + _: &'a EnvironmentRef, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { Ok(LibraryCatalog::default()) }) + } + + fn resolve_collection<'a>( + &'a self, + _: &'a EnvironmentRef, + _: &'a LibraryId, + ) -> LibraryFuture<'a, Result> + { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn save<'a>( + &'a self, + _: &'a EnvironmentRef, + _: &'a LibraryCatalog, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn commit_member<'a>( + &'a self, + _: CommitLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn purge_retired<'a>( + &'a self, + _: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn delete_library<'a>( + &'a self, + _: &'a EnvironmentRef, + _: &'a LibraryId, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn read_skill_content<'a>( + &'a self, + _: &'a EnvironmentRef, + _: &'a LibraryId, + _: &'a str, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } +} + +#[derive(Clone)] +struct FixtureFacts { + initial: ScopePlanningSnapshot, + workspace: WslWorkspace, +} + +#[derive(Clone)] +struct FixtureLocations { + source: FixtureFacts, + target: FixtureFacts, +} + +impl ScopePlanningSnapshotSource for FixtureLocations { + fn snapshot<'a>( + &'a self, + context: &'a SkillLocationRef, + ) -> ScopePlanningFuture<'a, Result> { + if context == &self.source.initial.resolved_context.context { + ScopePlanningSnapshotSource::snapshot(&self.source, context) + } else { + ScopePlanningSnapshotSource::snapshot(&self.target, context) + } + } +} + +impl RuntimeRevisionSource for FixtureLocations { + fn current<'a>( + &'a self, + context: &'a SkillLocationRef, + ) -> BoxFuture<'a, Result> { + if context == &self.source.initial.resolved_context.context { + self.source.current(context) + } else { + self.target.current(context) + } + } +} + +impl ScopePlanningSnapshotSource for FixtureFacts { + fn snapshot<'a>( + &'a self, + context: &'a SkillLocationRef, + ) -> ScopePlanningFuture<'a, Result> { + Box::pin(async move { + if context != &self.initial.resolved_context.context { + return Err(AppError::StaleContext); + } + let mut snapshot = self.initial.clone(); + snapshot.lock_document = load_lock_document( + &WslAtomicDocumentIo::new(self.workspace.clone()), + &snapshot.resolved_context.lock, + None, + LockSchema::Project, + ) + .await?; + if let Some(project) = &snapshot.resolved_context.project { + snapshot.eve_targets = crate::environment::agent_environment::inspect_eve_project( + &self.workspace, + &project.native_path, + ) + .await? + .install_targets(&project.native_path); + } + Ok(snapshot) + }) + } +} + +impl RuntimeRevisionSource for FixtureFacts { + fn current<'a>( + &'a self, + _: &'a SkillLocationRef, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(self.initial.revisions.clone()) }) + } +} + +// 来源由受控 Git 夹具提供,完整内容通过正式存储接口传给 WSL。 +struct FixtureSource { + root: PathBuf, + payloads: Arc, + calls: Arc, +} + +impl SkillSourceModule for FixtureSource { + fn acquire_saved_groups<'a>( + &'a self, + groups: &'a [SavedSkillSourceGroup], + cancellation: CancellationSignal, + ) -> SkillSourceFuture<'a, Result, AppError>> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + let mut results = Vec::new(); + for group in groups { + let discovery = self + .payloads + .discover( + group.environment.clone(), + format!("wsl-cli-fixture-{}", uuid::Uuid::new_v4()), + ) + .await?; + let mut handles = Vec::new(); + let mut leases = Vec::new(); + for skill in &group.skills { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let path = + crate::core::skill_paths::normalize_skill_folder_path(skill.skill_path()); + let payload = build_skill_payload(&self.root.join(&path))?; + let computed_hash = compute_cli_project_hash_from_payload(&payload)?; + let handle = self.payloads.acquire_payload_with_metadata( + &discovery, path.clone(), payload, + PayloadPlanningMetadata { + skill_name: skill.name.clone(), + install_dir_name: crate::application::installed_skill_resolver::InstalledSkillResolver::install_dir_name(&skill.name)?, + source: skill.metadata.source.clone(), + source_type: skill.metadata.source_type.clone(), + source_url: skill.metadata.source_url.clone(), + ref_name: skill.metadata.ref_name.clone(), + skill_path: path, + plugin_name: None, + computed_hash, + upstream_revision: None, + well_known: None, + }, + ).await?; + leases.push(Arc::new(self.payloads.pin_verified(&handle).await?)); + handles.push((skill.name.clone(), handle)); + } + results.push(SavedSkillSourceAcquisition { + source_result_id: group.source_result_id.clone(), + source: group.source.clone(), + skill_names: group + .skills + .iter() + .map(|skill| skill.name.clone()) + .collect(), + result: Ok(AcquiredSavedSkillSource { + discovery_session: discovery, + payloads: handles, + skill_errors: Vec::new(), + redirected_download_hosts: Vec::new(), + _leases: leases, + }), + }); + } + Ok(results) + }) + } +} + +async fn run_fixture( + distro: &str, + script: &str, + arguments: &[String], +) -> Result, AppError> { + let mut command: tokio::process::Command = + crate::background_process::std_command("wsl.exe").into(); + command + .args([ + "--distribution", + distro, + "--exec", + "/bin/sh", + "-c", + script, + "--", + ]) + .args(arguments) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(30), command.output()) + .await + .map_err(|_| AppError::ExecutionFailed { + message: "WSL CLI fixture timed out".into(), + })??; + if !output.status.success() { + return Err(AppError::ExecutionFailed { + message: format!( + "WSL fixture failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + }); + } + Ok(output.stdout) +} + +struct FixtureCleanup { + distro: String, + root: String, +} + +impl Drop for FixtureCleanup { + fn drop(&mut self) { + if !self.root.starts_with("/tmp/skill-deck-cli-update-") { + return; + } + let Ok(mut child) = crate::background_process::std_command("wsl.exe") + .args([ + "--distribution", + &self.distro, + "--exec", + "rm", + "-rf", + "--", + &self.root, + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + else { + return; + }; + let deadline = Instant::now() + Duration::from_secs(10); + while matches!(child.try_wait(), Ok(None)) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + if matches!(child.try_wait(), Ok(None)) { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[tokio::test] +#[ignore = "requires Windows, a matching Worker, and SKILL_DECK_TEST_WSL_NODE / SKILL_DECK_TEST_WSL_CLI"] +async fn real_wsl2_private_only_update_preserves_layout() { + run_wsl_update(WslWorkflow::PrivateUpdate).await; +} + +#[tokio::test] +#[ignore = "requires Windows, a matching Worker, and SKILL_DECK_TEST_WSL_NODE / SKILL_DECK_TEST_WSL_CLI"] +async fn real_wsl2_project_update_preserves_external_content() { + run_wsl_update(WslWorkflow::ScopeBoundary).await; +} + +#[tokio::test] +#[ignore = "requires Windows, a matching Worker, and SKILL_DECK_TEST_WSL_NODE / SKILL_DECK_TEST_WSL_CLI"] +async fn real_wsl2_eve_update_restores_recorded_missing_installation() { + run_wsl_update(WslWorkflow::EveUpdate).await; +} + +#[tokio::test] +#[ignore = "requires Windows, a matching Worker, and SKILL_DECK_TEST_WSL_NODE / SKILL_DECK_TEST_WSL_CLI"] +async fn real_wsl2_private_only_copy_manage_and_remove() { + run_wsl_update(WslWorkflow::Adjacent).await; +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum WslWorkflow { + PrivateUpdate, + ScopeBoundary, + EveUpdate, + Adjacent, +} + +async fn run_wsl_update(workflow: WslWorkflow) { + let eve = workflow == WslWorkflow::EveUpdate; + let distro = std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".into()); + let node = std::env::var("SKILL_DECK_TEST_WSL_NODE") + .expect("set the target distribution Node executable"); + let cli = std::env::var("SKILL_DECK_TEST_WSL_CLI") + .expect("set the target distribution skills@1.5.23 cli.mjs"); + let runtime = Arc::new(WslRuntime::for_wsl_test()); + let session = runtime + .connect(&distro) + .await + .expect("connect the real Worker"); + let workspace = runtime.workspace(&distro).unwrap(); + let root = format!( + "/tmp/skill-deck-cli-update-{}", + uuid::Uuid::new_v4().simple() + ); + let _cleanup = FixtureCleanup { + distro: distro.clone(), + root: root.clone(), + }; + let remote = BareSkillRepo::new(&["alpha"]); + let native_remote = url::Url::parse(&remote.local_source()) + .unwrap() + .to_file_path() + .unwrap(); + let mapped = workspace + .map_host_path(native_remote.to_string_lossy().into_owned(), None) + .await + .unwrap(); + run_fixture(&distro, r#"set -eu +root=$1 +node=$2 +cli=$3 +remote=$4 +public=$5 +mkdir -p "$root/project/.codebuddy" "$root/home" +git config --file "$root/home/gitconfig" "url.file://$remote.insteadOf" "$public" +git config --file "$root/home/gitconfig" protocol.file.allow always +cd "$root/project" +if [ "$6" = eve ]; then + mkdir -p agent/subagents/research agent/subagents/writer + printf '%s\n' '{"dependencies":{"eve":"*"}}' > package.json + HOME="$root/home" USERPROFILE="$root/home" GIT_CONFIG_GLOBAL="$root/home/gitconfig" GIT_CONFIG_NOSYSTEM=1 DISABLE_TELEMETRY=1 DO_NOT_TRACK=1 "$node" "$cli" add "$public" --skill alpha --agent eve --subagent research writer --yes +else + HOME="$root/home" USERPROFILE="$root/home" GIT_CONFIG_GLOBAL="$root/home/gitconfig" GIT_CONFIG_NOSYSTEM=1 DISABLE_TELEMETRY=1 DO_NOT_TRACK=1 "$node" "$cli" add "$public" --skill alpha --agent codebuddy --yes +fi +"#, &[root.clone(), node, cli, mapped, remote.source(), if eve { "eve".into() } else { "private".into() }]).await.unwrap(); + + let environment = EnvironmentRef::Wsl { + distro_name: distro.clone(), + }; + let locator = |path: String| ResourceLocator { + environment: environment.clone(), + native_path: path, + }; + let context = SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Project { + project_id: "fixture".into(), + }, + }; + let project = format!("{root}/project"); + let home = format!("{root}/home"); + let environment_revision = format!("fixture-worker-{}", session.runtime_generation); + let registry = crate::native_workflow_integration_support::test_registry(); + let agent_runtime = AgentEnvironmentResolver::from_environment(EnvironmentContext { + environment: environment.clone(), + home: home.clone(), + config_home: format!("{home}/.config"), + environment_variables: BTreeMap::new(), + availability: EnvironmentStatus::Available, + revision: environment_revision.clone(), + wsl_workspace: Some(workspace.clone()), + }) + .resolve_registry(®istry, Some(&project)) + .await + .unwrap(); + let facts = FixtureFacts { + initial: ScopePlanningSnapshot { + resolved_context: ResolvedContext { + context: context.clone(), + project: Some(RegisteredProject { + id: "fixture".into(), + native_path: project.clone(), + display_name: Some("WSL fixture".into()), + order: None, + suppress_cross_storage_warning: false, + }), + home: locator(home), + skill_root: locator(format!("{project}/.agents/skills")), + lock: locator(format!("{project}/skills-lock.json")), + }, + agent_runtime, + revisions: RuntimeRevisions { + registry: registry.revision.clone(), + environment: environment_revision, + context: ContextSnapshotRevision::parse("context-v1-wsl-cli-fixture").unwrap(), + }, + lock_schema: LockSchema::Project, + lock_document: crate::core::lossless_lock::LosslessLockDocument::empty( + LockSchema::Project, + ), + eve_targets: Vec::new(), + }, + workspace: workspace.clone(), + }; + let libraries: Arc = Arc::new(EmptyLibraries); + let targets = RuntimeTargetFactResolver::new(runtime.clone()); + if workflow == WslWorkflow::PrivateUpdate { + let global = + crate::environment::context_resolver::ContextResolver::resolve_wsl_from_projects( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + &session, + Vec::new(), + ) + .unwrap(); + let base = global.path_base(&targets).await; + assert_eq!(base.logical_root.native_path, session.home); + assert_eq!(base.logical_root.environment, environment); + assert_eq!( + base.path_style, + crate::environment::context_resolver::DisplayPathStyle::Posix + ); + assert!(base.physical_root.is_some()); + + let alias = format!("{root}/project-alias"); + run_fixture( + &distro, + "ln -s -- \"$1\" \"$2\"", + &[project.clone(), alias.clone()], + ) + .await + .unwrap(); + let mut linked = facts.initial.resolved_context.clone(); + linked.project.as_mut().unwrap().native_path = alias.clone(); + let base = linked.path_base(&targets).await; + assert_eq!(base.logical_root.native_path, alias); + assert_eq!(base.physical_root.unwrap().native_path, project); + } + let records = + InstalledUpdateRecordProvider::new(facts.clone(), targets.clone(), libraries.clone()) + .snapshot_installed_records(&context, ["alpha".to_string()].into()) + .await + .unwrap(); + assert!(matches!(records[0].state, UpdateRecordState::Source(_))); + let observed_facts = ScopePlanningSnapshotSource::snapshot(&facts, &context) + .await + .unwrap(); + let catalog = build_agent_selection_catalog( + &context, + &observed_facts.agent_runtime, + &observed_facts.eve_targets, + &observed_facts.resolved_context.skill_root, + &targets, + ) + .await + .unwrap(); + let known = ResolvedLibraryCandidateIndex::load_known( + libraries.as_ref(), + &targets, + &environment, + &["alpha".try_into().unwrap()].into(), + ) + .await + .unwrap(); + let observed = + observe_scope_skill_placements(&targets, &context, "alpha", &observed_facts, &catalog) + .await + .unwrap(); + let placements = observed.describe(&catalog, &known).unwrap(); + let selected = representative_direct_placement(&placements).unwrap(); + assert_eq!( + selected.entry.fact.destination.native_path, + if eve { + format!("{project}/agent/subagents/research/skills/alpha") + } else { + format!("{project}/.codebuddy/skills/alpha") + } + ); + RuntimeResourceReader::new(runtime.clone()) + .read_skill(selected.entry.fact.destination.clone()) + .await + .unwrap(); + + let eve_targets = observed_facts + .eve_targets + .iter() + .cloned() + .map(|target| crate::models::SkillInstallTargetInfo { + target_id: target.target_id, + agent: target.agent, + display_name: target.display_name, + subagent: target.subagent, + path: target.path, + }) + .collect::>(); + let mut read_plan = crate::application::skill_read::build_skill_read_plan( + &observed_facts.resolved_context, + &observed_facts.agent_runtime, + &eve_targets, + ) + .unwrap(); + read_plan.set_project_lock(Some( + &observed_facts.lock_document.to_pretty_bytes().unwrap(), + )); + let inspector = Arc::new(WslInspector::new(workspace.clone())); + let snapshot = inspector.inspect(&read_plan.read_plan).await.unwrap(); + let listed = crate::application::skill_read::project_direct_skill_snapshot( + &read_plan, + snapshot, + &observed_facts.agent_runtime, + libraries.as_ref(), + &targets, + inspector.as_ref(), + ) + .await + .unwrap(); + assert_eq!(listed.skills.len(), 1); + assert_eq!(listed.skills[0].name, "alpha"); + + let writer = format!("{project}/agent/subagents/writer/skills/alpha"); + if eve { + run_fixture(&distro, "rm -rf -- \"$1\"", std::slice::from_ref(&writer)) + .await + .unwrap(); + } + + remote.publish_change("alpha"); + let clock = Arc::new(AtomicU64::new(1_000)); + let read_clock = clock.clone(); + let manager = Arc::new(PayloadSessionManager::new( + workspace.payload_storage(), + PayloadSessionLimits { + ttl_ms: 60_000, + max_sessions: 4, + max_bytes: 16 * 1024 * 1024, + }, + move || read_clock.load(Ordering::SeqCst), + )); + let source_calls = Arc::new(AtomicUsize::new(0)); + let recovery = tempfile::tempdir().unwrap(); + let executor = RuntimePlanExecutor::new( + runtime.clone(), + Arc::new(NativeRecoveryMarkerStore::new(recovery.path()).unwrap()), + Arc::new(RuntimeLockCommitter::new()), + Arc::new(facts.clone()), + ); + let service = UpdateService::new( + manager.clone(), + ConcreteUpdatePlanner::new( + facts.clone(), + targets.clone(), + manager.clone(), + libraries.clone(), + || "2026-09-14T00:00:00Z".into(), + ), + FixtureSource { + root: remote.work.clone(), + payloads: manager.clone(), + calls: source_calls.clone(), + }, + executor, + ); + let request = UpdateRequest { + context: context.clone(), + skill_names: vec!["alpha".into()], + }; + if workflow == WslWorkflow::ScopeBoundary { + use crate::application::mutation::result::OperationErrorCode; + use crate::application::update::UpdateCoverage; + + run_fixture( + &distro, + r#"set -eu +mv "$1/project/.codebuddy" "$1/external" +ln -s "$1/external" "$1/project/.codebuddy" +mkdir -p "$1/project/.agents/skills" +cp -R "$1/external/skills/alpha" "$1/project/.agents/skills/alpha" +cp -R "$1/external" "$1/external-before" +"#, + std::slice::from_ref(&root), + ) + .await + .unwrap(); + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + let preview = &prepared.preview.skills[0]; + assert_eq!(preview.targets.len(), 1); + assert_eq!( + preview.targets[0].display_path.native_path, + format!("{project}/.agents/skills/alpha") + ); + assert!(preview.overwrite_private_entries.is_empty()); + assert_eq!(preview.preserved_targets.as_ref().unwrap().len(), 1); + let calls = source_calls.load(Ordering::SeqCst); + let response = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_eq!(response.outcome, UpdateOutcome::Succeeded, "{response:#?}"); + assert_eq!(source_calls.load(Ordering::SeqCst), calls); + let guide = run_fixture( + &distro, + r#"set -eu +test -L "$1/project/.codebuddy" +diff -r "$1/external-before" "$1/external" +cat "$1/project/.agents/skills/alpha/references/guide.md" +"#, + std::slice::from_ref(&root), + ) + .await + .unwrap(); + assert_eq!( + guide, + std::fs::read(remote.work.join("skills/alpha/references/guide.md")).unwrap() + ); + run_fixture( + &distro, + "rm -rf -- \"$1/.agents/skills/alpha\"", + std::slice::from_ref(&project), + ) + .await + .unwrap(); + let lock_before = run_fixture( + &distro, + "cat \"$1/skills-lock.json\"", + std::slice::from_ref(&project), + ) + .await + .unwrap(); + let prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + let preview = &prepared.preview.skills[0]; + assert_eq!( + preview.blocking_reasons, + vec![OperationErrorCode::NoUpdateTargets] + ); + assert!(preview.targets.is_empty() && preview.overwrite_private_entries.is_empty()); + assert_eq!(source_calls.load(Ordering::SeqCst), calls); + let response = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert!( + matches!(&response.skills[0].coverage, UpdateCoverage::NotUpdated { error } if error.code == OperationErrorCode::NoUpdateTargets) + ); + assert!(response.skills[0].mutation.is_none()); + let lock_after = run_fixture( + &distro, + r#"set -eu +diff -r "$1/external-before" "$1/external" +test ! -e "$1/project/.agents/skills/alpha" +cat "$1/project/skills-lock.json" +"#, + std::slice::from_ref(&root), + ) + .await + .unwrap(); + assert_eq!(lock_after, lock_before); + clock.store(120_000, Ordering::SeqCst); + manager.cleanup().await.unwrap(); + return; + } + let mut prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert!( + prepared.preview.blocked.is_empty(), + "{:#?}", + prepared.preview + ); + let base = prepared + .preview + .path_base + .as_ref() + .expect("preview path base"); + assert_eq!(base.logical_root.native_path, project); + assert_eq!(base.physical_root.as_ref().unwrap().native_path, project); + assert_eq!(base.logical_root.environment, environment); + assert_eq!( + prepared.preview.skills[0].targets.len(), + if eve { 2 } else { 1 } + ); + if eve { + assert_eq!( + prepared.preview.skills[0] + .targets + .iter() + .filter(|target| target.restoring) + .count(), + 1 + ); + drop(prepared); + run_fixture(&distro, "test ! -e \"$1\"", std::slice::from_ref(&writer)) + .await + .unwrap(); + prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + run_fixture( + &distro, + "mkdir -p -- \"$1\"\nprintf '%s' occupied > \"$1/SKILL.md\"", + std::slice::from_ref(&writer), + ) + .await + .unwrap(); + let stale = service + .execute_prepared(prepared, &[], CancellationSignal::default(), |_| {}) + .await + .unwrap(); + assert_ne!(stale.outcome, UpdateOutcome::Succeeded); + let kept = run_fixture( + &distro, + "cat \"$1/SKILL.md\"", + std::slice::from_ref(&writer), + ) + .await + .unwrap(); + assert_eq!(kept, b"occupied"); + run_fixture(&distro, "rm -rf -- \"$1\"", std::slice::from_ref(&writer)) + .await + .unwrap(); + prepared = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + } + let calls_before_execution = source_calls.load(Ordering::SeqCst); + let selected_copies = prepared + .preview + .skills + .iter() + .flat_map(|skill| &skill.targets) + .filter_map(|target| target.selectable_entry_id.clone()) + .collect::>(); + let response = service + .execute_prepared( + prepared, + &selected_copies, + CancellationSignal::default(), + |_| {}, + ) + .await + .unwrap(); + assert_eq!(response.outcome, UpdateOutcome::Succeeded, "{response:#?}"); + assert_eq!(source_calls.load(Ordering::SeqCst), calls_before_execution); + let guide = run_fixture( + &distro, + r#"set -eu +test ! -e "$1/.agents/skills/alpha" +test ! -e "$1/.minimax/skills/alpha" +test ! -e "$1/.custom/skills/alpha" +if [ "$2" = eve ]; then + ! grep -q '^name:' "$1/agent/subagents/research/skills/alpha/SKILL.md" + ! grep -q '^name:' "$1/agent/subagents/writer/skills/alpha/SKILL.md" + cmp "$1/agent/subagents/research/skills/alpha/references/guide.md" "$1/agent/subagents/writer/skills/alpha/references/guide.md" + cat "$1/agent/subagents/research/skills/alpha/references/guide.md" +else + cat "$1/.codebuddy/skills/alpha/references/guide.md" +fi +"#, + &[project.clone(), if eve { "eve".into() } else { "private".into() }], + ) + .await + .unwrap(); + assert_eq!( + guide, + std::fs::read(remote.work.join("skills/alpha/references/guide.md")).unwrap() + ); + let final_facts = ScopePlanningSnapshotSource::snapshot(&facts, &context) + .await + .unwrap(); + assert_eq!( + final_facts + .lock_document + .entry_snapshot("alpha") + .value() + .unwrap()["computedHash"], + remote.computed_hash("alpha") + ); + if eve { + let lock_before = run_fixture( + &distro, + "cat \"$1/skills-lock.json\"", + std::slice::from_ref(&project), + ) + .await + .unwrap(); + let acquired = + crate::application::installed_skill_payload::InstalledSkillPayloadAcquirer::new( + manager.clone(), + runtime.clone(), + ) + .acquire(&context, "alpha", &selected.entry.fact) + .await; + assert!( + matches!(acquired, Err(AppError::CapabilityUnavailable { capability, .. }) if capability == "installedSkillFormat") + ); + run_fixture( + &distro, + "rm -rf -- \"$1\"\nprintf '%s' preserve-file > \"$1.md\"", + std::slice::from_ref(&writer), + ) + .await + .unwrap(); + let blocked = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert_eq!(blocked.preview.blocked.len(), 1, "{:#?}", blocked.preview); + assert!(blocked.preview.skills.is_empty()); + assert_eq!( + run_fixture(&distro, "cat \"$1.md\"", std::slice::from_ref(&writer)) + .await + .unwrap(), + b"preserve-file" + ); + assert_eq!( + run_fixture( + &distro, + "cat \"$1/skills-lock.json\"", + std::slice::from_ref(&project) + ) + .await + .unwrap(), + lock_before + ); + run_fixture( + &distro, + "rm -rf -- \"$1/agent/subagents/writer\"", + std::slice::from_ref(&project), + ) + .await + .unwrap(); + let blocked = service + .prepare(&request, CancellationSignal::default()) + .await + .unwrap(); + assert_eq!(blocked.preview.blocked.len(), 1); + run_fixture( + &distro, + "test ! -e \"$1/agent/subagents/writer\"", + std::slice::from_ref(&project), + ) + .await + .unwrap(); + } + if workflow == WslWorkflow::Adjacent { + verify_wsl_adjacent_workflows(&facts, runtime, manager.clone()).await; + } + clock.store(120_000, Ordering::SeqCst); + manager.cleanup().await.unwrap(); +} + +async fn verify_wsl_adjacent_workflows( + source: &FixtureFacts, + runtime: Arc, + payloads: Arc, +) { + use crate::application::agent_selection::AgentSelectionSubmission; + use crate::application::copy::{ + CopyExecutionRequest, CopyPreviewOutcome, CopyRequest, CopyService, + }; + use crate::application::installed_skill_payload::InstalledSkillPayloadAcquirer; + use crate::application::library_candidates::EmptyLibraryCandidateSource; + use crate::application::manage_agents::{ + ManageAgentsPreviewOutcome, ManageAgentsPreviewRequest, ManageAgentsRequest, + ManageAgentsService, + }; + use crate::application::remove::{RemoveIntent, RemoveRequest, RemoveService}; + use crate::application::scope_skill_placements::ScopeSkillPlacementResolver; + use crate::models::InstallMode; + use crate::runtime::copy_service::RuntimeCopyProjectComparator; + + verify_wsl_copy_project_identity(source, runtime.clone()).await; + + let context = source.initial.resolved_context.context.clone(); + let EnvironmentRef::Wsl { distro_name } = &context.environment else { + panic!("WSL fixture"); + }; + let project = source + .initial + .resolved_context + .project + .as_ref() + .unwrap() + .native_path + .clone(); + let destination = format!("{project}-copy"); + run_fixture( + distro_name, + "mkdir -p -- \"$1/.codebuddy\"", + std::slice::from_ref(&destination), + ) + .await + .unwrap(); + let mut target = source.clone(); + target.initial.resolved_context.context.scope = SkillLocation::Project { + project_id: "copy-target".into(), + }; + target.initial.resolved_context.project.as_mut().unwrap().id = "copy-target".into(); + target + .initial + .resolved_context + .project + .as_mut() + .unwrap() + .native_path = destination.clone(); + target.initial.resolved_context.skill_root.native_path = + format!("{destination}/.agents/skills"); + target.initial.resolved_context.lock.native_path = format!("{destination}/skills-lock.json"); + let home = source.initial.resolved_context.home.native_path.clone(); + target.initial.agent_runtime = AgentEnvironmentResolver::from_environment(EnvironmentContext { + environment: context.environment.clone(), + home: home.clone(), + config_home: format!("{home}/.config"), + environment_variables: BTreeMap::new(), + availability: EnvironmentStatus::Available, + revision: source.initial.revisions.environment.clone(), + wsl_workspace: Some(source.workspace.clone()), + }) + .resolve_registry( + &crate::native_workflow_integration_support::test_registry(), + Some(&destination), + ) + .await + .unwrap(); + let locations = FixtureLocations { + source: source.clone(), + target, + }; + let targets = RuntimeTargetFactResolver::new(runtime.clone()); + let recovery = tempfile::tempdir().unwrap(); + let executor = || { + RuntimePlanExecutor::new( + runtime.clone(), + Arc::new(NativeRecoveryMarkerStore::new(recovery.path()).unwrap()), + Arc::new(RuntimeLockCommitter::new()), + Arc::new(locations.clone()), + ) + }; + let acquirer = || InstalledSkillPayloadAcquirer::new(payloads.clone(), runtime.clone()); + let copy = CopyService::new( + locations.clone(), + targets.clone(), + payloads.clone(), + acquirer(), + executor(), + RuntimeCopyProjectComparator::new(runtime.clone()), + Arc::new(EmptyLibraryCandidateSource), + ); + let selection = copy.selection(&context, "alpha").await.unwrap().selection; + let request = CopyRequest { + skill_name: "alpha".into(), + source: context.clone(), + target_environment: context.environment.clone(), + target_project_ids: vec!["copy-target".into()], + agent_selection: AgentSelectionSubmission { + revision: selection.revision, + selected_option_ids: selection.baseline_selected_option_ids, + requested_mode: InstallMode::Copy, + }, + }; + let CopyPreviewOutcome::Ready { preview } = copy.preview(&request).await.unwrap() else { + panic!("copy preview"); + }; + let copied = copy + .execute( + &CopyExecutionRequest { + request, + token: preview.token, + payload: preview.payload, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + copied.units.iter().all(|unit| unit.status + == crate::application::mutation::result::MutationUnitStatus::Succeeded), + "{copied:#?}" + ); + run_fixture( + distro_name, + "test -f \"$1/.agents/skills/alpha/SKILL.md\"\ntest ! -e \"$2/.agents/skills/alpha\"", + &[destination.clone(), project.clone()], + ) + .await + .unwrap(); + + let manage = ManageAgentsService::new( + locations.clone(), + ScopeSkillPlacementResolver::new(targets.clone()), + targets.clone(), + payloads.clone(), + acquirer(), + executor(), + Arc::new(EmptyLibraryCandidateSource), + ); + let snapshot = manage.selection(&context, "alpha").await.unwrap(); + let selection = AgentSelectionSubmission { + revision: snapshot.selection.revision, + selected_option_ids: snapshot + .selection + .install_options + .iter() + .filter(|option| { + option + .agent_ids + .iter() + .any(|id| matches!(id.as_str(), "codebuddy" | "minimax-code")) + }) + .map(|option| option.id.clone()) + .collect(), + requested_mode: InstallMode::Copy, + }; + let ManageAgentsPreviewOutcome::Ready { preview } = manage + .preview(&ManageAgentsPreviewRequest { + context: context.clone(), + skill_name: "alpha".into(), + agent_selection: selection.clone(), + }) + .await + .unwrap() + else { + panic!("manage preview"); + }; + let added = manage + .execute( + &ManageAgentsRequest { + context: context.clone(), + skill_name: "alpha".into(), + agent_selection: selection, + token: preview.token, + original_payload: preview.original_payload, + confirm_entity_directories: false, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + added.units.iter().all(|unit| unit.status + == crate::application::mutation::result::MutationUnitStatus::Succeeded), + "{added:#?}" + ); + run_fixture( + distro_name, + "test -f \"$1/.minimax/skills/alpha/SKILL.md\"\ntest ! -e \"$1/.agents/skills/alpha\"", + std::slice::from_ref(&project), + ) + .await + .unwrap(); + + let remove = RemoveService::new( + locations.clone(), + targets, + executor(), + Arc::new(EmptyLibraryCandidateSource), + ); + let preview = remove.preview(&context, "alpha").await.unwrap(); + let removed = remove + .execute( + &RemoveRequest { + context: context.clone(), + skill_name: "alpha".into(), + token: preview.token, + intent: RemoveIntent::FullSkill, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + assert!( + removed.units.iter().all(|unit| unit.status + == crate::application::mutation::result::MutationUnitStatus::Succeeded), + "{removed:#?}" + ); + run_fixture(distro_name, "test ! -e \"$1/.codebuddy/skills/alpha\"\ntest ! -e \"$1/.minimax/skills/alpha\"\ntest ! -e \"$1/.agents/skills/alpha\"", std::slice::from_ref(&project)).await.unwrap(); +} + +async fn verify_wsl_copy_project_identity(source: &FixtureFacts, runtime: Arc) { + use crate::application::copy::CopyProjectComparator; + use crate::environment::runtime::{ExecutionBackend, PhysicalIdentityComparison}; + use crate::environment::types::StorageAccess; + use crate::runtime::copy_service::RuntimeCopyProjectComparator; + + let environment = &source.initial.resolved_context.context.environment; + let EnvironmentRef::Wsl { distro_name } = environment else { + panic!("WSL fixture") + }; + let project = &source + .initial + .resolved_context + .project + .as_ref() + .unwrap() + .native_path; + let comparator = RuntimeCopyProjectComparator::new(runtime); + let identity = comparator.capture_source(&source.initial).await.unwrap(); + assert!(matches!( + identity.key.backend, + ExecutionBackend::WslPosix { .. } + )); + assert_eq!(identity.storage_access, StorageAccess::Native); + let alias = format!("{project}-alias"); + let sibling = format!("{project}-sibling"); + run_fixture( + distro_name, + r#"set -eu +mkdir -p -- "$1/nested" "$3" +ln -s -- "$1" "$2" +"#, + &[project.clone(), alias.clone(), sibling.clone()], + ) + .await + .unwrap(); + let at = |environment: EnvironmentRef, path: String| { + let mut facts = source.initial.clone(); + facts.resolved_context.context.environment = environment; + facts.resolved_context.project.as_mut().unwrap().native_path = path; + facts + }; + for (path, expected) in [ + (project.clone(), PhysicalIdentityComparison::Same), + (alias.clone(), PhysicalIdentityComparison::Same), + (format!("{alias}/nested"), PhysicalIdentityComparison::Same), + ( + project.rsplit_once('/').unwrap().0.into(), + PhysicalIdentityComparison::Same, + ), + (sibling, PhysicalIdentityComparison::Different), + ] { + let target = at(environment.clone(), path.clone()); + let compared = comparator.compare(&identity, &target).await.unwrap(); + assert_eq!(compared.physical_identity, expected, "{path}"); + assert_eq!(compared.target_storage_access, StorageAccess::Native); + } + let missing = at(environment.clone(), format!("{project}/missing-project")); + assert!(comparator.capture_source(&missing).await.is_err()); + assert!(comparator.compare(&identity, &missing).await.is_err()); + + let unc = source + .workspace + .map_path_to_windows(project.clone()) + .await + .unwrap() + .unwrap(); + let native_alias = at(EnvironmentRef::Native, unc); + let compared = comparator.compare(&identity, &native_alias).await.unwrap(); + assert_eq!(compared.physical_identity, PhysicalIdentityComparison::Same); + assert_eq!(compared.target_storage_access, StorageAccess::CrossStorage); + + let native = tempfile::tempdir().unwrap(); + let native_project = native.path().join("project"); + std::fs::create_dir_all(native_project.join("nested")).unwrap(); + let mapped = source + .workspace + .map_host_path(native_project.to_str().unwrap().into(), None) + .await + .unwrap(); + let mounted = at(environment.clone(), mapped.clone()); + let mounted_identity = comparator.capture_source(&mounted).await.unwrap(); + assert!(matches!( + mounted_identity.key.backend, + ExecutionBackend::NativeWindows + )); + assert_eq!(mounted_identity.storage_access, StorageAccess::CrossStorage); + for path in [ + native_project.clone(), + native_project.join("nested"), + std::fs::canonicalize(&native_project).unwrap(), + ] { + let target = at(EnvironmentRef::Native, path.to_str().unwrap().into()); + assert_eq!( + comparator + .compare(&mounted_identity, &target) + .await + .unwrap() + .physical_identity, + PhysicalIdentityComparison::Same + ); + } + assert_eq!( + comparator + .compare(&mounted_identity, &source.initial) + .await + .unwrap() + .physical_identity, + PhysicalIdentityComparison::Different + ); + let mounted_alias = format!("{project}-mounted-alias"); + run_fixture( + distro_name, + "ln -s -- \"$1\" \"$2\"", + &[mapped, mounted_alias.clone()], + ) + .await + .unwrap(); + assert_eq!( + comparator + .compare(&mounted_identity, &at(environment.clone(), mounted_alias)) + .await + .unwrap() + .physical_identity, + PhysicalIdentityComparison::Same + ); + run_fixture( + distro_name, + "test ! -e \"$1/.skill-deck-project-identity\"", + std::slice::from_ref(project), + ) + .await + .unwrap(); +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 00000000..01258fa9 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,8 @@ +{ + "bundle": { + "resources": { + "target/wsl-worker/current/worker": "wsl-worker/current/worker", + "target/wsl-worker/current/manifest.json": "wsl-worker/current/manifest.json" + } + } +} diff --git a/src-tauri/tests/window_command_acl.rs b/src-tauri/tests/window_command_acl.rs index 897f790d..00473d18 100644 --- a/src-tauri/tests/window_command_acl.rs +++ b/src-tauri/tests/window_command_acl.rs @@ -373,14 +373,8 @@ fn main_window_applies_representative_business_command_permissions() { invoke(&main, "acquire_selected_payloads"), Ok(Value::from("acquire-selected-payloads")) ); - assert_eq!( - invoke(&main, "preview_install"), - Ok(Value::from("preview-install")) - ); - assert_eq!( - invoke(&main, "install_skills"), - Ok(Value::from("install-skills")) - ); + assert_denied(invoke(&main, "preview_install"), "preview_install"); + assert_denied(invoke(&main, "install_skills"), "install_skills"); assert_eq!( invoke(&main, "preview_add_library_skills"), Ok(Value::from("preview-add-library-skills")) diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 435d625f..a8a7e1cf 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -130,7 +130,7 @@ describe('App', () => { window.history.replaceState({}, '', '/'); }); - it('refreshes the committed Native Global workspace after the wizard completes', async () => { + it('refreshes the installed workspace and invalidates its previous update results', async () => { mocks.refreshWorkspace.mockResolvedValue(undefined); render(); @@ -152,7 +152,7 @@ describe('App', () => { () => expect(mocks.refreshWorkspace).toHaveBeenCalledWith({ environment: { kind: 'wsl', distro_name: 'Ubuntu' }, scope: { scope: 'project', project_id: 'project-a' }, - }, { origin: 'selfMutation', mutatedSkillNames: ['toolkit'] }), + }, { origin: 'selfMutation', mutatedSkillNames: ['toolkit'], invalidateUpdates: true }), { timeout: 5000 }, ); }); diff --git a/src/bindings.ts b/src/bindings.ts index 71a805b1..98de1971 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -126,9 +126,17 @@ async previewAddLibrarySkills(request: PreviewAddLibrarySkillsRequest) : Promise else return { status: "error", error: e as any }; } }, -async previewLibrarySkillUpdates(request: UpdateLibrarySkillsRequest) : Promise> { +async previewRemoveLibrarySkill(request: RemoveLibrarySkillRequest) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("preview_library_skill_updates", { request }) }; + return { status: "ok", data: await TAURI_INVOKE("preview_remove_library_skill", { request }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async prepareLibrarySkillUpdates(operationId: string, request: UpdateLibrarySkillsRequest) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("prepare_library_skill_updates", { operationId, request }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -150,7 +158,7 @@ async renameSkillLibrary(environment: EnvironmentRef, libraryId: LibraryId, name else return { status: "error", error: e as any }; } }, -async removeLibrarySkill(request: RemoveLibrarySkillRequest) : Promise> { +async removeLibrarySkill(request: ExecuteRetireLibrarySkillRequest) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("remove_library_skill", { request }) }; } catch (e) { @@ -158,6 +166,14 @@ async removeLibrarySkill(request: RemoveLibrarySkillRequest) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("resume_library_membership", { environment, libraryId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async retryLibraryApplication(context: SkillLocationRef) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("retry_library_application", { context }) }; @@ -166,9 +182,9 @@ async retryLibraryApplication(context: SkillLocationRef) : Promise> { +async updateLibrarySkills(operationId: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("update_library_skills", { request }) }; + return { status: "ok", data: await TAURI_INVOKE("update_library_skills", { operationId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -297,9 +313,9 @@ async searchDiscoverSkills(query: string) : Promise> { +async getProxySettings(reload: boolean | null) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("get_proxy_settings") }; + return { status: "ok", data: await TAURI_INVOKE("get_proxy_settings", { reload }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -441,25 +457,25 @@ async checkUpdates(request: UpdateCheckRequest) : Promise> { +async prepareUpdate(operationId: string, request: UpdateRequest) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("preview_update", { request }) }; + return { status: "ok", data: await TAURI_INVOKE("prepare_update", { operationId, request }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async updateSkill(execution: UpdateExecutionRequest, expectedToken: PreviewToken, acknowledgeRedirect: boolean) : Promise> { +async executeUpdate(operationId: string, selectedCopyEntries: ObservedEntryId[]) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("update_skill", { execution, expectedToken, acknowledgeRedirect }) }; + return { status: "ok", data: await TAURI_INVOKE("execute_update", { operationId, selectedCopyEntries }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, -async updateSkillsBatch(execution: UpdateExecutionRequest, expectedToken: PreviewToken, acknowledgeRedirect: boolean) : Promise> { +async cancelUpdatePreparation(operationId: string) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("update_skills_batch", { execution, expectedToken, acknowledgeRedirect }) }; + return { status: "ok", data: await TAURI_INVOKE("cancel_update_preparation", { operationId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -688,7 +704,7 @@ export type AgentSelectionIntent = { wildcardRequested: boolean; explicitAgentId export type AgentSelectionInvalidReason = "duplicateOption" | "optionUnavailable" | "placementConflict" | "optionMissing" | "resultNotAllowed" export type AgentSelectionModeConstraint = "userSelectable" | "copyOnly" export type AgentSelectionRevision = string -export type AgentSelectionSnapshot = { agents: AgentSelectionAgent[]; installOptions: AgentInstallOption[]; groups: AgentSelectionGroup[]; initialSelectedOptionIds: AgentInstallOptionId[]; unavailableExplicitAgents: UnavailableAgentSelection[]; userModeOptionIds: AgentInstallOptionId[]; revision: AgentSelectionRevision } +export type AgentSelectionSnapshot = { agents: AgentSelectionAgent[]; installOptions: AgentInstallOption[]; groups: AgentSelectionGroup[]; baselineSelectedOptionIds: AgentInstallOptionId[]; unavailableExplicitAgents: UnavailableAgentSelection[]; userModeOptionIds: AgentInstallOptionId[]; revision: AgentSelectionRevision } export type AgentSelectionSubmission = { revision: AgentSelectionRevision; selectedOptionIds: AgentInstallOptionId[]; requestedMode: InstallMode } export type AgentSettingsSnapshot = { registryRevision: string; activeBuiltin: AgentDefinition[]; activeCustom: ActiveCustomAgent[]; disabledConflicts: DisabledAgentConflict[]; invalidCustomRecords: InvalidCustomAgentRecord[]; currentEnvironment: EnvironmentRef; customStorageIssue: AgentStorageIssue | null } export type AgentSource = "builtin" | "custom" @@ -696,12 +712,12 @@ export type AgentStorageIssue = { code: string; message: string; readOnly: boole export type AgentTargetFallbackPreview = { agentId: AgentId; targetId: string; requestedMode: InstallMode; forecastMode: InstallMode; reason: FallbackReasonCode | null } export type AgentTargetMutationResult = { targetId: string; agentId: AgentId; status: MutationUnitStatus; actualMode: InstallMode | null; fallbackReason: FallbackReasonCode | null; error: ErrorReport | null } export type AgentTargetPreview = { agentId: AgentId; targetId: string; displayPath: ResourceLocator; ownDirectorySelected: boolean; availability: DetectionState; blockingReason: OperationErrorCode | null } -export type AppError = { kind: "io"; data: { message: string } } | { kind: "yaml"; data: { message: string } } | { kind: "json"; data: { message: string } } | { kind: "invalidSkillMd"; data: { message: string } } | { kind: "path"; data: { message: string } } | { kind: "invalidSource"; data: { value: string } } | { kind: "upstreamSkillNameChanged"; data: { expectedName: string; actualName: string } } | { kind: "upstreamSkillDeleted"; data: { skillName: string } } | { kind: "sourceAcquisitionFailed"; data: { wellKnownReason: SourceAcquisitionFailureReason; downloadReason: SourceAcquisitionFailureReason } } | { kind: "wellKnownSourceFailed"; data: { reason: SourceAcquisitionFailureReason } } | { kind: "wellKnownScopeNotFound"; data: { scopePath: string; rootUrl: string } } | { kind: "directDownloadFailed"; data: { reason: DirectDownloadFailureReason } } | { kind: "directDownloadUnsupportedOperation" } | { kind: "directDownloadConflict"; data: { target: string } } | { kind: "invalidProxySettings"; data: { code: string } } | { kind: "discoveryRequestFailed"; data: { reason: string } } | { kind: "gitCloneFailed"; data: { message: string } } | { kind: "gitAuthFailed"; data: { message: string } } | { kind: "gitRepoNotFound"; data: { repo: string } } | { kind: "gitRefNotFound"; data: { refName: string } } | { kind: "gitTimeout"; data: { timeoutSecs: number } } | { kind: "gitNetworkError"; data: { message: string } } | +export type AppError = { kind: "io"; data: { message: string } } | { kind: "yaml"; data: { message: string } } | { kind: "json"; data: { message: string } } | { kind: "invalidSkillMd"; data: { message: string } } | { kind: "path"; data: { message: string } } | { kind: "invalidSource"; data: { value: string } } | { kind: "upstreamSkillNameChanged"; data: { expectedName: string; actualName: string } } | { kind: "upstreamSkillDeleted"; data: { skillName: string } } | { kind: "sourceAcquisitionFailed"; data: { wellKnownReason: SourceAcquisitionFailureReason; downloadReason: SourceAcquisitionFailureReason } } | { kind: "wellKnownSourceFailed"; data: { reason: SourceAcquisitionFailureReason } } | { kind: "wellKnownScopeNotFound"; data: { scopePath: string; rootUrl: string } } | { kind: "directDownloadFailed"; data: { reason: DirectDownloadFailureReason } } | { kind: "directDownloadConflict"; data: { target: string } } | { kind: "invalidProxySettings"; data: { code: string } } | { kind: "discoveryRequestFailed"; data: { reason: string } } | { kind: "gitCloneFailed"; data: { message: string } } | { kind: "gitAuthFailed"; data: { message: string } } | { kind: "gitRepoNotFound"; data: { repo: string } } | { kind: "gitRefNotFound"; data: { refName: string } } | { kind: "gitTimeout"; data: { timeoutSecs: number } } | { kind: "gitNetworkError"; data: { message: string } } | /** * GitHub API 调用失败,带机器可读的 reason 让前端可以区分文案。 * reason 当前取值: `rate-limited` / `network-error` / `auth` / `http-`。 */ -{ kind: "gitHubApiError"; data: { reason: string; message: string } } | { kind: "pathNotFound"; data: { path: string } } | { kind: "directDownloadRedirectConfirmationRequired"; data: { host: string } } | { kind: "noSkillsFound" } | { kind: "mutationBusy" } | { kind: "installWizardActive" } | { kind: "installWizardSessionUnavailable" } | { kind: "applicationTerminating" } | { kind: "wslIntegrationBusy"; data: { reason: WslIntegrationBusyReason } } | { kind: "mutationCancelled" } | { kind: "environmentDiscoveryFailed"; data: { message: string } } | { kind: "wslCommandTimedOut" } | { kind: "wslOutputLimitExceeded"; data: { stream: string; limit: number } } | { kind: "wslCommandFailed"; data: { exitCode: number | null; stderr: string } } | { kind: "environmentUnavailable"; data: { environment: EnvironmentRef; message: string } } | { kind: "storageMappingUnsupported"; data: { path: string; environment: EnvironmentRef } } | { kind: "projectMigrationFailed"; data: { message: string } } | { kind: "lockConflict"; data: { target: LockConflictTarget } } | { kind: "invalidAgent"; data: { agent: string } } | { kind: "agentSelectionInvalid"; data: { reason: AgentSelectionInvalidReason } } | { kind: "configurationReadOnly" } | { kind: "validation"; data: { field: string | null; message: string } } | { kind: "libraryReferenceConflict"; data: { usages: SkillLocationRef[] } } | { kind: "skillPlacementTargetConflict"; data: { skillName: string; agentIds: AgentId[]; targetPath: string; targetKind: SkillPlacementTargetKind } } | { kind: "storageUnsupported"; data: { path: string } } | { kind: "capabilityUnavailable"; data: { capability: string; path: string | null } } | { kind: "unsafePath"; data: { path: string; reason: string } } | { kind: "unsafeSourceLink"; data: { path: string } } | { kind: "selfCopy" } | { kind: "payloadSessionExpired"; data: { session_id: string } } | { kind: "payloadStorageRequiresCleanup"; data: { environment: EnvironmentRef } } | { kind: "staleContext" } | { kind: "staleRegistry" } | { kind: "staleEnvironment" } | { kind: "stalePayload" } | { kind: "staleTarget" } | { kind: "executionFailed"; data: { message: string } } | { kind: "restoreFailed"; data: { message: string } } | { kind: "recoveryRequired"; data: { recovery_resource_id: RecoveryResourceId; message: string } } | { kind: "libraryRecoveryIncomplete"; data: { environment: EnvironmentRef; message: string } } | { kind: "configurationCorrupted"; data: { message: string } } | { kind: "staleAgentRuntime"; data: { expected_registry_revision: string; actual_registry_revision: string; expected_environment_revision: string; actual_environment_revision: string } } | { kind: "custom"; data: { message: string } } +{ kind: "gitHubApiError"; data: { reason: string; message: string } } | { kind: "pathNotFound"; data: { path: string } } | { kind: "directDownloadRedirectConfirmationRequired"; data: { host: string } } | { kind: "noSkillsFound" } | { kind: "mutationBusy" } | { kind: "installWizardActive" } | { kind: "installWizardSessionUnavailable" } | { kind: "applicationTerminating" } | { kind: "wslIntegrationBusy"; data: { reason: WslIntegrationBusyReason } } | { kind: "mutationCancelled" } | { kind: "environmentDiscoveryFailed"; data: { message: string } } | { kind: "wslCommandTimedOut" } | { kind: "wslOutputLimitExceeded"; data: { stream: string; limit: number } } | { kind: "wslCommandFailed"; data: { exitCode: number | null; stderr: string } } | { kind: "environmentUnavailable"; data: { environment: EnvironmentRef; message: string } } | { kind: "storageMappingUnsupported"; data: { path: string; environment: EnvironmentRef } } | { kind: "projectMigrationFailed"; data: { message: string } } | { kind: "projectMatchesEnvironmentHome" } | { kind: "lockConflict"; data: { target: LockConflictTarget } } | { kind: "invalidAgent"; data: { agent: string } } | { kind: "agentSelectionInvalid"; data: { reason: AgentSelectionInvalidReason } } | { kind: "configurationReadOnly" } | { kind: "validation"; data: { field: string | null; message: string } } | { kind: "libraryReferenceConflict"; data: { usages: SkillLocationRef[] } } | { kind: "skillPlacementTargetConflict"; data: { skillName: string; agentIds: AgentId[]; targetPath: string; targetKind: SkillPlacementTargetKind } } | { kind: "storageUnsupported"; data: { path: string } } | { kind: "capabilityUnavailable"; data: { capability: string; path: string | null } } | { kind: "unsafePath"; data: { path: string; reason: string } } | { kind: "unsafeSourceLink"; data: { path: string } } | { kind: "selfCopy" } | { kind: "payloadSessionExpired"; data: { session_id: string } } | { kind: "payloadStorageRequiresCleanup"; data: { environment: EnvironmentRef } } | { kind: "staleContext" } | { kind: "staleRegistry" } | { kind: "staleEnvironment" } | { kind: "stalePayload" } | { kind: "staleTarget" } | { kind: "executionFailed"; data: { message: string } } | { kind: "restoreFailed"; data: { message: string } } | { kind: "recoveryRequired"; data: { recovery_resource_id: RecoveryResourceId; message: string } } | { kind: "libraryRecoveryIncomplete"; data: { environment: EnvironmentRef; message: string } } | { kind: "configurationCorrupted"; data: { message: string } } | { kind: "configurationWriteUnconfirmed" } | { kind: "staleAgentRuntime"; data: { expected_registry_revision: string; actual_registry_revision: string; expected_environment_revision: string; actual_environment_revision: string } } | { kind: "custom"; data: { message: string } } export type ApplicationUpdateInfo = { version: string; body: string | null } export type ApplicationUpdateProgress = { event: "started"; data: { content_length: number | null } } | { event: "progress"; data: { chunk_length: number } } | { event: "downloaded" } | { event: "installing" } | { event: "finished" } export type ApplicationUpdateResult = { version: string; installed: boolean } @@ -777,6 +793,7 @@ export type DiscoverLeaderboardPayload = { leaderboardHtml: string; officialCrea export type DiscoverLeaderboardTab = "popular" | "trending" | "hot" export type DiscoverSearchPayload = { searchJson: string; officialCreators: string[] | null } export type DiscoverySessionHandle = { sessionId: string; environment: EnvironmentRef; sourceFingerprint: string; expiresAtEpochMs: number } +export type DisplayPathStyle = "posix" | "windows" export type EnvironmentDiscoverySnapshot = { environments: EnvironmentInfo[]; error: AppError | null; wslIntegrationSupported: boolean; wslIntegrationEnabled: boolean; wslCapabilityRevision: number } export type EnvironmentInfo = { environment: EnvironmentRef; displayName: string; status: EnvironmentStatus; revision: number; error: AppError | null } export type EnvironmentRef = { kind: "native" } | { kind: "wsl"; distro_name: string } @@ -788,8 +805,8 @@ export type EvidenceAttempt = { checkedAtEpochMs: number; failure: EvidenceDetec export type EvidenceDetectionFailure = { reason: EvidenceFailureReason; message: string; retryAtEpochMs: number | null; providerCooldown: boolean } export type EvidenceFailureReason = "rateLimited" | "authenticationRequired" | "refNotFound" | "repositoryNotFound" | "notFoundOrUnauthorized" | "network" | "incompleteEvidence" | "sourceUnavailable" export type EvidenceFreshness = "fresh" | "cached" | "stale" | "coolingDown" | "backingOff" | "unavailable" -export type ExecuteAddLibrarySkillsRequest = { request: PreviewAddLibrarySkillsRequest; expectedToken: LibraryAddPreviewToken; acknowledgeRedirect: boolean } -export type ExecuteLibraryUpdateRequest = { request: UpdateLibrarySkillsRequest; expectedToken: LibraryUpdatePreviewToken; continuation: LibraryUpdateContinuation | null; riskConfirmation: LibraryUpdateRiskConfirmation | null } +export type ExecuteAddLibrarySkillsRequest = { request: PreviewAddLibrarySkillsRequest; expectedToken: LibraryAddPreviewToken; membership: LibraryMembershipPreview; acknowledgeRedirect: boolean } +export type ExecuteRetireLibrarySkillRequest = { request: RemoveLibrarySkillRequest; expectedToken: string; membership: LibraryMembershipPreview } export type FallbackReasonCode = "symlinkUnavailable" | "crossStorageCopyRequired" | "targetCapabilityFallback" /** * 来源发现结果 @@ -842,15 +859,22 @@ export type InstallResponse = { units: MutationUnitResult[]; warnings: SourceSup export type InstallSkillPreview = { skillName: string; payload: AcquiredPayloadHandle; overwriteTargets: string[]; blockingReasons: OperationErrorCode[]; fallbackForecasts: AgentTargetFallbackPreview[]; overridesLibrary: boolean } export type InstallWizardSessionSnapshot = { revision: number; active: boolean } /** - * 已安装的 Skill 信息 - * 对应 CLI: InstalledSkill (installer.ts:783-790) + * 当前 Scope 引用的库版本,用于定位到对应库成员。 + */ +export type InstalledLibraryVersion = { libraryId: string; libraryName: string; skillName: string } +/** + * 已安装的直接 Skill 信息。 */ export type InstalledSkill = { name: string; description: string; path: string; canonicalPath: string; scope: InstalledSkillLocation; agents: AgentId[]; /** * 每次读取 Skill 时根据当前 runtime 和文件系统重新组装,不写入 skill-lock。 * 只包含当前已检测到并且实际能够读取该 Skill 的关联 Agent。 */ -associatedAgents: AgentId[]; source?: string | null; sourceUrl?: string | null; installedAt?: string | null; updatedAt?: string | null; hasUpdate?: boolean | null; +associatedAgents: AgentId[]; +/** + * 当前 Scope 中实际被引用的同名库版本。 + */ +libraryVersions?: InstalledLibraryVersion[] | null; maintenanceError?: AppError | null; comparisonFingerprint?: string | null; source?: string | null; sourceUrl?: string | null; installedAt?: string | null; updatedAt?: string | null; hasUpdate?: boolean | null; /** * 是否可直接执行更新 */ @@ -916,9 +940,9 @@ export type LegacyMigrationTarget = "currentPrivate" | "standardCanonical" export type LegacyPath = { scope: LegacyPathScope; path: PathSpec; behavior: LegacyPathBehavior; migrationTarget: LegacyMigrationTarget } export type LegacyPathBehavior = "detectOnly" | "offerMigration" export type LegacyPathScope = "global" | "project" -export type LibraryAddPreview = { token: LibraryAddPreviewToken; skills: LibraryAddSkillPreview[]; redirectedDownloadHost: string | null } +export type LibraryAddPreview = { token: LibraryAddPreviewToken; skills: LibraryAddSkillPreview[]; redirectedDownloadHost: string | null; membership: LibraryMembershipPreview } export type LibraryAddPreviewToken = { generation: string; contextRevision: string; skillRevisions: LibraryAddSkillRevision[]; redirectedDownloadHost: string | null } -export type LibraryAddResponse = { results: LibraryAddSkillResult[]; library: SkillLibraryDetail } +export type LibraryAddResponse = { results: LibraryAddSkillResult[]; library: SkillLibraryDetail | null; membership: LibraryMembershipOutcome } export type LibraryAddSkillPreview = { skillName: string; targetPath: string } export type LibraryAddSkillResult = { skillName: string; status: LibraryAddSkillStatus; error: AppError | null } export type LibraryAddSkillRevision = { skillName: string; targetRevision: string; sourceRecordRevision: string } @@ -929,9 +953,14 @@ export type LibraryApplicationDraft = { context: SkillLocationRef; orderedLibrar export type LibraryApplicationPreview = { token: PreviewToken; current: LibraryApplicationState; target: LibraryApplicationState; addedSkillNames: string[]; removedSkillNames: string[]; switchedSkillNames: string[]; changedDirectorySkillNames: string[]; overriddenByDirectSkillNames: string[] } export type LibraryApplicationResponse = { application: LibraryApplicationSummary; units: MutationUnitResult[] } export type LibraryApplicationState = { orderedLibraryIds: LibraryId[]; selectedAgentIds: AgentId[] } -export type LibraryApplicationSummary = { orderedLibraries: SkillLibrarySummary[]; selectedAgentIds: AgentId[]; pending: boolean } +export type LibraryApplicationSummary = { orderedLibraries: SkillLibrarySummary[]; selectedAgentIds: AgentId[]; pending: boolean; syncState: LibraryApplicationSyncState } +export type LibraryApplicationSyncState = "synced" | "pending" | "unverified" | "recoveryRequired" export type LibraryCommitStatus = "succeeded" | "failed" | "notRun" export type LibraryId = string +export type LibraryMembershipOutcome = { scopes: MembershipScopeResult[]; cleanup: RetiredCleanupResult[]; snapshotError: AppError | null } +export type LibraryMembershipPreview = { environment: EnvironmentRef; libraryId: LibraryId; scopes: SkillLocationRef[]; impacts: MembershipScopeImpact[]; inventoryComplete: boolean; inventoryToken: string; token: string } +export type LibraryRetirePreview = { skillName: string; token: string; membership: LibraryMembershipPreview } +export type LibraryRetireResponse = { library: SkillLibraryDetail | null; membership: LibraryMembershipOutcome } export type LibrarySkillSummary = { name: string; description: string; source: string; sourceType: string; sourceUrl: string | null; skillPath: string; contentHash: string; /** * 内容所属插件。属于 Skill 自身的元数据,与 Agent 无关。 @@ -945,17 +974,8 @@ refName: string | null; * Skill Deck 最近一次成功提交该成员本地内容的时间,不表示上游发布时间。 * 旧成员在下一次成功写入前为 `None`。 */ -updatedAt: string | null } -export type LibraryUpdateContinuation = { sources: LibraryUpdatePreparedSource[] } -export type LibraryUpdateExecutionOutcome = { status: "completed"; response: LibraryUpdateResponse } | { status: "confirmationRequired"; token: LibraryUpdatePreviewToken; redirectedDownloadHosts: string[]; continuation: LibraryUpdateContinuation } -export type LibraryUpdatePreparedPayload = { skillName: string; payload: AcquiredPayloadHandle } -export type LibraryUpdatePreparedSkillError = { skillName: string; error: ErrorReport } -export type LibraryUpdatePreparedSource = { sourceResultId: string; source: string; skillNames: string[]; result: LibraryUpdatePreparedSourceResult } -export type LibraryUpdatePreparedSourceResult = { status: "acquired"; discoverySession: DiscoverySessionHandle; payloads: LibraryUpdatePreparedPayload[]; skillErrors: LibraryUpdatePreparedSkillError[]; redirectedDownloadHost: string | null } | { status: "failed"; error: ErrorReport } -export type LibraryUpdatePreview = { token: LibraryUpdatePreviewToken; skillNames: string[] } -export type LibraryUpdatePreviewToken = { generation: string } -export type LibraryUpdateResponse = { sources: UpdateSourceResult[]; results: LibraryUpdateSkillResult[]; outcome: UpdateOutcome; library: SkillLibraryDetail } -export type LibraryUpdateRiskConfirmation = { redirectedDownloadHosts: string[] } +updatedAt: string | null; comparisonFingerprint?: string | null; updateCapability?: CheckUpdateCapability | null } +export type LibraryUpdateResponse = { sources: UpdateSourceResult[]; results: LibraryUpdateSkillResult[]; outcome: UpdateOutcome; library: SkillLibraryDetail | null; membership: LibraryMembershipOutcome } export type LibraryUpdateSkillResult = { skillName: string; status: LibraryUpdateSkillStatus; sourceResultId: string; contentCommit: LibraryCommitStatus; catalogCommit: LibraryCommitStatus; error: ErrorReport | null } export type LibraryUpdateSkillStatus = "succeeded" | "failed" | "nameChanged" | "deletedUpstream" | "cancelled" | "notRun" export type LibraryUsage = { context: SkillLocationRef; project: RegisteredProject | null; state: LibraryUsageState } @@ -969,15 +989,15 @@ export type LibraryUsageProjection = { libraryId: LibraryId; confirmedCount: num /** * 某个 Skill 位置引用当前对象的方式。 * - * 生效与锁定是两件事:`Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有 - * 未完成的应用操作引用它、尚未确认生效。两者的并集才是成员锁定的判定依据。 + * `Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有未完成的应用操作 + * 引用它、尚未确认生效。两者的并集用于整库删除保护和使用状态展示。 */ export type LibraryUsageState = "confirmed" | "pendingAdjustment" export type LibraryWorkspaceSnapshot = { environment: EnvironmentRef; libraries: SkillLibrarySummary[]; /** * catalog 内容的摘要。应用关系不参与该摘要,页面重新进入时自行拉取最新投影。 */ -revision: string; usageProjection: LibraryUsageProjection[] } +revision: string; usageProjection: LibraryUsageProjection[]; usageInventoryComplete: boolean; usageInventoryProblemCount: number } export type LifecycleAction = "closeCurrentWindow" | "quitApplication" | "restartApplication" export type LifecycleActionOutcome = { status: "performed" } | { status: "delegated" } | { status: "blocked"; snapshot: BackendActivitySnapshot } export type LifecycleActionRequestedEvent = { action: LifecycleAction } @@ -986,11 +1006,11 @@ export type LifecycleLeaseKind = "applicationUpdate" | "runtimeMaintenance" * `list_skills` 的运行时读取结果。 * Skill 与 scope Agents 来自同一次 Agent runtime snapshot,避免 Frontend 拼接不同 revision。 */ -export type ListSkillsResult = { skills: InstalledSkill[]; agents: ResolvedAgent[]; +export type ListSkillsResult = { pathBase?: ScopePathBase | null; skills: InstalledSkill[]; agents: ResolvedAgent[]; /** * 项目目录是否存在(project scope 时有意义,global 始终为 true) */ -pathExists: boolean; libraryApplication: LibraryApplicationSummary } +pathExists: boolean; readStatus?: SkillReadStatus | null; libraryApplication: LibraryApplicationSummary } export type LockConflictTarget = { kind: "skill"; skillName: string } | { kind: "rootField"; field: string } export type ManageAgentSelectionSnapshot = { selection: AgentSelectionSnapshot; optionStates: ManageInstallOptionState[] } export type ManageAgentsConfirmation = { removesEntityDirectories: boolean } @@ -1006,6 +1026,11 @@ export type ManageInstallOptionState = { optionId: AgentInstallOptionId; current export type ManageSelectedEffect = "retain" | "add" | "repair" export type ManageSelectionDisabledReason = "unrecognizedEntry" export type ManageUnselectedEffect = "keepAbsent" | "remove" | "restoreLibrary" +export type MembershipScopeImpact = { context: SkillLocationRef; skills: MembershipSkillImpact[] } +export type MembershipScopeImpactKind = "added" | "switched" | "fallback" | "removed" | "unchanged" | "unverified" +export type MembershipScopeResult = { context: SkillLocationRef; state: MembershipScopeState; error: AppError | null } +export type MembershipScopeState = "synced" | "pending" | "unverified" | "recoveryRequired" | "cancelled" +export type MembershipSkillImpact = { skillName: string; kind: MembershipScopeImpactKind } export type MutationKind = "install" | "update" | "remove" | "copy" | "manageAgents" | "duplicateCleanup" | "repair" | "manageAgentDefinitions" | "projectMigration" | "addProject" | "removeProject" | "updateProjectPreference" | "updateSettings" | "manageGithubCredential" | "manageLibraries" | "resolveRecovery" export type MutationPhase = "preparing" | "acquiring" | "validating" | "committing" | "finishing" export type MutationProgress = { subject: string | null; current: number | null; total: number | null } @@ -1021,13 +1046,15 @@ export type ObservedEntryId = string export type ObservedEntryKind = "missing" | "directory" | "symlink" | "junction" | "brokenLink" | "other" export type ObservedEntryReader = { agentId: AgentId; displayName: string; logicalTargetId: string } export type ObservedPhysicalEntry = { entryId: ObservedEntryId; displayPath: ResourceLocator; kind: ObservedEntryKind; physicalTargetKey: string; readers: ObservedEntryReader[]; willBreakIfStandardRemoved: boolean } -export type OperationErrorCode = "validation" | "skillPlacementTargetConflict" | "wellKnownScopeNotFound" | "environmentUnavailable" | "storageUnsupported" | "capabilityUnavailable" | "unsafePath" | "unsafeSourceLink" | "selfCopy" | "payloadSessionExpired" | "staleContext" | "staleRegistry" | "staleEnvironment" | "stalePayload" | "staleTarget" | "externalLockChanged" | "upstreamSkillNameChanged" | "upstreamSkillDeleted" | "mutationCancelled" | "executionFailed" | "restoreFailed" | "recoveryRequired" | "libraryRecoveryIncomplete" | "configurationReadOnly" | "configurationCorrupted" +export type OperationErrorCode = "validation" | "noUpdateTargets" | "skillPlacementTargetConflict" | "wellKnownScopeNotFound" | "environmentUnavailable" | "storageUnsupported" | "capabilityUnavailable" | "unsafePath" | "unsafeSourceLink" | "selfCopy" | "payloadSessionExpired" | "staleContext" | "staleRegistry" | "staleEnvironment" | "stalePayload" | "staleTarget" | "externalLockChanged" | "upstreamSkillNameChanged" | "upstreamSkillDeleted" | "mutationCancelled" | "executionFailed" | "restoreFailed" | "recoveryRequired" | "libraryRecoveryIncomplete" | "configurationReadOnly" | "configurationCorrupted" | "configurationWriteUnconfirmed" export type PathSpec = { kind: "home"; relativePath: string } | { kind: "configHome"; relativePath: string } | { kind: "project"; relativePath: string } | { kind: "environmentVariable"; name: string; relativePath: string; fallback: PathSpec } | { kind: "firstExisting"; candidates: PathSpec[]; fallback: PathSpec } | /** * Absolute paths are reserved for built-in system detection candidates. */ { kind: "absolute"; path: string } export type PhysicalIdentityComparison = "same" | "different" | "unknown" +export type PreparedLibraryUpdatePreview = { skillNames: string[]; blocked: UpdatePreparationIssue[]; redirectedDownloadHosts: string[] } +export type PreparedUpdatePreview = { sources: UpdateSourcePreview[]; pathBase?: ScopePathBase | null; skills: UpdateSkillPreview[]; blocked: UpdatePreparationIssue[]; redirectedDownloadHosts: string[] } export type PreviewAddLibrarySkillItem = { skillName: string; payload: AcquiredPayloadHandle } export type PreviewAddLibrarySkillsRequest = { environment: EnvironmentRef; libraryId: LibraryId; discoverySession: DiscoverySessionHandle; skills: PreviewAddLibrarySkillItem[] } export type PreviewToken = { generation: string; registryRevision: string; environmentRevision: string; contextRevision: ContextSnapshotRevision } @@ -1037,6 +1064,10 @@ export type ProxyConnectionProbe = { status: ProxyConnectionStatus; elapsedMs: n export type ProxyConnectionStatus = "succeeded" | "failed" export type ProxyConnectionTestResult = { onlineServices: ProxyConnectionProbe; nativeGit: ProxyConnectionProbe; wslGitByDistro: Partial<{ [key in string]: ProxyConnectionProbe }> } export type ProxyMode = "custom" | "direct" +export type ProxySettingsIssue = { target: ProxySettingsTarget; code: ProxySettingsIssueCode; usingPrevious: boolean } +export type ProxySettingsIssueCode = "invalidFormat" | "unsupportedFormat" | "invalidProxyUrl" | "unreadableDocument" | "invalidDocument" | "writeUnconfirmed" +export type ProxySettingsSnapshot = { settings: NetworkProxySettings; issues: ProxySettingsIssue[]; configPath: string | null; repairable: boolean } +export type ProxySettingsTarget = { kind: "all" } | { kind: "http" } | { kind: "nativeGit" } | { kind: "wslGit"; distro: string | null } export type RecoveryAction = { resourceId: RecoveryResourceId; suggestedActionCode: SuggestedActionCode } export type RecoveryResourceId = string export type RecoveryResourcePath = { kind: RecoveryResourcePathKind; location: ResourceLocator } @@ -1047,19 +1078,25 @@ export type RecoverySubject = { operationKind: MutationKind; skillName: string; export type RegisteredProject = { id: string; nativePath: string; displayName: string | null; order: number | null; suppressCrossStorageWarning?: boolean } export type RemoveIntent = { kind: "fullSkill" } | { kind: "agentEntries"; entryIds: ObservedEntryId[] } export type RemoveLibrarySkillRequest = { environment: EnvironmentRef; libraryId: LibraryId; skillName: string } -export type RemovePreview = { token: PreviewToken; context: SkillLocationRef; skillName: string; standard: ObservedEntryKind; physicalEntries: ObservedPhysicalEntry[]; restoresLibrary: boolean } +export type RemovePreview = { pathBase?: ScopePathBase | null; token: PreviewToken; context: SkillLocationRef; skillName: string; standard: ObservedEntryKind; standardPath: ResourceLocator | null; physicalEntries: ObservedPhysicalEntry[]; restoresLibrary: boolean } export type RemoveRequest = { token: PreviewToken; context: SkillLocationRef; skillName: string; intent: RemoveIntent } export type RemoveResponse = { units: MutationUnitResult[] } export type ResolvedAgent = { definition: AgentDefinition; detection: DetectionState; detectionReason: DetectionReason | null; global: ResolvedAgentScope; project: ResolvedAgentScope } export type ResolvedAgentScope = { enabled: boolean; readsStandard: boolean; standardPath: string | null; privatePath: string | null; readPaths: string[]; standardPresence: DirectoryPresenceState | null; privatePresence: DirectoryPresenceState | null; legacyPaths: ResolvedPathPresence[] } export type ResolvedPathPresence = { path: string | null; presence: DirectoryPresenceState } export type ResourceLocator = { environment: EnvironmentRef; nativePath: string } +export type RetiredCleanupResult = { libraryId: LibraryId; memberName: string; retirementId: string; state: RetiredCleanupState; error: AppError | null } +export type RetiredCleanupState = "purged" | "retained" | "failed" /** * 安装范围 */ export type Scope = "global" | "project" export type ScopeDefinition = { enabled: boolean; readsStandard: boolean; privatePath: PathSpec | null } export type ScopeLocation = "standard" | "private" | "both" +/** + * 仅供界面缩短安装路径;执行仍使用原有目标标识与物理身份。 + */ +export type ScopePathBase = { logicalRoot: ResourceLocator; physicalRoot: ResourceLocator | null; pathStyle: DisplayPathStyle } /** * Skill Deck 应用配置 * 持久化到 ~/.skill-deck/config.json @@ -1084,16 +1121,20 @@ export type SkillLibrarySummary = { id: LibraryId; name: string; skillCount: num export type SkillLocation = { scope: "global" } | { scope: "project"; project_id: string } export type SkillLocationRef = { environment: EnvironmentRef; scope: SkillLocation } export type SkillPlacementTargetKind = "file" | "other" +export type SkillReadIssue = { code: string; path: ResourceLocator; agentIds: AgentId[] } +export type SkillReadIssueCount = { code: string; count: number } +export type SkillReadStatus = { complete: boolean; issues: SkillReadIssue[]; counts: SkillReadIssueCount[]; omittedCount: number } export type SkillUpdateCheckStatus = "updateAvailable" | "upToDate" | "cannotCheck" | "deletedUpstream" -export type SkillUpdateInfo = { name: string; source: string; hasUpdate: boolean; status: SkillUpdateCheckStatus; capability: CheckUpdateCapability; reason: UpdateCheckReasonCode | null; gitRef: string | null; sourceUrl: string | null; skillPath: string | null; freshness: EvidenceFreshness } -export type SourceAcquisitionFailureReason = "notFound" | "authenticationRequired" | "timeout" | "network" | "limitExceeded" | "invalidContent" | "unavailable" +export type SkillUpdateInfo = { name: string; source: string; hasUpdate: boolean; status: SkillUpdateCheckStatus; capability: CheckUpdateCapability; reason: UpdateCheckReasonCode | null; gitRef: string | null; sourceUrl: string | null; skillPath: string | null; freshness: EvidenceFreshness; comparisonFingerprint?: string | null; sourceKey?: string | null; error?: AppError | null } +export type SourceAcquisitionFailureReason = "notFound" | "authenticationRequired" | "timeout" | "network" | "limitExceeded" | "invalidContent" | "directoryLinksUnsupported" | "unavailable" +export type SourceProvider = "github" | "gitlab" | "git" | "wellKnown" export type SourceSelectionIntent = { wildcardRequested: boolean; explicitSkillNames: string[] } export type SourceSuppressionWarningCode = "suppressionCleanupFailed" -export type SourceUpdateCheckInfo = { source: string; requestedRef: string | null; resolvedRef: string | null; refRevision: string | null; checkedAtEpochMs: number | null; expiresAtEpochMs: number | null; freshness: EvidenceFreshness; lastAttempt: EvidenceAttempt | null } +export type SourceUpdateCheckInfo = { provider?: SourceProvider | null; source: string; requestedRef: string | null; resolvedRef: string | null; refRevision: string | null; checkedAtEpochMs: number | null; expiresAtEpochMs: number | null; freshness: EvidenceFreshness; lastAttempt: EvidenceAttempt | null; sourceKey?: string | null; error?: AppError | null } export type StorageAccess = "native" | "crossStorage" | "unsupported" | "unknown" export type SuggestedActionCode = "reviewChanges" | "refresh" | "openRecoveryResource" | "saveDefaultsLater" export type UnavailableAgentSelection = { agentId: string; reason: UnavailableAgentSelectionReason } -export type UnavailableAgentSelectionReason = "definitionMissing" +export type UnavailableAgentSelectionReason = "definitionMissing" | "optionUnavailable" export type UpdateCapabilityReasonCode = "missingRemoteHash" | "missingSource" | "unsupportedSource" export type UpdateCheckMode = "automatic" | "force" export type UpdateCheckOutcome = "completed" | "partial" | "notCompleted" @@ -1101,19 +1142,21 @@ export type UpdateCheckReasonCode = "missingRemoteHash" | "missingSource" | "uns export type UpdateCheckRequest = { context: SkillLocationRef; mode: UpdateCheckMode; selection: UpdateCheckSelection } export type UpdateCheckResponse = { outcome: UpdateCheckOutcome; sources: SourceUpdateCheckInfo[]; skills: SkillUpdateInfo[] } export type UpdateCheckSelection = { kind: "skills"; skills: SkillIdentity[] } -export type UpdateConflictCopyPreview = { entryId: ObservedEntryId; readers: ObservedEntryReader[] } -export type UpdateCoverage = { kind: "updated" } | { kind: "preservedConflicts" } | { kind: "notUpdated"; error: ErrorReport } -export type UpdateExecutionRequest = { request: UpdateRequest; overwritePrivateEntries: ObservedEntryId[] } +export type UpdateConflictCopyPreview = { isStandard?: boolean | null; entryId: ObservedEntryId; readers: ObservedEntryReader[]; displayPath: ResourceLocator } +export type UpdateCoverage = { kind: "updated" } | { kind: "updatedWithSkippedCopies" } | { kind: "notUpdated"; error: ErrorReport } export type UpdateLibrarySkillsRequest = { environment: EnvironmentRef; libraryId: LibraryId; skillNames: string[] } +export type UpdateLinkedTargetPreview = { displayPath: ResourceLocator; readers: ObservedEntryReader[]; isStandard: boolean; targetPath: ResourceLocator; targetCopyEntryId: ObservedEntryId | null } export type UpdateOutcome = "succeeded" | "partial" | "failed" | "cancelled" -export type UpdatePreview = { token: PreviewToken; skills: UpdateSkillPreview[] } +export type UpdatePreparationIssue = { skillName: string; error: AppError } export type UpdateRequest = { context: SkillLocationRef; skillNames: string[] } export type UpdateResponse = { sources: UpdateSourceResult[]; skills: UpdateSkillResult[]; outcome: UpdateOutcome } -export type UpdateSkillPreview = { skillName: string; sourceDisplay: string; refDisplay: string; adapterTargets: ObservedEntryReader[]; capability: CheckUpdateCapability; cleanCopyCount: number; overwritePrivateEntries: UpdateConflictCopyPreview[]; blockingReasons: OperationErrorCode[]; fallbackForecasts: AgentTargetFallbackPreview[] } -export type UpdateSkillResult = { skillIdentity: SkillIdentity; sourceResultId: string; mutation: MutationUnitResult | null; coverage: UpdateCoverage; warnings: UpdateWarningCode[]; retryable: boolean } +export type UpdateSkillPreview = { linkedTargets: UpdateLinkedTargetPreview[]; sourceKey?: string | null; skillName: string; sourceDisplay: string; refDisplay: string; adapterTargets: ObservedEntryReader[]; capability: CheckUpdateCapability; cleanCopyCount: number; targets: UpdateTargetPreview[]; preservedTargets?: UpdateTargetPreview[] | null; overwritePrivateEntries: UpdateConflictCopyPreview[]; blockingReasons: OperationErrorCode[]; fallbackForecasts: AgentTargetFallbackPreview[] } +export type UpdateSkillResult = { skippedCopyPaths?: ResourceLocator[] | null; skillIdentity: SkillIdentity; sourceResultId: string; mutation: MutationUnitResult | null; coverage: UpdateCoverage; warnings: UpdateWarningCode[]; retryable: boolean } +export type UpdateSourcePreview = { sourceKey: string; sourceDisplay: string; refDisplay: string; skillNames: string[]; error: AppError | null } export type UpdateSourceResult = { id: string; source: string; status: UpdateSourceStatus; error: ErrorReport | null } export type UpdateSourceStatus = "acquired" | "failed" -export type UpdateWarningCode = "preservedConflictingCopy" +export type UpdateTargetPreview = { selectableEntryId?: ObservedEntryId | null; isStandard?: boolean | null; kind?: ObservedEntryKind | null; linkTarget?: ResourceLocator | null; displayPath: ResourceLocator; readers: ObservedEntryReader[]; restoring: boolean } +export type UpdateWarningCode = "skippedCopy" export type WslGitProxySettings = { behavior: "followNativeGit" } | { behavior: "useExistingGitConfig" } | { behavior: "useProxy"; proxyUrl: string; scope: GitProxyScope } export type WslIntegrationBusyReason = "mutation" | "lifecycle" | "installWizard" | "wslOperation" diff --git a/src/components/agents/selection/AgentSelectionPanel.tsx b/src/components/agents/selection/AgentSelectionPanel.tsx index ff7cb5b0..d00658bd 100644 --- a/src/components/agents/selection/AgentSelectionPanel.tsx +++ b/src/components/agents/selection/AgentSelectionPanel.tsx @@ -34,6 +34,7 @@ export function AgentSelectionPanel({ modeClassName, modeWrapperClassName, showUnavailableNotice = true, + showRuntimeMetadata = true, layout = 'stacked', title, notice, @@ -46,6 +47,7 @@ export function AgentSelectionPanel({ modeClassName?: string; modeWrapperClassName?: string; showUnavailableNotice?: boolean; + showRuntimeMetadata?: boolean; layout?: 'stacked' | 'wizard'; title?: string; notice?: ReactNode; @@ -121,6 +123,7 @@ export function AgentSelectionPanel({ optionStates={controller.optionStates} emptyMessage={emptyMessage} disabled={disabled} + showRuntimeMetadata={showRuntimeMetadata} onOptionChange={controller.setOptionSelected} onGroupChange={controller.setGroupSelected} onOtherExpandedChange={controller.setOtherAgentsExpanded} diff --git a/src/components/agents/selection/AgentSelectionView.tsx b/src/components/agents/selection/AgentSelectionView.tsx index 8f163dfd..e562887d 100644 --- a/src/components/agents/selection/AgentSelectionView.tsx +++ b/src/components/agents/selection/AgentSelectionView.tsx @@ -54,6 +54,7 @@ interface AgentSelectionViewProps { onGroupExpandedChange: (groupId: string, expanded: boolean) => void; disabled?: boolean; emptyMessage?: string; + showRuntimeMetadata?: boolean; } export function AgentSelectionView({ @@ -68,17 +69,66 @@ export function AgentSelectionView({ onGroupExpandedChange, disabled = false, emptyMessage, + showRuntimeMetadata = true, }: AgentSelectionViewProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const states = new Map(optionStates.map((state) => [state.optionId, state])); const { agentsById, directAgents, separateOptions, additionalOptions } = projectAgentSelectionView(snapshot); - const detected = separateOptions.filter((option) => option.agentIds.some((id) => agentsById.get(id)?.detection === 'detected')); - const other = separateOptions.filter((option) => !detected.includes(option)); + const detected = showRuntimeMetadata + ? separateOptions.filter((option) => option.agentIds.some((id) => agentsById.get(id)?.detection === 'detected')) + : separateOptions; + const other = showRuntimeMetadata + ? separateOptions.filter((option) => !detected.includes(option)) + : []; + const summarizedOptionIds = new Set([ + ...separateOptions.map((option) => option.id), + ...snapshot.groups.flatMap((group) => group.optionIds), + ]); + const selectedOptions = session.selectedOptionIds.flatMap((id) => ( + snapshot.installOptions + .filter((option) => option.id === id && summarizedOptionIds.has(option.id)) + .map((option) => { + const group = option.groupId + ? snapshot.groups.find((candidate) => candidate.id === option.groupId) + : null; + const members = option.agentIds.flatMap((agentId) => ( + snapshot.agents.filter((agent) => agent.id === agentId) + )); + return { + option, + displayName: group + ? [group.displayName, option.displayName].join(' · ') + : members.length > 1 + ? mergedAgentNames(members, i18n?.resolvedLanguage ?? i18n?.language, t) + : option.displayName, + members, + grouped: group !== null, + }; + }) + )); + const viewRef = useRef(null); + const summaryRemoveRefs = useRef(new Map()); + const removeSelectedOption = (optionId: AgentInstallOptionId) => { + const index = selectedOptions.findIndex(({ option }) => option.id === optionId); + const adjacent = selectedOptions[index + 1] ?? selectedOptions[index - 1]; + if (adjacent) { + summaryRemoveRefs.current.get(adjacent.option.id)?.focus(); + } else { + const candidate = [...(viewRef.current?.querySelectorAll( + '[data-agent-option-id]', + ) ?? [])].find((element) => element.dataset.agentOptionId === optionId); + const fallback = viewRef.current?.querySelector( + '[data-agent-other-trigger]', + ); + (candidate ?? fallback)?.focus(); + } + onOptionChange(optionId, false); + }; const hasSelectionContent = directAgents.length > 0 || snapshot.installOptions.length > 0; const commonRowProps = { snapshot, session, states, disabled, onOptionChange }; return ( -

+
{!hasSelectionContent && emptyMessage ? (

{emptyMessage}

) : null} @@ -89,6 +139,7 @@ export function AgentSelectionView({ presentation={presentation} {...commonRowProps} onExpandedChange={onAdditionalExpandedChange} + showRuntimeMetadata={showRuntimeMetadata} /> {(detected.length > 0 || other.length > 0 || snapshot.groups.length > 0) ? ( @@ -98,9 +149,53 @@ export function AgentSelectionView({ title={presentation.selectable.title} help={presentation.selectable.help} /> + {selectedOptions.length > 0 ? ( +
+ + {t('agentSelection.selectedTitle')} + + {selectedOptions.map(({ option, displayName, members, grouped }) => { + const state = states.get(option.id); + const removable = option.selectable + && (state === undefined || state.allowedResults === 'both'); + return ( + + {displayName} + {members.length > 1 && !grouped ? ( + + ) : null} + {removable ? ( + + ) : null} + + ); + })} +
+ ) : null}
{detected.map((item) => ( - + ))} {snapshot.groups.map((group) => ( ))} {other.length > 0 ? ( - + {t('agentSelection.otherAgents', { count: other.length })} @@ -156,20 +255,24 @@ function DirectAgentsSummary({ disabled, onOptionChange, onExpandedChange, + showRuntimeMetadata, }: CommonRowProps & { agents: AgentSelectionSnapshot['agents']; options: AgentInstallOption[]; presentation: AgentSelectionPresentation; onExpandedChange: (expanded: boolean) => void; + showRuntimeMetadata: boolean; }) { if (agents.length === 0) return null; const detected = agents.filter((agent) => agent.detection === 'detected'); const more = agents.filter((agent) => agent.detection !== 'detected'); const agentsById = new Map(agents.map((agent) => [agent.id, agent])); - const orderedOptions = [ - ...options.filter((option) => option.agentIds.some((id) => agentsById.get(id)?.detection === 'detected')), - ...options.filter((option) => !option.agentIds.some((id) => agentsById.get(id)?.detection === 'detected')), - ]; + const orderedOptions = showRuntimeMetadata + ? [ + ...options.filter((option) => option.agentIds.some((id) => agentsById.get(id)?.detection === 'detected')), + ...options.filter((option) => !option.agentIds.some((id) => agentsById.get(id)?.detection === 'detected')), + ] + : options; const selectedIds = new Set(session.selectedOptionIds); const selectedAgentCount = new Set( options @@ -187,7 +290,9 @@ function DirectAgentsSummary({ {detected.map((agent) => ( ))} - {more.length > 0 ? : null} + {more.length > 0 ? ( + + ) : null}
{orderedOptions.length > 0 ? ( -

- {presentation.ownDirectory.description} -

-
+
{orderedOptions.map((item) => ( ))}
@@ -258,7 +361,13 @@ function SectionHeading({ id, title, help }: { id: string; title: string; help: ); } -function DirectAgentsMorePopover({ agents }: { agents: AgentSelectionSnapshot['agents'] }) { +function DirectAgentsMorePopover({ + agents, + showDescription = true, +}: { + agents: AgentSelectionSnapshot['agents']; + showDescription?: boolean; +}) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const closeTimer = useRef | null>(null); @@ -316,9 +425,11 @@ function DirectAgentsMorePopover({ agents }: { agents: AgentSelectionSnapshot['a onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} > -

- {t('agentSelection.moreAgentsDescription')} -

+ {showDescription ? ( +

+ {t('agentSelection.moreAgentsDescription')} +

+ ) : null}
{agents.map((agent) => ( @@ -344,6 +455,7 @@ interface CommonRowProps { states: Map; disabled: boolean; onOptionChange: (optionId: AgentInstallOptionId, selected: boolean) => void; + showRuntimeMetadata?: boolean; } function SelectionGroup({ @@ -355,6 +467,7 @@ function SelectionGroup({ onOptionChange, onGroupChange, onGroupExpandedChange, + showRuntimeMetadata = true, }: CommonRowProps & { group: AgentSelectionGroup; onGroupChange: (groupId: string, selected: boolean) => void; @@ -404,7 +517,9 @@ function SelectionGroup({
{groupOptions.map((option) => ( @@ -416,6 +531,7 @@ function SelectionGroup({ states={states} disabled={disabled} onOptionChange={onOptionChange} + showRuntimeMetadata={showRuntimeMetadata} /> ))} @@ -423,7 +539,7 @@ function SelectionGroup({ ); } -function SelectionGroupChild({ option, session, states, disabled, onOptionChange }: CommonRowProps & { option: AgentInstallOption }) { +function SelectionGroupChild({ option, session, states, disabled, onOptionChange, showRuntimeMetadata = true }: CommonRowProps & { option: AgentInstallOption }) { const checkboxId = useId(); const selected = session.selectedOptionIds.includes(option.id); const state = states.get(option.id); @@ -440,7 +556,7 @@ function SelectionGroupChild({ option, session, states, disabled, onOptionChange disabled={disabled} onOptionChange={onOptionChange} /> - + - {members.length > 1 - ? - : } + {showRuntimeMetadata + ? members.length > 1 + ? + : + : null}
); @@ -503,7 +622,7 @@ function SelectionCheckbox({ id, option, state, selected, disabled, label, onOpt const accessibleLabel = label ?? option.displayName; const lockedSelected = state?.allowedResults === 'selected'; const readOnly = !option.selectable || (state !== undefined && state.allowedResults !== 'both'); - if (lockedSelected) return ; + if (lockedSelected) return ; if (readOnly) return
@@ -159,7 +167,7 @@ function LibraryAddHeader({ ); } -function LibraryAddBody({ flow }: { flow: LibraryAddFlow }) { +function LibraryAddBody({ flow, environment }: { flow: LibraryAddFlow; environment: EnvironmentRef }) { if (flow.phase === 'source') return ; if (flow.phase === 'selection') return ; if (flow.phase === 'preparing') { @@ -169,7 +177,7 @@ function LibraryAddBody({ flow }: { flow: LibraryAddFlow }) { return ; } if (flow.phase === 'result') return ; - return ; + return ; } function LibrarySourceStep({ flow }: { flow: LibraryAddFlow }) { @@ -317,33 +325,58 @@ function LibrarySkillSelectionStep({ flow }: { flow: LibraryAddFlow }) { ); } -function LibraryAddReviewStep({ flow }: { flow: LibraryAddFlow }) { +function LibraryAddReviewStep({ flow, environment }: { flow: LibraryAddFlow; environment: EnvironmentRef }) { const { t } = useTranslation(); const preview = flow.prepared?.preview; + const unverifiedCount = preview?.membership.impacts.reduce( + (count, impact) => count + impact.skills.filter((skill) => skill.kind === 'unverified').length, + 0, + ) ?? 0; return ( -
-

- {t('libraries.addFlow.review.summary', { count: preview?.skills.length ?? 0 })} -

-
+
+
+

+ {t('libraries.addFlow.review.summary', { count: preview?.skills.length ?? 0 })} +

+ {preview && preview.membership.scopes.length > 0 ? ( + + + + + + + + + ) : null} +
+ {preview && !preview.membership.inventoryComplete ? ( +

+ {t('libraries.membership.inventoryIncomplete', { count: preview.membership.scopes.length })} +

+ ) : null} + {unverifiedCount > 0 ? ( +

+ {t('libraries.membership.impact.unverified', { count: unverifiedCount })} +

+ ) : null} +
    {preview?.skills.map((skill) => ( -
    - - {skill.skillName} - +
  • - - {skill.targetPath} - + + {skill.skillName} + - - {skill.targetPath} - + {skill.skillName} -
  • + + ))} -
+ {preview?.redirectedDownloadHost ? ( + + + + {fullPath.slice(0, split)} + {fullPath.slice(split)} + + + + {fullPath} + + +
+ ); +} + +function LibraryAddSyncDetails({ membership }: { membership: LibraryMembershipPreview }) { + const { projects } = useProjectWorkspace(membership.environment); + return ( +
    + {membership.scopes.map((context) => { + const scope = context.scope; + const project = scope.scope === 'project' + ? projects.find((item) => item.binding.id === scope.project_id)?.binding ?? null + : null; + const impact = membership.impacts.find((item) => sameContext(item.context, context)); + return ( +
  • + + {impact ? : null} +
  • + ); + })} +
+ ); +} + function LibraryAddProgress({ labelKey }: { labelKey: string }) { const { t } = useTranslation(); return ( @@ -413,6 +488,9 @@ function LibraryAddResultStep({ flow }: { flow: LibraryAddFlow }) {
))}
+ {flow.membershipOutcome ? ( + + ) : null} {flow.flowError ? (

{formatAppError(flow.flowError, t)} @@ -432,23 +510,25 @@ function LibraryAddFooter({ flow }: { flow: LibraryAddFlow }) { const canBack = flow.phase === 'selection' || flow.phase === 'review'; const locked = flow.phase === 'preparing' || flow.phase === 'executing'; const hasRetry = flow.phase === 'result' && flow.prepared !== null; + const completed = flow.phase === 'result' && !hasRetry + && flow.results.every((result) => result.status === 'succeeded'); return ( - + {!locked ? ( - ) : null} {canBack ? ( - ) : null} {flow.phase === 'selection' ? ( ) : null} + {locked ? ( + + ) : null} {hasRetry ? ( - ); -} +import { InstallPath } from './InstallPath'; +import './DeleteSkillDialog.css'; export const DeleteSkillDialog = memo(function DeleteSkillDialog() { const { t } = useTranslation(); @@ -79,12 +30,18 @@ export const DeleteSkillDialog = memo(function DeleteSkillDialog() { const writeBlocked = useBusinessWriteBlocked(); const [removing, setRemoving] = useState(false); const [recovery, setRecovery] = useState([]); - const [showFullPaths, setShowFullPaths] = useState(false); const cancelButtonRef = useRef(null); + const { projects } = useProjectWorkspace(target?.context.environment ?? { kind: 'native' }); + const scope = target?.context.scope; + const project = scope?.scope === 'project' + ? projects.find((item) => item.binding.id === scope.project_id) + : undefined; + const scopeLabel = scope?.scope === 'project' + ? project ? t('skills.installPath.project', { name: projectDisplayName(project) }) : t('context.projects') + : t('context.global'); useEffect(() => { setRecovery([]); - setShowFullPaths(false); }, [preview, target]); const confirm = useCallback(async () => { @@ -107,41 +64,30 @@ export const DeleteSkillDialog = memo(function DeleteSkillDialog() { const retryingPreview = feedback === 'previewError' && !preview; const recoveryRequired = recovery.length > 0; const hasCopies = preview?.physicalEntries.some((entry) => entry.kind === 'directory') ?? false; - const canonicalPath = target?.skill.canonicalPath ?? ''; - const basePath = target ? getSkillBasePath(canonicalPath) : null; - const relativeSharedPath = relativeToBase(canonicalPath, basePath); - const sharedDisplayPath = showFullPaths ? canonicalPath : relativeSharedPath; - const canToggleFullPaths = Boolean(basePath && relativeSharedPath !== canonicalPath); return (

!open && !removing && close()}> { event.preventDefault(); cancelButtonRef.current?.focus(); }} > - -
-
-
-
- {t('skills.deleteConfirm.title')} - - {t('skills.deleteConfirm.description', { name: target?.skill.name })} - -
-
+ + + {t('skills.deleteConfirm.title', { name: target?.skill.name ?? '' })}{' '} + · {scopeLabel} +
{feedback || recoveryRequired ? (
- ) : ( - <> - {preview ? ( -
-
-
-

- {t('skills.deleteConfirm.scopeLabel')} -

- - {t('skills.deleteConfirm.scopeCount', { - count: preview.physicalEntries.length + 1, - })} - -
- - {canToggleFullPaths ? ( -
- setShowFullPaths(false)} - > - {t('skills.deleteConfirm.relativePaths')} - - setShowFullPaths(true)} - > - {t('skills.deleteConfirm.fullPaths')} - -
- ) : null} -
- - {preview.restoresLibrary ? ( -
-
- ) : null} - -
+ {preview.standardPath ?
  • + {t('skills.deleteConfirm.standardDirSection')} +
    + +
    +
  • : null} + + {preview.physicalEntries.map((entry) => { + const copy = entry.kind === 'directory'; + const mode = copy ? 'copyMode' : 'linkMode'; + const EntryIcon = copy ? Files : Link2; + return ( +
  • -
    -
    -
    -
    -
    - - {t('skills.deleteConfirm.standardDirSection')} - - - {t('skills.deleteConfirm.mainDirectory')} - -
    - - {sharedDisplayPath} - -
    + + {entry.readers.map((reader) => reader.displayName).join(' · ') || t('skills.updatePlan.installLocation')} + +
    +
    - - {preview.physicalEntries.map((entry) => { - const copy = entry.kind === 'directory'; - const mode = copy ? 'copyMode' : 'linkMode'; - const EntryIcon = copy ? Copy : Link2; - const displayPath = showFullPaths - ? entry.displayPath.nativePath - : relativeToBase(entry.displayPath.nativePath, basePath); - return ( -
    -
    -
    -
    -
    - - {entry.readers.map((reader) => reader.displayName).join(', ')} - - - {t(`skills.deleteConfirm.${mode}`)} - -
    - - {displayPath} - -
    -
    - ); - })} -
    - -
  • - ) : null} - - {hasCopies ? ( -
    -
    - ) : null} - - )} + + + + ); + })} + + ) : null}
    - - - {!recoveryRequired ? ( - + + {!loading && !recoveryRequired && preview && (hasCopies || preview.restoresLibrary) ? ( +
    + {hasCopies ?

    {t('skills.deleteConfirm.copyWarning')}

    : null} + {preview.restoresLibrary ?

    {t('skills.deleteConfirm.restoresLibrary')}

    : null} +
    ) : null} +
    + + {!recoveryRequired ? ( + + ) : null} +
    diff --git a/src/components/skills/InstallPath.tsx b/src/components/skills/InstallPath.tsx new file mode 100644 index 00000000..b4a845dc --- /dev/null +++ b/src/components/skills/InstallPath.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Copy } from 'lucide-react'; +import { toast } from 'sonner'; +import type { ResourceLocator, ScopePathBase, SkillLocation } from '@/bindings'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { presentInstallPath } from '@/lib/install-path'; + +export function InstallPath({ path, base, scope, copyable = true }: { + path: ResourceLocator; + base?: ScopePathBase | null; + scope: SkillLocation['scope']; + copyable?: boolean; +}) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const { label } = presentInstallPath(path, base, scope); + const separator = base?.pathStyle === 'windows' ? '\\' : '/'; + const split = label.lastIndexOf(separator) + 1; + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + + const copy = async () => { + try { + await navigator.clipboard.writeText(path.nativePath); + setCopied(true); + } catch { + toast.error(t('skills.installPath.copyFailed')); + } + }; + + return ( + +
    + + + + + + {path.nativePath} + + + {copyable ? + + + + {t('skills.installPath.copyPath')} + : null} + {copied ? t('skills.installPath.copied') : ''} +
    +
    + ); +} diff --git a/src/components/skills/LibraryApplicationStrip.tsx b/src/components/skills/LibraryApplicationStrip.tsx index ba4a9543..75d27e4b 100644 --- a/src/components/skills/LibraryApplicationStrip.tsx +++ b/src/components/skills/LibraryApplicationStrip.tsx @@ -48,7 +48,11 @@ export function LibraryApplicationStrip({ application, compact = false }: Librar ))} ) : null} - {application.pending ? {t('libraries.pending')} : null} + {application.syncState !== 'synced' ? ( + + {t(`libraries.syncState.${application.syncState}`)} + + ) : null} ); } diff --git a/src/components/skills/ManageLibraryApplicationDialog.tsx b/src/components/skills/ManageLibraryApplicationDialog.tsx index 0b3caffa..b7eba865 100644 --- a/src/components/skills/ManageLibraryApplicationDialog.tsx +++ b/src/components/skills/ManageLibraryApplicationDialog.tsx @@ -80,6 +80,7 @@ export function ManageLibraryApplicationDialog({ const [catalog, setCatalog] = useState(null); const [agentOptions, setAgentOptions] = useState(null); const [agentSession, setAgentSession] = useState(null); + const [retainedUnavailableAgentIds, setRetainedUnavailableAgentIds] = useState([]); const [selected, setSelected] = useState([]); const [loadState, setLoadState] = useState('idle'); const [loadAttempt, setLoadAttempt] = useState(0); @@ -102,6 +103,7 @@ export function ManageLibraryApplicationDialog({ setCatalog(null); setAgentOptions(null); setAgentSession(null); + setRetainedUnavailableAgentIds([]); if (application.pending) { setLoadState('ready'); return; @@ -113,13 +115,12 @@ export function ManageLibraryApplicationDialog({ getLibraryAgentOptions(context), ]).then(([nextCatalog, nextAgentOptions]) => { if (requestId !== loadRequestId.current) return; - const selection = withInitialAgents( - nextAgentOptions.selection, - application.selectedAgentIds, - ); setCatalog(nextCatalog); - setAgentOptions({ ...nextAgentOptions, selection }); - setAgentSession(createAgentSelectionSession(selection)); + setAgentOptions(nextAgentOptions); + setAgentSession(createAgentSelectionSession(nextAgentOptions.selection)); + setRetainedUnavailableAgentIds( + nextAgentOptions.selection.unavailableExplicitAgents.map((agent) => agent.agentId), + ); setLoadState('ready'); }).catch(() => { if (requestId === loadRequestId.current) setLoadState('error'); @@ -130,9 +131,12 @@ export function ManageLibraryApplicationDialog({ const selection = agentOptions?.selection ?? null; const selectedAgentIds = useMemo(() => ( selection && agentSession - ? selectedAgentsFromOptions(selection, agentSession.selectedOptionIds) + ? [...new Set([ + ...selectedAgentsFromOptions(selection, agentSession.selectedOptionIds), + ...retainedUnavailableAgentIds, + ])].sort() : [] - ), [agentSession, selection]); + ), [agentSession, retainedUnavailableAgentIds, selection]); const draft = useMemo(() => ( context && loadState === 'ready' && agentSession ? { context, orderedLibraryIds: selected, selectedAgentIds } @@ -142,6 +146,7 @@ export function ManageLibraryApplicationDialog({ ? !sameArray(draft.orderedLibraryIds, application.orderedLibraries.map((library) => library.id)) || !sameSet(draft.selectedAgentIds, application.selectedAgentIds) : false; + const canReapply = Boolean(application?.orderedLibraries.length) && !dirty; const selectedLibraries = selected.flatMap((id) => { const library = catalog?.libraries.find((candidate) => candidate.id === id) ?? application?.orderedLibraries.find((candidate) => candidate.id === id); @@ -166,6 +171,7 @@ export function ManageLibraryApplicationDialog({ const next = current.filter((id) => id !== libraryId); if (next.length === 0) { setAgentSession((session) => session ? { ...session, selectedOptionIds: [] } : session); + setRetainedUnavailableAgentIds([]); } return next; }); @@ -175,7 +181,7 @@ export function ManageLibraryApplicationDialog({ onOpenChange(false); }; const save = async () => { - if (!draft || !dirty) return; + if (!draft || (!dirty && !canReapply)) return; setOperation('saving'); setSaveFailure(null); try { @@ -268,6 +274,7 @@ export function ManageLibraryApplicationDialog({ selection={selection} agentSession={agentSession} agentOptions={agentOptions} + retainedUnavailableAgentIds={retainedUnavailableAgentIds} agentPresentation={agentPresentation} agentsDisabled={selected.length === 0} onRemoveLibrary={removeLibrary} @@ -280,6 +287,12 @@ export function ManageLibraryApplicationDialog({ setSaveFailure(null); setAgentSession(session); }} + onUnavailableAgentChange={(agentId, retained) => { + setSaveFailure(null); + setRetainedUnavailableAgentIds((current) => retained + ? [...new Set([...current, agentId])].sort() + : current.filter((id) => id !== agentId)); + }} /> )} @@ -296,12 +309,14 @@ export function ManageLibraryApplicationDialog({ @@ -370,12 +385,14 @@ function LibraryApplicationEditor({ selection, agentSession, agentOptions, + retainedUnavailableAgentIds, agentPresentation, agentsDisabled, onRemoveLibrary, onAddLibrary, onMoveLibrary, onAgentSessionChange, + onUnavailableAgentChange, }: { libraries: SkillLibrarySummary[]; availableLibraries: SkillLibrarySummary[]; @@ -383,12 +400,14 @@ function LibraryApplicationEditor({ selection: AgentSelectionSnapshot; agentSession: AgentSelectionSession; agentOptions: LibraryAgentOptions; + retainedUnavailableAgentIds: string[]; agentPresentation: ReturnType; agentsDisabled: boolean; onRemoveLibrary: (id: LibraryId) => void; onAddLibrary: (id: LibraryId) => void; onMoveLibrary: (id: LibraryId, offset: number) => void; onAgentSessionChange: (session: AgentSelectionSession) => void; + onUnavailableAgentChange: (agentId: string, retained: boolean) => void; }) { const { t } = useTranslation(); const setOption = (optionId: AgentInstallOptionId, selected: boolean) => { @@ -487,6 +506,33 @@ function LibraryApplicationEditor({ {t('libraries.copyOnlyUnsupported', { names: agentOptions.unsupportedAgentNames.join(', ') })} ) : null} + {!agentsDisabled && selection.unavailableExplicitAgents.length > 0 ? ( +
    +

    + {t('libraries.unavailableSavedAgents')} +

    +
    + {selection.unavailableExplicitAgents.map((agent) => { + const id = `unavailable-library-agent-${agent.agentId}`; + return ( +
    + onUnavailableAgentChange(agent.agentId, checked === true)} + /> +
    + +

    + {t(`libraries.unavailableAgentReasons.${agent.reason}`)} +

    +
    +
    + ); + })} +
    +
    + ) : null} {!agentsDisabled ? agentOptions.migrations .filter((migration) => selectedAgentsFromOptions(selection, agentSession.selectedOptionIds).includes(migration.agentId)) .map((migration) => ( @@ -535,7 +581,7 @@ function SelectedLibraryRow({ function AvailableLibraryRow({ library, onAdd }: { library: SkillLibrarySummary; onAdd: () => void }) { return (