Compare commits
16 commits
main
...
fix/clippy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9a571ae57 | ||
|
|
29b99b21f7 | ||
|
|
0d2cd3229e | ||
|
|
6a01aa52e6 | ||
|
|
1e8e152148 | ||
|
|
7976bf994c | ||
|
|
d9c1883879 | ||
|
|
722eee3ea5 | ||
|
|
23ba21bdd5 | ||
|
|
464a870180 | ||
|
|
2959cd1e51 | ||
|
|
86bc510722 | ||
|
|
920842fffe | ||
|
|
ec5d3a546b | ||
|
|
9cfbb8ceed | ||
|
|
ace763eede |
92 changed files with 1452 additions and 23913 deletions
56
.github/workflows/rust-ci.yml
vendored
56
.github/workflows/rust-ci.yml
vendored
|
|
@ -1,56 +0,0 @@
|
||||||
name: Rust CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- 'gaebal/**'
|
|
||||||
- 'omx-issue-*'
|
|
||||||
paths:
|
|
||||||
- .github/workflows/rust-ci.yml
|
|
||||||
- rust/**
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- .github/workflows/rust-ci.yml
|
|
||||||
- rust/**
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: rust
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
fmt:
|
|
||||||
name: cargo fmt
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: rustfmt
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: rust -> target
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt --all --check
|
|
||||||
|
|
||||||
test-rusty-claude-cli:
|
|
||||||
name: cargo test -p rusty-claude-cli
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: rust -> target
|
|
||||||
- name: Run crate tests
|
|
||||||
run: cargo test -p rusty-claude-cli
|
|
||||||
346
PARITY.md
346
PARITY.md
|
|
@ -1,187 +1,253 @@
|
||||||
# Parity Status — claw-code Rust Port
|
# PARITY Gap Analysis
|
||||||
|
|
||||||
Last updated: 2026-04-03
|
Date: 2026-04-01
|
||||||
|
|
||||||
## Summary
|
Scope compared:
|
||||||
|
- Upstream TypeScript: `/home/bellman/Workspace/claude-code/src/`
|
||||||
|
- Rust port: `rust/crates/`
|
||||||
|
|
||||||
- Canonical document: this top-level `PARITY.md` is the file consumed by `rust/scripts/run_mock_parity_diff.py`.
|
Method:
|
||||||
- Requested 9-lane checkpoint: **All 9 lanes merged on `main`.**
|
- Read-only comparison only.
|
||||||
- Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger).
|
- No upstream source was copied into this repo.
|
||||||
- Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**.
|
- This is a focused feature-gap report for `tools`, `hooks`, `plugins`, `skills`, `cli`, `assistant`, and `services`.
|
||||||
- Mock parity harness stats: **10 scripted scenarios**, **19 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`.
|
|
||||||
|
|
||||||
## Mock parity harness — milestone 1
|
## Executive summary
|
||||||
|
|
||||||
- [x] Deterministic Anthropic-compatible mock service (`rust/crates/mock-anthropic-service`)
|
The Rust port has a solid core for:
|
||||||
- [x] Reproducible clean-environment CLI harness (`rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`)
|
- basic prompt/REPL flow
|
||||||
- [x] Scripted scenarios: `streaming_text`, `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, `write_file_denied`
|
- session/runtime state
|
||||||
|
- Anthropic API/OAuth plumbing
|
||||||
|
- a compact MVP tool registry
|
||||||
|
- CLAUDE.md discovery
|
||||||
|
- MCP config parsing/bootstrap primitives
|
||||||
|
|
||||||
## Mock parity harness — milestone 2 (behavioral expansion)
|
But it is still materially behind the TypeScript implementation in six major areas:
|
||||||
|
1. **Tools surface area** is much smaller.
|
||||||
|
2. **Hook execution** is largely missing; Rust mostly loads hook config but does not run a TS-style PreToolUse/PostToolUse pipeline.
|
||||||
|
3. **Plugins** are effectively absent in Rust.
|
||||||
|
4. **Skills** are only partially implemented in Rust via direct `SKILL.md` loading; there is no comparable skills command/discovery/registration surface.
|
||||||
|
5. **CLI** breadth is much narrower in Rust.
|
||||||
|
6. **Assistant/tool orchestration** lacks the richer streaming concurrency, hook integration, and orchestration behavior present in TS.
|
||||||
|
7. **Services** in Rust cover API/auth/runtime basics, but many higher-level TS services are missing.
|
||||||
|
|
||||||
- [x] Scripted multi-tool turn coverage: `multi_tool_turn_roundtrip`
|
## Critical bug status on this branch
|
||||||
- [x] Scripted bash coverage: `bash_stdout_roundtrip`
|
|
||||||
- [x] Scripted permission prompt coverage: `bash_permission_prompt_approved`, `bash_permission_prompt_denied`
|
|
||||||
- [x] Scripted plugin-path coverage: `plugin_tool_roundtrip`
|
|
||||||
- [x] Behavioral diff/checklist runner: `rust/scripts/run_mock_parity_diff.py`
|
|
||||||
|
|
||||||
## Harness v2 behavioral checklist
|
Targeted critical items requested by the user:
|
||||||
|
- **Prompt mode tools enabled**: fixed in `rust/crates/rusty-claude-cli/src/main.rs:75-82`
|
||||||
|
- **Default permission mode = danger-full-access**: fixed in `rust/crates/rusty-claude-cli/src/args.rs:12-16`, `rust/crates/rusty-claude-cli/src/main.rs:348-353`, and starter config `rust/crates/rusty-claude-cli/src/init.rs:4-9`
|
||||||
|
- **Tool input `{}` prefix bug**: fixed/guarded in streaming vs non-stream paths at `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`
|
||||||
|
- **Unlimited max_iterations**: already present at `rust/crates/runtime/src/conversation.rs:143-148` with `usize::MAX` initialization at `rust/crates/runtime/src/conversation.rs:119`
|
||||||
|
|
||||||
Canonical scenario map: `rust/mock_parity_scenarios.json`
|
Build/test/manual verification is tracked separately below and must pass before the branch is considered done.
|
||||||
|
|
||||||
- Multi-tool assistant turns
|
---
|
||||||
- Bash flow roundtrips
|
|
||||||
- Permission enforcement across tool paths
|
|
||||||
- Plugin tool execution path
|
|
||||||
- File tools — harness-validated flows
|
|
||||||
- Streaming response support validated by the mock parity harness
|
|
||||||
|
|
||||||
## 9-lane checkpoint
|
## 1) tools/
|
||||||
|
|
||||||
| Lane | Status | Feature commit | Merge commit | Evidence |
|
### Upstream TS has
|
||||||
|---|---|---|---|---|
|
- Large per-tool module surface under `src/tools/`, including agent/task tools, AskUserQuestion, MCP tools, plan/worktree tools, REPL, schedule/task tools, synthetic output, brief/upload, and more.
|
||||||
| 1. Bash validation | merged | `36dac6c` | `1cfd78a` | `jobdori/bash-validation-submodules`, `rust/crates/runtime/src/bash_validation.rs` (`+1004` on `main`) |
|
- Evidence:
|
||||||
| 2. CI fix | merged | `89104eb` | `f1969ce` | `rust/crates/runtime/src/sandbox.rs` (`+22/-1`) |
|
- `src/tools/AgentTool/AgentTool.tsx`
|
||||||
| 3. File-tool | merged | `284163b` | `a98f2b6` | `rust/crates/runtime/src/file_ops.rs` (`+195/-1`) |
|
- `src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx`
|
||||||
| 4. TaskRegistry | merged | `5ea138e` | `21a1e1d` | `rust/crates/runtime/src/task_registry.rs` (`+336`) |
|
- `src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts`
|
||||||
| 5. Task wiring | merged | `e8692e4` | `d994be6` | `rust/crates/tools/src/lib.rs` (`+79/-35`) |
|
- `src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts`
|
||||||
| 6. Team+Cron | merged | `c486ca6` | `49653fe` | `rust/crates/runtime/src/team_cron_registry.rs`, `rust/crates/tools/src/lib.rs` (`+441/-37`) |
|
- `src/tools/EnterPlanModeTool/EnterPlanModeTool.ts`
|
||||||
| 7. MCP lifecycle | merged | `730667f` | `cc0f92e` | `rust/crates/runtime/src/mcp_tool_bridge.rs`, `rust/crates/tools/src/lib.rs` (`+491/-24`) |
|
- `src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts`
|
||||||
| 8. LSP client | merged | `2d66503` | `d7f0dc6` | `rust/crates/runtime/src/lsp_client.rs`, `rust/crates/tools/src/lib.rs` (`+461/-9`) |
|
- `src/tools/EnterWorktreeTool/EnterWorktreeTool.ts`
|
||||||
| 9. Permission enforcement | merged | `66283f4` | `336f820` | `rust/crates/runtime/src/permission_enforcer.rs`, `rust/crates/tools/src/lib.rs` (`+357`) |
|
- `src/tools/ExitWorktreeTool/ExitWorktreeTool.ts`
|
||||||
|
- `src/tools/RemoteTriggerTool/RemoteTriggerTool.ts`
|
||||||
|
- `src/tools/ScheduleCronTool/*`
|
||||||
|
- `src/tools/TaskCreateTool/*`, `TaskGetTool/*`, `TaskListTool/*`, `TaskOutputTool/*`
|
||||||
|
|
||||||
## Lane details
|
### Rust currently has
|
||||||
|
- A single MVP registry in `rust/crates/tools/src/lib.rs:53-371`.
|
||||||
|
- Implemented tools include `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`, `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `StructuredOutput`, `REPL`, `PowerShell`.
|
||||||
|
|
||||||
### Lane 1 — Bash validation
|
### Missing or broken in Rust
|
||||||
|
- **Missing large chunks of the upstream tool catalog**: I did not find Rust equivalents for AskUserQuestion, MCP resource listing/reading tools, plan/worktree entry/exit tools, task management tools, remote trigger, synthetic output, or schedule/cron tools.
|
||||||
|
- **Tool decomposition is much coarser**: TS isolates tool-specific validation/security/UI behavior per tool module; Rust centralizes almost everything in one file (`rust/crates/tools/src/lib.rs`).
|
||||||
|
- **Likely parity impact**: lower fidelity tool prompting, weaker per-tool behavior specialization, and fewer native tool choices exposed to the model.
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
---
|
||||||
- **Feature commit:** `36dac6c` — `feat: add bash validation submodules — readOnlyValidation, destructiveCommandWarning, modeValidation, sedValidation, pathValidation, commandSemantics`
|
|
||||||
- **Evidence:** branch-only diff adds `rust/crates/runtime/src/bash_validation.rs` and a `runtime::lib` export (`+1005` across 2 files).
|
|
||||||
- **Main-branch reality:** `rust/crates/runtime/src/bash.rs` is still the active on-`main` implementation at **283 LOC**, with timeout/background/sandbox execution. `PermissionEnforcer::check_bash()` adds read-only gating on `main`, but the dedicated validation module is not landed.
|
|
||||||
|
|
||||||
### Bash tool — upstream has 18 submodules, Rust has 1:
|
## 2) hooks/
|
||||||
|
|
||||||
- On `main`, this statement is still materially true.
|
### Upstream TS has
|
||||||
- Harness coverage proves bash execution and prompt escalation flows, but not the full upstream validation matrix.
|
- A full permission and tool-hook system with **PermissionRequest**, **PreToolUse**, **PostToolUse**, and failure/cancellation handling.
|
||||||
- The branch-only lane targets `readOnlyValidation`, `destructiveCommandWarning`, `modeValidation`, `sedValidation`, `pathValidation`, and `commandSemantics`.
|
- Evidence:
|
||||||
|
- `src/hooks/toolPermission/PermissionContext.ts:25,222`
|
||||||
|
- `src/hooks/toolPermission/handlers/coordinatorHandler.ts:32-38`
|
||||||
|
- `src/hooks/toolPermission/handlers/interactiveHandler.ts:412-429`
|
||||||
|
- `src/services/tools/toolHooks.ts:39,435`
|
||||||
|
- `src/services/tools/toolExecution.ts:800,1074,1483`
|
||||||
|
- `src/commands/hooks/index.ts:5-8`
|
||||||
|
|
||||||
### Lane 2 — CI fix
|
### Rust currently has
|
||||||
|
- Hook data is **loaded/merged from config** and visible in reports:
|
||||||
|
- `rust/crates/runtime/src/config.rs:786-797,829-838`
|
||||||
|
- `rust/crates/rusty-claude-cli/src/main.rs:1665-1669`
|
||||||
|
- The system prompt acknowledges user-configured hooks:
|
||||||
|
- `rust/crates/runtime/src/prompt.rs:452-459`
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
### Missing or broken in Rust
|
||||||
- **Feature commit:** `89104eb` — `fix(sandbox): probe unshare capability instead of binary existence`
|
- **No comparable hook execution pipeline found** in the Rust runtime conversation/tool execution path.
|
||||||
- **Merge commit:** `f1969ce` — `Merge jobdori/fix-ci-sandbox: probe unshare capability for CI fix`
|
- `rust/crates/runtime/src/conversation.rs:151-208` goes straight from assistant tool_use -> permission check -> tool execute -> tool_result, without TS-style PreToolUse/PostToolUse processing.
|
||||||
- **Evidence:** `rust/crates/runtime/src/sandbox.rs` is **385 LOC** and now resolves sandbox support from actual `unshare` capability and container signals instead of assuming support from binary presence alone.
|
- I did **not** find Rust counterparts to TS files like `toolHooks.ts` or `PermissionContext.ts` that execute hook callbacks and alter/block tool behavior.
|
||||||
- **Why it matters:** `.github/workflows/rust-ci.yml` runs `cargo fmt --all --check` and `cargo test -p rusty-claude-cli`; this lane removed a CI-specific sandbox assumption from runtime behavior.
|
- Result: Rust appears to support **hook configuration visibility**, but not full **hook behavior parity**.
|
||||||
|
|
||||||
### Lane 3 — File-tool
|
---
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
## 3) plugins/
|
||||||
- **Feature commit:** `284163b` — `feat(file_ops): add edge-case guards — binary detection, size limits, workspace boundary, symlink escape`
|
|
||||||
- **Merge commit:** `a98f2b6` — `Merge jobdori/file-tool-edge-cases: binary detection, size limits, workspace boundary guards`
|
|
||||||
- **Evidence:** `rust/crates/runtime/src/file_ops.rs` is **744 LOC** and now includes `MAX_READ_SIZE`, `MAX_WRITE_SIZE`, NUL-byte binary detection, and canonical workspace-boundary validation.
|
|
||||||
- **Harness coverage:** `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, and `write_file_denied` are in the manifest and exercised by the clean-env harness.
|
|
||||||
|
|
||||||
### File tools — harness-validated flows
|
### Upstream TS has
|
||||||
|
- Built-in and bundled plugin registration plus CLI/service support for validate/list/install/uninstall/enable/disable/update flows.
|
||||||
|
- Evidence:
|
||||||
|
- `src/plugins/builtinPlugins.ts:7-17,149-150`
|
||||||
|
- `src/plugins/bundled/index.ts:7-22`
|
||||||
|
- `src/cli/handlers/plugins.ts:51,101,157,668`
|
||||||
|
- `src/services/plugins/pluginOperations.ts:16,54,306,435,713`
|
||||||
|
- `src/services/plugins/pluginCliCommands.ts:7,36`
|
||||||
|
|
||||||
- `read_file_roundtrip` checks read-path execution and final synthesis.
|
### Rust currently has
|
||||||
- `grep_chunk_assembly` checks chunked grep tool output handling.
|
- I did **not** find a dedicated plugin crate/module/handler under `rust/crates/`.
|
||||||
- `write_file_allowed` and `write_file_denied` validate both write success and permission denial.
|
- The Rust crate layout is only `api`, `commands`, `compat-harness`, `runtime`, `rusty-claude-cli`, and `tools`.
|
||||||
|
|
||||||
### Lane 4 — TaskRegistry
|
### Missing or broken in Rust
|
||||||
|
- **Plugin loading/install/update/validation is missing.**
|
||||||
|
- **No plugin CLI surface found** comparable to `claude plugin ...`.
|
||||||
|
- **No plugin runtime refresh/reconciliation layer found**.
|
||||||
|
- This is one of the largest parity gaps.
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
---
|
||||||
- **Feature commit:** `5ea138e` — `feat(runtime): add TaskRegistry — in-memory task lifecycle management`
|
|
||||||
- **Merge commit:** `21a1e1d` — `Merge jobdori/task-runtime: TaskRegistry in-memory lifecycle management`
|
|
||||||
- **Evidence:** `rust/crates/runtime/src/task_registry.rs` is **335 LOC** and provides `create`, `get`, `list`, `stop`, `update`, `output`, `append_output`, `set_status`, and `assign_team` over a thread-safe in-memory registry.
|
|
||||||
- **Scope:** this lane replaces pure fixed-payload stub state with real runtime-backed task records, but it does not add external subprocess execution by itself.
|
|
||||||
|
|
||||||
### Lane 5 — Task wiring
|
## 4) skills/
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
### Upstream TS has
|
||||||
- **Feature commit:** `e8692e4` — `feat(tools): wire TaskRegistry into task tool dispatch`
|
- Bundled skills registry and loader integration, plus a `skills` command.
|
||||||
- **Merge commit:** `d994be6` — `Merge jobdori/task-registry-wiring: real TaskRegistry backing for all 6 task tools`
|
- Evidence:
|
||||||
- **Evidence:** `rust/crates/tools/src/lib.rs` dispatches `TaskCreate`, `TaskGet`, `TaskList`, `TaskStop`, `TaskUpdate`, and `TaskOutput` through `execute_tool()` and concrete `run_task_*` handlers.
|
- `src/commands/skills/index.ts:6`
|
||||||
- **Current state:** task tools now expose real registry state on `main` via `global_task_registry()`.
|
- `src/skills/bundledSkills.ts:44,99,107,114`
|
||||||
|
- `src/skills/loadSkillsDir.ts:65`
|
||||||
|
- `src/skills/mcpSkillBuilders.ts:4-21,40`
|
||||||
|
|
||||||
### Lane 6 — Team+Cron
|
### Rust currently has
|
||||||
|
- A `Skill` tool that loads local `SKILL.md` files directly:
|
||||||
|
- `rust/crates/tools/src/lib.rs:1244-1255`
|
||||||
|
- `rust/crates/tools/src/lib.rs:1288-1323`
|
||||||
|
- CLAUDE.md / instruction discovery exists in runtime prompt loading:
|
||||||
|
- `rust/crates/runtime/src/prompt.rs:203-208`
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
### Missing or broken in Rust
|
||||||
- **Feature commit:** `c486ca6` — `feat(runtime+tools): TeamRegistry and CronRegistry — replace team/cron stubs`
|
- **No Rust `/skills` slash command** in `rust/crates/commands/src/lib.rs:41-166`.
|
||||||
- **Merge commit:** `49653fe` — `Merge jobdori/team-cron-runtime: TeamRegistry + CronRegistry wired into tool dispatch`
|
- **No visible bundled-skill registry equivalent** to TS `bundledSkills.ts` / `loadSkillsDir.ts` / `mcpSkillBuilders.ts`.
|
||||||
- **Evidence:** `rust/crates/runtime/src/team_cron_registry.rs` is **363 LOC** and adds thread-safe `TeamRegistry` and `CronRegistry`; `rust/crates/tools/src/lib.rs` wires `TeamCreate`, `TeamDelete`, `CronCreate`, `CronDelete`, and `CronList` into those registries.
|
- Current Rust skill support is closer to **direct file loading** than full upstream **skill discovery/registration/command integration**.
|
||||||
- **Current state:** team/cron tools now have in-memory lifecycle behavior on `main`; they still stop short of a real background scheduler or worker fleet.
|
|
||||||
|
|
||||||
### Lane 7 — MCP lifecycle
|
---
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
## 5) cli/
|
||||||
- **Feature commit:** `730667f` — `feat(runtime+tools): McpToolRegistry — MCP lifecycle bridge for tool surface`
|
|
||||||
- **Merge commit:** `cc0f92e` — `Merge jobdori/mcp-lifecycle: McpToolRegistry lifecycle bridge for all MCP tools`
|
|
||||||
- **Evidence:** `rust/crates/runtime/src/mcp_tool_bridge.rs` is **406 LOC** and tracks server connection status, resource listing, resource reads, tool listing, tool dispatch acknowledgements, auth state, and disconnects.
|
|
||||||
- **Wiring:** `rust/crates/tools/src/lib.rs` routes `ListMcpResources`, `ReadMcpResource`, `McpAuth`, and `MCP` into `global_mcp_registry()` handlers.
|
|
||||||
- **Scope:** this lane replaces pure stub responses with a registry bridge on `main`; end-to-end MCP connection population and broader transport/runtime depth still depend on the wider MCP runtime (`mcp_stdio.rs`, `mcp_client.rs`, `mcp.rs`).
|
|
||||||
|
|
||||||
### Lane 8 — LSP client
|
### Upstream TS has
|
||||||
|
- Broad CLI handler and transport surface.
|
||||||
|
- Evidence:
|
||||||
|
- `src/cli/handlers/agents.ts:2-32`
|
||||||
|
- `src/cli/handlers/auth.ts`
|
||||||
|
- `src/cli/handlers/autoMode.ts:24,35,73`
|
||||||
|
- `src/cli/handlers/plugins.ts:2-3,101,157,668`
|
||||||
|
- `src/cli/remoteIO.ts:25-35,118-127`
|
||||||
|
- `src/cli/transports/SSETransport.ts`
|
||||||
|
- `src/cli/transports/WebSocketTransport.ts`
|
||||||
|
- `src/cli/transports/HybridTransport.ts`
|
||||||
|
- `src/cli/transports/SerialBatchEventUploader.ts`
|
||||||
|
- `src/cli/transports/WorkerStateUploader.ts`
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
### Rust currently has
|
||||||
- **Feature commit:** `2d66503` — `feat(runtime+tools): LspRegistry — LSP client dispatch for tool surface`
|
- Minimal top-level subcommands in `rust/crates/rusty-claude-cli/src/args.rs:29-39` and `rust/crates/rusty-claude-cli/src/main.rs:67-90,242-261`.
|
||||||
- **Merge commit:** `d7f0dc6` — `Merge jobdori/lsp-client: LspRegistry dispatch for all LSP tool actions`
|
- Slash command surface is 15 commands total in `rust/crates/commands/src/lib.rs:41-166,389`.
|
||||||
- **Evidence:** `rust/crates/runtime/src/lsp_client.rs` is **438 LOC** and models diagnostics, hover, definition, references, completion, symbols, and formatting across a stateful registry.
|
|
||||||
- **Wiring:** the exposed `LSP` tool schema in `rust/crates/tools/src/lib.rs` currently enumerates `symbols`, `references`, `diagnostics`, `definition`, and `hover`, then routes requests through `registry.dispatch(action, path, line, character, query)`.
|
|
||||||
- **Scope:** current parity is registry/dispatch-level; completion/format support exists in the registry model, but not as clearly exposed at the tool schema boundary, and actual external language-server process orchestration remains separate.
|
|
||||||
|
|
||||||
### Lane 9 — Permission enforcement
|
### Missing or broken in Rust
|
||||||
|
- **Missing major CLI subcommand families**: agents, plugins, mcp management, auto-mode tooling, and many other TS commands.
|
||||||
|
- **Missing remote/transport stack parity**: I did not find Rust equivalents to TS remote structured IO / SSE / websocket / CCR transport layers.
|
||||||
|
- **Slash command breadth is much narrower** than TS command inventory under `src/commands/`.
|
||||||
|
- **Prompt-mode parity bug** was present and is now fixed for this branch’s prompt path.
|
||||||
|
|
||||||
- **Status:** merged on `main`.
|
---
|
||||||
- **Feature commit:** `66283f4` — `feat(runtime+tools): PermissionEnforcer — permission mode enforcement layer`
|
|
||||||
- **Merge commit:** `336f820` — `Merge jobdori/permission-enforcement: PermissionEnforcer with workspace + bash enforcement`
|
|
||||||
- **Evidence:** `rust/crates/runtime/src/permission_enforcer.rs` is **340 LOC** and adds tool gating, file write boundary checks, and bash read-only heuristics on top of `rust/crates/runtime/src/permissions.rs`.
|
|
||||||
- **Wiring:** `rust/crates/tools/src/lib.rs` exposes `enforce_permission_check()` and carries per-tool `required_permission` values in tool specs.
|
|
||||||
|
|
||||||
### Permission enforcement across tool paths
|
## 6) assistant/
|
||||||
|
|
||||||
- Harness scenarios validate `write_file_denied`, `bash_permission_prompt_approved`, and `bash_permission_prompt_denied`.
|
### Upstream TS has
|
||||||
- `PermissionEnforcer::check()` delegates to `PermissionPolicy::authorize()` and returns structured allow/deny results.
|
- Rich tool orchestration and streaming execution behavior, including concurrency/cancellation/fallback logic.
|
||||||
- `check_file_write()` enforces workspace boundaries and read-only denial; `check_bash()` denies mutating commands in read-only mode and blocks prompt-mode bash without confirmation.
|
- Evidence:
|
||||||
|
- `src/services/tools/StreamingToolExecutor.ts:35-214`
|
||||||
|
- `src/services/tools/toolExecution.ts:455-569,800-918,1483`
|
||||||
|
- `src/services/tools/toolOrchestration.ts:134-167`
|
||||||
|
- `src/assistant/sessionHistory.ts`
|
||||||
|
|
||||||
## Tool Surface: 40 exposed tool specs on `main`
|
### Rust currently has
|
||||||
|
- A straightforward agentic loop in `rust/crates/runtime/src/conversation.rs:130-214`.
|
||||||
|
- Streaming API adaptation in `rust/crates/rusty-claude-cli/src/main.rs:1998-2058`.
|
||||||
|
- Tool-use block assembly and non-stream fallback handling in `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`.
|
||||||
|
|
||||||
- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes **40** tool specs.
|
### Missing or broken in Rust
|
||||||
- Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`.
|
- **No TS-style streaming tool executor** with sibling cancellation / fallback discard semantics.
|
||||||
- Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`.
|
- **No integrated PreToolUse/PostToolUse hook participation** in assistant execution.
|
||||||
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`.
|
- **No comparable orchestration layer for richer tool event semantics** found.
|
||||||
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
|
- Historically broken parity items in prompt mode were:
|
||||||
|
- prompt tool enablement (`main.rs:75-82`) — now fixed on this branch
|
||||||
|
- streamed `{}` tool-input prefix behavior (`main.rs:2211-2256`) — now fixed/guarded on this branch
|
||||||
|
|
||||||
### Still limited or intentionally shallow
|
---
|
||||||
|
|
||||||
- `AskUserQuestion` still returns a pending response payload rather than real interactive UI wiring.
|
## 7) services/
|
||||||
- `RemoteTrigger` remains a stub response.
|
|
||||||
- `TestingPermission` remains test-only.
|
|
||||||
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.
|
|
||||||
- Bash deep validation remains branch-only until `36dac6c` is merged.
|
|
||||||
|
|
||||||
## Reconciled from the older PARITY checklist
|
### Upstream TS has
|
||||||
|
- Very broad service layer, including API, analytics, compact/session memory, prompt suggestions, plugin services, MCP service helpers, LSP management, policy limits, team memory sync, notifier/tips, etc.
|
||||||
|
- Evidence:
|
||||||
|
- `src/services/api/client.ts`, `src/services/api/claude.ts`, `src/services/api/withRetry.ts`
|
||||||
|
- `src/services/oauth/client.ts`, `src/services/oauth/index.ts`
|
||||||
|
- `src/services/mcp/*`
|
||||||
|
- `src/services/plugins/*`
|
||||||
|
- `src/services/lsp/*`
|
||||||
|
- `src/services/compact/*`
|
||||||
|
- `src/services/SessionMemory/*`
|
||||||
|
- `src/services/PromptSuggestion/*`
|
||||||
|
- `src/services/analytics/*`
|
||||||
|
- `src/services/teamMemorySync/*`
|
||||||
|
|
||||||
- [x] Path traversal prevention (symlink following, `../` escapes)
|
### Rust currently has
|
||||||
- [x] Size limits on read/write
|
- Core service equivalents for:
|
||||||
- [x] Binary file detection
|
- API client + SSE: `rust/crates/api/src/client.rs`, `rust/crates/api/src/sse.rs`, `rust/crates/api/src/types.rs`
|
||||||
- [x] Permission mode enforcement (read-only vs workspace-write)
|
- OAuth: `rust/crates/runtime/src/oauth.rs`
|
||||||
- [x] Config merge precedence (user > project > local) — `ConfigLoader::discover()` loads user → project → local, and `loads_and_merges_claude_code_config_files_by_precedence()` verifies the merge order.
|
- MCP config/bootstrap primitives: `rust/crates/runtime/src/mcp.rs`, `rust/crates/runtime/src/mcp_client.rs`, `rust/crates/runtime/src/mcp_stdio.rs`, `rust/crates/runtime/src/config.rs`
|
||||||
- [x] Plugin install/enable/disable/uninstall flow — `/plugin` slash handling in `rust/crates/commands/src/lib.rs` delegates to `PluginManager::{install, enable, disable, uninstall}` in `rust/crates/plugins/src/lib.rs`.
|
- prompt/context loading: `rust/crates/runtime/src/prompt.rs`
|
||||||
- [x] No `#[ignore]` tests hiding failures — `grep` over `rust/**/*.rs` found 0 ignored tests.
|
- session compaction/runtime usage: `rust/crates/runtime/src/compact.rs`, `rust/crates/runtime/src/usage.rs`
|
||||||
|
|
||||||
## Still open
|
### Missing or broken in Rust
|
||||||
|
- **Missing many higher-level services**: analytics, plugin services, prompt suggestion, team memory sync, richer LSP service management, notifier/tips ecosystem, and much of the surrounding product/service scaffolding.
|
||||||
|
- Rust is closer to a **runtime/API core** than a full parity implementation of the TS service layer.
|
||||||
|
|
||||||
- [ ] End-to-end MCP runtime lifecycle beyond the registry bridge now on `main`
|
---
|
||||||
- [x] Output truncation (large stdout/file content)
|
|
||||||
- [ ] Session compaction behavior matching
|
|
||||||
- [ ] Token counting / cost tracking accuracy
|
|
||||||
- [x] Bash validation lane merged onto `main`
|
|
||||||
- [ ] CI green on every commit
|
|
||||||
|
|
||||||
## Migration Readiness
|
## Highest-priority parity gaps after the critical bug fixes
|
||||||
|
|
||||||
- [x] `PARITY.md` maintained and honest
|
1. **Hook execution parity**
|
||||||
- [x] 9 requested lanes documented with commit hashes and current status
|
- Config exists, execution does not appear to.
|
||||||
- [x] All 9 requested lanes landed on `main` (`bash-validation` is still branch-only)
|
- This affects permissions, tool interception, and continuation behavior.
|
||||||
- [x] No `#[ignore]` tests hiding failures
|
|
||||||
- [ ] CI green on every commit
|
2. **Plugin system parity**
|
||||||
- [x] Codebase shape clean enough for handoff documentation
|
- Entire install/load/manage surface appears missing.
|
||||||
|
|
||||||
|
3. **CLI breadth parity**
|
||||||
|
- Missing many upstream command families and remote transports.
|
||||||
|
|
||||||
|
4. **Tool surface parity**
|
||||||
|
- MVP tool registry exists, but a large number of upstream tool types are absent.
|
||||||
|
|
||||||
|
5. **Assistant orchestration parity**
|
||||||
|
- Core loop exists, but advanced streaming/execution behaviors from TS are missing.
|
||||||
|
|
||||||
|
## Recommended next work after current critical fixes
|
||||||
|
|
||||||
|
1. Finish build/test/manual verification of the critical bug patch.
|
||||||
|
2. Implement **hook execution** before broadening the tool surface further.
|
||||||
|
3. Decide whether **plugins** are in-scope for parity; if yes, this likely needs dedicated design work, not a small patch.
|
||||||
|
4. Expand the CLI/tool matrix deliberately rather than adding one-off commands without shared orchestration support.
|
||||||
|
|
|
||||||
345
ROADMAP.md
345
ROADMAP.md
|
|
@ -1,345 +0,0 @@
|
||||||
# ROADMAP.md
|
|
||||||
|
|
||||||
# Clawable Coding Harness Roadmap
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Turn claw-code into the most **clawable** coding harness:
|
|
||||||
- no human-first terminal assumptions
|
|
||||||
- no fragile prompt injection timing
|
|
||||||
- no opaque session state
|
|
||||||
- no hidden plugin or MCP failures
|
|
||||||
- no manual babysitting for routine recovery
|
|
||||||
|
|
||||||
This roadmap assumes the primary users are **claws wired through hooks, plugins, sessions, and channel events**.
|
|
||||||
|
|
||||||
## Definition of "clawable"
|
|
||||||
|
|
||||||
A clawable harness is:
|
|
||||||
- deterministic to start
|
|
||||||
- machine-readable in state and failure modes
|
|
||||||
- recoverable without a human watching the terminal
|
|
||||||
- branch/test/worktree aware
|
|
||||||
- plugin/MCP lifecycle aware
|
|
||||||
- event-first, not log-first
|
|
||||||
- capable of autonomous next-step execution
|
|
||||||
|
|
||||||
## Current Pain Points
|
|
||||||
|
|
||||||
### 1. Session boot is fragile
|
|
||||||
- trust prompts can block TUI startup
|
|
||||||
- prompts can land in the shell instead of the coding agent
|
|
||||||
- "session exists" does not mean "session is ready"
|
|
||||||
|
|
||||||
### 2. Truth is split across layers
|
|
||||||
- tmux state
|
|
||||||
- clawhip event stream
|
|
||||||
- git/worktree state
|
|
||||||
- test state
|
|
||||||
- gateway/plugin/MCP runtime state
|
|
||||||
|
|
||||||
### 3. Events are too log-shaped
|
|
||||||
- claws currently infer too much from noisy text
|
|
||||||
- important states are not normalized into machine-readable events
|
|
||||||
|
|
||||||
### 4. Recovery loops are too manual
|
|
||||||
- restart worker
|
|
||||||
- accept trust prompt
|
|
||||||
- re-inject prompt
|
|
||||||
- detect stale branch
|
|
||||||
- retry failed startup
|
|
||||||
- classify infra vs code failures manually
|
|
||||||
|
|
||||||
### 5. Branch freshness is not enforced enough
|
|
||||||
- side branches can miss already-landed main fixes
|
|
||||||
- broad test failures can be stale-branch noise instead of real regressions
|
|
||||||
|
|
||||||
### 6. Plugin/MCP failures are under-classified
|
|
||||||
- startup failures, handshake failures, config errors, partial startup, and degraded mode are not exposed cleanly enough
|
|
||||||
|
|
||||||
### 7. Human UX still leaks into claw workflows
|
|
||||||
- too much depends on terminal/TUI behavior instead of explicit agent state transitions and control APIs
|
|
||||||
|
|
||||||
## Product Principles
|
|
||||||
|
|
||||||
1. **State machine first** — every worker has explicit lifecycle states.
|
|
||||||
2. **Events over scraped prose** — channel output should be derived from typed events.
|
|
||||||
3. **Recovery before escalation** — known failure modes should auto-heal once before asking for help.
|
|
||||||
4. **Branch freshness before blame** — detect stale branches before treating red tests as new regressions.
|
|
||||||
5. **Partial success is first-class** — e.g. MCP startup can succeed for some servers and fail for others, with structured degraded-mode reporting.
|
|
||||||
6. **Terminal is transport, not truth** — tmux/TUI may remain implementation details, but orchestration state must live above them.
|
|
||||||
7. **Policy is executable** — merge, retry, rebase, stale cleanup, and escalation rules should be machine-enforced.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
## Phase 1 — Reliable Worker Boot
|
|
||||||
|
|
||||||
### 1. Ready-handshake lifecycle for coding workers
|
|
||||||
Add explicit states:
|
|
||||||
- `spawning`
|
|
||||||
- `trust_required`
|
|
||||||
- `ready_for_prompt`
|
|
||||||
- `prompt_accepted`
|
|
||||||
- `running`
|
|
||||||
- `blocked`
|
|
||||||
- `finished`
|
|
||||||
- `failed`
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- prompts are never sent before `ready_for_prompt`
|
|
||||||
- trust prompt state is detectable and emitted
|
|
||||||
- shell misdelivery becomes detectable as a first-class failure state
|
|
||||||
|
|
||||||
### 2. Trust prompt resolver
|
|
||||||
Add allowlisted auto-trust behavior for known repos/worktrees.
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- trusted repos auto-clear trust prompts
|
|
||||||
- events emitted for `trust_required` and `trust_resolved`
|
|
||||||
- non-allowlisted repos remain gated
|
|
||||||
|
|
||||||
### 3. Structured session control API
|
|
||||||
Provide machine control above tmux:
|
|
||||||
- create worker
|
|
||||||
- await ready
|
|
||||||
- send task
|
|
||||||
- fetch state
|
|
||||||
- fetch last error
|
|
||||||
- restart worker
|
|
||||||
- terminate worker
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- a claw can operate a coding worker without raw send-keys as the primary control plane
|
|
||||||
|
|
||||||
## Phase 2 — Event-Native Clawhip Integration
|
|
||||||
|
|
||||||
### 4. Canonical lane event schema
|
|
||||||
Define typed events such as:
|
|
||||||
- `lane.started`
|
|
||||||
- `lane.ready`
|
|
||||||
- `lane.prompt_misdelivery`
|
|
||||||
- `lane.blocked`
|
|
||||||
- `lane.red`
|
|
||||||
- `lane.green`
|
|
||||||
- `lane.commit.created`
|
|
||||||
- `lane.pr.opened`
|
|
||||||
- `lane.merge.ready`
|
|
||||||
- `lane.finished`
|
|
||||||
- `lane.failed`
|
|
||||||
- `branch.stale_against_main`
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- clawhip consumes typed lane events
|
|
||||||
- Discord summaries are rendered from structured events instead of pane scraping alone
|
|
||||||
|
|
||||||
### 5. Failure taxonomy
|
|
||||||
Normalize failure classes:
|
|
||||||
- `prompt_delivery`
|
|
||||||
- `trust_gate`
|
|
||||||
- `branch_divergence`
|
|
||||||
- `compile`
|
|
||||||
- `test`
|
|
||||||
- `plugin_startup`
|
|
||||||
- `mcp_startup`
|
|
||||||
- `mcp_handshake`
|
|
||||||
- `gateway_routing`
|
|
||||||
- `tool_runtime`
|
|
||||||
- `infra`
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- blockers are machine-classified
|
|
||||||
- dashboards and retry policies can branch on failure type
|
|
||||||
|
|
||||||
### 6. Actionable summary compression
|
|
||||||
Collapse noisy event streams into:
|
|
||||||
- current phase
|
|
||||||
- last successful checkpoint
|
|
||||||
- current blocker
|
|
||||||
- recommended next recovery action
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- channel status updates stay short and machine-grounded
|
|
||||||
- claws stop inferring state from raw build spam
|
|
||||||
|
|
||||||
## Phase 3 — Branch/Test Awareness and Auto-Recovery
|
|
||||||
|
|
||||||
### 7. Stale-branch detection before broad verification
|
|
||||||
Before broad test runs, compare current branch to `main` and detect if known fixes are missing.
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- emit `branch.stale_against_main`
|
|
||||||
- suggest or auto-run rebase/merge-forward according to policy
|
|
||||||
- avoid misclassifying stale-branch failures as new regressions
|
|
||||||
|
|
||||||
### 8. Recovery recipes for common failures
|
|
||||||
Encode known automatic recoveries for:
|
|
||||||
- trust prompt unresolved
|
|
||||||
- prompt delivered to shell
|
|
||||||
- stale branch
|
|
||||||
- compile red after cross-crate refactor
|
|
||||||
- MCP startup handshake failure
|
|
||||||
- partial plugin startup
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- one automatic recovery attempt occurs before escalation
|
|
||||||
- the attempted recovery is itself emitted as structured event data
|
|
||||||
|
|
||||||
### 9. Green-ness contract
|
|
||||||
Workers should distinguish:
|
|
||||||
- targeted tests green
|
|
||||||
- package green
|
|
||||||
- workspace green
|
|
||||||
- merge-ready green
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- no more ambiguous "tests passed" messaging
|
|
||||||
- merge policy can require the correct green level for the lane type
|
|
||||||
|
|
||||||
## Phase 4 — Claws-First Task Execution
|
|
||||||
|
|
||||||
### 10. Typed task packet format
|
|
||||||
Define a structured task packet with fields like:
|
|
||||||
- objective
|
|
||||||
- scope
|
|
||||||
- repo/worktree
|
|
||||||
- branch policy
|
|
||||||
- acceptance tests
|
|
||||||
- commit policy
|
|
||||||
- reporting contract
|
|
||||||
- escalation policy
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- claws can dispatch work without relying on long natural-language prompt blobs alone
|
|
||||||
- task packets can be logged, retried, and transformed safely
|
|
||||||
|
|
||||||
### 11. Policy engine for autonomous coding
|
|
||||||
Encode automation rules such as:
|
|
||||||
- if green + scoped diff + review passed -> merge to dev
|
|
||||||
- if stale branch -> merge-forward before broad tests
|
|
||||||
- if startup blocked -> recover once, then escalate
|
|
||||||
- if lane completed -> emit closeout and cleanup session
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- doctrine moves from chat instructions into executable rules
|
|
||||||
|
|
||||||
### 12. Claw-native dashboards / lane board
|
|
||||||
Expose a machine-readable board of:
|
|
||||||
- repos
|
|
||||||
- active claws
|
|
||||||
- worktrees
|
|
||||||
- branch freshness
|
|
||||||
- red/green state
|
|
||||||
- current blocker
|
|
||||||
- merge readiness
|
|
||||||
- last meaningful event
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- claws can query status directly
|
|
||||||
- human-facing views become a rendering layer, not the source of truth
|
|
||||||
|
|
||||||
## Phase 5 — Plugin and MCP Lifecycle Maturity
|
|
||||||
|
|
||||||
### 13. First-class plugin/MCP lifecycle contract
|
|
||||||
Each plugin/MCP integration should expose:
|
|
||||||
- config validation contract
|
|
||||||
- startup healthcheck
|
|
||||||
- discovery result
|
|
||||||
- degraded-mode behavior
|
|
||||||
- shutdown/cleanup contract
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- partial-startup and per-server failures are reported structurally
|
|
||||||
- successful servers remain usable even when one server fails
|
|
||||||
|
|
||||||
### 14. MCP end-to-end lifecycle parity
|
|
||||||
Close gaps from:
|
|
||||||
- config load
|
|
||||||
- server registration
|
|
||||||
- spawn/connect
|
|
||||||
- initialize handshake
|
|
||||||
- tool/resource discovery
|
|
||||||
- invocation path
|
|
||||||
- error surfacing
|
|
||||||
- shutdown/cleanup
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
- parity harness and runtime tests cover healthy and degraded startup cases
|
|
||||||
- broken servers are surfaced as structured failures, not opaque warnings
|
|
||||||
|
|
||||||
## Immediate Backlog (from current real pain)
|
|
||||||
|
|
||||||
Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = clawability hardening, P3 = swarm-efficiency improvements.
|
|
||||||
|
|
||||||
**P0 — Fix first (CI reliability)**
|
|
||||||
1. Isolate `render_diff_report` tests into tmpdir — flaky under `cargo test --workspace`; reads real working-tree state; breaks CI during active worktree ops
|
|
||||||
|
|
||||||
**P1 — Next (integration wiring, unblocks verification)**
|
|
||||||
2. Add cross-module integration tests — every Phase 1-2 module has unit tests but no integration test connects adjacent modules; wiring gaps are invisible to CI without these
|
|
||||||
3. Wire lane-completion emitter — `LaneContext::completed` is a passive bool; nothing sets it automatically; need a runtime path from push+green+session-done to policy engine lane-closeout
|
|
||||||
4. Wire `SummaryCompressor` into the lane event pipeline — exported but called nowhere; `LaneEvent` stream never fed through compressor
|
|
||||||
|
|
||||||
**P2 — Clawability hardening (original backlog)**
|
|
||||||
5. Worker readiness handshake + trust resolution
|
|
||||||
6. Prompt misdelivery detection and recovery
|
|
||||||
7. Canonical lane event schema in clawhip
|
|
||||||
8. Failure taxonomy + blocker normalization
|
|
||||||
9. Stale-branch detection before workspace tests
|
|
||||||
10. MCP structured degraded-startup reporting
|
|
||||||
11. Structured task packet format
|
|
||||||
12. Lane board / machine-readable status API
|
|
||||||
|
|
||||||
**P3 — Swarm efficiency**
|
|
||||||
13. Swarm branch-lock protocol — detect same-module/same-branch collision before parallel workers drift into duplicate implementation
|
|
||||||
|
|
||||||
## Suggested Session Split
|
|
||||||
|
|
||||||
### Session A — worker boot protocol
|
|
||||||
Focus:
|
|
||||||
- trust prompt detection
|
|
||||||
- ready-for-prompt handshake
|
|
||||||
- prompt misdelivery detection
|
|
||||||
|
|
||||||
### Session B — clawhip lane events
|
|
||||||
Focus:
|
|
||||||
- canonical lane event schema
|
|
||||||
- failure taxonomy
|
|
||||||
- summary compression
|
|
||||||
|
|
||||||
### Session C — branch/test intelligence
|
|
||||||
Focus:
|
|
||||||
- stale-branch detection
|
|
||||||
- green-level contract
|
|
||||||
- recovery recipes
|
|
||||||
|
|
||||||
### Session D — MCP lifecycle hardening
|
|
||||||
Focus:
|
|
||||||
- startup/handshake reliability
|
|
||||||
- structured failed server reporting
|
|
||||||
- degraded-mode runtime behavior
|
|
||||||
- lifecycle tests/harness coverage
|
|
||||||
|
|
||||||
### Session E — typed task packets + policy engine
|
|
||||||
Focus:
|
|
||||||
- structured task format
|
|
||||||
- retry/merge/escalation rules
|
|
||||||
- autonomous lane closure behavior
|
|
||||||
|
|
||||||
## MVP Success Criteria
|
|
||||||
|
|
||||||
We should consider claw-code materially more clawable when:
|
|
||||||
- a claw can start a worker and know with certainty when it is ready
|
|
||||||
- claws no longer accidentally type tasks into the shell
|
|
||||||
- stale-branch failures are identified before they waste debugging time
|
|
||||||
- clawhip reports machine states, not just tmux prose
|
|
||||||
- MCP/plugin startup failures are classified and surfaced cleanly
|
|
||||||
- a coding lane can self-recover from common startup and branch issues without human babysitting
|
|
||||||
|
|
||||||
## Short Version
|
|
||||||
|
|
||||||
claw-code should evolve from:
|
|
||||||
- a CLI a human can also drive
|
|
||||||
|
|
||||||
to:
|
|
||||||
- a **claw-native execution runtime**
|
|
||||||
- an **event-native orchestration substrate**
|
|
||||||
- a **plugin/hook-first autonomous coding harness**
|
|
||||||
11
rust/Cargo.lock
generated
11
rust/Cargo.lock
generated
|
|
@ -719,15 +719,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mock-anthropic-service"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"api",
|
|
||||||
"serde_json",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nibble_vec"
|
name = "nibble_vec"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -1203,12 +1194,10 @@ dependencies = [
|
||||||
"commands",
|
"commands",
|
||||||
"compat-harness",
|
"compat-harness",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"mock-anthropic-service",
|
|
||||||
"plugins",
|
"plugins",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"runtime",
|
"runtime",
|
||||||
"rustyline",
|
"rustyline",
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"syntect",
|
"syntect",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
# Mock LLM parity harness
|
|
||||||
|
|
||||||
This milestone adds a deterministic Anthropic-compatible mock service plus a reproducible CLI harness for the Rust `claw` binary.
|
|
||||||
|
|
||||||
## Artifacts
|
|
||||||
|
|
||||||
- `crates/mock-anthropic-service/` — mock `/v1/messages` service
|
|
||||||
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — end-to-end clean-environment harness
|
|
||||||
- `scripts/run_mock_parity_harness.sh` — convenience wrapper
|
|
||||||
|
|
||||||
## Scenarios
|
|
||||||
|
|
||||||
The harness runs these scripted scenarios against a fresh workspace and isolated environment variables:
|
|
||||||
|
|
||||||
1. `streaming_text`
|
|
||||||
2. `read_file_roundtrip`
|
|
||||||
3. `grep_chunk_assembly`
|
|
||||||
4. `write_file_allowed`
|
|
||||||
5. `write_file_denied`
|
|
||||||
6. `multi_tool_turn_roundtrip`
|
|
||||||
7. `bash_stdout_roundtrip`
|
|
||||||
8. `bash_permission_prompt_approved`
|
|
||||||
9. `bash_permission_prompt_denied`
|
|
||||||
10. `plugin_tool_roundtrip`
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd rust/
|
|
||||||
./scripts/run_mock_parity_harness.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavioral checklist / parity diff:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd rust/
|
|
||||||
python3 scripts/run_mock_parity_diff.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Scenario-to-PARITY mappings live in `mock_parity_scenarios.json`.
|
|
||||||
|
|
||||||
## Manual mock server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd rust/
|
|
||||||
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
|
|
||||||
```
|
|
||||||
|
|
||||||
The server prints `MOCK_ANTHROPIC_BASE_URL=...`; point `ANTHROPIC_BASE_URL` at that URL and use any non-empty `ANTHROPIC_API_KEY`.
|
|
||||||
148
rust/PARITY.md
148
rust/PARITY.md
|
|
@ -1,148 +0,0 @@
|
||||||
# Parity Status — claw-code Rust Port
|
|
||||||
|
|
||||||
Last updated: 2026-04-03
|
|
||||||
|
|
||||||
## Mock parity harness — milestone 1
|
|
||||||
|
|
||||||
- [x] Deterministic Anthropic-compatible mock service (`rust/crates/mock-anthropic-service`)
|
|
||||||
- [x] Reproducible clean-environment CLI harness (`rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`)
|
|
||||||
- [x] Scripted scenarios: `streaming_text`, `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, `write_file_denied`
|
|
||||||
|
|
||||||
## Mock parity harness — milestone 2 (behavioral expansion)
|
|
||||||
|
|
||||||
- [x] Scripted multi-tool turn coverage: `multi_tool_turn_roundtrip`
|
|
||||||
- [x] Scripted bash coverage: `bash_stdout_roundtrip`
|
|
||||||
- [x] Scripted permission prompt coverage: `bash_permission_prompt_approved`, `bash_permission_prompt_denied`
|
|
||||||
- [x] Scripted plugin-path coverage: `plugin_tool_roundtrip`
|
|
||||||
- [x] Behavioral diff/checklist runner: `rust/scripts/run_mock_parity_diff.py`
|
|
||||||
|
|
||||||
## Harness v2 behavioral checklist
|
|
||||||
|
|
||||||
Canonical scenario map: `rust/mock_parity_scenarios.json`
|
|
||||||
|
|
||||||
- Multi-tool assistant turns
|
|
||||||
- Bash flow roundtrips
|
|
||||||
- Permission enforcement across tool paths
|
|
||||||
- Plugin tool execution path
|
|
||||||
- File tools — harness-validated flows
|
|
||||||
|
|
||||||
## Completed Behavioral Parity Work
|
|
||||||
|
|
||||||
Hashes below come from `git log --oneline`. Merge line counts come from `git show --stat <merge>`.
|
|
||||||
|
|
||||||
| Lane | Status | Feature commit | Merge commit | Diff stat |
|
|
||||||
|------|--------|----------------|--------------|-----------|
|
|
||||||
| Bash validation (9 submodules) | ✅ complete | `36dac6c` | — (`jobdori/bash-validation-submodules`) | `1005 insertions` |
|
|
||||||
| CI fix | ✅ complete | `89104eb` | `f1969ce` | `22 insertions, 1 deletion` |
|
|
||||||
| File-tool edge cases | ✅ complete | `284163b` | `a98f2b6` | `195 insertions, 1 deletion` |
|
|
||||||
| TaskRegistry | ✅ complete | `5ea138e` | `21a1e1d` | `336 insertions` |
|
|
||||||
| Task tool wiring | ✅ complete | `e8692e4` | `d994be6` | `79 insertions, 35 deletions` |
|
|
||||||
| Team + cron runtime | ✅ complete | `c486ca6` | `49653fe` | `441 insertions, 37 deletions` |
|
|
||||||
| MCP lifecycle | ✅ complete | `730667f` | `cc0f92e` | `491 insertions, 24 deletions` |
|
|
||||||
| LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` |
|
|
||||||
| Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` |
|
|
||||||
|
|
||||||
## Tool Surface: 40/40 (spec parity)
|
|
||||||
|
|
||||||
### Real Implementations (behavioral parity — varying depth)
|
|
||||||
|
|
||||||
| Tool | Rust Impl | Behavioral Notes |
|
|
||||||
|------|-----------|-----------------|
|
|
||||||
| **bash** | `runtime::bash` 283 LOC | subprocess exec, timeout, background, sandbox — **strong parity**. 9/9 requested validation submodules are now tracked as complete via `36dac6c`, with on-main sandbox + permission enforcement runtime support |
|
|
||||||
| **read_file** | `runtime::file_ops` | offset/limit read — **good parity** |
|
|
||||||
| **write_file** | `runtime::file_ops` | file create/overwrite — **good parity** |
|
|
||||||
| **edit_file** | `runtime::file_ops` | old/new string replacement — **good parity**. Missing: replace_all was recently added |
|
|
||||||
| **glob_search** | `runtime::file_ops` | glob pattern matching — **good parity** |
|
|
||||||
| **grep_search** | `runtime::file_ops` | ripgrep-style search — **good parity** |
|
|
||||||
| **WebFetch** | `tools` | URL fetch + content extraction — **moderate parity** (need to verify content truncation, redirect handling vs upstream) |
|
|
||||||
| **WebSearch** | `tools` | search query execution — **moderate parity** |
|
|
||||||
| **TodoWrite** | `tools` | todo/note persistence — **moderate parity** |
|
|
||||||
| **Skill** | `tools` | skill discovery/install — **moderate parity** |
|
|
||||||
| **Agent** | `tools` | agent delegation — **moderate parity** |
|
|
||||||
| **TaskCreate** | `runtime::task_registry` + `tools` | in-memory task creation wired into tool dispatch — **good parity** |
|
|
||||||
| **TaskGet** | `runtime::task_registry` + `tools` | task lookup + metadata payload — **good parity** |
|
|
||||||
| **TaskList** | `runtime::task_registry` + `tools` | registry-backed task listing — **good parity** |
|
|
||||||
| **TaskStop** | `runtime::task_registry` + `tools` | terminal-state stop handling — **good parity** |
|
|
||||||
| **TaskUpdate** | `runtime::task_registry` + `tools` | registry-backed message updates — **good parity** |
|
|
||||||
| **TaskOutput** | `runtime::task_registry` + `tools` | output capture retrieval — **good parity** |
|
|
||||||
| **TeamCreate** | `runtime::team_cron_registry` + `tools` | team lifecycle + task assignment — **good parity** |
|
|
||||||
| **TeamDelete** | `runtime::team_cron_registry` + `tools` | team delete lifecycle — **good parity** |
|
|
||||||
| **CronCreate** | `runtime::team_cron_registry` + `tools` | cron entry creation — **good parity** |
|
|
||||||
| **CronDelete** | `runtime::team_cron_registry` + `tools` | cron entry removal — **good parity** |
|
|
||||||
| **CronList** | `runtime::team_cron_registry` + `tools` | registry-backed cron listing — **good parity** |
|
|
||||||
| **LSP** | `runtime::lsp_client` + `tools` | registry + dispatch for diagnostics, hover, definition, references, completion, symbols, formatting — **good parity** |
|
|
||||||
| **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **good parity** |
|
|
||||||
| **ReadMcpResource** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource reads — **good parity** |
|
|
||||||
| **MCP** | `runtime::mcp_tool_bridge` + `tools` | stateful MCP tool invocation bridge — **good parity** |
|
|
||||||
| **ToolSearch** | `tools` | tool discovery — **good parity** |
|
|
||||||
| **NotebookEdit** | `tools` | jupyter notebook cell editing — **moderate parity** |
|
|
||||||
| **Sleep** | `tools` | delay execution — **good parity** |
|
|
||||||
| **SendUserMessage/Brief** | `tools` | user-facing message — **good parity** |
|
|
||||||
| **Config** | `tools` | config inspection — **moderate parity** |
|
|
||||||
| **EnterPlanMode** | `tools` | worktree plan mode toggle — **good parity** |
|
|
||||||
| **ExitPlanMode** | `tools` | worktree plan mode restore — **good parity** |
|
|
||||||
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
|
|
||||||
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
|
|
||||||
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
|
|
||||||
|
|
||||||
### Stubs Only (surface parity, no behavior)
|
|
||||||
|
|
||||||
| Tool | Status | Notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| **AskUserQuestion** | stub | needs live user I/O integration |
|
|
||||||
| **McpAuth** | stub | needs full auth UX beyond the MCP lifecycle bridge |
|
|
||||||
| **RemoteTrigger** | stub | needs HTTP client |
|
|
||||||
| **TestingPermission** | stub | test-only, low priority |
|
|
||||||
|
|
||||||
## Slash Commands: 67/141 upstream entries
|
|
||||||
|
|
||||||
- 27 original specs (pre-today) — all with real handlers
|
|
||||||
- 40 new specs — parse + stub handler ("not yet implemented")
|
|
||||||
- Remaining ~74 upstream entries are internal modules/dialogs/steps, not user `/commands`
|
|
||||||
|
|
||||||
### Behavioral Feature Checkpoints (completed work + remaining gaps)
|
|
||||||
|
|
||||||
**Bash tool — 9/9 requested validation submodules complete:**
|
|
||||||
- [x] `sedValidation` — validate sed commands before execution
|
|
||||||
- [x] `pathValidation` — validate file paths in commands
|
|
||||||
- [x] `readOnlyValidation` — block writes in read-only mode
|
|
||||||
- [x] `destructiveCommandWarning` — warn on rm -rf, etc.
|
|
||||||
- [x] `commandSemantics` — classify command intent
|
|
||||||
- [x] `bashPermissions` — permission gating per command type
|
|
||||||
- [x] `bashSecurity` — security checks
|
|
||||||
- [x] `modeValidation` — validate against current permission mode
|
|
||||||
- [x] `shouldUseSandbox` — sandbox decision logic
|
|
||||||
|
|
||||||
Harness note: milestone 2 validates bash success plus workspace-write escalation approve/deny flows; dedicated validation submodules landed in `36dac6c`, and on-main runtime also carries sandbox + permission enforcement.
|
|
||||||
|
|
||||||
**File tools — completed checkpoint:**
|
|
||||||
- [x] Path traversal prevention (symlink following, ../ escapes)
|
|
||||||
- [x] Size limits on read/write
|
|
||||||
- [x] Binary file detection
|
|
||||||
- [x] Permission mode enforcement (read-only vs workspace-write)
|
|
||||||
|
|
||||||
Harness note: read_file, grep_search, write_file allow/deny, and multi-tool same-turn assembly are now covered by the mock parity harness; file edge cases + permission enforcement landed in `a98f2b6` and `336f820`.
|
|
||||||
|
|
||||||
**Config/Plugin/MCP flows:**
|
|
||||||
- [x] Full MCP server lifecycle (connect, list tools, call tool, disconnect)
|
|
||||||
- [ ] Plugin install/enable/disable/uninstall full flow
|
|
||||||
- [ ] Config merge precedence (user > project > local)
|
|
||||||
|
|
||||||
Harness note: external plugin discovery + execution is now covered via `plugin_tool_roundtrip`; MCP lifecycle landed in `cc0f92e`, while plugin lifecycle + config merge precedence remain open.
|
|
||||||
|
|
||||||
## Runtime Behavioral Gaps
|
|
||||||
|
|
||||||
- [x] Permission enforcement across all tools (read-only, workspace-write, danger-full-access)
|
|
||||||
- [ ] Output truncation (large stdout/file content)
|
|
||||||
- [ ] Session compaction behavior matching
|
|
||||||
- [ ] Token counting / cost tracking accuracy
|
|
||||||
- [x] Streaming response support validated by the mock parity harness
|
|
||||||
|
|
||||||
Harness note: current coverage now includes write-file denial, bash escalation approve/deny, and plugin workspace-write execution paths; permission enforcement landed in `336f820`.
|
|
||||||
|
|
||||||
## Migration Readiness
|
|
||||||
|
|
||||||
- [x] `PARITY.md` maintained and honest
|
|
||||||
- [ ] No `#[ignore]` tests hiding failures (only 1 allowed: `live_stream_smoke_test`)
|
|
||||||
- [ ] CI green on every commit
|
|
||||||
- [ ] Codebase shape clean for handoff
|
|
||||||
|
|
@ -35,41 +35,6 @@ Or authenticate via OAuth:
|
||||||
claw login
|
claw login
|
||||||
```
|
```
|
||||||
|
|
||||||
## Mock parity harness
|
|
||||||
|
|
||||||
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd rust/
|
|
||||||
|
|
||||||
# Run the scripted clean-environment harness
|
|
||||||
./scripts/run_mock_parity_harness.sh
|
|
||||||
|
|
||||||
# Or start the mock service manually for ad hoc CLI runs
|
|
||||||
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
|
|
||||||
```
|
|
||||||
|
|
||||||
Harness coverage:
|
|
||||||
|
|
||||||
- `streaming_text`
|
|
||||||
- `read_file_roundtrip`
|
|
||||||
- `grep_chunk_assembly`
|
|
||||||
- `write_file_allowed`
|
|
||||||
- `write_file_denied`
|
|
||||||
- `multi_tool_turn_roundtrip`
|
|
||||||
- `bash_stdout_roundtrip`
|
|
||||||
- `bash_permission_prompt_approved`
|
|
||||||
- `bash_permission_prompt_denied`
|
|
||||||
- `plugin_tool_roundtrip`
|
|
||||||
|
|
||||||
Primary artifacts:
|
|
||||||
|
|
||||||
- `crates/mock-anthropic-service/` — reusable mock Anthropic-compatible service
|
|
||||||
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — clean-env CLI harness
|
|
||||||
- `scripts/run_mock_parity_harness.sh` — reproducible wrapper
|
|
||||||
- `scripts/run_mock_parity_diff.py` — scenario checklist + PARITY mapping runner
|
|
||||||
- `mock_parity_scenarios.json` — scenario-to-PARITY manifest
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|
|
@ -131,8 +96,6 @@ Commands:
|
||||||
|
|
||||||
## Slash Commands (REPL)
|
## Slash Commands (REPL)
|
||||||
|
|
||||||
Tab completion now expands not just slash command names, but also common workflow arguments like model aliases, permission modes, and recent session IDs.
|
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/help` | Show help |
|
| `/help` | Show help |
|
||||||
|
|
@ -159,7 +122,6 @@ rust/
|
||||||
├── api/ # Anthropic API client + SSE streaming
|
├── api/ # Anthropic API client + SSE streaming
|
||||||
├── commands/ # Shared slash-command registry
|
├── commands/ # Shared slash-command registry
|
||||||
├── compat-harness/ # TS manifest extraction harness
|
├── compat-harness/ # TS manifest extraction harness
|
||||||
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
|
|
||||||
├── runtime/ # Session, config, permissions, MCP, prompts
|
├── runtime/ # Session, config, permissions, MCP, prompts
|
||||||
├── rusty-claude-cli/ # Main CLI binary (`claw`)
|
├── rusty-claude-cli/ # Main CLI binary (`claw`)
|
||||||
└── tools/ # Built-in tool implementations
|
└── tools/ # Built-in tool implementations
|
||||||
|
|
@ -170,7 +132,6 @@ rust/
|
||||||
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer)
|
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer)
|
||||||
- **commands** — Slash command definitions and help text generation
|
- **commands** — Slash command definitions and help text generation
|
||||||
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
|
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
|
||||||
- **mock-anthropic-service** — Deterministic `/v1/messages` mock for CLI parity tests and local harness runs
|
|
||||||
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
|
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
|
||||||
- **rusty-claude-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
|
- **rusty-claude-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
|
||||||
- **tools** — Tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, REPL runtimes
|
- **tools** — Tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, REPL runtimes
|
||||||
|
|
@ -178,7 +139,7 @@ rust/
|
||||||
## Stats
|
## Stats
|
||||||
|
|
||||||
- **~20K lines** of Rust
|
- **~20K lines** of Rust
|
||||||
- **7 crates** in workspace
|
- **6 crates** in workspace
|
||||||
- **Binary name:** `claw`
|
- **Binary name:** `claw`
|
||||||
- **Default model:** `claude-opus-4-6`
|
- **Default model:** `claude-opus-4-6`
|
||||||
- **Default permissions:** `danger-full-access`
|
- **Default permissions:** `danger-full-access`
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,26 @@ use crate::error::ApiError;
|
||||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||||
use crate::providers::anthropic::{self, AnthropicClient, AuthSource};
|
use crate::providers::anthropic::{self, AnthropicClient, AuthSource};
|
||||||
use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig};
|
use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig};
|
||||||
use crate::providers::{self, ProviderKind};
|
use crate::providers::{self, Provider, ProviderKind};
|
||||||
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
|
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
async fn send_via_provider<P: Provider>(
|
||||||
|
provider: &P,
|
||||||
|
request: &MessageRequest,
|
||||||
|
) -> Result<MessageResponse, ApiError> {
|
||||||
|
provider.send_message(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_via_provider<P: Provider>(
|
||||||
|
provider: &P,
|
||||||
|
request: &MessageRequest,
|
||||||
|
) -> Result<P::Stream, ApiError> {
|
||||||
|
provider.stream_message(request).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ProviderClient {
|
pub enum ProviderClient {
|
||||||
Anthropic(AnthropicClient),
|
Anthropic(Box<AnthropicClient>),
|
||||||
Xai(OpenAiCompatClient),
|
Xai(OpenAiCompatClient),
|
||||||
OpenAi(OpenAiCompatClient),
|
OpenAi(OpenAiCompatClient),
|
||||||
}
|
}
|
||||||
|
|
@ -24,10 +37,10 @@ impl ProviderClient {
|
||||||
) -> Result<Self, ApiError> {
|
) -> Result<Self, ApiError> {
|
||||||
let resolved_model = providers::resolve_model_alias(model);
|
let resolved_model = providers::resolve_model_alias(model);
|
||||||
match providers::detect_provider_kind(&resolved_model) {
|
match providers::detect_provider_kind(&resolved_model) {
|
||||||
ProviderKind::Anthropic => Ok(Self::Anthropic(match anthropic_auth {
|
ProviderKind::Anthropic => Ok(Self::Anthropic(Box::new(match anthropic_auth {
|
||||||
Some(auth) => AnthropicClient::from_auth(auth),
|
Some(auth) => AnthropicClient::from_auth(auth),
|
||||||
None => AnthropicClient::from_env()?,
|
None => AnthropicClient::from_env()?,
|
||||||
})),
|
}))),
|
||||||
ProviderKind::Xai => Ok(Self::Xai(OpenAiCompatClient::from_env(
|
ProviderKind::Xai => Ok(Self::Xai(OpenAiCompatClient::from_env(
|
||||||
OpenAiCompatConfig::xai(),
|
OpenAiCompatConfig::xai(),
|
||||||
)?)),
|
)?)),
|
||||||
|
|
@ -49,7 +62,9 @@ impl ProviderClient {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_prompt_cache(self, prompt_cache: PromptCache) -> Self {
|
pub fn with_prompt_cache(self, prompt_cache: PromptCache) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::Anthropic(client) => Self::Anthropic(client.with_prompt_cache(prompt_cache)),
|
Self::Anthropic(client) => {
|
||||||
|
Self::Anthropic(Box::new((*client).with_prompt_cache(prompt_cache)))
|
||||||
|
}
|
||||||
other => other,
|
other => other,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,8 +90,8 @@ impl ProviderClient {
|
||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<MessageResponse, ApiError> {
|
) -> Result<MessageResponse, ApiError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Anthropic(client) => client.send_message(request).await,
|
Self::Anthropic(client) => send_via_provider(client.as_ref(), request).await,
|
||||||
Self::Xai(client) | Self::OpenAi(client) => client.send_message(request).await,
|
Self::Xai(client) | Self::OpenAi(client) => send_via_provider(client, request).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,12 +100,10 @@ impl ProviderClient {
|
||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<MessageStream, ApiError> {
|
) -> Result<MessageStream, ApiError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Anthropic(client) => client
|
Self::Anthropic(client) => stream_via_provider(client.as_ref(), request)
|
||||||
.stream_message(request)
|
|
||||||
.await
|
.await
|
||||||
.map(MessageStream::Anthropic),
|
.map(MessageStream::Anthropic),
|
||||||
Self::Xai(client) | Self::OpenAi(client) => client
|
Self::Xai(client) | Self::OpenAi(client) => stream_via_provider(client, request)
|
||||||
.stream_message(request)
|
|
||||||
.await
|
.await
|
||||||
.map(MessageStream::OpenAiCompat),
|
.map(MessageStream::OpenAiCompat),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,10 +322,7 @@ impl AnthropicClient {
|
||||||
.with_property(
|
.with_property(
|
||||||
"estimated_cost_usd",
|
"estimated_cost_usd",
|
||||||
Value::String(format_usd(
|
Value::String(format_usd(
|
||||||
response
|
response.usage.estimated_cost_usd(&response.model).total_cost_usd(),
|
||||||
.usage
|
|
||||||
.estimated_cost_usd(&response.model)
|
|
||||||
.total_cost_usd(),
|
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ impl OpenAiCompatConfig {
|
||||||
pub struct OpenAiCompatClient {
|
pub struct OpenAiCompatClient {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
config: OpenAiCompatConfig,
|
|
||||||
base_url: String,
|
base_url: String,
|
||||||
max_retries: u32,
|
max_retries: u32,
|
||||||
initial_backoff: Duration,
|
initial_backoff: Duration,
|
||||||
|
|
@ -75,15 +74,11 @@ pub struct OpenAiCompatClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenAiCompatClient {
|
impl OpenAiCompatClient {
|
||||||
const fn config(&self) -> OpenAiCompatConfig {
|
|
||||||
self.config
|
|
||||||
}
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(api_key: impl Into<String>, config: OpenAiCompatConfig) -> Self {
|
pub fn new(api_key: impl Into<String>, config: OpenAiCompatConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
api_key: api_key.into(),
|
api_key: api_key.into(),
|
||||||
config,
|
|
||||||
base_url: read_base_url(config),
|
base_url: read_base_url(config),
|
||||||
max_retries: DEFAULT_MAX_RETRIES,
|
max_retries: DEFAULT_MAX_RETRIES,
|
||||||
initial_backoff: DEFAULT_INITIAL_BACKOFF,
|
initial_backoff: DEFAULT_INITIAL_BACKOFF,
|
||||||
|
|
@ -195,7 +190,7 @@ impl OpenAiCompatClient {
|
||||||
.post(&request_url)
|
.post(&request_url)
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
.bearer_auth(&self.api_key)
|
.bearer_auth(&self.api_key)
|
||||||
.json(&build_chat_completion_request(request, self.config()))
|
.json(&build_chat_completion_request(request))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)
|
.map_err(ApiError::from)
|
||||||
|
|
@ -256,7 +251,7 @@ impl MessageStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.done {
|
if self.done {
|
||||||
self.pending.extend(self.state.finish()?);
|
self.pending.extend(self.state.finish());
|
||||||
if let Some(event) = self.pending.pop_front() {
|
if let Some(event) = self.pending.pop_front() {
|
||||||
return Ok(Some(event));
|
return Ok(Some(event));
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +261,7 @@ impl MessageStream {
|
||||||
match self.response.chunk().await? {
|
match self.response.chunk().await? {
|
||||||
Some(chunk) => {
|
Some(chunk) => {
|
||||||
for parsed in self.parser.push(&chunk)? {
|
for parsed in self.parser.push(&chunk)? {
|
||||||
self.pending.extend(self.state.ingest_chunk(parsed)?);
|
self.pending.extend(self.state.ingest_chunk(parsed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|
@ -301,37 +296,44 @@ impl OpenAiSseParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct StreamState {
|
struct StreamState {
|
||||||
model: String,
|
model: String,
|
||||||
message_started: bool,
|
message: MessageState,
|
||||||
text_started: bool,
|
text: TextState,
|
||||||
text_finished: bool,
|
|
||||||
finished: bool,
|
|
||||||
stop_reason: Option<String>,
|
stop_reason: Option<String>,
|
||||||
usage: Option<Usage>,
|
usage: Option<Usage>,
|
||||||
tool_calls: BTreeMap<u32, ToolCallState>,
|
tool_calls: BTreeMap<u32, ToolCallState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct MessageState {
|
||||||
|
started: bool,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TextState {
|
||||||
|
started: bool,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
fn new(model: String) -> Self {
|
fn new(model: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
model,
|
model,
|
||||||
message_started: false,
|
message: MessageState::default(),
|
||||||
text_started: false,
|
text: TextState::default(),
|
||||||
text_finished: false,
|
|
||||||
finished: false,
|
|
||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
tool_calls: BTreeMap::new(),
|
tool_calls: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ingest_chunk(&mut self, chunk: ChatCompletionChunk) -> Result<Vec<StreamEvent>, ApiError> {
|
fn ingest_chunk(&mut self, chunk: ChatCompletionChunk) -> Vec<StreamEvent> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
if !self.message_started {
|
if !self.message.started {
|
||||||
self.message_started = true;
|
self.message.started = true;
|
||||||
events.push(StreamEvent::MessageStart(MessageStartEvent {
|
events.push(StreamEvent::MessageStart(MessageStartEvent {
|
||||||
message: MessageResponse {
|
message: MessageResponse {
|
||||||
id: chunk.id.clone(),
|
id: chunk.id.clone(),
|
||||||
|
|
@ -363,8 +365,8 @@ impl StreamState {
|
||||||
|
|
||||||
for choice in chunk.choices {
|
for choice in chunk.choices {
|
||||||
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
|
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
|
||||||
if !self.text_started {
|
if !self.text.started {
|
||||||
self.text_started = true;
|
self.text.started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||||
index: 0,
|
index: 0,
|
||||||
content_block: OutputContentBlock::Text {
|
content_block: OutputContentBlock::Text {
|
||||||
|
|
@ -383,7 +385,7 @@ impl StreamState {
|
||||||
state.apply(tool_call);
|
state.apply(tool_call);
|
||||||
let block_index = state.block_index();
|
let block_index = state.block_index();
|
||||||
if !state.started {
|
if !state.started {
|
||||||
if let Some(start_event) = state.start_event()? {
|
if let Some(start_event) = state.start_event() {
|
||||||
state.started = true;
|
state.started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -416,18 +418,18 @@ impl StreamState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(events)
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish(&mut self) -> Result<Vec<StreamEvent>, ApiError> {
|
fn finish(&mut self) -> Vec<StreamEvent> {
|
||||||
if self.finished {
|
if self.message.finished {
|
||||||
return Ok(Vec::new());
|
return Vec::new();
|
||||||
}
|
}
|
||||||
self.finished = true;
|
self.message.finished = true;
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
if self.text_started && !self.text_finished {
|
if self.text.started && !self.text.finished {
|
||||||
self.text_finished = true;
|
self.text.finished = true;
|
||||||
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
|
||||||
index: 0,
|
index: 0,
|
||||||
}));
|
}));
|
||||||
|
|
@ -435,7 +437,7 @@ impl StreamState {
|
||||||
|
|
||||||
for state in self.tool_calls.values_mut() {
|
for state in self.tool_calls.values_mut() {
|
||||||
if !state.started {
|
if !state.started {
|
||||||
if let Some(start_event) = state.start_event()? {
|
if let Some(start_event) = state.start_event() {
|
||||||
state.started = true;
|
state.started = true;
|
||||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||||
if let Some(delta_event) = state.delta_event() {
|
if let Some(delta_event) = state.delta_event() {
|
||||||
|
|
@ -451,7 +453,7 @@ impl StreamState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.message_started {
|
if self.message.started {
|
||||||
events.push(StreamEvent::MessageDelta(MessageDeltaEvent {
|
events.push(StreamEvent::MessageDelta(MessageDeltaEvent {
|
||||||
delta: MessageDelta {
|
delta: MessageDelta {
|
||||||
stop_reason: Some(
|
stop_reason: Some(
|
||||||
|
|
@ -470,7 +472,7 @@ impl StreamState {
|
||||||
}));
|
}));
|
||||||
events.push(StreamEvent::MessageStop(MessageStopEvent {}));
|
events.push(StreamEvent::MessageStop(MessageStopEvent {}));
|
||||||
}
|
}
|
||||||
Ok(events)
|
events
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -503,23 +505,20 @@ impl ToolCallState {
|
||||||
self.openai_index + 1
|
self.openai_index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
fn start_event(&self) -> Option<ContentBlockStartEvent> {
|
||||||
fn start_event(&self) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
let name = self.name.clone()?;
|
||||||
let Some(name) = self.name.clone() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let id = self
|
let id = self
|
||||||
.id
|
.id
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
||||||
Ok(Some(ContentBlockStartEvent {
|
Some(ContentBlockStartEvent {
|
||||||
index: self.block_index(),
|
index: self.block_index(),
|
||||||
content_block: OutputContentBlock::ToolUse {
|
content_block: OutputContentBlock::ToolUse {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
input: json!({}),
|
input: json!({}),
|
||||||
},
|
},
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delta_event(&mut self) -> Option<ContentBlockDeltaEvent> {
|
fn delta_event(&mut self) -> Option<ContentBlockDeltaEvent> {
|
||||||
|
|
@ -638,7 +637,7 @@ struct ErrorBody {
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
|
fn build_chat_completion_request(request: &MessageRequest) -> Value {
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
|
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
|
||||||
messages.push(json!({
|
messages.push(json!({
|
||||||
|
|
@ -657,10 +656,6 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
|
||||||
"stream": request.stream,
|
"stream": request.stream,
|
||||||
});
|
});
|
||||||
|
|
||||||
if request.stream && should_request_stream_usage(config) {
|
|
||||||
payload["stream_options"] = json!({ "include_usage": true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tools) = &request.tools {
|
if let Some(tools) = &request.tools {
|
||||||
payload["tools"] =
|
payload["tools"] =
|
||||||
Value::Array(tools.iter().map(openai_tool_definition).collect::<Vec<_>>());
|
Value::Array(tools.iter().map(openai_tool_definition).collect::<Vec<_>>());
|
||||||
|
|
@ -758,10 +753,6 @@ fn openai_tool_choice(tool_choice: &ToolChoice) -> Value {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_request_stream_usage(config: OpenAiCompatConfig) -> bool {
|
|
||||||
matches!(config.provider_name, "OpenAI")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_response(
|
fn normalize_response(
|
||||||
model: &str,
|
model: &str,
|
||||||
response: ChatCompletionResponse,
|
response: ChatCompletionResponse,
|
||||||
|
|
@ -964,8 +955,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn request_translation_uses_openai_compatible_shape() {
|
fn request_translation_uses_openai_compatible_shape() {
|
||||||
let payload = build_chat_completion_request(
|
let payload = build_chat_completion_request(&MessageRequest {
|
||||||
&MessageRequest {
|
|
||||||
model: "grok-3".to_string(),
|
model: "grok-3".to_string(),
|
||||||
max_tokens: 64,
|
max_tokens: 64,
|
||||||
messages: vec![InputMessage {
|
messages: vec![InputMessage {
|
||||||
|
|
@ -991,9 +981,7 @@ mod tests {
|
||||||
}]),
|
}]),
|
||||||
tool_choice: Some(ToolChoice::Auto),
|
tool_choice: Some(ToolChoice::Auto),
|
||||||
stream: false,
|
stream: false,
|
||||||
},
|
});
|
||||||
OpenAiCompatConfig::xai(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(payload["messages"][0]["role"], json!("system"));
|
assert_eq!(payload["messages"][0]["role"], json!("system"));
|
||||||
assert_eq!(payload["messages"][1]["role"], json!("user"));
|
assert_eq!(payload["messages"][1]["role"], json!("user"));
|
||||||
|
|
@ -1002,42 +990,6 @@ mod tests {
|
||||||
assert_eq!(payload["tool_choice"], json!("auto"));
|
assert_eq!(payload["tool_choice"], json!("auto"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn openai_streaming_requests_include_usage_opt_in() {
|
|
||||||
let payload = build_chat_completion_request(
|
|
||||||
&MessageRequest {
|
|
||||||
model: "gpt-5".to_string(),
|
|
||||||
max_tokens: 64,
|
|
||||||
messages: vec![InputMessage::user_text("hello")],
|
|
||||||
system: None,
|
|
||||||
tools: None,
|
|
||||||
tool_choice: None,
|
|
||||||
stream: true,
|
|
||||||
},
|
|
||||||
OpenAiCompatConfig::openai(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(payload["stream_options"], json!({"include_usage": true}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn xai_streaming_requests_skip_openai_specific_usage_opt_in() {
|
|
||||||
let payload = build_chat_completion_request(
|
|
||||||
&MessageRequest {
|
|
||||||
model: "grok-3".to_string(),
|
|
||||||
max_tokens: 64,
|
|
||||||
messages: vec![InputMessage::user_text("hello")],
|
|
||||||
system: None,
|
|
||||||
tools: None,
|
|
||||||
tool_choice: None,
|
|
||||||
stream: true,
|
|
||||||
},
|
|
||||||
OpenAiCompatConfig::xai(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(payload.get("stream_options").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_choice_translation_supports_required_function() {
|
fn tool_choice_translation_supports_required_function() {
|
||||||
assert_eq!(openai_tool_choice(&ToolChoice::Any), json!("required"));
|
assert_eq!(openai_tool_choice(&ToolChoice::Any), json!("required"));
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,7 @@ async fn provider_client_dispatches_anthropic_requests() {
|
||||||
.expect("anthropic provider client should be constructed");
|
.expect("anthropic provider client should be constructed");
|
||||||
let client = match client {
|
let client = match client {
|
||||||
ProviderClient::Anthropic(client) => {
|
ProviderClient::Anthropic(client) => {
|
||||||
ProviderClient::Anthropic(client.with_base_url(server.base_url()))
|
ProviderClient::Anthropic(Box::new((*client).with_base_url(server.base_url())))
|
||||||
}
|
}
|
||||||
other => panic!("expected anthropic provider, got {other:?}"),
|
other => panic!("expected anthropic provider, got {other:?}"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ use std::sync::{Mutex as StdMutex, OnceLock};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||||
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
|
InputContentBlock, InputMessage, MessageRequest, OpenAiCompatClient, OpenAiCompatConfig,
|
||||||
OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent, ToolChoice,
|
OutputContentBlock, ProviderClient, StreamEvent, ToolChoice, ToolDefinition,
|
||||||
ToolDefinition,
|
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
@ -196,83 +195,6 @@ async fn stream_message_normalizes_text_and_multiple_tool_calls() {
|
||||||
assert!(request.body.contains("\"stream\":true"));
|
assert!(request.body.contains("\"stream\":true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::await_holding_lock)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn openai_streaming_requests_opt_into_usage_chunks() {
|
|
||||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
|
||||||
let sse = concat!(
|
|
||||||
"data: {\"id\":\"chatcmpl_openai_stream\",\"model\":\"gpt-5\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n",
|
|
||||||
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
|
||||||
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}\n\n",
|
|
||||||
"data: [DONE]\n\n"
|
|
||||||
);
|
|
||||||
let server = spawn_server(
|
|
||||||
state.clone(),
|
|
||||||
vec![http_response_with_headers(
|
|
||||||
"200 OK",
|
|
||||||
"text/event-stream",
|
|
||||||
sse,
|
|
||||||
&[("x-request-id", "req_openai_stream")],
|
|
||||||
)],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
|
||||||
.with_base_url(server.base_url());
|
|
||||||
let mut stream = client
|
|
||||||
.stream_message(&sample_request(false))
|
|
||||||
.await
|
|
||||||
.expect("stream should start");
|
|
||||||
|
|
||||||
assert_eq!(stream.request_id(), Some("req_openai_stream"));
|
|
||||||
|
|
||||||
let mut events = Vec::new();
|
|
||||||
while let Some(event) = stream.next_event().await.expect("event should parse") {
|
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
|
|
||||||
assert!(matches!(
|
|
||||||
events[1],
|
|
||||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
|
||||||
content_block: OutputContentBlock::Text { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
events[2],
|
|
||||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
|
||||||
delta: ContentBlockDelta::TextDelta { .. },
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
events[3],
|
|
||||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
events[4],
|
|
||||||
StreamEvent::MessageDelta(MessageDeltaEvent { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(events[5], StreamEvent::MessageStop(_)));
|
|
||||||
|
|
||||||
match &events[4] {
|
|
||||||
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
|
|
||||||
assert_eq!(usage.input_tokens, 9);
|
|
||||||
assert_eq!(usage.output_tokens, 4);
|
|
||||||
}
|
|
||||||
other => panic!("expected message delta, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let captured = state.lock().await;
|
|
||||||
let request = captured.first().expect("captured request");
|
|
||||||
assert_eq!(request.path, "/chat/completions");
|
|
||||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
|
||||||
assert_eq!(body["stream"], json!(true));
|
|
||||||
assert_eq!(body["stream_options"], json!({"include_usage": true}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::await_holding_lock)]
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn provider_client_dispatches_xai_requests_from_env() {
|
async fn provider_client_dispatches_xai_requests_from_env() {
|
||||||
let _lock = env_lock();
|
let _lock = env_lock();
|
||||||
|
|
@ -467,7 +389,7 @@ fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
|
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
|
||||||
LOCK.get_or_init(|| StdMutex::new(()))
|
LOCK.get_or_init(|| StdMutex::new(()))
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ScopedEnvVar {
|
struct ScopedEnvVar {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
LOCK.get_or_init(|| Mutex::new(()))
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EnvVarGuard {
|
struct EnvVarGuard {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "mock-anthropic-service"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "mock-anthropic-service"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
api = { path = "../api" }
|
|
||||||
serde_json.workspace = true
|
|
||||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync"] }
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,34 +0,0 @@
|
||||||
use std::env;
|
|
||||||
|
|
||||||
use mock_anthropic_service::MockAnthropicService;
|
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let mut bind_addr = String::from("127.0.0.1:0");
|
|
||||||
let mut args = env::args().skip(1);
|
|
||||||
while let Some(arg) = args.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--bind" => {
|
|
||||||
bind_addr = args
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| "missing value for --bind".to_string())?;
|
|
||||||
}
|
|
||||||
flag if flag.starts_with("--bind=") => {
|
|
||||||
bind_addr = flag[7..].to_string();
|
|
||||||
}
|
|
||||||
"--help" | "-h" => {
|
|
||||||
println!("Usage: mock-anthropic-service [--bind HOST:PORT]");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
return Err(format!("unsupported argument: {other}").into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let server = MockAnthropicService::spawn_on(&bind_addr).await?;
|
|
||||||
println!("MOCK_ANTHROPIC_BASE_URL={}", server.base_url());
|
|
||||||
tokio::signal::ctrl_c().await?;
|
|
||||||
drop(server);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ use crate::{PluginError, PluginHooks, PluginRegistry};
|
||||||
pub enum HookEvent {
|
pub enum HookEvent {
|
||||||
PreToolUse,
|
PreToolUse,
|
||||||
PostToolUse,
|
PostToolUse,
|
||||||
PostToolUseFailure,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HookEvent {
|
impl HookEvent {
|
||||||
|
|
@ -18,7 +17,6 @@ impl HookEvent {
|
||||||
match self {
|
match self {
|
||||||
Self::PreToolUse => "PreToolUse",
|
Self::PreToolUse => "PreToolUse",
|
||||||
Self::PostToolUse => "PostToolUse",
|
Self::PostToolUse => "PostToolUse",
|
||||||
Self::PostToolUseFailure => "PostToolUseFailure",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +24,6 @@ impl HookEvent {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct HookRunResult {
|
pub struct HookRunResult {
|
||||||
denied: bool,
|
denied: bool,
|
||||||
failed: bool,
|
|
||||||
messages: Vec<String>,
|
messages: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +32,6 @@ impl HookRunResult {
|
||||||
pub fn allow(messages: Vec<String>) -> Self {
|
pub fn allow(messages: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
denied: false,
|
denied: false,
|
||||||
failed: false,
|
|
||||||
messages,
|
messages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,11 +41,6 @@ impl HookRunResult {
|
||||||
self.denied
|
self.denied
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_failed(&self) -> bool {
|
|
||||||
self.failed
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn messages(&self) -> &[String] {
|
pub fn messages(&self) -> &[String] {
|
||||||
&self.messages
|
&self.messages
|
||||||
|
|
@ -73,7 +64,7 @@ impl HookRunner {
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
|
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
|
||||||
Self::run_commands(
|
self.run_commands(
|
||||||
HookEvent::PreToolUse,
|
HookEvent::PreToolUse,
|
||||||
&self.hooks.pre_tool_use,
|
&self.hooks.pre_tool_use,
|
||||||
tool_name,
|
tool_name,
|
||||||
|
|
@ -91,7 +82,7 @@ impl HookRunner {
|
||||||
tool_output: &str,
|
tool_output: &str,
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
) -> HookRunResult {
|
) -> HookRunResult {
|
||||||
Self::run_commands(
|
self.run_commands(
|
||||||
HookEvent::PostToolUse,
|
HookEvent::PostToolUse,
|
||||||
&self.hooks.post_tool_use,
|
&self.hooks.post_tool_use,
|
||||||
tool_name,
|
tool_name,
|
||||||
|
|
@ -101,24 +92,8 @@ impl HookRunner {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn run_post_tool_use_failure(
|
|
||||||
&self,
|
|
||||||
tool_name: &str,
|
|
||||||
tool_input: &str,
|
|
||||||
tool_error: &str,
|
|
||||||
) -> HookRunResult {
|
|
||||||
Self::run_commands(
|
|
||||||
HookEvent::PostToolUseFailure,
|
|
||||||
&self.hooks.post_tool_use_failure,
|
|
||||||
tool_name,
|
|
||||||
tool_input,
|
|
||||||
Some(tool_error),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_commands(
|
fn run_commands(
|
||||||
|
&self,
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
commands: &[String],
|
commands: &[String],
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
|
|
@ -130,12 +105,20 @@ impl HookRunner {
|
||||||
return HookRunResult::allow(Vec::new());
|
return HookRunResult::allow(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
|
let payload = json!({
|
||||||
|
"hook_event_name": event.as_str(),
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_input": parse_tool_input(tool_input),
|
||||||
|
"tool_input_json": tool_input,
|
||||||
|
"tool_output": tool_output,
|
||||||
|
"tool_result_is_error": is_error,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
for command in commands {
|
for command in commands {
|
||||||
match Self::run_command(
|
match self.run_command(
|
||||||
command,
|
command,
|
||||||
event,
|
event,
|
||||||
tool_name,
|
tool_name,
|
||||||
|
|
@ -155,26 +138,19 @@ impl HookRunner {
|
||||||
}));
|
}));
|
||||||
return HookRunResult {
|
return HookRunResult {
|
||||||
denied: true,
|
denied: true,
|
||||||
failed: false,
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
HookCommandOutcome::Failed { message } => {
|
|
||||||
messages.push(message);
|
|
||||||
return HookRunResult {
|
|
||||||
denied: false,
|
|
||||||
failed: true,
|
|
||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
HookCommandOutcome::Warn { message } => messages.push(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HookRunResult::allow(messages)
|
HookRunResult::allow(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments, clippy::unused_self)]
|
||||||
fn run_command(
|
fn run_command(
|
||||||
|
&self,
|
||||||
command: &str,
|
command: &str,
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
|
|
@ -203,7 +179,7 @@ impl HookRunner {
|
||||||
match output.status.code() {
|
match output.status.code() {
|
||||||
Some(0) => HookCommandOutcome::Allow { message },
|
Some(0) => HookCommandOutcome::Allow { message },
|
||||||
Some(2) => HookCommandOutcome::Deny { message },
|
Some(2) => HookCommandOutcome::Deny { message },
|
||||||
Some(code) => HookCommandOutcome::Failed {
|
Some(code) => HookCommandOutcome::Warn {
|
||||||
message: format_hook_warning(
|
message: format_hook_warning(
|
||||||
command,
|
command,
|
||||||
code,
|
code,
|
||||||
|
|
@ -211,7 +187,7 @@ impl HookRunner {
|
||||||
stderr.as_str(),
|
stderr.as_str(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
None => HookCommandOutcome::Failed {
|
None => HookCommandOutcome::Warn {
|
||||||
message: format!(
|
message: format!(
|
||||||
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
|
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
|
||||||
event.as_str()
|
event.as_str()
|
||||||
|
|
@ -219,7 +195,7 @@ impl HookRunner {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => HookCommandOutcome::Failed {
|
Err(error) => HookCommandOutcome::Warn {
|
||||||
message: format!(
|
message: format!(
|
||||||
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
|
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
|
||||||
event.as_str()
|
event.as_str()
|
||||||
|
|
@ -232,34 +208,7 @@ impl HookRunner {
|
||||||
enum HookCommandOutcome {
|
enum HookCommandOutcome {
|
||||||
Allow { message: Option<String> },
|
Allow { message: Option<String> },
|
||||||
Deny { message: Option<String> },
|
Deny { message: Option<String> },
|
||||||
Failed { message: String },
|
Warn { message: String },
|
||||||
}
|
|
||||||
|
|
||||||
fn hook_payload(
|
|
||||||
event: HookEvent,
|
|
||||||
tool_name: &str,
|
|
||||||
tool_input: &str,
|
|
||||||
tool_output: Option<&str>,
|
|
||||||
is_error: bool,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
match event {
|
|
||||||
HookEvent::PostToolUseFailure => json!({
|
|
||||||
"hook_event_name": event.as_str(),
|
|
||||||
"tool_name": tool_name,
|
|
||||||
"tool_input": parse_tool_input(tool_input),
|
|
||||||
"tool_input_json": tool_input,
|
|
||||||
"tool_error": tool_output,
|
|
||||||
"tool_result_is_error": true,
|
|
||||||
}),
|
|
||||||
_ => json!({
|
|
||||||
"hook_event_name": event.as_str(),
|
|
||||||
"tool_name": tool_name,
|
|
||||||
"tool_input": parse_tool_input(tool_input),
|
|
||||||
"tool_input_json": tool_input,
|
|
||||||
"tool_output": tool_output,
|
|
||||||
"tool_result_is_error": is_error,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
|
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
|
||||||
|
|
@ -267,7 +216,8 @@ fn parse_tool_input(tool_input: &str) -> serde_json::Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
||||||
let mut message = format!("Hook `{command}` exited with status {code}");
|
let mut message =
|
||||||
|
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
|
||||||
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
||||||
message.push_str(": ");
|
message.push_str(": ");
|
||||||
message.push_str(stdout);
|
message.push_str(stdout);
|
||||||
|
|
@ -359,13 +309,7 @@ mod tests {
|
||||||
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
|
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_hook_plugin(
|
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
|
||||||
root: &Path,
|
|
||||||
name: &str,
|
|
||||||
pre_message: &str,
|
|
||||||
post_message: &str,
|
|
||||||
failure_message: &str,
|
|
||||||
) {
|
|
||||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||||
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
|
|
@ -378,15 +322,10 @@ mod tests {
|
||||||
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
|
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
|
||||||
)
|
)
|
||||||
.expect("write post hook");
|
.expect("write post hook");
|
||||||
fs::write(
|
|
||||||
root.join("hooks").join("failure.sh"),
|
|
||||||
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
|
|
||||||
)
|
|
||||||
.expect("write failure hook");
|
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join(".claude-plugin").join("plugin.json"),
|
root.join(".claude-plugin").join("plugin.json"),
|
||||||
format!(
|
format!(
|
||||||
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.expect("write plugin manifest");
|
.expect("write plugin manifest");
|
||||||
|
|
@ -394,7 +333,6 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collects_and_runs_hooks_from_enabled_plugins() {
|
fn collects_and_runs_hooks_from_enabled_plugins() {
|
||||||
// given
|
|
||||||
let config_home = temp_dir("config");
|
let config_home = temp_dir("config");
|
||||||
let first_source_root = temp_dir("source-a");
|
let first_source_root = temp_dir("source-a");
|
||||||
let second_source_root = temp_dir("source-b");
|
let second_source_root = temp_dir("source-b");
|
||||||
|
|
@ -403,14 +341,12 @@ mod tests {
|
||||||
"first",
|
"first",
|
||||||
"plugin pre one",
|
"plugin pre one",
|
||||||
"plugin post one",
|
"plugin post one",
|
||||||
"plugin failure one",
|
|
||||||
);
|
);
|
||||||
write_hook_plugin(
|
write_hook_plugin(
|
||||||
&second_source_root,
|
&second_source_root,
|
||||||
"second",
|
"second",
|
||||||
"plugin pre two",
|
"plugin pre two",
|
||||||
"plugin post two",
|
"plugin post two",
|
||||||
"plugin failure two",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
|
@ -422,10 +358,8 @@ mod tests {
|
||||||
.expect("second plugin install should succeed");
|
.expect("second plugin install should succeed");
|
||||||
let registry = manager.plugin_registry().expect("registry should build");
|
let registry = manager.plugin_registry().expect("registry should build");
|
||||||
|
|
||||||
// when
|
|
||||||
let runner = HookRunner::from_registry(®istry).expect("plugin hooks should load");
|
let runner = HookRunner::from_registry(®istry).expect("plugin hooks should load");
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
|
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
|
||||||
HookRunResult::allow(vec![
|
HookRunResult::allow(vec![
|
||||||
|
|
@ -440,13 +374,6 @@ mod tests {
|
||||||
"plugin post two".to_string(),
|
"plugin post two".to_string(),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "tool failed",),
|
|
||||||
HookRunResult::allow(vec![
|
|
||||||
"plugin failure one".to_string(),
|
|
||||||
"plugin failure two".to_string(),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
let _ = fs::remove_dir_all(first_source_root);
|
let _ = fs::remove_dir_all(first_source_root);
|
||||||
|
|
@ -455,45 +382,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
|
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
|
||||||
// given
|
|
||||||
let runner = HookRunner::new(crate::PluginHooks {
|
let runner = HookRunner::new(crate::PluginHooks {
|
||||||
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
|
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
|
||||||
post_tool_use: Vec::new(),
|
post_tool_use: Vec::new(),
|
||||||
post_tool_use_failure: Vec::new(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// when
|
|
||||||
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(result.is_denied());
|
assert!(result.is_denied());
|
||||||
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
|
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn propagates_plugin_hook_failures() {
|
|
||||||
// given
|
|
||||||
let runner = HookRunner::new(crate::PluginHooks {
|
|
||||||
pre_tool_use: vec![
|
|
||||||
"printf 'broken plugin hook'; exit 1".to_string(),
|
|
||||||
"printf 'later plugin hook'".to_string(),
|
|
||||||
],
|
|
||||||
post_tool_use: Vec::new(),
|
|
||||||
post_tool_use_failure: Vec::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(result.is_failed());
|
|
||||||
assert!(result
|
|
||||||
.messages()
|
|
||||||
.iter()
|
|
||||||
.any(|message| message.contains("broken plugin hook")));
|
|
||||||
assert!(!result
|
|
||||||
.messages()
|
|
||||||
.iter()
|
|
||||||
.any(|message| message == "later plugin hook"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,12 @@ pub struct PluginHooks {
|
||||||
pub pre_tool_use: Vec<String>,
|
pub pre_tool_use: Vec<String>,
|
||||||
#[serde(rename = "PostToolUse", default)]
|
#[serde(rename = "PostToolUse", default)]
|
||||||
pub post_tool_use: Vec<String>,
|
pub post_tool_use: Vec<String>,
|
||||||
#[serde(rename = "PostToolUseFailure", default)]
|
|
||||||
pub post_tool_use_failure: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginHooks {
|
impl PluginHooks {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.pre_tool_use.is_empty()
|
self.pre_tool_use.is_empty() && self.post_tool_use.is_empty()
|
||||||
&& self.post_tool_use.is_empty()
|
|
||||||
&& self.post_tool_use_failure.is_empty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -89,9 +85,6 @@ impl PluginHooks {
|
||||||
.post_tool_use
|
.post_tool_use
|
||||||
.extend(other.post_tool_use.iter().cloned());
|
.extend(other.post_tool_use.iter().cloned());
|
||||||
merged
|
merged
|
||||||
.post_tool_use_failure
|
|
||||||
.extend(other.post_tool_use_failure.iter().cloned());
|
|
||||||
merged
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,106 +648,6 @@ pub struct PluginSummary {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PluginLoadFailure {
|
|
||||||
pub plugin_root: PathBuf,
|
|
||||||
pub kind: PluginKind,
|
|
||||||
pub source: String,
|
|
||||||
error: Box<PluginError>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginLoadFailure {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(plugin_root: PathBuf, kind: PluginKind, source: String, error: PluginError) -> Self {
|
|
||||||
Self {
|
|
||||||
plugin_root,
|
|
||||||
kind,
|
|
||||||
source,
|
|
||||||
error: Box::new(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn error(&self) -> &PluginError {
|
|
||||||
self.error.as_ref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for PluginLoadFailure {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"failed to load {} plugin from `{}` (source: {}): {}",
|
|
||||||
self.kind,
|
|
||||||
self.plugin_root.display(),
|
|
||||||
self.source,
|
|
||||||
self.error()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PluginRegistryReport {
|
|
||||||
registry: PluginRegistry,
|
|
||||||
failures: Vec<PluginLoadFailure>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginRegistryReport {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(registry: PluginRegistry, failures: Vec<PluginLoadFailure>) -> Self {
|
|
||||||
Self { registry, failures }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn registry(&self) -> &PluginRegistry {
|
|
||||||
&self.registry
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn failures(&self) -> &[PluginLoadFailure] {
|
|
||||||
&self.failures
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn has_failures(&self) -> bool {
|
|
||||||
!self.failures.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn summaries(&self) -> Vec<PluginSummary> {
|
|
||||||
self.registry.summaries()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_registry(self) -> Result<PluginRegistry, PluginError> {
|
|
||||||
if self.failures.is_empty() {
|
|
||||||
Ok(self.registry)
|
|
||||||
} else {
|
|
||||||
Err(PluginError::LoadFailures(self.failures))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct PluginDiscovery {
|
|
||||||
plugins: Vec<PluginDefinition>,
|
|
||||||
failures: Vec<PluginLoadFailure>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginDiscovery {
|
|
||||||
fn push_plugin(&mut self, plugin: PluginDefinition) {
|
|
||||||
self.plugins.push(plugin);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_failure(&mut self, failure: PluginLoadFailure) {
|
|
||||||
self.failures.push(failure);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extend(&mut self, other: Self) {
|
|
||||||
self.plugins.extend(other.plugins);
|
|
||||||
self.failures.extend(other.failures);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct PluginRegistry {
|
pub struct PluginRegistry {
|
||||||
plugins: Vec<RegisteredPlugin>,
|
plugins: Vec<RegisteredPlugin>,
|
||||||
|
|
@ -909,10 +802,6 @@ pub enum PluginManifestValidationError {
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
PathIsDirectory {
|
|
||||||
kind: &'static str,
|
|
||||||
path: PathBuf,
|
|
||||||
},
|
|
||||||
InvalidToolInputSchema {
|
InvalidToolInputSchema {
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
},
|
},
|
||||||
|
|
@ -949,9 +838,6 @@ impl Display for PluginManifestValidationError {
|
||||||
Self::MissingPath { kind, path } => {
|
Self::MissingPath { kind, path } => {
|
||||||
write!(f, "{kind} path `{}` does not exist", path.display())
|
write!(f, "{kind} path `{}` does not exist", path.display())
|
||||||
}
|
}
|
||||||
Self::PathIsDirectory { kind, path } => {
|
|
||||||
write!(f, "{kind} path `{}` must point to a file", path.display())
|
|
||||||
}
|
|
||||||
Self::InvalidToolInputSchema { tool_name } => {
|
Self::InvalidToolInputSchema { tool_name } => {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
|
|
@ -974,7 +860,6 @@ pub enum PluginError {
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Json(serde_json::Error),
|
Json(serde_json::Error),
|
||||||
ManifestValidation(Vec<PluginManifestValidationError>),
|
ManifestValidation(Vec<PluginManifestValidationError>),
|
||||||
LoadFailures(Vec<PluginLoadFailure>),
|
|
||||||
InvalidManifest(String),
|
InvalidManifest(String),
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
CommandFailed(String),
|
CommandFailed(String),
|
||||||
|
|
@ -994,15 +879,6 @@ impl Display for PluginError {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Self::LoadFailures(failures) => {
|
|
||||||
for (index, failure) in failures.iter().enumerate() {
|
|
||||||
if index > 0 {
|
|
||||||
write!(f, "; ")?;
|
|
||||||
}
|
|
||||||
write!(f, "{failure}")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Self::InvalidManifest(message)
|
Self::InvalidManifest(message)
|
||||||
| Self::NotFound(message)
|
| Self::NotFound(message)
|
||||||
| Self::CommandFailed(message) => write!(f, "{message}"),
|
| Self::CommandFailed(message) => write!(f, "{message}"),
|
||||||
|
|
@ -1059,23 +935,15 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
||||||
self.plugin_registry_report()?.into_registry()
|
Ok(PluginRegistry::new(
|
||||||
}
|
self.discover_plugins()?
|
||||||
|
.into_iter()
|
||||||
pub fn plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
|
.map(|plugin| {
|
||||||
self.sync_bundled_plugins()?;
|
let enabled = self.is_enabled(plugin.metadata());
|
||||||
|
RegisteredPlugin::new(plugin, enabled)
|
||||||
let mut discovery = PluginDiscovery::default();
|
})
|
||||||
discovery.plugins.extend(builtin_plugins());
|
.collect(),
|
||||||
|
))
|
||||||
let installed = self.discover_installed_plugins_with_failures()?;
|
|
||||||
discovery.extend(installed);
|
|
||||||
|
|
||||||
let external =
|
|
||||||
self.discover_external_directory_plugins_with_failures(&discovery.plugins)?;
|
|
||||||
discovery.extend(external);
|
|
||||||
|
|
||||||
Ok(self.build_registry_report(discovery))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
|
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
|
||||||
|
|
@ -1087,12 +955,11 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
||||||
Ok(self
|
self.sync_bundled_plugins()?;
|
||||||
.plugin_registry()?
|
let mut plugins = builtin_plugins();
|
||||||
.plugins
|
plugins.extend(self.discover_installed_plugins()?);
|
||||||
.into_iter()
|
plugins.extend(self.discover_external_directory_plugins(&plugins)?);
|
||||||
.map(|plugin| plugin.definition)
|
Ok(plugins)
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
|
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
|
||||||
|
|
@ -1227,9 +1094,9 @@ impl PluginManager {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_installed_plugins_with_failures(&self) -> Result<PluginDiscovery, PluginError> {
|
fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
||||||
let mut registry = self.load_registry()?;
|
let mut registry = self.load_registry()?;
|
||||||
let mut discovery = PluginDiscovery::default();
|
let mut plugins = Vec::new();
|
||||||
let mut seen_ids = BTreeSet::<String>::new();
|
let mut seen_ids = BTreeSet::<String>::new();
|
||||||
let mut seen_paths = BTreeSet::<PathBuf>::new();
|
let mut seen_paths = BTreeSet::<PathBuf>::new();
|
||||||
let mut stale_registry_ids = Vec::new();
|
let mut stale_registry_ids = Vec::new();
|
||||||
|
|
@ -1244,21 +1111,10 @@ impl PluginManager {
|
||||||
|| install_path.display().to_string(),
|
|| install_path.display().to_string(),
|
||||||
|record| describe_install_source(&record.source),
|
|record| describe_install_source(&record.source),
|
||||||
);
|
);
|
||||||
match load_plugin_definition(&install_path, kind, source.clone(), kind.marketplace()) {
|
let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
|
||||||
Ok(plugin) => {
|
|
||||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||||
seen_paths.insert(install_path);
|
seen_paths.insert(install_path);
|
||||||
discovery.push_plugin(plugin);
|
plugins.push(plugin);
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
discovery.push_failure(PluginLoadFailure::new(
|
|
||||||
install_path,
|
|
||||||
kind,
|
|
||||||
source,
|
|
||||||
error,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1271,27 +1127,15 @@ impl PluginManager {
|
||||||
stale_registry_ids.push(record.id.clone());
|
stale_registry_ids.push(record.id.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let source = describe_install_source(&record.source);
|
let plugin = load_plugin_definition(
|
||||||
match load_plugin_definition(
|
|
||||||
&record.install_path,
|
&record.install_path,
|
||||||
record.kind,
|
record.kind,
|
||||||
source.clone(),
|
describe_install_source(&record.source),
|
||||||
record.kind.marketplace(),
|
record.kind.marketplace(),
|
||||||
) {
|
)?;
|
||||||
Ok(plugin) => {
|
|
||||||
if seen_ids.insert(plugin.metadata().id.clone()) {
|
if seen_ids.insert(plugin.metadata().id.clone()) {
|
||||||
seen_paths.insert(record.install_path.clone());
|
seen_paths.insert(record.install_path.clone());
|
||||||
discovery.push_plugin(plugin);
|
plugins.push(plugin);
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
discovery.push_failure(PluginLoadFailure::new(
|
|
||||||
record.install_path.clone(),
|
|
||||||
record.kind,
|
|
||||||
source,
|
|
||||||
error,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1302,51 +1146,47 @@ impl PluginManager {
|
||||||
self.store_registry(®istry)?;
|
self.store_registry(®istry)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(discovery)
|
Ok(plugins)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_external_directory_plugins_with_failures(
|
fn discover_external_directory_plugins(
|
||||||
&self,
|
&self,
|
||||||
existing_plugins: &[PluginDefinition],
|
existing_plugins: &[PluginDefinition],
|
||||||
) -> Result<PluginDiscovery, PluginError> {
|
) -> Result<Vec<PluginDefinition>, PluginError> {
|
||||||
let mut discovery = PluginDiscovery::default();
|
let mut plugins = Vec::new();
|
||||||
|
|
||||||
for directory in &self.config.external_dirs {
|
for directory in &self.config.external_dirs {
|
||||||
for root in discover_plugin_dirs(directory)? {
|
for root in discover_plugin_dirs(directory)? {
|
||||||
let source = root.display().to_string();
|
let plugin = load_plugin_definition(
|
||||||
match load_plugin_definition(
|
|
||||||
&root,
|
&root,
|
||||||
PluginKind::External,
|
PluginKind::External,
|
||||||
source.clone(),
|
root.display().to_string(),
|
||||||
EXTERNAL_MARKETPLACE,
|
EXTERNAL_MARKETPLACE,
|
||||||
) {
|
)?;
|
||||||
Ok(plugin) => {
|
|
||||||
if existing_plugins
|
if existing_plugins
|
||||||
.iter()
|
.iter()
|
||||||
.chain(discovery.plugins.iter())
|
.chain(plugins.iter())
|
||||||
.all(|existing| existing.metadata().id != plugin.metadata().id)
|
.all(|existing| existing.metadata().id != plugin.metadata().id)
|
||||||
{
|
{
|
||||||
discovery.push_plugin(plugin);
|
plugins.push(plugin);
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
discovery.push_failure(PluginLoadFailure::new(
|
|
||||||
root,
|
|
||||||
PluginKind::External,
|
|
||||||
source,
|
|
||||||
error,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(discovery)
|
Ok(plugins)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn installed_plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
|
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
||||||
self.sync_bundled_plugins()?;
|
self.sync_bundled_plugins()?;
|
||||||
Ok(self.build_registry_report(self.discover_installed_plugins_with_failures()?))
|
Ok(PluginRegistry::new(
|
||||||
|
self.discover_installed_plugins()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|plugin| {
|
||||||
|
let enabled = self.is_enabled(plugin.metadata());
|
||||||
|
RegisteredPlugin::new(plugin, enabled)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
|
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
|
||||||
|
|
@ -1492,26 +1332,6 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
|
||||||
self.installed_plugin_registry_report()?.into_registry()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_registry_report(&self, discovery: PluginDiscovery) -> PluginRegistryReport {
|
|
||||||
PluginRegistryReport::new(
|
|
||||||
PluginRegistry::new(
|
|
||||||
discovery
|
|
||||||
.plugins
|
|
||||||
.into_iter()
|
|
||||||
.map(|plugin| {
|
|
||||||
let enabled = self.is_enabled(plugin.metadata());
|
|
||||||
RegisteredPlugin::new(plugin, enabled)
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
discovery.failures,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -1629,12 +1449,6 @@ fn build_plugin_manifest(
|
||||||
let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
|
let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
|
||||||
validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
|
validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
|
||||||
validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
|
validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
|
||||||
validate_command_entries(
|
|
||||||
root,
|
|
||||||
raw.hooks.post_tool_use_failure.iter(),
|
|
||||||
"hook",
|
|
||||||
&mut errors,
|
|
||||||
);
|
|
||||||
validate_command_entries(
|
validate_command_entries(
|
||||||
root,
|
root,
|
||||||
raw.lifecycle.init.iter(),
|
raw.lifecycle.init.iter(),
|
||||||
|
|
@ -1862,8 +1676,6 @@ fn validate_command_entry(
|
||||||
};
|
};
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
errors.push(PluginManifestValidationError::MissingPath { kind, path });
|
errors.push(PluginManifestValidationError::MissingPath { kind, path });
|
||||||
} else if !path.is_file() {
|
|
||||||
errors.push(PluginManifestValidationError::PathIsDirectory { kind, path });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1879,11 +1691,6 @@ fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| resolve_hook_entry(root, entry))
|
.map(|entry| resolve_hook_entry(root, entry))
|
||||||
.collect(),
|
.collect(),
|
||||||
post_tool_use_failure: hooks
|
|
||||||
.post_tool_use_failure
|
|
||||||
.iter()
|
|
||||||
.map(|entry| resolve_hook_entry(root, entry))
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1932,12 +1739,7 @@ fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), P
|
||||||
let Some(root) = root else {
|
let Some(root) = root else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
for entry in hooks
|
for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
|
||||||
.pre_tool_use
|
|
||||||
.iter()
|
|
||||||
.chain(hooks.post_tool_use.iter())
|
|
||||||
.chain(hooks.post_tool_use_failure.iter())
|
|
||||||
{
|
|
||||||
validate_command_path(root, entry, "hook")?;
|
validate_command_path(root, entry, "hook")?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -1981,12 +1783,6 @@ fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), Plu
|
||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if !path.is_file() {
|
|
||||||
return Err(PluginError::InvalidManifest(format!(
|
|
||||||
"{kind} path `{}` must point to a file",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2298,30 +2094,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_directory_path_plugin(root: &Path, name: &str) {
|
|
||||||
fs::create_dir_all(root.join("hooks").join("pre-dir")).expect("hook dir");
|
|
||||||
fs::create_dir_all(root.join("tools").join("tool-dir")).expect("tool dir");
|
|
||||||
fs::create_dir_all(root.join("commands").join("sync-dir")).expect("command dir");
|
|
||||||
fs::create_dir_all(root.join("lifecycle").join("init-dir")).expect("lifecycle dir");
|
|
||||||
write_file(
|
|
||||||
root.join(MANIFEST_FILE_NAME).as_path(),
|
|
||||||
format!(
|
|
||||||
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"directory path plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre-dir\"]\n }},\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init-dir\"]\n }},\n \"tools\": [\n {{\n \"name\": \"dir_tool\",\n \"description\": \"Directory tool\",\n \"inputSchema\": {{\"type\": \"object\"}},\n \"command\": \"./tools/tool-dir\"\n }}\n ],\n \"commands\": [\n {{\n \"name\": \"sync\",\n \"description\": \"Directory command\",\n \"command\": \"./commands/sync-dir\"\n }}\n ]\n}}"
|
|
||||||
)
|
|
||||||
.as_str(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_broken_failure_hook_plugin(root: &Path, name: &str) {
|
|
||||||
write_file(
|
|
||||||
root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
|
||||||
format!(
|
|
||||||
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PostToolUseFailure\": [\"./hooks/missing-failure.sh\"]\n }}\n}}"
|
|
||||||
)
|
|
||||||
.as_str(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
||||||
let log_path = root.join("lifecycle.log");
|
let log_path = root.join("lifecycle.log");
|
||||||
write_file(
|
write_file(
|
||||||
|
|
@ -2543,90 +2315,6 @@ mod tests {
|
||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_plugin_from_directory_rejects_missing_lifecycle_paths() {
|
|
||||||
// given
|
|
||||||
let root = temp_dir("manifest-lifecycle-paths");
|
|
||||||
write_file(
|
|
||||||
root.join(MANIFEST_FILE_NAME).as_path(),
|
|
||||||
r#"{
|
|
||||||
"name": "missing-lifecycle-paths",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Missing lifecycle path validation",
|
|
||||||
"lifecycle": {
|
|
||||||
"Init": ["./lifecycle/init.sh"],
|
|
||||||
"Shutdown": ["./lifecycle/shutdown.sh"]
|
|
||||||
}
|
|
||||||
}"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error =
|
|
||||||
load_plugin_from_directory(&root).expect_err("missing lifecycle paths should fail");
|
|
||||||
|
|
||||||
// then
|
|
||||||
match error {
|
|
||||||
PluginError::ManifestValidation(errors) => {
|
|
||||||
assert!(errors.iter().any(|error| matches!(
|
|
||||||
error,
|
|
||||||
PluginManifestValidationError::MissingPath { kind, path }
|
|
||||||
if *kind == "lifecycle command"
|
|
||||||
&& path.ends_with(Path::new("lifecycle/init.sh"))
|
|
||||||
)));
|
|
||||||
assert!(errors.iter().any(|error| matches!(
|
|
||||||
error,
|
|
||||||
PluginManifestValidationError::MissingPath { kind, path }
|
|
||||||
if *kind == "lifecycle command"
|
|
||||||
&& path.ends_with(Path::new("lifecycle/shutdown.sh"))
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
other => panic!("expected manifest validation errors, got {other}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_plugin_from_directory_rejects_directory_command_paths() {
|
|
||||||
// given
|
|
||||||
let root = temp_dir("manifest-directory-paths");
|
|
||||||
write_directory_path_plugin(&root, "directory-paths");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error =
|
|
||||||
load_plugin_from_directory(&root).expect_err("directory command paths should fail");
|
|
||||||
|
|
||||||
// then
|
|
||||||
match error {
|
|
||||||
PluginError::ManifestValidation(errors) => {
|
|
||||||
assert!(errors.iter().any(|error| matches!(
|
|
||||||
error,
|
|
||||||
PluginManifestValidationError::PathIsDirectory { kind, path }
|
|
||||||
if *kind == "hook" && path.ends_with(Path::new("hooks/pre-dir"))
|
|
||||||
)));
|
|
||||||
assert!(errors.iter().any(|error| matches!(
|
|
||||||
error,
|
|
||||||
PluginManifestValidationError::PathIsDirectory { kind, path }
|
|
||||||
if *kind == "lifecycle command"
|
|
||||||
&& path.ends_with(Path::new("lifecycle/init-dir"))
|
|
||||||
)));
|
|
||||||
assert!(errors.iter().any(|error| matches!(
|
|
||||||
error,
|
|
||||||
PluginManifestValidationError::PathIsDirectory { kind, path }
|
|
||||||
if *kind == "tool" && path.ends_with(Path::new("tools/tool-dir"))
|
|
||||||
)));
|
|
||||||
assert!(errors.iter().any(|error| matches!(
|
|
||||||
error,
|
|
||||||
PluginManifestValidationError::PathIsDirectory { kind, path }
|
|
||||||
if *kind == "command" && path.ends_with(Path::new("commands/sync-dir"))
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
other => panic!("expected manifest validation errors, got {other}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_rejects_invalid_permissions() {
|
fn load_plugin_from_directory_rejects_invalid_permissions() {
|
||||||
let root = temp_dir("manifest-invalid-permissions");
|
let root = temp_dir("manifest-invalid-permissions");
|
||||||
|
|
@ -3118,95 +2806,16 @@ mod tests {
|
||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
|
|
||||||
// given
|
|
||||||
let config_home = temp_dir("report-home");
|
|
||||||
let external_root = temp_dir("report-external");
|
|
||||||
write_external_plugin(&external_root.join("valid"), "valid-report", "1.0.0");
|
|
||||||
write_broken_plugin(&external_root.join("broken"), "broken-report");
|
|
||||||
|
|
||||||
let mut config = PluginManagerConfig::new(&config_home);
|
|
||||||
config.external_dirs = vec![external_root.clone()];
|
|
||||||
let manager = PluginManager::new(config);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let report = manager
|
|
||||||
.plugin_registry_report()
|
|
||||||
.expect("report should tolerate invalid external plugins");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(report.registry().contains("valid-report@external"));
|
|
||||||
assert_eq!(report.failures().len(), 1);
|
|
||||||
assert_eq!(report.failures()[0].kind, PluginKind::External);
|
|
||||||
assert!(report.failures()[0]
|
|
||||||
.plugin_root
|
|
||||||
.ends_with(Path::new("broken")));
|
|
||||||
assert!(report.failures()[0]
|
|
||||||
.error()
|
|
||||||
.to_string()
|
|
||||||
.contains("does not exist"));
|
|
||||||
|
|
||||||
let error = manager
|
|
||||||
.plugin_registry()
|
|
||||||
.expect_err("strict registry should surface load failures");
|
|
||||||
match error {
|
|
||||||
PluginError::LoadFailures(failures) => {
|
|
||||||
assert_eq!(failures.len(), 1);
|
|
||||||
assert!(failures[0].plugin_root.ends_with(Path::new("broken")));
|
|
||||||
}
|
|
||||||
other => panic!("expected load failures, got {other}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(config_home);
|
|
||||||
let _ = fs::remove_dir_all(external_root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
|
|
||||||
// given
|
|
||||||
let config_home = temp_dir("installed-report-home");
|
|
||||||
let bundled_root = temp_dir("installed-report-bundled");
|
|
||||||
let install_root = config_home.join("plugins").join("installed");
|
|
||||||
write_external_plugin(&install_root.join("valid"), "installed-valid", "1.0.0");
|
|
||||||
write_broken_plugin(&install_root.join("broken"), "installed-broken");
|
|
||||||
|
|
||||||
let mut config = PluginManagerConfig::new(&config_home);
|
|
||||||
config.bundled_root = Some(bundled_root.clone());
|
|
||||||
config.install_root = Some(install_root);
|
|
||||||
let manager = PluginManager::new(config);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let report = manager
|
|
||||||
.installed_plugin_registry_report()
|
|
||||||
.expect("installed report should tolerate invalid installed plugins");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(report.registry().contains("installed-valid@external"));
|
|
||||||
assert_eq!(report.failures().len(), 1);
|
|
||||||
assert!(report.failures()[0]
|
|
||||||
.plugin_root
|
|
||||||
.ends_with(Path::new("broken")));
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(config_home);
|
|
||||||
let _ = fs::remove_dir_all(bundled_root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_plugin_sources_with_missing_hook_paths() {
|
fn rejects_plugin_sources_with_missing_hook_paths() {
|
||||||
// given
|
|
||||||
let config_home = temp_dir("broken-home");
|
let config_home = temp_dir("broken-home");
|
||||||
let source_root = temp_dir("broken-source");
|
let source_root = temp_dir("broken-source");
|
||||||
write_broken_plugin(&source_root, "broken");
|
write_broken_plugin(&source_root, "broken");
|
||||||
|
|
||||||
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
|
||||||
// when
|
|
||||||
let error = manager
|
let error = manager
|
||||||
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
|
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
|
||||||
.expect_err("missing hook file should fail validation");
|
.expect_err("missing hook file should fail validation");
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("does not exist"));
|
assert!(error.to_string().contains("does not exist"));
|
||||||
|
|
||||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
|
@ -3219,33 +2828,6 @@ mod tests {
|
||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
|
|
||||||
// given
|
|
||||||
let config_home = temp_dir("broken-failure-home");
|
|
||||||
let source_root = temp_dir("broken-failure-source");
|
|
||||||
write_broken_failure_hook_plugin(&source_root, "broken-failure");
|
|
||||||
|
|
||||||
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = manager
|
|
||||||
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
|
|
||||||
.expect_err("missing failure hook file should fail validation");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("does not exist"));
|
|
||||||
|
|
||||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
|
||||||
let install_error = manager
|
|
||||||
.install(source_root.to_str().expect("utf8 path"))
|
|
||||||
.expect_err("install should reject invalid failure hook paths");
|
|
||||||
assert!(install_error.to_string().contains("does not exist"));
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(config_home);
|
|
||||||
let _ = fs::remove_dir_all(source_root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
||||||
let config_home = temp_dir("lifecycle-home");
|
let config_home = temp_dir("lifecycle-home");
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ async fn execute_bash_async(
|
||||||
};
|
};
|
||||||
|
|
||||||
let (output, interrupted) = output_result;
|
let (output, interrupted) = output_result;
|
||||||
let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
|
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||||
let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
|
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||||
let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
|
let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
|
||||||
let return_code_interpretation = output.status.code().and_then(|code| {
|
let return_code_interpretation = output.status.code().and_then(|code| {
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
|
|
@ -281,53 +281,3 @@ mod tests {
|
||||||
assert!(!output.sandbox_status.expect("sandbox status").enabled);
|
assert!(!output.sandbox_status.expect("sandbox status").enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
|
||||||
const MAX_OUTPUT_BYTES: usize = 16_384;
|
|
||||||
|
|
||||||
/// Truncate output to `MAX_OUTPUT_BYTES`, appending a marker when trimmed.
|
|
||||||
fn truncate_output(s: &str) -> String {
|
|
||||||
if s.len() <= MAX_OUTPUT_BYTES {
|
|
||||||
return s.to_string();
|
|
||||||
}
|
|
||||||
// Find the last valid UTF-8 boundary at or before MAX_OUTPUT_BYTES
|
|
||||||
let mut end = MAX_OUTPUT_BYTES;
|
|
||||||
while end > 0 && !s.is_char_boundary(end) {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
let mut truncated = s[..end].to_string();
|
|
||||||
truncated.push_str("\n\n[output truncated — exceeded 16384 bytes]");
|
|
||||||
truncated
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod truncation_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn short_output_unchanged() {
|
|
||||||
let s = "hello world";
|
|
||||||
assert_eq!(truncate_output(s), s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn long_output_truncated() {
|
|
||||||
let s = "x".repeat(20_000);
|
|
||||||
let result = truncate_output(&s);
|
|
||||||
assert!(result.len() < 20_000);
|
|
||||||
assert!(result.ends_with("[output truncated — exceeded 16384 bytes]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn exact_boundary_unchanged() {
|
|
||||||
let s = "a".repeat(MAX_OUTPUT_BYTES);
|
|
||||||
assert_eq!(truncate_output(&s), s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_over_boundary_truncated() {
|
|
||||||
let s = "a".repeat(MAX_OUTPUT_BYTES + 1);
|
|
||||||
let result = truncate_output(&s);
|
|
||||||
assert!(result.contains("[output truncated"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -54,58 +54,3 @@ impl BootstrapPlan {
|
||||||
&self.phases
|
&self.phases
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{BootstrapPhase, BootstrapPlan};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_phases_deduplicates_while_preserving_order() {
|
|
||||||
// given
|
|
||||||
let phases = vec![
|
|
||||||
BootstrapPhase::CliEntry,
|
|
||||||
BootstrapPhase::FastPathVersion,
|
|
||||||
BootstrapPhase::CliEntry,
|
|
||||||
BootstrapPhase::MainRuntime,
|
|
||||||
BootstrapPhase::FastPathVersion,
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let plan = BootstrapPlan::from_phases(phases);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
plan.phases(),
|
|
||||||
&[
|
|
||||||
BootstrapPhase::CliEntry,
|
|
||||||
BootstrapPhase::FastPathVersion,
|
|
||||||
BootstrapPhase::MainRuntime,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn claude_code_default_covers_each_phase_once() {
|
|
||||||
// given
|
|
||||||
let expected = [
|
|
||||||
BootstrapPhase::CliEntry,
|
|
||||||
BootstrapPhase::FastPathVersion,
|
|
||||||
BootstrapPhase::StartupProfiler,
|
|
||||||
BootstrapPhase::SystemPromptFastPath,
|
|
||||||
BootstrapPhase::ChromeMcpFastPath,
|
|
||||||
BootstrapPhase::DaemonWorkerFastPath,
|
|
||||||
BootstrapPhase::BridgeFastPath,
|
|
||||||
BootstrapPhase::DaemonFastPath,
|
|
||||||
BootstrapPhase::BackgroundSessionFastPath,
|
|
||||||
BootstrapPhase::TemplateFastPath,
|
|
||||||
BootstrapPhase::EnvironmentRunnerFastPath,
|
|
||||||
BootstrapPhase::MainRuntime,
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let plan = BootstrapPlan::claude_code_default();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(plan.phases(), &expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,6 @@ pub struct McpStdioServerConfig {
|
||||||
pub command: String,
|
pub command: String,
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
pub env: BTreeMap<String, String>,
|
pub env: BTreeMap<String, String>,
|
||||||
pub tool_call_timeout_ms: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -792,7 +791,6 @@ fn parse_mcp_server_config(
|
||||||
command: expect_string(object, "command", context)?.to_string(),
|
command: expect_string(object, "command", context)?.to_string(),
|
||||||
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
||||||
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
||||||
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
|
|
||||||
})),
|
})),
|
||||||
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
||||||
object, context,
|
object, context,
|
||||||
|
|
@ -916,27 +914,6 @@ fn optional_u16(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn optional_u64(
|
|
||||||
object: &BTreeMap<String, JsonValue>,
|
|
||||||
key: &str,
|
|
||||||
context: &str,
|
|
||||||
) -> Result<Option<u64>, ConfigError> {
|
|
||||||
match object.get(key) {
|
|
||||||
Some(value) => {
|
|
||||||
let Some(number) = value.as_i64() else {
|
|
||||||
return Err(ConfigError::Parse(format!(
|
|
||||||
"{context}: field {key} must be a non-negative integer"
|
|
||||||
)));
|
|
||||||
};
|
|
||||||
let number = u64::try_from(number).map_err(|_| {
|
|
||||||
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
|
||||||
})?;
|
|
||||||
Ok(Some(number))
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
|
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
|
||||||
let Some(map) = value.as_object() else {
|
let Some(map) = value.as_object() else {
|
||||||
return Err(ConfigError::Parse(format!(
|
return Err(ConfigError::Parse(format!(
|
||||||
|
|
@ -1044,9 +1021,8 @@ fn push_unique(target: &mut Vec<String>, value: String) {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource,
|
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
|
||||||
McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig,
|
CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
|
||||||
};
|
};
|
||||||
use crate::json::JsonValue;
|
use crate::json::JsonValue;
|
||||||
use crate::sandbox::FilesystemIsolationMode;
|
use crate::sandbox::FilesystemIsolationMode;
|
||||||
|
|
@ -1389,7 +1365,6 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_mcp_server_shapes() {
|
fn rejects_invalid_mcp_server_shapes() {
|
||||||
// given
|
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
let cwd = root.join("project");
|
let cwd = root.join("project");
|
||||||
let home = root.join("home").join(".claw");
|
let home = root.join("home").join(".claw");
|
||||||
|
|
@ -1401,132 +1376,13 @@ mod tests {
|
||||||
)
|
)
|
||||||
.expect("write broken settings");
|
.expect("write broken settings");
|
||||||
|
|
||||||
// when
|
|
||||||
let error = ConfigLoader::new(&cwd, &home)
|
let error = ConfigLoader::new(&cwd, &home)
|
||||||
.load()
|
.load()
|
||||||
.expect_err("config should fail");
|
.expect_err("config should fail");
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error
|
assert!(error
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("mcpServers.broken: missing string field url"));
|
.contains("mcpServers.broken: missing string field url"));
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_settings_file_loads_defaults() {
|
|
||||||
// given
|
|
||||||
let root = temp_dir();
|
|
||||||
let cwd = root.join("project");
|
|
||||||
let home = root.join("home").join(".claw");
|
|
||||||
fs::create_dir_all(&home).expect("home config dir");
|
|
||||||
fs::create_dir_all(&cwd).expect("project dir");
|
|
||||||
fs::write(home.join("settings.json"), "").expect("write empty settings");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let loaded = ConfigLoader::new(&cwd, &home)
|
|
||||||
.load()
|
|
||||||
.expect("empty settings should still load");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(loaded.loaded_entries().len(), 1);
|
|
||||||
assert_eq!(loaded.permission_mode(), None);
|
|
||||||
assert_eq!(loaded.plugins().enabled_plugins().len(), 0);
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deep_merge_objects_merges_nested_maps() {
|
|
||||||
// given
|
|
||||||
let mut target = JsonValue::parse(r#"{"env":{"A":"1","B":"2"},"model":"haiku"}"#)
|
|
||||||
.expect("target JSON should parse")
|
|
||||||
.as_object()
|
|
||||||
.expect("target should be an object")
|
|
||||||
.clone();
|
|
||||||
let source =
|
|
||||||
JsonValue::parse(r#"{"env":{"B":"override","C":"3"},"sandbox":{"enabled":true}}"#)
|
|
||||||
.expect("source JSON should parse")
|
|
||||||
.as_object()
|
|
||||||
.expect("source should be an object")
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// when
|
|
||||||
deep_merge_objects(&mut target, &source);
|
|
||||||
|
|
||||||
// then
|
|
||||||
let env = target
|
|
||||||
.get("env")
|
|
||||||
.and_then(JsonValue::as_object)
|
|
||||||
.expect("env should remain an object");
|
|
||||||
assert_eq!(env.get("A"), Some(&JsonValue::String("1".to_string())));
|
|
||||||
assert_eq!(
|
|
||||||
env.get("B"),
|
|
||||||
Some(&JsonValue::String("override".to_string()))
|
|
||||||
);
|
|
||||||
assert_eq!(env.get("C"), Some(&JsonValue::String("3".to_string())));
|
|
||||||
assert!(target.contains_key("sandbox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn permission_mode_aliases_resolve_to_expected_modes() {
|
|
||||||
// given / when / then
|
|
||||||
assert_eq!(
|
|
||||||
parse_permission_mode_label("plan", "test").expect("plan should resolve"),
|
|
||||||
ResolvedPermissionMode::ReadOnly
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_permission_mode_label("acceptEdits", "test").expect("acceptEdits should resolve"),
|
|
||||||
ResolvedPermissionMode::WorkspaceWrite
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_permission_mode_label("dontAsk", "test").expect("dontAsk should resolve"),
|
|
||||||
ResolvedPermissionMode::DangerFullAccess
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hook_config_merge_preserves_uniques() {
|
|
||||||
// given
|
|
||||||
let base = RuntimeHookConfig::new(
|
|
||||||
vec!["pre-a".to_string()],
|
|
||||||
vec!["post-a".to_string()],
|
|
||||||
vec!["failure-a".to_string()],
|
|
||||||
);
|
|
||||||
let overlay = RuntimeHookConfig::new(
|
|
||||||
vec!["pre-a".to_string(), "pre-b".to_string()],
|
|
||||||
vec!["post-a".to_string(), "post-b".to_string()],
|
|
||||||
vec!["failure-b".to_string()],
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let merged = base.merged(&overlay);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
merged.pre_tool_use(),
|
|
||||||
&["pre-a".to_string(), "pre-b".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
merged.post_tool_use(),
|
|
||||||
&["post-a".to_string(), "post-b".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
merged.post_tool_use_failure(),
|
|
||||||
&["failure-a".to_string(), "failure-b".to_string()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plugin_state_falls_back_to_default_for_unknown_plugin() {
|
|
||||||
// given
|
|
||||||
let mut config = RuntimePluginConfig::default();
|
|
||||||
config.set_plugin_state("known".to_string(), true);
|
|
||||||
|
|
||||||
// when / then
|
|
||||||
assert!(config.state_for("known", false));
|
|
||||||
assert!(config.state_for("missing", true));
|
|
||||||
assert!(!config.state_for("missing", false));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,7 @@ where
|
||||||
);
|
);
|
||||||
|
|
||||||
self.session
|
self.session
|
||||||
.push_message(assistant_message.clone())
|
.push_message(&assistant_message)
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
assistant_messages.push(assistant_message);
|
assistant_messages.push(assistant_message);
|
||||||
|
|
||||||
|
|
@ -374,13 +374,6 @@ where
|
||||||
&format!("PreToolUse hook cancelled tool `{tool_name}`"),
|
&format!("PreToolUse hook cancelled tool `{tool_name}`"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
} else if pre_hook_result.is_failed() {
|
|
||||||
PermissionOutcome::Deny {
|
|
||||||
reason: format_hook_message(
|
|
||||||
&pre_hook_result,
|
|
||||||
&format!("PreToolUse hook failed for tool `{tool_name}`"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
} else if pre_hook_result.is_denied() {
|
} else if pre_hook_result.is_denied() {
|
||||||
PermissionOutcome::Deny {
|
PermissionOutcome::Deny {
|
||||||
reason: format_hook_message(
|
reason: format_hook_message(
|
||||||
|
|
@ -428,18 +421,13 @@ where
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if post_hook_result.is_denied()
|
if post_hook_result.is_denied() || post_hook_result.is_cancelled() {
|
||||||
|| post_hook_result.is_failed()
|
|
||||||
|| post_hook_result.is_cancelled()
|
|
||||||
{
|
|
||||||
is_error = true;
|
is_error = true;
|
||||||
}
|
}
|
||||||
output = merge_hook_feedback(
|
output = merge_hook_feedback(
|
||||||
post_hook_result.messages(),
|
post_hook_result.messages(),
|
||||||
output,
|
output,
|
||||||
post_hook_result.is_denied()
|
post_hook_result.is_denied() || post_hook_result.is_cancelled(),
|
||||||
|| post_hook_result.is_failed()
|
|
||||||
|| post_hook_result.is_cancelled(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
|
ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
|
||||||
|
|
@ -452,7 +440,7 @@ where
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
self.session
|
self.session
|
||||||
.push_message(result_message.clone())
|
.push_message(&result_message)
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
self.record_tool_finished(iterations, &result_message);
|
self.record_tool_finished(iterations, &result_message);
|
||||||
tool_results.push(result_message);
|
tool_results.push(result_message);
|
||||||
|
|
@ -719,7 +707,7 @@ fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_hook_feedback(messages: &[String], output: String, is_error: bool) -> String {
|
fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
@ -728,8 +716,8 @@ fn merge_hook_feedback(messages: &[String], output: String, is_error: bool) -> S
|
||||||
if !output.trim().is_empty() {
|
if !output.trim().is_empty() {
|
||||||
sections.push(output);
|
sections.push(output);
|
||||||
}
|
}
|
||||||
let label = if is_error {
|
let label = if denied {
|
||||||
"Hook feedback (error)"
|
"Hook feedback (denied)"
|
||||||
} else {
|
} else {
|
||||||
"Hook feedback"
|
"Hook feedback"
|
||||||
};
|
};
|
||||||
|
|
@ -772,9 +760,9 @@ impl ToolExecutor for StaticToolExecutor {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_assistant_message, parse_auto_compaction_threshold, ApiClient, ApiRequest,
|
parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent,
|
||||||
AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
|
AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
|
||||||
StaticToolExecutor, ToolExecutor, DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
StaticToolExecutor, DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
||||||
};
|
};
|
||||||
use crate::compact::CompactionConfig;
|
use crate::compact::CompactionConfig;
|
||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||||
|
|
@ -785,7 +773,6 @@ mod tests {
|
||||||
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
||||||
use crate::session::{ContentBlock, MessageRole, Session};
|
use crate::session::{ContentBlock, MessageRole, Session};
|
||||||
use crate::usage::TokenUsage;
|
use crate::usage::TokenUsage;
|
||||||
use crate::ToolError;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -847,7 +834,7 @@ mod tests {
|
||||||
AssistantEvent::MessageStop,
|
AssistantEvent::MessageStop,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
_ => unreachable!("extra API call"),
|
_ => Err(RuntimeError::new("unexpected extra API call")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1063,71 +1050,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn denies_tool_use_when_pre_tool_hook_fails() {
|
|
||||||
struct SingleCallApiClient;
|
|
||||||
impl ApiClient for SingleCallApiClient {
|
|
||||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
||||||
if request
|
|
||||||
.messages
|
|
||||||
.iter()
|
|
||||||
.any(|message| message.role == MessageRole::Tool)
|
|
||||||
{
|
|
||||||
return Ok(vec![
|
|
||||||
AssistantEvent::TextDelta("failed".to_string()),
|
|
||||||
AssistantEvent::MessageStop,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
Ok(vec![
|
|
||||||
AssistantEvent::ToolUse {
|
|
||||||
id: "tool-1".to_string(),
|
|
||||||
name: "blocked".to_string(),
|
|
||||||
input: r#"{"path":"secret.txt"}"#.to_string(),
|
|
||||||
},
|
|
||||||
AssistantEvent::MessageStop,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// given
|
|
||||||
let mut runtime = ConversationRuntime::new_with_features(
|
|
||||||
Session::new(),
|
|
||||||
SingleCallApiClient,
|
|
||||||
StaticToolExecutor::new().register("blocked", |_input| {
|
|
||||||
panic!("tool should not execute when hook fails")
|
|
||||||
}),
|
|
||||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
||||||
vec!["system".to_string()],
|
|
||||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
|
||||||
vec![shell_snippet("printf 'broken hook'; exit 1")],
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let summary = runtime
|
|
||||||
.run_turn("use the tool", None)
|
|
||||||
.expect("conversation should continue after hook failure");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(summary.tool_results.len(), 1);
|
|
||||||
let ContentBlock::ToolResult {
|
|
||||||
is_error, output, ..
|
|
||||||
} = &summary.tool_results[0].blocks[0]
|
|
||||||
else {
|
|
||||||
panic!("expected tool result block");
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
*is_error,
|
|
||||||
"hook failure should produce an error result: {output}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
output.contains("exited with status 1") || output.contains("broken hook"),
|
|
||||||
"unexpected hook failure output: {output:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn appends_post_tool_hook_feedback_to_tool_result() {
|
fn appends_post_tool_hook_feedback_to_tool_result() {
|
||||||
struct TwoCallApiClient {
|
struct TwoCallApiClient {
|
||||||
|
|
@ -1156,7 +1078,7 @@ mod tests {
|
||||||
AssistantEvent::MessageStop,
|
AssistantEvent::MessageStop,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
_ => unreachable!("extra API call"),
|
_ => Err(RuntimeError::new("unexpected extra API call")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1203,85 +1125,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn appends_post_tool_use_failure_hook_feedback_to_tool_result() {
|
|
||||||
struct TwoCallApiClient {
|
|
||||||
calls: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiClient for TwoCallApiClient {
|
|
||||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
||||||
self.calls += 1;
|
|
||||||
match self.calls {
|
|
||||||
1 => Ok(vec![
|
|
||||||
AssistantEvent::ToolUse {
|
|
||||||
id: "tool-1".to_string(),
|
|
||||||
name: "fail".to_string(),
|
|
||||||
input: r#"{"path":"README.md"}"#.to_string(),
|
|
||||||
},
|
|
||||||
AssistantEvent::MessageStop,
|
|
||||||
]),
|
|
||||||
2 => {
|
|
||||||
assert!(request
|
|
||||||
.messages
|
|
||||||
.iter()
|
|
||||||
.any(|message| message.role == MessageRole::Tool));
|
|
||||||
Ok(vec![
|
|
||||||
AssistantEvent::TextDelta("done".to_string()),
|
|
||||||
AssistantEvent::MessageStop,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
_ => unreachable!("extra API call"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// given
|
|
||||||
let mut runtime = ConversationRuntime::new_with_features(
|
|
||||||
Session::new(),
|
|
||||||
TwoCallApiClient { calls: 0 },
|
|
||||||
StaticToolExecutor::new()
|
|
||||||
.register("fail", |_input| Err(ToolError::new("tool exploded"))),
|
|
||||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
||||||
vec!["system".to_string()],
|
|
||||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
|
||||||
Vec::new(),
|
|
||||||
vec![shell_snippet("printf 'post hook should not run'")],
|
|
||||||
vec![shell_snippet("printf 'failure hook ran'")],
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let summary = runtime
|
|
||||||
.run_turn("use fail", None)
|
|
||||||
.expect("tool loop succeeds");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(summary.tool_results.len(), 1);
|
|
||||||
let ContentBlock::ToolResult {
|
|
||||||
is_error, output, ..
|
|
||||||
} = &summary.tool_results[0].blocks[0]
|
|
||||||
else {
|
|
||||||
panic!("expected tool result block");
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
*is_error,
|
|
||||||
"failure hook path should preserve error result: {output:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
output.contains("tool exploded"),
|
|
||||||
"tool output missing failure reason: {output:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
output.contains("failure hook ran"),
|
|
||||||
"tool output missing failure hook feedback: {output:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!output.contains("post hook should not run"),
|
|
||||||
"normal post hook should not run on tool failure: {output:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reconstructs_usage_tracker_from_restored_session() {
|
fn reconstructs_usage_tracker_from_restored_session() {
|
||||||
struct SimpleApi;
|
struct SimpleApi;
|
||||||
|
|
@ -1550,129 +1393,9 @@ mod tests {
|
||||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
||||||
);
|
);
|
||||||
assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
|
assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
|
||||||
assert_eq!(
|
|
||||||
parse_auto_compaction_threshold(Some("0")),
|
|
||||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_auto_compaction_threshold(Some("not-a-number")),
|
parse_auto_compaction_threshold(Some("not-a-number")),
|
||||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_assistant_message_requires_message_stop_event() {
|
|
||||||
// given
|
|
||||||
let events = vec![AssistantEvent::TextDelta("hello".to_string())];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = build_assistant_message(events)
|
|
||||||
.expect_err("assistant messages should require a stop event");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error
|
|
||||||
.to_string()
|
|
||||||
.contains("assistant stream ended without a message stop event"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_assistant_message_requires_content() {
|
|
||||||
// given
|
|
||||||
let events = vec![AssistantEvent::MessageStop];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error =
|
|
||||||
build_assistant_message(events).expect_err("assistant messages should require content");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error
|
|
||||||
.to_string()
|
|
||||||
.contains("assistant stream produced no content"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn static_tool_executor_rejects_unknown_tools() {
|
|
||||||
// given
|
|
||||||
let mut executor = StaticToolExecutor::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = executor
|
|
||||||
.execute("missing", "{}")
|
|
||||||
.expect_err("unregistered tools should fail");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(error.to_string(), "unknown tool: missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_turn_errors_when_max_iterations_is_exceeded() {
|
|
||||||
struct LoopingApi;
|
|
||||||
|
|
||||||
impl ApiClient for LoopingApi {
|
|
||||||
fn stream(
|
|
||||||
&mut self,
|
|
||||||
_request: ApiRequest,
|
|
||||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
||||||
Ok(vec![
|
|
||||||
AssistantEvent::ToolUse {
|
|
||||||
id: "tool-1".to_string(),
|
|
||||||
name: "echo".to_string(),
|
|
||||||
input: "payload".to_string(),
|
|
||||||
},
|
|
||||||
AssistantEvent::MessageStop,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// given
|
|
||||||
let mut runtime = ConversationRuntime::new(
|
|
||||||
Session::new(),
|
|
||||||
LoopingApi,
|
|
||||||
StaticToolExecutor::new().register("echo", |input| Ok(input.to_string())),
|
|
||||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
||||||
vec!["system".to_string()],
|
|
||||||
)
|
|
||||||
.with_max_iterations(1);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = runtime
|
|
||||||
.run_turn("loop", None)
|
|
||||||
.expect_err("conversation loop should stop after the configured limit");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error
|
|
||||||
.to_string()
|
|
||||||
.contains("conversation loop exceeded the maximum number of iterations"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_turn_propagates_api_errors() {
|
|
||||||
struct FailingApi;
|
|
||||||
|
|
||||||
impl ApiClient for FailingApi {
|
|
||||||
fn stream(
|
|
||||||
&mut self,
|
|
||||||
_request: ApiRequest,
|
|
||||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
||||||
Err(RuntimeError::new("upstream failed"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// given
|
|
||||||
let mut runtime = ConversationRuntime::new(
|
|
||||||
Session::new(),
|
|
||||||
FailingApi,
|
|
||||||
StaticToolExecutor::new(),
|
|
||||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
|
||||||
vec!["system".to_string()],
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = runtime
|
|
||||||
.run_turn("hello", None)
|
|
||||||
.expect_err("API failures should propagate");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(error.to_string(), "upstream failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,39 +9,6 @@ use regex::RegexBuilder;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
/// Maximum file size that can be read (10 MB).
|
|
||||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Maximum file size that can be written (10 MB).
|
|
||||||
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Check whether a file appears to contain binary content by examining
|
|
||||||
/// the first chunk for NUL bytes.
|
|
||||||
fn is_binary_file(path: &Path) -> io::Result<bool> {
|
|
||||||
use std::io::Read;
|
|
||||||
let mut file = fs::File::open(path)?;
|
|
||||||
let mut buffer = [0u8; 8192];
|
|
||||||
let bytes_read = file.read(&mut buffer)?;
|
|
||||||
Ok(buffer[..bytes_read].contains(&0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that a resolved path stays within the given workspace root.
|
|
||||||
/// Returns the canonical path on success, or an error if the path escapes
|
|
||||||
/// the workspace boundary (e.g. via `../` traversal or symlink).
|
|
||||||
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
|
|
||||||
if !resolved.starts_with(workspace_root) {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::PermissionDenied,
|
|
||||||
format!(
|
|
||||||
"path {} escapes workspace boundary {}",
|
|
||||||
resolved.display(),
|
|
||||||
workspace_root.display()
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct TextFilePayload {
|
pub struct TextFilePayload {
|
||||||
#[serde(rename = "filePath")]
|
#[serde(rename = "filePath")]
|
||||||
|
|
@ -168,28 +135,6 @@ pub fn read_file(
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> io::Result<ReadFileOutput> {
|
) -> io::Result<ReadFileOutput> {
|
||||||
let absolute_path = normalize_path(path)?;
|
let absolute_path = normalize_path(path)?;
|
||||||
|
|
||||||
// Check file size before reading
|
|
||||||
let metadata = fs::metadata(&absolute_path)?;
|
|
||||||
if metadata.len() > MAX_READ_SIZE {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
format!(
|
|
||||||
"file is too large ({} bytes, max {} bytes)",
|
|
||||||
metadata.len(),
|
|
||||||
MAX_READ_SIZE
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect binary files
|
|
||||||
if is_binary_file(&absolute_path)? {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"file appears to be binary",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs::read_to_string(&absolute_path)?;
|
let content = fs::read_to_string(&absolute_path)?;
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||||
|
|
@ -211,17 +156,6 @@ pub fn read_file(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
||||||
if content.len() > MAX_WRITE_SIZE {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
format!(
|
|
||||||
"content is too large ({} bytes, max {} bytes)",
|
|
||||||
content.len(),
|
|
||||||
MAX_WRITE_SIZE
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let absolute_path = normalize_path_allow_missing(path)?;
|
let absolute_path = normalize_path_allow_missing(path)?;
|
||||||
let original_file = fs::read_to_string(&absolute_path).ok();
|
let original_file = fs::read_to_string(&absolute_path).ok();
|
||||||
if let Some(parent) = absolute_path.parent() {
|
if let Some(parent) = absolute_path.parent() {
|
||||||
|
|
@ -543,72 +477,11 @@ fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
|
||||||
Ok(candidate)
|
Ok(candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a file with workspace boundary enforcement.
|
|
||||||
pub fn read_file_in_workspace(
|
|
||||||
path: &str,
|
|
||||||
offset: Option<usize>,
|
|
||||||
limit: Option<usize>,
|
|
||||||
workspace_root: &Path,
|
|
||||||
) -> io::Result<ReadFileOutput> {
|
|
||||||
let absolute_path = normalize_path(path)?;
|
|
||||||
let canonical_root = workspace_root
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
|
||||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
|
||||||
read_file(path, offset, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a file with workspace boundary enforcement.
|
|
||||||
pub fn write_file_in_workspace(
|
|
||||||
path: &str,
|
|
||||||
content: &str,
|
|
||||||
workspace_root: &Path,
|
|
||||||
) -> io::Result<WriteFileOutput> {
|
|
||||||
let absolute_path = normalize_path_allow_missing(path)?;
|
|
||||||
let canonical_root = workspace_root
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
|
||||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
|
||||||
write_file(path, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Edit a file with workspace boundary enforcement.
|
|
||||||
pub fn edit_file_in_workspace(
|
|
||||||
path: &str,
|
|
||||||
old_string: &str,
|
|
||||||
new_string: &str,
|
|
||||||
replace_all: bool,
|
|
||||||
workspace_root: &Path,
|
|
||||||
) -> io::Result<EditFileOutput> {
|
|
||||||
let absolute_path = normalize_path(path)?;
|
|
||||||
let canonical_root = workspace_root
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
|
||||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
|
||||||
edit_file(path, old_string, new_string, replace_all)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether a path is a symlink that resolves outside the workspace.
|
|
||||||
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
|
|
||||||
let metadata = fs::symlink_metadata(path)?;
|
|
||||||
if !metadata.is_symlink() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let resolved = path.canonicalize()?;
|
|
||||||
let canonical_root = workspace_root
|
|
||||||
.canonicalize()
|
|
||||||
.unwrap_or_else(|_| workspace_root.to_path_buf());
|
|
||||||
Ok(!resolved.starts_with(&canonical_root))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::{
|
use super::{edit_file, glob_search, grep_search, read_file, write_file, GrepSearchInput};
|
||||||
edit_file, glob_search, grep_search, is_symlink_escape, read_file, read_file_in_workspace,
|
|
||||||
write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||||
let unique = SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
|
|
@ -640,73 +513,6 @@ mod tests {
|
||||||
assert!(output.replace_all);
|
assert!(output.replace_all);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_binary_files() {
|
|
||||||
let path = temp_path("binary-test.bin");
|
|
||||||
std::fs::write(&path, b"\x00\x01\x02\x03binary content").expect("write should succeed");
|
|
||||||
let result = read_file(path.to_string_lossy().as_ref(), None, None);
|
|
||||||
assert!(result.is_err());
|
|
||||||
let error = result.unwrap_err();
|
|
||||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
|
||||||
assert!(error.to_string().contains("binary"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_oversized_writes() {
|
|
||||||
let path = temp_path("oversize-write.txt");
|
|
||||||
let huge = "x".repeat(MAX_WRITE_SIZE + 1);
|
|
||||||
let result = write_file(path.to_string_lossy().as_ref(), &huge);
|
|
||||||
assert!(result.is_err());
|
|
||||||
let error = result.unwrap_err();
|
|
||||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
|
||||||
assert!(error.to_string().contains("too large"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enforces_workspace_boundary() {
|
|
||||||
let workspace = temp_path("workspace-boundary");
|
|
||||||
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
|
|
||||||
let inside = workspace.join("inside.txt");
|
|
||||||
write_file(inside.to_string_lossy().as_ref(), "safe content")
|
|
||||||
.expect("write inside workspace should succeed");
|
|
||||||
|
|
||||||
// Reading inside workspace should succeed
|
|
||||||
let result =
|
|
||||||
read_file_in_workspace(inside.to_string_lossy().as_ref(), None, None, &workspace);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
// Reading outside workspace should fail
|
|
||||||
let outside = temp_path("outside-boundary.txt");
|
|
||||||
write_file(outside.to_string_lossy().as_ref(), "unsafe content")
|
|
||||||
.expect("write outside should succeed");
|
|
||||||
let result =
|
|
||||||
read_file_in_workspace(outside.to_string_lossy().as_ref(), None, None, &workspace);
|
|
||||||
assert!(result.is_err());
|
|
||||||
let error = result.unwrap_err();
|
|
||||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
|
||||||
assert!(error.to_string().contains("escapes workspace"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_symlink_escape() {
|
|
||||||
let workspace = temp_path("symlink-workspace");
|
|
||||||
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
|
|
||||||
let outside = temp_path("symlink-target.txt");
|
|
||||||
std::fs::write(&outside, "target content").expect("target should write");
|
|
||||||
|
|
||||||
let link_path = workspace.join("escape-link.txt");
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
std::os::unix::fs::symlink(&outside, &link_path).expect("symlink should create");
|
|
||||||
assert!(is_symlink_escape(&link_path, &workspace).expect("check should succeed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-symlink file should not be an escape
|
|
||||||
let normal = workspace.join("normal.txt");
|
|
||||||
std::fs::write(&normal, "normal content").expect("normal file should write");
|
|
||||||
assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn globs_and_greps_directory() {
|
fn globs_and_greps_directory() {
|
||||||
let dir = temp_path("search-dir");
|
let dir = temp_path("search-dir");
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum GreenLevel {
|
|
||||||
TargetedTests,
|
|
||||||
Package,
|
|
||||||
Workspace,
|
|
||||||
MergeReady,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GreenLevel {
|
|
||||||
#[must_use]
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::TargetedTests => "targeted_tests",
|
|
||||||
Self::Package => "package",
|
|
||||||
Self::Workspace => "workspace",
|
|
||||||
Self::MergeReady => "merge_ready",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for GreenLevel {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct GreenContract {
|
|
||||||
pub required_level: GreenLevel,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GreenContract {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(required_level: GreenLevel) -> Self {
|
|
||||||
Self { required_level }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn evaluate(self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
|
|
||||||
match observed_level {
|
|
||||||
Some(level) if level >= self.required_level => GreenContractOutcome::Satisfied {
|
|
||||||
required_level: self.required_level,
|
|
||||||
observed_level: level,
|
|
||||||
},
|
|
||||||
_ => GreenContractOutcome::Unsatisfied {
|
|
||||||
required_level: self.required_level,
|
|
||||||
observed_level,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_satisfied_by(self, observed_level: GreenLevel) -> bool {
|
|
||||||
observed_level >= self.required_level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
|
||||||
pub enum GreenContractOutcome {
|
|
||||||
Satisfied {
|
|
||||||
required_level: GreenLevel,
|
|
||||||
observed_level: GreenLevel,
|
|
||||||
},
|
|
||||||
Unsatisfied {
|
|
||||||
required_level: GreenLevel,
|
|
||||||
observed_level: Option<GreenLevel>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GreenContractOutcome {
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_satisfied(&self) -> bool {
|
|
||||||
matches!(self, Self::Satisfied { .. })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_matching_level_when_evaluating_contract_then_it_is_satisfied() {
|
|
||||||
// given
|
|
||||||
let contract = GreenContract::new(GreenLevel::Package);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let outcome = contract.evaluate(Some(GreenLevel::Package));
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
outcome,
|
|
||||||
GreenContractOutcome::Satisfied {
|
|
||||||
required_level: GreenLevel::Package,
|
|
||||||
observed_level: GreenLevel::Package,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert!(outcome.is_satisfied());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_higher_level_when_checking_requirement_then_it_still_satisfies_contract() {
|
|
||||||
// given
|
|
||||||
let contract = GreenContract::new(GreenLevel::TargetedTests);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let is_satisfied = contract.is_satisfied_by(GreenLevel::Workspace);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(is_satisfied);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_lower_level_when_evaluating_contract_then_it_is_unsatisfied() {
|
|
||||||
// given
|
|
||||||
let contract = GreenContract::new(GreenLevel::Workspace);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let outcome = contract.evaluate(Some(GreenLevel::Package));
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
outcome,
|
|
||||||
GreenContractOutcome::Unsatisfied {
|
|
||||||
required_level: GreenLevel::Workspace,
|
|
||||||
observed_level: Some(GreenLevel::Package),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert!(!outcome.is_satisfied());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_no_green_level_when_evaluating_contract_then_contract_is_unsatisfied() {
|
|
||||||
// given
|
|
||||||
let contract = GreenContract::new(GreenLevel::MergeReady);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let outcome = contract.evaluate(None);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
outcome,
|
|
||||||
GreenContractOutcome::Unsatisfied {
|
|
||||||
required_level: GreenLevel::MergeReady,
|
|
||||||
observed_level: None,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -80,7 +80,6 @@ impl HookAbortSignal {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct HookRunResult {
|
pub struct HookRunResult {
|
||||||
denied: bool,
|
denied: bool,
|
||||||
failed: bool,
|
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
messages: Vec<String>,
|
messages: Vec<String>,
|
||||||
permission_override: Option<PermissionOverride>,
|
permission_override: Option<PermissionOverride>,
|
||||||
|
|
@ -93,7 +92,6 @@ impl HookRunResult {
|
||||||
pub fn allow(messages: Vec<String>) -> Self {
|
pub fn allow(messages: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
denied: false,
|
denied: false,
|
||||||
failed: false,
|
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
messages,
|
messages,
|
||||||
permission_override: None,
|
permission_override: None,
|
||||||
|
|
@ -107,11 +105,6 @@ impl HookRunResult {
|
||||||
self.denied
|
self.denied
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_failed(&self) -> bool {
|
|
||||||
self.failed
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_cancelled(&self) -> bool {
|
pub fn is_cancelled(&self) -> bool {
|
||||||
self.cancelled
|
self.cancelled
|
||||||
|
|
@ -324,7 +317,6 @@ impl HookRunner {
|
||||||
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
|
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
|
||||||
return HookRunResult {
|
return HookRunResult {
|
||||||
denied: false,
|
denied: false,
|
||||||
failed: false,
|
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
messages: vec![format!(
|
messages: vec![format!(
|
||||||
"{} hook cancelled before execution",
|
"{} hook cancelled before execution",
|
||||||
|
|
@ -380,7 +372,7 @@ impl HookRunner {
|
||||||
result.denied = true;
|
result.denied = true;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
HookCommandOutcome::Failed { parsed } => {
|
HookCommandOutcome::Warn { message } => {
|
||||||
if let Some(reporter) = reporter.as_deref_mut() {
|
if let Some(reporter) = reporter.as_deref_mut() {
|
||||||
reporter.on_event(&HookProgressEvent::Completed {
|
reporter.on_event(&HookProgressEvent::Completed {
|
||||||
event,
|
event,
|
||||||
|
|
@ -388,9 +380,7 @@ impl HookRunner {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
merge_parsed_hook_output(&mut result, parsed);
|
result.messages.push(message);
|
||||||
result.failed = true;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
HookCommandOutcome::Cancelled { message } => {
|
HookCommandOutcome::Cancelled { message } => {
|
||||||
if let Some(reporter) = reporter.as_deref_mut() {
|
if let Some(reporter) = reporter.as_deref_mut() {
|
||||||
|
|
@ -438,7 +428,6 @@ impl HookRunner {
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
let parsed = parse_hook_output(&stdout);
|
let parsed = parse_hook_output(&stdout);
|
||||||
let primary_message = parsed.primary_message().map(ToOwned::to_owned);
|
|
||||||
match output.status.code() {
|
match output.status.code() {
|
||||||
Some(0) => {
|
Some(0) => {
|
||||||
if parsed.deny {
|
if parsed.deny {
|
||||||
|
|
@ -453,20 +442,20 @@ impl HookRunner {
|
||||||
event.as_str()
|
event.as_str()
|
||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
Some(code) => HookCommandOutcome::Failed {
|
Some(code) => HookCommandOutcome::Warn {
|
||||||
parsed: parsed.with_fallback_message(format_hook_failure(
|
message: format_hook_warning(
|
||||||
command,
|
command,
|
||||||
code,
|
code,
|
||||||
primary_message.as_deref(),
|
parsed.primary_message(),
|
||||||
stderr.as_str(),
|
stderr.as_str(),
|
||||||
)),
|
),
|
||||||
},
|
},
|
||||||
None => HookCommandOutcome::Failed {
|
None => HookCommandOutcome::Warn {
|
||||||
parsed: parsed.with_fallback_message(format!(
|
message: format!(
|
||||||
"{} hook `{command}` terminated by signal while handling `{}`",
|
"{} hook `{command}` terminated by signal while handling `{}`",
|
||||||
event.as_str(),
|
event.as_str(),
|
||||||
tool_name
|
tool_name
|
||||||
)),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -476,15 +465,12 @@ impl HookRunner {
|
||||||
event.as_str()
|
event.as_str()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Err(error) => HookCommandOutcome::Failed {
|
Err(error) => HookCommandOutcome::Warn {
|
||||||
parsed: ParsedHookOutput {
|
message: format!(
|
||||||
messages: vec![format!(
|
|
||||||
"{} hook `{command}` failed to start for `{}`: {error}",
|
"{} hook `{command}` failed to start for `{}`: {error}",
|
||||||
event.as_str(),
|
event.as_str(),
|
||||||
tool_name
|
tool_name
|
||||||
)],
|
),
|
||||||
..ParsedHookOutput::default()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -493,7 +479,7 @@ impl HookRunner {
|
||||||
enum HookCommandOutcome {
|
enum HookCommandOutcome {
|
||||||
Allow { parsed: ParsedHookOutput },
|
Allow { parsed: ParsedHookOutput },
|
||||||
Deny { parsed: ParsedHookOutput },
|
Deny { parsed: ParsedHookOutput },
|
||||||
Failed { parsed: ParsedHookOutput },
|
Warn { message: String },
|
||||||
Cancelled { message: String },
|
Cancelled { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -619,8 +605,9 @@ fn parse_tool_input(tool_input: &str) -> Value {
|
||||||
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
|
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
|
||||||
let mut message = format!("Hook `{command}` exited with status {code}");
|
let mut message =
|
||||||
|
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
|
||||||
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
|
||||||
message.push_str(": ");
|
message.push_str(": ");
|
||||||
message.push_str(stdout);
|
message.push_str(stdout);
|
||||||
|
|
@ -762,7 +749,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn propagates_other_non_zero_statuses_as_failures() {
|
fn warns_for_other_non_zero_statuses() {
|
||||||
let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
|
let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
|
||||||
RuntimeHookConfig::new(
|
RuntimeHookConfig::new(
|
||||||
vec![shell_snippet("printf 'warning hook'; exit 1")],
|
vec![shell_snippet("printf 'warning hook'; exit 1")],
|
||||||
|
|
@ -771,16 +758,13 @@ mod tests {
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
// given
|
|
||||||
// when
|
|
||||||
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
|
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
|
||||||
|
|
||||||
// then
|
assert!(!result.is_denied());
|
||||||
assert!(result.is_failed());
|
|
||||||
assert!(result
|
assert!(result
|
||||||
.messages()
|
.messages()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|message| message.contains("warning hook")));
|
.any(|message| message.contains("allowing tool execution to continue")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -806,135 +790,19 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runs_post_tool_use_failure_hooks() {
|
fn runs_post_tool_use_failure_hooks() {
|
||||||
// given
|
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![shell_snippet("printf 'failure hook ran'")],
|
vec![shell_snippet("printf 'failure hook ran'")],
|
||||||
));
|
));
|
||||||
|
|
||||||
// when
|
|
||||||
let result =
|
let result =
|
||||||
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
|
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(!result.is_denied());
|
assert!(!result.is_denied());
|
||||||
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
|
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stops_running_failure_hooks_after_failure() {
|
|
||||||
// given
|
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
|
||||||
vec![
|
|
||||||
shell_snippet("printf 'broken failure hook'; exit 1"),
|
|
||||||
shell_snippet("printf 'later failure hook'"),
|
|
||||||
],
|
|
||||||
));
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result =
|
|
||||||
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(result.is_failed());
|
|
||||||
assert!(result
|
|
||||||
.messages()
|
|
||||||
.iter()
|
|
||||||
.any(|message| message.contains("broken failure hook")));
|
|
||||||
assert!(!result
|
|
||||||
.messages()
|
|
||||||
.iter()
|
|
||||||
.any(|message| message == "later failure hook"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn executes_hooks_in_configured_order() {
|
|
||||||
// given
|
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
|
||||||
vec![
|
|
||||||
shell_snippet("printf 'first'"),
|
|
||||||
shell_snippet("printf 'second'"),
|
|
||||||
],
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
|
||||||
));
|
|
||||||
let mut reporter = RecordingReporter { events: Vec::new() };
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = runner.run_pre_tool_use_with_context(
|
|
||||||
"Read",
|
|
||||||
r#"{"path":"README.md"}"#,
|
|
||||||
None,
|
|
||||||
Some(&mut reporter),
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
HookRunResult::allow(vec!["first".to_string(), "second".to_string()])
|
|
||||||
);
|
|
||||||
assert_eq!(reporter.events.len(), 4);
|
|
||||||
assert!(matches!(
|
|
||||||
&reporter.events[0],
|
|
||||||
HookProgressEvent::Started {
|
|
||||||
event: HookEvent::PreToolUse,
|
|
||||||
command,
|
|
||||||
..
|
|
||||||
} if command == "printf 'first'"
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
&reporter.events[1],
|
|
||||||
HookProgressEvent::Completed {
|
|
||||||
event: HookEvent::PreToolUse,
|
|
||||||
command,
|
|
||||||
..
|
|
||||||
} if command == "printf 'first'"
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
&reporter.events[2],
|
|
||||||
HookProgressEvent::Started {
|
|
||||||
event: HookEvent::PreToolUse,
|
|
||||||
command,
|
|
||||||
..
|
|
||||||
} if command == "printf 'second'"
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
&reporter.events[3],
|
|
||||||
HookProgressEvent::Completed {
|
|
||||||
event: HookEvent::PreToolUse,
|
|
||||||
command,
|
|
||||||
..
|
|
||||||
} if command == "printf 'second'"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stops_running_hooks_after_failure() {
|
|
||||||
// given
|
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
|
||||||
vec![
|
|
||||||
shell_snippet("printf 'broken'; exit 1"),
|
|
||||||
shell_snippet("printf 'later'"),
|
|
||||||
],
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(result.is_failed());
|
|
||||||
assert!(result
|
|
||||||
.messages()
|
|
||||||
.iter()
|
|
||||||
.any(|message| message.contains("broken")));
|
|
||||||
assert!(!result.messages().iter().any(|message| message == "later"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
|
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,21 @@
|
||||||
mod bash;
|
mod bash;
|
||||||
pub mod bash_validation;
|
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
mod compact;
|
mod compact;
|
||||||
mod config;
|
mod config;
|
||||||
mod conversation;
|
mod conversation;
|
||||||
mod file_ops;
|
mod file_ops;
|
||||||
pub mod green_contract;
|
|
||||||
mod hooks;
|
mod hooks;
|
||||||
mod json;
|
mod json;
|
||||||
pub mod lsp_client;
|
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod mcp_client;
|
mod mcp_client;
|
||||||
pub mod mcp_lifecycle_hardened;
|
|
||||||
mod mcp_stdio;
|
mod mcp_stdio;
|
||||||
pub mod mcp_tool_bridge;
|
|
||||||
mod oauth;
|
mod oauth;
|
||||||
pub mod permission_enforcer;
|
|
||||||
mod policy_engine;
|
|
||||||
pub mod recovery_recipes;
|
|
||||||
mod permissions;
|
mod permissions;
|
||||||
pub mod plugin_lifecycle;
|
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod remote;
|
mod remote;
|
||||||
pub mod session_control;
|
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
mod session;
|
mod session;
|
||||||
mod sse;
|
|
||||||
pub mod stale_branch;
|
|
||||||
pub mod summary_compression;
|
|
||||||
pub mod task_registry;
|
|
||||||
pub mod task_packet;
|
|
||||||
pub mod team_cron_registry;
|
|
||||||
pub mod trust_resolver;
|
|
||||||
mod usage;
|
mod usage;
|
||||||
pub mod worker_boot;
|
|
||||||
|
|
||||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||||
|
|
@ -42,17 +24,17 @@ pub use compact::{
|
||||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
|
||||||
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||||
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
||||||
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||||
CLAW_SETTINGS_SCHEMA_NAME,
|
CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent,
|
||||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
|
||||||
ToolExecutor, TurnSummary,
|
StaticToolExecutor, ToolError, ToolExecutor, TurnSummary,
|
||||||
};
|
};
|
||||||
pub use file_ops::{
|
pub use file_ops::{
|
||||||
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
|
||||||
|
|
@ -67,21 +49,16 @@ pub use mcp::{
|
||||||
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
|
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
|
||||||
};
|
};
|
||||||
pub use mcp_client::{
|
pub use mcp_client::{
|
||||||
McpClientAuth, McpClientBootstrap, McpClientTransport, McpManagedProxyTransport,
|
McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
|
||||||
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
||||||
};
|
};
|
||||||
pub use mcp_lifecycle_hardened::{
|
|
||||||
McpDegradedReport, McpErrorSurface, McpFailedServer, McpLifecyclePhase, McpLifecycleState,
|
|
||||||
McpLifecycleValidator, McpPhaseResult,
|
|
||||||
};
|
|
||||||
pub use mcp_stdio::{
|
pub use mcp_stdio::{
|
||||||
spawn_mcp_stdio_process, JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
|
spawn_mcp_stdio_process, JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
|
||||||
ManagedMcpTool, McpDiscoveryFailure, McpInitializeClientInfo, McpInitializeParams,
|
ManagedMcpTool, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult,
|
||||||
McpInitializeResult, McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult,
|
McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult, McpListToolsParams,
|
||||||
McpListToolsParams, McpListToolsResult, McpReadResourceParams, McpReadResourceResult,
|
McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpResource,
|
||||||
McpResource, McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess,
|
McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess, McpTool,
|
||||||
McpTool, McpToolCallContent, McpToolCallParams, McpToolCallResult, McpToolDiscoveryReport,
|
McpToolCallContent, McpToolCallParams, McpToolCallResult, UnsupportedMcpServer,
|
||||||
UnsupportedMcpServer,
|
|
||||||
};
|
};
|
||||||
pub use oauth::{
|
pub use oauth::{
|
||||||
clear_oauth_credentials, code_challenge_s256, credentials_path, generate_pkce_pair,
|
clear_oauth_credentials, code_challenge_s256, credentials_path, generate_pkce_pair,
|
||||||
|
|
@ -90,22 +67,10 @@ pub use oauth::{
|
||||||
OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
|
OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
|
||||||
PkceChallengeMethod, PkceCodePair,
|
PkceChallengeMethod, PkceCodePair,
|
||||||
};
|
};
|
||||||
pub use policy_engine::{
|
|
||||||
evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition,
|
|
||||||
PolicyEngine, PolicyRule, ReviewStatus,
|
|
||||||
};
|
|
||||||
pub use permissions::{
|
pub use permissions::{
|
||||||
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
|
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
|
||||||
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
|
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
|
||||||
};
|
};
|
||||||
pub use plugin_lifecycle::{
|
|
||||||
DegradedMode, DiscoveryResult, PluginHealthcheck, PluginLifecycle, PluginLifecycleEvent,
|
|
||||||
PluginState, ResourceInfo, ServerHealth, ServerStatus, ToolInfo,
|
|
||||||
};
|
|
||||||
pub use recovery_recipes::{
|
|
||||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,
|
|
||||||
RecoveryEvent, RecoveryRecipe, RecoveryResult, RecoveryStep,
|
|
||||||
};
|
|
||||||
pub use prompt::{
|
pub use prompt::{
|
||||||
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
|
||||||
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
|
|
@ -125,24 +90,9 @@ pub use session::{
|
||||||
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
||||||
SessionFork,
|
SessionFork,
|
||||||
};
|
};
|
||||||
pub use stale_branch::{
|
|
||||||
apply_policy, check_freshness, BranchFreshness, StaleBranchAction, StaleBranchEvent,
|
|
||||||
StaleBranchPolicy,
|
|
||||||
};
|
|
||||||
pub use sse::{IncrementalSseParser, SseEvent};
|
|
||||||
pub use task_packet::{
|
|
||||||
validate_packet, AcceptanceTest, BranchPolicy, CommitPolicy,
|
|
||||||
RepoConfig, ReportingContract, TaskPacket, TaskPacketValidationError, TaskScope,
|
|
||||||
ValidatedPacket,
|
|
||||||
};
|
|
||||||
pub use usage::{
|
pub use usage::{
|
||||||
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
||||||
};
|
};
|
||||||
pub use trust_resolver::{TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolver};
|
|
||||||
pub use worker_boot::{
|
|
||||||
Worker, WorkerEvent, WorkerEventKind, WorkerFailure, WorkerFailureKind, WorkerReadySnapshot,
|
|
||||||
WorkerRegistry, WorkerStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
|
|
||||||
|
|
@ -1,746 +0,0 @@
|
||||||
//! LSP (Language Server Protocol) client registry for tool dispatch.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Supported LSP actions.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum LspAction {
|
|
||||||
Diagnostics,
|
|
||||||
Hover,
|
|
||||||
Definition,
|
|
||||||
References,
|
|
||||||
Completion,
|
|
||||||
Symbols,
|
|
||||||
Format,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspAction {
|
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
|
||||||
match s {
|
|
||||||
"diagnostics" => Some(Self::Diagnostics),
|
|
||||||
"hover" => Some(Self::Hover),
|
|
||||||
"definition" | "goto_definition" => Some(Self::Definition),
|
|
||||||
"references" | "find_references" => Some(Self::References),
|
|
||||||
"completion" | "completions" => Some(Self::Completion),
|
|
||||||
"symbols" | "document_symbols" => Some(Self::Symbols),
|
|
||||||
"format" | "formatting" => Some(Self::Format),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LspDiagnostic {
|
|
||||||
pub path: String,
|
|
||||||
pub line: u32,
|
|
||||||
pub character: u32,
|
|
||||||
pub severity: String,
|
|
||||||
pub message: String,
|
|
||||||
pub source: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LspLocation {
|
|
||||||
pub path: String,
|
|
||||||
pub line: u32,
|
|
||||||
pub character: u32,
|
|
||||||
pub end_line: Option<u32>,
|
|
||||||
pub end_character: Option<u32>,
|
|
||||||
pub preview: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LspHoverResult {
|
|
||||||
pub content: String,
|
|
||||||
pub language: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LspCompletionItem {
|
|
||||||
pub label: String,
|
|
||||||
pub kind: Option<String>,
|
|
||||||
pub detail: Option<String>,
|
|
||||||
pub insert_text: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LspSymbol {
|
|
||||||
pub name: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub path: String,
|
|
||||||
pub line: u32,
|
|
||||||
pub character: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum LspServerStatus {
|
|
||||||
Connected,
|
|
||||||
Disconnected,
|
|
||||||
Starting,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for LspServerStatus {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Connected => write!(f, "connected"),
|
|
||||||
Self::Disconnected => write!(f, "disconnected"),
|
|
||||||
Self::Starting => write!(f, "starting"),
|
|
||||||
Self::Error => write!(f, "error"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LspServerState {
|
|
||||||
pub language: String,
|
|
||||||
pub status: LspServerStatus,
|
|
||||||
pub root_path: Option<String>,
|
|
||||||
pub capabilities: Vec<String>,
|
|
||||||
pub diagnostics: Vec<LspDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct LspRegistry {
|
|
||||||
inner: Arc<Mutex<RegistryInner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct RegistryInner {
|
|
||||||
servers: HashMap<String, LspServerState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspRegistry {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register(
|
|
||||||
&self,
|
|
||||||
language: &str,
|
|
||||||
status: LspServerStatus,
|
|
||||||
root_path: Option<&str>,
|
|
||||||
capabilities: Vec<String>,
|
|
||||||
) {
|
|
||||||
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
inner.servers.insert(
|
|
||||||
language.to_owned(),
|
|
||||||
LspServerState {
|
|
||||||
language: language.to_owned(),
|
|
||||||
status,
|
|
||||||
root_path: root_path.map(str::to_owned),
|
|
||||||
capabilities,
|
|
||||||
diagnostics: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, language: &str) -> Option<LspServerState> {
|
|
||||||
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
inner.servers.get(language).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the appropriate server for a file path based on extension.
|
|
||||||
pub fn find_server_for_path(&self, path: &str) -> Option<LspServerState> {
|
|
||||||
let ext = std::path::Path::new(path)
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let language = match ext {
|
|
||||||
"rs" => "rust",
|
|
||||||
"ts" | "tsx" => "typescript",
|
|
||||||
"js" | "jsx" => "javascript",
|
|
||||||
"py" => "python",
|
|
||||||
"go" => "go",
|
|
||||||
"java" => "java",
|
|
||||||
"c" | "h" => "c",
|
|
||||||
"cpp" | "hpp" | "cc" => "cpp",
|
|
||||||
"rb" => "ruby",
|
|
||||||
"lua" => "lua",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.get(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all registered servers.
|
|
||||||
pub fn list_servers(&self) -> Vec<LspServerState> {
|
|
||||||
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
inner.servers.values().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add diagnostics to a server.
|
|
||||||
pub fn add_diagnostics(
|
|
||||||
&self,
|
|
||||||
language: &str,
|
|
||||||
diagnostics: Vec<LspDiagnostic>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
let server = inner
|
|
||||||
.servers
|
|
||||||
.get_mut(language)
|
|
||||||
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
|
|
||||||
server.diagnostics.extend(diagnostics);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get diagnostics for a specific file path.
|
|
||||||
pub fn get_diagnostics(&self, path: &str) -> Vec<LspDiagnostic> {
|
|
||||||
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
inner
|
|
||||||
.servers
|
|
||||||
.values()
|
|
||||||
.flat_map(|s| &s.diagnostics)
|
|
||||||
.filter(|d| d.path == path)
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear diagnostics for a language server.
|
|
||||||
pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
let server = inner
|
|
||||||
.servers
|
|
||||||
.get_mut(language)
|
|
||||||
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
|
|
||||||
server.diagnostics.clear();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect a server.
|
|
||||||
pub fn disconnect(&self, language: &str) -> Option<LspServerState> {
|
|
||||||
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
inner.servers.remove(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
inner.servers.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch an LSP action and return a structured result.
|
|
||||||
pub fn dispatch(
|
|
||||||
&self,
|
|
||||||
action: &str,
|
|
||||||
path: Option<&str>,
|
|
||||||
line: Option<u32>,
|
|
||||||
character: Option<u32>,
|
|
||||||
_query: Option<&str>,
|
|
||||||
) -> Result<serde_json::Value, String> {
|
|
||||||
let lsp_action =
|
|
||||||
LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?;
|
|
||||||
|
|
||||||
// For diagnostics, we can check existing cached diagnostics
|
|
||||||
if lsp_action == LspAction::Diagnostics {
|
|
||||||
if let Some(path) = path {
|
|
||||||
let diags = self.get_diagnostics(path);
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"action": "diagnostics",
|
|
||||||
"path": path,
|
|
||||||
"diagnostics": diags,
|
|
||||||
"count": diags.len()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// All diagnostics across all servers
|
|
||||||
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
||||||
let all_diags: Vec<_> = inner
|
|
||||||
.servers
|
|
||||||
.values()
|
|
||||||
.flat_map(|s| &s.diagnostics)
|
|
||||||
.collect();
|
|
||||||
return Ok(serde_json::json!({
|
|
||||||
"action": "diagnostics",
|
|
||||||
"diagnostics": all_diags,
|
|
||||||
"count": all_diags.len()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other actions, we need a connected server for the given file
|
|
||||||
let path = path.ok_or("path is required for this LSP action")?;
|
|
||||||
let server = self
|
|
||||||
.find_server_for_path(path)
|
|
||||||
.ok_or_else(|| format!("no LSP server available for path: {path}"))?;
|
|
||||||
|
|
||||||
if server.status != LspServerStatus::Connected {
|
|
||||||
return Err(format!(
|
|
||||||
"LSP server for '{}' is not connected (status: {})",
|
|
||||||
server.language, server.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return structured placeholder — actual LSP JSON-RPC calls would
|
|
||||||
// go through the real LSP process here.
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"action": action,
|
|
||||||
"path": path,
|
|
||||||
"line": line,
|
|
||||||
"character": character,
|
|
||||||
"language": server.language,
|
|
||||||
"status": "dispatched",
|
|
||||||
"message": format!("LSP {} dispatched to {} server", action, server.language)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn registers_and_retrieves_server() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register(
|
|
||||||
"rust",
|
|
||||||
LspServerStatus::Connected,
|
|
||||||
Some("/workspace"),
|
|
||||||
vec!["hover".into(), "completion".into()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let server = registry.get("rust").expect("should exist");
|
|
||||||
assert_eq!(server.language, "rust");
|
|
||||||
assert_eq!(server.status, LspServerStatus::Connected);
|
|
||||||
assert_eq!(server.capabilities.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finds_server_by_file_extension() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
registry.register("typescript", LspServerStatus::Connected, None, vec![]);
|
|
||||||
|
|
||||||
let rs_server = registry.find_server_for_path("src/main.rs").unwrap();
|
|
||||||
assert_eq!(rs_server.language, "rust");
|
|
||||||
|
|
||||||
let ts_server = registry.find_server_for_path("src/index.ts").unwrap();
|
|
||||||
assert_eq!(ts_server.language, "typescript");
|
|
||||||
|
|
||||||
assert!(registry.find_server_for_path("data.csv").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manages_diagnostics() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
|
|
||||||
registry
|
|
||||||
.add_diagnostics(
|
|
||||||
"rust",
|
|
||||||
vec![LspDiagnostic {
|
|
||||||
path: "src/main.rs".into(),
|
|
||||||
line: 10,
|
|
||||||
character: 5,
|
|
||||||
severity: "error".into(),
|
|
||||||
message: "mismatched types".into(),
|
|
||||||
source: Some("rust-analyzer".into()),
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let diags = registry.get_diagnostics("src/main.rs");
|
|
||||||
assert_eq!(diags.len(), 1);
|
|
||||||
assert_eq!(diags[0].message, "mismatched types");
|
|
||||||
|
|
||||||
registry.clear_diagnostics("rust").unwrap();
|
|
||||||
assert!(registry.get_diagnostics("src/main.rs").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatches_diagnostics_action() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
registry
|
|
||||||
.add_diagnostics(
|
|
||||||
"rust",
|
|
||||||
vec![LspDiagnostic {
|
|
||||||
path: "src/lib.rs".into(),
|
|
||||||
line: 1,
|
|
||||||
character: 0,
|
|
||||||
severity: "warning".into(),
|
|
||||||
message: "unused import".into(),
|
|
||||||
source: None,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = registry
|
|
||||||
.dispatch("diagnostics", Some("src/lib.rs"), None, None, None)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result["count"], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatches_hover_action() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
|
|
||||||
let result = registry
|
|
||||||
.dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result["action"], "hover");
|
|
||||||
assert_eq!(result["language"], "rust");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_action_on_disconnected_server() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Disconnected, None, vec![]);
|
|
||||||
|
|
||||||
assert!(registry
|
|
||||||
.dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_unknown_action() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
assert!(registry
|
|
||||||
.dispatch("unknown_action", Some("file.rs"), None, None, None)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disconnects_server() {
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
assert_eq!(registry.len(), 1);
|
|
||||||
|
|
||||||
let removed = registry.disconnect("rust");
|
|
||||||
assert!(removed.is_some());
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lsp_action_from_str_all_aliases() {
|
|
||||||
// given
|
|
||||||
let cases = [
|
|
||||||
("diagnostics", Some(LspAction::Diagnostics)),
|
|
||||||
("hover", Some(LspAction::Hover)),
|
|
||||||
("definition", Some(LspAction::Definition)),
|
|
||||||
("goto_definition", Some(LspAction::Definition)),
|
|
||||||
("references", Some(LspAction::References)),
|
|
||||||
("find_references", Some(LspAction::References)),
|
|
||||||
("completion", Some(LspAction::Completion)),
|
|
||||||
("completions", Some(LspAction::Completion)),
|
|
||||||
("symbols", Some(LspAction::Symbols)),
|
|
||||||
("document_symbols", Some(LspAction::Symbols)),
|
|
||||||
("format", Some(LspAction::Format)),
|
|
||||||
("formatting", Some(LspAction::Format)),
|
|
||||||
("unknown", None),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let resolved: Vec<_> = cases
|
|
||||||
.into_iter()
|
|
||||||
.map(|(input, expected)| (input, LspAction::from_str(input), expected))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// then
|
|
||||||
for (input, actual, expected) in resolved {
|
|
||||||
assert_eq!(actual, expected, "unexpected action resolution for {input}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lsp_server_status_display_all_variants() {
|
|
||||||
// given
|
|
||||||
let cases = [
|
|
||||||
(LspServerStatus::Connected, "connected"),
|
|
||||||
(LspServerStatus::Disconnected, "disconnected"),
|
|
||||||
(LspServerStatus::Starting, "starting"),
|
|
||||||
(LspServerStatus::Error, "error"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let rendered: Vec<_> = cases
|
|
||||||
.into_iter()
|
|
||||||
.map(|(status, expected)| (status.to_string(), expected))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
rendered,
|
|
||||||
vec![
|
|
||||||
("connected".to_string(), "connected"),
|
|
||||||
("disconnected".to_string(), "disconnected"),
|
|
||||||
("starting".to_string(), "starting"),
|
|
||||||
("error".to_string(), "error"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatch_diagnostics_without_path_aggregates() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
registry.register("python", LspServerStatus::Connected, None, vec![]);
|
|
||||||
registry
|
|
||||||
.add_diagnostics(
|
|
||||||
"rust",
|
|
||||||
vec![LspDiagnostic {
|
|
||||||
path: "src/lib.rs".into(),
|
|
||||||
line: 1,
|
|
||||||
character: 0,
|
|
||||||
severity: "warning".into(),
|
|
||||||
message: "unused import".into(),
|
|
||||||
source: Some("rust-analyzer".into()),
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.expect("rust diagnostics should add");
|
|
||||||
registry
|
|
||||||
.add_diagnostics(
|
|
||||||
"python",
|
|
||||||
vec![LspDiagnostic {
|
|
||||||
path: "script.py".into(),
|
|
||||||
line: 2,
|
|
||||||
character: 4,
|
|
||||||
severity: "error".into(),
|
|
||||||
message: "undefined name".into(),
|
|
||||||
source: Some("pyright".into()),
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.expect("python diagnostics should add");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry
|
|
||||||
.dispatch("diagnostics", None, None, None, None)
|
|
||||||
.expect("aggregate diagnostics should work");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(result["action"], "diagnostics");
|
|
||||||
assert_eq!(result["count"], 2);
|
|
||||||
assert_eq!(result["diagnostics"].as_array().map(Vec::len), Some(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatch_non_diagnostics_requires_path() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.dispatch("hover", None, Some(1), Some(0), None);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
result.expect_err("path should be required"),
|
|
||||||
"path is required for this LSP action"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatch_no_server_for_path_errors() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.dispatch("hover", Some("notes.md"), Some(1), Some(0), None);
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("missing server should fail");
|
|
||||||
assert!(error.contains("no LSP server available for path: notes.md"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatch_disconnected_server_error_payload() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("typescript", LspServerStatus::Disconnected, None, vec![]);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.dispatch("hover", Some("src/index.ts"), Some(3), Some(2), None);
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("disconnected server should fail");
|
|
||||||
assert!(error.contains("typescript"));
|
|
||||||
assert!(error.contains("disconnected"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_server_for_all_extensions() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
for language in [
|
|
||||||
"rust",
|
|
||||||
"typescript",
|
|
||||||
"javascript",
|
|
||||||
"python",
|
|
||||||
"go",
|
|
||||||
"java",
|
|
||||||
"c",
|
|
||||||
"cpp",
|
|
||||||
"ruby",
|
|
||||||
"lua",
|
|
||||||
] {
|
|
||||||
registry.register(language, LspServerStatus::Connected, None, vec![]);
|
|
||||||
}
|
|
||||||
let cases = [
|
|
||||||
("src/main.rs", "rust"),
|
|
||||||
("src/index.ts", "typescript"),
|
|
||||||
("src/view.tsx", "typescript"),
|
|
||||||
("src/app.js", "javascript"),
|
|
||||||
("src/app.jsx", "javascript"),
|
|
||||||
("script.py", "python"),
|
|
||||||
("main.go", "go"),
|
|
||||||
("Main.java", "java"),
|
|
||||||
("native.c", "c"),
|
|
||||||
("native.h", "c"),
|
|
||||||
("native.cpp", "cpp"),
|
|
||||||
("native.hpp", "cpp"),
|
|
||||||
("native.cc", "cpp"),
|
|
||||||
("script.rb", "ruby"),
|
|
||||||
("script.lua", "lua"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let resolved: Vec<_> = cases
|
|
||||||
.into_iter()
|
|
||||||
.map(|(path, expected)| {
|
|
||||||
(
|
|
||||||
path,
|
|
||||||
registry
|
|
||||||
.find_server_for_path(path)
|
|
||||||
.map(|server| server.language),
|
|
||||||
expected,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// then
|
|
||||||
for (path, actual, expected) in resolved {
|
|
||||||
assert_eq!(
|
|
||||||
actual.as_deref(),
|
|
||||||
Some(expected),
|
|
||||||
"unexpected mapping for {path}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_server_for_path_no_extension() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.find_server_for_path("Makefile");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_servers_with_multiple() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
registry.register("typescript", LspServerStatus::Starting, None, vec![]);
|
|
||||||
registry.register("python", LspServerStatus::Error, None, vec![]);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let servers = registry.list_servers();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(servers.len(), 3);
|
|
||||||
assert!(servers.iter().any(|server| server.language == "rust"));
|
|
||||||
assert!(servers.iter().any(|server| server.language == "typescript"));
|
|
||||||
assert!(servers.iter().any(|server| server.language == "python"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn get_missing_server_returns_none() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let server = registry.get("missing");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(server.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_diagnostics_missing_language_errors() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.add_diagnostics("missing", vec![]);
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("missing language should fail");
|
|
||||||
assert!(error.contains("LSP server not found for language: missing"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn get_diagnostics_across_servers() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
let shared_path = "shared/file.txt";
|
|
||||||
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
||||||
registry.register("python", LspServerStatus::Connected, None, vec![]);
|
|
||||||
registry
|
|
||||||
.add_diagnostics(
|
|
||||||
"rust",
|
|
||||||
vec![LspDiagnostic {
|
|
||||||
path: shared_path.into(),
|
|
||||||
line: 4,
|
|
||||||
character: 1,
|
|
||||||
severity: "warning".into(),
|
|
||||||
message: "warn".into(),
|
|
||||||
source: None,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.expect("rust diagnostics should add");
|
|
||||||
registry
|
|
||||||
.add_diagnostics(
|
|
||||||
"python",
|
|
||||||
vec![LspDiagnostic {
|
|
||||||
path: shared_path.into(),
|
|
||||||
line: 8,
|
|
||||||
character: 3,
|
|
||||||
severity: "error".into(),
|
|
||||||
message: "err".into(),
|
|
||||||
source: None,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.expect("python diagnostics should add");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let diagnostics = registry.get_diagnostics(shared_path);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(diagnostics.len(), 2);
|
|
||||||
assert!(diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| diagnostic.message == "warn"));
|
|
||||||
assert!(diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| diagnostic.message == "err"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_diagnostics_missing_language_errors() {
|
|
||||||
// given
|
|
||||||
let registry = LspRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.clear_diagnostics("missing");
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("missing language should fail");
|
|
||||||
assert!(error.contains("LSP server not found for language: missing"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -84,13 +84,10 @@ pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
|
||||||
pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
|
pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
|
||||||
let rendered = match &config.config {
|
let rendered = match &config.config {
|
||||||
McpServerConfig::Stdio(stdio) => format!(
|
McpServerConfig::Stdio(stdio) => format!(
|
||||||
"stdio|{}|{}|{}|{}",
|
"stdio|{}|{}|{}",
|
||||||
stdio.command,
|
stdio.command,
|
||||||
render_command_signature(&stdio.args),
|
render_command_signature(&stdio.args),
|
||||||
render_env_signature(&stdio.env),
|
render_env_signature(&stdio.env)
|
||||||
stdio
|
|
||||||
.tool_call_timeout_ms
|
|
||||||
.map_or_else(String::new, |timeout_ms| timeout_ms.to_string())
|
|
||||||
),
|
),
|
||||||
McpServerConfig::Sse(remote) => format!(
|
McpServerConfig::Sse(remote) => format!(
|
||||||
"sse|{}|{}|{}|{}",
|
"sse|{}|{}|{}|{}",
|
||||||
|
|
@ -248,7 +245,6 @@ mod tests {
|
||||||
command: "uvx".to_string(),
|
command: "uvx".to_string(),
|
||||||
args: vec!["mcp-server".to_string()],
|
args: vec!["mcp-server".to_string()],
|
||||||
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
|
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
|
||||||
tool_call_timeout_ms: None,
|
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mcp_server_signature(&stdio),
|
mcp_server_signature(&stdio),
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ use std::collections::BTreeMap;
|
||||||
use crate::config::{McpOAuthConfig, McpServerConfig, ScopedMcpServerConfig};
|
use crate::config::{McpOAuthConfig, McpServerConfig, ScopedMcpServerConfig};
|
||||||
use crate::mcp::{mcp_server_signature, mcp_tool_prefix, normalize_name_for_mcp};
|
use crate::mcp::{mcp_server_signature, mcp_tool_prefix, normalize_name_for_mcp};
|
||||||
|
|
||||||
pub const DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS: u64 = 60_000;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum McpClientTransport {
|
pub enum McpClientTransport {
|
||||||
Stdio(McpStdioTransport),
|
Stdio(McpStdioTransport),
|
||||||
|
|
@ -20,7 +18,6 @@ pub struct McpStdioTransport {
|
||||||
pub command: String,
|
pub command: String,
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
pub env: BTreeMap<String, String>,
|
pub env: BTreeMap<String, String>,
|
||||||
pub tool_call_timeout_ms: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -78,7 +75,6 @@ impl McpClientTransport {
|
||||||
command: config.command.clone(),
|
command: config.command.clone(),
|
||||||
args: config.args.clone(),
|
args: config.args.clone(),
|
||||||
env: config.env.clone(),
|
env: config.env.clone(),
|
||||||
tool_call_timeout_ms: config.tool_call_timeout_ms,
|
|
||||||
}),
|
}),
|
||||||
McpServerConfig::Sse(config) => Self::Sse(McpRemoteTransport {
|
McpServerConfig::Sse(config) => Self::Sse(McpRemoteTransport {
|
||||||
url: config.url.clone(),
|
url: config.url.clone(),
|
||||||
|
|
@ -101,20 +97,14 @@ impl McpClientTransport {
|
||||||
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
|
McpServerConfig::Sdk(config) => Self::Sdk(McpSdkTransport {
|
||||||
name: config.name.clone(),
|
name: config.name.clone(),
|
||||||
}),
|
}),
|
||||||
McpServerConfig::ManagedProxy(config) => Self::ManagedProxy(McpManagedProxyTransport {
|
McpServerConfig::ManagedProxy(config) => {
|
||||||
|
Self::ManagedProxy(McpManagedProxyTransport {
|
||||||
url: config.url.clone(),
|
url: config.url.clone(),
|
||||||
id: config.id.clone(),
|
id: config.id.clone(),
|
||||||
}),
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpStdioTransport {
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolved_tool_call_timeout_ms(&self) -> u64 {
|
|
||||||
self.tool_call_timeout_ms
|
|
||||||
.unwrap_or(DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpClientAuth {
|
impl McpClientAuth {
|
||||||
|
|
@ -148,7 +138,6 @@ mod tests {
|
||||||
command: "uvx".to_string(),
|
command: "uvx".to_string(),
|
||||||
args: vec!["mcp-server".to_string()],
|
args: vec!["mcp-server".to_string()],
|
||||||
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
|
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
|
||||||
tool_call_timeout_ms: Some(15_000),
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -167,7 +156,6 @@ mod tests {
|
||||||
transport.env.get("TOKEN").map(String::as_str),
|
transport.env.get("TOKEN").map(String::as_str),
|
||||||
Some("secret")
|
Some("secret")
|
||||||
);
|
);
|
||||||
assert_eq!(transport.tool_call_timeout_ms, Some(15_000));
|
|
||||||
}
|
}
|
||||||
other => panic!("expected stdio transport, got {other:?}"),
|
other => panic!("expected stdio transport, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,761 +0,0 @@
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum McpLifecyclePhase {
|
|
||||||
ConfigLoad,
|
|
||||||
ServerRegistration,
|
|
||||||
SpawnConnect,
|
|
||||||
InitializeHandshake,
|
|
||||||
ToolDiscovery,
|
|
||||||
ResourceDiscovery,
|
|
||||||
Ready,
|
|
||||||
Invocation,
|
|
||||||
ErrorSurfacing,
|
|
||||||
Shutdown,
|
|
||||||
Cleanup,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpLifecyclePhase {
|
|
||||||
#[must_use]
|
|
||||||
pub fn all() -> [Self; 11] {
|
|
||||||
[
|
|
||||||
Self::ConfigLoad,
|
|
||||||
Self::ServerRegistration,
|
|
||||||
Self::SpawnConnect,
|
|
||||||
Self::InitializeHandshake,
|
|
||||||
Self::ToolDiscovery,
|
|
||||||
Self::ResourceDiscovery,
|
|
||||||
Self::Ready,
|
|
||||||
Self::Invocation,
|
|
||||||
Self::ErrorSurfacing,
|
|
||||||
Self::Shutdown,
|
|
||||||
Self::Cleanup,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for McpLifecyclePhase {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::ConfigLoad => write!(f, "config_load"),
|
|
||||||
Self::ServerRegistration => write!(f, "server_registration"),
|
|
||||||
Self::SpawnConnect => write!(f, "spawn_connect"),
|
|
||||||
Self::InitializeHandshake => write!(f, "initialize_handshake"),
|
|
||||||
Self::ToolDiscovery => write!(f, "tool_discovery"),
|
|
||||||
Self::ResourceDiscovery => write!(f, "resource_discovery"),
|
|
||||||
Self::Ready => write!(f, "ready"),
|
|
||||||
Self::Invocation => write!(f, "invocation"),
|
|
||||||
Self::ErrorSurfacing => write!(f, "error_surfacing"),
|
|
||||||
Self::Shutdown => write!(f, "shutdown"),
|
|
||||||
Self::Cleanup => write!(f, "cleanup"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct McpErrorSurface {
|
|
||||||
pub phase: McpLifecyclePhase,
|
|
||||||
pub server_name: Option<String>,
|
|
||||||
pub message: String,
|
|
||||||
pub context: BTreeMap<String, String>,
|
|
||||||
pub recoverable: bool,
|
|
||||||
pub timestamp: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpErrorSurface {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(
|
|
||||||
phase: McpLifecyclePhase,
|
|
||||||
server_name: Option<String>,
|
|
||||||
message: impl Into<String>,
|
|
||||||
context: BTreeMap<String, String>,
|
|
||||||
recoverable: bool,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
phase,
|
|
||||||
server_name,
|
|
||||||
message: message.into(),
|
|
||||||
context,
|
|
||||||
recoverable,
|
|
||||||
timestamp: now_secs(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for McpErrorSurface {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"MCP lifecycle error during {}: {}",
|
|
||||||
self.phase, self.message
|
|
||||||
)?;
|
|
||||||
if let Some(server_name) = &self.server_name {
|
|
||||||
write!(f, " (server: {server_name})")?;
|
|
||||||
}
|
|
||||||
if !self.context.is_empty() {
|
|
||||||
write!(f, " with context {:?}", self.context)?;
|
|
||||||
}
|
|
||||||
if self.recoverable {
|
|
||||||
write!(f, " [recoverable]")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for McpErrorSurface {}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum McpPhaseResult {
|
|
||||||
Success {
|
|
||||||
phase: McpLifecyclePhase,
|
|
||||||
duration: Duration,
|
|
||||||
},
|
|
||||||
Failure {
|
|
||||||
phase: McpLifecyclePhase,
|
|
||||||
error: McpErrorSurface,
|
|
||||||
recoverable: bool,
|
|
||||||
},
|
|
||||||
Timeout {
|
|
||||||
phase: McpLifecyclePhase,
|
|
||||||
waited: Duration,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpPhaseResult {
|
|
||||||
#[must_use]
|
|
||||||
pub fn phase(&self) -> McpLifecyclePhase {
|
|
||||||
match self {
|
|
||||||
Self::Success { phase, .. }
|
|
||||||
| Self::Failure { phase, .. }
|
|
||||||
| Self::Timeout { phase, .. } => *phase,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct McpLifecycleState {
|
|
||||||
current_phase: Option<McpLifecyclePhase>,
|
|
||||||
phase_errors: BTreeMap<McpLifecyclePhase, Vec<McpErrorSurface>>,
|
|
||||||
phase_timestamps: BTreeMap<McpLifecyclePhase, u64>,
|
|
||||||
phase_results: Vec<McpPhaseResult>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpLifecycleState {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_phase(&self) -> Option<McpLifecyclePhase> {
|
|
||||||
self.current_phase
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn errors_for_phase(&self, phase: McpLifecyclePhase) -> &[McpErrorSurface] {
|
|
||||||
self.phase_errors
|
|
||||||
.get(&phase)
|
|
||||||
.map(Vec::as_slice)
|
|
||||||
.unwrap_or(&[])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn results(&self) -> &[McpPhaseResult] {
|
|
||||||
&self.phase_results
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn phase_timestamps(&self) -> &BTreeMap<McpLifecyclePhase, u64> {
|
|
||||||
&self.phase_timestamps
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn phase_timestamp(&self, phase: McpLifecyclePhase) -> Option<u64> {
|
|
||||||
self.phase_timestamps.get(&phase).copied()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_phase(&mut self, phase: McpLifecyclePhase) {
|
|
||||||
self.current_phase = Some(phase);
|
|
||||||
self.phase_timestamps.insert(phase, now_secs());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_error(&mut self, error: McpErrorSurface) {
|
|
||||||
self.phase_errors
|
|
||||||
.entry(error.phase)
|
|
||||||
.or_default()
|
|
||||||
.push(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_result(&mut self, result: McpPhaseResult) {
|
|
||||||
self.phase_results.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct McpFailedServer {
|
|
||||||
pub server_name: String,
|
|
||||||
pub phase: McpLifecyclePhase,
|
|
||||||
pub error: McpErrorSurface,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct McpDegradedReport {
|
|
||||||
pub working_servers: Vec<String>,
|
|
||||||
pub failed_servers: Vec<McpFailedServer>,
|
|
||||||
pub available_tools: Vec<String>,
|
|
||||||
pub missing_tools: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpDegradedReport {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(
|
|
||||||
working_servers: Vec<String>,
|
|
||||||
failed_servers: Vec<McpFailedServer>,
|
|
||||||
available_tools: Vec<String>,
|
|
||||||
expected_tools: Vec<String>,
|
|
||||||
) -> Self {
|
|
||||||
let working_servers = dedupe_sorted(working_servers);
|
|
||||||
let available_tools = dedupe_sorted(available_tools);
|
|
||||||
let available_tool_set: BTreeSet<_> = available_tools.iter().cloned().collect();
|
|
||||||
let expected_tools = dedupe_sorted(expected_tools);
|
|
||||||
let missing_tools = expected_tools
|
|
||||||
.into_iter()
|
|
||||||
.filter(|tool| !available_tool_set.contains(tool))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
working_servers,
|
|
||||||
failed_servers,
|
|
||||||
available_tools,
|
|
||||||
missing_tools,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct McpLifecycleValidator {
|
|
||||||
state: McpLifecycleState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpLifecycleValidator {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn state(&self) -> &McpLifecycleState {
|
|
||||||
&self.state
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn validate_phase_transition(from: McpLifecyclePhase, to: McpLifecyclePhase) -> bool {
|
|
||||||
match (from, to) {
|
|
||||||
(McpLifecyclePhase::ConfigLoad, McpLifecyclePhase::ServerRegistration)
|
|
||||||
| (McpLifecyclePhase::ServerRegistration, McpLifecyclePhase::SpawnConnect)
|
|
||||||
| (McpLifecyclePhase::SpawnConnect, McpLifecyclePhase::InitializeHandshake)
|
|
||||||
| (McpLifecyclePhase::InitializeHandshake, McpLifecyclePhase::ToolDiscovery)
|
|
||||||
| (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::ResourceDiscovery)
|
|
||||||
| (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready)
|
|
||||||
| (McpLifecyclePhase::ResourceDiscovery, McpLifecyclePhase::Ready)
|
|
||||||
| (McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation)
|
|
||||||
| (McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready)
|
|
||||||
| (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Ready)
|
|
||||||
| (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Shutdown)
|
|
||||||
| (McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup) => true,
|
|
||||||
(_, McpLifecyclePhase::Shutdown) => from != McpLifecyclePhase::Cleanup,
|
|
||||||
(_, McpLifecyclePhase::ErrorSurfacing) => {
|
|
||||||
from != McpLifecyclePhase::Cleanup && from != McpLifecyclePhase::Shutdown
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_phase(&mut self, phase: McpLifecyclePhase) -> McpPhaseResult {
|
|
||||||
let started = Instant::now();
|
|
||||||
|
|
||||||
if let Some(current_phase) = self.state.current_phase() {
|
|
||||||
if !Self::validate_phase_transition(current_phase, phase) {
|
|
||||||
return self.record_failure(
|
|
||||||
phase,
|
|
||||||
McpErrorSurface::new(
|
|
||||||
phase,
|
|
||||||
None,
|
|
||||||
format!("invalid MCP lifecycle transition from {current_phase} to {phase}"),
|
|
||||||
BTreeMap::from([
|
|
||||||
("from".to_string(), current_phase.to_string()),
|
|
||||||
("to".to_string(), phase.to_string()),
|
|
||||||
]),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if phase != McpLifecyclePhase::ConfigLoad {
|
|
||||||
return self.record_failure(
|
|
||||||
phase,
|
|
||||||
McpErrorSurface::new(
|
|
||||||
phase,
|
|
||||||
None,
|
|
||||||
format!("invalid initial MCP lifecycle phase {phase}"),
|
|
||||||
BTreeMap::from([("phase".to_string(), phase.to_string())]),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.state.record_phase(phase);
|
|
||||||
let result = McpPhaseResult::Success {
|
|
||||||
phase,
|
|
||||||
duration: started.elapsed(),
|
|
||||||
};
|
|
||||||
self.state.record_result(result.clone());
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_failure(
|
|
||||||
&mut self,
|
|
||||||
phase: McpLifecyclePhase,
|
|
||||||
error: McpErrorSurface,
|
|
||||||
recoverable: bool,
|
|
||||||
) -> McpPhaseResult {
|
|
||||||
self.state.record_error(error.clone());
|
|
||||||
self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
|
|
||||||
let result = McpPhaseResult::Failure {
|
|
||||||
phase,
|
|
||||||
error,
|
|
||||||
recoverable,
|
|
||||||
};
|
|
||||||
self.state.record_result(result.clone());
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_timeout(
|
|
||||||
&mut self,
|
|
||||||
phase: McpLifecyclePhase,
|
|
||||||
waited: Duration,
|
|
||||||
server_name: Option<String>,
|
|
||||||
mut context: BTreeMap<String, String>,
|
|
||||||
) -> McpPhaseResult {
|
|
||||||
context.insert("waited_ms".to_string(), waited.as_millis().to_string());
|
|
||||||
let error = McpErrorSurface::new(
|
|
||||||
phase,
|
|
||||||
server_name,
|
|
||||||
format!(
|
|
||||||
"MCP lifecycle phase {phase} timed out after {} ms",
|
|
||||||
waited.as_millis()
|
|
||||||
),
|
|
||||||
context,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
self.state.record_error(error);
|
|
||||||
self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
|
|
||||||
let result = McpPhaseResult::Timeout { phase, waited };
|
|
||||||
self.state.record_result(result.clone());
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dedupe_sorted(mut values: Vec<String>) -> Vec<String> {
|
|
||||||
values.sort();
|
|
||||||
values.dedup();
|
|
||||||
values
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn phase_display_matches_serde_name() {
|
|
||||||
// given
|
|
||||||
let phases = McpLifecyclePhase::all();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let serialized = phases
|
|
||||||
.into_iter()
|
|
||||||
.map(|phase| {
|
|
||||||
(
|
|
||||||
phase.to_string(),
|
|
||||||
serde_json::to_value(phase).expect("serialize phase"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
// then
|
|
||||||
for (display, json_value) in serialized {
|
|
||||||
assert_eq!(json_value, json!(display));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_startup_path_when_running_to_cleanup_then_each_control_transition_succeeds() {
|
|
||||||
// given
|
|
||||||
let mut validator = McpLifecycleValidator::new();
|
|
||||||
let phases = [
|
|
||||||
McpLifecyclePhase::ConfigLoad,
|
|
||||||
McpLifecyclePhase::ServerRegistration,
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
McpLifecyclePhase::InitializeHandshake,
|
|
||||||
McpLifecyclePhase::ToolDiscovery,
|
|
||||||
McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
McpLifecyclePhase::Ready,
|
|
||||||
McpLifecyclePhase::Invocation,
|
|
||||||
McpLifecyclePhase::Ready,
|
|
||||||
McpLifecyclePhase::Shutdown,
|
|
||||||
McpLifecyclePhase::Cleanup,
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let results = phases
|
|
||||||
.into_iter()
|
|
||||||
.map(|phase| validator.run_phase(phase))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(results
|
|
||||||
.iter()
|
|
||||||
.all(|result| matches!(result, McpPhaseResult::Success { .. })));
|
|
||||||
assert_eq!(
|
|
||||||
validator.state().current_phase(),
|
|
||||||
Some(McpLifecyclePhase::Cleanup)
|
|
||||||
);
|
|
||||||
for phase in [
|
|
||||||
McpLifecyclePhase::ConfigLoad,
|
|
||||||
McpLifecyclePhase::ServerRegistration,
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
McpLifecyclePhase::InitializeHandshake,
|
|
||||||
McpLifecyclePhase::ToolDiscovery,
|
|
||||||
McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
McpLifecyclePhase::Ready,
|
|
||||||
McpLifecyclePhase::Invocation,
|
|
||||||
McpLifecyclePhase::Shutdown,
|
|
||||||
McpLifecyclePhase::Cleanup,
|
|
||||||
] {
|
|
||||||
assert!(validator.state().phase_timestamp(phase).is_some());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_tool_discovery_when_resource_discovery_is_skipped_then_ready_is_still_allowed() {
|
|
||||||
// given
|
|
||||||
let mut validator = McpLifecycleValidator::new();
|
|
||||||
for phase in [
|
|
||||||
McpLifecyclePhase::ConfigLoad,
|
|
||||||
McpLifecyclePhase::ServerRegistration,
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
McpLifecyclePhase::InitializeHandshake,
|
|
||||||
McpLifecyclePhase::ToolDiscovery,
|
|
||||||
] {
|
|
||||||
let result = validator.run_phase(phase);
|
|
||||||
assert!(matches!(result, McpPhaseResult::Success { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = validator.run_phase(McpLifecyclePhase::Ready);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(matches!(result, McpPhaseResult::Success { .. }));
|
|
||||||
assert_eq!(
|
|
||||||
validator.state().current_phase(),
|
|
||||||
Some(McpLifecyclePhase::Ready)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validates_expected_phase_transitions() {
|
|
||||||
// given
|
|
||||||
let valid_transitions = [
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::ConfigLoad,
|
|
||||||
McpLifecyclePhase::ServerRegistration,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::ServerRegistration,
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
McpLifecyclePhase::InitializeHandshake,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::InitializeHandshake,
|
|
||||||
McpLifecyclePhase::ToolDiscovery,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::ToolDiscovery,
|
|
||||||
McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
),
|
|
||||||
(McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready),
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
McpLifecyclePhase::Ready,
|
|
||||||
),
|
|
||||||
(McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation),
|
|
||||||
(McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready),
|
|
||||||
(McpLifecyclePhase::Ready, McpLifecyclePhase::Shutdown),
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::Invocation,
|
|
||||||
McpLifecyclePhase::ErrorSurfacing,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
McpLifecyclePhase::ErrorSurfacing,
|
|
||||||
McpLifecyclePhase::Shutdown,
|
|
||||||
),
|
|
||||||
(McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when / then
|
|
||||||
for (from, to) in valid_transitions {
|
|
||||||
assert!(McpLifecycleValidator::validate_phase_transition(from, to));
|
|
||||||
}
|
|
||||||
assert!(!McpLifecycleValidator::validate_phase_transition(
|
|
||||||
McpLifecyclePhase::Ready,
|
|
||||||
McpLifecyclePhase::ConfigLoad,
|
|
||||||
));
|
|
||||||
assert!(!McpLifecycleValidator::validate_phase_transition(
|
|
||||||
McpLifecyclePhase::Cleanup,
|
|
||||||
McpLifecyclePhase::Ready,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_invalid_transition_when_running_phase_then_structured_failure_is_recorded() {
|
|
||||||
// given
|
|
||||||
let mut validator = McpLifecycleValidator::new();
|
|
||||||
let _ = validator.run_phase(McpLifecyclePhase::ConfigLoad);
|
|
||||||
let _ = validator.run_phase(McpLifecyclePhase::ServerRegistration);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = validator.run_phase(McpLifecyclePhase::Ready);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match result {
|
|
||||||
McpPhaseResult::Failure {
|
|
||||||
phase,
|
|
||||||
error,
|
|
||||||
recoverable,
|
|
||||||
} => {
|
|
||||||
assert_eq!(phase, McpLifecyclePhase::Ready);
|
|
||||||
assert!(!recoverable);
|
|
||||||
assert_eq!(error.phase, McpLifecyclePhase::Ready);
|
|
||||||
assert_eq!(
|
|
||||||
error.context.get("from").map(String::as_str),
|
|
||||||
Some("server_registration")
|
|
||||||
);
|
|
||||||
assert_eq!(error.context.get("to").map(String::as_str), Some("ready"));
|
|
||||||
}
|
|
||||||
other => panic!("expected failure result, got {other:?}"),
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
validator.state().current_phase(),
|
|
||||||
Some(McpLifecyclePhase::ErrorSurfacing)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validator
|
|
||||||
.state()
|
|
||||||
.errors_for_phase(McpLifecyclePhase::Ready)
|
|
||||||
.len(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_each_phase_when_failure_is_recorded_then_error_is_tracked_per_phase() {
|
|
||||||
// given
|
|
||||||
let mut validator = McpLifecycleValidator::new();
|
|
||||||
|
|
||||||
// when / then
|
|
||||||
for phase in McpLifecyclePhase::all() {
|
|
||||||
let result = validator.record_failure(
|
|
||||||
phase,
|
|
||||||
McpErrorSurface::new(
|
|
||||||
phase,
|
|
||||||
Some("alpha".to_string()),
|
|
||||||
format!("failure at {phase}"),
|
|
||||||
BTreeMap::from([("server".to_string(), "alpha".to_string())]),
|
|
||||||
phase == McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
),
|
|
||||||
phase == McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
McpPhaseResult::Failure {
|
|
||||||
phase: failed_phase,
|
|
||||||
error,
|
|
||||||
recoverable,
|
|
||||||
} => {
|
|
||||||
assert_eq!(failed_phase, phase);
|
|
||||||
assert_eq!(error.phase, phase);
|
|
||||||
assert_eq!(recoverable, phase == McpLifecyclePhase::ResourceDiscovery);
|
|
||||||
}
|
|
||||||
other => panic!("expected failure result, got {other:?}"),
|
|
||||||
}
|
|
||||||
assert_eq!(validator.state().errors_for_phase(phase).len(), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_spawn_connect_timeout_when_recorded_then_waited_duration_is_preserved() {
|
|
||||||
// given
|
|
||||||
let mut validator = McpLifecycleValidator::new();
|
|
||||||
let waited = Duration::from_millis(250);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = validator.record_timeout(
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
waited,
|
|
||||||
Some("alpha".to_string()),
|
|
||||||
BTreeMap::from([("attempt".to_string(), "1".to_string())]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match result {
|
|
||||||
McpPhaseResult::Timeout {
|
|
||||||
phase,
|
|
||||||
waited: actual,
|
|
||||||
} => {
|
|
||||||
assert_eq!(phase, McpLifecyclePhase::SpawnConnect);
|
|
||||||
assert_eq!(actual, waited);
|
|
||||||
}
|
|
||||||
other => panic!("expected timeout result, got {other:?}"),
|
|
||||||
}
|
|
||||||
let errors = validator
|
|
||||||
.state()
|
|
||||||
.errors_for_phase(McpLifecyclePhase::SpawnConnect);
|
|
||||||
assert_eq!(errors.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
errors[0].context.get("waited_ms").map(String::as_str),
|
|
||||||
Some("250")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validator.state().current_phase(),
|
|
||||||
Some(McpLifecyclePhase::ErrorSurfacing)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_partial_server_health_when_building_degraded_report_then_missing_tools_are_reported() {
|
|
||||||
// given
|
|
||||||
let failed = vec![McpFailedServer {
|
|
||||||
server_name: "broken".to_string(),
|
|
||||||
phase: McpLifecyclePhase::InitializeHandshake,
|
|
||||||
error: McpErrorSurface::new(
|
|
||||||
McpLifecyclePhase::InitializeHandshake,
|
|
||||||
Some("broken".to_string()),
|
|
||||||
"initialize failed",
|
|
||||||
BTreeMap::from([("reason".to_string(), "broken pipe".to_string())]),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
}];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let report = McpDegradedReport::new(
|
|
||||||
vec!["alpha".to_string(), "beta".to_string(), "alpha".to_string()],
|
|
||||||
failed,
|
|
||||||
vec![
|
|
||||||
"alpha.echo".to_string(),
|
|
||||||
"beta.search".to_string(),
|
|
||||||
"alpha.echo".to_string(),
|
|
||||||
],
|
|
||||||
vec![
|
|
||||||
"alpha.echo".to_string(),
|
|
||||||
"beta.search".to_string(),
|
|
||||||
"broken.fetch".to_string(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
report.working_servers,
|
|
||||||
vec!["alpha".to_string(), "beta".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(report.failed_servers.len(), 1);
|
|
||||||
assert_eq!(report.failed_servers[0].server_name, "broken");
|
|
||||||
assert_eq!(
|
|
||||||
report.available_tools,
|
|
||||||
vec!["alpha.echo".to_string(), "beta.search".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(report.missing_tools, vec!["broken.fetch".to_string()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_failure_during_resource_discovery_when_shutting_down_then_cleanup_still_succeeds() {
|
|
||||||
// given
|
|
||||||
let mut validator = McpLifecycleValidator::new();
|
|
||||||
for phase in [
|
|
||||||
McpLifecyclePhase::ConfigLoad,
|
|
||||||
McpLifecyclePhase::ServerRegistration,
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
McpLifecyclePhase::InitializeHandshake,
|
|
||||||
McpLifecyclePhase::ToolDiscovery,
|
|
||||||
] {
|
|
||||||
let result = validator.run_phase(phase);
|
|
||||||
assert!(matches!(result, McpPhaseResult::Success { .. }));
|
|
||||||
}
|
|
||||||
let _ = validator.record_failure(
|
|
||||||
McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
McpErrorSurface::new(
|
|
||||||
McpLifecyclePhase::ResourceDiscovery,
|
|
||||||
Some("alpha".to_string()),
|
|
||||||
"resource listing failed",
|
|
||||||
BTreeMap::from([("reason".to_string(), "timeout".to_string())]),
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let shutdown = validator.run_phase(McpLifecyclePhase::Shutdown);
|
|
||||||
let cleanup = validator.run_phase(McpLifecyclePhase::Cleanup);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(matches!(shutdown, McpPhaseResult::Success { .. }));
|
|
||||||
assert!(matches!(cleanup, McpPhaseResult::Success { .. }));
|
|
||||||
assert_eq!(
|
|
||||||
validator.state().current_phase(),
|
|
||||||
Some(McpLifecyclePhase::Cleanup)
|
|
||||||
);
|
|
||||||
assert!(validator
|
|
||||||
.state()
|
|
||||||
.phase_timestamp(McpLifecyclePhase::ErrorSurfacing)
|
|
||||||
.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_surface_display_includes_phase_server_and_recoverable_flag() {
|
|
||||||
// given
|
|
||||||
let error = McpErrorSurface::new(
|
|
||||||
McpLifecyclePhase::SpawnConnect,
|
|
||||||
Some("alpha".to_string()),
|
|
||||||
"process exited early",
|
|
||||||
BTreeMap::from([("exit_code".to_string(), "1".to_string())]),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let rendered = error.to_string();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(rendered.contains("spawn_connect"));
|
|
||||||
assert!(rendered.contains("process exited early"));
|
|
||||||
assert!(rendered.contains("server: alpha"));
|
|
||||||
assert!(rendered.contains("recoverable"));
|
|
||||||
let trait_object: &dyn std::error::Error = &error;
|
|
||||||
assert_eq!(trait_object.to_string(), rendered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,912 +0,0 @@
|
||||||
//! Bridge between MCP tool surface (ListMcpResources, ReadMcpResource, McpAuth, MCP)
|
|
||||||
//! and the existing McpServerManager runtime.
|
|
||||||
//!
|
|
||||||
//! Provides a stateful client registry that tool handlers can use to
|
|
||||||
//! connect to MCP servers and invoke their capabilities.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
|
||||||
|
|
||||||
use crate::mcp::mcp_tool_name;
|
|
||||||
use crate::mcp_stdio::McpServerManager;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Status of a managed MCP server connection.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum McpConnectionStatus {
|
|
||||||
Disconnected,
|
|
||||||
Connecting,
|
|
||||||
Connected,
|
|
||||||
AuthRequired,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for McpConnectionStatus {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Disconnected => write!(f, "disconnected"),
|
|
||||||
Self::Connecting => write!(f, "connecting"),
|
|
||||||
Self::Connected => write!(f, "connected"),
|
|
||||||
Self::AuthRequired => write!(f, "auth_required"),
|
|
||||||
Self::Error => write!(f, "error"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata about an MCP resource.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpResourceInfo {
|
|
||||||
pub uri: String,
|
|
||||||
pub name: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub mime_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata about an MCP tool exposed by a server.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpToolInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub input_schema: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracked state of an MCP server connection.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpServerState {
|
|
||||||
pub server_name: String,
|
|
||||||
pub status: McpConnectionStatus,
|
|
||||||
pub tools: Vec<McpToolInfo>,
|
|
||||||
pub resources: Vec<McpResourceInfo>,
|
|
||||||
pub server_info: Option<String>,
|
|
||||||
pub error_message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct McpToolRegistry {
|
|
||||||
inner: Arc<Mutex<HashMap<String, McpServerState>>>,
|
|
||||||
manager: Arc<OnceLock<Arc<Mutex<McpServerManager>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl McpToolRegistry {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_manager(
|
|
||||||
&self,
|
|
||||||
manager: Arc<Mutex<McpServerManager>>,
|
|
||||||
) -> Result<(), Arc<Mutex<McpServerManager>>> {
|
|
||||||
self.manager.set(manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_server(
|
|
||||||
&self,
|
|
||||||
server_name: &str,
|
|
||||||
status: McpConnectionStatus,
|
|
||||||
tools: Vec<McpToolInfo>,
|
|
||||||
resources: Vec<McpResourceInfo>,
|
|
||||||
server_info: Option<String>,
|
|
||||||
) {
|
|
||||||
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
inner.insert(
|
|
||||||
server_name.to_owned(),
|
|
||||||
McpServerState {
|
|
||||||
server_name: server_name.to_owned(),
|
|
||||||
status,
|
|
||||||
tools,
|
|
||||||
resources,
|
|
||||||
server_info,
|
|
||||||
error_message: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_server(&self, server_name: &str) -> Option<McpServerState> {
|
|
||||||
let inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
inner.get(server_name).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_servers(&self) -> Vec<McpServerState> {
|
|
||||||
let inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
inner.values().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_resources(&self, server_name: &str) -> Result<Vec<McpResourceInfo>, String> {
|
|
||||||
let inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
match inner.get(server_name) {
|
|
||||||
Some(state) => {
|
|
||||||
if state.status != McpConnectionStatus::Connected {
|
|
||||||
return Err(format!(
|
|
||||||
"server '{}' is not connected (status: {})",
|
|
||||||
server_name, state.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(state.resources.clone())
|
|
||||||
}
|
|
||||||
None => Err(format!("server '{}' not found", server_name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<McpResourceInfo, String> {
|
|
||||||
let inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
let state = inner
|
|
||||||
.get(server_name)
|
|
||||||
.ok_or_else(|| format!("server '{}' not found", server_name))?;
|
|
||||||
|
|
||||||
if state.status != McpConnectionStatus::Connected {
|
|
||||||
return Err(format!(
|
|
||||||
"server '{}' is not connected (status: {})",
|
|
||||||
server_name, state.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
|
||||||
.resources
|
|
||||||
.iter()
|
|
||||||
.find(|r| r.uri == uri)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| format!("resource '{}' not found on server '{}'", uri, server_name))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_tools(&self, server_name: &str) -> Result<Vec<McpToolInfo>, String> {
|
|
||||||
let inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
match inner.get(server_name) {
|
|
||||||
Some(state) => {
|
|
||||||
if state.status != McpConnectionStatus::Connected {
|
|
||||||
return Err(format!(
|
|
||||||
"server '{}' is not connected (status: {})",
|
|
||||||
server_name, state.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(state.tools.clone())
|
|
||||||
}
|
|
||||||
None => Err(format!("server '{}' not found", server_name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_tool_call(
|
|
||||||
manager: Arc<Mutex<McpServerManager>>,
|
|
||||||
qualified_tool_name: String,
|
|
||||||
arguments: Option<serde_json::Value>,
|
|
||||||
) -> Result<serde_json::Value, String> {
|
|
||||||
let join_handle = std::thread::Builder::new()
|
|
||||||
.name(format!("mcp-tool-call-{qualified_tool_name}"))
|
|
||||||
.spawn(move || {
|
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|error| format!("failed to create MCP tool runtime: {error}"))?;
|
|
||||||
|
|
||||||
runtime.block_on(async move {
|
|
||||||
let response = {
|
|
||||||
let mut manager = manager
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| "mcp server manager lock poisoned".to_string())?;
|
|
||||||
manager
|
|
||||||
.discover_tools()
|
|
||||||
.await
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
let response = manager
|
|
||||||
.call_tool(&qualified_tool_name, arguments)
|
|
||||||
.await
|
|
||||||
.map_err(|error| error.to_string());
|
|
||||||
let shutdown = manager.shutdown().await.map_err(|error| error.to_string());
|
|
||||||
|
|
||||||
match (response, shutdown) {
|
|
||||||
(Ok(response), Ok(())) => Ok(response),
|
|
||||||
(Err(error), Ok(())) | (Err(error), Err(_)) => Err(error),
|
|
||||||
(Ok(_), Err(error)) => Err(error),
|
|
||||||
}
|
|
||||||
}?;
|
|
||||||
|
|
||||||
if let Some(error) = response.error {
|
|
||||||
return Err(format!(
|
|
||||||
"MCP server returned JSON-RPC error for tools/call: {} ({})",
|
|
||||||
error.message, error.code
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = response.result.ok_or_else(|| {
|
|
||||||
"MCP server returned no result for tools/call".to_string()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
serde_json::to_value(result)
|
|
||||||
.map_err(|error| format!("failed to serialize MCP tool result: {error}"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|error| format!("failed to spawn MCP tool call thread: {error}"))?;
|
|
||||||
|
|
||||||
join_handle.join().map_err(|panic_payload| {
|
|
||||||
if let Some(message) = panic_payload.downcast_ref::<&str>() {
|
|
||||||
format!("MCP tool call thread panicked: {message}")
|
|
||||||
} else if let Some(message) = panic_payload.downcast_ref::<String>() {
|
|
||||||
format!("MCP tool call thread panicked: {message}")
|
|
||||||
} else {
|
|
||||||
"MCP tool call thread panicked".to_string()
|
|
||||||
}
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn call_tool(
|
|
||||||
&self,
|
|
||||||
server_name: &str,
|
|
||||||
tool_name: &str,
|
|
||||||
arguments: &serde_json::Value,
|
|
||||||
) -> Result<serde_json::Value, String> {
|
|
||||||
let inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
let state = inner
|
|
||||||
.get(server_name)
|
|
||||||
.ok_or_else(|| format!("server '{}' not found", server_name))?;
|
|
||||||
|
|
||||||
if state.status != McpConnectionStatus::Connected {
|
|
||||||
return Err(format!(
|
|
||||||
"server '{}' is not connected (status: {})",
|
|
||||||
server_name, state.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !state.tools.iter().any(|t| t.name == tool_name) {
|
|
||||||
return Err(format!(
|
|
||||||
"tool '{}' not found on server '{}'",
|
|
||||||
tool_name, server_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(inner);
|
|
||||||
|
|
||||||
let manager = self
|
|
||||||
.manager
|
|
||||||
.get()
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| "MCP server manager is not configured".to_string())?;
|
|
||||||
|
|
||||||
Self::spawn_tool_call(
|
|
||||||
manager,
|
|
||||||
mcp_tool_name(server_name, tool_name),
|
|
||||||
(!arguments.is_null()).then(|| arguments.clone()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set auth status for a server.
|
|
||||||
pub fn set_auth_status(
|
|
||||||
&self,
|
|
||||||
server_name: &str,
|
|
||||||
status: McpConnectionStatus,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
let state = inner
|
|
||||||
.get_mut(server_name)
|
|
||||||
.ok_or_else(|| format!("server '{}' not found", server_name))?;
|
|
||||||
state.status = status;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect / remove a server.
|
|
||||||
pub fn disconnect(&self, server_name: &str) -> Option<McpServerState> {
|
|
||||||
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
inner.remove(server_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of registered servers.
|
|
||||||
#[must_use]
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
let inner = self.inner.lock().expect("mcp registry lock poisoned");
|
|
||||||
inner.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::config::{
|
|
||||||
ConfigSource, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn temp_dir() -> PathBuf {
|
|
||||||
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
|
|
||||||
let nanos = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("time should be after epoch")
|
|
||||||
.as_nanos();
|
|
||||||
let unique_id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
|
|
||||||
std::env::temp_dir().join(format!("runtime-mcp-tool-bridge-{nanos}-{unique_id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup_script(script_path: &Path) {
|
|
||||||
if let Some(root) = script_path.parent() {
|
|
||||||
let _ = fs::remove_dir_all(root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_bridge_mcp_server_script() -> PathBuf {
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("temp dir");
|
|
||||||
let script_path = root.join("bridge-mcp-server.py");
|
|
||||||
let script = [
|
|
||||||
"#!/usr/bin/env python3",
|
|
||||||
"import json, os, sys",
|
|
||||||
"LABEL = os.environ.get('MCP_SERVER_LABEL', 'server')",
|
|
||||||
"LOG_PATH = os.environ.get('MCP_LOG_PATH')",
|
|
||||||
"",
|
|
||||||
"def log(method):",
|
|
||||||
" if LOG_PATH:",
|
|
||||||
" with open(LOG_PATH, 'a', encoding='utf-8') as handle:",
|
|
||||||
" handle.write(f'{method}\\n')",
|
|
||||||
"",
|
|
||||||
"def read_message():",
|
|
||||||
" header = b''",
|
|
||||||
r" while not header.endswith(b'\r\n\r\n'):",
|
|
||||||
" chunk = sys.stdin.buffer.read(1)",
|
|
||||||
" if not chunk:",
|
|
||||||
" return None",
|
|
||||||
" header += chunk",
|
|
||||||
" length = 0",
|
|
||||||
r" for line in header.decode().split('\r\n'):",
|
|
||||||
r" if line.lower().startswith('content-length:'):",
|
|
||||||
r" length = int(line.split(':', 1)[1].strip())",
|
|
||||||
" payload = sys.stdin.buffer.read(length)",
|
|
||||||
" return json.loads(payload.decode())",
|
|
||||||
"",
|
|
||||||
"def send_message(message):",
|
|
||||||
" payload = json.dumps(message).encode()",
|
|
||||||
r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
|
|
||||||
" sys.stdout.buffer.flush()",
|
|
||||||
"",
|
|
||||||
"while True:",
|
|
||||||
" request = read_message()",
|
|
||||||
" if request is None:",
|
|
||||||
" break",
|
|
||||||
" method = request['method']",
|
|
||||||
" log(method)",
|
|
||||||
" if method == 'initialize':",
|
|
||||||
" send_message({",
|
|
||||||
" 'jsonrpc': '2.0',",
|
|
||||||
" 'id': request['id'],",
|
|
||||||
" 'result': {",
|
|
||||||
" 'protocolVersion': request['params']['protocolVersion'],",
|
|
||||||
" 'capabilities': {'tools': {}},",
|
|
||||||
" 'serverInfo': {'name': LABEL, 'version': '1.0.0'}",
|
|
||||||
" }",
|
|
||||||
" })",
|
|
||||||
" elif method == 'tools/list':",
|
|
||||||
" send_message({",
|
|
||||||
" 'jsonrpc': '2.0',",
|
|
||||||
" 'id': request['id'],",
|
|
||||||
" 'result': {",
|
|
||||||
" 'tools': [",
|
|
||||||
" {",
|
|
||||||
" 'name': 'echo',",
|
|
||||||
" 'description': f'Echo tool for {LABEL}',",
|
|
||||||
" 'inputSchema': {",
|
|
||||||
" 'type': 'object',",
|
|
||||||
" 'properties': {'text': {'type': 'string'}},",
|
|
||||||
" 'required': ['text']",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" ]",
|
|
||||||
" }",
|
|
||||||
" })",
|
|
||||||
" elif method == 'tools/call':",
|
|
||||||
" args = request['params'].get('arguments') or {}",
|
|
||||||
" text = args.get('text', '')",
|
|
||||||
" send_message({",
|
|
||||||
" 'jsonrpc': '2.0',",
|
|
||||||
" 'id': request['id'],",
|
|
||||||
" 'result': {",
|
|
||||||
" 'content': [{'type': 'text', 'text': f'{LABEL}:{text}'}],",
|
|
||||||
" 'structuredContent': {'server': LABEL, 'echoed': text},",
|
|
||||||
" 'isError': False",
|
|
||||||
" }",
|
|
||||||
" })",
|
|
||||||
" else:",
|
|
||||||
" send_message({",
|
|
||||||
" 'jsonrpc': '2.0',",
|
|
||||||
" 'id': request['id'],",
|
|
||||||
" 'error': {'code': -32601, 'message': f'unknown method: {method}'},",
|
|
||||||
" })",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
.join("\n");
|
|
||||||
fs::write(&script_path, script).expect("write script");
|
|
||||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
|
||||||
permissions.set_mode(0o755);
|
|
||||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
|
||||||
script_path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn manager_server_config(
|
|
||||||
script_path: &Path,
|
|
||||||
server_name: &str,
|
|
||||||
log_path: &Path,
|
|
||||||
) -> ScopedMcpServerConfig {
|
|
||||||
ScopedMcpServerConfig {
|
|
||||||
scope: ConfigSource::Local,
|
|
||||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
|
||||||
command: "python3".to_string(),
|
|
||||||
args: vec![script_path.to_string_lossy().into_owned()],
|
|
||||||
env: BTreeMap::from([
|
|
||||||
("MCP_SERVER_LABEL".to_string(), server_name.to_string()),
|
|
||||||
(
|
|
||||||
"MCP_LOG_PATH".to_string(),
|
|
||||||
log_path.to_string_lossy().into_owned(),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
tool_call_timeout_ms: Some(1_000),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn registers_and_retrieves_server() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"test-server",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![McpToolInfo {
|
|
||||||
name: "greet".into(),
|
|
||||||
description: Some("Greet someone".into()),
|
|
||||||
input_schema: None,
|
|
||||||
}],
|
|
||||||
vec![McpResourceInfo {
|
|
||||||
uri: "res://data".into(),
|
|
||||||
name: "Data".into(),
|
|
||||||
description: None,
|
|
||||||
mime_type: Some("application/json".into()),
|
|
||||||
}],
|
|
||||||
Some("TestServer v1.0".into()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let server = registry.get_server("test-server").expect("should exist");
|
|
||||||
assert_eq!(server.status, McpConnectionStatus::Connected);
|
|
||||||
assert_eq!(server.tools.len(), 1);
|
|
||||||
assert_eq!(server.resources.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lists_resources_from_connected_server() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![],
|
|
||||||
vec![McpResourceInfo {
|
|
||||||
uri: "res://alpha".into(),
|
|
||||||
name: "Alpha".into(),
|
|
||||||
description: None,
|
|
||||||
mime_type: None,
|
|
||||||
}],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let resources = registry.list_resources("srv").expect("should succeed");
|
|
||||||
assert_eq!(resources.len(), 1);
|
|
||||||
assert_eq!(resources[0].uri, "res://alpha");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_resource_listing_for_disconnected_server() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::Disconnected,
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(registry.list_resources("srv").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_specific_resource() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![],
|
|
||||||
vec![McpResourceInfo {
|
|
||||||
uri: "res://data".into(),
|
|
||||||
name: "Data".into(),
|
|
||||||
description: Some("Test data".into()),
|
|
||||||
mime_type: Some("text/plain".into()),
|
|
||||||
}],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let resource = registry
|
|
||||||
.read_resource("srv", "res://data")
|
|
||||||
.expect("should find");
|
|
||||||
assert_eq!(resource.name, "Data");
|
|
||||||
|
|
||||||
assert!(registry.read_resource("srv", "res://missing").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_connected_server_without_manager_when_calling_tool_then_it_errors() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![McpToolInfo {
|
|
||||||
name: "greet".into(),
|
|
||||||
description: None,
|
|
||||||
input_schema: None,
|
|
||||||
}],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let error = registry
|
|
||||||
.call_tool("srv", "greet", &serde_json::json!({"name": "world"}))
|
|
||||||
.expect_err("should require a configured manager");
|
|
||||||
assert!(error.contains("MCP server manager is not configured"));
|
|
||||||
|
|
||||||
// Unknown tool should fail
|
|
||||||
assert!(registry
|
|
||||||
.call_tool("srv", "missing", &serde_json::json!({}))
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn given_connected_server_with_manager_when_calling_tool_then_it_returns_live_result() {
|
|
||||||
let script_path = write_bridge_mcp_server_script();
|
|
||||||
let root = script_path.parent().expect("script parent");
|
|
||||||
let log_path = root.join("bridge.log");
|
|
||||||
let servers = BTreeMap::from([(
|
|
||||||
"alpha".to_string(),
|
|
||||||
manager_server_config(&script_path, "alpha", &log_path),
|
|
||||||
)]);
|
|
||||||
let manager = Arc::new(Mutex::new(McpServerManager::from_servers(&servers)));
|
|
||||||
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"alpha",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![McpToolInfo {
|
|
||||||
name: "echo".into(),
|
|
||||||
description: Some("Echo tool for alpha".into()),
|
|
||||||
input_schema: Some(serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"text": {"type": "string"}},
|
|
||||||
"required": ["text"]
|
|
||||||
})),
|
|
||||||
}],
|
|
||||||
vec![],
|
|
||||||
Some("bridge test server".into()),
|
|
||||||
);
|
|
||||||
registry
|
|
||||||
.set_manager(Arc::clone(&manager))
|
|
||||||
.expect("manager should only be set once");
|
|
||||||
|
|
||||||
let result = registry
|
|
||||||
.call_tool("alpha", "echo", &serde_json::json!({"text": "hello"}))
|
|
||||||
.expect("should return live MCP result");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
result["structuredContent"]["server"],
|
|
||||||
serde_json::json!("alpha")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result["structuredContent"]["echoed"],
|
|
||||||
serde_json::json!("hello")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result["content"][0]["text"],
|
|
||||||
serde_json::json!("alpha:hello")
|
|
||||||
);
|
|
||||||
|
|
||||||
let log = fs::read_to_string(&log_path).expect("read log");
|
|
||||||
assert_eq!(
|
|
||||||
log.lines().collect::<Vec<_>>(),
|
|
||||||
vec!["initialize", "tools/list", "tools/call"]
|
|
||||||
);
|
|
||||||
|
|
||||||
cleanup_script(&script_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_tool_call_on_disconnected_server() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::AuthRequired,
|
|
||||||
vec![McpToolInfo {
|
|
||||||
name: "greet".into(),
|
|
||||||
description: None,
|
|
||||||
input_schema: None,
|
|
||||||
}],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(registry
|
|
||||||
.call_tool("srv", "greet", &serde_json::json!({}))
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sets_auth_and_disconnects() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::AuthRequired,
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
registry
|
|
||||||
.set_auth_status("srv", McpConnectionStatus::Connected)
|
|
||||||
.expect("should succeed");
|
|
||||||
let state = registry.get_server("srv").unwrap();
|
|
||||||
assert_eq!(state.status, McpConnectionStatus::Connected);
|
|
||||||
|
|
||||||
let removed = registry.disconnect("srv");
|
|
||||||
assert!(removed.is_some());
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_operations_on_missing_server() {
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
assert!(registry.list_resources("missing").is_err());
|
|
||||||
assert!(registry.read_resource("missing", "uri").is_err());
|
|
||||||
assert!(registry.list_tools("missing").is_err());
|
|
||||||
assert!(registry
|
|
||||||
.call_tool("missing", "tool", &serde_json::json!({}))
|
|
||||||
.is_err());
|
|
||||||
assert!(registry
|
|
||||||
.set_auth_status("missing", McpConnectionStatus::Connected)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mcp_connection_status_display_all_variants() {
|
|
||||||
// given
|
|
||||||
let cases = [
|
|
||||||
(McpConnectionStatus::Disconnected, "disconnected"),
|
|
||||||
(McpConnectionStatus::Connecting, "connecting"),
|
|
||||||
(McpConnectionStatus::Connected, "connected"),
|
|
||||||
(McpConnectionStatus::AuthRequired, "auth_required"),
|
|
||||||
(McpConnectionStatus::Error, "error"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let rendered: Vec<_> = cases
|
|
||||||
.into_iter()
|
|
||||||
.map(|(status, expected)| (status.to_string(), expected))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
rendered,
|
|
||||||
vec![
|
|
||||||
("disconnected".to_string(), "disconnected"),
|
|
||||||
("connecting".to_string(), "connecting"),
|
|
||||||
("connected".to_string(), "connected"),
|
|
||||||
("auth_required".to_string(), "auth_required"),
|
|
||||||
("error".to_string(), "error"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_servers_returns_all_registered() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"alpha",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
registry.register_server(
|
|
||||||
"beta",
|
|
||||||
McpConnectionStatus::Connecting,
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let servers = registry.list_servers();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(servers.len(), 2);
|
|
||||||
assert!(servers.iter().any(|server| server.server_name == "alpha"));
|
|
||||||
assert!(servers.iter().any(|server| server.server_name == "beta"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_tools_from_connected_server() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![McpToolInfo {
|
|
||||||
name: "inspect".into(),
|
|
||||||
description: Some("Inspect data".into()),
|
|
||||||
input_schema: Some(serde_json::json!({"type": "object"})),
|
|
||||||
}],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let tools = registry.list_tools("srv").expect("tools should list");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(tools.len(), 1);
|
|
||||||
assert_eq!(tools[0].name, "inspect");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_tools_rejects_disconnected_server() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::AuthRequired,
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.list_tools("srv");
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("non-connected server should fail");
|
|
||||||
assert!(error.contains("not connected"));
|
|
||||||
assert!(error.contains("auth_required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_tools_rejects_missing_server() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.list_tools("missing");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
result.expect_err("missing server should fail"),
|
|
||||||
"server 'missing' not found"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn get_server_returns_none_for_missing() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let server = registry.get_server("missing");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(server.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn call_tool_payload_structure() {
|
|
||||||
let script_path = write_bridge_mcp_server_script();
|
|
||||||
let root = script_path.parent().expect("script parent");
|
|
||||||
let log_path = root.join("payload.log");
|
|
||||||
let servers = BTreeMap::from([(
|
|
||||||
"srv".to_string(),
|
|
||||||
manager_server_config(&script_path, "srv", &log_path),
|
|
||||||
)]);
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
let arguments = serde_json::json!({"text": "world"});
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![McpToolInfo {
|
|
||||||
name: "echo".into(),
|
|
||||||
description: Some("Echo tool for srv".into()),
|
|
||||||
input_schema: Some(serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"text": {"type": "string"}},
|
|
||||||
"required": ["text"]
|
|
||||||
})),
|
|
||||||
}],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
registry
|
|
||||||
.set_manager(Arc::new(Mutex::new(McpServerManager::from_servers(
|
|
||||||
&servers,
|
|
||||||
))))
|
|
||||||
.expect("manager should only be set once");
|
|
||||||
|
|
||||||
let result = registry
|
|
||||||
.call_tool("srv", "echo", &arguments)
|
|
||||||
.expect("tool should return live payload");
|
|
||||||
|
|
||||||
assert_eq!(result["structuredContent"]["server"], "srv");
|
|
||||||
assert_eq!(result["structuredContent"]["echoed"], "world");
|
|
||||||
assert_eq!(result["content"][0]["text"], "srv:world");
|
|
||||||
|
|
||||||
cleanup_script(&script_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_overwrites_existing_server() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
registry.register_server("srv", McpConnectionStatus::Connecting, vec![], vec![], None);
|
|
||||||
|
|
||||||
// when
|
|
||||||
registry.register_server(
|
|
||||||
"srv",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![McpToolInfo {
|
|
||||||
name: "inspect".into(),
|
|
||||||
description: None,
|
|
||||||
input_schema: None,
|
|
||||||
}],
|
|
||||||
vec![],
|
|
||||||
Some("Inspector".into()),
|
|
||||||
);
|
|
||||||
let state = registry.get_server("srv").expect("server should exist");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(state.status, McpConnectionStatus::Connected);
|
|
||||||
assert_eq!(state.tools.len(), 1);
|
|
||||||
assert_eq!(state.server_info.as_deref(), Some("Inspector"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disconnect_missing_returns_none() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let removed = registry.disconnect("missing");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(removed.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn len_and_is_empty_transitions() {
|
|
||||||
// given
|
|
||||||
let registry = McpToolRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
registry.register_server(
|
|
||||||
"alpha",
|
|
||||||
McpConnectionStatus::Connected,
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
registry.register_server("beta", McpConnectionStatus::Connected, vec![], vec![], None);
|
|
||||||
let after_create = registry.len();
|
|
||||||
registry.disconnect("alpha");
|
|
||||||
let after_first_remove = registry.len();
|
|
||||||
registry.disconnect("beta");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(after_create, 2);
|
|
||||||
assert_eq!(after_first_remove, 1);
|
|
||||||
assert_eq!(registry.len(), 0);
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -442,7 +442,7 @@ fn decode_hex(byte: u8) -> Result<u8, String> {
|
||||||
b'0'..=b'9' => Ok(byte - b'0'),
|
b'0'..=b'9' => Ok(byte - b'0'),
|
||||||
b'a'..=b'f' => Ok(byte - b'a' + 10),
|
b'a'..=b'f' => Ok(byte - b'a' + 10),
|
||||||
b'A'..=b'F' => Ok(byte - b'A' + 10),
|
b'A'..=b'F' => Ok(byte - b'A' + 10),
|
||||||
_ => Err(format!("invalid percent byte: {byte}")),
|
_ => Err(format!("invalid percent-encoding byte: {byte}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,546 +0,0 @@
|
||||||
//! Permission enforcement layer that gates tool execution based on the
|
|
||||||
//! active `PermissionPolicy`.
|
|
||||||
|
|
||||||
use crate::permissions::{PermissionMode, PermissionOutcome, PermissionPolicy};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "outcome")]
|
|
||||||
pub enum EnforcementResult {
|
|
||||||
/// Tool execution is allowed.
|
|
||||||
Allowed,
|
|
||||||
/// Tool execution was denied due to insufficient permissions.
|
|
||||||
Denied {
|
|
||||||
tool: String,
|
|
||||||
active_mode: String,
|
|
||||||
required_mode: String,
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct PermissionEnforcer {
|
|
||||||
policy: PermissionPolicy,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PermissionEnforcer {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(policy: PermissionPolicy) -> Self {
|
|
||||||
Self { policy }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether a tool can be executed under the current permission policy.
|
|
||||||
/// Auto-denies when prompting is required but no prompter is provided.
|
|
||||||
pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
|
|
||||||
// When the active mode is Prompt, defer to the caller's interactive
|
|
||||||
// prompt flow rather than hard-denying (the enforcer has no prompter).
|
|
||||||
if self.policy.active_mode() == PermissionMode::Prompt {
|
|
||||||
return EnforcementResult::Allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
let outcome = self.policy.authorize(tool_name, input, None);
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
PermissionOutcome::Allow => EnforcementResult::Allowed,
|
|
||||||
PermissionOutcome::Deny { reason } => {
|
|
||||||
let active_mode = self.policy.active_mode();
|
|
||||||
let required_mode = self.policy.required_mode_for(tool_name);
|
|
||||||
EnforcementResult::Denied {
|
|
||||||
tool: tool_name.to_owned(),
|
|
||||||
active_mode: active_mode.as_str().to_owned(),
|
|
||||||
required_mode: required_mode.as_str().to_owned(),
|
|
||||||
reason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_allowed(&self, tool_name: &str, input: &str) -> bool {
|
|
||||||
matches!(self.check(tool_name, input), EnforcementResult::Allowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn active_mode(&self) -> PermissionMode {
|
|
||||||
self.policy.active_mode()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify a file operation against workspace boundaries.
|
|
||||||
pub fn check_file_write(&self, path: &str, workspace_root: &str) -> EnforcementResult {
|
|
||||||
let mode = self.policy.active_mode();
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
PermissionMode::ReadOnly => EnforcementResult::Denied {
|
|
||||||
tool: "write_file".to_owned(),
|
|
||||||
active_mode: mode.as_str().to_owned(),
|
|
||||||
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
|
|
||||||
reason: format!("file writes are not allowed in '{}' mode", mode.as_str()),
|
|
||||||
},
|
|
||||||
PermissionMode::WorkspaceWrite => {
|
|
||||||
if is_within_workspace(path, workspace_root) {
|
|
||||||
EnforcementResult::Allowed
|
|
||||||
} else {
|
|
||||||
EnforcementResult::Denied {
|
|
||||||
tool: "write_file".to_owned(),
|
|
||||||
active_mode: mode.as_str().to_owned(),
|
|
||||||
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
|
|
||||||
reason: format!(
|
|
||||||
"path '{}' is outside workspace root '{}'",
|
|
||||||
path, workspace_root
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Allow and DangerFullAccess permit all writes
|
|
||||||
PermissionMode::Allow | PermissionMode::DangerFullAccess => EnforcementResult::Allowed,
|
|
||||||
PermissionMode::Prompt => EnforcementResult::Denied {
|
|
||||||
tool: "write_file".to_owned(),
|
|
||||||
active_mode: mode.as_str().to_owned(),
|
|
||||||
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
|
|
||||||
reason: "file write requires confirmation in prompt mode".to_owned(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a bash command should be allowed based on current mode.
|
|
||||||
pub fn check_bash(&self, command: &str) -> EnforcementResult {
|
|
||||||
let mode = self.policy.active_mode();
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
PermissionMode::ReadOnly => {
|
|
||||||
if is_read_only_command(command) {
|
|
||||||
EnforcementResult::Allowed
|
|
||||||
} else {
|
|
||||||
EnforcementResult::Denied {
|
|
||||||
tool: "bash".to_owned(),
|
|
||||||
active_mode: mode.as_str().to_owned(),
|
|
||||||
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
|
|
||||||
reason: format!(
|
|
||||||
"command may modify state; not allowed in '{}' mode",
|
|
||||||
mode.as_str()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PermissionMode::Prompt => EnforcementResult::Denied {
|
|
||||||
tool: "bash".to_owned(),
|
|
||||||
active_mode: mode.as_str().to_owned(),
|
|
||||||
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
|
|
||||||
reason: "bash requires confirmation in prompt mode".to_owned(),
|
|
||||||
},
|
|
||||||
// WorkspaceWrite, Allow, DangerFullAccess: permit bash
|
|
||||||
_ => EnforcementResult::Allowed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple workspace boundary check via string prefix.
|
|
||||||
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
|
|
||||||
let normalized = if path.starts_with('/') {
|
|
||||||
path.to_owned()
|
|
||||||
} else {
|
|
||||||
format!("{workspace_root}/{path}")
|
|
||||||
};
|
|
||||||
|
|
||||||
let root = if workspace_root.ends_with('/') {
|
|
||||||
workspace_root.to_owned()
|
|
||||||
} else {
|
|
||||||
format!("{workspace_root}/")
|
|
||||||
};
|
|
||||||
|
|
||||||
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Conservative heuristic: is this bash command read-only?
|
|
||||||
fn is_read_only_command(command: &str) -> bool {
|
|
||||||
let first_token = command
|
|
||||||
.split_whitespace()
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.rsplit('/')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
matches!(
|
|
||||||
first_token,
|
|
||||||
"cat"
|
|
||||||
| "head"
|
|
||||||
| "tail"
|
|
||||||
| "less"
|
|
||||||
| "more"
|
|
||||||
| "wc"
|
|
||||||
| "ls"
|
|
||||||
| "find"
|
|
||||||
| "grep"
|
|
||||||
| "rg"
|
|
||||||
| "awk"
|
|
||||||
| "sed"
|
|
||||||
| "echo"
|
|
||||||
| "printf"
|
|
||||||
| "which"
|
|
||||||
| "where"
|
|
||||||
| "whoami"
|
|
||||||
| "pwd"
|
|
||||||
| "env"
|
|
||||||
| "printenv"
|
|
||||||
| "date"
|
|
||||||
| "cal"
|
|
||||||
| "df"
|
|
||||||
| "du"
|
|
||||||
| "free"
|
|
||||||
| "uptime"
|
|
||||||
| "uname"
|
|
||||||
| "file"
|
|
||||||
| "stat"
|
|
||||||
| "diff"
|
|
||||||
| "sort"
|
|
||||||
| "uniq"
|
|
||||||
| "tr"
|
|
||||||
| "cut"
|
|
||||||
| "paste"
|
|
||||||
| "tee"
|
|
||||||
| "xargs"
|
|
||||||
| "test"
|
|
||||||
| "true"
|
|
||||||
| "false"
|
|
||||||
| "type"
|
|
||||||
| "readlink"
|
|
||||||
| "realpath"
|
|
||||||
| "basename"
|
|
||||||
| "dirname"
|
|
||||||
| "sha256sum"
|
|
||||||
| "md5sum"
|
|
||||||
| "b3sum"
|
|
||||||
| "xxd"
|
|
||||||
| "hexdump"
|
|
||||||
| "od"
|
|
||||||
| "strings"
|
|
||||||
| "tree"
|
|
||||||
| "jq"
|
|
||||||
| "yq"
|
|
||||||
| "python3"
|
|
||||||
| "python"
|
|
||||||
| "node"
|
|
||||||
| "ruby"
|
|
||||||
| "cargo"
|
|
||||||
| "rustc"
|
|
||||||
| "git"
|
|
||||||
| "gh"
|
|
||||||
) && !command.contains("-i ")
|
|
||||||
&& !command.contains("--in-place")
|
|
||||||
&& !command.contains(" > ")
|
|
||||||
&& !command.contains(" >> ")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_enforcer(mode: PermissionMode) -> PermissionEnforcer {
|
|
||||||
let policy = PermissionPolicy::new(mode);
|
|
||||||
PermissionEnforcer::new(policy)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allow_mode_permits_everything() {
|
|
||||||
let enforcer = make_enforcer(PermissionMode::Allow);
|
|
||||||
assert!(enforcer.is_allowed("bash", ""));
|
|
||||||
assert!(enforcer.is_allowed("write_file", ""));
|
|
||||||
assert!(enforcer.is_allowed("edit_file", ""));
|
|
||||||
assert_eq!(
|
|
||||||
enforcer.check_file_write("/outside/path", "/workspace"),
|
|
||||||
EnforcementResult::Allowed
|
|
||||||
);
|
|
||||||
assert_eq!(enforcer.check_bash("rm -rf /"), EnforcementResult::Allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_only_denies_writes() {
|
|
||||||
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
|
|
||||||
.with_tool_requirement("read_file", PermissionMode::ReadOnly)
|
|
||||||
.with_tool_requirement("grep_search", PermissionMode::ReadOnly)
|
|
||||||
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
|
|
||||||
|
|
||||||
let enforcer = PermissionEnforcer::new(policy);
|
|
||||||
assert!(enforcer.is_allowed("read_file", ""));
|
|
||||||
assert!(enforcer.is_allowed("grep_search", ""));
|
|
||||||
|
|
||||||
// write_file requires WorkspaceWrite but we're in ReadOnly
|
|
||||||
let result = enforcer.check("write_file", "");
|
|
||||||
assert!(matches!(result, EnforcementResult::Denied { .. }));
|
|
||||||
|
|
||||||
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
|
|
||||||
assert!(matches!(result, EnforcementResult::Denied { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_only_allows_read_commands() {
|
|
||||||
let enforcer = make_enforcer(PermissionMode::ReadOnly);
|
|
||||||
assert_eq!(
|
|
||||||
enforcer.check_bash("cat src/main.rs"),
|
|
||||||
EnforcementResult::Allowed
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
enforcer.check_bash("grep -r 'pattern' ."),
|
|
||||||
EnforcementResult::Allowed
|
|
||||||
);
|
|
||||||
assert_eq!(enforcer.check_bash("ls -la"), EnforcementResult::Allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_only_denies_write_commands() {
|
|
||||||
let enforcer = make_enforcer(PermissionMode::ReadOnly);
|
|
||||||
let result = enforcer.check_bash("rm file.txt");
|
|
||||||
assert!(matches!(result, EnforcementResult::Denied { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_write_allows_within_workspace() {
|
|
||||||
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
|
|
||||||
let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace");
|
|
||||||
assert_eq!(result, EnforcementResult::Allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_write_denies_outside_workspace() {
|
|
||||||
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
|
|
||||||
let result = enforcer.check_file_write("/etc/passwd", "/workspace");
|
|
||||||
assert!(matches!(result, EnforcementResult::Denied { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_mode_denies_without_prompter() {
|
|
||||||
let enforcer = make_enforcer(PermissionMode::Prompt);
|
|
||||||
let result = enforcer.check_bash("echo test");
|
|
||||||
assert!(matches!(result, EnforcementResult::Denied { .. }));
|
|
||||||
|
|
||||||
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
|
|
||||||
assert!(matches!(result, EnforcementResult::Denied { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_boundary_check() {
|
|
||||||
assert!(is_within_workspace("/workspace/src/main.rs", "/workspace"));
|
|
||||||
assert!(is_within_workspace("/workspace", "/workspace"));
|
|
||||||
assert!(!is_within_workspace("/etc/passwd", "/workspace"));
|
|
||||||
assert!(!is_within_workspace("/workspacex/hack", "/workspace"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_only_command_heuristic() {
|
|
||||||
assert!(is_read_only_command("cat file.txt"));
|
|
||||||
assert!(is_read_only_command("grep pattern file"));
|
|
||||||
assert!(is_read_only_command("git log --oneline"));
|
|
||||||
assert!(!is_read_only_command("rm file.txt"));
|
|
||||||
assert!(!is_read_only_command("echo test > file.txt"));
|
|
||||||
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn active_mode_returns_policy_mode() {
|
|
||||||
// given
|
|
||||||
let modes = [
|
|
||||||
PermissionMode::ReadOnly,
|
|
||||||
PermissionMode::WorkspaceWrite,
|
|
||||||
PermissionMode::DangerFullAccess,
|
|
||||||
PermissionMode::Prompt,
|
|
||||||
PermissionMode::Allow,
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let active_modes: Vec<_> = modes
|
|
||||||
.into_iter()
|
|
||||||
.map(|mode| make_enforcer(mode).active_mode())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(active_modes, modes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn danger_full_access_permits_file_writes_and_bash() {
|
|
||||||
// given
|
|
||||||
let enforcer = make_enforcer(PermissionMode::DangerFullAccess);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let file_result = enforcer.check_file_write("/outside/workspace/file.txt", "/workspace");
|
|
||||||
let bash_result = enforcer.check_bash("rm -rf /tmp/scratch");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(file_result, EnforcementResult::Allowed);
|
|
||||||
assert_eq!(bash_result, EnforcementResult::Allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn check_denied_payload_contains_tool_and_modes() {
|
|
||||||
// given
|
|
||||||
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
|
|
||||||
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
|
|
||||||
let enforcer = PermissionEnforcer::new(policy);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = enforcer.check("write_file", "{}");
|
|
||||||
|
|
||||||
// then
|
|
||||||
match result {
|
|
||||||
EnforcementResult::Denied {
|
|
||||||
tool,
|
|
||||||
active_mode,
|
|
||||||
required_mode,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
assert_eq!(tool, "write_file");
|
|
||||||
assert_eq!(active_mode, "read-only");
|
|
||||||
assert_eq!(required_mode, "workspace-write");
|
|
||||||
assert!(reason.contains("requires workspace-write permission"));
|
|
||||||
}
|
|
||||||
other => panic!("expected denied result, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_write_relative_path_resolved() {
|
|
||||||
// given
|
|
||||||
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = enforcer.check_file_write("src/main.rs", "/workspace");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(result, EnforcementResult::Allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_root_with_trailing_slash() {
|
|
||||||
// given
|
|
||||||
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace/");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(result, EnforcementResult::Allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_root_equality() {
|
|
||||||
// given
|
|
||||||
let root = "/workspace/";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let equal_to_root = is_within_workspace("/workspace", root);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(equal_to_root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bash_heuristic_full_path_prefix() {
|
|
||||||
// given
|
|
||||||
let full_path_command = "/usr/bin/cat Cargo.toml";
|
|
||||||
let git_path_command = "/usr/local/bin/git status";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let cat_result = is_read_only_command(full_path_command);
|
|
||||||
let git_result = is_read_only_command(git_path_command);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(cat_result);
|
|
||||||
assert!(git_result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bash_heuristic_redirects_block_read_only_commands() {
|
|
||||||
// given
|
|
||||||
let overwrite = "cat Cargo.toml > out.txt";
|
|
||||||
let append = "echo test >> out.txt";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let overwrite_result = is_read_only_command(overwrite);
|
|
||||||
let append_result = is_read_only_command(append);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(!overwrite_result);
|
|
||||||
assert!(!append_result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bash_heuristic_in_place_flag_blocks() {
|
|
||||||
// given
|
|
||||||
let interactive_python = "python -i script.py";
|
|
||||||
let in_place_sed = "sed --in-place 's/a/b/' file.txt";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let interactive_result = is_read_only_command(interactive_python);
|
|
||||||
let in_place_result = is_read_only_command(in_place_sed);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(!interactive_result);
|
|
||||||
assert!(!in_place_result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bash_heuristic_empty_command() {
|
|
||||||
// given
|
|
||||||
let empty = "";
|
|
||||||
let whitespace = " ";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let empty_result = is_read_only_command(empty);
|
|
||||||
let whitespace_result = is_read_only_command(whitespace);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(!empty_result);
|
|
||||||
assert!(!whitespace_result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_mode_check_bash_denied_payload_fields() {
|
|
||||||
// given
|
|
||||||
let enforcer = make_enforcer(PermissionMode::Prompt);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = enforcer.check_bash("git status");
|
|
||||||
|
|
||||||
// then
|
|
||||||
match result {
|
|
||||||
EnforcementResult::Denied {
|
|
||||||
tool,
|
|
||||||
active_mode,
|
|
||||||
required_mode,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
assert_eq!(tool, "bash");
|
|
||||||
assert_eq!(active_mode, "prompt");
|
|
||||||
assert_eq!(required_mode, "danger-full-access");
|
|
||||||
assert_eq!(reason, "bash requires confirmation in prompt mode");
|
|
||||||
}
|
|
||||||
other => panic!("expected denied result, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_only_check_file_write_denied_payload() {
|
|
||||||
// given
|
|
||||||
let enforcer = make_enforcer(PermissionMode::ReadOnly);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = enforcer.check_file_write("/workspace/file.txt", "/workspace");
|
|
||||||
|
|
||||||
// then
|
|
||||||
match result {
|
|
||||||
EnforcementResult::Denied {
|
|
||||||
tool,
|
|
||||||
active_mode,
|
|
||||||
required_mode,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
assert_eq!(tool, "write_file");
|
|
||||||
assert_eq!(active_mode, "read-only");
|
|
||||||
assert_eq!(required_mode, "workspace-write");
|
|
||||||
assert!(reason.contains("file writes are not allowed"));
|
|
||||||
}
|
|
||||||
other => panic!("expected denied result, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,532 +0,0 @@
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::config::RuntimePluginConfig;
|
|
||||||
use crate::mcp_tool_bridge::{McpResourceInfo, McpToolInfo};
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ToolInfo = McpToolInfo;
|
|
||||||
pub type ResourceInfo = McpResourceInfo;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ServerStatus {
|
|
||||||
Healthy,
|
|
||||||
Degraded,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for ServerStatus {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Healthy => write!(f, "healthy"),
|
|
||||||
Self::Degraded => write!(f, "degraded"),
|
|
||||||
Self::Failed => write!(f, "failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ServerHealth {
|
|
||||||
pub server_name: String,
|
|
||||||
pub status: ServerStatus,
|
|
||||||
pub capabilities: Vec<String>,
|
|
||||||
pub last_error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case", tag = "state")]
|
|
||||||
pub enum PluginState {
|
|
||||||
Unconfigured,
|
|
||||||
Validated,
|
|
||||||
Starting,
|
|
||||||
Healthy,
|
|
||||||
Degraded {
|
|
||||||
healthy_servers: Vec<String>,
|
|
||||||
failed_servers: Vec<ServerHealth>,
|
|
||||||
},
|
|
||||||
Failed {
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
ShuttingDown,
|
|
||||||
Stopped,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginState {
|
|
||||||
#[must_use]
|
|
||||||
pub fn from_servers(servers: &[ServerHealth]) -> Self {
|
|
||||||
if servers.is_empty() {
|
|
||||||
return Self::Failed {
|
|
||||||
reason: "no servers available".to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let healthy_servers = servers
|
|
||||||
.iter()
|
|
||||||
.filter(|server| server.status != ServerStatus::Failed)
|
|
||||||
.map(|server| server.server_name.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let failed_servers = servers
|
|
||||||
.iter()
|
|
||||||
.filter(|server| server.status == ServerStatus::Failed)
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let has_degraded_server = servers
|
|
||||||
.iter()
|
|
||||||
.any(|server| server.status == ServerStatus::Degraded);
|
|
||||||
|
|
||||||
if failed_servers.is_empty() && !has_degraded_server {
|
|
||||||
Self::Healthy
|
|
||||||
} else if healthy_servers.is_empty() {
|
|
||||||
Self::Failed {
|
|
||||||
reason: format!("all {} servers failed", failed_servers.len()),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Self::Degraded {
|
|
||||||
healthy_servers,
|
|
||||||
failed_servers,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for PluginState {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Unconfigured => write!(f, "unconfigured"),
|
|
||||||
Self::Validated => write!(f, "validated"),
|
|
||||||
Self::Starting => write!(f, "starting"),
|
|
||||||
Self::Healthy => write!(f, "healthy"),
|
|
||||||
Self::Degraded { .. } => write!(f, "degraded"),
|
|
||||||
Self::Failed { .. } => write!(f, "failed"),
|
|
||||||
Self::ShuttingDown => write!(f, "shutting_down"),
|
|
||||||
Self::Stopped => write!(f, "stopped"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct PluginHealthcheck {
|
|
||||||
pub plugin_name: String,
|
|
||||||
pub state: PluginState,
|
|
||||||
pub servers: Vec<ServerHealth>,
|
|
||||||
pub last_check: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginHealthcheck {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(plugin_name: impl Into<String>, servers: Vec<ServerHealth>) -> Self {
|
|
||||||
let state = PluginState::from_servers(&servers);
|
|
||||||
Self {
|
|
||||||
plugin_name: plugin_name.into(),
|
|
||||||
state,
|
|
||||||
servers,
|
|
||||||
last_check: now_secs(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn degraded_mode(&self, discovery: &DiscoveryResult) -> Option<DegradedMode> {
|
|
||||||
match &self.state {
|
|
||||||
PluginState::Degraded {
|
|
||||||
healthy_servers,
|
|
||||||
failed_servers,
|
|
||||||
} => Some(DegradedMode {
|
|
||||||
available_tools: discovery
|
|
||||||
.tools
|
|
||||||
.iter()
|
|
||||||
.map(|tool| tool.name.clone())
|
|
||||||
.collect(),
|
|
||||||
unavailable_tools: failed_servers
|
|
||||||
.iter()
|
|
||||||
.flat_map(|server| server.capabilities.iter().cloned())
|
|
||||||
.collect(),
|
|
||||||
reason: format!(
|
|
||||||
"{} servers healthy, {} servers failed",
|
|
||||||
healthy_servers.len(),
|
|
||||||
failed_servers.len()
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DiscoveryResult {
|
|
||||||
pub tools: Vec<ToolInfo>,
|
|
||||||
pub resources: Vec<ResourceInfo>,
|
|
||||||
pub partial: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct DegradedMode {
|
|
||||||
pub available_tools: Vec<String>,
|
|
||||||
pub unavailable_tools: Vec<String>,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DegradedMode {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(
|
|
||||||
available_tools: Vec<String>,
|
|
||||||
unavailable_tools: Vec<String>,
|
|
||||||
reason: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
available_tools,
|
|
||||||
unavailable_tools,
|
|
||||||
reason: reason.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum PluginLifecycleEvent {
|
|
||||||
ConfigValidated,
|
|
||||||
StartupHealthy,
|
|
||||||
StartupDegraded,
|
|
||||||
StartupFailed,
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for PluginLifecycleEvent {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::ConfigValidated => write!(f, "config_validated"),
|
|
||||||
Self::StartupHealthy => write!(f, "startup_healthy"),
|
|
||||||
Self::StartupDegraded => write!(f, "startup_degraded"),
|
|
||||||
Self::StartupFailed => write!(f, "startup_failed"),
|
|
||||||
Self::Shutdown => write!(f, "shutdown"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait PluginLifecycle {
|
|
||||||
fn validate_config(&self, config: &RuntimePluginConfig) -> Result<(), String>;
|
|
||||||
fn healthcheck(&self) -> PluginHealthcheck;
|
|
||||||
fn discover(&self) -> DiscoveryResult;
|
|
||||||
fn shutdown(&mut self) -> Result<(), String>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct MockPluginLifecycle {
|
|
||||||
plugin_name: String,
|
|
||||||
valid_config: bool,
|
|
||||||
healthcheck: PluginHealthcheck,
|
|
||||||
discovery: DiscoveryResult,
|
|
||||||
shutdown_error: Option<String>,
|
|
||||||
shutdown_called: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockPluginLifecycle {
|
|
||||||
fn new(
|
|
||||||
plugin_name: &str,
|
|
||||||
valid_config: bool,
|
|
||||||
servers: Vec<ServerHealth>,
|
|
||||||
discovery: DiscoveryResult,
|
|
||||||
shutdown_error: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
plugin_name: plugin_name.to_string(),
|
|
||||||
valid_config,
|
|
||||||
healthcheck: PluginHealthcheck::new(plugin_name, servers),
|
|
||||||
discovery,
|
|
||||||
shutdown_error,
|
|
||||||
shutdown_called: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginLifecycle for MockPluginLifecycle {
|
|
||||||
fn validate_config(&self, _config: &RuntimePluginConfig) -> Result<(), String> {
|
|
||||||
if self.valid_config {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!(
|
|
||||||
"plugin `{}` failed configuration validation",
|
|
||||||
self.plugin_name
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn healthcheck(&self) -> PluginHealthcheck {
|
|
||||||
if self.shutdown_called {
|
|
||||||
PluginHealthcheck {
|
|
||||||
plugin_name: self.plugin_name.clone(),
|
|
||||||
state: PluginState::Stopped,
|
|
||||||
servers: self.healthcheck.servers.clone(),
|
|
||||||
last_check: now_secs(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.healthcheck.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn discover(&self) -> DiscoveryResult {
|
|
||||||
self.discovery.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown(&mut self) -> Result<(), String> {
|
|
||||||
if let Some(error) = &self.shutdown_error {
|
|
||||||
return Err(error.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.shutdown_called = true;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn healthy_server(name: &str, capabilities: &[&str]) -> ServerHealth {
|
|
||||||
ServerHealth {
|
|
||||||
server_name: name.to_string(),
|
|
||||||
status: ServerStatus::Healthy,
|
|
||||||
capabilities: capabilities
|
|
||||||
.iter()
|
|
||||||
.map(|capability| capability.to_string())
|
|
||||||
.collect(),
|
|
||||||
last_error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn failed_server(name: &str, capabilities: &[&str], error: &str) -> ServerHealth {
|
|
||||||
ServerHealth {
|
|
||||||
server_name: name.to_string(),
|
|
||||||
status: ServerStatus::Failed,
|
|
||||||
capabilities: capabilities
|
|
||||||
.iter()
|
|
||||||
.map(|capability| capability.to_string())
|
|
||||||
.collect(),
|
|
||||||
last_error: Some(error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn degraded_server(name: &str, capabilities: &[&str], error: &str) -> ServerHealth {
|
|
||||||
ServerHealth {
|
|
||||||
server_name: name.to_string(),
|
|
||||||
status: ServerStatus::Degraded,
|
|
||||||
capabilities: capabilities
|
|
||||||
.iter()
|
|
||||||
.map(|capability| capability.to_string())
|
|
||||||
.collect(),
|
|
||||||
last_error: Some(error.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool(name: &str) -> ToolInfo {
|
|
||||||
ToolInfo {
|
|
||||||
name: name.to_string(),
|
|
||||||
description: Some(format!("{name} tool")),
|
|
||||||
input_schema: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resource(name: &str, uri: &str) -> ResourceInfo {
|
|
||||||
ResourceInfo {
|
|
||||||
uri: uri.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
description: Some(format!("{name} resource")),
|
|
||||||
mime_type: Some("application/json".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn full_lifecycle_happy_path() {
|
|
||||||
// given
|
|
||||||
let mut lifecycle = MockPluginLifecycle::new(
|
|
||||||
"healthy-plugin",
|
|
||||||
true,
|
|
||||||
vec![
|
|
||||||
healthy_server("alpha", &["search", "read"]),
|
|
||||||
healthy_server("beta", &["write"]),
|
|
||||||
],
|
|
||||||
DiscoveryResult {
|
|
||||||
tools: vec![tool("search"), tool("read"), tool("write")],
|
|
||||||
resources: vec![resource("docs", "file:///docs")],
|
|
||||||
partial: false,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let config = RuntimePluginConfig::default();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let validation = lifecycle.validate_config(&config);
|
|
||||||
let healthcheck = lifecycle.healthcheck();
|
|
||||||
let discovery = lifecycle.discover();
|
|
||||||
let shutdown = lifecycle.shutdown();
|
|
||||||
let post_shutdown = lifecycle.healthcheck();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(validation, Ok(()));
|
|
||||||
assert_eq!(healthcheck.state, PluginState::Healthy);
|
|
||||||
assert_eq!(healthcheck.plugin_name, "healthy-plugin");
|
|
||||||
assert_eq!(discovery.tools.len(), 3);
|
|
||||||
assert_eq!(discovery.resources.len(), 1);
|
|
||||||
assert!(!discovery.partial);
|
|
||||||
assert_eq!(shutdown, Ok(()));
|
|
||||||
assert_eq!(post_shutdown.state, PluginState::Stopped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn degraded_startup_when_one_of_three_servers_fails() {
|
|
||||||
// given
|
|
||||||
let lifecycle = MockPluginLifecycle::new(
|
|
||||||
"degraded-plugin",
|
|
||||||
true,
|
|
||||||
vec![
|
|
||||||
healthy_server("alpha", &["search"]),
|
|
||||||
failed_server("beta", &["write"], "connection refused"),
|
|
||||||
healthy_server("gamma", &["read"]),
|
|
||||||
],
|
|
||||||
DiscoveryResult {
|
|
||||||
tools: vec![tool("search"), tool("read")],
|
|
||||||
resources: vec![resource("alpha-docs", "file:///alpha")],
|
|
||||||
partial: true,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let healthcheck = lifecycle.healthcheck();
|
|
||||||
let discovery = lifecycle.discover();
|
|
||||||
let degraded_mode = healthcheck
|
|
||||||
.degraded_mode(&discovery)
|
|
||||||
.expect("degraded startup should expose degraded mode");
|
|
||||||
|
|
||||||
// then
|
|
||||||
match healthcheck.state {
|
|
||||||
PluginState::Degraded {
|
|
||||||
healthy_servers,
|
|
||||||
failed_servers,
|
|
||||||
} => {
|
|
||||||
assert_eq!(
|
|
||||||
healthy_servers,
|
|
||||||
vec!["alpha".to_string(), "gamma".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(failed_servers.len(), 1);
|
|
||||||
assert_eq!(failed_servers[0].server_name, "beta");
|
|
||||||
assert_eq!(
|
|
||||||
failed_servers[0].last_error.as_deref(),
|
|
||||||
Some("connection refused")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => panic!("expected degraded state, got {other:?}"),
|
|
||||||
}
|
|
||||||
assert!(discovery.partial);
|
|
||||||
assert_eq!(
|
|
||||||
degraded_mode.available_tools,
|
|
||||||
vec!["search".to_string(), "read".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(degraded_mode.unavailable_tools, vec!["write".to_string()]);
|
|
||||||
assert_eq!(degraded_mode.reason, "2 servers healthy, 1 servers failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn degraded_server_status_keeps_server_usable() {
|
|
||||||
// given
|
|
||||||
let lifecycle = MockPluginLifecycle::new(
|
|
||||||
"soft-degraded-plugin",
|
|
||||||
true,
|
|
||||||
vec![
|
|
||||||
healthy_server("alpha", &["search"]),
|
|
||||||
degraded_server("beta", &["write"], "high latency"),
|
|
||||||
],
|
|
||||||
DiscoveryResult {
|
|
||||||
tools: vec![tool("search"), tool("write")],
|
|
||||||
resources: Vec::new(),
|
|
||||||
partial: true,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let healthcheck = lifecycle.healthcheck();
|
|
||||||
|
|
||||||
// then
|
|
||||||
match healthcheck.state {
|
|
||||||
PluginState::Degraded {
|
|
||||||
healthy_servers,
|
|
||||||
failed_servers,
|
|
||||||
} => {
|
|
||||||
assert_eq!(
|
|
||||||
healthy_servers,
|
|
||||||
vec!["alpha".to_string(), "beta".to_string()]
|
|
||||||
);
|
|
||||||
assert!(failed_servers.is_empty());
|
|
||||||
}
|
|
||||||
other => panic!("expected degraded state, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_failure_when_all_servers_fail() {
|
|
||||||
// given
|
|
||||||
let lifecycle = MockPluginLifecycle::new(
|
|
||||||
"failed-plugin",
|
|
||||||
true,
|
|
||||||
vec![
|
|
||||||
failed_server("alpha", &["search"], "timeout"),
|
|
||||||
failed_server("beta", &["read"], "handshake failed"),
|
|
||||||
],
|
|
||||||
DiscoveryResult {
|
|
||||||
tools: Vec::new(),
|
|
||||||
resources: Vec::new(),
|
|
||||||
partial: false,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let healthcheck = lifecycle.healthcheck();
|
|
||||||
let discovery = lifecycle.discover();
|
|
||||||
|
|
||||||
// then
|
|
||||||
match &healthcheck.state {
|
|
||||||
PluginState::Failed { reason } => {
|
|
||||||
assert_eq!(reason, "all 2 servers failed");
|
|
||||||
}
|
|
||||||
other => panic!("expected failed state, got {other:?}"),
|
|
||||||
}
|
|
||||||
assert!(!discovery.partial);
|
|
||||||
assert!(discovery.tools.is_empty());
|
|
||||||
assert!(discovery.resources.is_empty());
|
|
||||||
assert!(healthcheck.degraded_mode(&discovery).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn graceful_shutdown() {
|
|
||||||
// given
|
|
||||||
let mut lifecycle = MockPluginLifecycle::new(
|
|
||||||
"shutdown-plugin",
|
|
||||||
true,
|
|
||||||
vec![healthy_server("alpha", &["search"])],
|
|
||||||
DiscoveryResult {
|
|
||||||
tools: vec![tool("search")],
|
|
||||||
resources: Vec::new(),
|
|
||||||
partial: false,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let shutdown = lifecycle.shutdown();
|
|
||||||
let post_shutdown = lifecycle.healthcheck();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(shutdown, Ok(()));
|
|
||||||
assert_eq!(PluginLifecycleEvent::Shutdown.to_string(), "shutdown");
|
|
||||||
assert_eq!(post_shutdown.state, PluginState::Stopped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,458 +0,0 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub type GreenLevel = u8;
|
|
||||||
|
|
||||||
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct PolicyRule {
|
|
||||||
pub name: String,
|
|
||||||
pub condition: PolicyCondition,
|
|
||||||
pub action: PolicyAction,
|
|
||||||
pub priority: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PolicyRule {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(
|
|
||||||
name: impl Into<String>,
|
|
||||||
condition: PolicyCondition,
|
|
||||||
action: PolicyAction,
|
|
||||||
priority: u32,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.into(),
|
|
||||||
condition,
|
|
||||||
action,
|
|
||||||
priority,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn matches(&self, context: &LaneContext) -> bool {
|
|
||||||
self.condition.matches(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum PolicyCondition {
|
|
||||||
And(Vec<PolicyCondition>),
|
|
||||||
Or(Vec<PolicyCondition>),
|
|
||||||
GreenAt { level: GreenLevel },
|
|
||||||
StaleBranch,
|
|
||||||
StartupBlocked,
|
|
||||||
LaneCompleted,
|
|
||||||
ReviewPassed,
|
|
||||||
ScopedDiff,
|
|
||||||
TimedOut { duration: Duration },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PolicyCondition {
|
|
||||||
#[must_use]
|
|
||||||
pub fn matches(&self, context: &LaneContext) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::And(conditions) => conditions
|
|
||||||
.iter()
|
|
||||||
.all(|condition| condition.matches(context)),
|
|
||||||
Self::Or(conditions) => conditions
|
|
||||||
.iter()
|
|
||||||
.any(|condition| condition.matches(context)),
|
|
||||||
Self::GreenAt { level } => context.green_level >= *level,
|
|
||||||
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
|
|
||||||
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
|
|
||||||
Self::LaneCompleted => context.completed,
|
|
||||||
Self::ReviewPassed => context.review_status == ReviewStatus::Approved,
|
|
||||||
Self::ScopedDiff => context.diff_scope == DiffScope::Scoped,
|
|
||||||
Self::TimedOut { duration } => context.branch_freshness >= *duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum PolicyAction {
|
|
||||||
MergeToDev,
|
|
||||||
MergeForward,
|
|
||||||
RecoverOnce,
|
|
||||||
Escalate { reason: String },
|
|
||||||
CloseoutLane,
|
|
||||||
CleanupSession,
|
|
||||||
Notify { channel: String },
|
|
||||||
Block { reason: String },
|
|
||||||
Chain(Vec<PolicyAction>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PolicyAction {
|
|
||||||
fn flatten_into(&self, actions: &mut Vec<PolicyAction>) {
|
|
||||||
match self {
|
|
||||||
Self::Chain(chained) => {
|
|
||||||
for action in chained {
|
|
||||||
action.flatten_into(actions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => actions.push(self.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum LaneBlocker {
|
|
||||||
None,
|
|
||||||
Startup,
|
|
||||||
External,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ReviewStatus {
|
|
||||||
Pending,
|
|
||||||
Approved,
|
|
||||||
Rejected,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DiffScope {
|
|
||||||
Full,
|
|
||||||
Scoped,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LaneContext {
|
|
||||||
pub lane_id: String,
|
|
||||||
pub green_level: GreenLevel,
|
|
||||||
pub branch_freshness: Duration,
|
|
||||||
pub blocker: LaneBlocker,
|
|
||||||
pub review_status: ReviewStatus,
|
|
||||||
pub diff_scope: DiffScope,
|
|
||||||
pub completed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LaneContext {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(
|
|
||||||
lane_id: impl Into<String>,
|
|
||||||
green_level: GreenLevel,
|
|
||||||
branch_freshness: Duration,
|
|
||||||
blocker: LaneBlocker,
|
|
||||||
review_status: ReviewStatus,
|
|
||||||
diff_scope: DiffScope,
|
|
||||||
completed: bool,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
lane_id: lane_id.into(),
|
|
||||||
green_level,
|
|
||||||
branch_freshness,
|
|
||||||
blocker,
|
|
||||||
review_status,
|
|
||||||
diff_scope,
|
|
||||||
completed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct PolicyEngine {
|
|
||||||
rules: Vec<PolicyRule>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PolicyEngine {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(mut rules: Vec<PolicyRule>) -> Self {
|
|
||||||
rules.sort_by_key(|rule| rule.priority);
|
|
||||||
Self { rules }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn rules(&self) -> &[PolicyRule] {
|
|
||||||
&self.rules
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn evaluate(&self, context: &LaneContext) -> Vec<PolicyAction> {
|
|
||||||
evaluate(self, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn evaluate(engine: &PolicyEngine, context: &LaneContext) -> Vec<PolicyAction> {
|
|
||||||
let mut actions = Vec::new();
|
|
||||||
for rule in &engine.rules {
|
|
||||||
if rule.matches(context) {
|
|
||||||
rule.action.flatten_into(&mut actions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
actions
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine,
|
|
||||||
PolicyRule, ReviewStatus, STALE_BRANCH_THRESHOLD,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn default_context() -> LaneContext {
|
|
||||||
LaneContext::new(
|
|
||||||
"lane-7",
|
|
||||||
0,
|
|
||||||
Duration::from_secs(0),
|
|
||||||
LaneBlocker::None,
|
|
||||||
ReviewStatus::Pending,
|
|
||||||
DiffScope::Full,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_to_dev_rule_fires_for_green_scoped_reviewed_lane() {
|
|
||||||
// given
|
|
||||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
|
||||||
"merge-to-dev",
|
|
||||||
PolicyCondition::And(vec![
|
|
||||||
PolicyCondition::GreenAt { level: 2 },
|
|
||||||
PolicyCondition::ScopedDiff,
|
|
||||||
PolicyCondition::ReviewPassed,
|
|
||||||
]),
|
|
||||||
PolicyAction::MergeToDev,
|
|
||||||
20,
|
|
||||||
)]);
|
|
||||||
let context = LaneContext::new(
|
|
||||||
"lane-7",
|
|
||||||
3,
|
|
||||||
Duration::from_secs(5),
|
|
||||||
LaneBlocker::None,
|
|
||||||
ReviewStatus::Approved,
|
|
||||||
DiffScope::Scoped,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let actions = engine.evaluate(&context);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_branch_rule_fires_at_threshold() {
|
|
||||||
// given
|
|
||||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
|
||||||
"merge-forward",
|
|
||||||
PolicyCondition::StaleBranch,
|
|
||||||
PolicyAction::MergeForward,
|
|
||||||
10,
|
|
||||||
)]);
|
|
||||||
let context = LaneContext::new(
|
|
||||||
"lane-7",
|
|
||||||
1,
|
|
||||||
STALE_BRANCH_THRESHOLD,
|
|
||||||
LaneBlocker::None,
|
|
||||||
ReviewStatus::Pending,
|
|
||||||
DiffScope::Full,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let actions = engine.evaluate(&context);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(actions, vec![PolicyAction::MergeForward]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn startup_blocked_rule_recovers_then_escalates() {
|
|
||||||
// given
|
|
||||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
|
||||||
"startup-recovery",
|
|
||||||
PolicyCondition::StartupBlocked,
|
|
||||||
PolicyAction::Chain(vec![
|
|
||||||
PolicyAction::RecoverOnce,
|
|
||||||
PolicyAction::Escalate {
|
|
||||||
reason: "startup remained blocked".to_string(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
15,
|
|
||||||
)]);
|
|
||||||
let context = LaneContext::new(
|
|
||||||
"lane-7",
|
|
||||||
0,
|
|
||||||
Duration::from_secs(0),
|
|
||||||
LaneBlocker::Startup,
|
|
||||||
ReviewStatus::Pending,
|
|
||||||
DiffScope::Full,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let actions = engine.evaluate(&context);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
actions,
|
|
||||||
vec![
|
|
||||||
PolicyAction::RecoverOnce,
|
|
||||||
PolicyAction::Escalate {
|
|
||||||
reason: "startup remained blocked".to_string(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn completed_lane_rule_closes_out_and_cleans_up() {
|
|
||||||
// given
|
|
||||||
let engine = PolicyEngine::new(vec![PolicyRule::new(
|
|
||||||
"lane-closeout",
|
|
||||||
PolicyCondition::LaneCompleted,
|
|
||||||
PolicyAction::Chain(vec![
|
|
||||||
PolicyAction::CloseoutLane,
|
|
||||||
PolicyAction::CleanupSession,
|
|
||||||
]),
|
|
||||||
30,
|
|
||||||
)]);
|
|
||||||
let context = LaneContext::new(
|
|
||||||
"lane-7",
|
|
||||||
0,
|
|
||||||
Duration::from_secs(0),
|
|
||||||
LaneBlocker::None,
|
|
||||||
ReviewStatus::Pending,
|
|
||||||
DiffScope::Full,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let actions = engine.evaluate(&context);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
actions,
|
|
||||||
vec![PolicyAction::CloseoutLane, PolicyAction::CleanupSession]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matching_rules_are_returned_in_priority_order_with_stable_ties() {
|
|
||||||
// given
|
|
||||||
let engine = PolicyEngine::new(vec![
|
|
||||||
PolicyRule::new(
|
|
||||||
"late-cleanup",
|
|
||||||
PolicyCondition::And(vec![]),
|
|
||||||
PolicyAction::CleanupSession,
|
|
||||||
30,
|
|
||||||
),
|
|
||||||
PolicyRule::new(
|
|
||||||
"first-notify",
|
|
||||||
PolicyCondition::And(vec![]),
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "ops".to_string(),
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
PolicyRule::new(
|
|
||||||
"second-notify",
|
|
||||||
PolicyCondition::And(vec![]),
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "review".to_string(),
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
PolicyRule::new(
|
|
||||||
"merge",
|
|
||||||
PolicyCondition::And(vec![]),
|
|
||||||
PolicyAction::MergeToDev,
|
|
||||||
20,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
let context = default_context();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let actions = evaluate(&engine, &context);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
actions,
|
|
||||||
vec![
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "ops".to_string(),
|
|
||||||
},
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "review".to_string(),
|
|
||||||
},
|
|
||||||
PolicyAction::MergeToDev,
|
|
||||||
PolicyAction::CleanupSession,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn combinators_handle_empty_cases_and_nested_chains() {
|
|
||||||
// given
|
|
||||||
let engine = PolicyEngine::new(vec![
|
|
||||||
PolicyRule::new(
|
|
||||||
"empty-and",
|
|
||||||
PolicyCondition::And(vec![]),
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "orchestrator".to_string(),
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
),
|
|
||||||
PolicyRule::new(
|
|
||||||
"empty-or",
|
|
||||||
PolicyCondition::Or(vec![]),
|
|
||||||
PolicyAction::Block {
|
|
||||||
reason: "should not fire".to_string(),
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
PolicyRule::new(
|
|
||||||
"nested",
|
|
||||||
PolicyCondition::Or(vec![
|
|
||||||
PolicyCondition::StartupBlocked,
|
|
||||||
PolicyCondition::And(vec![
|
|
||||||
PolicyCondition::GreenAt { level: 2 },
|
|
||||||
PolicyCondition::TimedOut {
|
|
||||||
duration: Duration::from_secs(5),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
PolicyAction::Chain(vec![
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "alerts".to_string(),
|
|
||||||
},
|
|
||||||
PolicyAction::Chain(vec![
|
|
||||||
PolicyAction::MergeForward,
|
|
||||||
PolicyAction::CleanupSession,
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
15,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
let context = LaneContext::new(
|
|
||||||
"lane-7",
|
|
||||||
2,
|
|
||||||
Duration::from_secs(10),
|
|
||||||
LaneBlocker::External,
|
|
||||||
ReviewStatus::Pending,
|
|
||||||
DiffScope::Full,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let actions = engine.evaluate(&context);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
actions,
|
|
||||||
vec![
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "orchestrator".to_string(),
|
|
||||||
},
|
|
||||||
PolicyAction::Notify {
|
|
||||||
channel: "alerts".to_string(),
|
|
||||||
},
|
|
||||||
PolicyAction::MergeForward,
|
|
||||||
PolicyAction::CleanupSession,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,554 +0,0 @@
|
||||||
//! Recovery recipes for common failure scenarios.
|
|
||||||
//!
|
|
||||||
//! Encodes known automatic recoveries for the six failure scenarios
|
|
||||||
//! listed in ROADMAP item 8, and enforces one automatic recovery
|
|
||||||
//! attempt before escalation. Each attempt is emitted as a structured
|
|
||||||
//! recovery event.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// The six failure scenarios that have known recovery recipes.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum FailureScenario {
|
|
||||||
TrustPromptUnresolved,
|
|
||||||
PromptMisdelivery,
|
|
||||||
StaleBranch,
|
|
||||||
CompileRedCrossCrate,
|
|
||||||
McpHandshakeFailure,
|
|
||||||
PartialPluginStartup,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FailureScenario {
|
|
||||||
/// Returns all known failure scenarios.
|
|
||||||
#[must_use]
|
|
||||||
pub fn all() -> &'static [FailureScenario] {
|
|
||||||
&[
|
|
||||||
Self::TrustPromptUnresolved,
|
|
||||||
Self::PromptMisdelivery,
|
|
||||||
Self::StaleBranch,
|
|
||||||
Self::CompileRedCrossCrate,
|
|
||||||
Self::McpHandshakeFailure,
|
|
||||||
Self::PartialPluginStartup,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for FailureScenario {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::TrustPromptUnresolved => write!(f, "trust_prompt_unresolved"),
|
|
||||||
Self::PromptMisdelivery => write!(f, "prompt_misdelivery"),
|
|
||||||
Self::StaleBranch => write!(f, "stale_branch"),
|
|
||||||
Self::CompileRedCrossCrate => write!(f, "compile_red_cross_crate"),
|
|
||||||
Self::McpHandshakeFailure => write!(f, "mcp_handshake_failure"),
|
|
||||||
Self::PartialPluginStartup => write!(f, "partial_plugin_startup"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Individual step that can be executed as part of a recovery recipe.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum RecoveryStep {
|
|
||||||
AcceptTrustPrompt,
|
|
||||||
RedirectPromptToAgent,
|
|
||||||
RebaseBranch,
|
|
||||||
CleanBuild,
|
|
||||||
RetryMcpHandshake { timeout: u64 },
|
|
||||||
RestartPlugin { name: String },
|
|
||||||
EscalateToHuman { reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Policy governing what happens when automatic recovery is exhausted.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EscalationPolicy {
|
|
||||||
AlertHuman,
|
|
||||||
LogAndContinue,
|
|
||||||
Abort,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A recovery recipe encodes the sequence of steps to attempt for a
|
|
||||||
/// given failure scenario, along with the maximum number of automatic
|
|
||||||
/// attempts and the escalation policy.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RecoveryRecipe {
|
|
||||||
pub scenario: FailureScenario,
|
|
||||||
pub steps: Vec<RecoveryStep>,
|
|
||||||
pub max_attempts: u32,
|
|
||||||
pub escalation_policy: EscalationPolicy,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome of a recovery attempt.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum RecoveryResult {
|
|
||||||
Recovered {
|
|
||||||
steps_taken: u32,
|
|
||||||
},
|
|
||||||
PartialRecovery {
|
|
||||||
recovered: Vec<RecoveryStep>,
|
|
||||||
remaining: Vec<RecoveryStep>,
|
|
||||||
},
|
|
||||||
EscalationRequired {
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Structured event emitted during recovery.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum RecoveryEvent {
|
|
||||||
RecoveryAttempted {
|
|
||||||
scenario: FailureScenario,
|
|
||||||
recipe: RecoveryRecipe,
|
|
||||||
result: RecoveryResult,
|
|
||||||
},
|
|
||||||
RecoverySucceeded,
|
|
||||||
RecoveryFailed,
|
|
||||||
Escalated,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal context for tracking recovery state and emitting events.
|
|
||||||
///
|
|
||||||
/// Holds per-scenario attempt counts, a structured event log, and an
|
|
||||||
/// optional simulation knob for controlling step outcomes during tests.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct RecoveryContext {
|
|
||||||
attempts: HashMap<FailureScenario, u32>,
|
|
||||||
events: Vec<RecoveryEvent>,
|
|
||||||
/// Optional step index at which simulated execution fails.
|
|
||||||
/// `None` means all steps succeed.
|
|
||||||
fail_at_step: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RecoveryContext {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure a step index at which simulated execution will fail.
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_fail_at_step(mut self, index: usize) -> Self {
|
|
||||||
self.fail_at_step = Some(index);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the structured event log populated during recovery.
|
|
||||||
#[must_use]
|
|
||||||
pub fn events(&self) -> &[RecoveryEvent] {
|
|
||||||
&self.events
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the number of recovery attempts made for a scenario.
|
|
||||||
#[must_use]
|
|
||||||
pub fn attempt_count(&self, scenario: &FailureScenario) -> u32 {
|
|
||||||
self.attempts.get(scenario).copied().unwrap_or(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the known recovery recipe for the given failure scenario.
|
|
||||||
#[must_use]
|
|
||||||
pub fn recipe_for(scenario: &FailureScenario) -> RecoveryRecipe {
|
|
||||||
match scenario {
|
|
||||||
FailureScenario::TrustPromptUnresolved => RecoveryRecipe {
|
|
||||||
scenario: *scenario,
|
|
||||||
steps: vec![RecoveryStep::AcceptTrustPrompt],
|
|
||||||
max_attempts: 1,
|
|
||||||
escalation_policy: EscalationPolicy::AlertHuman,
|
|
||||||
},
|
|
||||||
FailureScenario::PromptMisdelivery => RecoveryRecipe {
|
|
||||||
scenario: *scenario,
|
|
||||||
steps: vec![RecoveryStep::RedirectPromptToAgent],
|
|
||||||
max_attempts: 1,
|
|
||||||
escalation_policy: EscalationPolicy::AlertHuman,
|
|
||||||
},
|
|
||||||
FailureScenario::StaleBranch => RecoveryRecipe {
|
|
||||||
scenario: *scenario,
|
|
||||||
steps: vec![RecoveryStep::RebaseBranch, RecoveryStep::CleanBuild],
|
|
||||||
max_attempts: 1,
|
|
||||||
escalation_policy: EscalationPolicy::AlertHuman,
|
|
||||||
},
|
|
||||||
FailureScenario::CompileRedCrossCrate => RecoveryRecipe {
|
|
||||||
scenario: *scenario,
|
|
||||||
steps: vec![RecoveryStep::CleanBuild],
|
|
||||||
max_attempts: 1,
|
|
||||||
escalation_policy: EscalationPolicy::AlertHuman,
|
|
||||||
},
|
|
||||||
FailureScenario::McpHandshakeFailure => RecoveryRecipe {
|
|
||||||
scenario: *scenario,
|
|
||||||
steps: vec![RecoveryStep::RetryMcpHandshake { timeout: 5000 }],
|
|
||||||
max_attempts: 1,
|
|
||||||
escalation_policy: EscalationPolicy::Abort,
|
|
||||||
},
|
|
||||||
FailureScenario::PartialPluginStartup => RecoveryRecipe {
|
|
||||||
scenario: *scenario,
|
|
||||||
steps: vec![
|
|
||||||
RecoveryStep::RestartPlugin {
|
|
||||||
name: "stalled".to_string(),
|
|
||||||
},
|
|
||||||
RecoveryStep::RetryMcpHandshake { timeout: 3000 },
|
|
||||||
],
|
|
||||||
max_attempts: 1,
|
|
||||||
escalation_policy: EscalationPolicy::LogAndContinue,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts automatic recovery for the given failure scenario.
|
|
||||||
///
|
|
||||||
/// Looks up the recipe, enforces the one-attempt-before-escalation
|
|
||||||
/// policy, simulates step execution (controlled by the context), and
|
|
||||||
/// emits structured [`RecoveryEvent`]s for every attempt.
|
|
||||||
pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -> RecoveryResult {
|
|
||||||
let recipe = recipe_for(scenario);
|
|
||||||
let attempt_count = ctx.attempts.entry(*scenario).or_insert(0);
|
|
||||||
|
|
||||||
// Enforce one automatic recovery attempt before escalation.
|
|
||||||
if *attempt_count >= recipe.max_attempts {
|
|
||||||
let result = RecoveryResult::EscalationRequired {
|
|
||||||
reason: format!(
|
|
||||||
"max recovery attempts ({}) exceeded for {}",
|
|
||||||
recipe.max_attempts, scenario
|
|
||||||
),
|
|
||||||
};
|
|
||||||
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
|
||||||
scenario: *scenario,
|
|
||||||
recipe,
|
|
||||||
result: result.clone(),
|
|
||||||
});
|
|
||||||
ctx.events.push(RecoveryEvent::Escalated);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
*attempt_count += 1;
|
|
||||||
|
|
||||||
// Execute steps, honoring the optional fail_at_step simulation.
|
|
||||||
let fail_index = ctx.fail_at_step;
|
|
||||||
let mut executed = Vec::new();
|
|
||||||
let mut failed = false;
|
|
||||||
|
|
||||||
for (i, step) in recipe.steps.iter().enumerate() {
|
|
||||||
if fail_index == Some(i) {
|
|
||||||
failed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
executed.push(step.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = if failed {
|
|
||||||
let remaining: Vec<RecoveryStep> = recipe.steps[executed.len()..].to_vec();
|
|
||||||
if executed.is_empty() {
|
|
||||||
RecoveryResult::EscalationRequired {
|
|
||||||
reason: format!("recovery failed at first step for {}", scenario),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RecoveryResult::PartialRecovery {
|
|
||||||
recovered: executed,
|
|
||||||
remaining,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RecoveryResult::Recovered {
|
|
||||||
steps_taken: recipe.steps.len() as u32,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit the attempt as structured event data.
|
|
||||||
ctx.events.push(RecoveryEvent::RecoveryAttempted {
|
|
||||||
scenario: *scenario,
|
|
||||||
recipe,
|
|
||||||
result: result.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
match &result {
|
|
||||||
RecoveryResult::Recovered { .. } => {
|
|
||||||
ctx.events.push(RecoveryEvent::RecoverySucceeded);
|
|
||||||
}
|
|
||||||
RecoveryResult::PartialRecovery { .. } => {
|
|
||||||
ctx.events.push(RecoveryEvent::RecoveryFailed);
|
|
||||||
}
|
|
||||||
RecoveryResult::EscalationRequired { .. } => {
|
|
||||||
ctx.events.push(RecoveryEvent::Escalated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn each_scenario_has_a_matching_recipe() {
|
|
||||||
// given
|
|
||||||
let scenarios = FailureScenario::all();
|
|
||||||
|
|
||||||
// when / then
|
|
||||||
for scenario in scenarios {
|
|
||||||
let recipe = recipe_for(scenario);
|
|
||||||
assert_eq!(
|
|
||||||
recipe.scenario, *scenario,
|
|
||||||
"recipe scenario should match requested scenario"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!recipe.steps.is_empty(),
|
|
||||||
"recipe for {} should have at least one step",
|
|
||||||
scenario
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
recipe.max_attempts >= 1,
|
|
||||||
"recipe for {} should allow at least one attempt",
|
|
||||||
scenario
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn successful_recovery_returns_recovered_and_emits_events() {
|
|
||||||
// given
|
|
||||||
let mut ctx = RecoveryContext::new();
|
|
||||||
let scenario = FailureScenario::TrustPromptUnresolved;
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = attempt_recovery(&scenario, &mut ctx);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 1 });
|
|
||||||
assert_eq!(ctx.events().len(), 2);
|
|
||||||
assert!(matches!(
|
|
||||||
&ctx.events()[0],
|
|
||||||
RecoveryEvent::RecoveryAttempted {
|
|
||||||
scenario: s,
|
|
||||||
result: r,
|
|
||||||
..
|
|
||||||
} if *s == FailureScenario::TrustPromptUnresolved
|
|
||||||
&& matches!(r, RecoveryResult::Recovered { steps_taken: 1 })
|
|
||||||
));
|
|
||||||
assert_eq!(ctx.events()[1], RecoveryEvent::RecoverySucceeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn escalation_after_max_attempts_exceeded() {
|
|
||||||
// given
|
|
||||||
let mut ctx = RecoveryContext::new();
|
|
||||||
let scenario = FailureScenario::PromptMisdelivery;
|
|
||||||
|
|
||||||
// when — first attempt succeeds
|
|
||||||
let first = attempt_recovery(&scenario, &mut ctx);
|
|
||||||
assert!(matches!(first, RecoveryResult::Recovered { .. }));
|
|
||||||
|
|
||||||
// when — second attempt should escalate
|
|
||||||
let second = attempt_recovery(&scenario, &mut ctx);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
&second,
|
|
||||||
RecoveryResult::EscalationRequired { reason }
|
|
||||||
if reason.contains("max recovery attempts")
|
|
||||||
),
|
|
||||||
"second attempt should require escalation, got: {second:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(ctx.attempt_count(&scenario), 1);
|
|
||||||
assert!(ctx
|
|
||||||
.events()
|
|
||||||
.iter()
|
|
||||||
.any(|e| matches!(e, RecoveryEvent::Escalated)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn partial_recovery_when_step_fails_midway() {
|
|
||||||
// given — PartialPluginStartup has two steps; fail at step index 1
|
|
||||||
let mut ctx = RecoveryContext::new().with_fail_at_step(1);
|
|
||||||
let scenario = FailureScenario::PartialPluginStartup;
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = attempt_recovery(&scenario, &mut ctx);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match &result {
|
|
||||||
RecoveryResult::PartialRecovery {
|
|
||||||
recovered,
|
|
||||||
remaining,
|
|
||||||
} => {
|
|
||||||
assert_eq!(recovered.len(), 1, "one step should have succeeded");
|
|
||||||
assert_eq!(remaining.len(), 1, "one step should remain");
|
|
||||||
assert!(matches!(recovered[0], RecoveryStep::RestartPlugin { .. }));
|
|
||||||
assert!(matches!(
|
|
||||||
remaining[0],
|
|
||||||
RecoveryStep::RetryMcpHandshake { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
other => panic!("expected PartialRecovery, got {other:?}"),
|
|
||||||
}
|
|
||||||
assert!(ctx
|
|
||||||
.events()
|
|
||||||
.iter()
|
|
||||||
.any(|e| matches!(e, RecoveryEvent::RecoveryFailed)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn first_step_failure_escalates_immediately() {
|
|
||||||
// given — fail at step index 0
|
|
||||||
let mut ctx = RecoveryContext::new().with_fail_at_step(0);
|
|
||||||
let scenario = FailureScenario::CompileRedCrossCrate;
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = attempt_recovery(&scenario, &mut ctx);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
&result,
|
|
||||||
RecoveryResult::EscalationRequired { reason }
|
|
||||||
if reason.contains("failed at first step")
|
|
||||||
),
|
|
||||||
"zero-step failure should escalate, got: {result:?}"
|
|
||||||
);
|
|
||||||
assert!(ctx
|
|
||||||
.events()
|
|
||||||
.iter()
|
|
||||||
.any(|e| matches!(e, RecoveryEvent::Escalated)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn emitted_events_include_structured_attempt_data() {
|
|
||||||
// given
|
|
||||||
let mut ctx = RecoveryContext::new();
|
|
||||||
let scenario = FailureScenario::McpHandshakeFailure;
|
|
||||||
|
|
||||||
// when
|
|
||||||
let _ = attempt_recovery(&scenario, &mut ctx);
|
|
||||||
|
|
||||||
// then — verify the RecoveryAttempted event carries full context
|
|
||||||
let attempted = ctx
|
|
||||||
.events()
|
|
||||||
.iter()
|
|
||||||
.find(|e| matches!(e, RecoveryEvent::RecoveryAttempted { .. }))
|
|
||||||
.expect("should have emitted RecoveryAttempted event");
|
|
||||||
|
|
||||||
match attempted {
|
|
||||||
RecoveryEvent::RecoveryAttempted {
|
|
||||||
scenario: s,
|
|
||||||
recipe,
|
|
||||||
result,
|
|
||||||
} => {
|
|
||||||
assert_eq!(*s, scenario);
|
|
||||||
assert_eq!(recipe.scenario, scenario);
|
|
||||||
assert!(!recipe.steps.is_empty());
|
|
||||||
assert!(matches!(result, RecoveryResult::Recovered { .. }));
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the event is serializable as structured JSON
|
|
||||||
let json = serde_json::to_string(&ctx.events()[0])
|
|
||||||
.expect("recovery event should be serializable to JSON");
|
|
||||||
assert!(
|
|
||||||
json.contains("mcp_handshake_failure"),
|
|
||||||
"serialized event should contain scenario name"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recovery_context_tracks_attempts_per_scenario() {
|
|
||||||
// given
|
|
||||||
let mut ctx = RecoveryContext::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
assert_eq!(ctx.attempt_count(&FailureScenario::StaleBranch), 0);
|
|
||||||
attempt_recovery(&FailureScenario::StaleBranch, &mut ctx);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(ctx.attempt_count(&FailureScenario::StaleBranch), 1);
|
|
||||||
assert_eq!(ctx.attempt_count(&FailureScenario::PromptMisdelivery), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_branch_recipe_has_rebase_then_clean_build() {
|
|
||||||
// given
|
|
||||||
let recipe = recipe_for(&FailureScenario::StaleBranch);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(recipe.steps.len(), 2);
|
|
||||||
assert_eq!(recipe.steps[0], RecoveryStep::RebaseBranch);
|
|
||||||
assert_eq!(recipe.steps[1], RecoveryStep::CleanBuild);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn partial_plugin_startup_recipe_has_restart_then_handshake() {
|
|
||||||
// given
|
|
||||||
let recipe = recipe_for(&FailureScenario::PartialPluginStartup);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(recipe.steps.len(), 2);
|
|
||||||
assert!(matches!(
|
|
||||||
recipe.steps[0],
|
|
||||||
RecoveryStep::RestartPlugin { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
recipe.steps[1],
|
|
||||||
RecoveryStep::RetryMcpHandshake { timeout: 3000 }
|
|
||||||
));
|
|
||||||
assert_eq!(recipe.escalation_policy, EscalationPolicy::LogAndContinue);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failure_scenario_display_all_variants() {
|
|
||||||
// given
|
|
||||||
let cases = [
|
|
||||||
(
|
|
||||||
FailureScenario::TrustPromptUnresolved,
|
|
||||||
"trust_prompt_unresolved",
|
|
||||||
),
|
|
||||||
(FailureScenario::PromptMisdelivery, "prompt_misdelivery"),
|
|
||||||
(FailureScenario::StaleBranch, "stale_branch"),
|
|
||||||
(
|
|
||||||
FailureScenario::CompileRedCrossCrate,
|
|
||||||
"compile_red_cross_crate",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
FailureScenario::McpHandshakeFailure,
|
|
||||||
"mcp_handshake_failure",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
FailureScenario::PartialPluginStartup,
|
|
||||||
"partial_plugin_startup",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when / then
|
|
||||||
for (scenario, expected) in &cases {
|
|
||||||
assert_eq!(scenario.to_string(), *expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multi_step_success_reports_correct_steps_taken() {
|
|
||||||
// given — StaleBranch has 2 steps, no simulated failure
|
|
||||||
let mut ctx = RecoveryContext::new();
|
|
||||||
let scenario = FailureScenario::StaleBranch;
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = attempt_recovery(&scenario, &mut ctx);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mcp_handshake_recipe_uses_abort_escalation_policy() {
|
|
||||||
// given
|
|
||||||
let recipe = recipe_for(&FailureScenario::McpHandshakeFailure);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(recipe.escalation_policy, EscalationPolicy::Abort);
|
|
||||||
assert_eq!(recipe.max_attempts, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -161,7 +161,7 @@ pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStat
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
|
pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
|
||||||
let container = detect_container_environment();
|
let container = detect_container_environment();
|
||||||
let namespace_supported = cfg!(target_os = "linux") && unshare_user_namespace_works();
|
let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare");
|
||||||
let network_supported = namespace_supported;
|
let network_supported = namespace_supported;
|
||||||
let filesystem_active =
|
let filesystem_active =
|
||||||
request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
|
request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
|
||||||
|
|
@ -282,27 +282,6 @@ fn command_exists(command: &str) -> bool {
|
||||||
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
|
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether `unshare --user` actually works on this system.
|
|
||||||
/// On some CI environments (e.g. GitHub Actions), the binary exists but
|
|
||||||
/// user namespaces are restricted, causing silent failures.
|
|
||||||
fn unshare_user_namespace_works() -> bool {
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
static RESULT: OnceLock<bool> = OnceLock::new();
|
|
||||||
*RESULT.get_or_init(|| {
|
|
||||||
if !command_exists("unshare") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
std::process::Command::new("unshare")
|
|
||||||
.args(["--user", "--map-root-user", "true"])
|
|
||||||
.stdin(std::process::Stdio::null())
|
|
||||||
.stdout(std::process::Stdio::null())
|
|
||||||
.stderr(std::process::Stdio::null())
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,8 @@ impl Session {
|
||||||
|
|
||||||
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
|
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let snapshot = self.render_jsonl_snapshot()?;
|
|
||||||
rotate_session_file_if_needed(path)?;
|
rotate_session_file_if_needed(path)?;
|
||||||
write_atomic(path, &snapshot)?;
|
write_atomic(path, &self.render_jsonl_snapshot())?;
|
||||||
cleanup_rotated_logs(path)?;
|
cleanup_rotated_logs(path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -174,24 +173,14 @@ impl Session {
|
||||||
Ok(session.with_persistence_path(path.to_path_buf()))
|
Ok(session.with_persistence_path(path.to_path_buf()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_message(&mut self, message: ConversationMessage) -> Result<(), SessionError> {
|
pub fn push_message(&mut self, message: &ConversationMessage) -> Result<(), SessionError> {
|
||||||
self.touch();
|
self.touch();
|
||||||
self.messages.push(message);
|
self.messages.push(message.clone());
|
||||||
let persist_result = {
|
self.append_persisted_message(message)
|
||||||
let message_ref = self.messages.last().ok_or_else(|| {
|
|
||||||
SessionError::Format("message was just pushed but missing".to_string())
|
|
||||||
})?;
|
|
||||||
self.append_persisted_message(message_ref)
|
|
||||||
};
|
|
||||||
if let Err(error) = persist_result {
|
|
||||||
self.messages.pop();
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_user_text(&mut self, text: impl Into<String>) -> Result<(), SessionError> {
|
pub fn push_user_text(&mut self, text: impl Into<String>) -> Result<(), SessionError> {
|
||||||
self.push_message(ConversationMessage::user_text(text))
|
self.push_message(&ConversationMessage::user_text(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_compaction(&mut self, summary: impl Into<String>, removed_message_count: usize) {
|
pub fn record_compaction(&mut self, summary: impl Into<String>, removed_message_count: usize) {
|
||||||
|
|
@ -222,7 +211,8 @@ impl Session {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json(&self) -> Result<JsonValue, SessionError> {
|
#[must_use]
|
||||||
|
pub fn to_json(&self) -> JsonValue {
|
||||||
let mut object = BTreeMap::new();
|
let mut object = BTreeMap::new();
|
||||||
object.insert(
|
object.insert(
|
||||||
"version".to_string(),
|
"version".to_string(),
|
||||||
|
|
@ -234,11 +224,11 @@ impl Session {
|
||||||
);
|
);
|
||||||
object.insert(
|
object.insert(
|
||||||
"created_at_ms".to_string(),
|
"created_at_ms".to_string(),
|
||||||
JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")?),
|
JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")),
|
||||||
);
|
);
|
||||||
object.insert(
|
object.insert(
|
||||||
"updated_at_ms".to_string(),
|
"updated_at_ms".to_string(),
|
||||||
JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")?),
|
JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")),
|
||||||
);
|
);
|
||||||
object.insert(
|
object.insert(
|
||||||
"messages".to_string(),
|
"messages".to_string(),
|
||||||
|
|
@ -250,12 +240,12 @@ impl Session {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if let Some(compaction) = &self.compaction {
|
if let Some(compaction) = &self.compaction {
|
||||||
object.insert("compaction".to_string(), compaction.to_json()?);
|
object.insert("compaction".to_string(), compaction.to_json());
|
||||||
}
|
}
|
||||||
if let Some(fork) = &self.fork {
|
if let Some(fork) = &self.fork {
|
||||||
object.insert("fork".to_string(), fork.to_json());
|
object.insert("fork".to_string(), fork.to_json());
|
||||||
}
|
}
|
||||||
Ok(JsonValue::Object(object))
|
JsonValue::Object(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
|
pub fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
|
||||||
|
|
@ -386,10 +376,10 @@ impl Session {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_jsonl_snapshot(&self) -> Result<String, SessionError> {
|
fn render_jsonl_snapshot(&self) -> String {
|
||||||
let mut lines = vec![self.meta_record()?.render()];
|
let mut lines = vec![self.meta_record().render()];
|
||||||
if let Some(compaction) = &self.compaction {
|
if let Some(compaction) = &self.compaction {
|
||||||
lines.push(compaction.to_jsonl_record()?.render());
|
lines.push(compaction.to_jsonl_record().render());
|
||||||
}
|
}
|
||||||
lines.extend(
|
lines.extend(
|
||||||
self.messages
|
self.messages
|
||||||
|
|
@ -398,7 +388,7 @@ impl Session {
|
||||||
);
|
);
|
||||||
let mut rendered = lines.join("\n");
|
let mut rendered = lines.join("\n");
|
||||||
rendered.push('\n');
|
rendered.push('\n');
|
||||||
Ok(rendered)
|
rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_persisted_message(&self, message: &ConversationMessage) -> Result<(), SessionError> {
|
fn append_persisted_message(&self, message: &ConversationMessage) -> Result<(), SessionError> {
|
||||||
|
|
@ -417,7 +407,7 @@ impl Session {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn meta_record(&self) -> Result<JsonValue, SessionError> {
|
fn meta_record(&self) -> JsonValue {
|
||||||
let mut object = BTreeMap::new();
|
let mut object = BTreeMap::new();
|
||||||
object.insert(
|
object.insert(
|
||||||
"type".to_string(),
|
"type".to_string(),
|
||||||
|
|
@ -433,16 +423,16 @@ impl Session {
|
||||||
);
|
);
|
||||||
object.insert(
|
object.insert(
|
||||||
"created_at_ms".to_string(),
|
"created_at_ms".to_string(),
|
||||||
JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")?),
|
JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")),
|
||||||
);
|
);
|
||||||
object.insert(
|
object.insert(
|
||||||
"updated_at_ms".to_string(),
|
"updated_at_ms".to_string(),
|
||||||
JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")?),
|
JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")),
|
||||||
);
|
);
|
||||||
if let Some(fork) = &self.fork {
|
if let Some(fork) = &self.fork {
|
||||||
object.insert("fork".to_string(), fork.to_json());
|
object.insert("fork".to_string(), fork.to_json());
|
||||||
}
|
}
|
||||||
Ok(JsonValue::Object(object))
|
JsonValue::Object(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn touch(&mut self) {
|
fn touch(&mut self) {
|
||||||
|
|
@ -640,7 +630,8 @@ impl ContentBlock {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionCompaction {
|
impl SessionCompaction {
|
||||||
pub fn to_json(&self) -> Result<JsonValue, SessionError> {
|
#[must_use]
|
||||||
|
pub fn to_json(&self) -> JsonValue {
|
||||||
let mut object = BTreeMap::new();
|
let mut object = BTreeMap::new();
|
||||||
object.insert(
|
object.insert(
|
||||||
"count".to_string(),
|
"count".to_string(),
|
||||||
|
|
@ -651,37 +642,27 @@ impl SessionCompaction {
|
||||||
JsonValue::Number(i64_from_usize(
|
JsonValue::Number(i64_from_usize(
|
||||||
self.removed_message_count,
|
self.removed_message_count,
|
||||||
"removed_message_count",
|
"removed_message_count",
|
||||||
)?),
|
)),
|
||||||
);
|
);
|
||||||
object.insert(
|
object.insert(
|
||||||
"summary".to_string(),
|
"summary".to_string(),
|
||||||
JsonValue::String(self.summary.clone()),
|
JsonValue::String(self.summary.clone()),
|
||||||
);
|
);
|
||||||
Ok(JsonValue::Object(object))
|
JsonValue::Object(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_jsonl_record(&self) -> Result<JsonValue, SessionError> {
|
#[must_use]
|
||||||
let mut object = BTreeMap::new();
|
pub fn to_jsonl_record(&self) -> JsonValue {
|
||||||
|
let mut object = self
|
||||||
|
.to_json()
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.expect("compaction should render to object");
|
||||||
object.insert(
|
object.insert(
|
||||||
"type".to_string(),
|
"type".to_string(),
|
||||||
JsonValue::String("compaction".to_string()),
|
JsonValue::String("compaction".to_string()),
|
||||||
);
|
);
|
||||||
object.insert(
|
JsonValue::Object(object)
|
||||||
"count".to_string(),
|
|
||||||
JsonValue::Number(i64::from(self.count)),
|
|
||||||
);
|
|
||||||
object.insert(
|
|
||||||
"removed_message_count".to_string(),
|
|
||||||
JsonValue::Number(i64_from_usize(
|
|
||||||
self.removed_message_count,
|
|
||||||
"removed_message_count",
|
|
||||||
)?),
|
|
||||||
);
|
|
||||||
object.insert(
|
|
||||||
"summary".to_string(),
|
|
||||||
JsonValue::String(self.summary.clone()),
|
|
||||||
);
|
|
||||||
Ok(JsonValue::Object(object))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
|
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
|
||||||
|
|
@ -808,14 +789,12 @@ fn required_usize(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<usi
|
||||||
usize::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range")))
|
usize::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn i64_from_u64(value: u64, key: &str) -> Result<i64, SessionError> {
|
fn i64_from_u64(value: u64, key: &str) -> i64 {
|
||||||
i64::try_from(value)
|
i64::try_from(value).unwrap_or_else(|_| panic!("{key} out of range for JSON number"))
|
||||||
.map_err(|_| SessionError::Format(format!("{key} out of range for JSON number")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn i64_from_usize(value: usize, key: &str) -> Result<i64, SessionError> {
|
fn i64_from_usize(value: usize, key: &str) -> i64 {
|
||||||
i64::try_from(value)
|
i64::try_from(value).unwrap_or_else(|_| panic!("{key} out of range for JSON number"))
|
||||||
.map_err(|_| SessionError::Format(format!("{key} out of range for JSON number")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||||
|
|
@ -836,6 +815,12 @@ fn current_time_millis() -> u64 {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_jsonl_extension(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("jsonl"))
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_session_id() -> String {
|
fn generate_session_id() -> String {
|
||||||
let millis = current_time_millis();
|
let millis = current_time_millis();
|
||||||
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
|
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
@ -900,12 +885,8 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
|
||||||
entry_path
|
entry_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.is_some_and(|name| {
|
.is_some_and(|name| name.starts_with(&prefix))
|
||||||
name.starts_with(&prefix)
|
&& has_jsonl_extension(entry_path)
|
||||||
&& Path::new(name)
|
|
||||||
.extension()
|
|
||||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
@ -941,7 +922,7 @@ mod tests {
|
||||||
.push_user_text("hello")
|
.push_user_text("hello")
|
||||||
.expect("user message should append");
|
.expect("user message should append");
|
||||||
session
|
session
|
||||||
.push_message(ConversationMessage::assistant_with_usage(
|
.push_message(&ConversationMessage::assistant_with_usage(
|
||||||
vec![
|
vec![
|
||||||
ContentBlock::Text {
|
ContentBlock::Text {
|
||||||
text: "thinking".to_string(),
|
text: "thinking".to_string(),
|
||||||
|
|
@ -961,7 +942,7 @@ mod tests {
|
||||||
))
|
))
|
||||||
.expect("assistant message should append");
|
.expect("assistant message should append");
|
||||||
session
|
session
|
||||||
.push_message(ConversationMessage::tool_result(
|
.push_message(&ConversationMessage::tool_result(
|
||||||
"tool-1", "bash", "hi", false,
|
"tool-1", "bash", "hi", false,
|
||||||
))
|
))
|
||||||
.expect("tool result should append");
|
.expect("tool result should append");
|
||||||
|
|
@ -1018,7 +999,7 @@ mod tests {
|
||||||
.push_user_text("hi")
|
.push_user_text("hi")
|
||||||
.expect("user append should succeed");
|
.expect("user append should succeed");
|
||||||
session
|
session
|
||||||
.push_message(ConversationMessage::assistant(vec![ContentBlock::Text {
|
.push_message(&ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||||
text: "hello".to_string(),
|
text: "hello".to_string(),
|
||||||
}]))
|
}]))
|
||||||
.expect("assistant append should succeed");
|
.expect("assistant append should succeed");
|
||||||
|
|
@ -1080,16 +1061,16 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rotates_and_cleans_up_large_session_logs() {
|
fn rotates_and_cleans_up_large_session_logs() {
|
||||||
// given
|
|
||||||
let path = temp_session_path("rotation");
|
let path = temp_session_path("rotation");
|
||||||
let oversized_length =
|
fs::write(
|
||||||
usize::try_from(super::ROTATE_AFTER_BYTES + 10).expect("rotate threshold should fit");
|
&path,
|
||||||
fs::write(&path, "x".repeat(oversized_length)).expect("oversized file should write");
|
"x".repeat(
|
||||||
|
usize::try_from(super::ROTATE_AFTER_BYTES + 10)
|
||||||
// when
|
.expect("rotation threshold should fit usize"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("oversized file should write");
|
||||||
rotate_session_file_if_needed(&path).expect("rotation should succeed");
|
rotate_session_file_if_needed(&path).expect("rotation should succeed");
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
assert!(
|
||||||
!path.exists(),
|
!path.exists(),
|
||||||
"original path should be rotated away before rewrite"
|
"original path should be rotated away before rewrite"
|
||||||
|
|
@ -1108,97 +1089,6 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_jsonl_record_without_type() {
|
|
||||||
// given
|
|
||||||
let path = write_temp_session_file(
|
|
||||||
"missing-type",
|
|
||||||
r#"{"message":{"role":"user","blocks":[{"type":"text","text":"hello"}]}}"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = Session::load_from_path(&path)
|
|
||||||
.expect_err("session should reject JSONL records without a type");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("missing type"));
|
|
||||||
fs::remove_file(path).expect("temp file should be removable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_jsonl_message_record_without_message_payload() {
|
|
||||||
// given
|
|
||||||
let path = write_temp_session_file("missing-message", r#"{"type":"message"}"#);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = Session::load_from_path(&path)
|
|
||||||
.expect_err("session should reject JSONL message records without message payload");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("missing message"));
|
|
||||||
fs::remove_file(path).expect("temp file should be removable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_jsonl_record_with_unknown_type() {
|
|
||||||
// given
|
|
||||||
let path = write_temp_session_file("unknown-type", r#"{"type":"mystery"}"#);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = Session::load_from_path(&path)
|
|
||||||
.expect_err("session should reject unknown JSONL record types");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("unsupported JSONL record type"));
|
|
||||||
fs::remove_file(path).expect("temp file should be removable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_legacy_session_json_without_messages() {
|
|
||||||
// given
|
|
||||||
let session = JsonValue::Object(
|
|
||||||
[("version".to_string(), JsonValue::Number(1))]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = Session::from_json(&session)
|
|
||||||
.expect_err("legacy session objects should require messages");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("missing messages"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalizes_blank_fork_branch_name_to_none() {
|
|
||||||
// given
|
|
||||||
let session = Session::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let forked = session.fork(Some(" ".to_string()));
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(forked.fork.expect("fork metadata").branch_name, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_unknown_content_block_type() {
|
|
||||||
// given
|
|
||||||
let block = JsonValue::Object(
|
|
||||||
[("type".to_string(), JsonValue::String("unknown".to_string()))]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = ContentBlock::from_json(&block)
|
|
||||||
.expect_err("content blocks should reject unknown types");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("unsupported block type"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn temp_session_path(label: &str) -> PathBuf {
|
fn temp_session_path(label: &str) -> PathBuf {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|
@ -1207,12 +1097,6 @@ mod tests {
|
||||||
std::env::temp_dir().join(format!("runtime-session-{label}-{nanos}.json"))
|
std::env::temp_dir().join(format!("runtime-session-{label}-{nanos}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_temp_session_file(label: &str, contents: &str) -> PathBuf {
|
|
||||||
let path = temp_session_path(label);
|
|
||||||
fs::write(&path, format!("{contents}\n")).expect("temp session file should write");
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rotation_files(path: &Path) -> Vec<PathBuf> {
|
fn rotation_files(path: &Path) -> Vec<PathBuf> {
|
||||||
let stem = path
|
let stem = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
|
|
@ -1227,12 +1111,8 @@ mod tests {
|
||||||
entry_path
|
entry_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.is_some_and(|name| {
|
.is_some_and(|name| name.starts_with(&format!("{stem}.rot-")))
|
||||||
name.starts_with(&format!("{stem}.rot-"))
|
&& super::has_jsonl_extension(entry_path)
|
||||||
&& Path::new(name)
|
|
||||||
.extension()
|
|
||||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,461 +0,0 @@
|
||||||
use std::env;
|
|
||||||
use std::fmt::{Display, Formatter};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::UNIX_EPOCH;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::session::{Session, SessionError};
|
|
||||||
use crate::worker_boot::{Worker, WorkerReadySnapshot, WorkerRegistry, WorkerStatus};
|
|
||||||
|
|
||||||
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
|
||||||
pub const LEGACY_SESSION_EXTENSION: &str = "json";
|
|
||||||
pub const LATEST_SESSION_REFERENCE: &str = "latest";
|
|
||||||
|
|
||||||
const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"];
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SessionHandle {
|
|
||||||
pub id: String,
|
|
||||||
pub path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ManagedSessionSummary {
|
|
||||||
pub id: String,
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub modified_epoch_millis: u128,
|
|
||||||
pub message_count: usize,
|
|
||||||
pub parent_session_id: Option<String>,
|
|
||||||
pub branch_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct LoadedManagedSession {
|
|
||||||
pub handle: SessionHandle,
|
|
||||||
pub session: Session,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ForkedManagedSession {
|
|
||||||
pub parent_session_id: String,
|
|
||||||
pub handle: SessionHandle,
|
|
||||||
pub session: Session,
|
|
||||||
pub branch_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum SessionControlError {
|
|
||||||
Io(std::io::Error),
|
|
||||||
Session(SessionError),
|
|
||||||
Format(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for SessionControlError {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Io(error) => write!(f, "{error}"),
|
|
||||||
Self::Session(error) => write!(f, "{error}"),
|
|
||||||
Self::Format(error) => write!(f, "{error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for SessionControlError {}
|
|
||||||
|
|
||||||
impl From<std::io::Error> for SessionControlError {
|
|
||||||
fn from(value: std::io::Error) -> Self {
|
|
||||||
Self::Io(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SessionError> for SessionControlError {
|
|
||||||
fn from(value: SessionError) -> Self {
|
|
||||||
Self::Session(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sessions_dir() -> Result<PathBuf, SessionControlError> {
|
|
||||||
managed_sessions_dir_for(env::current_dir()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn managed_sessions_dir_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
) -> Result<PathBuf, SessionControlError> {
|
|
||||||
let path = base_dir.as_ref().join(".claw").join("sessions");
|
|
||||||
fs::create_dir_all(&path)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_managed_session_handle(
|
|
||||||
session_id: &str,
|
|
||||||
) -> Result<SessionHandle, SessionControlError> {
|
|
||||||
create_managed_session_handle_for(env::current_dir()?, session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_managed_session_handle_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
session_id: &str,
|
|
||||||
) -> Result<SessionHandle, SessionControlError> {
|
|
||||||
let id = session_id.to_string();
|
|
||||||
let path =
|
|
||||||
managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
|
|
||||||
Ok(SessionHandle { id, path })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
|
|
||||||
resolve_session_reference_for(env::current_dir()?, reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_session_reference_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
reference: &str,
|
|
||||||
) -> Result<SessionHandle, SessionControlError> {
|
|
||||||
let base_dir = base_dir.as_ref();
|
|
||||||
if is_session_reference_alias(reference) {
|
|
||||||
let latest = latest_managed_session_for(base_dir)?;
|
|
||||||
return Ok(SessionHandle {
|
|
||||||
id: latest.id,
|
|
||||||
path: latest.path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let direct = PathBuf::from(reference);
|
|
||||||
let candidate = if direct.is_absolute() {
|
|
||||||
direct.clone()
|
|
||||||
} else {
|
|
||||||
base_dir.join(&direct)
|
|
||||||
};
|
|
||||||
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
|
|
||||||
let path = if candidate.exists() {
|
|
||||||
candidate
|
|
||||||
} else if looks_like_path {
|
|
||||||
return Err(SessionControlError::Format(
|
|
||||||
format_missing_session_reference(reference),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
resolve_managed_session_path_for(base_dir, reference)?
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(SessionHandle {
|
|
||||||
id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()),
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
|
|
||||||
resolve_managed_session_path_for(env::current_dir()?, session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_managed_session_path_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
session_id: &str,
|
|
||||||
) -> Result<PathBuf, SessionControlError> {
|
|
||||||
let directory = managed_sessions_dir_for(base_dir)?;
|
|
||||||
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
|
||||||
let path = directory.join(format!("{session_id}.{extension}"));
|
|
||||||
if path.exists() {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(SessionControlError::Format(
|
|
||||||
format_missing_session_reference(session_id),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_managed_session_file(path: &Path) -> bool {
|
|
||||||
path.extension()
|
|
||||||
.and_then(|ext| ext.to_str())
|
|
||||||
.is_some_and(|extension| {
|
|
||||||
extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
|
||||||
list_managed_sessions_for(env::current_dir()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_managed_sessions_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
|
||||||
let mut sessions = Vec::new();
|
|
||||||
for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if !is_managed_session_file(&path) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let metadata = entry.metadata()?;
|
|
||||||
let modified_epoch_millis = metadata
|
|
||||||
.modified()
|
|
||||||
.ok()
|
|
||||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
||||||
.map(|duration| duration.as_millis())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let (id, message_count, parent_session_id, branch_name) =
|
|
||||||
match Session::load_from_path(&path) {
|
|
||||||
Ok(session) => {
|
|
||||||
let parent_session_id = session
|
|
||||||
.fork
|
|
||||||
.as_ref()
|
|
||||||
.map(|fork| fork.parent_session_id.clone());
|
|
||||||
let branch_name = session
|
|
||||||
.fork
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|fork| fork.branch_name.clone());
|
|
||||||
(
|
|
||||||
session.session_id,
|
|
||||||
session.messages.len(),
|
|
||||||
parent_session_id,
|
|
||||||
branch_name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(_) => (
|
|
||||||
path.file_stem()
|
|
||||||
.and_then(|value| value.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string(),
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
sessions.push(ManagedSessionSummary {
|
|
||||||
id,
|
|
||||||
path,
|
|
||||||
modified_epoch_millis,
|
|
||||||
message_count,
|
|
||||||
parent_session_id,
|
|
||||||
branch_name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sessions.sort_by(|left, right| {
|
|
||||||
right
|
|
||||||
.modified_epoch_millis
|
|
||||||
.cmp(&left.modified_epoch_millis)
|
|
||||||
.then_with(|| right.id.cmp(&left.id))
|
|
||||||
});
|
|
||||||
Ok(sessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
|
|
||||||
latest_managed_session_for(env::current_dir()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn latest_managed_session_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
) -> Result<ManagedSessionSummary, SessionControlError> {
|
|
||||||
list_managed_sessions_for(base_dir)?
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
|
|
||||||
load_managed_session_for(env::current_dir()?, reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_managed_session_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
reference: &str,
|
|
||||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
|
||||||
let handle = resolve_session_reference_for(base_dir, reference)?;
|
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
|
||||||
Ok(LoadedManagedSession {
|
|
||||||
handle: SessionHandle {
|
|
||||||
id: session.session_id.clone(),
|
|
||||||
path: handle.path,
|
|
||||||
},
|
|
||||||
session,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fork_managed_session(
|
|
||||||
session: &Session,
|
|
||||||
branch_name: Option<String>,
|
|
||||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
|
||||||
fork_managed_session_for(env::current_dir()?, session, branch_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fork_managed_session_for(
|
|
||||||
base_dir: impl AsRef<Path>,
|
|
||||||
session: &Session,
|
|
||||||
branch_name: Option<String>,
|
|
||||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
|
||||||
let parent_session_id = session.session_id.clone();
|
|
||||||
let forked = session.fork(branch_name);
|
|
||||||
let handle = create_managed_session_handle_for(base_dir, &forked.session_id)?;
|
|
||||||
let branch_name = forked
|
|
||||||
.fork
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|fork| fork.branch_name.clone());
|
|
||||||
let forked = forked.with_persistence_path(handle.path.clone());
|
|
||||||
forked.save_to_path(&handle.path)?;
|
|
||||||
Ok(ForkedManagedSession {
|
|
||||||
parent_session_id,
|
|
||||||
handle,
|
|
||||||
session: forked,
|
|
||||||
branch_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_session_reference_alias(reference: &str) -> bool {
|
|
||||||
SESSION_REFERENCE_ALIASES
|
|
||||||
.iter()
|
|
||||||
.any(|alias| reference.eq_ignore_ascii_case(alias))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_id_from_path(path: &Path) -> Option<String> {
|
|
||||||
path.file_name()
|
|
||||||
.and_then(|value| value.to_str())
|
|
||||||
.and_then(|name| {
|
|
||||||
name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}"))
|
|
||||||
.or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}")))
|
|
||||||
})
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_missing_session_reference(reference: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_no_managed_sessions() -> String {
|
|
||||||
format!(
|
|
||||||
"no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias,
|
|
||||||
list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for,
|
|
||||||
ManagedSessionSummary, LATEST_SESSION_REFERENCE,
|
|
||||||
};
|
|
||||||
use crate::session::Session;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_dir() -> PathBuf {
|
|
||||||
let nanos = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("time should be after epoch")
|
|
||||||
.as_nanos();
|
|
||||||
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_session(root: &Path, text: &str) -> Session {
|
|
||||||
let mut session = Session::new();
|
|
||||||
session
|
|
||||||
.push_user_text(text)
|
|
||||||
.expect("session message should save");
|
|
||||||
let handle = create_managed_session_handle_for(root, &session.session_id)
|
|
||||||
.expect("managed session handle should build");
|
|
||||||
let session = session.with_persistence_path(handle.path.clone());
|
|
||||||
session
|
|
||||||
.save_to_path(&handle.path)
|
|
||||||
.expect("session should persist");
|
|
||||||
session
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_for_next_millisecond() {
|
|
||||||
let start = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("time should be after epoch")
|
|
||||||
.as_millis();
|
|
||||||
while SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("time should be after epoch")
|
|
||||||
.as_millis()
|
|
||||||
<= start
|
|
||||||
{}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summary_by_id<'a>(
|
|
||||||
summaries: &'a [ManagedSessionSummary],
|
|
||||||
id: &str,
|
|
||||||
) -> &'a ManagedSessionSummary {
|
|
||||||
summaries
|
|
||||||
.iter()
|
|
||||||
.find(|summary| summary.id == id)
|
|
||||||
.expect("session summary should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn creates_and_lists_managed_sessions() {
|
|
||||||
// given
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir should exist");
|
|
||||||
let older = persist_session(&root, "older session");
|
|
||||||
wait_for_next_millisecond();
|
|
||||||
let newer = persist_session(&root, "newer session");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(sessions.len(), 2);
|
|
||||||
assert_eq!(sessions[0].id, newer.session_id);
|
|
||||||
assert_eq!(summary_by_id(&sessions, &older.session_id).message_count, 1);
|
|
||||||
assert_eq!(summary_by_id(&sessions, &newer.session_id).message_count, 1);
|
|
||||||
fs::remove_dir_all(root).expect("temp dir should clean up");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolves_latest_alias_and_loads_session_from_workspace_root() {
|
|
||||||
// given
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir should exist");
|
|
||||||
let older = persist_session(&root, "older session");
|
|
||||||
wait_for_next_millisecond();
|
|
||||||
let newer = persist_session(&root, "newer session");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let handle = resolve_session_reference_for(&root, LATEST_SESSION_REFERENCE)
|
|
||||||
.expect("latest alias should resolve");
|
|
||||||
let loaded = load_managed_session_for(&root, "recent")
|
|
||||||
.expect("recent alias should load the latest session");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(handle.id, newer.session_id);
|
|
||||||
assert_eq!(loaded.handle.id, newer.session_id);
|
|
||||||
assert_eq!(loaded.session.messages.len(), 1);
|
|
||||||
assert_ne!(loaded.handle.id, older.session_id);
|
|
||||||
assert!(is_session_reference_alias("last"));
|
|
||||||
fs::remove_dir_all(root).expect("temp dir should clean up");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn forks_session_into_managed_storage_with_lineage() {
|
|
||||||
// given
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir should exist");
|
|
||||||
let source = persist_session(&root, "parent session");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let forked = fork_managed_session_for(&root, &source, Some("incident-review".to_string()))
|
|
||||||
.expect("session should fork");
|
|
||||||
let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
|
|
||||||
let summary = summary_by_id(&sessions, &forked.handle.id);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(forked.parent_session_id, source.session_id);
|
|
||||||
assert_eq!(forked.branch_name.as_deref(), Some("incident-review"));
|
|
||||||
assert_eq!(
|
|
||||||
summary.parent_session_id.as_deref(),
|
|
||||||
Some(source.session_id.as_str())
|
|
||||||
);
|
|
||||||
assert_eq!(summary.branch_name.as_deref(), Some("incident-review"));
|
|
||||||
assert_eq!(
|
|
||||||
forked.session.persistence_path(),
|
|
||||||
Some(forked.handle.path.as_path())
|
|
||||||
);
|
|
||||||
fs::remove_dir_all(root).expect("temp dir should clean up");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -80,11 +80,7 @@ impl IncrementalSseParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn take_event(&mut self) -> Option<SseEvent> {
|
fn take_event(&mut self) -> Option<SseEvent> {
|
||||||
if self.data_lines.is_empty()
|
if self.data_lines.is_empty() && self.event_name.is_none() && self.id.is_none() && self.retry.is_none() {
|
||||||
&& self.event_name.is_none()
|
|
||||||
&& self.id.is_none()
|
|
||||||
&& self.retry.is_none()
|
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,13 +102,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_streaming_events() {
|
fn parses_streaming_events() {
|
||||||
// given
|
|
||||||
let mut parser = IncrementalSseParser::new();
|
let mut parser = IncrementalSseParser::new();
|
||||||
|
|
||||||
// when
|
|
||||||
let first = parser.push_chunk("event: message\ndata: hel");
|
let first = parser.push_chunk("event: message\ndata: hel");
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(first.is_empty());
|
assert!(first.is_empty());
|
||||||
|
|
||||||
let second = parser.push_chunk("lo\n\nid: 1\ndata: world\n\n");
|
let second = parser.push_chunk("lo\n\nid: 1\ndata: world\n\n");
|
||||||
|
|
@ -134,25 +125,4 @@ mod tests {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finish_flushes_a_trailing_event_without_separator() {
|
|
||||||
// given
|
|
||||||
let mut parser = IncrementalSseParser::new();
|
|
||||||
parser.push_chunk("event: message\ndata: trailing");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let events = parser.finish();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
events,
|
|
||||||
vec![SseEvent {
|
|
||||||
event: Some("message".to_string()),
|
|
||||||
data: "trailing".to_string(),
|
|
||||||
id: None,
|
|
||||||
retry: None,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,389 +0,0 @@
|
||||||
use std::path::Path;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum BranchFreshness {
|
|
||||||
Fresh,
|
|
||||||
Stale {
|
|
||||||
commits_behind: usize,
|
|
||||||
missing_fixes: Vec<String>,
|
|
||||||
},
|
|
||||||
Diverged {
|
|
||||||
ahead: usize,
|
|
||||||
behind: usize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum StaleBranchPolicy {
|
|
||||||
AutoRebase,
|
|
||||||
AutoMergeForward,
|
|
||||||
WarnOnly,
|
|
||||||
Block,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum StaleBranchEvent {
|
|
||||||
BranchStaleAgainstMain {
|
|
||||||
branch: String,
|
|
||||||
commits_behind: usize,
|
|
||||||
missing_fixes: Vec<String>,
|
|
||||||
},
|
|
||||||
RebaseAttempted {
|
|
||||||
branch: String,
|
|
||||||
result: String,
|
|
||||||
},
|
|
||||||
MergeForwardAttempted {
|
|
||||||
branch: String,
|
|
||||||
result: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum StaleBranchAction {
|
|
||||||
Noop,
|
|
||||||
Warn { message: String },
|
|
||||||
Block { message: String },
|
|
||||||
Rebase,
|
|
||||||
MergeForward,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_freshness(branch: &str, main_ref: &str) -> BranchFreshness {
|
|
||||||
check_freshness_in(branch, main_ref, Path::new("."))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_policy(freshness: &BranchFreshness, policy: StaleBranchPolicy) -> StaleBranchAction {
|
|
||||||
match freshness {
|
|
||||||
BranchFreshness::Fresh => StaleBranchAction::Noop,
|
|
||||||
BranchFreshness::Stale {
|
|
||||||
commits_behind,
|
|
||||||
missing_fixes,
|
|
||||||
} => match policy {
|
|
||||||
StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
|
|
||||||
message: format!(
|
|
||||||
"Branch is {commits_behind} commit(s) behind main. Missing fixes: {}",
|
|
||||||
if missing_fixes.is_empty() {
|
|
||||||
"(none)".to_string()
|
|
||||||
} else {
|
|
||||||
missing_fixes.join("; ")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
},
|
|
||||||
StaleBranchPolicy::Block => StaleBranchAction::Block {
|
|
||||||
message: format!(
|
|
||||||
"Branch is {commits_behind} commit(s) behind main and must be updated before proceeding."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
|
|
||||||
StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
|
|
||||||
},
|
|
||||||
BranchFreshness::Diverged { ahead, behind } => match policy {
|
|
||||||
StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
|
|
||||||
message: format!(
|
|
||||||
"Branch has diverged: {ahead} commit(s) ahead, {behind} commit(s) behind main."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
StaleBranchPolicy::Block => StaleBranchAction::Block {
|
|
||||||
message: format!(
|
|
||||||
"Branch has diverged ({ahead} ahead, {behind} behind) and must be reconciled before proceeding."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
|
|
||||||
StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn check_freshness_in(
|
|
||||||
branch: &str,
|
|
||||||
main_ref: &str,
|
|
||||||
repo_path: &Path,
|
|
||||||
) -> BranchFreshness {
|
|
||||||
let behind = rev_list_count(main_ref, branch, repo_path);
|
|
||||||
let ahead = rev_list_count(branch, main_ref, repo_path);
|
|
||||||
|
|
||||||
if behind == 0 {
|
|
||||||
return BranchFreshness::Fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ahead > 0 {
|
|
||||||
return BranchFreshness::Diverged { ahead, behind };
|
|
||||||
}
|
|
||||||
|
|
||||||
let missing_fixes = missing_fix_subjects(main_ref, branch, repo_path);
|
|
||||||
BranchFreshness::Stale {
|
|
||||||
commits_behind: behind,
|
|
||||||
missing_fixes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rev_list_count(a: &str, b: &str, repo_path: &Path) -> usize {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.args(["rev-list", "--count", &format!("{b}..{a}")])
|
|
||||||
.current_dir(repo_path)
|
|
||||||
.output();
|
|
||||||
match output {
|
|
||||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
|
||||||
.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.unwrap_or(0),
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn missing_fix_subjects(a: &str, b: &str, repo_path: &Path) -> Vec<String> {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.args(["log", "--format=%s", &format!("{b}..{a}")])
|
|
||||||
.current_dir(repo_path)
|
|
||||||
.output();
|
|
||||||
match output {
|
|
||||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
|
||||||
.lines()
|
|
||||||
.filter(|l| !l.is_empty())
|
|
||||||
.map(String::from)
|
|
||||||
.collect(),
|
|
||||||
_ => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::fs;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_dir() -> std::path::PathBuf {
|
|
||||||
let nanos = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("time should be after epoch")
|
|
||||||
.as_nanos();
|
|
||||||
std::env::temp_dir().join(format!("runtime-stale-branch-{nanos}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_repo(path: &Path) {
|
|
||||||
fs::create_dir_all(path).expect("create repo dir");
|
|
||||||
run(path, &["init", "--quiet", "-b", "main"]);
|
|
||||||
run(path, &["config", "user.email", "tests@example.com"]);
|
|
||||||
run(path, &["config", "user.name", "Stale Branch Tests"]);
|
|
||||||
fs::write(path.join("init.txt"), "initial\n").expect("write init file");
|
|
||||||
run(path, &["add", "."]);
|
|
||||||
run(path, &["commit", "-m", "initial commit", "--quiet"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(cwd: &Path, args: &[&str]) {
|
|
||||||
let status = Command::new("git")
|
|
||||||
.args(args)
|
|
||||||
.current_dir(cwd)
|
|
||||||
.status()
|
|
||||||
.unwrap_or_else(|e| panic!("git {} failed to execute: {e}", args.join(" ")));
|
|
||||||
assert!(
|
|
||||||
status.success(),
|
|
||||||
"git {} exited with {status}",
|
|
||||||
args.join(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commit_file(repo: &Path, name: &str, msg: &str) {
|
|
||||||
fs::write(repo.join(name), format!("{msg}\n")).expect("write file");
|
|
||||||
run(repo, &["add", name]);
|
|
||||||
run(repo, &["commit", "-m", msg, "--quiet"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fresh_branch_passes() {
|
|
||||||
let root = temp_dir();
|
|
||||||
init_repo(&root);
|
|
||||||
|
|
||||||
// given
|
|
||||||
run(&root, &["checkout", "-b", "topic"]);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let freshness = check_freshness_in("topic", "main", &root);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(freshness, BranchFreshness::Fresh);
|
|
||||||
|
|
||||||
fs::remove_dir_all(&root).expect("cleanup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fresh_branch_ahead_of_main_still_fresh() {
|
|
||||||
let root = temp_dir();
|
|
||||||
init_repo(&root);
|
|
||||||
|
|
||||||
// given
|
|
||||||
run(&root, &["checkout", "-b", "topic"]);
|
|
||||||
commit_file(&root, "feature.txt", "add feature");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let freshness = check_freshness_in("topic", "main", &root);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(freshness, BranchFreshness::Fresh);
|
|
||||||
|
|
||||||
fs::remove_dir_all(&root).expect("cleanup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_branch_detected_with_correct_behind_count_and_missing_fixes() {
|
|
||||||
let root = temp_dir();
|
|
||||||
init_repo(&root);
|
|
||||||
|
|
||||||
// given
|
|
||||||
run(&root, &["checkout", "-b", "topic"]);
|
|
||||||
run(&root, &["checkout", "main"]);
|
|
||||||
commit_file(&root, "fix1.txt", "fix: resolve timeout");
|
|
||||||
commit_file(&root, "fix2.txt", "fix: handle null pointer");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let freshness = check_freshness_in("topic", "main", &root);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match freshness {
|
|
||||||
BranchFreshness::Stale {
|
|
||||||
commits_behind,
|
|
||||||
missing_fixes,
|
|
||||||
} => {
|
|
||||||
assert_eq!(commits_behind, 2);
|
|
||||||
assert_eq!(missing_fixes.len(), 2);
|
|
||||||
assert_eq!(missing_fixes[0], "fix: handle null pointer");
|
|
||||||
assert_eq!(missing_fixes[1], "fix: resolve timeout");
|
|
||||||
}
|
|
||||||
other => panic!("expected Stale, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::remove_dir_all(&root).expect("cleanup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn diverged_branch_detection() {
|
|
||||||
let root = temp_dir();
|
|
||||||
init_repo(&root);
|
|
||||||
|
|
||||||
// given
|
|
||||||
run(&root, &["checkout", "-b", "topic"]);
|
|
||||||
commit_file(&root, "topic_work.txt", "topic work");
|
|
||||||
run(&root, &["checkout", "main"]);
|
|
||||||
commit_file(&root, "main_fix.txt", "main fix");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let freshness = check_freshness_in("topic", "main", &root);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match freshness {
|
|
||||||
BranchFreshness::Diverged { ahead, behind } => {
|
|
||||||
assert_eq!(ahead, 1);
|
|
||||||
assert_eq!(behind, 1);
|
|
||||||
}
|
|
||||||
other => panic!("expected Diverged, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::remove_dir_all(&root).expect("cleanup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_noop_for_fresh_branch() {
|
|
||||||
// given
|
|
||||||
let freshness = BranchFreshness::Fresh;
|
|
||||||
|
|
||||||
// when
|
|
||||||
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(action, StaleBranchAction::Noop);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_warn_for_stale_branch() {
|
|
||||||
// given
|
|
||||||
let freshness = BranchFreshness::Stale {
|
|
||||||
commits_behind: 3,
|
|
||||||
missing_fixes: vec!["fix: timeout".into(), "fix: null ptr".into()],
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match action {
|
|
||||||
StaleBranchAction::Warn { message } => {
|
|
||||||
assert!(message.contains("3 commit(s) behind"));
|
|
||||||
assert!(message.contains("fix: timeout"));
|
|
||||||
assert!(message.contains("fix: null ptr"));
|
|
||||||
}
|
|
||||||
other => panic!("expected Warn, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_block_for_stale_branch() {
|
|
||||||
// given
|
|
||||||
let freshness = BranchFreshness::Stale {
|
|
||||||
commits_behind: 1,
|
|
||||||
missing_fixes: vec!["hotfix".into()],
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let action = apply_policy(&freshness, StaleBranchPolicy::Block);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match action {
|
|
||||||
StaleBranchAction::Block { message } => {
|
|
||||||
assert!(message.contains("1 commit(s) behind"));
|
|
||||||
}
|
|
||||||
other => panic!("expected Block, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_auto_rebase_for_stale_branch() {
|
|
||||||
// given
|
|
||||||
let freshness = BranchFreshness::Stale {
|
|
||||||
commits_behind: 2,
|
|
||||||
missing_fixes: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let action = apply_policy(&freshness, StaleBranchPolicy::AutoRebase);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(action, StaleBranchAction::Rebase);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_auto_merge_forward_for_diverged_branch() {
|
|
||||||
// given
|
|
||||||
let freshness = BranchFreshness::Diverged {
|
|
||||||
ahead: 5,
|
|
||||||
behind: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let action = apply_policy(&freshness, StaleBranchPolicy::AutoMergeForward);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(action, StaleBranchAction::MergeForward);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn policy_warn_for_diverged_branch() {
|
|
||||||
// given
|
|
||||||
let freshness = BranchFreshness::Diverged {
|
|
||||||
ahead: 3,
|
|
||||||
behind: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
|
|
||||||
|
|
||||||
// then
|
|
||||||
match action {
|
|
||||||
StaleBranchAction::Warn { message } => {
|
|
||||||
assert!(message.contains("diverged"));
|
|
||||||
assert!(message.contains("3 commit(s) ahead"));
|
|
||||||
assert!(message.contains("1 commit(s) behind"));
|
|
||||||
}
|
|
||||||
other => panic!("expected Warn, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
const DEFAULT_MAX_CHARS: usize = 1_200;
|
|
||||||
const DEFAULT_MAX_LINES: usize = 24;
|
|
||||||
const DEFAULT_MAX_LINE_CHARS: usize = 160;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct SummaryCompressionBudget {
|
|
||||||
pub max_chars: usize,
|
|
||||||
pub max_lines: usize,
|
|
||||||
pub max_line_chars: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SummaryCompressionBudget {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_chars: DEFAULT_MAX_CHARS,
|
|
||||||
max_lines: DEFAULT_MAX_LINES,
|
|
||||||
max_line_chars: DEFAULT_MAX_LINE_CHARS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SummaryCompressionResult {
|
|
||||||
pub summary: String,
|
|
||||||
pub original_chars: usize,
|
|
||||||
pub compressed_chars: usize,
|
|
||||||
pub original_lines: usize,
|
|
||||||
pub compressed_lines: usize,
|
|
||||||
pub removed_duplicate_lines: usize,
|
|
||||||
pub omitted_lines: usize,
|
|
||||||
pub truncated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn compress_summary(
|
|
||||||
summary: &str,
|
|
||||||
budget: SummaryCompressionBudget,
|
|
||||||
) -> SummaryCompressionResult {
|
|
||||||
let original_chars = summary.chars().count();
|
|
||||||
let original_lines = summary.lines().count();
|
|
||||||
|
|
||||||
let normalized = normalize_lines(summary, budget.max_line_chars);
|
|
||||||
if normalized.lines.is_empty() || budget.max_chars == 0 || budget.max_lines == 0 {
|
|
||||||
return SummaryCompressionResult {
|
|
||||||
summary: String::new(),
|
|
||||||
original_chars,
|
|
||||||
compressed_chars: 0,
|
|
||||||
original_lines,
|
|
||||||
compressed_lines: 0,
|
|
||||||
removed_duplicate_lines: normalized.removed_duplicate_lines,
|
|
||||||
omitted_lines: normalized.lines.len(),
|
|
||||||
truncated: original_chars > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = select_line_indexes(&normalized.lines, budget);
|
|
||||||
let mut compressed_lines = selected
|
|
||||||
.iter()
|
|
||||||
.map(|index| normalized.lines[*index].clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if compressed_lines.is_empty() {
|
|
||||||
compressed_lines.push(truncate_line(&normalized.lines[0], budget.max_chars));
|
|
||||||
}
|
|
||||||
let omitted_lines = normalized
|
|
||||||
.lines
|
|
||||||
.len()
|
|
||||||
.saturating_sub(compressed_lines.len());
|
|
||||||
|
|
||||||
if omitted_lines > 0 {
|
|
||||||
let omission_notice = omission_notice(omitted_lines);
|
|
||||||
push_line_with_budget(&mut compressed_lines, omission_notice, budget);
|
|
||||||
}
|
|
||||||
|
|
||||||
let compressed_summary = compressed_lines.join("\n");
|
|
||||||
|
|
||||||
SummaryCompressionResult {
|
|
||||||
compressed_chars: compressed_summary.chars().count(),
|
|
||||||
compressed_lines: compressed_lines.len(),
|
|
||||||
removed_duplicate_lines: normalized.removed_duplicate_lines,
|
|
||||||
omitted_lines,
|
|
||||||
truncated: compressed_summary != summary.trim(),
|
|
||||||
summary: compressed_summary,
|
|
||||||
original_chars,
|
|
||||||
original_lines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn compress_summary_text(summary: &str) -> String {
|
|
||||||
compress_summary(summary, SummaryCompressionBudget::default()).summary
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct NormalizedSummary {
|
|
||||||
lines: Vec<String>,
|
|
||||||
removed_duplicate_lines: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_lines(summary: &str, max_line_chars: usize) -> NormalizedSummary {
|
|
||||||
let mut seen = BTreeSet::new();
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
let mut removed_duplicate_lines = 0;
|
|
||||||
|
|
||||||
for raw_line in summary.lines() {
|
|
||||||
let normalized = collapse_inline_whitespace(raw_line);
|
|
||||||
if normalized.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let truncated = truncate_line(&normalized, max_line_chars);
|
|
||||||
let dedupe_key = dedupe_key(&truncated);
|
|
||||||
if !seen.insert(dedupe_key) {
|
|
||||||
removed_duplicate_lines += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(truncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
NormalizedSummary {
|
|
||||||
lines,
|
|
||||||
removed_duplicate_lines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_line_indexes(lines: &[String], budget: SummaryCompressionBudget) -> Vec<usize> {
|
|
||||||
let mut selected = BTreeSet::<usize>::new();
|
|
||||||
|
|
||||||
for priority in 0..=3 {
|
|
||||||
for (index, line) in lines.iter().enumerate() {
|
|
||||||
if selected.contains(&index) || line_priority(line) != priority {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidate = selected
|
|
||||||
.iter()
|
|
||||||
.map(|selected_index| lines[*selected_index].as_str())
|
|
||||||
.chain(std::iter::once(line.as_str()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
if candidate.len() > budget.max_lines {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if joined_char_count(&candidate) > budget.max_chars {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
selected.insert(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selected.into_iter().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_line_with_budget(lines: &mut Vec<String>, line: String, budget: SummaryCompressionBudget) {
|
|
||||||
let candidate = lines
|
|
||||||
.iter()
|
|
||||||
.map(String::as_str)
|
|
||||||
.chain(std::iter::once(line.as_str()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
if candidate.len() <= budget.max_lines && joined_char_count(&candidate) <= budget.max_chars {
|
|
||||||
lines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn joined_char_count(lines: &[&str]) -> usize {
|
|
||||||
lines.iter().map(|line| line.chars().count()).sum::<usize>() + lines.len().saturating_sub(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn line_priority(line: &str) -> usize {
|
|
||||||
if line == "Summary:" || line == "Conversation summary:" || is_core_detail(line) {
|
|
||||||
0
|
|
||||||
} else if is_section_header(line) {
|
|
||||||
1
|
|
||||||
} else if line.starts_with("- ") || line.starts_with(" - ") {
|
|
||||||
2
|
|
||||||
} else {
|
|
||||||
3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_core_detail(line: &str) -> bool {
|
|
||||||
[
|
|
||||||
"- Scope:",
|
|
||||||
"- Current work:",
|
|
||||||
"- Pending work:",
|
|
||||||
"- Key files referenced:",
|
|
||||||
"- Tools mentioned:",
|
|
||||||
"- Recent user requests:",
|
|
||||||
"- Previously compacted context:",
|
|
||||||
"- Newly compacted context:",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|prefix| line.starts_with(prefix))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_section_header(line: &str) -> bool {
|
|
||||||
line.ends_with(':')
|
|
||||||
}
|
|
||||||
|
|
||||||
fn omission_notice(omitted_lines: usize) -> String {
|
|
||||||
format!("- … {omitted_lines} additional line(s) omitted.")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collapse_inline_whitespace(line: &str) -> String {
|
|
||||||
line.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_line(line: &str, max_chars: usize) -> String {
|
|
||||||
if max_chars == 0 || line.chars().count() <= max_chars {
|
|
||||||
return line.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if max_chars == 1 {
|
|
||||||
return "…".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut truncated = line
|
|
||||||
.chars()
|
|
||||||
.take(max_chars.saturating_sub(1))
|
|
||||||
.collect::<String>();
|
|
||||||
truncated.push('…');
|
|
||||||
truncated
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dedupe_key(line: &str) -> String {
|
|
||||||
line.to_ascii_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{compress_summary, compress_summary_text, SummaryCompressionBudget};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn collapses_whitespace_and_duplicate_lines() {
|
|
||||||
// given
|
|
||||||
let summary = "Conversation summary:\n\n- Scope: compact earlier messages.\n- Scope: compact earlier messages.\n- Current work: update runtime module.\n";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = compress_summary(summary, SummaryCompressionBudget::default());
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(result.removed_duplicate_lines, 1);
|
|
||||||
assert!(result
|
|
||||||
.summary
|
|
||||||
.contains("- Scope: compact earlier messages."));
|
|
||||||
assert!(!result.summary.contains(" compact earlier"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keeps_core_lines_when_budget_is_tight() {
|
|
||||||
// given
|
|
||||||
let summary = [
|
|
||||||
"Conversation summary:",
|
|
||||||
"- Scope: 18 earlier messages compacted.",
|
|
||||||
"- Current work: finish summary compression.",
|
|
||||||
"- Key timeline:",
|
|
||||||
" - user: asked for a working implementation.",
|
|
||||||
" - assistant: inspected runtime compaction flow.",
|
|
||||||
" - tool: cargo check succeeded.",
|
|
||||||
]
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = compress_summary(
|
|
||||||
&summary,
|
|
||||||
SummaryCompressionBudget {
|
|
||||||
max_chars: 120,
|
|
||||||
max_lines: 3,
|
|
||||||
max_line_chars: 80,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(result.summary.contains("Conversation summary:"));
|
|
||||||
assert!(result
|
|
||||||
.summary
|
|
||||||
.contains("- Scope: 18 earlier messages compacted."));
|
|
||||||
assert!(result
|
|
||||||
.summary
|
|
||||||
.contains("- Current work: finish summary compression."));
|
|
||||||
assert!(result.omitted_lines > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn provides_a_default_text_only_helper() {
|
|
||||||
// given
|
|
||||||
let summary = "Summary:\n\nA short line.";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let compressed = compress_summary_text(summary);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(compressed, "Summary:\nA short line.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,591 +0,0 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value as JsonValue;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fmt::{Display, Formatter};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RepoConfig {
|
|
||||||
pub repo_root: PathBuf,
|
|
||||||
pub worktree_root: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RepoConfig {
|
|
||||||
#[must_use]
|
|
||||||
pub fn dispatch_root(&self) -> &Path {
|
|
||||||
self.worktree_root
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(self.repo_root.as_path())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TaskScope {
|
|
||||||
SingleFile { path: PathBuf },
|
|
||||||
Module { crate_name: String },
|
|
||||||
Workspace,
|
|
||||||
Custom { paths: Vec<PathBuf> },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskScope {
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolve_paths(&self, repo_config: &RepoConfig) -> Vec<PathBuf> {
|
|
||||||
let dispatch_root = repo_config.dispatch_root();
|
|
||||||
match self {
|
|
||||||
Self::SingleFile { path } => vec![resolve_path(dispatch_root, path)],
|
|
||||||
Self::Module { crate_name } => vec![dispatch_root.join("crates").join(crate_name)],
|
|
||||||
Self::Workspace => vec![dispatch_root.to_path_buf()],
|
|
||||||
Self::Custom { paths } => paths
|
|
||||||
.iter()
|
|
||||||
.map(|path| resolve_path(dispatch_root, path))
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for TaskScope {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::SingleFile { .. } => write!(f, "single_file"),
|
|
||||||
Self::Module { .. } => write!(f, "module"),
|
|
||||||
Self::Workspace => write!(f, "workspace"),
|
|
||||||
Self::Custom { .. } => write!(f, "custom"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum BranchPolicy {
|
|
||||||
CreateNew { prefix: String },
|
|
||||||
UseExisting { name: String },
|
|
||||||
WorktreeIsolated,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for BranchPolicy {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::CreateNew { .. } => write!(f, "create_new"),
|
|
||||||
Self::UseExisting { .. } => write!(f, "use_existing"),
|
|
||||||
Self::WorktreeIsolated => write!(f, "worktree_isolated"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CommitPolicy {
|
|
||||||
CommitPerTask,
|
|
||||||
SquashOnMerge,
|
|
||||||
NoAutoCommit,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for CommitPolicy {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::CommitPerTask => write!(f, "commit_per_task"),
|
|
||||||
Self::SquashOnMerge => write!(f, "squash_on_merge"),
|
|
||||||
Self::NoAutoCommit => write!(f, "no_auto_commit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum GreenLevel {
|
|
||||||
Package,
|
|
||||||
Workspace,
|
|
||||||
MergeReady,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for GreenLevel {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Package => write!(f, "package"),
|
|
||||||
Self::Workspace => write!(f, "workspace"),
|
|
||||||
Self::MergeReady => write!(f, "merge_ready"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AcceptanceTest {
|
|
||||||
CargoTest { filter: Option<String> },
|
|
||||||
CustomCommand { cmd: String },
|
|
||||||
GreenLevel { level: GreenLevel },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for AcceptanceTest {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::CargoTest { .. } => write!(f, "cargo_test"),
|
|
||||||
Self::CustomCommand { .. } => write!(f, "custom_command"),
|
|
||||||
Self::GreenLevel { .. } => write!(f, "green_level"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ReportingContract {
|
|
||||||
EventStream,
|
|
||||||
Summary,
|
|
||||||
Silent,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for ReportingContract {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::EventStream => write!(f, "event_stream"),
|
|
||||||
Self::Summary => write!(f, "summary"),
|
|
||||||
Self::Silent => write!(f, "silent"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum EscalationPolicy {
|
|
||||||
RetryThenEscalate { max_retries: u32 },
|
|
||||||
AutoEscalate,
|
|
||||||
NeverEscalate,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for EscalationPolicy {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::RetryThenEscalate { .. } => write!(f, "retry_then_escalate"),
|
|
||||||
Self::AutoEscalate => write!(f, "auto_escalate"),
|
|
||||||
Self::NeverEscalate => write!(f, "never_escalate"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct TaskPacket {
|
|
||||||
pub id: String,
|
|
||||||
pub objective: String,
|
|
||||||
pub scope: TaskScope,
|
|
||||||
pub repo_config: RepoConfig,
|
|
||||||
pub branch_policy: BranchPolicy,
|
|
||||||
pub acceptance_tests: Vec<AcceptanceTest>,
|
|
||||||
pub commit_policy: CommitPolicy,
|
|
||||||
pub reporting: ReportingContract,
|
|
||||||
pub escalation: EscalationPolicy,
|
|
||||||
pub created_at: u64,
|
|
||||||
pub metadata: BTreeMap<String, JsonValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskPacket {
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolve_scope_paths(&self) -> Vec<PathBuf> {
|
|
||||||
self.scope.resolve_paths(&self.repo_config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TaskPacketValidationError {
|
|
||||||
errors: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskPacketValidationError {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(errors: Vec<String>) -> Self {
|
|
||||||
Self { errors }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn errors(&self) -> &[String] {
|
|
||||||
&self.errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for TaskPacketValidationError {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.errors.join("; "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for TaskPacketValidationError {}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct ValidatedPacket(TaskPacket);
|
|
||||||
|
|
||||||
impl ValidatedPacket {
|
|
||||||
#[must_use]
|
|
||||||
pub fn packet(&self) -> &TaskPacket {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn into_inner(self) -> TaskPacket {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolve_scope_paths(&self) -> Vec<PathBuf> {
|
|
||||||
self.0.resolve_scope_paths()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacketValidationError> {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
if packet.id.trim().is_empty() {
|
|
||||||
errors.push("packet id must not be empty".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if packet.objective.trim().is_empty() {
|
|
||||||
errors.push("packet objective must not be empty".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if packet.repo_config.repo_root.as_os_str().is_empty() {
|
|
||||||
errors.push("repo_config repo_root must not be empty".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if packet
|
|
||||||
.repo_config
|
|
||||||
.worktree_root
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|path| path.as_os_str().is_empty())
|
|
||||||
{
|
|
||||||
errors.push("repo_config worktree_root must not be empty when present".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_scope(&packet.scope, &mut errors);
|
|
||||||
validate_branch_policy(&packet.branch_policy, &mut errors);
|
|
||||||
validate_acceptance_tests(&packet.acceptance_tests, &mut errors);
|
|
||||||
validate_escalation_policy(packet.escalation, &mut errors);
|
|
||||||
|
|
||||||
if errors.is_empty() {
|
|
||||||
Ok(ValidatedPacket(packet))
|
|
||||||
} else {
|
|
||||||
Err(TaskPacketValidationError::new(errors))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_scope(scope: &TaskScope, errors: &mut Vec<String>) {
|
|
||||||
match scope {
|
|
||||||
TaskScope::SingleFile { path } if path.as_os_str().is_empty() => {
|
|
||||||
errors.push("single_file scope path must not be empty".to_string());
|
|
||||||
}
|
|
||||||
TaskScope::Module { crate_name } if crate_name.trim().is_empty() => {
|
|
||||||
errors.push("module scope crate_name must not be empty".to_string());
|
|
||||||
}
|
|
||||||
TaskScope::Custom { paths } if paths.is_empty() => {
|
|
||||||
errors.push("custom scope paths must not be empty".to_string());
|
|
||||||
}
|
|
||||||
TaskScope::Custom { paths } => {
|
|
||||||
for (index, path) in paths.iter().enumerate() {
|
|
||||||
if path.as_os_str().is_empty() {
|
|
||||||
errors.push(format!("custom scope contains empty path at index {index}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TaskScope::SingleFile { .. } | TaskScope::Module { .. } | TaskScope::Workspace => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_branch_policy(branch_policy: &BranchPolicy, errors: &mut Vec<String>) {
|
|
||||||
match branch_policy {
|
|
||||||
BranchPolicy::CreateNew { prefix } if prefix.trim().is_empty() => {
|
|
||||||
errors.push("create_new branch prefix must not be empty".to_string());
|
|
||||||
}
|
|
||||||
BranchPolicy::UseExisting { name } if name.trim().is_empty() => {
|
|
||||||
errors.push("use_existing branch name must not be empty".to_string());
|
|
||||||
}
|
|
||||||
BranchPolicy::CreateNew { .. }
|
|
||||||
| BranchPolicy::UseExisting { .. }
|
|
||||||
| BranchPolicy::WorktreeIsolated => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_acceptance_tests(tests: &[AcceptanceTest], errors: &mut Vec<String>) {
|
|
||||||
for test in tests {
|
|
||||||
match test {
|
|
||||||
AcceptanceTest::CargoTest { filter } => {
|
|
||||||
if filter
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|value| value.trim().is_empty())
|
|
||||||
{
|
|
||||||
errors.push("cargo_test filter must not be empty when present".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AcceptanceTest::CustomCommand { cmd } if cmd.trim().is_empty() => {
|
|
||||||
errors.push("custom_command cmd must not be empty".to_string());
|
|
||||||
}
|
|
||||||
AcceptanceTest::CustomCommand { .. } | AcceptanceTest::GreenLevel { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_escalation_policy(escalation: EscalationPolicy, errors: &mut Vec<String>) {
|
|
||||||
if matches!(
|
|
||||||
escalation,
|
|
||||||
EscalationPolicy::RetryThenEscalate { max_retries: 0 }
|
|
||||||
) {
|
|
||||||
errors.push("retry_then_escalate max_retries must be greater than zero".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_path(dispatch_root: &Path, path: &Path) -> PathBuf {
|
|
||||||
if path.is_absolute() {
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
dispatch_root.join(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_repo_config() -> RepoConfig {
|
|
||||||
RepoConfig {
|
|
||||||
repo_root: PathBuf::from("/repo"),
|
|
||||||
worktree_root: Some(PathBuf::from("/repo/.worktrees/task-1")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_packet() -> TaskPacket {
|
|
||||||
let mut metadata = BTreeMap::new();
|
|
||||||
metadata.insert("attempt".to_string(), json!(1));
|
|
||||||
metadata.insert("lane".to_string(), json!("runtime"));
|
|
||||||
|
|
||||||
TaskPacket {
|
|
||||||
id: "packet_001".to_string(),
|
|
||||||
objective: "Implement typed task packet format".to_string(),
|
|
||||||
scope: TaskScope::Module {
|
|
||||||
crate_name: "runtime".to_string(),
|
|
||||||
},
|
|
||||||
repo_config: sample_repo_config(),
|
|
||||||
branch_policy: BranchPolicy::CreateNew {
|
|
||||||
prefix: "ultraclaw".to_string(),
|
|
||||||
},
|
|
||||||
acceptance_tests: vec![
|
|
||||||
AcceptanceTest::CargoTest {
|
|
||||||
filter: Some("task_packet".to_string()),
|
|
||||||
},
|
|
||||||
AcceptanceTest::GreenLevel {
|
|
||||||
level: GreenLevel::Workspace,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
commit_policy: CommitPolicy::CommitPerTask,
|
|
||||||
reporting: ReportingContract::EventStream,
|
|
||||||
escalation: EscalationPolicy::RetryThenEscalate { max_retries: 2 },
|
|
||||||
created_at: now_secs(),
|
|
||||||
metadata,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn valid_packet_passes_validation() {
|
|
||||||
// given
|
|
||||||
let packet = sample_packet();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let validated = validate_packet(packet);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(validated.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_packet_accumulates_errors() {
|
|
||||||
// given
|
|
||||||
let packet = TaskPacket {
|
|
||||||
id: " ".to_string(),
|
|
||||||
objective: " ".to_string(),
|
|
||||||
scope: TaskScope::Custom {
|
|
||||||
paths: vec![PathBuf::new()],
|
|
||||||
},
|
|
||||||
repo_config: RepoConfig {
|
|
||||||
repo_root: PathBuf::new(),
|
|
||||||
worktree_root: Some(PathBuf::new()),
|
|
||||||
},
|
|
||||||
branch_policy: BranchPolicy::CreateNew {
|
|
||||||
prefix: " ".to_string(),
|
|
||||||
},
|
|
||||||
acceptance_tests: vec![
|
|
||||||
AcceptanceTest::CargoTest {
|
|
||||||
filter: Some(" ".to_string()),
|
|
||||||
},
|
|
||||||
AcceptanceTest::CustomCommand {
|
|
||||||
cmd: " ".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
commit_policy: CommitPolicy::NoAutoCommit,
|
|
||||||
reporting: ReportingContract::Silent,
|
|
||||||
escalation: EscalationPolicy::RetryThenEscalate { max_retries: 0 },
|
|
||||||
created_at: 0,
|
|
||||||
metadata: BTreeMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let error = validate_packet(packet).expect_err("packet should be rejected");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.errors().len() >= 8);
|
|
||||||
assert!(error
|
|
||||||
.errors()
|
|
||||||
.contains(&"packet id must not be empty".to_string()));
|
|
||||||
assert!(error
|
|
||||||
.errors()
|
|
||||||
.contains(&"packet objective must not be empty".to_string()));
|
|
||||||
assert!(error
|
|
||||||
.errors()
|
|
||||||
.contains(&"repo_config repo_root must not be empty".to_string()));
|
|
||||||
assert!(error
|
|
||||||
.errors()
|
|
||||||
.contains(&"create_new branch prefix must not be empty".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_file_scope_resolves_against_worktree_root() {
|
|
||||||
// given
|
|
||||||
let repo_config = sample_repo_config();
|
|
||||||
let scope = TaskScope::SingleFile {
|
|
||||||
path: PathBuf::from("crates/runtime/src/task_packet.rs"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let paths = scope.resolve_paths(&repo_config);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
paths,
|
|
||||||
vec![PathBuf::from(
|
|
||||||
"/repo/.worktrees/task-1/crates/runtime/src/task_packet.rs"
|
|
||||||
)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_scope_resolves_to_dispatch_root() {
|
|
||||||
// given
|
|
||||||
let repo_config = sample_repo_config();
|
|
||||||
let scope = TaskScope::Workspace;
|
|
||||||
|
|
||||||
// when
|
|
||||||
let paths = scope.resolve_paths(&repo_config);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(paths, vec![PathBuf::from("/repo/.worktrees/task-1")]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn module_scope_resolves_to_crate_directory() {
|
|
||||||
// given
|
|
||||||
let repo_config = sample_repo_config();
|
|
||||||
let scope = TaskScope::Module {
|
|
||||||
crate_name: "runtime".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let paths = scope.resolve_paths(&repo_config);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
paths,
|
|
||||||
vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_scope_preserves_absolute_paths_and_resolves_relative_paths() {
|
|
||||||
// given
|
|
||||||
let repo_config = sample_repo_config();
|
|
||||||
let scope = TaskScope::Custom {
|
|
||||||
paths: vec![
|
|
||||||
PathBuf::from("Cargo.toml"),
|
|
||||||
PathBuf::from("/tmp/shared/script.sh"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// when
|
|
||||||
let paths = scope.resolve_paths(&repo_config);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
paths,
|
|
||||||
vec![
|
|
||||||
PathBuf::from("/repo/.worktrees/task-1/Cargo.toml"),
|
|
||||||
PathBuf::from("/tmp/shared/script.sh"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn serialization_roundtrip_preserves_packet() {
|
|
||||||
// given
|
|
||||||
let packet = sample_packet();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let serialized = serde_json::to_string(&packet).expect("packet should serialize");
|
|
||||||
let deserialized: TaskPacket =
|
|
||||||
serde_json::from_str(&serialized).expect("packet should deserialize");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(deserialized, packet);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validated_packet_exposes_inner_packet_and_scope_paths() {
|
|
||||||
// given
|
|
||||||
let packet = sample_packet();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let validated = validate_packet(packet.clone()).expect("packet should validate");
|
|
||||||
let resolved_paths = validated.resolve_scope_paths();
|
|
||||||
let inner = validated.into_inner();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
resolved_paths,
|
|
||||||
vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")]
|
|
||||||
);
|
|
||||||
assert_eq!(inner, packet);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn display_impls_render_snake_case_variants() {
|
|
||||||
// given
|
|
||||||
let rendered = vec![
|
|
||||||
TaskScope::Workspace.to_string(),
|
|
||||||
BranchPolicy::WorktreeIsolated.to_string(),
|
|
||||||
CommitPolicy::SquashOnMerge.to_string(),
|
|
||||||
GreenLevel::MergeReady.to_string(),
|
|
||||||
AcceptanceTest::GreenLevel {
|
|
||||||
level: GreenLevel::Package,
|
|
||||||
}
|
|
||||||
.to_string(),
|
|
||||||
ReportingContract::EventStream.to_string(),
|
|
||||||
EscalationPolicy::AutoEscalate.to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let expected = vec![
|
|
||||||
"workspace",
|
|
||||||
"worktree_isolated",
|
|
||||||
"squash_on_merge",
|
|
||||||
"merge_ready",
|
|
||||||
"green_level",
|
|
||||||
"event_stream",
|
|
||||||
"auto_escalate",
|
|
||||||
];
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(rendered, expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,449 +0,0 @@
|
||||||
//! In-memory task registry for sub-agent task lifecycle management.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TaskStatus {
|
|
||||||
Created,
|
|
||||||
Running,
|
|
||||||
Completed,
|
|
||||||
Failed,
|
|
||||||
Stopped,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for TaskStatus {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Created => write!(f, "created"),
|
|
||||||
Self::Running => write!(f, "running"),
|
|
||||||
Self::Completed => write!(f, "completed"),
|
|
||||||
Self::Failed => write!(f, "failed"),
|
|
||||||
Self::Stopped => write!(f, "stopped"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Task {
|
|
||||||
pub task_id: String,
|
|
||||||
pub prompt: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub status: TaskStatus,
|
|
||||||
pub created_at: u64,
|
|
||||||
pub updated_at: u64,
|
|
||||||
pub messages: Vec<TaskMessage>,
|
|
||||||
pub output: String,
|
|
||||||
pub team_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TaskMessage {
|
|
||||||
pub role: String,
|
|
||||||
pub content: String,
|
|
||||||
pub timestamp: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct TaskRegistry {
|
|
||||||
inner: Arc<Mutex<RegistryInner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct RegistryInner {
|
|
||||||
tasks: HashMap<String, Task>,
|
|
||||||
counter: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskRegistry {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
|
|
||||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
inner.counter += 1;
|
|
||||||
let ts = now_secs();
|
|
||||||
let task_id = format!("task_{:08x}_{}", ts, inner.counter);
|
|
||||||
let task = Task {
|
|
||||||
task_id: task_id.clone(),
|
|
||||||
prompt: prompt.to_owned(),
|
|
||||||
description: description.map(str::to_owned),
|
|
||||||
status: TaskStatus::Created,
|
|
||||||
created_at: ts,
|
|
||||||
updated_at: ts,
|
|
||||||
messages: Vec::new(),
|
|
||||||
output: String::new(),
|
|
||||||
team_id: None,
|
|
||||||
};
|
|
||||||
inner.tasks.insert(task_id, task.clone());
|
|
||||||
task
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, task_id: &str) -> Option<Task> {
|
|
||||||
let inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
inner.tasks.get(task_id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(&self, status_filter: Option<TaskStatus>) -> Vec<Task> {
|
|
||||||
let inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
inner
|
|
||||||
.tasks
|
|
||||||
.values()
|
|
||||||
.filter(|t| status_filter.map_or(true, |s| t.status == s))
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self, task_id: &str) -> Result<Task, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
let task = inner
|
|
||||||
.tasks
|
|
||||||
.get_mut(task_id)
|
|
||||||
.ok_or_else(|| format!("task not found: {task_id}"))?;
|
|
||||||
|
|
||||||
match task.status {
|
|
||||||
TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Stopped => {
|
|
||||||
return Err(format!(
|
|
||||||
"task {task_id} is already in terminal state: {}",
|
|
||||||
task.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
task.status = TaskStatus::Stopped;
|
|
||||||
task.updated_at = now_secs();
|
|
||||||
Ok(task.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&self, task_id: &str, message: &str) -> Result<Task, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
let task = inner
|
|
||||||
.tasks
|
|
||||||
.get_mut(task_id)
|
|
||||||
.ok_or_else(|| format!("task not found: {task_id}"))?;
|
|
||||||
|
|
||||||
task.messages.push(TaskMessage {
|
|
||||||
role: String::from("user"),
|
|
||||||
content: message.to_owned(),
|
|
||||||
timestamp: now_secs(),
|
|
||||||
});
|
|
||||||
task.updated_at = now_secs();
|
|
||||||
Ok(task.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn output(&self, task_id: &str) -> Result<String, String> {
|
|
||||||
let inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
let task = inner
|
|
||||||
.tasks
|
|
||||||
.get(task_id)
|
|
||||||
.ok_or_else(|| format!("task not found: {task_id}"))?;
|
|
||||||
Ok(task.output.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn append_output(&self, task_id: &str, output: &str) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
let task = inner
|
|
||||||
.tasks
|
|
||||||
.get_mut(task_id)
|
|
||||||
.ok_or_else(|| format!("task not found: {task_id}"))?;
|
|
||||||
task.output.push_str(output);
|
|
||||||
task.updated_at = now_secs();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_status(&self, task_id: &str, status: TaskStatus) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
let task = inner
|
|
||||||
.tasks
|
|
||||||
.get_mut(task_id)
|
|
||||||
.ok_or_else(|| format!("task not found: {task_id}"))?;
|
|
||||||
task.status = status;
|
|
||||||
task.updated_at = now_secs();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn assign_team(&self, task_id: &str, team_id: &str) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
let task = inner
|
|
||||||
.tasks
|
|
||||||
.get_mut(task_id)
|
|
||||||
.ok_or_else(|| format!("task not found: {task_id}"))?;
|
|
||||||
task.team_id = Some(team_id.to_owned());
|
|
||||||
task.updated_at = now_secs();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&self, task_id: &str) -> Option<Task> {
|
|
||||||
let mut inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
inner.tasks.remove(task_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
let inner = self.inner.lock().expect("registry lock poisoned");
|
|
||||||
inner.tasks.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn creates_and_retrieves_tasks() {
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("Do something", Some("A test task"));
|
|
||||||
assert_eq!(task.status, TaskStatus::Created);
|
|
||||||
assert_eq!(task.prompt, "Do something");
|
|
||||||
assert_eq!(task.description.as_deref(), Some("A test task"));
|
|
||||||
|
|
||||||
let fetched = registry.get(&task.task_id).expect("task should exist");
|
|
||||||
assert_eq!(fetched.task_id, task.task_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lists_tasks_with_optional_filter() {
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
registry.create("Task A", None);
|
|
||||||
let task_b = registry.create("Task B", None);
|
|
||||||
registry
|
|
||||||
.set_status(&task_b.task_id, TaskStatus::Running)
|
|
||||||
.expect("set status should succeed");
|
|
||||||
|
|
||||||
let all = registry.list(None);
|
|
||||||
assert_eq!(all.len(), 2);
|
|
||||||
|
|
||||||
let running = registry.list(Some(TaskStatus::Running));
|
|
||||||
assert_eq!(running.len(), 1);
|
|
||||||
assert_eq!(running[0].task_id, task_b.task_id);
|
|
||||||
|
|
||||||
let created = registry.list(Some(TaskStatus::Created));
|
|
||||||
assert_eq!(created.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stops_running_task() {
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("Stoppable", None);
|
|
||||||
registry
|
|
||||||
.set_status(&task.task_id, TaskStatus::Running)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let stopped = registry.stop(&task.task_id).expect("stop should succeed");
|
|
||||||
assert_eq!(stopped.status, TaskStatus::Stopped);
|
|
||||||
|
|
||||||
// Stopping again should fail
|
|
||||||
let result = registry.stop(&task.task_id);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn updates_task_with_messages() {
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("Messageable", None);
|
|
||||||
let updated = registry
|
|
||||||
.update(&task.task_id, "Here's more context")
|
|
||||||
.expect("update should succeed");
|
|
||||||
assert_eq!(updated.messages.len(), 1);
|
|
||||||
assert_eq!(updated.messages[0].content, "Here's more context");
|
|
||||||
assert_eq!(updated.messages[0].role, "user");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn appends_and_retrieves_output() {
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("Output task", None);
|
|
||||||
registry
|
|
||||||
.append_output(&task.task_id, "line 1\n")
|
|
||||||
.expect("append should succeed");
|
|
||||||
registry
|
|
||||||
.append_output(&task.task_id, "line 2\n")
|
|
||||||
.expect("append should succeed");
|
|
||||||
|
|
||||||
let output = registry.output(&task.task_id).expect("output should exist");
|
|
||||||
assert_eq!(output, "line 1\nline 2\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assigns_team_and_removes_task() {
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("Team task", None);
|
|
||||||
registry
|
|
||||||
.assign_team(&task.task_id, "team_abc")
|
|
||||||
.expect("assign should succeed");
|
|
||||||
|
|
||||||
let fetched = registry.get(&task.task_id).unwrap();
|
|
||||||
assert_eq!(fetched.team_id.as_deref(), Some("team_abc"));
|
|
||||||
|
|
||||||
let removed = registry.remove(&task.task_id);
|
|
||||||
assert!(removed.is_some());
|
|
||||||
assert!(registry.get(&task.task_id).is_none());
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_operations_on_missing_task() {
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
assert!(registry.stop("nonexistent").is_err());
|
|
||||||
assert!(registry.update("nonexistent", "msg").is_err());
|
|
||||||
assert!(registry.output("nonexistent").is_err());
|
|
||||||
assert!(registry.append_output("nonexistent", "data").is_err());
|
|
||||||
assert!(registry
|
|
||||||
.set_status("nonexistent", TaskStatus::Running)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn task_status_display_all_variants() {
|
|
||||||
// given
|
|
||||||
let cases = [
|
|
||||||
(TaskStatus::Created, "created"),
|
|
||||||
(TaskStatus::Running, "running"),
|
|
||||||
(TaskStatus::Completed, "completed"),
|
|
||||||
(TaskStatus::Failed, "failed"),
|
|
||||||
(TaskStatus::Stopped, "stopped"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let rendered: Vec<_> = cases
|
|
||||||
.into_iter()
|
|
||||||
.map(|(status, expected)| (status.to_string(), expected))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
rendered,
|
|
||||||
vec![
|
|
||||||
("created".to_string(), "created"),
|
|
||||||
("running".to_string(), "running"),
|
|
||||||
("completed".to_string(), "completed"),
|
|
||||||
("failed".to_string(), "failed"),
|
|
||||||
("stopped".to_string(), "stopped"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stop_rejects_completed_task() {
|
|
||||||
// given
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("done", None);
|
|
||||||
registry
|
|
||||||
.set_status(&task.task_id, TaskStatus::Completed)
|
|
||||||
.expect("set status should succeed");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.stop(&task.task_id);
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("completed task should be rejected");
|
|
||||||
assert!(error.contains("already in terminal state"));
|
|
||||||
assert!(error.contains("completed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stop_rejects_failed_task() {
|
|
||||||
// given
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("failed", None);
|
|
||||||
registry
|
|
||||||
.set_status(&task.task_id, TaskStatus::Failed)
|
|
||||||
.expect("set status should succeed");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.stop(&task.task_id);
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("failed task should be rejected");
|
|
||||||
assert!(error.contains("already in terminal state"));
|
|
||||||
assert!(error.contains("failed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stop_succeeds_from_created_state() {
|
|
||||||
// given
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
let task = registry.create("created task", None);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let stopped = registry.stop(&task.task_id).expect("stop should succeed");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(stopped.status, TaskStatus::Stopped);
|
|
||||||
assert!(stopped.updated_at >= task.updated_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_registry_is_empty() {
|
|
||||||
// given
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let all_tasks = registry.list(None);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
assert_eq!(registry.len(), 0);
|
|
||||||
assert!(all_tasks.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_without_description() {
|
|
||||||
// given
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let task = registry.create("Do the thing", None);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(task.task_id.starts_with("task_"));
|
|
||||||
assert_eq!(task.description, None);
|
|
||||||
assert!(task.messages.is_empty());
|
|
||||||
assert!(task.output.is_empty());
|
|
||||||
assert_eq!(task.team_id, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_nonexistent_returns_none() {
|
|
||||||
// given
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let removed = registry.remove("missing");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(removed.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_team_rejects_missing_task() {
|
|
||||||
// given
|
|
||||||
let registry = TaskRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let result = registry.assign_team("missing", "team_123");
|
|
||||||
|
|
||||||
// then
|
|
||||||
let error = result.expect_err("missing task should be rejected");
|
|
||||||
assert_eq!(error, "task not found: missing");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,508 +0,0 @@
|
||||||
//! In-memory registries for Team and Cron lifecycle management.
|
|
||||||
//!
|
|
||||||
//! Provides TeamCreate/Delete and CronCreate/Delete/List runtime backing
|
|
||||||
//! to replace the stub implementations in the tools crate.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Team {
|
|
||||||
pub team_id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub task_ids: Vec<String>,
|
|
||||||
pub status: TeamStatus,
|
|
||||||
pub created_at: u64,
|
|
||||||
pub updated_at: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TeamStatus {
|
|
||||||
Created,
|
|
||||||
Running,
|
|
||||||
Completed,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for TeamStatus {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Created => write!(f, "created"),
|
|
||||||
Self::Running => write!(f, "running"),
|
|
||||||
Self::Completed => write!(f, "completed"),
|
|
||||||
Self::Deleted => write!(f, "deleted"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct TeamRegistry {
|
|
||||||
inner: Arc<Mutex<TeamInner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct TeamInner {
|
|
||||||
teams: HashMap<String, Team>,
|
|
||||||
counter: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TeamRegistry {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(&self, name: &str, task_ids: Vec<String>) -> Team {
|
|
||||||
let mut inner = self.inner.lock().expect("team registry lock poisoned");
|
|
||||||
inner.counter += 1;
|
|
||||||
let ts = now_secs();
|
|
||||||
let team_id = format!("team_{:08x}_{}", ts, inner.counter);
|
|
||||||
let team = Team {
|
|
||||||
team_id: team_id.clone(),
|
|
||||||
name: name.to_owned(),
|
|
||||||
task_ids,
|
|
||||||
status: TeamStatus::Created,
|
|
||||||
created_at: ts,
|
|
||||||
updated_at: ts,
|
|
||||||
};
|
|
||||||
inner.teams.insert(team_id, team.clone());
|
|
||||||
team
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, team_id: &str) -> Option<Team> {
|
|
||||||
let inner = self.inner.lock().expect("team registry lock poisoned");
|
|
||||||
inner.teams.get(team_id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(&self) -> Vec<Team> {
|
|
||||||
let inner = self.inner.lock().expect("team registry lock poisoned");
|
|
||||||
inner.teams.values().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete(&self, team_id: &str) -> Result<Team, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("team registry lock poisoned");
|
|
||||||
let team = inner
|
|
||||||
.teams
|
|
||||||
.get_mut(team_id)
|
|
||||||
.ok_or_else(|| format!("team not found: {team_id}"))?;
|
|
||||||
team.status = TeamStatus::Deleted;
|
|
||||||
team.updated_at = now_secs();
|
|
||||||
Ok(team.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&self, team_id: &str) -> Option<Team> {
|
|
||||||
let mut inner = self.inner.lock().expect("team registry lock poisoned");
|
|
||||||
inner.teams.remove(team_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
let inner = self.inner.lock().expect("team registry lock poisoned");
|
|
||||||
inner.teams.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CronEntry {
|
|
||||||
pub cron_id: String,
|
|
||||||
pub schedule: String,
|
|
||||||
pub prompt: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub created_at: u64,
|
|
||||||
pub updated_at: u64,
|
|
||||||
pub last_run_at: Option<u64>,
|
|
||||||
pub run_count: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct CronRegistry {
|
|
||||||
inner: Arc<Mutex<CronInner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct CronInner {
|
|
||||||
entries: HashMap<String, CronEntry>,
|
|
||||||
counter: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CronRegistry {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(&self, schedule: &str, prompt: &str, description: Option<&str>) -> CronEntry {
|
|
||||||
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
|
|
||||||
inner.counter += 1;
|
|
||||||
let ts = now_secs();
|
|
||||||
let cron_id = format!("cron_{:08x}_{}", ts, inner.counter);
|
|
||||||
let entry = CronEntry {
|
|
||||||
cron_id: cron_id.clone(),
|
|
||||||
schedule: schedule.to_owned(),
|
|
||||||
prompt: prompt.to_owned(),
|
|
||||||
description: description.map(str::to_owned),
|
|
||||||
enabled: true,
|
|
||||||
created_at: ts,
|
|
||||||
updated_at: ts,
|
|
||||||
last_run_at: None,
|
|
||||||
run_count: 0,
|
|
||||||
};
|
|
||||||
inner.entries.insert(cron_id, entry.clone());
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, cron_id: &str) -> Option<CronEntry> {
|
|
||||||
let inner = self.inner.lock().expect("cron registry lock poisoned");
|
|
||||||
inner.entries.get(cron_id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(&self, enabled_only: bool) -> Vec<CronEntry> {
|
|
||||||
let inner = self.inner.lock().expect("cron registry lock poisoned");
|
|
||||||
inner
|
|
||||||
.entries
|
|
||||||
.values()
|
|
||||||
.filter(|e| !enabled_only || e.enabled)
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete(&self, cron_id: &str) -> Result<CronEntry, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
|
|
||||||
inner
|
|
||||||
.entries
|
|
||||||
.remove(cron_id)
|
|
||||||
.ok_or_else(|| format!("cron not found: {cron_id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disable a cron entry without removing it.
|
|
||||||
pub fn disable(&self, cron_id: &str) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
|
|
||||||
let entry = inner
|
|
||||||
.entries
|
|
||||||
.get_mut(cron_id)
|
|
||||||
.ok_or_else(|| format!("cron not found: {cron_id}"))?;
|
|
||||||
entry.enabled = false;
|
|
||||||
entry.updated_at = now_secs();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a cron run.
|
|
||||||
pub fn record_run(&self, cron_id: &str) -> Result<(), String> {
|
|
||||||
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
|
|
||||||
let entry = inner
|
|
||||||
.entries
|
|
||||||
.get_mut(cron_id)
|
|
||||||
.ok_or_else(|| format!("cron not found: {cron_id}"))?;
|
|
||||||
entry.last_run_at = Some(now_secs());
|
|
||||||
entry.run_count += 1;
|
|
||||||
entry.updated_at = now_secs();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
let inner = self.inner.lock().expect("cron registry lock poisoned");
|
|
||||||
inner.entries.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// ── Team tests ──────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn creates_and_retrieves_team() {
|
|
||||||
let registry = TeamRegistry::new();
|
|
||||||
let team = registry.create("Alpha Squad", vec!["task_001".into(), "task_002".into()]);
|
|
||||||
assert_eq!(team.name, "Alpha Squad");
|
|
||||||
assert_eq!(team.task_ids.len(), 2);
|
|
||||||
assert_eq!(team.status, TeamStatus::Created);
|
|
||||||
|
|
||||||
let fetched = registry.get(&team.team_id).expect("team should exist");
|
|
||||||
assert_eq!(fetched.team_id, team.team_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lists_and_deletes_teams() {
|
|
||||||
let registry = TeamRegistry::new();
|
|
||||||
let t1 = registry.create("Team A", vec![]);
|
|
||||||
let t2 = registry.create("Team B", vec![]);
|
|
||||||
|
|
||||||
let all = registry.list();
|
|
||||||
assert_eq!(all.len(), 2);
|
|
||||||
|
|
||||||
let deleted = registry.delete(&t1.team_id).expect("delete should succeed");
|
|
||||||
assert_eq!(deleted.status, TeamStatus::Deleted);
|
|
||||||
|
|
||||||
// Team is still listable (soft delete)
|
|
||||||
let still_there = registry.get(&t1.team_id).unwrap();
|
|
||||||
assert_eq!(still_there.status, TeamStatus::Deleted);
|
|
||||||
|
|
||||||
// Hard remove
|
|
||||||
registry.remove(&t2.team_id);
|
|
||||||
assert_eq!(registry.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_missing_team_operations() {
|
|
||||||
let registry = TeamRegistry::new();
|
|
||||||
assert!(registry.delete("nonexistent").is_err());
|
|
||||||
assert!(registry.get("nonexistent").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cron tests ──────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn creates_and_retrieves_cron() {
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
let entry = registry.create("0 * * * *", "Check status", Some("hourly check"));
|
|
||||||
assert_eq!(entry.schedule, "0 * * * *");
|
|
||||||
assert_eq!(entry.prompt, "Check status");
|
|
||||||
assert!(entry.enabled);
|
|
||||||
assert_eq!(entry.run_count, 0);
|
|
||||||
assert!(entry.last_run_at.is_none());
|
|
||||||
|
|
||||||
let fetched = registry.get(&entry.cron_id).expect("cron should exist");
|
|
||||||
assert_eq!(fetched.cron_id, entry.cron_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lists_with_enabled_filter() {
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
let c1 = registry.create("* * * * *", "Task 1", None);
|
|
||||||
let c2 = registry.create("0 * * * *", "Task 2", None);
|
|
||||||
registry
|
|
||||||
.disable(&c1.cron_id)
|
|
||||||
.expect("disable should succeed");
|
|
||||||
|
|
||||||
let all = registry.list(false);
|
|
||||||
assert_eq!(all.len(), 2);
|
|
||||||
|
|
||||||
let enabled_only = registry.list(true);
|
|
||||||
assert_eq!(enabled_only.len(), 1);
|
|
||||||
assert_eq!(enabled_only[0].cron_id, c2.cron_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deletes_cron_entry() {
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
let entry = registry.create("* * * * *", "To delete", None);
|
|
||||||
let deleted = registry
|
|
||||||
.delete(&entry.cron_id)
|
|
||||||
.expect("delete should succeed");
|
|
||||||
assert_eq!(deleted.cron_id, entry.cron_id);
|
|
||||||
assert!(registry.get(&entry.cron_id).is_none());
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn records_cron_runs() {
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
let entry = registry.create("*/5 * * * *", "Recurring", None);
|
|
||||||
registry.record_run(&entry.cron_id).unwrap();
|
|
||||||
registry.record_run(&entry.cron_id).unwrap();
|
|
||||||
|
|
||||||
let fetched = registry.get(&entry.cron_id).unwrap();
|
|
||||||
assert_eq!(fetched.run_count, 2);
|
|
||||||
assert!(fetched.last_run_at.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_missing_cron_operations() {
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
assert!(registry.delete("nonexistent").is_err());
|
|
||||||
assert!(registry.disable("nonexistent").is_err());
|
|
||||||
assert!(registry.record_run("nonexistent").is_err());
|
|
||||||
assert!(registry.get("nonexistent").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn team_status_display_all_variants() {
|
|
||||||
// given
|
|
||||||
let cases = [
|
|
||||||
(TeamStatus::Created, "created"),
|
|
||||||
(TeamStatus::Running, "running"),
|
|
||||||
(TeamStatus::Completed, "completed"),
|
|
||||||
(TeamStatus::Deleted, "deleted"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// when
|
|
||||||
let rendered: Vec<_> = cases
|
|
||||||
.into_iter()
|
|
||||||
.map(|(status, expected)| (status.to_string(), expected))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(
|
|
||||||
rendered,
|
|
||||||
vec![
|
|
||||||
("created".to_string(), "created"),
|
|
||||||
("running".to_string(), "running"),
|
|
||||||
("completed".to_string(), "completed"),
|
|
||||||
("deleted".to_string(), "deleted"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_team_registry_is_empty() {
|
|
||||||
// given
|
|
||||||
let registry = TeamRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let teams = registry.list();
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
assert_eq!(registry.len(), 0);
|
|
||||||
assert!(teams.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn team_remove_nonexistent_returns_none() {
|
|
||||||
// given
|
|
||||||
let registry = TeamRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let removed = registry.remove("missing");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(removed.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn team_len_transitions() {
|
|
||||||
// given
|
|
||||||
let registry = TeamRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let alpha = registry.create("Alpha", vec![]);
|
|
||||||
let beta = registry.create("Beta", vec![]);
|
|
||||||
let after_create = registry.len();
|
|
||||||
registry.remove(&alpha.team_id);
|
|
||||||
let after_first_remove = registry.len();
|
|
||||||
registry.remove(&beta.team_id);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(after_create, 2);
|
|
||||||
assert_eq!(after_first_remove, 1);
|
|
||||||
assert_eq!(registry.len(), 0);
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cron_list_all_disabled_returns_empty_for_enabled_only() {
|
|
||||||
// given
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
let first = registry.create("* * * * *", "Task 1", None);
|
|
||||||
let second = registry.create("0 * * * *", "Task 2", None);
|
|
||||||
registry
|
|
||||||
.disable(&first.cron_id)
|
|
||||||
.expect("disable should succeed");
|
|
||||||
registry
|
|
||||||
.disable(&second.cron_id)
|
|
||||||
.expect("disable should succeed");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let enabled_only = registry.list(true);
|
|
||||||
let all_entries = registry.list(false);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(enabled_only.is_empty());
|
|
||||||
assert_eq!(all_entries.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cron_create_without_description() {
|
|
||||||
// given
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let entry = registry.create("*/15 * * * *", "Check health", None);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(entry.cron_id.starts_with("cron_"));
|
|
||||||
assert_eq!(entry.description, None);
|
|
||||||
assert!(entry.enabled);
|
|
||||||
assert_eq!(entry.run_count, 0);
|
|
||||||
assert_eq!(entry.last_run_at, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_cron_registry_is_empty() {
|
|
||||||
// given
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
|
|
||||||
// when
|
|
||||||
let enabled_only = registry.list(true);
|
|
||||||
let all_entries = registry.list(false);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(registry.is_empty());
|
|
||||||
assert_eq!(registry.len(), 0);
|
|
||||||
assert!(enabled_only.is_empty());
|
|
||||||
assert!(all_entries.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cron_record_run_updates_timestamp_and_counter() {
|
|
||||||
// given
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
let entry = registry.create("*/5 * * * *", "Recurring", None);
|
|
||||||
|
|
||||||
// when
|
|
||||||
registry
|
|
||||||
.record_run(&entry.cron_id)
|
|
||||||
.expect("first run should succeed");
|
|
||||||
registry
|
|
||||||
.record_run(&entry.cron_id)
|
|
||||||
.expect("second run should succeed");
|
|
||||||
let fetched = registry.get(&entry.cron_id).expect("entry should exist");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(fetched.run_count, 2);
|
|
||||||
assert!(fetched.last_run_at.is_some());
|
|
||||||
assert!(fetched.updated_at >= entry.updated_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cron_disable_updates_timestamp() {
|
|
||||||
// given
|
|
||||||
let registry = CronRegistry::new();
|
|
||||||
let entry = registry.create("0 0 * * *", "Nightly", None);
|
|
||||||
|
|
||||||
// when
|
|
||||||
registry
|
|
||||||
.disable(&entry.cron_id)
|
|
||||||
.expect("disable should succeed");
|
|
||||||
let fetched = registry.get(&entry.cron_id).expect("entry should exist");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(!fetched.enabled);
|
|
||||||
assert!(fetched.updated_at >= entry.updated_at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
const TRUST_PROMPT_CUES: &[&str] = &[
|
|
||||||
"do you trust the files in this folder",
|
|
||||||
"trust the files in this folder",
|
|
||||||
"trust this folder",
|
|
||||||
"allow and continue",
|
|
||||||
"yes, proceed",
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum TrustPolicy {
|
|
||||||
AutoTrust,
|
|
||||||
RequireApproval,
|
|
||||||
Deny,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum TrustEvent {
|
|
||||||
TrustRequired { cwd: String },
|
|
||||||
TrustResolved { cwd: String, policy: TrustPolicy },
|
|
||||||
TrustDenied { cwd: String, reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct TrustConfig {
|
|
||||||
allowlisted: Vec<PathBuf>,
|
|
||||||
denied: Vec<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TrustConfig {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
|
|
||||||
self.allowlisted.push(path.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_denied(mut self, path: impl Into<PathBuf>) -> Self {
|
|
||||||
self.denied.push(path.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum TrustDecision {
|
|
||||||
NotRequired,
|
|
||||||
Required {
|
|
||||||
policy: TrustPolicy,
|
|
||||||
events: Vec<TrustEvent>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TrustDecision {
|
|
||||||
#[must_use]
|
|
||||||
pub fn policy(&self) -> Option<TrustPolicy> {
|
|
||||||
match self {
|
|
||||||
Self::NotRequired => None,
|
|
||||||
Self::Required { policy, .. } => Some(*policy),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn events(&self) -> &[TrustEvent] {
|
|
||||||
match self {
|
|
||||||
Self::NotRequired => &[],
|
|
||||||
Self::Required { events, .. } => events,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TrustResolver {
|
|
||||||
config: TrustConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TrustResolver {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(config: TrustConfig) -> Self {
|
|
||||||
Self { config }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
|
|
||||||
if !detect_trust_prompt(screen_text) {
|
|
||||||
return TrustDecision::NotRequired;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut events = vec![TrustEvent::TrustRequired {
|
|
||||||
cwd: cwd.to_owned(),
|
|
||||||
}];
|
|
||||||
|
|
||||||
if let Some(matched_root) = self
|
|
||||||
.config
|
|
||||||
.denied
|
|
||||||
.iter()
|
|
||||||
.find(|root| path_matches(cwd, root))
|
|
||||||
{
|
|
||||||
let reason = format!("cwd matches denied trust root: {}", matched_root.display());
|
|
||||||
events.push(TrustEvent::TrustDenied {
|
|
||||||
cwd: cwd.to_owned(),
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
return TrustDecision::Required {
|
|
||||||
policy: TrustPolicy::Deny,
|
|
||||||
events,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if self
|
|
||||||
.config
|
|
||||||
.allowlisted
|
|
||||||
.iter()
|
|
||||||
.any(|root| path_matches(cwd, root))
|
|
||||||
{
|
|
||||||
events.push(TrustEvent::TrustResolved {
|
|
||||||
cwd: cwd.to_owned(),
|
|
||||||
policy: TrustPolicy::AutoTrust,
|
|
||||||
});
|
|
||||||
return TrustDecision::Required {
|
|
||||||
policy: TrustPolicy::AutoTrust,
|
|
||||||
events,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
TrustDecision::Required {
|
|
||||||
policy: TrustPolicy::RequireApproval,
|
|
||||||
events,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn trusts(&self, cwd: &str) -> bool {
|
|
||||||
!self
|
|
||||||
.config
|
|
||||||
.denied
|
|
||||||
.iter()
|
|
||||||
.any(|root| path_matches(cwd, root))
|
|
||||||
&& self
|
|
||||||
.config
|
|
||||||
.allowlisted
|
|
||||||
.iter()
|
|
||||||
.any(|root| path_matches(cwd, root))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn detect_trust_prompt(screen_text: &str) -> bool {
|
|
||||||
let lowered = screen_text.to_ascii_lowercase();
|
|
||||||
TRUST_PROMPT_CUES
|
|
||||||
.iter()
|
|
||||||
.any(|needle| lowered.contains(needle))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn path_matches_trusted_root(cwd: &str, trusted_root: &str) -> bool {
|
|
||||||
path_matches(cwd, &normalize_path(Path::new(trusted_root)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_matches(candidate: &str, root: &Path) -> bool {
|
|
||||||
let candidate = normalize_path(Path::new(candidate));
|
|
||||||
let root = normalize_path(root);
|
|
||||||
candidate == root || candidate.starts_with(&root)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_path(path: &Path) -> PathBuf {
|
|
||||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
|
|
||||||
TrustPolicy, TrustResolver,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_known_trust_prompt_copy() {
|
|
||||||
// given
|
|
||||||
let screen_text = "Do you trust the files in this folder?\n1. Yes, proceed\n2. No";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let detected = detect_trust_prompt(screen_text);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(detected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn does_not_emit_events_when_prompt_is_absent() {
|
|
||||||
// given
|
|
||||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
|
||||||
|
|
||||||
// when
|
|
||||||
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(decision, TrustDecision::NotRequired);
|
|
||||||
assert_eq!(decision.events(), &[]);
|
|
||||||
assert_eq!(decision.policy(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_trusts_allowlisted_cwd_after_prompt_detection() {
|
|
||||||
// given
|
|
||||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
|
||||||
|
|
||||||
// when
|
|
||||||
let decision = resolver.resolve(
|
|
||||||
"/tmp/worktrees/repo-a",
|
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
|
||||||
assert_eq!(
|
|
||||||
decision.events(),
|
|
||||||
&[
|
|
||||||
TrustEvent::TrustRequired {
|
|
||||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
|
||||||
},
|
|
||||||
TrustEvent::TrustResolved {
|
|
||||||
cwd: "/tmp/worktrees/repo-a".to_string(),
|
|
||||||
policy: TrustPolicy::AutoTrust,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn requires_approval_for_unknown_cwd_after_prompt_detection() {
|
|
||||||
// given
|
|
||||||
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
|
||||||
|
|
||||||
// when
|
|
||||||
let decision = resolver.resolve(
|
|
||||||
"/tmp/other/repo-b",
|
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
|
|
||||||
assert_eq!(
|
|
||||||
decision.events(),
|
|
||||||
&[TrustEvent::TrustRequired {
|
|
||||||
cwd: "/tmp/other/repo-b".to_string(),
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn denied_root_takes_precedence_over_allowlist() {
|
|
||||||
// given
|
|
||||||
let resolver = TrustResolver::new(
|
|
||||||
TrustConfig::new()
|
|
||||||
.with_allowlisted("/tmp/worktrees")
|
|
||||||
.with_denied("/tmp/worktrees/repo-c"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when
|
|
||||||
let decision = resolver.resolve(
|
|
||||||
"/tmp/worktrees/repo-c",
|
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_eq!(decision.policy(), Some(TrustPolicy::Deny));
|
|
||||||
assert_eq!(
|
|
||||||
decision.events(),
|
|
||||||
&[
|
|
||||||
TrustEvent::TrustRequired {
|
|
||||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
|
||||||
},
|
|
||||||
TrustEvent::TrustDenied {
|
|
||||||
cwd: "/tmp/worktrees/repo-c".to_string(),
|
|
||||||
reason: "cwd matches denied trust root: /tmp/worktrees/repo-c".to_string(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sibling_prefix_does_not_match_trusted_root() {
|
|
||||||
// given
|
|
||||||
let trusted_root = "/tmp/worktrees";
|
|
||||||
let sibling_path = "/tmp/worktrees-other/repo-d";
|
|
||||||
|
|
||||||
// when
|
|
||||||
let matched = path_matches_trusted_root(sibling_path, trusted_root);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(!matched);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,732 +0,0 @@
|
||||||
//! In-memory worker-boot state machine and control registry.
|
|
||||||
//!
|
|
||||||
//! This provides a foundational control plane for reliable worker startup:
|
|
||||||
//! trust-gate detection, ready-for-prompt handshakes, and prompt-misdelivery
|
|
||||||
//! detection/recovery all live above raw terminal transport.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum WorkerStatus {
|
|
||||||
Spawning,
|
|
||||||
TrustRequired,
|
|
||||||
ReadyForPrompt,
|
|
||||||
PromptAccepted,
|
|
||||||
Running,
|
|
||||||
Blocked,
|
|
||||||
Finished,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for WorkerStatus {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Spawning => write!(f, "spawning"),
|
|
||||||
Self::TrustRequired => write!(f, "trust_required"),
|
|
||||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
|
||||||
Self::PromptAccepted => write!(f, "prompt_accepted"),
|
|
||||||
Self::Running => write!(f, "running"),
|
|
||||||
Self::Blocked => write!(f, "blocked"),
|
|
||||||
Self::Finished => write!(f, "finished"),
|
|
||||||
Self::Failed => write!(f, "failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum WorkerFailureKind {
|
|
||||||
TrustGate,
|
|
||||||
PromptDelivery,
|
|
||||||
Protocol,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct WorkerFailure {
|
|
||||||
pub kind: WorkerFailureKind,
|
|
||||||
pub message: String,
|
|
||||||
pub created_at: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum WorkerEventKind {
|
|
||||||
Spawning,
|
|
||||||
TrustRequired,
|
|
||||||
TrustResolved,
|
|
||||||
ReadyForPrompt,
|
|
||||||
PromptAccepted,
|
|
||||||
PromptMisdelivery,
|
|
||||||
PromptReplayArmed,
|
|
||||||
Running,
|
|
||||||
Restarted,
|
|
||||||
Finished,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct WorkerEvent {
|
|
||||||
pub seq: u64,
|
|
||||||
pub kind: WorkerEventKind,
|
|
||||||
pub status: WorkerStatus,
|
|
||||||
pub detail: Option<String>,
|
|
||||||
pub timestamp: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct Worker {
|
|
||||||
pub worker_id: String,
|
|
||||||
pub cwd: String,
|
|
||||||
pub status: WorkerStatus,
|
|
||||||
pub trust_auto_resolve: bool,
|
|
||||||
pub trust_gate_cleared: bool,
|
|
||||||
pub auto_recover_prompt_misdelivery: bool,
|
|
||||||
pub prompt_delivery_attempts: u32,
|
|
||||||
pub last_prompt: Option<String>,
|
|
||||||
pub replay_prompt: Option<String>,
|
|
||||||
pub last_error: Option<WorkerFailure>,
|
|
||||||
pub created_at: u64,
|
|
||||||
pub updated_at: u64,
|
|
||||||
pub events: Vec<WorkerEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct WorkerRegistry {
|
|
||||||
inner: Arc<Mutex<WorkerRegistryInner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct WorkerRegistryInner {
|
|
||||||
workers: HashMap<String, Worker>,
|
|
||||||
counter: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkerRegistry {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn create(
|
|
||||||
&self,
|
|
||||||
cwd: &str,
|
|
||||||
trusted_roots: &[String],
|
|
||||||
auto_recover_prompt_misdelivery: bool,
|
|
||||||
) -> Worker {
|
|
||||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
|
||||||
inner.counter += 1;
|
|
||||||
let ts = now_secs();
|
|
||||||
let worker_id = format!("worker_{:08x}_{}", ts, inner.counter);
|
|
||||||
let trust_auto_resolve = trusted_roots
|
|
||||||
.iter()
|
|
||||||
.any(|root| path_matches_allowlist(cwd, root));
|
|
||||||
let mut worker = Worker {
|
|
||||||
worker_id: worker_id.clone(),
|
|
||||||
cwd: cwd.to_owned(),
|
|
||||||
status: WorkerStatus::Spawning,
|
|
||||||
trust_auto_resolve,
|
|
||||||
trust_gate_cleared: false,
|
|
||||||
auto_recover_prompt_misdelivery,
|
|
||||||
prompt_delivery_attempts: 0,
|
|
||||||
last_prompt: None,
|
|
||||||
replay_prompt: None,
|
|
||||||
last_error: None,
|
|
||||||
created_at: ts,
|
|
||||||
updated_at: ts,
|
|
||||||
events: Vec::new(),
|
|
||||||
};
|
|
||||||
push_event(
|
|
||||||
&mut worker,
|
|
||||||
WorkerEventKind::Spawning,
|
|
||||||
WorkerStatus::Spawning,
|
|
||||||
Some("worker created".to_string()),
|
|
||||||
);
|
|
||||||
inner.workers.insert(worker_id, worker.clone());
|
|
||||||
worker
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn get(&self, worker_id: &str) -> Option<Worker> {
|
|
||||||
let inner = self.inner.lock().expect("worker registry lock poisoned");
|
|
||||||
inner.workers.get(worker_id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn observe(&self, worker_id: &str, screen_text: &str) -> Result<Worker, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
|
||||||
let worker = inner
|
|
||||||
.workers
|
|
||||||
.get_mut(worker_id)
|
|
||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
|
||||||
let lowered = screen_text.to_ascii_lowercase();
|
|
||||||
|
|
||||||
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
|
|
||||||
worker.status = WorkerStatus::TrustRequired;
|
|
||||||
worker.last_error = Some(WorkerFailure {
|
|
||||||
kind: WorkerFailureKind::TrustGate,
|
|
||||||
message: "worker boot blocked on trust prompt".to_string(),
|
|
||||||
created_at: now_secs(),
|
|
||||||
});
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::TrustRequired,
|
|
||||||
WorkerStatus::TrustRequired,
|
|
||||||
Some("trust prompt detected".to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if worker.trust_auto_resolve {
|
|
||||||
worker.trust_gate_cleared = true;
|
|
||||||
worker.last_error = None;
|
|
||||||
worker.status = WorkerStatus::Spawning;
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::TrustResolved,
|
|
||||||
WorkerStatus::Spawning,
|
|
||||||
Some("allowlisted repo auto-resolved trust prompt".to_string()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Ok(worker.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if prompt_misdelivery_is_relevant(worker)
|
|
||||||
&& detect_prompt_misdelivery(&lowered, worker.last_prompt.as_deref())
|
|
||||||
{
|
|
||||||
let detail = prompt_preview(worker.last_prompt.as_deref().unwrap_or_default());
|
|
||||||
worker.last_error = Some(WorkerFailure {
|
|
||||||
kind: WorkerFailureKind::PromptDelivery,
|
|
||||||
message: format!("worker prompt landed in shell instead of coding agent: {detail}"),
|
|
||||||
created_at: now_secs(),
|
|
||||||
});
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::PromptMisdelivery,
|
|
||||||
WorkerStatus::Blocked,
|
|
||||||
Some("shell misdelivery detected".to_string()),
|
|
||||||
);
|
|
||||||
if worker.auto_recover_prompt_misdelivery {
|
|
||||||
worker.replay_prompt = worker.last_prompt.clone();
|
|
||||||
worker.status = WorkerStatus::ReadyForPrompt;
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::PromptReplayArmed,
|
|
||||||
WorkerStatus::ReadyForPrompt,
|
|
||||||
Some("prompt replay armed after shell misdelivery".to_string()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
worker.status = WorkerStatus::Blocked;
|
|
||||||
}
|
|
||||||
return Ok(worker.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if detect_running_cue(&lowered)
|
|
||||||
&& matches!(
|
|
||||||
worker.status,
|
|
||||||
WorkerStatus::PromptAccepted | WorkerStatus::ReadyForPrompt
|
|
||||||
)
|
|
||||||
{
|
|
||||||
worker.status = WorkerStatus::Running;
|
|
||||||
worker.last_error = None;
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::Running,
|
|
||||||
WorkerStatus::Running,
|
|
||||||
Some("worker accepted prompt and started running".to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if detect_ready_for_prompt(screen_text, &lowered)
|
|
||||||
&& !matches!(
|
|
||||||
worker.status,
|
|
||||||
WorkerStatus::ReadyForPrompt | WorkerStatus::Running
|
|
||||||
)
|
|
||||||
{
|
|
||||||
worker.status = WorkerStatus::ReadyForPrompt;
|
|
||||||
if matches!(
|
|
||||||
worker.last_error.as_ref().map(|failure| failure.kind),
|
|
||||||
Some(WorkerFailureKind::TrustGate)
|
|
||||||
) {
|
|
||||||
worker.last_error = None;
|
|
||||||
}
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::ReadyForPrompt,
|
|
||||||
WorkerStatus::ReadyForPrompt,
|
|
||||||
Some("worker is ready for prompt delivery".to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(worker.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_trust(&self, worker_id: &str) -> Result<Worker, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
|
||||||
let worker = inner
|
|
||||||
.workers
|
|
||||||
.get_mut(worker_id)
|
|
||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
|
||||||
|
|
||||||
if worker.status != WorkerStatus::TrustRequired {
|
|
||||||
return Err(format!(
|
|
||||||
"worker {worker_id} is not waiting on trust; current status: {}",
|
|
||||||
worker.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
worker.trust_gate_cleared = true;
|
|
||||||
worker.last_error = None;
|
|
||||||
worker.status = WorkerStatus::Spawning;
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::TrustResolved,
|
|
||||||
WorkerStatus::Spawning,
|
|
||||||
Some("trust prompt resolved manually".to_string()),
|
|
||||||
);
|
|
||||||
Ok(worker.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_prompt(&self, worker_id: &str, prompt: Option<&str>) -> Result<Worker, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
|
||||||
let worker = inner
|
|
||||||
.workers
|
|
||||||
.get_mut(worker_id)
|
|
||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
|
||||||
|
|
||||||
if worker.status != WorkerStatus::ReadyForPrompt {
|
|
||||||
return Err(format!(
|
|
||||||
"worker {worker_id} is not ready for prompt delivery; current status: {}",
|
|
||||||
worker.status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_prompt = prompt
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_owned)
|
|
||||||
.or_else(|| worker.replay_prompt.clone())
|
|
||||||
.ok_or_else(|| format!("worker {worker_id} has no prompt to send or replay"))?;
|
|
||||||
|
|
||||||
worker.prompt_delivery_attempts += 1;
|
|
||||||
worker.last_prompt = Some(next_prompt.clone());
|
|
||||||
worker.replay_prompt = None;
|
|
||||||
worker.last_error = None;
|
|
||||||
worker.status = WorkerStatus::PromptAccepted;
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::PromptAccepted,
|
|
||||||
WorkerStatus::PromptAccepted,
|
|
||||||
Some(format!(
|
|
||||||
"prompt accepted for delivery: {}",
|
|
||||||
prompt_preview(&next_prompt)
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
Ok(worker.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn await_ready(&self, worker_id: &str) -> Result<WorkerReadySnapshot, String> {
|
|
||||||
let worker = self
|
|
||||||
.get(worker_id)
|
|
||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
|
||||||
|
|
||||||
Ok(WorkerReadySnapshot {
|
|
||||||
worker_id: worker.worker_id.clone(),
|
|
||||||
status: worker.status,
|
|
||||||
ready: worker.status == WorkerStatus::ReadyForPrompt,
|
|
||||||
blocked: matches!(
|
|
||||||
worker.status,
|
|
||||||
WorkerStatus::TrustRequired | WorkerStatus::Blocked
|
|
||||||
),
|
|
||||||
replay_prompt_ready: worker.replay_prompt.is_some(),
|
|
||||||
last_error: worker.last_error.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn restart(&self, worker_id: &str) -> Result<Worker, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
|
||||||
let worker = inner
|
|
||||||
.workers
|
|
||||||
.get_mut(worker_id)
|
|
||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
|
||||||
worker.status = WorkerStatus::Spawning;
|
|
||||||
worker.trust_gate_cleared = false;
|
|
||||||
worker.last_prompt = None;
|
|
||||||
worker.replay_prompt = None;
|
|
||||||
worker.last_error = None;
|
|
||||||
worker.prompt_delivery_attempts = 0;
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::Restarted,
|
|
||||||
WorkerStatus::Spawning,
|
|
||||||
Some("worker restarted".to_string()),
|
|
||||||
);
|
|
||||||
Ok(worker.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn terminate(&self, worker_id: &str) -> Result<Worker, String> {
|
|
||||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
|
||||||
let worker = inner
|
|
||||||
.workers
|
|
||||||
.get_mut(worker_id)
|
|
||||||
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
|
|
||||||
worker.status = WorkerStatus::Finished;
|
|
||||||
push_event(
|
|
||||||
worker,
|
|
||||||
WorkerEventKind::Finished,
|
|
||||||
WorkerStatus::Finished,
|
|
||||||
Some("worker terminated by control plane".to_string()),
|
|
||||||
);
|
|
||||||
Ok(worker.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct WorkerReadySnapshot {
|
|
||||||
pub worker_id: String,
|
|
||||||
pub status: WorkerStatus,
|
|
||||||
pub ready: bool,
|
|
||||||
pub blocked: bool,
|
|
||||||
pub replay_prompt_ready: bool,
|
|
||||||
pub last_error: Option<WorkerFailure>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_misdelivery_is_relevant(worker: &Worker) -> bool {
|
|
||||||
matches!(
|
|
||||||
worker.status,
|
|
||||||
WorkerStatus::PromptAccepted | WorkerStatus::Running
|
|
||||||
) && worker.last_prompt.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_event(
|
|
||||||
worker: &mut Worker,
|
|
||||||
kind: WorkerEventKind,
|
|
||||||
status: WorkerStatus,
|
|
||||||
detail: Option<String>,
|
|
||||||
) {
|
|
||||||
let timestamp = now_secs();
|
|
||||||
let seq = worker.events.len() as u64 + 1;
|
|
||||||
worker.updated_at = timestamp;
|
|
||||||
worker.events.push(WorkerEvent {
|
|
||||||
seq,
|
|
||||||
kind,
|
|
||||||
status,
|
|
||||||
detail,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_matches_allowlist(cwd: &str, trusted_root: &str) -> bool {
|
|
||||||
let cwd = normalize_path(cwd);
|
|
||||||
let trusted_root = normalize_path(trusted_root);
|
|
||||||
cwd == trusted_root || cwd.starts_with(&trusted_root)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_path(path: &str) -> PathBuf {
|
|
||||||
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_trust_prompt(lowered: &str) -> bool {
|
|
||||||
[
|
|
||||||
"do you trust the files in this folder",
|
|
||||||
"trust the files in this folder",
|
|
||||||
"trust this folder",
|
|
||||||
"allow and continue",
|
|
||||||
"yes, proceed",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|needle| lowered.contains(needle))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_ready_for_prompt(screen_text: &str, lowered: &str) -> bool {
|
|
||||||
if [
|
|
||||||
"ready for input",
|
|
||||||
"ready for your input",
|
|
||||||
"ready for prompt",
|
|
||||||
"send a message",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|needle| lowered.contains(needle))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(last_non_empty) = screen_text
|
|
||||||
.lines()
|
|
||||||
.rev()
|
|
||||||
.find(|line| !line.trim().is_empty())
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let trimmed = last_non_empty.trim();
|
|
||||||
if is_shell_prompt(trimmed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmed == ">"
|
|
||||||
|| trimmed == "›"
|
|
||||||
|| trimmed == "❯"
|
|
||||||
|| trimmed.starts_with("> ")
|
|
||||||
|| trimmed.starts_with("› ")
|
|
||||||
|| trimmed.starts_with("❯ ")
|
|
||||||
|| trimmed.contains("│ >")
|
|
||||||
|| trimmed.contains("│ ›")
|
|
||||||
|| trimmed.contains("│ ❯")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_running_cue(lowered: &str) -> bool {
|
|
||||||
[
|
|
||||||
"thinking",
|
|
||||||
"working",
|
|
||||||
"running tests",
|
|
||||||
"inspecting",
|
|
||||||
"analyzing",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|needle| lowered.contains(needle))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_shell_prompt(trimmed: &str) -> bool {
|
|
||||||
trimmed.ends_with('$')
|
|
||||||
|| trimmed.ends_with('%')
|
|
||||||
|| trimmed.ends_with('#')
|
|
||||||
|| trimmed.starts_with('$')
|
|
||||||
|| trimmed.starts_with('%')
|
|
||||||
|| trimmed.starts_with('#')
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_prompt_misdelivery(lowered: &str, prompt: Option<&str>) -> bool {
|
|
||||||
let Some(prompt) = prompt else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let shell_error = [
|
|
||||||
"command not found",
|
|
||||||
"syntax error near unexpected token",
|
|
||||||
"parse error near",
|
|
||||||
"no such file or directory",
|
|
||||||
"unknown command",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|needle| lowered.contains(needle));
|
|
||||||
|
|
||||||
if !shell_error {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let first_prompt_line = prompt
|
|
||||||
.lines()
|
|
||||||
.find(|line| !line.trim().is_empty())
|
|
||||||
.map(|line| line.trim().to_ascii_lowercase())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
first_prompt_line.is_empty() || lowered.contains(&first_prompt_line)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_preview(prompt: &str) -> String {
|
|
||||||
let trimmed = prompt.trim();
|
|
||||||
if trimmed.chars().count() <= 48 {
|
|
||||||
return trimmed.to_string();
|
|
||||||
}
|
|
||||||
let preview = trimmed.chars().take(48).collect::<String>();
|
|
||||||
format!("{}…", preview.trim_end())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
|
|
||||||
let registry = WorkerRegistry::new();
|
|
||||||
let worker = registry.create(
|
|
||||||
"/tmp/worktrees/repo-a",
|
|
||||||
&["/tmp/worktrees".to_string()],
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
let after_trust = registry
|
|
||||||
.observe(
|
|
||||||
&worker.worker_id,
|
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
|
||||||
)
|
|
||||||
.expect("trust observe should succeed");
|
|
||||||
assert_eq!(after_trust.status, WorkerStatus::Spawning);
|
|
||||||
assert!(after_trust.trust_gate_cleared);
|
|
||||||
assert!(after_trust
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == WorkerEventKind::TrustRequired));
|
|
||||||
assert!(after_trust
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == WorkerEventKind::TrustResolved));
|
|
||||||
|
|
||||||
let ready = registry
|
|
||||||
.observe(&worker.worker_id, "Ready for your input\n>")
|
|
||||||
.expect("ready observe should succeed");
|
|
||||||
assert_eq!(ready.status, WorkerStatus::ReadyForPrompt);
|
|
||||||
assert!(ready.last_error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trust_prompt_blocks_non_allowlisted_worker_until_resolved() {
|
|
||||||
let registry = WorkerRegistry::new();
|
|
||||||
let worker = registry.create("/tmp/repo-b", &[], true);
|
|
||||||
|
|
||||||
let blocked = registry
|
|
||||||
.observe(
|
|
||||||
&worker.worker_id,
|
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
|
||||||
)
|
|
||||||
.expect("trust observe should succeed");
|
|
||||||
assert_eq!(blocked.status, WorkerStatus::TrustRequired);
|
|
||||||
assert_eq!(
|
|
||||||
blocked.last_error.expect("trust error should exist").kind,
|
|
||||||
WorkerFailureKind::TrustGate
|
|
||||||
);
|
|
||||||
|
|
||||||
let send_before_resolve = registry.send_prompt(&worker.worker_id, Some("ship it"));
|
|
||||||
assert!(send_before_resolve
|
|
||||||
.expect_err("prompt delivery should be gated")
|
|
||||||
.contains("not ready for prompt delivery"));
|
|
||||||
|
|
||||||
let resolved = registry
|
|
||||||
.resolve_trust(&worker.worker_id)
|
|
||||||
.expect("manual trust resolution should succeed");
|
|
||||||
assert_eq!(resolved.status, WorkerStatus::Spawning);
|
|
||||||
assert!(resolved.trust_gate_cleared);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ready_detection_ignores_plain_shell_prompts() {
|
|
||||||
assert!(!detect_ready_for_prompt("bellman@host %", "bellman@host %"));
|
|
||||||
assert!(!detect_ready_for_prompt("/tmp/repo $", "/tmp/repo $"));
|
|
||||||
assert!(detect_ready_for_prompt("│ >", "│ >"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
|
|
||||||
let registry = WorkerRegistry::new();
|
|
||||||
let worker = registry.create("/tmp/repo-c", &[], true);
|
|
||||||
registry
|
|
||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
|
||||||
.expect("ready observe should succeed");
|
|
||||||
|
|
||||||
let accepted = registry
|
|
||||||
.send_prompt(&worker.worker_id, Some("Implement worker handshake"))
|
|
||||||
.expect("prompt send should succeed");
|
|
||||||
assert_eq!(accepted.status, WorkerStatus::PromptAccepted);
|
|
||||||
assert_eq!(accepted.prompt_delivery_attempts, 1);
|
|
||||||
|
|
||||||
let recovered = registry
|
|
||||||
.observe(
|
|
||||||
&worker.worker_id,
|
|
||||||
"% Implement worker handshake\nzsh: command not found: Implement",
|
|
||||||
)
|
|
||||||
.expect("misdelivery observe should succeed");
|
|
||||||
assert_eq!(recovered.status, WorkerStatus::ReadyForPrompt);
|
|
||||||
assert_eq!(
|
|
||||||
recovered
|
|
||||||
.last_error
|
|
||||||
.expect("misdelivery error should exist")
|
|
||||||
.kind,
|
|
||||||
WorkerFailureKind::PromptDelivery
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
recovered.replay_prompt.as_deref(),
|
|
||||||
Some("Implement worker handshake")
|
|
||||||
);
|
|
||||||
assert!(recovered
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == WorkerEventKind::PromptMisdelivery));
|
|
||||||
assert!(recovered
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == WorkerEventKind::PromptReplayArmed));
|
|
||||||
|
|
||||||
let replayed = registry
|
|
||||||
.send_prompt(&worker.worker_id, None)
|
|
||||||
.expect("replay send should succeed");
|
|
||||||
assert_eq!(replayed.status, WorkerStatus::PromptAccepted);
|
|
||||||
assert!(replayed.replay_prompt.is_none());
|
|
||||||
assert_eq!(replayed.prompt_delivery_attempts, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn await_ready_surfaces_blocked_or_ready_worker_state() {
|
|
||||||
let registry = WorkerRegistry::new();
|
|
||||||
let worker = registry.create("/tmp/repo-d", &[], false);
|
|
||||||
|
|
||||||
let initial = registry
|
|
||||||
.await_ready(&worker.worker_id)
|
|
||||||
.expect("await should succeed");
|
|
||||||
assert!(!initial.ready);
|
|
||||||
assert!(!initial.blocked);
|
|
||||||
|
|
||||||
registry
|
|
||||||
.observe(
|
|
||||||
&worker.worker_id,
|
|
||||||
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
|
||||||
)
|
|
||||||
.expect("trust observe should succeed");
|
|
||||||
let blocked = registry
|
|
||||||
.await_ready(&worker.worker_id)
|
|
||||||
.expect("await should succeed");
|
|
||||||
assert!(!blocked.ready);
|
|
||||||
assert!(blocked.blocked);
|
|
||||||
|
|
||||||
registry
|
|
||||||
.resolve_trust(&worker.worker_id)
|
|
||||||
.expect("manual trust resolution should succeed");
|
|
||||||
registry
|
|
||||||
.observe(&worker.worker_id, "Ready for your input\n>")
|
|
||||||
.expect("ready observe should succeed");
|
|
||||||
let ready = registry
|
|
||||||
.await_ready(&worker.worker_id)
|
|
||||||
.expect("await should succeed");
|
|
||||||
assert!(ready.ready);
|
|
||||||
assert!(!ready.blocked);
|
|
||||||
assert!(ready.last_error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restart_and_terminate_reset_or_finish_worker() {
|
|
||||||
let registry = WorkerRegistry::new();
|
|
||||||
let worker = registry.create("/tmp/repo-e", &[], true);
|
|
||||||
registry
|
|
||||||
.observe(&worker.worker_id, "Ready for input\n>")
|
|
||||||
.expect("ready observe should succeed");
|
|
||||||
registry
|
|
||||||
.send_prompt(&worker.worker_id, Some("Run tests"))
|
|
||||||
.expect("prompt send should succeed");
|
|
||||||
|
|
||||||
let restarted = registry
|
|
||||||
.restart(&worker.worker_id)
|
|
||||||
.expect("restart should succeed");
|
|
||||||
assert_eq!(restarted.status, WorkerStatus::Spawning);
|
|
||||||
assert_eq!(restarted.prompt_delivery_attempts, 0);
|
|
||||||
assert!(restarted.last_prompt.is_none());
|
|
||||||
|
|
||||||
let finished = registry
|
|
||||||
.terminate(&worker.worker_id)
|
|
||||||
.expect("terminate should succeed");
|
|
||||||
assert_eq!(finished.status, WorkerStatus::Finished);
|
|
||||||
assert!(finished
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == WorkerEventKind::Finished));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"created_at_ms":1775230717464,"session_id":"session-1775230717464-3","type":"session_meta","updated_at_ms":1775230717464,"version":1}
|
|
||||||
|
|
@ -18,7 +18,6 @@ pulldown-cmark = "0.13"
|
||||||
rustyline = "15"
|
rustyline = "15"
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
plugins = { path = "../plugins" }
|
plugins = { path = "../plugins" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
syntect = "5"
|
syntect = "5"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||||
|
|
@ -26,8 +25,3 @@ tools = { path = "../tools" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
|
||||||
serde_json.workspace = true
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,6 @@ pub enum SlashCommand {
|
||||||
Help,
|
Help,
|
||||||
Status,
|
Status,
|
||||||
Compact,
|
Compact,
|
||||||
Model { model: Option<String> },
|
|
||||||
Permissions { mode: Option<String> },
|
|
||||||
Config { section: Option<String> },
|
|
||||||
Memory,
|
|
||||||
Clear { confirm: bool },
|
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,25 +55,15 @@ impl SlashCommand {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
|
let command = trimmed
|
||||||
let command = parts.next().unwrap_or_default();
|
.trim_start_matches('/')
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default();
|
||||||
Some(match command {
|
Some(match command {
|
||||||
"help" => Self::Help,
|
"help" => Self::Help,
|
||||||
"status" => Self::Status,
|
"status" => Self::Status,
|
||||||
"compact" => Self::Compact,
|
"compact" => Self::Compact,
|
||||||
"model" => Self::Model {
|
|
||||||
model: parts.next().map(ToOwned::to_owned),
|
|
||||||
},
|
|
||||||
"permissions" => Self::Permissions {
|
|
||||||
mode: parts.next().map(ToOwned::to_owned),
|
|
||||||
},
|
|
||||||
"config" => Self::Config {
|
|
||||||
section: parts.next().map(ToOwned::to_owned),
|
|
||||||
},
|
|
||||||
"memory" => Self::Memory,
|
|
||||||
"clear" => Self::Clear {
|
|
||||||
confirm: parts.next() == Some("--confirm"),
|
|
||||||
},
|
|
||||||
other => Self::Unknown(other.to_string()),
|
other => Self::Unknown(other.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -102,26 +87,6 @@ const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
|
||||||
command: SlashCommand::Compact,
|
command: SlashCommand::Compact,
|
||||||
summary: "Compact local session history",
|
summary: "Compact local session history",
|
||||||
},
|
},
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Model { model: None },
|
|
||||||
summary: "Show or switch the active model",
|
|
||||||
},
|
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Permissions { mode: None },
|
|
||||||
summary: "Show or switch the active permission mode",
|
|
||||||
},
|
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Config { section: None },
|
|
||||||
summary: "Inspect current config path or section",
|
|
||||||
},
|
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Memory,
|
|
||||||
summary: "Inspect loaded memory/instruction files",
|
|
||||||
},
|
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Clear { confirm: false },
|
|
||||||
summary: "Start a fresh local session",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct CliApp {
|
pub struct CliApp {
|
||||||
|
|
@ -193,11 +158,6 @@ impl CliApp {
|
||||||
SlashCommand::Help => Self::handle_help(out),
|
SlashCommand::Help => Self::handle_help(out),
|
||||||
SlashCommand::Status => self.handle_status(out),
|
SlashCommand::Status => self.handle_status(out),
|
||||||
SlashCommand::Compact => self.handle_compact(out),
|
SlashCommand::Compact => self.handle_compact(out),
|
||||||
SlashCommand::Model { model } => self.handle_model(model.as_deref(), out),
|
|
||||||
SlashCommand::Permissions { mode } => self.handle_permissions(mode.as_deref(), out),
|
|
||||||
SlashCommand::Config { section } => self.handle_config(section.as_deref(), out),
|
|
||||||
SlashCommand::Memory => self.handle_memory(out),
|
|
||||||
SlashCommand::Clear { confirm } => self.handle_clear(confirm, out),
|
|
||||||
SlashCommand::Unknown(name) => {
|
SlashCommand::Unknown(name) => {
|
||||||
writeln!(out, "Unknown slash command: /{name}")?;
|
writeln!(out, "Unknown slash command: /{name}")?;
|
||||||
Ok(CommandResult::Continue)
|
Ok(CommandResult::Continue)
|
||||||
|
|
@ -212,11 +172,6 @@ impl CliApp {
|
||||||
SlashCommand::Help => "/help",
|
SlashCommand::Help => "/help",
|
||||||
SlashCommand::Status => "/status",
|
SlashCommand::Status => "/status",
|
||||||
SlashCommand::Compact => "/compact",
|
SlashCommand::Compact => "/compact",
|
||||||
SlashCommand::Model { .. } => "/model [model]",
|
|
||||||
SlashCommand::Permissions { .. } => "/permissions [mode]",
|
|
||||||
SlashCommand::Config { .. } => "/config [section]",
|
|
||||||
SlashCommand::Memory => "/memory",
|
|
||||||
SlashCommand::Clear { .. } => "/clear [--confirm]",
|
|
||||||
SlashCommand::Unknown(_) => continue,
|
SlashCommand::Unknown(_) => continue,
|
||||||
};
|
};
|
||||||
writeln!(out, " {name:<9} {}", handler.summary)?;
|
writeln!(out, " {name:<9} {}", handler.summary)?;
|
||||||
|
|
@ -254,102 +209,6 @@ impl CliApp {
|
||||||
Ok(CommandResult::Continue)
|
Ok(CommandResult::Continue)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_model(
|
|
||||||
&mut self,
|
|
||||||
model: Option<&str>,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) -> io::Result<CommandResult> {
|
|
||||||
match model {
|
|
||||||
Some(model) => {
|
|
||||||
self.config.model = model.to_string();
|
|
||||||
self.state.last_model = model.to_string();
|
|
||||||
writeln!(out, "Active model set to {model}")?;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
writeln!(out, "Active model: {}", self.config.model)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_permissions(
|
|
||||||
&mut self,
|
|
||||||
mode: Option<&str>,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) -> io::Result<CommandResult> {
|
|
||||||
match mode {
|
|
||||||
None => writeln!(out, "Permission mode: {:?}", self.config.permission_mode)?,
|
|
||||||
Some("read-only") => {
|
|
||||||
self.config.permission_mode = PermissionMode::ReadOnly;
|
|
||||||
writeln!(out, "Permission mode set to read-only")?;
|
|
||||||
}
|
|
||||||
Some("workspace-write") => {
|
|
||||||
self.config.permission_mode = PermissionMode::WorkspaceWrite;
|
|
||||||
writeln!(out, "Permission mode set to workspace-write")?;
|
|
||||||
}
|
|
||||||
Some("danger-full-access") => {
|
|
||||||
self.config.permission_mode = PermissionMode::DangerFullAccess;
|
|
||||||
writeln!(out, "Permission mode set to danger-full-access")?;
|
|
||||||
}
|
|
||||||
Some(other) => {
|
|
||||||
writeln!(out, "Unknown permission mode: {other}")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_config(
|
|
||||||
&mut self,
|
|
||||||
section: Option<&str>,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) -> io::Result<CommandResult> {
|
|
||||||
match section {
|
|
||||||
None => writeln!(
|
|
||||||
out,
|
|
||||||
"Config path: {}",
|
|
||||||
self.config
|
|
||||||
.config
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
|
|
||||||
)?,
|
|
||||||
Some(section) => writeln!(
|
|
||||||
out,
|
|
||||||
"Config section `{section}` is not fully implemented yet; current config path is {}",
|
|
||||||
self.config
|
|
||||||
.config
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
|
|
||||||
)?,
|
|
||||||
}
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_memory(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"Loaded memory/config file: {}",
|
|
||||||
self.config
|
|
||||||
.config
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
|
|
||||||
)?;
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_clear(&mut self, confirm: bool, out: &mut impl Write) -> io::Result<CommandResult> {
|
|
||||||
if !confirm {
|
|
||||||
writeln!(out, "Refusing to clear without confirmation. Re-run as /clear --confirm")?;
|
|
||||||
return Ok(CommandResult::Continue);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.state.turns = 0;
|
|
||||||
self.state.compacted_messages = 0;
|
|
||||||
self.state.last_usage = UsageSummary::default();
|
|
||||||
self.conversation_history.clear();
|
|
||||||
writeln!(out, "Started a fresh local session.")?;
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_stream_event(
|
fn handle_stream_event(
|
||||||
renderer: &TerminalRenderer,
|
renderer: &TerminalRenderer,
|
||||||
event: StreamEvent,
|
event: StreamEvent,
|
||||||
|
|
@ -510,29 +369,6 @@ mod tests {
|
||||||
SlashCommand::parse("/compact now"),
|
SlashCommand::parse("/compact now"),
|
||||||
Some(SlashCommand::Compact)
|
Some(SlashCommand::Compact)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
SlashCommand::parse("/model claude-sonnet"),
|
|
||||||
Some(SlashCommand::Model {
|
|
||||||
model: Some("claude-sonnet".into()),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
SlashCommand::parse("/permissions workspace-write"),
|
|
||||||
Some(SlashCommand::Permissions {
|
|
||||||
mode: Some("workspace-write".into()),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
SlashCommand::parse("/config hooks"),
|
|
||||||
Some(SlashCommand::Config {
|
|
||||||
section: Some("hooks".into()),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
|
||||||
assert_eq!(
|
|
||||||
SlashCommand::parse("/clear --confirm"),
|
|
||||||
Some(SlashCommand::Clear { confirm: true })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -544,11 +380,6 @@ mod tests {
|
||||||
assert!(output.contains("/help"));
|
assert!(output.contains("/help"));
|
||||||
assert!(output.contains("/status"));
|
assert!(output.contains("/status"));
|
||||||
assert!(output.contains("/compact"));
|
assert!(output.contains("/compact"));
|
||||||
assert!(output.contains("/model [model]"));
|
|
||||||
assert!(output.contains("/permissions [mode]"));
|
|
||||||
assert!(output.contains("/config [section]"));
|
|
||||||
assert!(output.contains("/memory"));
|
|
||||||
assert!(output.contains("/clear [--confirm]"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::BTreeSet;
|
|
||||||
use std::io::{self, IsTerminal, Write};
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
|
||||||
use rustyline::completion::{Completer, Pair};
|
use rustyline::completion::{Completer, Pair};
|
||||||
|
|
@ -28,7 +27,7 @@ struct SlashCommandHelper {
|
||||||
impl SlashCommandHelper {
|
impl SlashCommandHelper {
|
||||||
fn new(completions: Vec<String>) -> Self {
|
fn new(completions: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
completions: normalize_completions(completions),
|
completions,
|
||||||
current_line: RefCell::new(String::new()),
|
current_line: RefCell::new(String::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,10 +45,6 @@ impl SlashCommandHelper {
|
||||||
current.clear();
|
current.clear();
|
||||||
current.push_str(line);
|
current.push_str(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_completions(&mut self, completions: Vec<String>) {
|
|
||||||
self.completions = normalize_completions(completions);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Completer for SlashCommandHelper {
|
impl Completer for SlashCommandHelper {
|
||||||
|
|
@ -131,12 +126,6 @@ impl LineEditor {
|
||||||
let _ = self.editor.add_history_entry(entry);
|
let _ = self.editor.add_history_entry(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_completions(&mut self, completions: Vec<String>) {
|
|
||||||
if let Some(helper) = self.editor.helper_mut() {
|
|
||||||
helper.set_completions(completions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
|
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
|
||||||
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||||
return self.read_line_fallback();
|
return self.read_line_fallback();
|
||||||
|
|
@ -203,22 +192,13 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let prefix = &line[..pos];
|
let prefix = &line[..pos];
|
||||||
if !prefix.starts_with('/') {
|
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(prefix)
|
Some(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_completions(completions: Vec<String>) -> Vec<String> {
|
|
||||||
let mut seen = BTreeSet::new();
|
|
||||||
completions
|
|
||||||
.into_iter()
|
|
||||||
.filter(|candidate| candidate.starts_with('/'))
|
|
||||||
.filter(|candidate| seen.insert(candidate.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
|
use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
|
||||||
|
|
@ -228,13 +208,9 @@ mod tests {
|
||||||
use rustyline::Context;
|
use rustyline::Context;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_terminal_slash_command_prefixes_with_arguments() {
|
fn extracts_only_terminal_slash_command_prefixes() {
|
||||||
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
|
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
|
||||||
assert_eq!(slash_command_prefix("/help me", 8), Some("/help me"));
|
assert_eq!(slash_command_prefix("/help me", 5), None);
|
||||||
assert_eq!(
|
|
||||||
slash_command_prefix("/session switch ses", 19),
|
|
||||||
Some("/session switch ses")
|
|
||||||
);
|
|
||||||
assert_eq!(slash_command_prefix("hello", 5), None);
|
assert_eq!(slash_command_prefix("hello", 5), None);
|
||||||
assert_eq!(slash_command_prefix("/help", 2), None);
|
assert_eq!(slash_command_prefix("/help", 2), None);
|
||||||
}
|
}
|
||||||
|
|
@ -262,30 +238,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn completes_matching_slash_command_arguments() {
|
|
||||||
let helper = SlashCommandHelper::new(vec![
|
|
||||||
"/model".to_string(),
|
|
||||||
"/model opus".to_string(),
|
|
||||||
"/model sonnet".to_string(),
|
|
||||||
"/session switch alpha".to_string(),
|
|
||||||
]);
|
|
||||||
let history = DefaultHistory::new();
|
|
||||||
let ctx = Context::new(&history);
|
|
||||||
let (start, matches) = helper
|
|
||||||
.complete("/model o", 8, &ctx)
|
|
||||||
.expect("completion should work");
|
|
||||||
|
|
||||||
assert_eq!(start, 0);
|
|
||||||
assert_eq!(
|
|
||||||
matches
|
|
||||||
.into_iter()
|
|
||||||
.map(|candidate| candidate.replacement)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec!["/model opus".to_string()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_non_slash_command_completion_requests() {
|
fn ignores_non_slash_command_completion_requests() {
|
||||||
let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
|
let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
|
||||||
|
|
@ -314,17 +266,4 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(editor.editor.history().len(), 1);
|
assert_eq!(editor.editor.history().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_completions_replaces_and_normalizes_candidates() {
|
|
||||||
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
|
|
||||||
editor.set_completions(vec![
|
|
||||||
"/model opus".to_string(),
|
|
||||||
"/model opus".to_string(),
|
|
||||||
"status".to_string(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let helper = editor.editor.helper().expect("helper should exist");
|
|
||||||
assert_eq!(helper.completions, vec!["/model opus".to_string()]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,200 +0,0 @@
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::{Command, Output};
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use runtime::Session;
|
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_command_applies_model_and_permission_mode_flags() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("status-flags");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
|
||||||
.current_dir(&temp_dir)
|
|
||||||
.args([
|
|
||||||
"--model",
|
|
||||||
"sonnet",
|
|
||||||
"--permission-mode",
|
|
||||||
"read-only",
|
|
||||||
"status",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.expect("claw should launch");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_success(&output);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(stdout.contains("Status"));
|
|
||||||
assert!(stdout.contains("Model claude-sonnet-4-6"));
|
|
||||||
assert!(stdout.contains("Permission mode read-only"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resume_flag_loads_a_saved_session_and_dispatches_status() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("resume-status");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
let session_path = write_session(&temp_dir, "resume-status");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
|
||||||
.current_dir(&temp_dir)
|
|
||||||
.args([
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/status",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.expect("claw should launch");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_success(&output);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(stdout.contains("Status"));
|
|
||||||
assert!(stdout.contains("Messages 1"));
|
|
||||||
assert!(stdout.contains("Session "));
|
|
||||||
assert!(stdout.contains(session_path.to_str().expect("utf8 path")));
|
|
||||||
|
|
||||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("slash-dispatch");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let help_output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
|
||||||
.current_dir(&temp_dir)
|
|
||||||
.arg("/help")
|
|
||||||
.output()
|
|
||||||
.expect("claw should launch");
|
|
||||||
let unknown_output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
|
||||||
.current_dir(&temp_dir)
|
|
||||||
.arg("/zstats")
|
|
||||||
.output()
|
|
||||||
.expect("claw should launch");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_success(&help_output);
|
|
||||||
let help_stdout = String::from_utf8(help_output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(help_stdout.contains("Interactive slash commands:"));
|
|
||||||
assert!(help_stdout.contains("/status"));
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!unknown_output.status.success(),
|
|
||||||
"stdout:\n{}\n\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&unknown_output.stdout),
|
|
||||||
String::from_utf8_lossy(&unknown_output.stderr)
|
|
||||||
);
|
|
||||||
let stderr = String::from_utf8(unknown_output.stderr).expect("stderr should be utf8");
|
|
||||||
assert!(stderr.contains("unknown slash command outside the REPL: /zstats"));
|
|
||||||
assert!(stderr.contains("Did you mean"));
|
|
||||||
assert!(stderr.contains("/status"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_command_loads_defaults_from_standard_config_locations() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("config-defaults");
|
|
||||||
let config_home = temp_dir.join("home").join(".claw");
|
|
||||||
fs::create_dir_all(temp_dir.join(".claw")).expect("project config dir should exist");
|
|
||||||
fs::create_dir_all(&config_home).expect("home config dir should exist");
|
|
||||||
|
|
||||||
fs::write(config_home.join("settings.json"), r#"{"model":"haiku"}"#)
|
|
||||||
.expect("write user settings");
|
|
||||||
fs::write(temp_dir.join(".claw.json"), r#"{"model":"sonnet"}"#)
|
|
||||||
.expect("write project settings");
|
|
||||||
fs::write(
|
|
||||||
temp_dir.join(".claw").join("settings.local.json"),
|
|
||||||
r#"{"model":"opus"}"#,
|
|
||||||
)
|
|
||||||
.expect("write local settings");
|
|
||||||
let session_path = write_session(&temp_dir, "config-defaults");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = command_in(&temp_dir)
|
|
||||||
.env("CLAW_CONFIG_HOME", &config_home)
|
|
||||||
.args([
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/config",
|
|
||||||
"model",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.expect("claw should launch");
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert_success(&output);
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(stdout.contains("Config"));
|
|
||||||
assert!(stdout.contains("Loaded files 3"));
|
|
||||||
assert!(stdout.contains("Merged section: model"));
|
|
||||||
assert!(stdout.contains("opus"));
|
|
||||||
assert!(stdout.contains(
|
|
||||||
config_home
|
|
||||||
.join("settings.json")
|
|
||||||
.to_str()
|
|
||||||
.expect("utf8 path")
|
|
||||||
));
|
|
||||||
assert!(stdout.contains(temp_dir.join(".claw.json").to_str().expect("utf8 path")));
|
|
||||||
assert!(stdout.contains(
|
|
||||||
temp_dir
|
|
||||||
.join(".claw")
|
|
||||||
.join("settings.local.json")
|
|
||||||
.to_str()
|
|
||||||
.expect("utf8 path")
|
|
||||||
));
|
|
||||||
|
|
||||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn command_in(cwd: &Path) -> Command {
|
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
|
||||||
command.current_dir(cwd);
|
|
||||||
command
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_session(root: &Path, label: &str) -> PathBuf {
|
|
||||||
let session_path = root.join(format!("{label}.jsonl"));
|
|
||||||
let mut session = Session::new();
|
|
||||||
session
|
|
||||||
.push_user_text(format!("session fixture for {label}"))
|
|
||||||
.expect("session write should succeed");
|
|
||||||
session
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("session should persist");
|
|
||||||
session_path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_success(output: &Output) {
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stdout:\n{}\n\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
|
||||||
let millis = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("clock should be after epoch")
|
|
||||||
.as_millis();
|
|
||||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"claw-{label}-{}-{millis}-{counter}",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -1,877 +0,0 @@
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::{Command, Output, Stdio};
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn clean_env_cli_reaches_mock_anthropic_service_across_scripted_parity_scenarios() {
|
|
||||||
let manifest_entries = load_scenario_manifest();
|
|
||||||
let manifest = manifest_entries
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(|entry| (entry.name.clone(), entry))
|
|
||||||
.collect::<BTreeMap<_, _>>();
|
|
||||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
|
||||||
let server = runtime
|
|
||||||
.block_on(MockAnthropicService::spawn())
|
|
||||||
.expect("mock service should start");
|
|
||||||
let base_url = server.base_url();
|
|
||||||
|
|
||||||
let cases = [
|
|
||||||
ScenarioCase {
|
|
||||||
name: "streaming_text",
|
|
||||||
permission_mode: "read-only",
|
|
||||||
allowed_tools: None,
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_streaming_text,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "read_file_roundtrip",
|
|
||||||
permission_mode: "read-only",
|
|
||||||
allowed_tools: Some("read_file"),
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_read_fixture,
|
|
||||||
assert: assert_read_file_roundtrip,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "grep_chunk_assembly",
|
|
||||||
permission_mode: "read-only",
|
|
||||||
allowed_tools: Some("grep_search"),
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_grep_fixture,
|
|
||||||
assert: assert_grep_chunk_assembly,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "write_file_allowed",
|
|
||||||
permission_mode: "workspace-write",
|
|
||||||
allowed_tools: Some("write_file"),
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_write_file_allowed,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "write_file_denied",
|
|
||||||
permission_mode: "read-only",
|
|
||||||
allowed_tools: Some("write_file"),
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_write_file_denied,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "multi_tool_turn_roundtrip",
|
|
||||||
permission_mode: "read-only",
|
|
||||||
allowed_tools: Some("read_file,grep_search"),
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_multi_tool_fixture,
|
|
||||||
assert: assert_multi_tool_turn_roundtrip,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "bash_stdout_roundtrip",
|
|
||||||
permission_mode: "danger-full-access",
|
|
||||||
allowed_tools: Some("bash"),
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_bash_stdout_roundtrip,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "bash_permission_prompt_approved",
|
|
||||||
permission_mode: "workspace-write",
|
|
||||||
allowed_tools: Some("bash"),
|
|
||||||
stdin: Some("y\n"),
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_bash_permission_prompt_approved,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "bash_permission_prompt_denied",
|
|
||||||
permission_mode: "workspace-write",
|
|
||||||
allowed_tools: Some("bash"),
|
|
||||||
stdin: Some("n\n"),
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_bash_permission_prompt_denied,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "plugin_tool_roundtrip",
|
|
||||||
permission_mode: "workspace-write",
|
|
||||||
allowed_tools: None,
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_plugin_fixture,
|
|
||||||
assert: assert_plugin_tool_roundtrip,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "auto_compact_triggered",
|
|
||||||
permission_mode: "read-only",
|
|
||||||
allowed_tools: None,
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_auto_compact_triggered,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
ScenarioCase {
|
|
||||||
name: "token_cost_reporting",
|
|
||||||
permission_mode: "read-only",
|
|
||||||
allowed_tools: None,
|
|
||||||
stdin: None,
|
|
||||||
prepare: prepare_noop,
|
|
||||||
assert: assert_token_cost_reporting,
|
|
||||||
extra_env: None,
|
|
||||||
resume_session: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let case_names = cases.iter().map(|case| case.name).collect::<Vec<_>>();
|
|
||||||
let manifest_names = manifest_entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| entry.name.as_str())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(
|
|
||||||
case_names, manifest_names,
|
|
||||||
"manifest and harness cases must stay aligned"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut scenario_reports = Vec::new();
|
|
||||||
|
|
||||||
for case in cases {
|
|
||||||
let workspace = HarnessWorkspace::new(unique_temp_dir(case.name));
|
|
||||||
workspace.create().expect("workspace should exist");
|
|
||||||
(case.prepare)(&workspace);
|
|
||||||
|
|
||||||
let run = run_case(case, &workspace, &base_url);
|
|
||||||
(case.assert)(&workspace, &run);
|
|
||||||
|
|
||||||
let manifest_entry = manifest
|
|
||||||
.get(case.name)
|
|
||||||
.unwrap_or_else(|| panic!("missing manifest entry for {}", case.name));
|
|
||||||
scenario_reports.push(build_scenario_report(
|
|
||||||
case.name,
|
|
||||||
manifest_entry,
|
|
||||||
&run.response,
|
|
||||||
));
|
|
||||||
|
|
||||||
fs::remove_dir_all(&workspace.root).expect("workspace cleanup should succeed");
|
|
||||||
}
|
|
||||||
|
|
||||||
let captured = runtime.block_on(server.captured_requests());
|
|
||||||
assert_eq!(
|
|
||||||
captured.len(),
|
|
||||||
21,
|
|
||||||
"twelve scenarios should produce twenty-one requests"
|
|
||||||
);
|
|
||||||
assert!(captured
|
|
||||||
.iter()
|
|
||||||
.all(|request| request.path == "/v1/messages"));
|
|
||||||
assert!(captured.iter().all(|request| request.stream));
|
|
||||||
|
|
||||||
let scenarios = captured
|
|
||||||
.iter()
|
|
||||||
.map(|request| request.scenario.as_str())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(
|
|
||||||
scenarios,
|
|
||||||
vec![
|
|
||||||
"streaming_text",
|
|
||||||
"read_file_roundtrip",
|
|
||||||
"read_file_roundtrip",
|
|
||||||
"grep_chunk_assembly",
|
|
||||||
"grep_chunk_assembly",
|
|
||||||
"write_file_allowed",
|
|
||||||
"write_file_allowed",
|
|
||||||
"write_file_denied",
|
|
||||||
"write_file_denied",
|
|
||||||
"multi_tool_turn_roundtrip",
|
|
||||||
"multi_tool_turn_roundtrip",
|
|
||||||
"bash_stdout_roundtrip",
|
|
||||||
"bash_stdout_roundtrip",
|
|
||||||
"bash_permission_prompt_approved",
|
|
||||||
"bash_permission_prompt_approved",
|
|
||||||
"bash_permission_prompt_denied",
|
|
||||||
"bash_permission_prompt_denied",
|
|
||||||
"plugin_tool_roundtrip",
|
|
||||||
"plugin_tool_roundtrip",
|
|
||||||
"auto_compact_triggered",
|
|
||||||
"token_cost_reporting",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut request_counts = BTreeMap::new();
|
|
||||||
for request in &captured {
|
|
||||||
*request_counts
|
|
||||||
.entry(request.scenario.as_str())
|
|
||||||
.or_insert(0_usize) += 1;
|
|
||||||
}
|
|
||||||
for report in &mut scenario_reports {
|
|
||||||
report.request_count = *request_counts
|
|
||||||
.get(report.name.as_str())
|
|
||||||
.unwrap_or_else(|| panic!("missing request count for {}", report.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
maybe_write_report(&scenario_reports);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct ScenarioCase {
|
|
||||||
name: &'static str,
|
|
||||||
permission_mode: &'static str,
|
|
||||||
allowed_tools: Option<&'static str>,
|
|
||||||
stdin: Option<&'static str>,
|
|
||||||
prepare: fn(&HarnessWorkspace),
|
|
||||||
assert: fn(&HarnessWorkspace, &ScenarioRun),
|
|
||||||
extra_env: Option<(&'static str, &'static str)>,
|
|
||||||
resume_session: Option<&'static str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HarnessWorkspace {
|
|
||||||
root: PathBuf,
|
|
||||||
config_home: PathBuf,
|
|
||||||
home: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HarnessWorkspace {
|
|
||||||
fn new(root: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
config_home: root.join("config-home"),
|
|
||||||
home: root.join("home"),
|
|
||||||
root,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&self) -> std::io::Result<()> {
|
|
||||||
fs::create_dir_all(&self.root)?;
|
|
||||||
fs::create_dir_all(&self.config_home)?;
|
|
||||||
fs::create_dir_all(&self.home)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScenarioRun {
|
|
||||||
response: Value,
|
|
||||||
stdout: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct ScenarioManifestEntry {
|
|
||||||
name: String,
|
|
||||||
category: String,
|
|
||||||
description: String,
|
|
||||||
parity_refs: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ScenarioReport {
|
|
||||||
name: String,
|
|
||||||
category: String,
|
|
||||||
description: String,
|
|
||||||
parity_refs: Vec<String>,
|
|
||||||
iterations: u64,
|
|
||||||
request_count: usize,
|
|
||||||
tool_uses: Vec<String>,
|
|
||||||
tool_error_count: usize,
|
|
||||||
final_message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_case(case: ScenarioCase, workspace: &HarnessWorkspace, base_url: &str) -> ScenarioRun {
|
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
|
||||||
command
|
|
||||||
.current_dir(&workspace.root)
|
|
||||||
.env_clear()
|
|
||||||
.env("ANTHROPIC_API_KEY", "test-parity-key")
|
|
||||||
.env("ANTHROPIC_BASE_URL", base_url)
|
|
||||||
.env("CLAW_CONFIG_HOME", &workspace.config_home)
|
|
||||||
.env("HOME", &workspace.home)
|
|
||||||
.env("NO_COLOR", "1")
|
|
||||||
.env("PATH", "/usr/bin:/bin")
|
|
||||||
.args([
|
|
||||||
"--model",
|
|
||||||
"sonnet",
|
|
||||||
"--permission-mode",
|
|
||||||
case.permission_mode,
|
|
||||||
"--output-format=json",
|
|
||||||
]);
|
|
||||||
|
|
||||||
if let Some(allowed_tools) = case.allowed_tools {
|
|
||||||
command.args(["--allowedTools", allowed_tools]);
|
|
||||||
}
|
|
||||||
if let Some((key, value)) = case.extra_env {
|
|
||||||
command.env(key, value);
|
|
||||||
}
|
|
||||||
if let Some(session_id) = case.resume_session {
|
|
||||||
command.args(["--resume", session_id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prompt = format!("{SCENARIO_PREFIX}{}", case.name);
|
|
||||||
command.arg(prompt);
|
|
||||||
|
|
||||||
let output = if let Some(stdin) = case.stdin {
|
|
||||||
let mut child = command
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.expect("claw should launch");
|
|
||||||
child
|
|
||||||
.stdin
|
|
||||||
.as_mut()
|
|
||||||
.expect("stdin should be piped")
|
|
||||||
.write_all(stdin.as_bytes())
|
|
||||||
.expect("stdin should write");
|
|
||||||
child.wait_with_output().expect("claw should finish")
|
|
||||||
} else {
|
|
||||||
command.output().expect("claw should launch")
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_success(&output);
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
||||||
ScenarioRun {
|
|
||||||
response: parse_json_output(&stdout),
|
|
||||||
stdout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn prepare_auto_compact_fixture(workspace: &HarnessWorkspace) {
|
|
||||||
let sessions_dir = workspace.root.join(".claw").join("sessions");
|
|
||||||
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
|
||||||
|
|
||||||
// Write a pre-seeded session with 6 messages so auto-compact can remove them
|
|
||||||
let session_id = "parity-auto-compact-seed";
|
|
||||||
let session_jsonl = r#"{"type":"session_meta","version":3,"session_id":"parity-auto-compact-seed","created_at_ms":1743724800000,"updated_at_ms":1743724800000}
|
|
||||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step one of the parity scenario"}]}}
|
|
||||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step one"}]}}
|
|
||||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step two of the parity scenario"}]}}
|
|
||||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step two"}]}}
|
|
||||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step three of the parity scenario"}]}}
|
|
||||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step three"}]}}
|
|
||||||
"#;
|
|
||||||
fs::write(
|
|
||||||
sessions_dir.join(format!("{session_id}.jsonl")),
|
|
||||||
session_jsonl,
|
|
||||||
)
|
|
||||||
.expect("pre-seeded session should write");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_noop(_: &HarnessWorkspace) {}
|
|
||||||
|
|
||||||
fn prepare_read_fixture(workspace: &HarnessWorkspace) {
|
|
||||||
fs::write(workspace.root.join("fixture.txt"), "alpha parity line\n")
|
|
||||||
.expect("fixture should write");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_grep_fixture(workspace: &HarnessWorkspace) {
|
|
||||||
fs::write(
|
|
||||||
workspace.root.join("fixture.txt"),
|
|
||||||
"alpha parity line\nbeta line\ngamma parity line\n",
|
|
||||||
)
|
|
||||||
.expect("grep fixture should write");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_multi_tool_fixture(workspace: &HarnessWorkspace) {
|
|
||||||
fs::write(
|
|
||||||
workspace.root.join("fixture.txt"),
|
|
||||||
"alpha parity line\nbeta line\ngamma parity line\n",
|
|
||||||
)
|
|
||||||
.expect("multi tool fixture should write");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
|
|
||||||
let plugin_root = workspace
|
|
||||||
.root
|
|
||||||
.join("external-plugins")
|
|
||||||
.join("parity-plugin");
|
|
||||||
let tool_dir = plugin_root.join("tools");
|
|
||||||
let manifest_dir = plugin_root.join(".claude-plugin");
|
|
||||||
fs::create_dir_all(&tool_dir).expect("plugin tools dir");
|
|
||||||
fs::create_dir_all(&manifest_dir).expect("plugin manifest dir");
|
|
||||||
|
|
||||||
let script_path = tool_dir.join("echo-json.sh");
|
|
||||||
fs::write(
|
|
||||||
&script_path,
|
|
||||||
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
|
||||||
)
|
|
||||||
.expect("plugin script should write");
|
|
||||||
let mut permissions = fs::metadata(&script_path)
|
|
||||||
.expect("plugin script metadata")
|
|
||||||
.permissions();
|
|
||||||
permissions.set_mode(0o755);
|
|
||||||
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
|
|
||||||
|
|
||||||
fs::write(
|
|
||||||
manifest_dir.join("plugin.json"),
|
|
||||||
r#"{
|
|
||||||
"name": "parity-plugin",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "mock parity plugin",
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"name": "plugin_echo",
|
|
||||||
"description": "Echo JSON input",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": { "type": "string" }
|
|
||||||
},
|
|
||||||
"required": ["message"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"command": "./tools/echo-json.sh",
|
|
||||||
"requiredPermission": "workspace-write"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}"#,
|
|
||||||
)
|
|
||||||
.expect("plugin manifest should write");
|
|
||||||
|
|
||||||
fs::write(
|
|
||||||
workspace.config_home.join("settings.json"),
|
|
||||||
json!({
|
|
||||||
"enabledPlugins": {
|
|
||||||
"parity-plugin@external": true
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"externalDirectories": [plugin_root.parent().expect("plugin parent").display().to_string()]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
.expect("plugin settings should write");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_streaming_text(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(
|
|
||||||
run.response["message"],
|
|
||||||
Value::String("Mock streaming says hello from the parity harness.".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(1));
|
|
||||||
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
|
|
||||||
assert_eq!(run.response["tool_results"], Value::Array(Vec::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_read_file_roundtrip(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["name"],
|
|
||||||
Value::String("read_file".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["input"],
|
|
||||||
Value::String(r#"{"path":"fixture.txt"}"#.to_string())
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("alpha parity line"));
|
|
||||||
let output = run.response["tool_results"][0]["output"]
|
|
||||||
.as_str()
|
|
||||||
.expect("tool output");
|
|
||||||
assert!(output.contains(&workspace.root.join("fixture.txt").display().to_string()));
|
|
||||||
assert!(output.contains("alpha parity line"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_grep_chunk_assembly(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["name"],
|
|
||||||
Value::String("grep_search".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["input"],
|
|
||||||
Value::String(
|
|
||||||
r#"{"pattern":"parity","path":"fixture.txt","output_mode":"count"}"#.to_string()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("2 occurrences"));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_results"][0]["is_error"],
|
|
||||||
Value::Bool(false)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_write_file_allowed(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["name"],
|
|
||||||
Value::String("write_file".to_string())
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("generated/output.txt"));
|
|
||||||
let generated = workspace.root.join("generated").join("output.txt");
|
|
||||||
let contents = fs::read_to_string(&generated).expect("generated file should exist");
|
|
||||||
assert_eq!(contents, "created by mock service\n");
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_results"][0]["is_error"],
|
|
||||||
Value::Bool(false)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_write_file_denied(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["name"],
|
|
||||||
Value::String("write_file".to_string())
|
|
||||||
);
|
|
||||||
let tool_output = run.response["tool_results"][0]["output"]
|
|
||||||
.as_str()
|
|
||||||
.expect("tool output");
|
|
||||||
assert!(tool_output.contains("requires workspace-write permission"));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_results"][0]["is_error"],
|
|
||||||
Value::Bool(true)
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("denied as expected"));
|
|
||||||
assert!(!workspace.root.join("generated").join("denied.txt").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_multi_tool_turn_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
let tool_uses = run.response["tool_uses"]
|
|
||||||
.as_array()
|
|
||||||
.expect("tool uses array");
|
|
||||||
assert_eq!(
|
|
||||||
tool_uses.len(),
|
|
||||||
2,
|
|
||||||
"expected two tool uses in a single turn"
|
|
||||||
);
|
|
||||||
assert_eq!(tool_uses[0]["name"], Value::String("read_file".to_string()));
|
|
||||||
assert_eq!(
|
|
||||||
tool_uses[1]["name"],
|
|
||||||
Value::String("grep_search".to_string())
|
|
||||||
);
|
|
||||||
let tool_results = run.response["tool_results"]
|
|
||||||
.as_array()
|
|
||||||
.expect("tool results array");
|
|
||||||
assert_eq!(
|
|
||||||
tool_results.len(),
|
|
||||||
2,
|
|
||||||
"expected two tool results in a single turn"
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("alpha parity line"));
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("2 occurrences"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_bash_stdout_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["name"],
|
|
||||||
Value::String("bash".to_string())
|
|
||||||
);
|
|
||||||
let tool_output = run.response["tool_results"][0]["output"]
|
|
||||||
.as_str()
|
|
||||||
.expect("tool output");
|
|
||||||
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
|
|
||||||
assert_eq!(
|
|
||||||
parsed["stdout"],
|
|
||||||
Value::String("alpha from bash".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_results"][0]["is_error"],
|
|
||||||
Value::Bool(false)
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("alpha from bash"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_bash_permission_prompt_approved(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert!(run.stdout.contains("Permission approval required"));
|
|
||||||
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_results"][0]["is_error"],
|
|
||||||
Value::Bool(false)
|
|
||||||
);
|
|
||||||
let tool_output = run.response["tool_results"][0]["output"]
|
|
||||||
.as_str()
|
|
||||||
.expect("tool output");
|
|
||||||
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
|
|
||||||
assert_eq!(
|
|
||||||
parsed["stdout"],
|
|
||||||
Value::String("approved via prompt".to_string())
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("approved and executed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_bash_permission_prompt_denied(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert!(run.stdout.contains("Permission approval required"));
|
|
||||||
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
let tool_output = run.response["tool_results"][0]["output"]
|
|
||||||
.as_str()
|
|
||||||
.expect("tool output");
|
|
||||||
assert!(tool_output.contains("denied by user approval prompt"));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_results"][0]["is_error"],
|
|
||||||
Value::Bool(true)
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("denied as expected"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_plugin_tool_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(2));
|
|
||||||
assert_eq!(
|
|
||||||
run.response["tool_uses"][0]["name"],
|
|
||||||
Value::String("plugin_echo".to_string())
|
|
||||||
);
|
|
||||||
let tool_output = run.response["tool_results"][0]["output"]
|
|
||||||
.as_str()
|
|
||||||
.expect("tool output");
|
|
||||||
let parsed: Value = serde_json::from_str(tool_output).expect("plugin output json");
|
|
||||||
assert_eq!(
|
|
||||||
parsed["plugin"],
|
|
||||||
Value::String("parity-plugin@external".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(parsed["tool"], Value::String("plugin_echo".to_string()));
|
|
||||||
assert_eq!(
|
|
||||||
parsed["input"]["message"],
|
|
||||||
Value::String("hello from plugin parity".to_string())
|
|
||||||
);
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("hello from plugin parity"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_auto_compact_triggered(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
// Validates that the auto_compaction field is present in JSON output (format parity).
|
|
||||||
// Trigger behavior is covered by conversation::tests::auto_compacts_when_cumulative_input_threshold_is_crossed.
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(1));
|
|
||||||
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
|
|
||||||
assert!(
|
|
||||||
run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("auto compact parity complete."),
|
|
||||||
"expected auto compact message in response"
|
|
||||||
);
|
|
||||||
// auto_compaction key must be present in JSON (may be null for below-threshold sessions)
|
|
||||||
assert!(
|
|
||||||
run.response
|
|
||||||
.as_object()
|
|
||||||
.expect("response object")
|
|
||||||
.contains_key("auto_compaction"),
|
|
||||||
"auto_compaction key must be present in JSON output"
|
|
||||||
);
|
|
||||||
// Verify input_tokens field reflects the large mock token counts
|
|
||||||
let input_tokens = run.response["usage"]["input_tokens"]
|
|
||||||
.as_u64()
|
|
||||||
.expect("input_tokens should be present");
|
|
||||||
assert!(
|
|
||||||
input_tokens >= 50_000,
|
|
||||||
"input_tokens should reflect mock service value (got {input_tokens})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_token_cost_reporting(_: &HarnessWorkspace, run: &ScenarioRun) {
|
|
||||||
assert_eq!(run.response["iterations"], Value::from(1));
|
|
||||||
assert!(run.response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.contains("token cost reporting parity complete."),);
|
|
||||||
let usage = &run.response["usage"];
|
|
||||||
assert!(
|
|
||||||
usage["input_tokens"].as_u64().unwrap_or(0) > 0,
|
|
||||||
"input_tokens should be non-zero"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
usage["output_tokens"].as_u64().unwrap_or(0) > 0,
|
|
||||||
"output_tokens should be non-zero"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
run.response["estimated_cost"]
|
|
||||||
.as_str()
|
|
||||||
.map(|cost| cost.starts_with('$'))
|
|
||||||
.unwrap_or(false),
|
|
||||||
"estimated_cost should be a dollar-prefixed string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_json_output(stdout: &str) -> Value {
|
|
||||||
if let Some(index) = stdout.rfind("{\"auto_compaction\"") {
|
|
||||||
return serde_json::from_str(&stdout[index..]).unwrap_or_else(|error| {
|
|
||||||
panic!("failed to parse JSON response from stdout: {error}\n{stdout}")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout
|
|
||||||
.lines()
|
|
||||||
.rev()
|
|
||||||
.find_map(|line| {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if trimmed.starts_with('{') && trimmed.ends_with('}') {
|
|
||||||
serde_json::from_str(trimmed).ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| panic!("no JSON response line found in stdout:\n{stdout}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_scenario_report(
|
|
||||||
name: &str,
|
|
||||||
manifest_entry: &ScenarioManifestEntry,
|
|
||||||
response: &Value,
|
|
||||||
) -> ScenarioReport {
|
|
||||||
ScenarioReport {
|
|
||||||
name: name.to_string(),
|
|
||||||
category: manifest_entry.category.clone(),
|
|
||||||
description: manifest_entry.description.clone(),
|
|
||||||
parity_refs: manifest_entry.parity_refs.clone(),
|
|
||||||
iterations: response["iterations"]
|
|
||||||
.as_u64()
|
|
||||||
.expect("iterations should exist"),
|
|
||||||
request_count: 0,
|
|
||||||
tool_uses: response["tool_uses"]
|
|
||||||
.as_array()
|
|
||||||
.expect("tool uses array")
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| value["name"].as_str().map(ToOwned::to_owned))
|
|
||||||
.collect(),
|
|
||||||
tool_error_count: response["tool_results"]
|
|
||||||
.as_array()
|
|
||||||
.expect("tool results array")
|
|
||||||
.iter()
|
|
||||||
.filter(|value| value["is_error"].as_bool().unwrap_or(false))
|
|
||||||
.count(),
|
|
||||||
final_message: response["message"]
|
|
||||||
.as_str()
|
|
||||||
.expect("message text")
|
|
||||||
.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_write_report(reports: &[ScenarioReport]) {
|
|
||||||
let Some(path) = std::env::var_os("MOCK_PARITY_REPORT_PATH") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = json!({
|
|
||||||
"scenario_count": reports.len(),
|
|
||||||
"request_count": reports.iter().map(|report| report.request_count).sum::<usize>(),
|
|
||||||
"scenarios": reports.iter().map(scenario_report_json).collect::<Vec<_>>(),
|
|
||||||
});
|
|
||||||
fs::write(
|
|
||||||
path,
|
|
||||||
serde_json::to_vec_pretty(&payload).expect("report json should serialize"),
|
|
||||||
)
|
|
||||||
.expect("report should write");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_scenario_manifest() -> Vec<ScenarioManifestEntry> {
|
|
||||||
let manifest_path =
|
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../mock_parity_scenarios.json");
|
|
||||||
let manifest = fs::read_to_string(&manifest_path).expect("scenario manifest should exist");
|
|
||||||
serde_json::from_str::<Vec<Value>>(&manifest)
|
|
||||||
.expect("scenario manifest should parse")
|
|
||||||
.into_iter()
|
|
||||||
.map(|entry| ScenarioManifestEntry {
|
|
||||||
name: entry["name"]
|
|
||||||
.as_str()
|
|
||||||
.expect("scenario name should be a string")
|
|
||||||
.to_string(),
|
|
||||||
category: entry["category"]
|
|
||||||
.as_str()
|
|
||||||
.expect("scenario category should be a string")
|
|
||||||
.to_string(),
|
|
||||||
description: entry["description"]
|
|
||||||
.as_str()
|
|
||||||
.expect("scenario description should be a string")
|
|
||||||
.to_string(),
|
|
||||||
parity_refs: entry["parity_refs"]
|
|
||||||
.as_array()
|
|
||||||
.expect("parity refs should be an array")
|
|
||||||
.iter()
|
|
||||||
.map(|value| {
|
|
||||||
value
|
|
||||||
.as_str()
|
|
||||||
.expect("parity ref should be a string")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scenario_report_json(report: &ScenarioReport) -> Value {
|
|
||||||
json!({
|
|
||||||
"name": report.name,
|
|
||||||
"category": report.category,
|
|
||||||
"description": report.description,
|
|
||||||
"parity_refs": report.parity_refs,
|
|
||||||
"iterations": report.iterations,
|
|
||||||
"request_count": report.request_count,
|
|
||||||
"tool_uses": report.tool_uses,
|
|
||||||
"tool_error_count": report.tool_error_count,
|
|
||||||
"final_message": report.final_message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_success(output: &Output) {
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stdout:\n{}\n\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
|
||||||
let millis = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("clock should be after epoch")
|
|
||||||
.as_millis();
|
|
||||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"claw-mock-parity-{label}-{}-{millis}-{counter}",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::{Command, Output};
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use runtime::ContentBlock;
|
|
||||||
use runtime::Session;
|
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_binary_accepts_slash_commands_with_arguments() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("resume-slash-commands");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
|
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
|
||||||
let export_path = temp_dir.join("notes.txt");
|
|
||||||
|
|
||||||
let mut session = Session::new();
|
|
||||||
session
|
|
||||||
.push_user_text("ship the slash command harness")
|
|
||||||
.expect("session write should succeed");
|
|
||||||
session
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("session should persist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/export",
|
|
||||||
export_path.to_str().expect("utf8 path"),
|
|
||||||
"/clear",
|
|
||||||
"--confirm",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stdout:\n{}\n\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(stdout.contains("Export"));
|
|
||||||
assert!(stdout.contains("wrote transcript"));
|
|
||||||
assert!(stdout.contains(export_path.to_str().expect("utf8 path")));
|
|
||||||
assert!(stdout.contains("Session cleared"));
|
|
||||||
assert!(stdout.contains("Mode resumed session reset"));
|
|
||||||
assert!(stdout.contains("Previous session"));
|
|
||||||
assert!(stdout.contains("Resume previous claw --resume"));
|
|
||||||
assert!(stdout.contains("Backup "));
|
|
||||||
assert!(stdout.contains("Session file "));
|
|
||||||
|
|
||||||
let export = fs::read_to_string(&export_path).expect("export file should exist");
|
|
||||||
assert!(export.contains("# Conversation Export"));
|
|
||||||
assert!(export.contains("ship the slash command harness"));
|
|
||||||
|
|
||||||
let restored = Session::load_from_path(&session_path).expect("cleared session should load");
|
|
||||||
assert!(restored.messages.is_empty());
|
|
||||||
|
|
||||||
let backup_path = stdout
|
|
||||||
.lines()
|
|
||||||
.find_map(|line| line.strip_prefix(" Backup "))
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.expect("clear output should include backup path");
|
|
||||||
let backup = Session::load_from_path(&backup_path).expect("backup session should load");
|
|
||||||
assert_eq!(backup.messages.len(), 1);
|
|
||||||
assert!(matches!(
|
|
||||||
backup.messages[0].blocks.first(),
|
|
||||||
Some(ContentBlock::Text { text }) if text == "ship the slash command harness"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_command_applies_cli_flags_end_to_end() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("status-command-flags");
|
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = run_claw(
|
|
||||||
&temp_dir,
|
|
||||||
&[
|
|
||||||
"--model",
|
|
||||||
"sonnet",
|
|
||||||
"--permission-mode",
|
|
||||||
"read-only",
|
|
||||||
"status",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stdout:\n{}\n\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(stdout.contains("Status"));
|
|
||||||
assert!(stdout.contains("Model claude-sonnet-4-6"));
|
|
||||||
assert!(stdout.contains("Permission mode read-only"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resumed_config_command_loads_settings_files_end_to_end() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("resume-config");
|
|
||||||
let project_dir = temp_dir.join("project");
|
|
||||||
let config_home = temp_dir.join("home").join(".claw");
|
|
||||||
fs::create_dir_all(project_dir.join(".claw")).expect("project config dir should exist");
|
|
||||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
|
||||||
|
|
||||||
let session_path = project_dir.join("session.jsonl");
|
|
||||||
Session::new()
|
|
||||||
.with_persistence_path(&session_path)
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("session should persist");
|
|
||||||
|
|
||||||
fs::write(config_home.join("settings.json"), r#"{"model":"haiku"}"#)
|
|
||||||
.expect("user config should write");
|
|
||||||
fs::write(
|
|
||||||
project_dir.join(".claw").join("settings.local.json"),
|
|
||||||
r#"{"model":"opus"}"#,
|
|
||||||
)
|
|
||||||
.expect("local config should write");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = run_claw_with_env(
|
|
||||||
&project_dir,
|
|
||||||
&[
|
|
||||||
"--resume",
|
|
||||||
session_path.to_str().expect("utf8 path"),
|
|
||||||
"/config",
|
|
||||||
"model",
|
|
||||||
],
|
|
||||||
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
|
||||||
);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stdout:\n{}\n\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(stdout.contains("Config"));
|
|
||||||
assert!(stdout.contains("Loaded files 2"));
|
|
||||||
assert!(stdout.contains(
|
|
||||||
config_home
|
|
||||||
.join("settings.json")
|
|
||||||
.to_str()
|
|
||||||
.expect("utf8 path")
|
|
||||||
));
|
|
||||||
assert!(stdout.contains(
|
|
||||||
project_dir
|
|
||||||
.join(".claw")
|
|
||||||
.join("settings.local.json")
|
|
||||||
.to_str()
|
|
||||||
.expect("utf8 path")
|
|
||||||
));
|
|
||||||
assert!(stdout.contains("Merged section: model"));
|
|
||||||
assert!(stdout.contains("opus"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resume_latest_restores_the_most_recent_managed_session() {
|
|
||||||
// given
|
|
||||||
let temp_dir = unique_temp_dir("resume-latest");
|
|
||||||
let project_dir = temp_dir.join("project");
|
|
||||||
let sessions_dir = project_dir.join(".claw").join("sessions");
|
|
||||||
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
|
||||||
|
|
||||||
let older_path = sessions_dir.join("session-older.jsonl");
|
|
||||||
let newer_path = sessions_dir.join("session-newer.jsonl");
|
|
||||||
|
|
||||||
let mut older = Session::new().with_persistence_path(&older_path);
|
|
||||||
older
|
|
||||||
.push_user_text("older session")
|
|
||||||
.expect("older session write should succeed");
|
|
||||||
older
|
|
||||||
.save_to_path(&older_path)
|
|
||||||
.expect("older session should persist");
|
|
||||||
|
|
||||||
let mut newer = Session::new().with_persistence_path(&newer_path);
|
|
||||||
newer
|
|
||||||
.push_user_text("newer session")
|
|
||||||
.expect("newer session write should succeed");
|
|
||||||
newer
|
|
||||||
.push_user_text("resume me")
|
|
||||||
.expect("newer session write should succeed");
|
|
||||||
newer
|
|
||||||
.save_to_path(&newer_path)
|
|
||||||
.expect("newer session should persist");
|
|
||||||
|
|
||||||
// when
|
|
||||||
let output = run_claw(&project_dir, &["--resume", "latest", "/status"]);
|
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(
|
|
||||||
output.status.success(),
|
|
||||||
"stdout:\n{}\n\nstderr:\n{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
|
||||||
assert!(stdout.contains("Status"));
|
|
||||||
assert!(stdout.contains("Messages 2"));
|
|
||||||
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
|
||||||
run_claw_with_env(current_dir, args, &[])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
|
||||||
command.current_dir(current_dir).args(args);
|
|
||||||
for (key, value) in envs {
|
|
||||||
command.env(key, value);
|
|
||||||
}
|
|
||||||
command.output().expect("claw should launch")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
|
||||||
let millis = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("clock should be after epoch")
|
|
||||||
.as_millis();
|
|
||||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
std::env::temp_dir().join(format!(
|
|
||||||
"claw-{label}-{}-{millis}-{counter}",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,109 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "streaming_text",
|
|
||||||
"category": "baseline",
|
|
||||||
"description": "Validates streamed assistant text with no tool calls.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 1",
|
|
||||||
"Streaming response support validated by the mock parity harness"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "read_file_roundtrip",
|
|
||||||
"category": "file-tools",
|
|
||||||
"description": "Exercises read_file tool execution and final assistant synthesis.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 1",
|
|
||||||
"File tools \u2014 harness-validated flows"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "grep_chunk_assembly",
|
|
||||||
"category": "file-tools",
|
|
||||||
"description": "Validates grep_search partial JSON chunk assembly and follow-up synthesis.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 1",
|
|
||||||
"File tools \u2014 harness-validated flows"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "write_file_allowed",
|
|
||||||
"category": "file-tools",
|
|
||||||
"description": "Confirms workspace-write write_file success and filesystem side effects.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 1",
|
|
||||||
"File tools \u2014 harness-validated flows"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "write_file_denied",
|
|
||||||
"category": "permissions",
|
|
||||||
"description": "Confirms read-only mode blocks write_file with an error result.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 1",
|
|
||||||
"Permission enforcement across tool paths"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "multi_tool_turn_roundtrip",
|
|
||||||
"category": "multi-tool-turns",
|
|
||||||
"description": "Executes read_file and grep_search in the same assistant turn before the final reply.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
|
|
||||||
"Multi-tool assistant turns"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bash_stdout_roundtrip",
|
|
||||||
"category": "bash",
|
|
||||||
"description": "Validates bash execution and stdout roundtrip in danger-full-access mode.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
|
|
||||||
"Bash tool \u2014 upstream has 18 submodules, Rust has 1:"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bash_permission_prompt_approved",
|
|
||||||
"category": "permissions",
|
|
||||||
"description": "Exercises workspace-write to bash escalation with a positive approval response.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
|
|
||||||
"Permission enforcement across tool paths"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bash_permission_prompt_denied",
|
|
||||||
"category": "permissions",
|
|
||||||
"description": "Exercises workspace-write to bash escalation with a denied approval response.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
|
|
||||||
"Permission enforcement across tool paths"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "plugin_tool_roundtrip",
|
|
||||||
"category": "plugin-paths",
|
|
||||||
"description": "Loads an external plugin tool and executes it through the runtime tool registry.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Mock parity harness \u2014 milestone 2 (behavioral expansion)",
|
|
||||||
"Plugin tool execution path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "auto_compact_triggered",
|
|
||||||
"category": "session-compaction",
|
|
||||||
"description": "Verifies auto-compact fires when cumulative input tokens exceed the configured threshold.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Session compaction behavior matching",
|
|
||||||
"auto_compaction threshold from env"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "token_cost_reporting",
|
|
||||||
"category": "token-usage",
|
|
||||||
"description": "Confirms usage token counts and estimated_cost appear in JSON output.",
|
|
||||||
"parity_refs": [
|
|
||||||
"Token counting / cost tracking accuracy"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def load_manifest(path: Path) -> list[dict]:
|
|
||||||
return json.loads(path.read_text())
|
|
||||||
|
|
||||||
|
|
||||||
def load_parity_text(path: Path) -> str:
|
|
||||||
return path.read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_refs_exist(manifest: list[dict], parity_text: str) -> list[tuple[str, str]]:
|
|
||||||
missing: list[tuple[str, str]] = []
|
|
||||||
for entry in manifest:
|
|
||||||
for ref in entry.get("parity_refs", []):
|
|
||||||
if ref not in parity_text:
|
|
||||||
missing.append((entry["name"], ref))
|
|
||||||
return missing
|
|
||||||
|
|
||||||
|
|
||||||
def run_harness(rust_root: Path) -> dict:
|
|
||||||
with tempfile.TemporaryDirectory(prefix="mock-parity-report-") as temp_dir:
|
|
||||||
report_path = Path(temp_dir) / "report.json"
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["MOCK_PARITY_REPORT_PATH"] = str(report_path)
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"cargo",
|
|
||||||
"test",
|
|
||||||
"-p",
|
|
||||||
"rusty-claude-cli",
|
|
||||||
"--test",
|
|
||||||
"mock_parity_harness",
|
|
||||||
"--",
|
|
||||||
"--nocapture",
|
|
||||||
],
|
|
||||||
cwd=rust_root,
|
|
||||||
check=True,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
return json.loads(report_path.read_text())
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
script_path = Path(__file__).resolve()
|
|
||||||
rust_root = script_path.parent.parent
|
|
||||||
repo_root = rust_root.parent
|
|
||||||
manifest = load_manifest(rust_root / "mock_parity_scenarios.json")
|
|
||||||
parity_text = load_parity_text(repo_root / "PARITY.md")
|
|
||||||
|
|
||||||
missing_refs = ensure_refs_exist(manifest, parity_text)
|
|
||||||
if missing_refs:
|
|
||||||
print("Missing PARITY.md references:", file=sys.stderr)
|
|
||||||
for scenario_name, ref in missing_refs:
|
|
||||||
print(f" - {scenario_name}: {ref}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
should_run = "--no-run" not in sys.argv[1:]
|
|
||||||
report = run_harness(rust_root) if should_run else None
|
|
||||||
report_by_name = {
|
|
||||||
entry["name"]: entry for entry in report.get("scenarios", [])
|
|
||||||
} if report else {}
|
|
||||||
|
|
||||||
print("Mock parity diff checklist")
|
|
||||||
print(f"Repo root: {repo_root}")
|
|
||||||
print(f"Scenario manifest: {rust_root / 'mock_parity_scenarios.json'}")
|
|
||||||
print(f"PARITY source: {repo_root / 'PARITY.md'}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
for entry in manifest:
|
|
||||||
scenario_name = entry["name"]
|
|
||||||
scenario_report = report_by_name.get(scenario_name)
|
|
||||||
status = "PASS" if scenario_report else ("MAPPED" if not should_run else "MISSING")
|
|
||||||
print(f"[{status}] {scenario_name} ({entry['category']})")
|
|
||||||
print(f" description: {entry['description']}")
|
|
||||||
print(f" parity refs: {' | '.join(entry['parity_refs'])}")
|
|
||||||
if scenario_report:
|
|
||||||
print(
|
|
||||||
" result: iterations={iterations} requests={requests} tool_uses={tool_uses} tool_errors={tool_errors}".format(
|
|
||||||
iterations=scenario_report["iterations"],
|
|
||||||
requests=scenario_report["request_count"],
|
|
||||||
tool_uses=", ".join(scenario_report["tool_uses"]) or "none",
|
|
||||||
tool_errors=scenario_report["tool_error_count"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print(f" final: {scenario_report['final_message']}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
coverage = defaultdict(list)
|
|
||||||
for entry in manifest:
|
|
||||||
for ref in entry["parity_refs"]:
|
|
||||||
coverage[ref].append(entry["name"])
|
|
||||||
|
|
||||||
print("PARITY coverage map")
|
|
||||||
for ref, scenarios in coverage.items():
|
|
||||||
print(f"- {ref}")
|
|
||||||
print(f" scenarios: {', '.join(scenarios)}")
|
|
||||||
|
|
||||||
if report and report.get("scenarios"):
|
|
||||||
first = report["scenarios"][0]
|
|
||||||
print()
|
|
||||||
print("First scenario result")
|
|
||||||
print(f"- name: {first['name']}")
|
|
||||||
print(f"- iterations: {first['iterations']}")
|
|
||||||
print(f"- requests: {first['request_count']}")
|
|
||||||
print(f"- tool_uses: {', '.join(first['tool_uses']) or 'none'}")
|
|
||||||
print(f"- tool_errors: {first['tool_error_count']}")
|
|
||||||
print(f"- final_message: {first['final_message']}")
|
|
||||||
print()
|
|
||||||
print(
|
|
||||||
"Harness summary: {scenario_count} scenarios, {request_count} requests".format(
|
|
||||||
scenario_count=report["scenario_count"],
|
|
||||||
request_count=report["request_count"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
cargo test -p rusty-claude-cli --test mock_parity_harness -- --nocapture
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
"""Shared helper for archive placeholder packages."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def load_archive_metadata(package_name: str) -> dict:
|
|
||||||
"""Load archive metadata from reference_data/subsystems/{package_name}.json."""
|
|
||||||
snapshot_path = (
|
|
||||||
Path(__file__).resolve().parent
|
|
||||||
/ "reference_data"
|
|
||||||
/ "subsystems"
|
|
||||||
/ f"{package_name}.json"
|
|
||||||
)
|
|
||||||
return json.loads(snapshot_path.read_text())
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("assistant")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'assistant.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("bootstrap")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'bootstrap.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("bridge")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'bridge.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("buddy")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'buddy.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("cli")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'cli.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("components")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'components.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("constants")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'constants.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("coordinator")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'coordinator.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("entrypoints")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'entrypoints.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("hooks")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'hooks.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("keybindings")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'keybindings.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("memdir")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'memdir.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("migrations")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'migrations.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("moreright")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'moreright.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
"""Python package placeholder for the archived `native_ts` subsystem."""
|
"""Python package placeholder for the archived `native-ts` subsystem."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("native_ts")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'native_ts.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("outputStyles")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'outputStyles.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("plugins")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'plugins.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("remote")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'remote.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("schemas")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'schemas.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("screens")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'screens.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("server")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'server.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("services")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'services.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("skills")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'skills.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("state")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'state.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("types")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'types.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("upstreamproxy")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'upstreamproxy.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("utils")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'utils.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("vim")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'vim.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src._archive_helper import load_archive_metadata
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_SNAPSHOT = load_archive_metadata("voice")
|
SNAPSHOT_PATH = Path(__file__).resolve().parent.parent / 'reference_data' / 'subsystems' / 'voice.json'
|
||||||
|
_SNAPSHOT = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
|
||||||
ARCHIVE_NAME = _SNAPSHOT["archive_name"]
|
ARCHIVE_NAME = _SNAPSHOT['archive_name']
|
||||||
MODULE_COUNT = _SNAPSHOT["module_count"]
|
MODULE_COUNT = _SNAPSHOT['module_count']
|
||||||
SAMPLE_FILES = tuple(_SNAPSHOT["sample_files"])
|
SAMPLE_FILES = tuple(_SNAPSHOT['sample_files'])
|
||||||
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
PORTING_NOTE = f"Python placeholder package for '{ARCHIVE_NAME}' with {MODULE_COUNT} archived module references."
|
||||||
|
|
||||||
__all__ = ["ARCHIVE_NAME", "MODULE_COUNT", "PORTING_NOTE", "SAMPLE_FILES"]
|
__all__ = ['ARCHIVE_NAME', 'MODULE_COUNT', 'PORTING_NOTE', 'SAMPLE_FILES']
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue