An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

Rewrite and re-architecting #1

open opened by dathagerty.com targeting main from new-directions
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hegn4siucs5o5ti3mzbnvdge/sh.tangled.repo.pull/3mehsokrli722
+18409 -2414
Diff #0
+138
AGENTS.md
··· 1 + # Rustagent 2 + 3 + Last verified: 2026-02-09 4 + 5 + ## Project Overview 6 + 7 + Rustagent is a Rust-based AI agent framework for autonomous task execution. The architecture has two layers: 8 + 9 + - **V1 (legacy)**: Planning Agent + Ralph Loop using flat JSON specs 10 + - **V2 (current)**: Graph-based work tracking with typed agent profiles, SQLite persistence, and an agentic runtime loop 11 + 12 + V2 is the active development path. V1 modules (`planning/`, `ralph/`, `spec.rs`) remain for backward compatibility. 13 + 14 + ## Development Commands 15 + 16 + ```bash 17 + cargo build # Debug build 18 + cargo build --release # Optimized release build 19 + cargo check # Fast compilation check 20 + cargo fmt # Format code 21 + cargo clippy # Lint 22 + cargo test # Run full test suite 23 + cargo test <name> # Run specific test by name 24 + cargo doc --open # Generate and view documentation 25 + ``` 26 + 27 + ### V2 CLI Commands 28 + ```bash 29 + cargo run -- project add <name> <path> # Register a project 30 + cargo run -- run <goal> --profile coder # Execute a goal with an agent 31 + cargo run -- tasks # List tasks for current project 32 + cargo run -- decisions # List decisions 33 + cargo run -- status # Show project status 34 + cargo run -- search <query> # Full-text search graph nodes 35 + cargo run -- sessions # View sessions and handoff notes 36 + cargo run -- graph export <goal-id> # Export graph as TOML 37 + cargo run -- graph import <file> # Import TOML graph 38 + cargo run -- graph adr <goal-id> # Export decisions as ADR markdown 39 + ``` 40 + 41 + ## Project Structure 42 + 43 + ``` 44 + src/ 45 + ├── main.rs # CLI entry point (clap), V1 + V2 commands 46 + ├── lib.rs # Library exports: all public modules 47 + ├── config.rs # Configuration loading with env var substitution 48 + ├── logging.rs # File-based tracing with daily rotation 49 + ├── spec.rs # V1 specification data structures 50 + ├── project.rs # Project type and ProjectStore (CRUD over SQLite) 51 + ├── db/ # Database layer (SQLite + WAL mode) 52 + │ ├── mod.rs # Database wrapper with async access 53 + │ └── migrations.rs # Schema versioning and migration framework 54 + ├── graph/ # Work graph model (see src/graph/AGENTS.md) 55 + │ ├── mod.rs # Core types: NodeType, EdgeType, NodeStatus, GraphNode, GraphEdge 56 + │ ├── store.rs # GraphStore trait + SqliteGraphStore implementation 57 + │ ├── decay.rs # Node decay for context injection (Full/Summary/Minimal) 58 + │ ├── dependency.rs # Dependency resolution helpers 59 + │ ├── session.rs # Session management with handoff notes 60 + │ ├── export.rs # ADR markdown export 61 + │ └── interchange.rs # TOML import/export with content hashing 62 + ├── agent/ # Agent types and runtime (see src/agent/AGENTS.md) 63 + │ ├── mod.rs # Agent trait, AgentId, AgentContext, AgentOutcome 64 + │ ├── profile.rs # AgentProfile with inheritance and SecurityScope 65 + │ ├── builtin_profiles.rs # 5 built-in profiles: planner, coder, reviewer, tester, researcher 66 + │ └── runtime.rs # AgentRuntime: agentic loop with token budget and failure thresholds 67 + ├── context/ # Context building for agent prompts 68 + │ ├── mod.rs # ContextBuilder + ReadAgentsMdTool 69 + │ └── agents_md.rs # AGENTS.md file discovery and heading extraction 70 + ├── llm/ # LLM provider abstraction 71 + │ ├── mod.rs # LlmClient trait, Message, Response (with token tracking) 72 + │ ├── anthropic.rs # Anthropic (Claude) client 73 + │ ├── openai.rs # OpenAI client 74 + │ ├── ollama.rs # Ollama (local models) client 75 + │ ├── mock.rs # Mock client for testing 76 + │ ├── factory.rs # Client factory based on config 77 + │ ├── error.rs # Provider-agnostic error types 78 + │ └── retry.rs # Rate limit handling with retry logic 79 + ├── planning/ # V1 Planning Agent (interactive spec creation) 80 + ├── ralph/ # V1 Ralph Loop (spec-based task execution) 81 + ├── security/ 82 + │ ├── mod.rs # SecurityValidator for paths/commands 83 + │ ├── permission.rs # Permission handling (CLI prompts) 84 + │ └── scope.rs # SecurityScope: per-agent path/command/network restrictions 85 + └── tools/ 86 + ├── mod.rs # Tool trait and ToolRegistry 87 + ├── factory.rs # create_default_registry + create_v2_registry 88 + ├── graph_tools.rs # 11 graph tools for agents (create, update, query, claim, etc.) 89 + ├── file.rs # read_file, write_file, list_files 90 + ├── shell.rs # run_command 91 + ├── signal.rs # signal_completion 92 + └── permission_check.rs # File permission checking 93 + ``` 94 + 95 + ## Key Dependencies 96 + 97 + - `rusqlite` (bundled) + `tokio-rusqlite` for async SQLite 98 + - `blake3` for content hashing (interchange format) 99 + - `clap` (derive) for CLI 100 + - `chrono` for timestamps (RFC 3339 everywhere) 101 + - `uuid` v4 for ID generation 102 + 103 + ## Conventions 104 + 105 + ### ID Scheme 106 + - Projects/Goals: `ra-XXXX` (4 hex chars from UUID v4) 107 + - Child nodes: `ra-XXXX.N` (dot-separated sequence) 108 + - Edges: `e-XXXXXXXX` (8 hex chars) 109 + - Sessions: `sess-XXXXXXXX` 110 + 111 + ### Database 112 + - All writes use `BEGIN IMMEDIATE` transactions 113 + - WAL journal mode, foreign keys ON, busy timeout 5000ms 114 + - Schema versioned via `schema_version` table; migrations are forward-only 115 + 116 + ### Architecture Patterns 117 + - **Factory pattern**: LLM clients (`create_client`), tool registries (`create_v2_registry`) 118 + - **Trait objects**: `dyn LlmClient`, `dyn Tool`, `dyn GraphStore` for runtime polymorphism 119 + - **Arc wrapping**: `Arc<dyn GraphStore>` shared across tools and runtime 120 + - **async_trait**: All async traits use the `async_trait` crate 121 + - **anyhow::Result**: Unified error handling across the codebase 122 + 123 + ### Agent Profile Resolution 124 + Profiles resolve in order: project-level (`.rustagent/profiles/{name}.toml`) -> user-level (`~/.config/rustagent/profiles/{name}.toml`) -> built-in. Inheritance via `extends` field with cycle detection. 125 + 126 + ## Important Notes 127 + 128 + ### Cargo Edition 129 + `edition = "2024"` requires Rust 1.85.0+. 130 + 131 + ### Logging 132 + Logs at `~/.local/state/rustagent/logs/` with daily rotation. Use `RUST_LOG=rustagent=debug`. 133 + 134 + ### Database Location 135 + Default database at `~/.local/share/rustagent/rustagent.db`. 136 + 137 + ### Version Control 138 + This repo uses both Git and Jujutsu (`.jj/` directory). Be aware of dual VCS.
+1 -91
CLAUDE.md
··· 1 - # CLAUDE.md 2 - 3 - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 - 5 - ## Project Overview 6 - 7 - Rustagent is a Rust-based AI agent framework for autonomous task execution. It uses a two-phase approach: a Planning Agent that breaks down high-level goals into executable tasks, and the Ralph Loop that executes those tasks iteratively with tool access. 8 - 9 - ## Development Commands 10 - 11 - ### Building and Running 12 - ```bash 13 - cargo build # Compile in debug mode 14 - cargo build --release # Compile optimized release build 15 - cargo run -- init # Initialize a new spec directory 16 - cargo run -- plan # Run the planning agent 17 - cargo run -- run <spec> # Execute a spec with the Ralph loop 18 - cargo check # Fast compilation check without producing binary 19 - ``` 20 - 21 - ### Code Quality 22 - ```bash 23 - cargo fmt # Format code using rustfmt 24 - cargo clippy # Run Clippy linter for code improvements 25 - ``` 26 - 27 - ### Testing 28 - ```bash 29 - cargo test # Run test suite 30 - cargo test <name> # Run specific test by name 31 - ``` 32 - 33 - ### Documentation 34 - ```bash 35 - cargo doc --open # Generate and view documentation 36 - ``` 37 - 38 - ## Project Structure 39 - 40 - ``` 41 - src/ 42 - ├── main.rs # CLI entry point with init/plan/run commands 43 - ├── lib.rs # Library exports 44 - ├── config.rs # Configuration loading with env var substitution 45 - ├── logging.rs # File-based tracing with daily rotation 46 - ├── spec.rs # Specification data structures 47 - ├── llm/ 48 - │ ├── mod.rs # LlmClient trait and Message types 49 - │ ├── anthropic.rs # Anthropic (Claude) client 50 - │ ├── openai.rs # OpenAI (GPT) client 51 - │ ├── ollama.rs # Ollama (local models) client 52 - │ ├── mock.rs # Mock client for testing 53 - │ └── factory.rs # Client factory based on config 54 - ├── planning/ 55 - │ └── mod.rs # Planning Agent implementation 56 - ├── ralph/ 57 - │ └── mod.rs # Ralph Loop execution engine 58 - ├── security/ 59 - │ ├── mod.rs # Security validator for paths/commands 60 - │ └── permission.rs # Permission handling (CLI prompts) 61 - └── tools/ 62 - ├── mod.rs # Tool trait and registry 63 - ├── file.rs # read_file, write_file, list_files tools 64 - ├── shell.rs # run_command tool 65 - ├── signal.rs # signal_completion tool 66 - ├── factory.rs # Tool registry factory 67 - └── permission_check.rs # File permission checking 68 - ``` 69 - 70 - ## Important Notes 71 - 72 - ### Cargo Edition 73 - The `Cargo.toml` specifies `edition = "2024"`, which requires Rust 1.85.0 or later. This is the recommended edition for new Rust projects as of 2025. 74 - 75 - ### LLM Providers 76 - The project supports three LLM providers: 77 - - **Anthropic** (Claude) - Default, uses tool calling API 78 - - **OpenAI** (GPT-4) - Full tool calling with tool_call_id 79 - - **Ollama** (Local) - For running local models 80 - 81 - ### Logging 82 - Logs are written to `~/.local/state/rustagent/logs/` with daily rotation. Set `RUST_LOG=rustagent=debug` for verbose output. 83 - 84 - ### Version Control 85 - This repository uses both Git and Jujutsu (`.jj/` directory present). Be aware of this dual VCS setup when making version control operations. 86 - 87 - ### Architecture Patterns 88 - - **Factory pattern**: Used for LLM clients and tool registry 89 - - **Trait objects**: `dyn LlmClient` and `dyn Tool` for runtime polymorphism 90 - - **Arc/RwLock**: Thread-safe shared state in tool registry 91 - - **anyhow::Result**: Unified error handling across the codebase 1 + ./AGENTS.md
+169 -309
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "ahash" 7 + version = "0.8.12" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 10 + dependencies = [ 11 + "cfg-if", 12 + "once_cell", 13 + "version_check", 14 + "zerocopy", 15 + ] 16 + 17 + [[package]] 6 18 name = "aho-corasick" 7 19 version = "1.1.4" 8 20 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 10 22 dependencies = [ 11 23 "memchr", 12 24 ] 13 - 14 - [[package]] 15 - name = "allocator-api2" 16 - version = "0.2.21" 17 - source = "registry+https://github.com/rust-lang/crates.io-index" 18 - checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 25 20 26 [[package]] 21 27 name = "android_system_properties" ··· 81 87 version = "1.0.100" 82 88 source = "registry+https://github.com/rust-lang/crates.io-index" 83 89 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 90 + 91 + [[package]] 92 + name = "arrayref" 93 + version = "0.3.9" 94 + source = "registry+https://github.com/rust-lang/crates.io-index" 95 + checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" 96 + 97 + [[package]] 98 + name = "arrayvec" 99 + version = "0.7.6" 100 + source = "registry+https://github.com/rust-lang/crates.io-index" 101 + checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 84 102 85 103 [[package]] 86 104 name = "async-trait" ··· 118 136 checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 119 137 120 138 [[package]] 139 + name = "blake3" 140 + version = "1.8.3" 141 + source = "registry+https://github.com/rust-lang/crates.io-index" 142 + checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" 143 + dependencies = [ 144 + "arrayref", 145 + "arrayvec", 146 + "cc", 147 + "cfg-if", 148 + "constant_time_eq", 149 + "cpufeatures", 150 + ] 151 + 152 + [[package]] 121 153 name = "bumpalo" 122 154 version = "3.19.1" 123 155 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 128 160 version = "1.11.0" 129 161 source = "registry+https://github.com/rust-lang/crates.io-index" 130 162 checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" 131 - 132 - [[package]] 133 - name = "cassowary" 134 - version = "0.3.0" 135 - source = "registry+https://github.com/rust-lang/crates.io-index" 136 - checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 137 - 138 - [[package]] 139 - name = "castaway" 140 - version = "0.2.4" 141 - source = "registry+https://github.com/rust-lang/crates.io-index" 142 - checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 143 - dependencies = [ 144 - "rustversion", 145 - ] 146 163 147 164 [[package]] 148 165 name = "cc" ··· 221 238 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 222 239 223 240 [[package]] 224 - name = "compact_str" 225 - version = "0.8.1" 241 + name = "constant_time_eq" 242 + version = "0.4.2" 226 243 source = "registry+https://github.com/rust-lang/crates.io-index" 227 - checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 228 - dependencies = [ 229 - "castaway", 230 - "cfg-if", 231 - "itoa", 232 - "rustversion", 233 - "ryu", 234 - "static_assertions", 235 - ] 244 + checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" 236 245 237 246 [[package]] 238 247 name = "core-foundation" ··· 251 260 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 252 261 253 262 [[package]] 263 + name = "cpufeatures" 264 + version = "0.2.17" 265 + source = "registry+https://github.com/rust-lang/crates.io-index" 266 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 267 + dependencies = [ 268 + "libc", 269 + ] 270 + 271 + [[package]] 254 272 name = "crossbeam-channel" 255 273 version = "0.5.15" 256 274 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 266 284 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 267 285 268 286 [[package]] 269 - name = "crossterm" 270 - version = "0.28.1" 271 - source = "registry+https://github.com/rust-lang/crates.io-index" 272 - checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 273 - dependencies = [ 274 - "bitflags", 275 - "crossterm_winapi", 276 - "mio", 277 - "parking_lot", 278 - "rustix 0.38.44", 279 - "signal-hook", 280 - "signal-hook-mio", 281 - "winapi", 282 - ] 283 - 284 - [[package]] 285 - name = "crossterm_winapi" 286 - version = "0.9.1" 287 - source = "registry+https://github.com/rust-lang/crates.io-index" 288 - checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 289 - dependencies = [ 290 - "winapi", 291 - ] 292 - 293 - [[package]] 294 - name = "darling" 295 - version = "0.23.0" 296 - source = "registry+https://github.com/rust-lang/crates.io-index" 297 - checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" 298 - dependencies = [ 299 - "darling_core", 300 - "darling_macro", 301 - ] 302 - 303 - [[package]] 304 - name = "darling_core" 305 - version = "0.23.0" 306 - source = "registry+https://github.com/rust-lang/crates.io-index" 307 - checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" 308 - dependencies = [ 309 - "ident_case", 310 - "proc-macro2", 311 - "quote", 312 - "strsim", 313 - "syn", 314 - ] 315 - 316 - [[package]] 317 - name = "darling_macro" 318 - version = "0.23.0" 319 - source = "registry+https://github.com/rust-lang/crates.io-index" 320 - checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" 321 - dependencies = [ 322 - "darling_core", 323 - "quote", 324 - "syn", 325 - ] 326 - 327 - [[package]] 328 287 name = "deranged" 329 288 version = "0.5.5" 330 289 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 366 325 ] 367 326 368 327 [[package]] 369 - name = "either" 370 - version = "1.15.0" 371 - source = "registry+https://github.com/rust-lang/crates.io-index" 372 - checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 373 - 374 - [[package]] 375 328 name = "encoding_rs" 376 329 version = "0.8.35" 377 330 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 397 350 ] 398 351 399 352 [[package]] 353 + name = "fallible-iterator" 354 + version = "0.3.0" 355 + source = "registry+https://github.com/rust-lang/crates.io-index" 356 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" 357 + 358 + [[package]] 359 + name = "fallible-streaming-iterator" 360 + version = "0.1.9" 361 + source = "registry+https://github.com/rust-lang/crates.io-index" 362 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 363 + 364 + [[package]] 400 365 name = "fastrand" 401 366 version = "2.3.0" 402 367 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 413 378 version = "1.0.7" 414 379 source = "registry+https://github.com/rust-lang/crates.io-index" 415 380 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 416 - 417 - [[package]] 418 - name = "foldhash" 419 - version = "0.1.5" 420 - source = "registry+https://github.com/rust-lang/crates.io-index" 421 - checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 422 381 423 382 [[package]] 424 383 name = "foreign-types" ··· 508 467 ] 509 468 510 469 [[package]] 470 + name = "glob" 471 + version = "0.3.3" 472 + source = "registry+https://github.com/rust-lang/crates.io-index" 473 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 474 + 475 + [[package]] 511 476 name = "h2" 512 477 version = "0.4.13" 513 478 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 528 493 529 494 [[package]] 530 495 name = "hashbrown" 531 - version = "0.15.5" 496 + version = "0.14.5" 532 497 source = "registry+https://github.com/rust-lang/crates.io-index" 533 - checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 498 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 534 499 dependencies = [ 535 - "allocator-api2", 536 - "equivalent", 537 - "foldhash", 500 + "ahash", 538 501 ] 539 502 540 503 [[package]] ··· 544 507 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 545 508 546 509 [[package]] 510 + name = "hashlink" 511 + version = "0.9.1" 512 + source = "registry+https://github.com/rust-lang/crates.io-index" 513 + checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" 514 + dependencies = [ 515 + "hashbrown 0.14.5", 516 + ] 517 + 518 + [[package]] 547 519 name = "heck" 548 520 version = "0.5.0" 549 521 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 774 746 ] 775 747 776 748 [[package]] 777 - name = "ident_case" 778 - version = "1.0.1" 779 - source = "registry+https://github.com/rust-lang/crates.io-index" 780 - checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 781 - 782 - [[package]] 783 749 name = "idna" 784 750 version = "1.1.0" 785 751 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 811 777 ] 812 778 813 779 [[package]] 814 - name = "indoc" 815 - version = "2.0.7" 816 - source = "registry+https://github.com/rust-lang/crates.io-index" 817 - checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" 818 - dependencies = [ 819 - "rustversion", 820 - ] 821 - 822 - [[package]] 823 - name = "instability" 824 - version = "0.3.11" 825 - source = "registry+https://github.com/rust-lang/crates.io-index" 826 - checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" 827 - dependencies = [ 828 - "darling", 829 - "indoc", 830 - "proc-macro2", 831 - "quote", 832 - "syn", 833 - ] 834 - 835 - [[package]] 836 780 name = "ipnet" 837 781 version = "2.11.0" 838 782 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 853 797 version = "1.70.2" 854 798 source = "registry+https://github.com/rust-lang/crates.io-index" 855 799 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 856 - 857 - [[package]] 858 - name = "itertools" 859 - version = "0.13.0" 860 - source = "registry+https://github.com/rust-lang/crates.io-index" 861 - checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 862 - dependencies = [ 863 - "either", 864 - ] 865 800 866 801 [[package]] 867 802 name = "itoa" ··· 902 837 ] 903 838 904 839 [[package]] 905 - name = "linux-raw-sys" 906 - version = "0.4.15" 840 + name = "libsqlite3-sys" 841 + version = "0.30.1" 907 842 source = "registry+https://github.com/rust-lang/crates.io-index" 908 - checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 843 + checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" 844 + dependencies = [ 845 + "cc", 846 + "pkg-config", 847 + "vcpkg", 848 + ] 909 849 910 850 [[package]] 911 851 name = "linux-raw-sys" ··· 935 875 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 936 876 937 877 [[package]] 938 - name = "lru" 939 - version = "0.12.5" 940 - source = "registry+https://github.com/rust-lang/crates.io-index" 941 - checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 942 - dependencies = [ 943 - "hashbrown 0.15.5", 944 - ] 945 - 946 - [[package]] 947 878 name = "matchers" 948 879 version = "0.2.0" 949 880 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 971 902 checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" 972 903 dependencies = [ 973 904 "libc", 974 - "log", 975 905 "wasi", 976 906 "windows-sys 0.61.2", 977 907 ] ··· 1103 1033 ] 1104 1034 1105 1035 [[package]] 1106 - name = "paste" 1107 - version = "1.0.15" 1108 - source = "registry+https://github.com/rust-lang/crates.io-index" 1109 - checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1110 - 1111 - [[package]] 1112 1036 name = "percent-encoding" 1113 1037 version = "2.3.2" 1114 1038 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1172 1096 checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 1173 1097 1174 1098 [[package]] 1175 - name = "ratatui" 1176 - version = "0.29.0" 1177 - source = "registry+https://github.com/rust-lang/crates.io-index" 1178 - checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 1179 - dependencies = [ 1180 - "bitflags", 1181 - "cassowary", 1182 - "compact_str", 1183 - "crossterm", 1184 - "indoc", 1185 - "instability", 1186 - "itertools", 1187 - "lru", 1188 - "paste", 1189 - "strum", 1190 - "unicode-segmentation", 1191 - "unicode-truncate", 1192 - "unicode-width 0.2.0", 1193 - ] 1194 - 1195 - [[package]] 1196 1099 name = "redox_syscall" 1197 1100 version = "0.5.18" 1198 1101 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1296 1199 ] 1297 1200 1298 1201 [[package]] 1202 + name = "rusqlite" 1203 + version = "0.32.1" 1204 + source = "registry+https://github.com/rust-lang/crates.io-index" 1205 + checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" 1206 + dependencies = [ 1207 + "bitflags", 1208 + "fallible-iterator", 1209 + "fallible-streaming-iterator", 1210 + "hashlink", 1211 + "libsqlite3-sys", 1212 + "smallvec", 1213 + ] 1214 + 1215 + [[package]] 1299 1216 name = "rustagent" 1300 1217 version = "0.1.0" 1301 1218 dependencies = [ 1302 1219 "anyhow", 1303 1220 "async-trait", 1221 + "blake3", 1304 1222 "chrono", 1305 1223 "clap", 1306 - "crossterm", 1307 1224 "dirs", 1308 - "ratatui", 1225 + "glob", 1309 1226 "regex", 1310 1227 "reqwest", 1228 + "rusqlite", 1311 1229 "serde", 1312 1230 "serde_json", 1313 1231 "shellexpand", 1314 1232 "tempfile", 1315 1233 "tokio", 1234 + "tokio-rusqlite", 1316 1235 "toml", 1317 1236 "tracing", 1318 1237 "tracing-appender", 1319 1238 "tracing-subscriber", 1320 - "tui-textarea", 1321 1239 "uuid", 1322 - ] 1323 - 1324 - [[package]] 1325 - name = "rustix" 1326 - version = "0.38.44" 1327 - source = "registry+https://github.com/rust-lang/crates.io-index" 1328 - checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1329 - dependencies = [ 1330 - "bitflags", 1331 - "errno", 1332 - "libc", 1333 - "linux-raw-sys 0.4.15", 1334 - "windows-sys 0.52.0", 1240 + "walkdir", 1335 1241 ] 1336 1242 1337 1243 [[package]] ··· 1343 1249 "bitflags", 1344 1250 "errno", 1345 1251 "libc", 1346 - "linux-raw-sys 0.11.0", 1252 + "linux-raw-sys", 1347 1253 "windows-sys 0.61.2", 1348 1254 ] 1349 1255 ··· 1391 1297 version = "1.0.22" 1392 1298 source = "registry+https://github.com/rust-lang/crates.io-index" 1393 1299 checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" 1300 + 1301 + [[package]] 1302 + name = "same-file" 1303 + version = "1.0.6" 1304 + source = "registry+https://github.com/rust-lang/crates.io-index" 1305 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1306 + dependencies = [ 1307 + "winapi-util", 1308 + ] 1394 1309 1395 1310 [[package]] 1396 1311 name = "schannel" ··· 1519 1434 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1520 1435 1521 1436 [[package]] 1522 - name = "signal-hook" 1523 - version = "0.3.18" 1524 - source = "registry+https://github.com/rust-lang/crates.io-index" 1525 - checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 1526 - dependencies = [ 1527 - "libc", 1528 - "signal-hook-registry", 1529 - ] 1530 - 1531 - [[package]] 1532 - name = "signal-hook-mio" 1533 - version = "0.2.5" 1534 - source = "registry+https://github.com/rust-lang/crates.io-index" 1535 - checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 1536 - dependencies = [ 1537 - "libc", 1538 - "mio", 1539 - "signal-hook", 1540 - ] 1541 - 1542 - [[package]] 1543 1437 name = "signal-hook-registry" 1544 1438 version = "1.4.8" 1545 1439 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1578 1472 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 1579 1473 1580 1474 [[package]] 1581 - name = "static_assertions" 1582 - version = "1.1.0" 1583 - source = "registry+https://github.com/rust-lang/crates.io-index" 1584 - checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1585 - 1586 - [[package]] 1587 1475 name = "strsim" 1588 1476 version = "0.11.1" 1589 1477 source = "registry+https://github.com/rust-lang/crates.io-index" 1590 1478 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1591 1479 1592 1480 [[package]] 1593 - name = "strum" 1594 - version = "0.26.3" 1595 - source = "registry+https://github.com/rust-lang/crates.io-index" 1596 - checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 1597 - dependencies = [ 1598 - "strum_macros", 1599 - ] 1600 - 1601 - [[package]] 1602 - name = "strum_macros" 1603 - version = "0.26.4" 1604 - source = "registry+https://github.com/rust-lang/crates.io-index" 1605 - checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 1606 - dependencies = [ 1607 - "heck", 1608 - "proc-macro2", 1609 - "quote", 1610 - "rustversion", 1611 - "syn", 1612 - ] 1613 - 1614 - [[package]] 1615 1481 name = "subtle" 1616 1482 version = "2.6.1" 1617 1483 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1678 1544 "fastrand", 1679 1545 "getrandom 0.3.4", 1680 1546 "once_cell", 1681 - "rustix 1.1.3", 1547 + "rustix", 1682 1548 "windows-sys 0.61.2", 1683 1549 ] 1684 1550 ··· 1811 1677 ] 1812 1678 1813 1679 [[package]] 1680 + name = "tokio-rusqlite" 1681 + version = "0.6.0" 1682 + source = "registry+https://github.com/rust-lang/crates.io-index" 1683 + checksum = "b65501378eb676f400c57991f42cbd0986827ab5c5200c53f206d710fb32a945" 1684 + dependencies = [ 1685 + "crossbeam-channel", 1686 + "rusqlite", 1687 + "tokio", 1688 + ] 1689 + 1690 + [[package]] 1814 1691 name = "tokio-rustls" 1815 1692 version = "0.26.4" 1816 1693 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1999 1876 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2000 1877 2001 1878 [[package]] 2002 - name = "tui-textarea" 2003 - version = "0.7.0" 2004 - source = "registry+https://github.com/rust-lang/crates.io-index" 2005 - checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" 2006 - dependencies = [ 2007 - "crossterm", 2008 - "ratatui", 2009 - "unicode-width 0.2.0", 2010 - ] 2011 - 2012 - [[package]] 2013 1879 name = "unicode-ident" 2014 1880 version = "1.0.22" 2015 1881 source = "registry+https://github.com/rust-lang/crates.io-index" 2016 1882 checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 2017 1883 2018 1884 [[package]] 2019 - name = "unicode-segmentation" 2020 - version = "1.12.0" 2021 - source = "registry+https://github.com/rust-lang/crates.io-index" 2022 - checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 2023 - 2024 - [[package]] 2025 - name = "unicode-truncate" 2026 - version = "1.1.0" 2027 - source = "registry+https://github.com/rust-lang/crates.io-index" 2028 - checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 2029 - dependencies = [ 2030 - "itertools", 2031 - "unicode-segmentation", 2032 - "unicode-width 0.1.14", 2033 - ] 2034 - 2035 - [[package]] 2036 - name = "unicode-width" 2037 - version = "0.1.14" 2038 - source = "registry+https://github.com/rust-lang/crates.io-index" 2039 - checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 2040 - 2041 - [[package]] 2042 - name = "unicode-width" 2043 - version = "0.2.0" 2044 - source = "registry+https://github.com/rust-lang/crates.io-index" 2045 - checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 2046 - 2047 - [[package]] 2048 1885 name = "untrusted" 2049 1886 version = "0.9.0" 2050 1887 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2096 1933 version = "0.2.15" 2097 1934 source = "registry+https://github.com/rust-lang/crates.io-index" 2098 1935 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1936 + 1937 + [[package]] 1938 + name = "version_check" 1939 + version = "0.9.5" 1940 + source = "registry+https://github.com/rust-lang/crates.io-index" 1941 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1942 + 1943 + [[package]] 1944 + name = "walkdir" 1945 + version = "2.5.0" 1946 + source = "registry+https://github.com/rust-lang/crates.io-index" 1947 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1948 + dependencies = [ 1949 + "same-file", 1950 + "winapi-util", 1951 + ] 2099 1952 2100 1953 [[package]] 2101 1954 name = "want" ··· 2191 2044 ] 2192 2045 2193 2046 [[package]] 2194 - name = "winapi" 2195 - version = "0.3.9" 2047 + name = "winapi-util" 2048 + version = "0.1.11" 2196 2049 source = "registry+https://github.com/rust-lang/crates.io-index" 2197 - checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2050 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 2198 2051 dependencies = [ 2199 - "winapi-i686-pc-windows-gnu", 2200 - "winapi-x86_64-pc-windows-gnu", 2052 + "windows-sys 0.61.2", 2201 2053 ] 2202 - 2203 - [[package]] 2204 - name = "winapi-i686-pc-windows-gnu" 2205 - version = "0.4.0" 2206 - source = "registry+https://github.com/rust-lang/crates.io-index" 2207 - checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2208 - 2209 - [[package]] 2210 - name = "winapi-x86_64-pc-windows-gnu" 2211 - version = "0.4.0" 2212 - source = "registry+https://github.com/rust-lang/crates.io-index" 2213 - checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2214 2054 2215 2055 [[package]] 2216 2056 name = "windows-core" ··· 2546 2386 "quote", 2547 2387 "syn", 2548 2388 "synstructure", 2389 + ] 2390 + 2391 + [[package]] 2392 + name = "zerocopy" 2393 + version = "0.8.39" 2394 + source = "registry+https://github.com/rust-lang/crates.io-index" 2395 + checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" 2396 + dependencies = [ 2397 + "zerocopy-derive", 2398 + ] 2399 + 2400 + [[package]] 2401 + name = "zerocopy-derive" 2402 + version = "0.8.39" 2403 + source = "registry+https://github.com/rust-lang/crates.io-index" 2404 + checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" 2405 + dependencies = [ 2406 + "proc-macro2", 2407 + "quote", 2408 + "syn", 2549 2409 ] 2550 2410 2551 2411 [[package]]
+5 -3
Cargo.toml
··· 20 20 regex = "1.10" 21 21 shellexpand = "3.1" 22 22 uuid = { version = "1.0", features = ["v4"] } 23 - ratatui = "0.29" 24 - crossterm = "0.28" 25 - tui-textarea = { version = "0.7", default-features = false, features = ["crossterm"] } 23 + rusqlite = { version = "0.32", features = ["bundled"] } 24 + tokio-rusqlite = "0.6" 25 + blake3 = "1" 26 + walkdir = "2" 27 + glob = "0.3" 26 28 27 29 [dev-dependencies] 28 30 tempfile = "3.15"
-20
TODO.md
··· 1 - # Things I want to do in this app 2 - 3 - - [x] Better interaction model - TUI 4 - - [x] Async channel integration with PlanningAgent 5 - - [x] Async channel integration with RalphLoop 6 - - [x] Spinner animation 7 - - [x] Help overlay 8 - - [x] Keyboard navigation in Kanban 9 - - [x] Thinking indication when running - no idea what model is doing 10 - - [x] Respond better to 429 requests 11 - - [ ] Resumable conversations/sessions 12 - - [ ] Wrapping/dynamically sized input for planning chats 13 - - [ ] Replace tool calls with tool results in planning chat view 14 - - [ ] New critique agent that will ruthlessly evaluate specs against plans 15 - - [ ] Configurable prompts for all agents 16 - - [ ] Utilize AGENTS.md if it exists 17 - - [ ] Focus on token minimization and cost control - maybe default to Haiku for implementation? Opus for critique, Sonnet for planning? 18 - - [ ] Configurable editing mode for planning, should default to insert 19 - - [ ] External logging for debugging capabilities 20 - - [x] fix error in tool call: `[write_file result: Error: Path denied: Cannot resolve path]`
+375
docs/implementation-plans/2026-02-07-v2-phase1/phase_01.md
··· 1 + # Rustagent V2 Phase 1a: Database + Projects 2 + 3 + **Goal:** Set up the central SQLite database with WAL mode, schema migrations, and project registration CRUD with CLI. 4 + 5 + **Architecture:** Single SQLite database at XDG data dir (`~/.local/share/rustagent/rustagent.db`). All access through one `tokio_rusqlite::Connection` (internally Arc-wrapped, Clone-cheap). WAL mode + `BEGIN IMMEDIATE` for all write transactions. Hand-rolled sequential migrations tracked via `schema_version` table. 6 + 7 + **Tech Stack:** Rust (edition 2024), rusqlite 0.32 (bundled), tokio-rusqlite 0.6, clap 4.5 (derive), serde/serde_json, chrono, uuid, dirs, anyhow, tokio 8 + 9 + **Scope:** Phase 1 of 4 from the v2 architecture (Phase 1a: Database + Projects) 10 + 11 + **Codebase verified:** 2026-02-07 12 + 13 + **Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` 14 + 15 + --- 16 + 17 + ## Acceptance Criteria Coverage 18 + 19 + This phase implements and tests: 20 + 21 + ### P1a.AC1: Database initialization 22 + - **P1a.AC1.1 Success:** Database file created at `~/.local/share/rustagent/rustagent.db` on first startup 23 + - **P1a.AC1.2 Success:** WAL mode enabled, `foreign_keys = ON`, `busy_timeout = 5000` 24 + - **P1a.AC1.3 Success:** All tables created: `schema_version`, `projects`, `nodes`, `edges`, `sessions`, `nodes_fts` (virtual), `worker_conversations`, plus FTS sync triggers and indexes 25 + - **P1a.AC1.4 Success:** `schema_version` table contains version 1 after fresh init 26 + 27 + ### P1a.AC2: Schema versioning and migrations 28 + - **P1a.AC2.1 Success:** Fresh database: full schema created, version set to 1 29 + - **P1a.AC2.2 Success:** Database at current version: no migration runs, proceeds normally 30 + - **P1a.AC2.3 Failure:** Database newer than binary: returns clear error "your database was created by a newer version of rustagent, please upgrade" 31 + 32 + ### P1a.AC3: Project registration 33 + - **P1a.AC3.1 Success:** `rustagent project add <name> <path>` registers a project with auto-generated ID (`ra-` + 4 hex chars) 34 + - **P1a.AC3.2 Success:** `rustagent project list` shows all registered projects 35 + - **P1a.AC3.3 Success:** `rustagent project show <name>` returns project details 36 + - **P1a.AC3.4 Success:** `rustagent project remove <name>` deletes a project record 37 + - **P1a.AC3.5 Failure:** Adding a project with a duplicate name returns error 38 + - **P1a.AC3.6 Success:** When no `--project` flag, CLI resolves project from current working directory by matching registered project paths 39 + 40 + --- 41 + 42 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 43 + 44 + <!-- START_TASK_1 --> 45 + ### Task 1: Add new dependencies to Cargo.toml 46 + 47 + **Files:** 48 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/Cargo.toml` 49 + 50 + **Implementation:** 51 + 52 + Add the following to `[dependencies]` (after the existing entries): 53 + 54 + ```toml 55 + rusqlite = { version = "0.32", features = ["bundled"] } 56 + tokio-rusqlite = "0.6" 57 + ``` 58 + 59 + Remove the TUI dependencies that are no longer needed (v2 replaces TUI with web UI): 60 + 61 + ```toml 62 + # REMOVE these three lines: 63 + ratatui = "0.29" 64 + crossterm = "0.28" 65 + tui-textarea = { version = "0.7", default-features = false, features = ["crossterm"] } 66 + ``` 67 + 68 + **Verification:** 69 + 70 + Run: `cargo check` 71 + Expected: Compiles without errors (TUI removal will cause compile errors in src/tui/ and main.rs — that's expected and addressed in Task 2) 72 + 73 + **Commit:** `chore: add rusqlite and tokio-rusqlite, remove TUI deps` 74 + 75 + <!-- END_TASK_1 --> 76 + 77 + <!-- START_TASK_2 --> 78 + ### Task 2: Remove TUI module and update main.rs/lib.rs 79 + 80 + **Files:** 81 + - Delete: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tui/` (entire directory) 82 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — remove `pub mod tui;` 83 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — remove `Tui` variant from `Commands` enum, remove the `Commands::Tui` match arm, remove the `unwrap_or(Commands::Tui)` default (replace with showing help when no subcommand) 84 + 85 + **Implementation:** 86 + 87 + In `lib.rs`, remove the `pub mod tui;` line. Keep all other module declarations. 88 + 89 + In `main.rs`: 90 + - Remove the `Tui` variant from `Commands` enum 91 + - Change `cli.command.unwrap_or(Commands::Tui)` to handle `None` by printing help and exiting 92 + - Remove the entire `Commands::Tui` match arm and its `use rustagent::tui` import 93 + 94 + The v1 `Init`, `Plan`, and `Run` commands stay for now — they'll be replaced incrementally as v2 modules come online. 95 + 96 + **Verification:** 97 + 98 + Run: `cargo check` 99 + Expected: Compiles cleanly. No references to ratatui, crossterm, or tui remain. 100 + 101 + Run: `cargo test` 102 + Expected: All existing tests still pass (TUI had no tests). 103 + 104 + **Commit:** `refactor: remove TUI module (replaced by web UI in v2)` 105 + 106 + <!-- END_TASK_2 --> 107 + 108 + <!-- START_TASK_3 --> 109 + ### Task 3: Create database module with initialization and migrations 110 + 111 + **Verifies:** P1a.AC1.1, P1a.AC1.2, P1a.AC1.3, P1a.AC1.4, P1a.AC2.1, P1a.AC2.2, P1a.AC2.3 112 + 113 + **Files:** 114 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/db/mod.rs` 115 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/db/migrations.rs` 116 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod db;` 117 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/db_test.rs` (integration) 118 + 119 + **Implementation:** 120 + 121 + `src/db/mod.rs`: 122 + - `Database` struct wrapping `tokio_rusqlite::Connection` 123 + - `Database::open(path: &Path) -> Result<Self>` — opens connection, calls `init_pragmas` then `run_migrations` 124 + - `Database::open_in_memory() -> Result<Self>` — for testing 125 + - `Database::connection(&self) -> &tokio_rusqlite::Connection` — accessor 126 + - Private `init_pragmas(conn: &rusqlite::Connection)` — sets WAL, foreign_keys, busy_timeout, wal_autocheckpoint 127 + - The `Database` should implement `Clone` (delegates to inner `Connection::clone()`) 128 + 129 + `src/db/migrations.rs`: 130 + - `const CURRENT_VERSION: u32 = 1;` 131 + - `pub fn run_migrations(conn: &rusqlite::Connection) -> Result<()>`: 132 + 1. Check if `schema_version` table exists (query `sqlite_master`) 133 + 2. If not: fresh DB — run `create_schema_v1(conn)`, insert version 1 134 + 3. If exists: read version. If == CURRENT_VERSION, return Ok. If > CURRENT_VERSION, return error. If < CURRENT_VERSION, run sequential migrations. 135 + - `fn create_schema_v1(conn: &rusqlite::Connection) -> Result<()>` — all CREATE TABLE/INDEX/TRIGGER/VIRTUAL TABLE statements from the architecture doc's Database Schema section 136 + 137 + The full schema includes: `schema_version`, `projects`, `nodes` (with 3 indexes), `edges` (with 3 indexes), `sessions`, `nodes_fts` (FTS5 virtual table with content sync), FTS sync triggers (nodes_ai, nodes_ad, nodes_au), `worker_conversations`. 138 + 139 + **Testing:** 140 + 141 + Tests must verify each AC listed above: 142 + - P1a.AC1.1: `Database::open` creates file at specified path 143 + - P1a.AC1.2: After open, query `PRAGMA journal_mode` returns "wal", `PRAGMA foreign_keys` returns 1 144 + - P1a.AC1.3: After open, all tables exist in `sqlite_master` (projects, nodes, edges, sessions, nodes_fts, worker_conversations, schema_version) 145 + - P1a.AC1.4: After fresh init, `schema_version` contains version 1 146 + - P1a.AC2.1: `open_in_memory()` creates full schema and sets version 1 147 + - P1a.AC2.2: Opening an already-initialized DB does not error and version remains 1 148 + - P1a.AC2.3: Manually set version to 999, reopen — returns error containing "newer version" 149 + 150 + Use `Database::open_in_memory()` for most tests. Use `tempfile::TempDir` for the file creation test. 151 + 152 + Follow project testing patterns: integration tests in `tests/db_test.rs`, `#[tokio::test]` for async, `assert_eq!`/`assert!` for assertions. 153 + 154 + **Verification:** 155 + Run: `cargo test db_test` 156 + Expected: All tests pass 157 + 158 + **Commit:** `feat(db): database initialization with WAL mode, schema v1, and migration framework` 159 + 160 + <!-- END_TASK_3 --> 161 + <!-- END_SUBCOMPONENT_A --> 162 + 163 + <!-- START_SUBCOMPONENT_B (tasks 4-5) --> 164 + 165 + <!-- START_TASK_4 --> 166 + ### Task 4: Create Project type and store 167 + 168 + **Verifies:** P1a.AC3.1, P1a.AC3.5 169 + 170 + **Files:** 171 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/project.rs` 172 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod project;` 173 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/project_test.rs` (integration) 174 + 175 + **Implementation:** 176 + 177 + `src/project.rs`: 178 + 179 + ```rust 180 + pub struct Project { 181 + pub id: String, 182 + pub name: String, 183 + pub path: PathBuf, 184 + pub registered_at: DateTime<Utc>, 185 + pub config_overrides: Option<String>, // JSON string, stored as TEXT in DB 186 + pub metadata: String, // JSON string, stored as TEXT in DB, default "{}" 187 + } 188 + ``` 189 + 190 + **Architecture deviation:** The architecture shows `config_overrides: Option<ProjectConfig>` and `metadata: HashMap<String, String>`. This implementation stores both as JSON strings in the database (matching the SQLite TEXT columns). The typed structs can be deserialized on demand when accessed by application code. This avoids a deserialization step on every DB read and matches the DB schema directly. 191 + 192 + `ProjectStore` struct wrapping `Database`: 193 + - `new(db: Database) -> Self` 194 + - `async fn add(&self, name: &str, path: &Path) -> Result<Project>` — generates ID (`ra-` + 4 hex chars from uuid v4), inserts with `BEGIN IMMEDIATE`, returns the created `Project`. Fails if name already exists (UNIQUE constraint). 195 + - `async fn list(&self) -> Result<Vec<Project>>` — returns all projects ordered by name 196 + - `async fn get_by_name(&self, name: &str) -> Result<Option<Project>>` — lookup by name 197 + - `async fn get_by_path(&self, path: &Path) -> Result<Option<Project>>` — lookup by path (for cwd resolution). Canonicalize both stored path and query path before comparison. 198 + - `async fn remove(&self, name: &str) -> Result<bool>` — delete by name, returns true if deleted 199 + 200 + ID generation: `format!("ra-{}", &uuid::Uuid::new_v4().to_string().replace("-", "")[..4])` 201 + 202 + All write operations use `BEGIN IMMEDIATE` via `conn.call(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; ... })`. 203 + 204 + **Testing:** 205 + 206 + Tests must verify: 207 + - P1a.AC3.1: `add("my-api", "/tmp/test")` creates project with `ra-` prefixed 4-char hex ID, correct name and path 208 + - P1a.AC3.5: Adding two projects with the same name returns an error 209 + 210 + Use `Database::open_in_memory()`. Follow project patterns: `#[tokio::test]`, `tests/project_test.rs`. 211 + 212 + **Verification:** 213 + Run: `cargo test project_test` 214 + Expected: All tests pass 215 + 216 + **Commit:** `feat(project): Project type and ProjectStore with CRUD operations` 217 + 218 + <!-- END_TASK_4 --> 219 + 220 + <!-- START_TASK_5 --> 221 + ### Task 5: Project store — list, show, remove, resolve from cwd 222 + 223 + **Verifies:** P1a.AC3.2, P1a.AC3.3, P1a.AC3.4, P1a.AC3.6 224 + 225 + **Files:** 226 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/project.rs` (if not already covered in Task 4) 227 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/project_test.rs` (add more tests) 228 + 229 + **Implementation:** 230 + 231 + This task adds tests for the remaining ProjectStore methods implemented in Task 4. If any methods were left as stubs, implement them now. 232 + 233 + **Testing:** 234 + 235 + Tests must verify: 236 + - P1a.AC3.2: After adding 3 projects, `list()` returns all 3 ordered by name 237 + - P1a.AC3.3: After adding a project, `get_by_name("my-api")` returns the project with correct details 238 + - P1a.AC3.4: After adding then removing a project, `get_by_name` returns None 239 + - P1a.AC3.6: After adding a project with path `/tmp/test-proj`, `get_by_path("/tmp/test-proj")` returns it 240 + 241 + **Verification:** 242 + Run: `cargo test project_test` 243 + Expected: All tests pass 244 + 245 + **Commit:** `test(project): complete project store test coverage` 246 + 247 + <!-- END_TASK_5 --> 248 + <!-- END_SUBCOMPONENT_B --> 249 + 250 + <!-- START_SUBCOMPONENT_C (tasks 6-7) --> 251 + 252 + <!-- START_TASK_6 --> 253 + ### Task 6: Wire up `project` CLI subcommand 254 + 255 + **Files:** 256 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add `Project` subcommand with nested `add`/`list`/`show`/`remove` 257 + 258 + **Implementation:** 259 + 260 + Add a `Project` variant to `Commands` with nested subcommands: 261 + 262 + ```rust 263 + /// Manage projects 264 + Project { 265 + #[command(subcommand)] 266 + action: ProjectAction, 267 + }, 268 + ``` 269 + 270 + ```rust 271 + #[derive(Subcommand)] 272 + enum ProjectAction { 273 + /// Register a project 274 + Add { 275 + /// Friendly name for the project 276 + name: String, 277 + /// Path to the project directory 278 + path: String, 279 + }, 280 + /// List all registered projects 281 + List, 282 + /// Show project details 283 + Show { 284 + /// Project name 285 + name: String, 286 + }, 287 + /// Remove a registered project 288 + Remove { 289 + /// Project name 290 + name: String, 291 + }, 292 + } 293 + ``` 294 + 295 + In the `main()` match: 296 + - `ProjectAction::Add` — open database (see helper below), create `ProjectStore`, call `add()`, print the created project 297 + - `ProjectAction::List` — open database, list, print formatted table 298 + - `ProjectAction::Show` — open database, get_by_name, print details (or "not found") 299 + - `ProjectAction::Remove` — open database, remove, print confirmation 300 + 301 + Add a helper function to get the database path and open it: 302 + 303 + ```rust 304 + fn db_path() -> anyhow::Result<PathBuf> { 305 + let data_dir = dirs::data_dir() 306 + .ok_or_else(|| anyhow::anyhow!("Could not determine XDG data directory"))?; 307 + let db_dir = data_dir.join("rustagent"); 308 + std::fs::create_dir_all(&db_dir)?; 309 + Ok(db_dir.join("rustagent.db")) 310 + } 311 + ``` 312 + 313 + **Verification:** 314 + 315 + Run: `cargo build` 316 + Expected: Compiles cleanly 317 + 318 + Run: `cargo run -- project add test-proj .` 319 + Expected: Prints something like "Registered project 'test-proj' (ra-a3f8) at /Users/david.hagerty/code/personal/rustagent/new-directions" 320 + 321 + Run: `cargo run -- project list` 322 + Expected: Shows the registered project 323 + 324 + Run: `cargo run -- project show test-proj` 325 + Expected: Shows project details 326 + 327 + Run: `cargo run -- project remove test-proj` 328 + Expected: Prints confirmation 329 + 330 + **Commit:** `feat(cli): add project subcommand (add/list/show/remove)` 331 + 332 + <!-- END_TASK_6 --> 333 + 334 + <!-- START_TASK_7 --> 335 + ### Task 7: Add `--project` flag and cwd resolution to CLI 336 + 337 + **Files:** 338 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add global `--project` flag to `Cli` struct 339 + 340 + **Implementation:** 341 + 342 + Add a `--project` flag to the top-level `Cli` struct: 343 + 344 + ```rust 345 + #[derive(Parser)] 346 + #[command(name = "rustagent")] 347 + struct Cli { 348 + /// Project name (if omitted, resolves from current directory) 349 + #[arg(long, global = true)] 350 + project: Option<String>, 351 + 352 + #[command(subcommand)] 353 + command: Option<Commands>, 354 + } 355 + ``` 356 + 357 + Add a helper function `resolve_project` that: 358 + 1. If `--project <name>` was given, looks up by name 359 + 2. If not, gets the current working directory and looks up by path 360 + 3. Returns `Option<Project>` (None is valid — some commands don't need a project) 361 + 362 + This resolver is not used by the `project` subcommand itself (which takes explicit name args), but will be used by later commands like `run`, `tasks`, `status`. For now, just define the function — it gets exercised when those commands are wired up in Phase 1b. 363 + 364 + **Verification:** 365 + 366 + Run: `cargo build` 367 + Expected: Compiles cleanly 368 + 369 + Run: `cargo run -- --help` 370 + Expected: Shows `--project` in global options 371 + 372 + **Commit:** `feat(cli): add --project global flag with cwd resolution helper` 373 + 374 + <!-- END_TASK_7 --> 375 + <!-- END_SUBCOMPONENT_C -->
+535
docs/implementation-plans/2026-02-07-v2-phase1/phase_02.md
··· 1 + # Rustagent V2 Phase 1b: Graph Model + Node Lifecycle 2 + 3 + **Goal:** Implement the unified work graph — types, SQLite store, query builders, dependency resolution, FTS5 search, graph tools for agents, and CLI commands. 4 + 5 + **Architecture:** All entities (goals, tasks, decisions, options, outcomes, observations, revisits) are nodes in one DAG. Relationships are edges. Task surfacing (`ready`, `next`) is derived from dependency resolution over `DependsOn` edges. FTS5 provides full-text search. Atomic task claiming via conditional UPDATE. All writes through single `tokio_rusqlite::Connection` with `BEGIN IMMEDIATE`. 6 + 7 + **Tech Stack:** Rust (edition 2024), rusqlite 0.32 (bundled), tokio-rusqlite 0.6, async-trait, serde/serde_json, chrono, uuid, anyhow 8 + 9 + **Scope:** Phase 2 of 4 from the v2 architecture (Phase 1b: Graph Model + Node Lifecycle) 10 + 11 + **Codebase verified:** 2026-02-07 12 + 13 + **Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` 14 + 15 + **Depends on:** Phase 1a (database module, project store) 16 + 17 + --- 18 + 19 + ## Acceptance Criteria Coverage 20 + 21 + This phase implements and tests: 22 + 23 + ### P1b.AC1: Graph node types and data model 24 + - **P1b.AC1.1 Success:** `GraphNode` struct with all fields from architecture (id, project_id, node_type, title, description, status, priority, assigned_to, created_by, labels, timestamps, blocked_reason, metadata) 25 + - **P1b.AC1.2 Success:** 7 `NodeType` variants: Goal, Task, Decision, Option, Outcome, Observation, Revisit 26 + - **P1b.AC1.3 Success:** `NodeStatus` enum with all variants, valid status transitions per node type enforced 27 + - **P1b.AC1.4 Success:** `GraphEdge` struct with id, edge_type, from_node, to_node, label, created_at 28 + - **P1b.AC1.5 Success:** 7 `EdgeType` variants: Contains, DependsOn, LeadsTo, Chosen, Rejected, Supersedes, Informs 29 + 30 + ### P1b.AC2: Hierarchical ID generation 31 + - **P1b.AC2.1 Success:** Goal IDs: `ra-` + 4 hex chars from UUID v4 32 + - **P1b.AC2.2 Success:** Child IDs: parent_id + `.N` where N is sequential counter from parent's `next_child_seq` metadata 33 + - **P1b.AC2.3 Success:** Full dotted path is the primary key (e.g., `ra-a3f8.1.3`) 34 + - **P1b.AC2.4 Success:** Edge IDs: `e-` + 8 hex chars from UUID v4 35 + 36 + ### P1b.AC3: GraphStore CRUD 37 + - **P1b.AC3.1 Success:** `create_node` inserts node with all fields, returns Ok 38 + - **P1b.AC3.2 Success:** `get_node(id)` returns the node or None 39 + - **P1b.AC3.3 Success:** `update_node` modifies status and/or metadata 40 + - **P1b.AC3.4 Success:** `add_edge` inserts edge; `get_edges` returns edges for a node 41 + - **P1b.AC3.5 Success:** `get_children(node_id)` returns child nodes via Contains edges 42 + - **P1b.AC3.6 Success:** `get_subtree(node_id)` returns all descendant nodes recursively 43 + 44 + ### P1b.AC4: Dependency resolution and task surfacing 45 + - **P1b.AC4.1 Success:** Task moves from Pending to Ready when all DependsOn targets are Completed 46 + - **P1b.AC4.2 Success:** `get_ready_tasks(goal_id)` returns only tasks in Ready status with all deps satisfied 47 + - **P1b.AC4.3 Success:** `get_next_task(goal_id)` returns highest-priority Ready task, breaking ties by downstream unblock count 48 + 49 + ### P1b.AC5: Atomic task claiming 50 + - **P1b.AC5.1 Success:** `claim_task(node_id, agent_id)` sets status to Claimed and assigned_to atomically 51 + - **P1b.AC5.2 Success:** If task is not Ready, claim returns false (another worker got there first) 52 + 53 + ### P1b.AC6: Full-text search 54 + - **P1b.AC6.1 Success:** `search_nodes(query)` returns nodes matching title or description via FTS5 55 + - **P1b.AC6.2 Success:** Search can filter by project_id and node_type 56 + 57 + ### P1b.AC7: Graph tools for agents 58 + - **P1b.AC7.1 Success:** All low-level tools work: `create_node`, `update_node`, `add_edge`, `query_nodes`, `search_nodes` 59 + - **P1b.AC7.2 Success:** All high-level tools work: `claim_task`, `log_decision`, `choose_option`, `record_outcome`, `record_observation`, `revisit` 60 + 61 + --- 62 + 63 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 64 + 65 + <!-- START_TASK_1 --> 66 + ### Task 1: Graph types — NodeType, EdgeType, NodeStatus, Priority, GraphNode, GraphEdge 67 + 68 + **Verifies:** P1b.AC1.1, P1b.AC1.2, P1b.AC1.3, P1b.AC1.4, P1b.AC1.5 69 + 70 + **Files:** 71 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` 72 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod graph;` 73 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_types_test.rs` 74 + 75 + **Implementation:** 76 + 77 + `src/graph/mod.rs` — define these types (all `#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]`): 78 + 79 + - `NodeType` enum: Goal, Task, Decision, Option, Outcome, Observation, Revisit. Implement `Display` and `FromStr` for serialization to/from the lowercase string used in the DB (`"goal"`, `"task"`, etc.). 80 + 81 + - `EdgeType` enum: Contains, DependsOn, LeadsTo, Chosen, Rejected, Supersedes, Informs. Same Display/FromStr pattern. 82 + 83 + - `NodeStatus` enum: Pending, Active, Completed, Cancelled, Ready, Claimed, InProgress, Review, Blocked, Failed, Decided, Superseded, Abandoned, Chosen, Rejected. Same Display/FromStr. 84 + 85 + - `Priority` enum: Critical, High, Medium, Low. Same Display/FromStr. 86 + 87 + - `GraphNode` struct per architecture (line 453-469 of design doc). Fields: 88 + - `id: String`, `project_id: String`, `node_type: NodeType`, `title: String`, `description: String`, `status: NodeStatus`, `priority: Option<Priority>`, `assigned_to: Option<String>`, `created_by: Option<String>`, `labels: Vec<String>`, `created_at: DateTime<Utc>`, `started_at: Option<DateTime<Utc>>`, `completed_at: Option<DateTime<Utc>>`, `blocked_reason: Option<String>`, `metadata: HashMap<String, String>` 89 + 90 + - `GraphEdge` struct per architecture (line 393-401). Fields: 91 + - `id: String`, `edge_type: EdgeType`, `from_node: String`, `to_node: String`, `label: Option<String>`, `created_at: DateTime<Utc>` 92 + 93 + - `fn valid_statuses(node_type: &NodeType) -> Vec<NodeStatus>` — returns the valid statuses for each node type per the architecture (lines 442-448). 94 + 95 + - `fn validate_status(node_type: &NodeType, status: &NodeStatus) -> Result<()>` — returns error if status is not valid for the node type. 96 + 97 + Declare sub-modules: `pub mod store;`, `pub mod dependency;` 98 + 99 + **Note:** The architecture lists a separate `src/graph/query.rs` for query builders. In this implementation, `NodeQuery`, `EdgeDirection`, and `WorkGraph` are defined directly in `store.rs` alongside the `GraphStore` trait, avoiding premature file separation. The query builder functionality is part of the trait contract. 100 + 101 + **Testing:** 102 + 103 + Tests must verify: 104 + - P1b.AC1.2: All 7 NodeType variants roundtrip through Display/FromStr 105 + - P1b.AC1.3: `validate_status(Task, Ready)` is Ok; `validate_status(Goal, Ready)` is Err 106 + - P1b.AC1.5: All 7 EdgeType variants roundtrip through Display/FromStr 107 + - Serialization: GraphNode and GraphEdge serialize to/from JSON correctly 108 + 109 + **Verification:** 110 + Run: `cargo test graph_types_test` 111 + Expected: All tests pass 112 + 113 + **Commit:** `feat(graph): core types — NodeType, EdgeType, NodeStatus, GraphNode, GraphEdge` 114 + 115 + <!-- END_TASK_1 --> 116 + 117 + <!-- START_TASK_2 --> 118 + ### Task 2: ID generation helpers 119 + 120 + **Verifies:** P1b.AC2.1, P1b.AC2.2, P1b.AC2.3, P1b.AC2.4 121 + 122 + **Files:** 123 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add ID generation functions 124 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_types_test.rs` — add ID tests 125 + 126 + **Implementation:** 127 + 128 + Add to `src/graph/mod.rs`: 129 + 130 + - `pub fn generate_goal_id() -> String` — `format!("ra-{}", &uuid::Uuid::new_v4().simple().to_string()[..4])` 131 + - `pub fn generate_child_id(parent_id: &str, seq: u32) -> String` — `format!("{}.{}", parent_id, seq)` 132 + - `pub fn generate_edge_id() -> String` — `format!("e-{}", &uuid::Uuid::new_v4().simple().to_string()[..8])` 133 + - `pub fn parent_id(id: &str) -> Option<&str>` — extracts parent from hierarchical ID (e.g., `"ra-a3f8.1.3"` -> `Some("ra-a3f8.1")`) 134 + 135 + **Testing:** 136 + 137 + Tests must verify: 138 + - P1b.AC2.1: `generate_goal_id()` starts with `"ra-"` and has 4 hex chars after prefix 139 + - P1b.AC2.2: `generate_child_id("ra-a3f8", 1)` returns `"ra-a3f8.1"`; `generate_child_id("ra-a3f8.1", 3)` returns `"ra-a3f8.1.3"` 140 + - P1b.AC2.3: Generated IDs are valid as primary keys (no special chars beyond `-` and `.`) 141 + - P1b.AC2.4: `generate_edge_id()` starts with `"e-"` and has 8 hex chars 142 + - `parent_id("ra-a3f8.1.3")` returns `Some("ra-a3f8.1")`; `parent_id("ra-a3f8")` returns `None` 143 + 144 + **Verification:** 145 + Run: `cargo test graph_types_test` 146 + Expected: All tests pass 147 + 148 + **Commit:** `feat(graph): hierarchical ID generation (goal, child, edge)` 149 + 150 + <!-- END_TASK_2 --> 151 + <!-- END_SUBCOMPONENT_A --> 152 + 153 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 154 + 155 + <!-- START_TASK_3 --> 156 + ### Task 3: GraphStore trait definition 157 + 158 + **Files:** 159 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` 160 + 161 + **Implementation:** 162 + 163 + Define the `GraphStore` trait from the architecture (lines 1742-1778 of design doc). This is a trait definition only — no implementation yet. 164 + 165 + **Architecture deviation:** The architecture's `update_node` takes `&GraphNode` (full struct replacement). This implementation uses a partial-update signature with optional fields instead, which avoids read-modify-write races — callers update only the fields they intend to change without needing to read the full node first. 166 + 167 + ```rust 168 + #[async_trait] 169 + pub trait GraphStore: Send + Sync { 170 + // Node CRUD 171 + async fn create_node(&self, node: &GraphNode) -> Result<()>; 172 + async fn update_node(&self, id: &str, status: Option<NodeStatus>, title: Option<&str>, description: Option<&str>, metadata: Option<&HashMap<String, String>>) -> Result<()>; 173 + async fn get_node(&self, id: &str) -> Result<Option<GraphNode>>; 174 + async fn query_nodes(&self, query: &NodeQuery) -> Result<Vec<GraphNode>>; 175 + 176 + // Task-specific 177 + async fn claim_task(&self, node_id: &str, agent_id: &str) -> Result<bool>; 178 + async fn get_ready_tasks(&self, goal_id: &str) -> Result<Vec<GraphNode>>; 179 + async fn get_next_task(&self, goal_id: &str) -> Result<Option<GraphNode>>; 180 + 181 + // Edge operations 182 + async fn add_edge(&self, edge: &GraphEdge) -> Result<()>; 183 + async fn remove_edge(&self, edge_id: &str) -> Result<()>; 184 + async fn get_edges(&self, node_id: &str, direction: EdgeDirection) -> Result<Vec<(GraphEdge, GraphNode)>>; 185 + 186 + // Graph queries 187 + async fn get_children(&self, node_id: &str) -> Result<Vec<(GraphNode, EdgeType)>>; 188 + async fn get_subtree(&self, node_id: &str) -> Result<Vec<GraphNode>>; 189 + async fn get_active_decisions(&self, project_id: &str) -> Result<Vec<GraphNode>>; // Now mode 190 + async fn get_full_graph(&self, goal_id: &str) -> Result<WorkGraph>; // History mode 191 + async fn search_nodes(&self, query: &str, project_id: Option<&str>, node_type: Option<NodeType>, limit: usize) -> Result<Vec<GraphNode>>; 192 + 193 + // Child ID sequencing 194 + async fn next_child_seq(&self, parent_id: &str) -> Result<u32>; 195 + } 196 + 197 + pub enum EdgeDirection { Outgoing, Incoming, Both } 198 + 199 + pub struct WorkGraph { 200 + pub nodes: Vec<GraphNode>, 201 + pub edges: Vec<GraphEdge>, 202 + } 203 + 204 + pub struct NodeQuery { 205 + pub node_type: Option<NodeType>, 206 + pub status: Option<NodeStatus>, 207 + pub project_id: Option<String>, 208 + pub parent_id: Option<String>, 209 + pub query: Option<String>, 210 + } 211 + ``` 212 + 213 + **Verification:** 214 + Run: `cargo check` 215 + Expected: Compiles (trait is unused for now, that's fine) 216 + 217 + **Commit:** `feat(graph): GraphStore trait definition` 218 + 219 + <!-- END_TASK_3 --> 220 + 221 + <!-- START_TASK_4 --> 222 + ### Task 4: SqliteGraphStore — node and edge CRUD 223 + 224 + **Verifies:** P1b.AC3.1, P1b.AC3.2, P1b.AC3.3, P1b.AC3.4, P1b.AC3.5, P1b.AC3.6 225 + 226 + **Files:** 227 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` — add `SqliteGraphStore` implementing `GraphStore` 228 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_store_test.rs` 229 + 230 + **Implementation:** 231 + 232 + `SqliteGraphStore` struct wrapping `Database`: 233 + 234 + - `new(db: Database) -> Self` 235 + - Implement all `GraphStore` trait methods using `db.connection().call(...)` with `BEGIN IMMEDIATE` for writes. 236 + 237 + Key implementation details: 238 + 239 + - **create_node**: INSERT into `nodes` table. Serialize `labels` as JSON array, `metadata` as JSON object. If the node has a parent (determined by `parent_id()` helper), call `next_child_seq` first to get the sequence number. Store `next_child_seq` in the parent's metadata atomically (read current seq, increment, update parent metadata, insert child — all in one `BEGIN IMMEDIATE` transaction). 240 + 241 + - **update_node**: UPDATE with optional fields. Only set columns that are `Some`. Validate status against node_type before updating. 242 + 243 + - **get_node**: SELECT by id. Deserialize labels from JSON array, metadata from JSON object. 244 + 245 + - **add_edge**: INSERT into edges. Validate that both from_node and to_node exist. 246 + 247 + - **get_edges**: SELECT edges + JOIN nodes based on direction (Outgoing: from_node = id; Incoming: to_node = id; Both: either). 248 + 249 + - **get_children**: SELECT nodes joined via edges WHERE edge_type = 'contains' AND from_node = parent_id. 250 + 251 + - **get_subtree**: Recursive CTE (`WITH RECURSIVE`) walking Contains edges downward from the given node. 252 + 253 + - **get_active_decisions**: Query Decision nodes for the project where status is Active or Decided. "Now mode" — returns the current truth (active decisions only, no abandoned/superseded). 254 + 255 + - **get_full_graph**: Get the full subtree of nodes under a goal, plus all edges involving those nodes. Returns a `WorkGraph` struct containing both nodes and edges. "History mode" — includes abandoned paths and superseded decisions. 256 + 257 + - **next_child_seq**: Read parent node's `metadata.next_child_seq` (default 1 if absent), increment it, update parent metadata, return the old value. All in one `BEGIN IMMEDIATE` transaction. 258 + 259 + Row-to-struct mapping: implement a helper function `fn row_to_node(row: &rusqlite::Row) -> rusqlite::Result<GraphNode>` that maps column indices to struct fields. Same for `row_to_edge`. 260 + 261 + **Testing:** 262 + 263 + Tests must verify each AC: 264 + - P1b.AC3.1: Create a goal node, verify it's retrievable 265 + - P1b.AC3.2: `get_node` returns None for nonexistent ID 266 + - P1b.AC3.3: Create node as Pending, update to Active, verify status changed 267 + - P1b.AC3.4: Create two nodes, add Contains edge, verify `get_edges(Outgoing)` returns it 268 + - P1b.AC3.5: Create goal + 2 child tasks via Contains edges, verify `get_children` returns both 269 + - P1b.AC3.6: Create goal -> task -> subtask chain, verify `get_subtree(goal_id)` returns all 3 270 + - `get_active_decisions`: Create 2 decisions under a project — one Active, one Superseded. Verify `get_active_decisions` returns only the Active one. 271 + - `get_full_graph`: Create a goal with tasks and edges. Verify `get_full_graph` returns a `WorkGraph` containing all nodes and edges. 272 + 273 + Use `Database::open_in_memory()`. Create a helper to build test nodes with sensible defaults. 274 + 275 + **Verification:** 276 + Run: `cargo test graph_store_test` 277 + Expected: All tests pass 278 + 279 + **Commit:** `feat(graph): SqliteGraphStore with node/edge CRUD, subtree queries` 280 + 281 + <!-- END_TASK_4 --> 282 + <!-- END_SUBCOMPONENT_B --> 283 + 284 + <!-- START_SUBCOMPONENT_C (tasks 5-6) --> 285 + 286 + <!-- START_TASK_5 --> 287 + ### Task 5: Dependency resolution and task surfacing 288 + 289 + **Verifies:** P1b.AC4.1, P1b.AC4.2, P1b.AC4.3 290 + 291 + **Files:** 292 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/dependency.rs` 293 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` — implement `get_ready_tasks` and `get_next_task` 294 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_dependency_test.rs` 295 + 296 + **Implementation:** 297 + 298 + `src/graph/dependency.rs`: 299 + - `pub fn check_dependencies_met(conn: &rusqlite::Connection, node_id: &str) -> rusqlite::Result<bool>` — query all DependsOn edges from this node, check if all target nodes have status Completed. Returns true if all deps are met (or no deps exist). 300 + 301 + In `SqliteGraphStore`: 302 + - **get_ready_tasks**: Query task nodes under goal where status = 'ready'. A task is Ready when it was moved there by the status update logic (see below). 303 + - **get_next_task**: From ready tasks, sort by: (1) priority (Critical > High > Medium > Low), (2) downstream count (COUNT of nodes that transitively DependsOn this task), (3) break ties by created_at. Return the first. 304 + 305 + Status transition hook: When `update_node` completes a task (status -> Completed), scan all nodes that DependsOn it. For each, if all DependsOn targets are now Completed and current status is Pending, update to Ready. This runs within the same `BEGIN IMMEDIATE` transaction as the status update. 306 + 307 + **Testing:** 308 + 309 + Tests must verify: 310 + - P1b.AC4.1: Create task A (Pending) and task B (Pending, DependsOn A). Complete A. Verify B is now Ready. 311 + - P1b.AC4.2: Create 3 tasks under a goal — one Ready, one Pending (dep not met), one Completed. `get_ready_tasks` returns only the Ready one. 312 + - P1b.AC4.3: Create 2 Ready tasks — one High priority blocking 3 downstream tasks, one Critical priority blocking 0. `get_next_task` returns the Critical one (priority wins over downstream count). 313 + 314 + **Verification:** 315 + Run: `cargo test graph_dependency_test` 316 + Expected: All tests pass 317 + 318 + **Commit:** `feat(graph): dependency resolution, ready surfacing, next task recommendation` 319 + 320 + <!-- END_TASK_5 --> 321 + 322 + <!-- START_TASK_6 --> 323 + ### Task 6: Atomic task claiming and FTS5 search 324 + 325 + **Verifies:** P1b.AC5.1, P1b.AC5.2, P1b.AC6.1, P1b.AC6.2 326 + 327 + **Files:** 328 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/store.rs` — implement `claim_task` and `search_nodes` 329 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_claim_search_test.rs` 330 + 331 + **Implementation:** 332 + 333 + **claim_task**: Single conditional UPDATE within `BEGIN IMMEDIATE`: 334 + ```sql 335 + UPDATE nodes SET status = 'claimed', assigned_to = ?1, started_at = ?2 336 + WHERE id = ?3 AND status = 'ready'; 337 + ``` 338 + Check `conn.changes() == 1`. If so, return `Ok(true)`. If 0, return `Ok(false)`. 339 + 340 + **search_nodes**: FTS5 MATCH query: 341 + ```sql 342 + SELECT n.* FROM nodes n 343 + JOIN nodes_fts fts ON n.rowid = fts.rowid 344 + WHERE nodes_fts MATCH ?1 345 + ``` 346 + Add optional WHERE clauses for `project_id` and `node_type` filters. Add `LIMIT` clause. 347 + 348 + Note: The FTS5 sync triggers (created in Phase 1a schema) keep `nodes_fts` in sync automatically. No application code needed for sync. 349 + 350 + **Testing:** 351 + 352 + Tests must verify: 353 + - P1b.AC5.1: Create a Ready task, `claim_task(id, "agent-1")` returns true. Node now has status Claimed and assigned_to = "agent-1". 354 + - P1b.AC5.2: Create a Ready task, claim it once (true), claim it again (false — already claimed). 355 + - P1b.AC6.1: Create nodes with various titles/descriptions. `search_nodes("authentication")` returns nodes containing that term. 356 + - P1b.AC6.2: Create nodes in different projects. Search with `project_id` filter returns only nodes from that project. Create nodes of different types (e.g., Task and Observation). Search with `node_type` filter returns only nodes of that type. 357 + 358 + **Verification:** 359 + Run: `cargo test graph_claim_search_test` 360 + Expected: All tests pass 361 + 362 + **Commit:** `feat(graph): atomic task claiming and FTS5 full-text search` 363 + 364 + <!-- END_TASK_6 --> 365 + <!-- END_SUBCOMPONENT_C --> 366 + 367 + <!-- START_SUBCOMPONENT_D (tasks 7-8) --> 368 + 369 + <!-- START_TASK_7 --> 370 + ### Task 7: Graph tools for agents — low-level and high-level 371 + 372 + **Verifies:** P1b.AC7.1, P1b.AC7.2 373 + 374 + **Files:** 375 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tools/graph_tools.rs` 376 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tools/mod.rs` — add `pub mod graph_tools;` 377 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_tools_test.rs` 378 + 379 + **Implementation:** 380 + 381 + Each tool implements the existing `Tool` trait (async_trait, name/description/parameters/execute). Tools receive a `SqliteGraphStore` (via `Arc`) at construction time. 382 + 383 + **Low-level tools** (thin wrappers around GraphStore): 384 + - `CreateNodeTool` — params: `{ node_type, title, description, parent_id?, priority?, metadata? }`. If `parent_id` is given, generates child ID using `next_child_seq`. If not, generates goal ID. Creates the Contains edge if parent exists. 385 + - `UpdateNodeTool` — params: `{ node_id, status?, title?, description?, metadata? }`. Validates status transitions. 386 + - `AddEdgeTool` — params: `{ edge_type, from_node, to_node, label? }`. Generates edge ID. 387 + - `QueryNodesTool` — params: `{ node_type?, status?, project_id?, parent_id?, query? }`. Returns JSON array of matching nodes. 388 + - `SearchNodesTool` — params: `{ query, project_id?, node_type?, limit? }`. FTS5 search, returns JSON results. 389 + 390 + **High-level tools** (workflow shortcuts composing multiple store operations): 391 + - `ClaimTaskTool` — params: `{ node_id }`. Calls `claim_task`. 392 + - `LogDecisionTool` — params: `{ title, description, options: [{ title, description, pros?, cons? }], parent_id? }`. Creates Decision node + Option nodes + LeadsTo edges in one call. 393 + - `ChooseOptionTool` — params: `{ decision_id, option_id, rationale }`. Adds Chosen edge, Rejected edges to other options, updates Decision status to Decided. 394 + - `RecordOutcomeTool` — params: `{ parent_id, title, description, success }`. Creates Outcome node + LeadsTo edge. 395 + - `RecordObservationTool` — params: `{ title, description, related_node_id? }`. Creates Observation node + Informs edge if related_node_id given. 396 + - `RevisitTool` — params: `{ outcome_id, reason, new_decision_title? }`. Creates Revisit node + LeadsTo edge. If new_decision_title given, creates new Decision node + LeadsTo edge from Revisit. 397 + 398 + Each tool's `parameters()` method returns a JSON schema describing its params. 399 + Each tool's `execute()` method parses params from `serde_json::Value`, calls the store, and returns a JSON string result. 400 + 401 + **Testing:** 402 + 403 + Tests must verify: 404 + - P1b.AC7.1: Create a node via `CreateNodeTool::execute()`, verify it exists in the store. Same for update, add_edge, query, search. 405 + - P1b.AC7.2: Use `LogDecisionTool` to create a decision with 2 options, verify Decision + 2 Option nodes + 2 LeadsTo edges created. Use `ChooseOptionTool` to pick one, verify Chosen/Rejected edges and status updates. Use `RecordOutcomeTool`, verify Outcome + LeadsTo edge. Use `RecordObservationTool`, verify Observation + Informs edge. 406 + 407 + Use `Database::open_in_memory()` and construct tools with `Arc<SqliteGraphStore>`. 408 + 409 + **Verification:** 410 + Run: `cargo test graph_tools_test` 411 + Expected: All tests pass 412 + 413 + **Commit:** `feat(tools): graph tools for agents — create, update, query, search, claim, decision workflow` 414 + 415 + <!-- END_TASK_7 --> 416 + 417 + <!-- START_TASK_8 --> 418 + ### Task 8: Wire up graph CLI commands 419 + 420 + **Files:** 421 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add `Tasks`, `Decisions`, `Status`, `Search` subcommands 422 + 423 + **Implementation:** 424 + 425 + Add new `Commands` variants: 426 + 427 + ```rust 428 + /// View and manage tasks 429 + Tasks { 430 + #[command(subcommand)] 431 + action: Option<TaskAction>, 432 + }, 433 + /// View and manage decisions 434 + Decisions { 435 + #[command(subcommand)] 436 + action: Option<DecisionAction>, 437 + }, 438 + /// Show project status 439 + Status, 440 + /// Search graph nodes 441 + Search { 442 + /// Search query 443 + query: String, 444 + }, 445 + ``` 446 + 447 + ```rust 448 + #[derive(Subcommand)] 449 + enum TaskAction { 450 + /// List all tasks (filterable) 451 + List { 452 + #[arg(long)] status: Option<String>, 453 + #[arg(long)] priority: Option<String>, 454 + }, 455 + /// Show ready tasks 456 + Ready, 457 + /// Recommend next task 458 + Next, 459 + /// Show task tree 460 + Tree, 461 + } 462 + 463 + #[derive(Subcommand)] 464 + enum DecisionAction { 465 + /// List active decisions 466 + List, 467 + /// Current truth — active decisions only 468 + Now, 469 + /// Full evolution including abandoned paths 470 + History, 471 + /// Show decision details 472 + Show { id: String }, 473 + } 474 + ``` 475 + 476 + Each command: 477 + 1. Resolves project via `--project` flag or cwd 478 + 2. Opens database, creates `SqliteGraphStore` 479 + 3. Calls the appropriate store method 480 + 4. Formats and prints results 481 + 482 + Keep formatting simple — structured text output. Fancy formatting is not a priority. 483 + 484 + **Verification:** 485 + 486 + Run: `cargo build` 487 + Expected: Compiles cleanly 488 + 489 + Run: `cargo run -- tasks --help` 490 + Expected: Shows task subcommands (list, ready, next, tree) 491 + 492 + Run: `cargo run -- decisions --help` 493 + Expected: Shows decision subcommands (list, now, history, show) 494 + 495 + **Commit:** `feat(cli): tasks, decisions, status, and search commands` 496 + 497 + <!-- END_TASK_8 --> 498 + <!-- END_SUBCOMPONENT_D --> 499 + 500 + <!-- START_TASK_9 --> 501 + ### Task 9: Concurrency test — multiple claim_task attempts 502 + 503 + **Verifies:** P1b.AC5.1, P1b.AC5.2 (concurrency aspect) 504 + 505 + **Files:** 506 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/graph_concurrency_test.rs` 507 + 508 + **Implementation:** 509 + 510 + Write a concurrency test that spawns multiple tokio tasks all trying to claim the same Ready task simultaneously. Verify that exactly one succeeds and the rest get false. 511 + 512 + ```rust 513 + #[tokio::test] 514 + async fn test_concurrent_task_claiming() { 515 + // Setup: create a goal with one Ready task 516 + // Spawn 10 tokio tasks all calling claim_task for the same node 517 + // Collect results 518 + // Assert exactly 1 true, 9 false 519 + } 520 + ``` 521 + 522 + This tests the atomicity guarantee of the conditional UPDATE under concurrent access through the single tokio-rusqlite connection. 523 + 524 + **Testing:** 525 + 526 + - Exactly one of N concurrent claim attempts succeeds 527 + - The task ends up with status Claimed and a single assigned_to 528 + 529 + **Verification:** 530 + Run: `cargo test graph_concurrency_test` 531 + Expected: All tests pass 532 + 533 + **Commit:** `test(graph): concurrent task claiming atomicity` 534 + 535 + <!-- END_TASK_9 -->
+482
docs/implementation-plans/2026-02-07-v2-phase1/phase_03.md
··· 1 + # Rustagent V2 Phase 1c: Sessions + Export + Interchange 2 + 3 + **Goal:** Implement session management with deterministic handoff notes, ADR export to markdown, TOML graph import/export/diff, node decay for context injection, and associated CLI commands. 4 + 5 + **Architecture:** Sessions are temporal records (not graph nodes) tracking work periods per goal. Handoff notes are template-generated from graph state queries — no LLM call. TOML interchange uses one file per goal in `.rustagent/graph/`, with deterministic key ordering (BTreeMap) for git-friendly diffs. Node decay compacts old nodes for context injection based on configurable age thresholds. 6 + 7 + **Tech Stack:** Rust (edition 2024), rusqlite 0.32 (bundled), tokio-rusqlite 0.6, toml 0.8, blake3 1.x, chrono, serde/serde_json, anyhow 8 + 9 + **Scope:** Phase 3 of 4 from the v2 architecture (Phase 1c: Sessions + Export + Interchange) 10 + 11 + **Codebase verified:** 2026-02-07 12 + 13 + **Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` 14 + 15 + **Depends on:** Phase 1a (database), Phase 1b (graph store, node types) 16 + 17 + --- 18 + 19 + ## Acceptance Criteria Coverage 20 + 21 + This phase implements and tests: 22 + 23 + ### P1c.AC1: Session management 24 + - **P1c.AC1.1 Success:** `create_session(goal_id)` creates a session record with start time and goal reference 25 + - **P1c.AC1.2 Success:** `end_session(session_id)` generates deterministic handoff notes from graph state and stores them 26 + - **P1c.AC1.3 Success:** Handoff notes contain Done, Remaining, Blocked, and Decisions Made sections populated from graph queries 27 + - **P1c.AC1.4 Success:** `get_latest_session(goal_id)` returns the most recent session with handoff notes 28 + 29 + ### P1c.AC2: ADR export 30 + - **P1c.AC2.1 Success:** `export_adrs(project_id, output_dir)` generates numbered markdown files (001-xxx.md) from Decision nodes 31 + - **P1c.AC2.2 Success:** Each ADR contains Status, Context, Options Considered (with Chosen/Rejected labels, pros/cons), Outcome, and Related Tasks sections 32 + 33 + ### P1c.AC3: TOML graph interchange 34 + - **P1c.AC3.1 Success:** `export_goal(goal_id)` produces a TOML file matching the format in the architecture (meta, nodes, edges sections) 35 + - **P1c.AC3.2 Success:** Output is deterministic — re-exporting unchanged state produces byte-identical output (sorted keys, omitted null fields) 36 + - **P1c.AC3.3 Success:** `import_goal(toml_content, strategy)` imports nodes and edges with merge/theirs/ours conflict strategies 37 + - **P1c.AC3.4 Success:** Round-trip: export -> import -> export produces identical files 38 + - **P1c.AC3.5 Success:** `diff_goal(toml_content, goal_id)` shows added/changed/unchanged entities 39 + - **P1c.AC3.6 Success:** Cross-goal edge references to nonexistent nodes are skipped with a clear error message 40 + 41 + ### P1c.AC4: Node decay 42 + - **P1c.AC4.1 Success:** Nodes < 7 days old: full detail (description, criteria, outcomes) 43 + - **P1c.AC4.2 Success:** Nodes 7-30 days old: summary only (title, status, key outcome) 44 + - **P1c.AC4.3 Success:** Nodes > 30 days old: minimal (title, status) 45 + - **P1c.AC4.4 Success:** Thresholds are configurable 46 + 47 + ### P1c.AC5: CLI commands 48 + - **P1c.AC5.1 Success:** `rustagent sessions` lists sessions for current goal 49 + - **P1c.AC5.2 Success:** `rustagent sessions latest` shows most recent handoff notes 50 + - **P1c.AC5.3 Success:** `rustagent decisions export` writes ADR markdown files 51 + - **P1c.AC5.4 Success:** `rustagent graph export` / `rustagent graph import` / `rustagent graph diff` work 52 + 53 + --- 54 + 55 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 56 + 57 + <!-- START_TASK_1 --> 58 + ### Task 1: Session management and handoff notes 59 + 60 + **Verifies:** P1c.AC1.1, P1c.AC1.2, P1c.AC1.3, P1c.AC1.4 61 + 62 + **Files:** 63 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/session.rs` 64 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod session;` 65 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/session_test.rs` 66 + 67 + **Implementation:** 68 + 69 + `src/graph/session.rs`: 70 + 71 + ```rust 72 + pub struct Session { 73 + pub id: String, 74 + pub project_id: String, 75 + pub goal_id: String, 76 + pub started_at: DateTime<Utc>, 77 + pub ended_at: Option<DateTime<Utc>>, 78 + pub handoff_notes: Option<String>, 79 + pub agent_ids: Vec<String>, // JSON serialized 80 + pub summary: Option<String>, 81 + } 82 + ``` 83 + 84 + `SessionStore` wrapping `Database`: 85 + - `async fn create_session(&self, project_id: &str, goal_id: &str) -> Result<Session>` — generates ID, inserts with `BEGIN IMMEDIATE` 86 + - `async fn end_session(&self, session_id: &str, graph_store: &SqliteGraphStore) -> Result<()>` — calls `generate_handoff_notes`, updates ended_at and handoff_notes 87 + - `async fn get_latest_session(&self, goal_id: &str) -> Result<Option<Session>>` — SELECT ORDER BY started_at DESC LIMIT 1 88 + - `async fn list_sessions(&self, goal_id: &str) -> Result<Vec<Session>>` 89 + 90 + `fn generate_handoff_notes(conn: &rusqlite::Connection, goal_id: &str) -> rusqlite::Result<String>`: 91 + Template-based, queries graph state: 92 + 93 + ``` 94 + ## Done 95 + {for each node under goal with status Completed or Decided} 96 + - {id}: {title} ({status}) 97 + 98 + ## Remaining 99 + {for each node under goal with status Ready, Pending, or InProgress} 100 + - {id}: {title} ({status}{, blocked by X if blocked}) 101 + 102 + ## Blocked 103 + {for each node under goal with status Blocked} 104 + - {id}: {title} — {blocked_reason} 105 + 106 + ## Decisions Made 107 + {for each Decision node under goal with status Decided} 108 + - {id}: {title} → {chosen option title} ({rationale from Chosen edge label}) 109 + ``` 110 + 111 + This runs within the `end_session` call's `BEGIN IMMEDIATE` transaction using direct SQL queries on the raw `rusqlite::Connection` (not the async `GraphStore` methods — it's inside the `.call()` closure where only synchronous rusqlite is available). The queries duplicate some of what `SqliteGraphStore` does but operate directly on the connection for transactional consistency. 112 + 113 + **Testing:** 114 + 115 + Tests must verify: 116 + - P1c.AC1.1: `create_session` returns a session with valid ID and start time 117 + - P1c.AC1.2: After creating tasks (some completed, some pending) and a decided decision under a goal, `end_session` produces handoff notes 118 + - P1c.AC1.3: Handoff notes contain all 4 sections with correct content from the graph 119 + - P1c.AC1.4: `get_latest_session` returns the most recent of 2 sessions 120 + 121 + Set up test data by creating nodes and edges via `SqliteGraphStore` before calling session functions. 122 + 123 + **Verification:** 124 + Run: `cargo test session_test` 125 + Expected: All tests pass 126 + 127 + **Commit:** `feat(graph): session management with deterministic handoff notes` 128 + 129 + <!-- END_TASK_1 --> 130 + 131 + <!-- START_TASK_2 --> 132 + ### Task 2: ADR export 133 + 134 + **Verifies:** P1c.AC2.1, P1c.AC2.2 135 + 136 + **Files:** 137 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/export.rs` 138 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod export;` 139 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/adr_export_test.rs` 140 + 141 + **Implementation:** 142 + 143 + `src/graph/export.rs`: 144 + 145 + `pub async fn export_adrs(graph_store: &SqliteGraphStore, project_id: &str, output_dir: &Path) -> Result<Vec<PathBuf>>`: 146 + 1. Query all Decision nodes for the project (any status — Active, Decided, Superseded) 147 + 2. For each Decision, gather connected Options (via LeadsTo edges), their Chosen/Rejected status (via Chosen/Rejected edges), and any Outcome nodes (via LeadsTo from related tasks) 148 + 3. Number sequentially (001, 002, ...) ordered by created_at 149 + 4. Generate markdown per the architecture format (lines 583-596 of design doc) 150 + 5. Write to `output_dir/001-<slugified-title>.md` 151 + 6. Return list of written file paths 152 + 153 + Title slugification: lowercase, replace spaces with hyphens, strip non-alphanumeric except hyphens. 154 + 155 + **Testing:** 156 + 157 + Tests must verify: 158 + - P1c.AC2.1: Create 2 decisions in a project. Export to a tempdir. Two files created: `001-*.md` and `002-*.md`. 159 + - P1c.AC2.2: Open the exported file. Verify it contains Status, Context, Options Considered (with CHOSEN/REJECTED labels and pros/cons), and Related Tasks sections. 160 + 161 + **Verification:** 162 + Run: `cargo test adr_export_test` 163 + Expected: All tests pass 164 + 165 + **Commit:** `feat(graph): ADR export to markdown` 166 + 167 + <!-- END_TASK_2 --> 168 + <!-- END_SUBCOMPONENT_A --> 169 + 170 + <!-- START_SUBCOMPONENT_B (tasks 3-5) --> 171 + 172 + <!-- START_TASK_3 --> 173 + ### Task 3: TOML interchange — export 174 + 175 + **Verifies:** P1c.AC3.1, P1c.AC3.2 176 + 177 + **Files:** 178 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/Cargo.toml` — add `blake3` dependency 179 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/interchange.rs` 180 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod interchange;` 181 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/interchange_test.rs` 182 + 183 + **Prerequisite:** Add `blake3 = "1"` to `[dependencies]` in Cargo.toml. Run `cargo check` to verify. 184 + 185 + **Implementation:** 186 + 187 + `src/graph/interchange.rs`: 188 + 189 + Define serde types for the TOML format (separate from the DB types to control serialization): 190 + 191 + ```rust 192 + #[derive(Serialize, Deserialize)] 193 + struct GoalFile { 194 + meta: Meta, 195 + nodes: BTreeMap<String, TomlNode>, // BTreeMap for sorted keys 196 + edges: BTreeMap<String, TomlEdge>, 197 + } 198 + 199 + #[derive(Serialize, Deserialize)] 200 + struct Meta { 201 + version: u32, 202 + goal_id: String, 203 + project: String, 204 + exported_at: String, 205 + content_hash: String, 206 + } 207 + ``` 208 + 209 + `TomlNode` and `TomlEdge` are simplified serde structs that map to the TOML format shown in the architecture (lines 726-814). Use `#[serde(skip_serializing_if = "Option::is_none")]` to omit null fields. 210 + 211 + `pub async fn export_goal(graph_store: &SqliteGraphStore, goal_id: &str, project_name: &str) -> Result<String>`: 212 + 1. Get subtree of goal node (all descendants) 213 + 2. Get all edges where either from_node or to_node is in the subtree 214 + 3. Convert to `GoalFile` with BTreeMap for deterministic ordering 215 + 4. Compute content hash: blake3 hash of serialized nodes+edges (excluding meta) 216 + 5. Serialize with `toml::to_string_pretty()` 217 + 6. Return the TOML string 218 + 219 + Use BTreeMap (not HashMap) for the nodes and edges maps — toml crate's Map type is BTreeMap by default, which gives sorted key order. 220 + 221 + **Testing:** 222 + 223 + Tests must verify: 224 + - P1c.AC3.1: Create a goal with tasks, decisions, options, edges. Export. Parse the TOML string. Verify meta, nodes, and edges sections exist with correct data. 225 + - P1c.AC3.2: Export twice without changes. Both strings are byte-identical. 226 + 227 + **Verification:** 228 + Run: `cargo test interchange_test` 229 + Expected: All tests pass 230 + 231 + **Commit:** `feat(graph): TOML export for goal files` 232 + 233 + <!-- END_TASK_3 --> 234 + 235 + <!-- START_TASK_4 --> 236 + ### Task 4: TOML interchange — import with conflict strategies 237 + 238 + **Verifies:** P1c.AC3.3, P1c.AC3.4, P1c.AC3.6 239 + 240 + **Files:** 241 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/interchange.rs` 242 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/interchange_test.rs` (add more tests) 243 + 244 + **Implementation:** 245 + 246 + ```rust 247 + pub enum ImportStrategy { Merge, Theirs, Ours } 248 + 249 + pub struct ImportResult { 250 + pub added_nodes: usize, 251 + pub added_edges: usize, 252 + pub conflicts: Vec<ImportConflict>, 253 + pub skipped_edges: Vec<String>, // Cross-goal refs to nonexistent nodes 254 + pub unchanged: usize, 255 + } 256 + 257 + pub struct ImportConflict { 258 + pub node_id: String, 259 + pub field: String, 260 + pub db_value: String, 261 + pub file_value: String, 262 + } 263 + ``` 264 + 265 + `pub async fn import_goal(graph_store: &SqliteGraphStore, toml_content: &str, strategy: ImportStrategy) -> Result<ImportResult>`: 266 + 1. Parse TOML into `GoalFile` 267 + 2. For each node: check if it exists in DB 268 + - New node: insert 269 + - Existing, unchanged: skip 270 + - Existing, changed: apply strategy (Merge=flag conflict, Theirs=file wins, Ours=skip) 271 + 3. For each edge: check if both from_node and to_node exist in DB 272 + - Both exist: insert edge (idempotent — skip if edge already exists) 273 + - Target node missing: add to `skipped_edges` with clear message 274 + 4. Return `ImportResult` 275 + 276 + All writes in one `BEGIN IMMEDIATE` transaction. 277 + 278 + **Testing:** 279 + 280 + Tests must verify: 281 + - P1c.AC3.3: Export a goal, modify a node in the TOML string, import with `Theirs` strategy. Verify DB has the modified value. 282 + - P1c.AC3.4: Export, import into a fresh DB, export again. Both TOML strings are identical. 283 + - P1c.AC3.6: Create an edge in TOML referencing a nonexistent node ID. Import. Edge is skipped and appears in `skipped_edges` with a descriptive message. 284 + 285 + **Verification:** 286 + Run: `cargo test interchange_test` 287 + Expected: All tests pass 288 + 289 + **Commit:** `feat(graph): TOML import with merge/theirs/ours conflict resolution` 290 + 291 + <!-- END_TASK_4 --> 292 + 293 + <!-- START_TASK_5 --> 294 + ### Task 5: TOML interchange — diff 295 + 296 + **Verifies:** P1c.AC3.5 297 + 298 + **Files:** 299 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/interchange.rs` 300 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/interchange_test.rs` (add diff test) 301 + 302 + **Implementation:** 303 + 304 + ```rust 305 + pub struct DiffResult { 306 + pub added_nodes: Vec<String>, 307 + pub changed_nodes: Vec<(String, Vec<String>)>, // (id, changed_fields) 308 + pub removed_nodes: Vec<String>, // in DB but not in file 309 + pub added_edges: Vec<String>, 310 + pub removed_edges: Vec<String>, 311 + pub unchanged_nodes: usize, 312 + pub unchanged_edges: usize, 313 + } 314 + ``` 315 + 316 + `pub async fn diff_goal(graph_store: &SqliteGraphStore, toml_content: &str) -> Result<DiffResult>`: 317 + 1. Parse TOML 318 + 2. Compare each node/edge against DB state 319 + 3. Report differences without making any changes 320 + 321 + **Testing:** 322 + 323 + - P1c.AC3.5: Create a goal in DB. Export to TOML. Add a node and change another in the TOML string. Diff. Verify `added_nodes` has 1 entry, `changed_nodes` has 1 entry with the changed fields listed. 324 + 325 + **Verification:** 326 + Run: `cargo test interchange_test` 327 + Expected: All tests pass 328 + 329 + **Commit:** `feat(graph): TOML diff against DB state` 330 + 331 + <!-- END_TASK_5 --> 332 + <!-- END_SUBCOMPONENT_B --> 333 + 334 + <!-- START_SUBCOMPONENT_C (tasks 6-7) --> 335 + 336 + <!-- START_TASK_6 --> 337 + ### Task 6: Node decay for context injection 338 + 339 + **Verifies:** P1c.AC4.1, P1c.AC4.2, P1c.AC4.3, P1c.AC4.4 340 + 341 + **Files:** 342 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/decay.rs` 343 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/graph/mod.rs` — add `pub mod decay;` 344 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/decay_test.rs` 345 + 346 + **Implementation:** 347 + 348 + `src/graph/decay.rs`: 349 + 350 + ```rust 351 + pub struct DecayConfig { 352 + pub recent_days: i64, // Default: 7 353 + pub older_days: i64, // Default: 30 354 + } 355 + 356 + impl Default for DecayConfig { 357 + fn default() -> Self { 358 + Self { recent_days: 7, older_days: 30 } 359 + } 360 + } 361 + 362 + pub enum DecayLevel { Full, Summary, Minimal } 363 + 364 + pub struct DecayedNode { 365 + pub id: String, 366 + pub title: String, 367 + pub status: NodeStatus, 368 + pub detail: DecayDetail, 369 + } 370 + 371 + pub enum DecayDetail { 372 + Full { description: String, metadata: HashMap<String, String> }, 373 + Summary { key_outcome: Option<String> }, 374 + Minimal, 375 + } 376 + ``` 377 + 378 + `pub fn decay_node(node: &GraphNode, now: DateTime<Utc>, config: &DecayConfig) -> DecayedNode`: 379 + - Calculate age from `completed_at` (or `created_at` if not completed) 380 + - If age < `recent_days`: Full detail 381 + - If age < `older_days`: Summary — title, status, extract key outcome from metadata if present 382 + - Else: Minimal — title and status only 383 + 384 + `pub fn decay_nodes(nodes: &[GraphNode], now: DateTime<Utc>, config: &DecayConfig) -> Vec<DecayedNode>`: 385 + - Apply `decay_node` to each 386 + 387 + **Testing:** 388 + 389 + Tests must verify: 390 + - P1c.AC4.1: Node completed 2 days ago → DecayDetail::Full with description and metadata 391 + - P1c.AC4.2: Node completed 15 days ago → DecayDetail::Summary with title and status 392 + - P1c.AC4.3: Node completed 45 days ago → DecayDetail::Minimal with title and status only 393 + - P1c.AC4.4: Custom config with `recent_days: 3, older_days: 10`. Node completed 5 days ago → Summary (not Full). 394 + 395 + **Verification:** 396 + Run: `cargo test decay_test` 397 + Expected: All tests pass 398 + 399 + **Commit:** `feat(graph): node decay for context injection with configurable thresholds` 400 + 401 + <!-- END_TASK_6 --> 402 + 403 + <!-- START_TASK_7 --> 404 + ### Task 7: Wire up session, export, and interchange CLI commands 405 + 406 + **Verifies:** P1c.AC5.1, P1c.AC5.2, P1c.AC5.3, P1c.AC5.4 407 + 408 + **Files:** 409 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — add `Sessions`, `Graph` subcommands, extend `Decisions` with `Export` action 410 + 411 + **Implementation:** 412 + 413 + Add CLI variants: 414 + 415 + ```rust 416 + /// View sessions and handoff notes 417 + Sessions { 418 + #[command(subcommand)] 419 + action: Option<SessionAction>, 420 + }, 421 + /// Import/export graph data 422 + Graph { 423 + #[command(subcommand)] 424 + action: GraphAction, 425 + }, 426 + ``` 427 + 428 + ```rust 429 + #[derive(Subcommand)] 430 + enum SessionAction { 431 + /// List sessions for current goal 432 + List { #[arg(long)] goal: Option<String> }, 433 + /// Show most recent handoff notes 434 + Latest { #[arg(long)] goal: Option<String> }, 435 + } 436 + 437 + #[derive(Subcommand)] 438 + enum GraphAction { 439 + /// Export goals to TOML files 440 + Export { 441 + #[arg(long)] goal: Option<String>, 442 + #[arg(long)] output: Option<String>, 443 + }, 444 + /// Import TOML files 445 + Import { 446 + path: String, 447 + #[arg(long)] dry_run: bool, 448 + #[arg(long)] theirs: bool, 449 + #[arg(long)] ours: bool, 450 + }, 451 + /// Diff TOML file against DB 452 + Diff { path: String }, 453 + } 454 + ``` 455 + 456 + Add `Export` variant to `DecisionAction`: 457 + ```rust 458 + /// Export decisions as ADR markdown files 459 + Export { 460 + #[arg(long)] output: Option<String>, 461 + }, 462 + ``` 463 + 464 + Each command resolves project, opens DB, creates stores, calls functions, prints results. 465 + 466 + **Note:** Session TOML export (`.rustagent/sessions/*.toml` files as described in the architecture) is deferred to a later phase. This phase covers session DB records and handoff notes generation, but not the file-based session export format. 467 + 468 + **Verification:** 469 + 470 + Run: `cargo build` 471 + Expected: Compiles cleanly 472 + 473 + Run: `cargo run -- sessions --help` 474 + Expected: Shows list/latest subcommands 475 + 476 + Run: `cargo run -- graph --help` 477 + Expected: Shows export/import/diff subcommands 478 + 479 + **Commit:** `feat(cli): sessions, graph interchange, and ADR export commands` 480 + 481 + <!-- END_TASK_7 --> 482 + <!-- END_SUBCOMPONENT_C -->
+576
docs/implementation-plans/2026-02-07-v2-phase1/phase_04.md
··· 1 + # Rustagent V2 Phase 1d: Agent Runtime + Single-Agent Execution 2 + 3 + **Goal:** Cherry-pick existing modules (LLM, security, tools), define the Agent trait and AgentProfile system, refactor the Ralph loop into a generic AgentRuntime with error handling (confusion counter, token budget), implement profile resolution, and wire up `rustagent run` for single-agent execution. 4 + 5 + **Architecture:** The AgentRuntime is a generic agentic loop (LLM call -> tool execution -> repeat) that replaces the v1 Ralph loop. It's parameterized by an AgentProfile (which controls system prompt, allowed tools, security scope, LLM config) and an AgentContext (task details, decisions, handoff notes). Error handling adds a confusion counter for bad tool calls, configurable consecutive failure thresholds, and per-worker token budget tracking. 6 + 7 + **Tech Stack:** Rust (edition 2024), async-trait, tokio, serde/serde_json, chrono, uuid, anyhow, walkdir, glob 8 + 9 + **Scope:** Phase 4 of 4 from the v2 architecture (Phase 1d: Agent Runtime + Single-Agent Execution) 10 + 11 + **Codebase verified:** 2026-02-07 12 + 13 + **Design document:** `/Users/david.hagerty/code/personal/rustagent/new-directions/docs/plans/v2-architecture.md` 14 + 15 + **Depends on:** Phase 1a (database), Phase 1b (graph store, graph tools), Phase 1c (sessions, context decay) 16 + 17 + **Deferred to later phases:** 18 + - `src/config/autonomy.rs` (AutonomyLevel, ApprovalGate types) — architecture Phase 5. The `run` command in this phase does not support `--autonomy` flag. Autonomy enforcement requires the orchestrator (Phase 2) and approval gate system (Phase 5). 19 + - `src/tools/search.rs` (code search tool) — architecture Phase 5. The `search` CLI command in Phase 1b covers FTS5 graph node search only. File content search for agents is deferred. 20 + - `pulldown-cmark` (AGENTS.md parsing) — simple string-based heading extraction is sufficient for Phase 1d. Full markdown parsing deferred if needed. 21 + - `tokio-util` (CancellationToken) — `Agent::cancel()` is a no-op stub in Phase 1d (single-agent mode). CancellationToken integration deferred to Phase 2 (multi-agent orchestration). 22 + 23 + --- 24 + 25 + ## Acceptance Criteria Coverage 26 + 27 + This phase implements and tests: 28 + 29 + ### P1d.AC1: Cherry-pick and adapt existing modules 30 + - **P1d.AC1.1 Success:** LLM module (`src/llm/`) compiles in new structure with no TUI dependencies 31 + - **P1d.AC1.2 Success:** Security module (`src/security/`) compiles with new `SecurityScope` type added 32 + - **P1d.AC1.3 Success:** Tools module (`src/tools/`) compiles with graph_tools integrated into the registry 33 + 34 + ### P1d.AC2: Agent trait and types 35 + - **P1d.AC2.1 Success:** `Agent` trait defined with `id()`, `profile()`, `run(ctx) -> AgentOutcome`, `cancel()` 36 + - **P1d.AC2.2 Success:** `AgentContext` struct contains task details, relevant decisions, handoff notes, AGENTS.md summaries, profile 37 + - **P1d.AC2.3 Success:** `AgentOutcome` enum covers Completed, Blocked, Failed, TokenBudgetExhausted 38 + 39 + ### P1d.AC3: Agent profiles 40 + - **P1d.AC3.1 Success:** `AgentProfile` struct with name, extends, role, system_prompt, allowed_tools, security, llm config, turn_limit, token_budget 41 + - **P1d.AC3.2 Success:** 5 built-in profiles defined: planner, coder, reviewer, tester, researcher 42 + - **P1d.AC3.3 Success:** Custom profiles loaded from `.rustagent/profiles/*.toml` (project-level) and `~/.config/rustagent/profiles/*.toml` (user-level) 43 + - **P1d.AC3.4 Success:** Profile resolution: project-level > user-level > built-in. First match wins. 44 + - **P1d.AC3.5 Success:** Inheritance via `extends`: scalar fields replaced, list fields replaced, system_prompt appended 45 + 46 + ### P1d.AC4: AgentRuntime (agentic loop) 47 + - **P1d.AC4.1 Success:** AgentRuntime runs the LLM call -> tool execution -> repeat loop 48 + - **P1d.AC4.2 Success:** Confusion counter: after N consecutive bad tool calls (configurable), worker signals blocked 49 + - **P1d.AC4.3 Success:** Token budget: at warning threshold (default 80%), injects "wrap up" system message. At 100%, force-stops with partial completion. 50 + - **P1d.AC4.4 Success:** Consecutive LLM failure threshold: after N failures (configurable), worker signals blocked 51 + - **P1d.AC4.5 Success:** Turn limit: worker stops after max turns with partial completion report 52 + 53 + ### P1d.AC5: Context assembly 54 + - **P1d.AC5.1 Success:** ContextBuilder assembles compact structured context from task details, decisions, handoff notes, observations, AGENTS.md 55 + - **P1d.AC5.2 Success:** AGENTS.md files resolved by closest-to-file rule per agents.md spec 56 + 57 + ### P1d.AC6: Single-agent CLI 58 + - **P1d.AC6.1 Success:** `rustagent run --project <name> "<goal>"` creates a goal node, creates a session, runs a single coder agent, and records the outcome 59 + - **P1d.AC6.2 Success:** Profile selection via `--profile <name>` flag (defaults to coder) 60 + 61 + --- 62 + 63 + <!-- START_TASK_1 --> 64 + ### Task 1: Clean up cherry-picked modules for v2 compatibility 65 + 66 + **Verifies:** P1d.AC1.1, P1d.AC1.2, P1d.AC1.3 67 + 68 + **Files:** 69 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/ralph/mod.rs` — remove `use crate::tui::messages::{AgentMessage, AgentSender};` and the `run_with_sender` and `execute_task_with_sender` methods (TUI removed in Phase 1a Task 2) 70 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/tools/factory.rs` — add `SqliteGraphStore` parameter, register graph tools alongside existing tools 71 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/security/mod.rs` — no changes needed yet (SecurityScope added in Task 3) 72 + 73 + **Implementation:** 74 + 75 + In `ralph/mod.rs`: 76 + - Remove the import of `crate::tui::messages` 77 + - Remove the `run_with_sender` method entirely 78 + - Remove the `execute_task_with_sender` method entirely 79 + - The remaining `run` and `execute_task` methods stay as-is — they'll be replaced by AgentRuntime in Task 5, but the v1 `Run` command should still work in the meantime. 80 + 81 + In `tools/factory.rs`: 82 + - Add a new function `create_v2_registry(validator, permission_handler, graph_store)` that creates the default registry AND registers all graph tools from `graph_tools.rs`. Keep the existing `create_default_registry` for backward compatibility with v1 commands. 83 + - The graph tools need `Arc<SqliteGraphStore>` passed in, and the agent's ID for tools that need it (like `claim_task`). 84 + 85 + **Verification:** 86 + 87 + Run: `cargo check` 88 + Expected: Compiles cleanly 89 + 90 + Run: `cargo test` 91 + Expected: All existing tests still pass 92 + 93 + **Commit:** `refactor: clean up modules for v2 compatibility, add v2 tool registry factory` 94 + 95 + <!-- END_TASK_1 --> 96 + 97 + <!-- START_SUBCOMPONENT_A (tasks 2-3) --> 98 + 99 + <!-- START_TASK_2 --> 100 + ### Task 2: Agent trait and AgentOutcome 101 + 102 + **Verifies:** P1d.AC2.1, P1d.AC2.3 103 + 104 + **Files:** 105 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/mod.rs` 106 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod agent;` 107 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/agent_types_test.rs` 108 + 109 + **Implementation:** 110 + 111 + `src/agent/mod.rs`: 112 + 113 + ```rust 114 + pub type AgentId = String; 115 + 116 + #[async_trait] 117 + pub trait Agent: Send + Sync { 118 + fn id(&self) -> &AgentId; 119 + fn profile(&self) -> &AgentProfile; 120 + async fn run(&self, ctx: AgentContext) -> Result<AgentOutcome>; 121 + fn cancel(&self); // No-op stub in Phase 1d (single-agent). CancellationToken integration deferred to Phase 2. 122 + } 123 + 124 + pub enum AgentOutcome { 125 + Completed { summary: String }, 126 + Blocked { reason: String }, 127 + Failed { error: String }, 128 + TokenBudgetExhausted { summary: String, tokens_used: usize }, 129 + } 130 + 131 + pub struct AgentContext { 132 + pub work_package_tasks: Vec<GraphNode>, 133 + pub relevant_decisions: Vec<GraphNode>, 134 + pub handoff_notes: Option<String>, 135 + pub agents_md_summaries: Vec<(String, String)>, // (path, heading summary) 136 + pub profile: AgentProfile, 137 + pub project_path: PathBuf, 138 + pub graph_store: Arc<dyn GraphStore>, 139 + } 140 + ``` 141 + 142 + Declare sub-modules: `pub mod profile;`, `pub mod runtime;`, `pub mod builtin_profiles;` 143 + 144 + **Testing:** 145 + 146 + - P1d.AC2.1: Verify Agent trait compiles (it's a trait, so just verify a mock can implement it) 147 + - P1d.AC2.3: Verify AgentOutcome variants can be constructed and matched 148 + 149 + **Verification:** 150 + Run: `cargo test agent_types_test` 151 + Expected: All tests pass 152 + 153 + **Commit:** `feat(agent): Agent trait, AgentId, AgentContext, AgentOutcome types` 154 + 155 + <!-- END_TASK_2 --> 156 + 157 + <!-- START_TASK_3 --> 158 + ### Task 3: AgentProfile type and SecurityScope 159 + 160 + **Verifies:** P1d.AC3.1, P1d.AC1.2 161 + 162 + **Files:** 163 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/profile.rs` 164 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/security/scope.rs` 165 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/security/mod.rs` — add `pub mod scope;` 166 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/profile_test.rs` 167 + 168 + **Implementation:** 169 + 170 + `src/security/scope.rs`: 171 + 172 + ```rust 173 + #[derive(Debug, Clone, Serialize, Deserialize)] 174 + pub struct SecurityScope { 175 + pub allowed_paths: Vec<String>, 176 + pub denied_paths: Vec<String>, 177 + pub allowed_commands: Vec<String>, 178 + pub read_only: bool, 179 + pub can_create_files: bool, 180 + pub network_access: bool, 181 + } 182 + ``` 183 + 184 + Implement `Default` with permissive defaults (all paths allowed, not read-only, etc.) so built-in profiles can override specific fields. 185 + 186 + `src/agent/profile.rs`: 187 + 188 + ```rust 189 + #[derive(Debug, Clone, Serialize, Deserialize)] 190 + pub struct AgentProfile { 191 + pub name: String, 192 + pub extends: Option<String>, 193 + pub role: String, 194 + pub system_prompt: String, 195 + pub allowed_tools: Vec<String>, 196 + pub security: SecurityScope, 197 + #[serde(default)] 198 + pub llm: ProfileLlmConfig, 199 + pub turn_limit: Option<usize>, 200 + pub token_budget: Option<usize>, 201 + } 202 + 203 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 204 + pub struct ProfileLlmConfig { 205 + pub model: Option<String>, 206 + pub temperature: Option<f64>, 207 + pub max_tokens: Option<usize>, 208 + } 209 + ``` 210 + 211 + Implement `AgentProfile::apply_inheritance(&mut self, parent: &AgentProfile)`: 212 + 1. For scalar fields: only override if `self` has a meaningful value (non-empty string, Some, etc.) 213 + 2. For list fields (allowed_tools, allowed_paths, etc.): child replaces parent entirely (not merged) 214 + 3. For system_prompt: append child to parent with `\n\n## Project-Specific Instructions\n` separator 215 + 4. For optional fields (turn_limit, token_budget, llm): child Some wins, falls through to parent if None 216 + 217 + **Testing:** 218 + 219 + Tests must verify: 220 + - P1d.AC3.1: AgentProfile deserializes from TOML string matching the format in the architecture doc 221 + - P1d.AC1.2: SecurityScope deserializes correctly; Default gives permissive scope 222 + - Inheritance: parent with `role = "coder"`, child with `role = "rust-coder"` → child role wins. Parent with `allowed_tools = ["file", "shell"]`, child with `allowed_tools = ["file"]` → child list wins (not merged). Parent system_prompt + child system_prompt → concatenated with separator. 223 + 224 + **Verification:** 225 + Run: `cargo test profile_test` 226 + Expected: All tests pass 227 + 228 + **Commit:** `feat(agent): AgentProfile type with SecurityScope and inheritance` 229 + 230 + <!-- END_TASK_3 --> 231 + <!-- END_SUBCOMPONENT_A --> 232 + 233 + <!-- START_TASK_4 --> 234 + ### Task 4: Add token tracking fields to Response type 235 + 236 + **Files:** 237 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/mod.rs` — add `input_tokens` and `output_tokens` to `Response` 238 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/anthropic.rs` — populate token fields from API response 239 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/openai.rs` — populate token fields from API response 240 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/ollama.rs` — populate token fields (None if not available) 241 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/llm/mock.rs` — return configurable token counts 242 + 243 + **Implementation:** 244 + 245 + Add to `Response` in `src/llm/mod.rs`: 246 + 247 + ```rust 248 + pub struct Response { 249 + pub content: ResponseContent, 250 + pub stop_reason: Option<String>, 251 + pub input_tokens: Option<usize>, 252 + pub output_tokens: Option<usize>, 253 + } 254 + ``` 255 + 256 + Update each provider to extract token usage from their API responses: 257 + - Anthropic: `response.usage.input_tokens` and `response.usage.output_tokens` 258 + - OpenAI: `response.usage.prompt_tokens` and `response.usage.completion_tokens` 259 + - Ollama: `response.eval_count` for output, `response.prompt_eval_count` for input (if available) 260 + - Mock: Add `pub fn set_token_counts(&self, input: usize, output: usize)` to configure returned values 261 + 262 + **Verification:** 263 + 264 + Run: `cargo test` 265 + Expected: All existing tests pass (Response construction sites need updating with the new fields) 266 + 267 + **Commit:** `feat(llm): add token usage tracking to Response type` 268 + 269 + <!-- END_TASK_4 --> 270 + 271 + <!-- START_SUBCOMPONENT_B (tasks 5-6) --> 272 + 273 + <!-- START_TASK_5 --> 274 + ### Task 5: Built-in profiles and profile resolution 275 + 276 + **Verifies:** P1d.AC3.2, P1d.AC3.3, P1d.AC3.4, P1d.AC3.5 277 + 278 + **Files:** 279 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/builtin_profiles.rs` 280 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/profile.rs` — add `resolve_profile` function 281 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/profile_test.rs` (extend) 282 + 283 + **Implementation:** 284 + 285 + `src/agent/builtin_profiles.rs`: 286 + 287 + Define 5 functions, each returning an `AgentProfile`: 288 + - `pub fn planner() -> AgentProfile` — read-only, graph+signal tools, system prompt for task breakdown 289 + - `pub fn coder() -> AgentProfile` — write access (scoped), file+shell+graph+signal tools, system prompt for implementation 290 + - `pub fn reviewer() -> AgentProfile` — read-only, file+shell+graph+signal tools, system prompt for code review 291 + - `pub fn tester() -> AgentProfile` — write access (test dirs), file+shell+graph+signal tools, system prompt for test writing 292 + - `pub fn researcher() -> AgentProfile` — read-only, file+shell+search+graph+signal tools, system prompt for information gathering 293 + 294 + Each profile's system_prompt follows the template from the architecture (lines 1507-1525). 295 + 296 + In `profile.rs`, add: 297 + 298 + ```rust 299 + pub fn resolve_profile( 300 + name: &str, 301 + project_path: Option<&Path>, 302 + ) -> Result<AgentProfile> { 303 + // 1. Project-level: .rustagent/profiles/{name}.toml 304 + if let Some(path) = project_path { 305 + let profile_path = path.join(".rustagent/profiles").join(format!("{}.toml", name)); 306 + if profile_path.exists() { 307 + let content = std::fs::read_to_string(&profile_path)?; 308 + let mut profile: AgentProfile = toml::from_str(&content)?; 309 + if let Some(parent_name) = &profile.extends.clone() { 310 + let parent = resolve_profile(parent_name, project_path)?; 311 + profile.apply_inheritance(&parent); 312 + } 313 + return Ok(profile); 314 + } 315 + } 316 + 317 + // 2. User-level: ~/.config/rustagent/profiles/{name}.toml 318 + if let Some(config_dir) = dirs::config_dir() { 319 + let profile_path = config_dir.join("rustagent/profiles").join(format!("{}.toml", name)); 320 + if profile_path.exists() { 321 + let content = std::fs::read_to_string(&profile_path)?; 322 + let mut profile: AgentProfile = toml::from_str(&content)?; 323 + if let Some(parent_name) = &profile.extends.clone() { 324 + let parent = resolve_profile(parent_name, project_path)?; 325 + profile.apply_inheritance(&parent); 326 + } 327 + return Ok(profile); 328 + } 329 + } 330 + 331 + // 3. Built-in 332 + match name { 333 + "planner" => Ok(builtin_profiles::planner()), 334 + "coder" => Ok(builtin_profiles::coder()), 335 + "reviewer" => Ok(builtin_profiles::reviewer()), 336 + "tester" => Ok(builtin_profiles::tester()), 337 + "researcher" => Ok(builtin_profiles::researcher()), 338 + _ => anyhow::bail!("Unknown profile: {}", name), 339 + } 340 + } 341 + ``` 342 + 343 + Add cycle detection: track resolved names in a `HashSet` and error if a name appears twice. 344 + 345 + **Testing:** 346 + 347 + Tests must verify: 348 + - P1d.AC3.2: `resolve_profile("coder", None)` returns the built-in coder profile 349 + - P1d.AC3.3: Create a tempdir with `.rustagent/profiles/custom.toml`. Resolve "custom" with that project path. Returns the custom profile. 350 + - P1d.AC3.4: Create a project-level profile named "coder" that overrides the built-in. Resolve "coder" with that project path. Project-level wins. 351 + - P1d.AC3.5: Create a custom profile with `extends = "coder"`. Resolve it. Verify inheritance applied correctly (system_prompt appended, list fields replaced, scalar fields overridden). 352 + 353 + **Verification:** 354 + Run: `cargo test profile_test` 355 + Expected: All tests pass 356 + 357 + **Commit:** `feat(agent): 5 built-in profiles and profile resolution chain` 358 + 359 + <!-- END_TASK_5 --> 360 + 361 + <!-- START_TASK_6 --> 362 + ### Task 6: AgentRuntime — agentic loop with error handling 363 + 364 + **Verifies:** P1d.AC4.1, P1d.AC4.2, P1d.AC4.3, P1d.AC4.4, P1d.AC4.5 365 + 366 + **Files:** 367 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/agent/runtime.rs` 368 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/agent_runtime_test.rs` 369 + 370 + **Implementation:** 371 + 372 + `src/agent/runtime.rs`: 373 + 374 + ```rust 375 + pub struct AgentRuntime { 376 + client: Arc<dyn LlmClient>, 377 + tools: ToolRegistry, 378 + profile: AgentProfile, 379 + config: RuntimeConfig, 380 + } 381 + 382 + pub struct RuntimeConfig { 383 + pub max_turns: usize, // Default: 100 384 + pub max_consecutive_llm_failures: usize, // Default: 3 385 + pub max_consecutive_tool_failures: usize, // Default: 3 386 + pub token_budget: usize, // Default: 200_000 387 + pub token_budget_warning_pct: u8, // Default: 80 388 + } 389 + ``` 390 + 391 + `impl AgentRuntime`: 392 + - `pub fn new(client, tools, profile, config) -> Self` 393 + - `pub async fn run(&self, context: AgentContext) -> Result<AgentOutcome>`: 394 + 395 + The loop is a refactored version of `RalphLoop::execute_task`: 396 + 397 + 1. Build system message from context (using ContextBuilder — Task 6) 398 + 2. Loop for up to `max_turns`: 399 + a. Call `client.chat(messages, tools)` 400 + b. On LLM error: increment `consecutive_llm_failures`. If >= threshold, return `AgentOutcome::Blocked`. 401 + c. On success: reset `consecutive_llm_failures` to 0. 402 + d. Track token usage: `cumulative_tokens += response.input_tokens.unwrap_or(0) + response.output_tokens.unwrap_or(0)` (token fields added to `Response` in Task 4) 403 + e. If cumulative_tokens >= warning threshold and not yet warned: inject system message "You are approaching your token budget. Wrap up your current work and signal completion." 404 + f. If cumulative_tokens >= budget: return `AgentOutcome::TokenBudgetExhausted` 405 + g. Handle tool calls: execute each via registry. On unknown tool or parse error, increment `consecutive_tool_failures` and send error back to LLM. On success, reset counter. 406 + h. If `consecutive_tool_failures` >= threshold: return `AgentOutcome::Blocked` 407 + i. Check for signal_completion tool call — return appropriate `AgentOutcome` 408 + 3. If loop exhausts max_turns: return `AgentOutcome::Completed` with summary "turn limit reached" 409 + 410 + Key difference from Ralph loop: error responses go back to the LLM as tool results (not panics), giving it a chance to self-correct. 411 + 412 + **Testing:** 413 + 414 + Tests use `MockLlmClient` from `src/llm/mock.rs`: 415 + 416 + - P1d.AC4.1: Queue a text response then a signal_completion tool call. Run. Returns `AgentOutcome::Completed`. 417 + - P1d.AC4.2: Queue 3 consecutive responses with invalid tool calls (unknown tool name). Run. Returns `AgentOutcome::Blocked` with reason mentioning tool failures. 418 + - P1d.AC4.3: Mock responses that consume tokens. Set budget to 1000 with warning at 80%. Verify "wrap up" message injected at 800 tokens. Set budget to 500. Verify `TokenBudgetExhausted` returned. 419 + - P1d.AC4.4: Queue 3 consecutive errors from the mock client. Run. Returns `AgentOutcome::Blocked` with LLM failure reason. 420 + - P1d.AC4.5: Set max_turns to 3. Queue responses that never signal completion. Run. Returns after 3 turns. 421 + 422 + **Verification:** 423 + Run: `cargo test agent_runtime_test` 424 + Expected: All tests pass 425 + 426 + **Commit:** `feat(agent): AgentRuntime with confusion counter, token budget, and failure thresholds` 427 + 428 + <!-- END_TASK_6 --> 429 + <!-- END_SUBCOMPONENT_B --> 430 + 431 + <!-- START_SUBCOMPONENT_C (tasks 7-8) --> 432 + 433 + <!-- START_TASK_7 --> 434 + ### Task 7: ContextBuilder and AGENTS.md resolution 435 + 436 + **Verifies:** P1d.AC5.1, P1d.AC5.2 437 + 438 + **Files:** 439 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/Cargo.toml` — add `walkdir` and `glob` dependencies 440 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/context/mod.rs` 441 + - Create: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/context/agents_md.rs` 442 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/lib.rs` — add `pub mod context;` 443 + - Test: `/Users/david.hagerty/code/personal/rustagent/new-directions/tests/context_test.rs` 444 + 445 + **Prerequisite:** Add `walkdir = "2"` and `glob = "0.3"` to `[dependencies]` in Cargo.toml. Run `cargo check` to verify. 446 + 447 + **Implementation:** 448 + 449 + `src/context/agents_md.rs`: 450 + 451 + ```rust 452 + pub struct AgentsMdSummary { 453 + pub path: PathBuf, 454 + pub headings: Vec<String>, // Top-level headings extracted 455 + } 456 + ``` 457 + 458 + `pub fn resolve_agents_md(project_root: &Path, file_scope: &[PathBuf]) -> Result<Vec<AgentsMdSummary>>`: 459 + 1. Start at project root 460 + 2. For each file in scope, walk directory hierarchy from root toward the file 461 + 3. At each directory level, check for `AGENTS.md` (case-sensitive) 462 + 4. Extract top-level headings (lines starting with `# `) using simple string parsing — no full markdown parser needed for heading extraction 463 + 5. Return collected summaries, deduplicated, with closest-to-file ordering 464 + 465 + `src/context/mod.rs`: 466 + 467 + ```rust 468 + pub struct ContextBuilder; 469 + 470 + impl ContextBuilder { 471 + pub fn build_system_prompt(ctx: &AgentContext) -> String 472 + } 473 + ``` 474 + 475 + Also create a `ReadAgentsMdTool` implementing the existing `Tool` trait, so agents can call `read_agents_md(path)` during their agentic loop to get the full AGENTS.md content on demand (the system prompt only includes heading summaries). The tool takes a `path` parameter, reads the AGENTS.md file at that path, and returns its full contents. Register this tool in the v2 tool registry (`tools/factory.rs`). 476 + 477 + `build_system_prompt` assembles the compact structured format from the architecture (lines 1626-1654): 478 + 479 + ``` 480 + ## Role 481 + {profile.role} 482 + 483 + ## Task 484 + [TASK] {task.id} | {task.title} | priority={task.priority} 485 + [CRITERIA] {acceptance_criteria, semicolon-separated} 486 + ... 487 + 488 + ## Session Continuity 489 + [HANDOFF] {handoff_notes} 490 + 491 + ## Active Decisions 492 + [DECISION] {id} | {title} | chosen: ... 493 + ... 494 + 495 + ## Relevant Observations (use query_nodes(id) for full detail) 496 + - {node_id}: {one-line summary} 497 + ... 498 + 499 + ## Project Conventions (use read_agents_md(path) for full text) 500 + - {path}: {heading1}, {heading2} ... 501 + 502 + ## Rules 503 + {profile.system_prompt rules section} 504 + ``` 505 + 506 + **Testing:** 507 + 508 + Tests must verify: 509 + - P1d.AC5.1: Given an AgentContext with task, decisions, handoff notes — output contains all sections with correct formatting 510 + - P1d.AC5.2: Create a tempdir with `AGENTS.md` at root and `src/AGENTS.md`. Resolve for scope `["src/auth/handler.rs"]`. Returns both files with `src/AGENTS.md` closer. 511 + 512 + **Verification:** 513 + Run: `cargo test context_test` 514 + Expected: All tests pass 515 + 516 + **Commit:** `feat(context): ContextBuilder with compact structured format and AGENTS.md resolution` 517 + 518 + <!-- END_TASK_7 --> 519 + 520 + <!-- START_TASK_8 --> 521 + ### Task 8: Wire up `rustagent run` for single-agent execution 522 + 523 + **Verifies:** P1d.AC6.1, P1d.AC6.2 524 + 525 + **Files:** 526 + - Modify: `/Users/david.hagerty/code/personal/rustagent/new-directions/src/main.rs` — replace v1 `Run` command with v2 version 527 + 528 + **Implementation:** 529 + 530 + Replace the existing `Run` command: 531 + 532 + ```rust 533 + /// Execute a goal with an agent 534 + Run { 535 + /// Goal description 536 + goal: String, 537 + /// Agent profile to use 538 + #[arg(long, default_value = "coder")] 539 + profile: String, 540 + /// Maximum iterations 541 + #[arg(long)] 542 + max_iterations: Option<usize>, 543 + }, 544 + ``` 545 + 546 + In the match arm for `Commands::Run`: 547 + 1. Resolve project (from `--project` flag or cwd) 548 + 2. Open database 549 + 3. Create goal node in the graph (using `SqliteGraphStore::create_node`) 550 + 4. Create a session (using `SessionStore::create_session`) 551 + 5. Resolve profile (using `resolve_profile`) 552 + 6. Build `AgentContext` from the goal, profile, and session 553 + 7. Create `AgentRuntime` with the profile's LLM config 554 + 8. Run the runtime 555 + 9. Handle `AgentOutcome` — update task nodes, end session, print result 556 + 557 + This is the first end-to-end integration: CLI -> database -> graph -> profile -> runtime -> LLM -> tools -> graph updates -> session end. 558 + 559 + For this phase (single-agent), there's no orchestrator — the CLI directly creates one agent and runs it. The orchestrator comes in Phase 2 (multi-agent). 560 + 561 + **Verification:** 562 + 563 + Run: `cargo build` 564 + Expected: Compiles cleanly 565 + 566 + Manual verification (requires LLM API key): 567 + ``` 568 + cargo run -- project add test-proj . 569 + cargo run -- run --project test-proj "Create a hello world program" 570 + ``` 571 + Expected: Agent creates tasks, attempts to execute them, records outcome. 572 + 573 + **Commit:** `feat(cli): v2 run command with single-agent execution` 574 + 575 + <!-- END_TASK_8 --> 576 + <!-- END_SUBCOMPONENT_C -->
+330
docs/implementation-plans/2026-02-07-v2-phase1/test-requirements.md
··· 1 + # Test Requirements for V2 Phase 1 2 + 3 + This document maps every acceptance criterion from Phase 1a through Phase 1d to specific automated tests or documented human verification steps. Each criterion is traced to the implementation plan task that produces it and the test file where verification lives. 4 + 5 + --- 6 + 7 + ## Phase 1a: Database + Projects 8 + 9 + ### P1a.AC1: Database initialization 10 + 11 + | AC | Type | Test File | Description | 12 + |----|------|-----------|-------------| 13 + | P1a.AC1.1 | integration | `tests/db_test.rs` | `Database::open(path)` creates the SQLite file at the specified path. Uses `tempfile::TempDir` to verify file creation on disk. | 14 + | P1a.AC1.2 | integration | `tests/db_test.rs` | After `Database::open`, query `PRAGMA journal_mode` returns `"wal"`, `PRAGMA foreign_keys` returns `1`, and `PRAGMA busy_timeout` returns `5000`. Uses `Database::open_in_memory()`. | 15 + | P1a.AC1.3 | integration | `tests/db_test.rs` | After open, all expected tables exist in `sqlite_master`: `schema_version`, `projects`, `nodes`, `edges`, `sessions`, `nodes_fts` (virtual), `worker_conversations`. Verifies indexes and FTS sync triggers are also present. | 16 + | P1a.AC1.4 | integration | `tests/db_test.rs` | After fresh init, `SELECT version FROM schema_version` returns `1`. | 17 + 18 + **Implementation task:** Phase 1a, Task 3 (Database module with initialization and migrations). 19 + 20 + ### P1a.AC2: Schema versioning and migrations 21 + 22 + | AC | Type | Test File | Description | 23 + |----|------|-----------|-------------| 24 + | P1a.AC2.1 | integration | `tests/db_test.rs` | `Database::open_in_memory()` creates full schema and sets version to 1. Verify all tables exist and version is correct. (Overlaps with P1a.AC1.3/AC1.4 but tested as a distinct scenario for fresh-database path.) | 25 + | P1a.AC2.2 | integration | `tests/db_test.rs` | Open an already-initialized in-memory DB, then open it again (or call migration logic again). No error occurs and version remains 1. | 26 + | P1a.AC2.3 | integration | `tests/db_test.rs` | Manually set `schema_version.version` to 999 via raw SQL, then trigger migration logic. Returns an error whose message contains `"newer version"`. | 27 + 28 + **Implementation task:** Phase 1a, Task 3. 29 + 30 + ### P1a.AC3: Project registration 31 + 32 + | AC | Type | Test File | Description | 33 + |----|------|-----------|-------------| 34 + | P1a.AC3.1 | integration | `tests/project_test.rs` | `ProjectStore::add("my-api", "/tmp/test")` returns a `Project` whose `id` starts with `"ra-"` followed by 4 hex characters, with correct `name` and `path` fields. | 35 + | P1a.AC3.2 | integration | `tests/project_test.rs` | After adding 3 projects with names "alpha", "beta", "gamma", `ProjectStore::list()` returns all 3 ordered alphabetically by name. | 36 + | P1a.AC3.3 | integration | `tests/project_test.rs` | After adding a project, `ProjectStore::get_by_name("my-api")` returns `Some(project)` with correct details. | 37 + | P1a.AC3.4 | integration | `tests/project_test.rs` | After adding then removing a project, `ProjectStore::get_by_name` returns `None` and `remove` returns `true`. Removing a nonexistent project returns `false`. | 38 + | P1a.AC3.5 | integration | `tests/project_test.rs` | Adding two projects with the same name returns an error (SQLite UNIQUE constraint violation). | 39 + | P1a.AC3.6 | integration | `tests/project_test.rs` | After adding a project with path `/tmp/test-proj`, `ProjectStore::get_by_path("/tmp/test-proj")` returns the matching project. A non-matching path returns `None`. | 40 + 41 + **Implementation tasks:** Phase 1a, Task 4 (Project type and store) and Task 5 (list, show, remove, resolve from cwd). 42 + 43 + --- 44 + 45 + ## Phase 1b: Graph Model + Node Lifecycle 46 + 47 + ### P1b.AC1: Graph node types and data model 48 + 49 + | AC | Type | Test File | Description | 50 + |----|------|-----------|-------------| 51 + | P1b.AC1.1 | unit | `tests/graph_types_test.rs` | `GraphNode` struct can be constructed with all fields from the architecture (id, project_id, node_type, title, description, status, priority, assigned_to, created_by, labels, created_at, started_at, completed_at, blocked_reason, metadata). Roundtrips through JSON serialization. | 52 + | P1b.AC1.2 | unit | `tests/graph_types_test.rs` | All 7 `NodeType` variants (Goal, Task, Decision, Option, Outcome, Observation, Revisit) exist and roundtrip through `Display`/`FromStr` (e.g., `NodeType::Goal.to_string()` -> `"goal"` -> `NodeType::from_str("goal")` -> `NodeType::Goal`). | 53 + | P1b.AC1.3 | unit | `tests/graph_types_test.rs` | `NodeStatus` enum has all variants. `validate_status(NodeType::Task, NodeStatus::Ready)` returns Ok. `validate_status(NodeType::Goal, NodeStatus::Ready)` returns Err. Tests cover every node type's valid status set per the architecture spec: Goal (Pending, Active, Completed, Cancelled), Task (Pending, Ready, Claimed, InProgress, Review, Completed, Blocked, Failed, Cancelled), Decision (Pending, Active, Decided, Superseded), Option (Pending, Active, Chosen, Rejected, Abandoned), Outcome (Active, Completed), Observation (Active), Revisit (Active, Completed). | 54 + | P1b.AC1.4 | unit | `tests/graph_types_test.rs` | `GraphEdge` struct can be constructed with all fields (id, edge_type, from_node, to_node, label, created_at). Roundtrips through JSON serialization. | 55 + | P1b.AC1.5 | unit | `tests/graph_types_test.rs` | All 7 `EdgeType` variants (Contains, DependsOn, LeadsTo, Chosen, Rejected, Supersedes, Informs) roundtrip through `Display`/`FromStr`. | 56 + 57 + **Implementation task:** Phase 1b, Task 1 (Graph types). 58 + 59 + ### P1b.AC2: Hierarchical ID generation 60 + 61 + | AC | Type | Test File | Description | 62 + |----|------|-----------|-------------| 63 + | P1b.AC2.1 | unit | `tests/graph_types_test.rs` | `generate_goal_id()` returns a string matching `^ra-[0-9a-f]{4}$`. Called multiple times, produces unique IDs (statistical check). | 64 + | P1b.AC2.2 | unit | `tests/graph_types_test.rs` | `generate_child_id("ra-a3f8", 1)` returns `"ra-a3f8.1"`. `generate_child_id("ra-a3f8.1", 3)` returns `"ra-a3f8.1.3"`. Nesting is unbounded. | 65 + | P1b.AC2.3 | unit | `tests/graph_types_test.rs` | Generated IDs contain only valid primary key characters (`[a-z0-9\-\.]`). `parent_id("ra-a3f8.1.3")` returns `Some("ra-a3f8.1")`; `parent_id("ra-a3f8")` returns `None`. | 66 + | P1b.AC2.4 | unit | `tests/graph_types_test.rs` | `generate_edge_id()` returns a string matching `^e-[0-9a-f]{8}$`. | 67 + 68 + **Implementation task:** Phase 1b, Task 2 (ID generation helpers). 69 + 70 + ### P1b.AC3: GraphStore CRUD 71 + 72 + | AC | Type | Test File | Description | 73 + |----|------|-----------|-------------| 74 + | P1b.AC3.1 | integration | `tests/graph_store_test.rs` | Create a goal node via `SqliteGraphStore::create_node`. Retrieve it via `get_node(id)`. All fields match. | 75 + | P1b.AC3.2 | integration | `tests/graph_store_test.rs` | `get_node("nonexistent-id")` returns `None` (not an error). | 76 + | P1b.AC3.3 | integration | `tests/graph_store_test.rs` | Create a node with status Pending. Call `update_node` to set status to Active. `get_node` confirms status is Active. Metadata updates also verified. | 77 + | P1b.AC3.4 | integration | `tests/graph_store_test.rs` | Create two nodes, `add_edge` with a Contains edge between them. `get_edges(from_node, Outgoing)` returns the edge paired with the target node. `get_edges(to_node, Incoming)` returns the edge paired with the source node. | 78 + | P1b.AC3.5 | integration | `tests/graph_store_test.rs` | Create a goal + 2 child tasks with Contains edges. `get_children(goal_id)` returns both children with their edge types. | 79 + | P1b.AC3.6 | integration | `tests/graph_store_test.rs` | Create goal -> task -> subtask chain via Contains edges. `get_subtree(goal_id)` returns all 3 nodes (recursive CTE walk). Verify subtask is included despite being a grandchild. | 80 + 81 + **Additional coverage (not mapped to a numbered AC but tested as part of AC3):** 82 + 83 + | Extra | Type | Test File | Description | 84 + |-------|------|-----------|-------------| 85 + | get_active_decisions | integration | `tests/graph_store_test.rs` | Create 2 Decision nodes under a project -- one Active, one Superseded. `get_active_decisions(project_id)` returns only the Active one. | 86 + | get_full_graph | integration | `tests/graph_store_test.rs` | Create a goal with tasks and edges. `get_full_graph(goal_id)` returns a `WorkGraph` containing all nodes and all edges involving those nodes. | 87 + 88 + **Implementation task:** Phase 1b, Task 4 (SqliteGraphStore CRUD). 89 + 90 + ### P1b.AC4: Dependency resolution and task surfacing 91 + 92 + | AC | Type | Test File | Description | 93 + |----|------|-----------|-------------| 94 + | P1b.AC4.1 | integration | `tests/graph_dependency_test.rs` | Create task A (Pending, no deps) and task B (Pending, DependsOn A). Update A to Completed. Verify B's status is automatically updated to Ready by the status transition hook. | 95 + | P1b.AC4.2 | integration | `tests/graph_dependency_test.rs` | Create 3 tasks under a goal: one manually set to Ready, one Pending with unmet dependency, one Completed. `get_ready_tasks(goal_id)` returns exactly the Ready one. | 96 + | P1b.AC4.3 | integration | `tests/graph_dependency_test.rs` | Create 2 Ready tasks: one High priority blocking 3 downstream tasks, one Critical priority blocking 0. `get_next_task(goal_id)` returns the Critical one (priority wins over downstream unblock count). Verify tie-breaking: 2 tasks of same priority -- the one with more downstream dependents wins. | 97 + 98 + **Implementation task:** Phase 1b, Task 5 (Dependency resolution and task surfacing). 99 + 100 + ### P1b.AC5: Atomic task claiming 101 + 102 + | AC | Type | Test File | Description | 103 + |----|------|-----------|-------------| 104 + | P1b.AC5.1 | integration | `tests/graph_claim_search_test.rs` | Create a Ready task. `claim_task(id, "agent-1")` returns `true`. Node now has status Claimed and `assigned_to = "agent-1"`. | 105 + | P1b.AC5.2 | integration | `tests/graph_claim_search_test.rs` | Create a Ready task, claim it once (returns `true`), claim it again with a different agent (returns `false` -- already claimed). | 106 + | P1b.AC5.1 + P1b.AC5.2 (concurrency) | integration | `tests/graph_concurrency_test.rs` | Spawn 10 tokio tasks all calling `claim_task` for the same Ready task simultaneously. Exactly 1 succeeds (`true`), the other 9 get `false`. Task ends up Claimed with a single `assigned_to`. | 107 + 108 + **Implementation tasks:** Phase 1b, Task 6 (Atomic task claiming) and Task 9 (Concurrency test). 109 + 110 + ### P1b.AC6: Full-text search 111 + 112 + | AC | Type | Test File | Description | 113 + |----|------|-----------|-------------| 114 + | P1b.AC6.1 | integration | `tests/graph_claim_search_test.rs` | Create nodes with various titles and descriptions (e.g., "authentication handler", "database schema"). `search_nodes("authentication")` returns nodes containing that term in title or description. Nodes without the term are excluded. | 115 + | P1b.AC6.2 | integration | `tests/graph_claim_search_test.rs` | Create nodes in 2 different projects (project A and project B). Search with `project_id = A` returns only project A's nodes. Create nodes of different types (Task and Observation). Search with `node_type = Task` returns only Task nodes. Combined filter (project + type) works correctly. | 116 + 117 + **Implementation task:** Phase 1b, Task 6 (FTS5 search). 118 + 119 + ### P1b.AC7: Graph tools for agents 120 + 121 + | AC | Type | Test File | Description | 122 + |----|------|-----------|-------------| 123 + | P1b.AC7.1 | integration | `tests/graph_tools_test.rs` | **Low-level tools**: (1) `CreateNodeTool::execute()` with goal params creates a node retrievable from the store. (2) `UpdateNodeTool::execute()` changes a node's status. (3) `AddEdgeTool::execute()` creates an edge between two nodes. (4) `QueryNodesTool::execute()` returns JSON array of matching nodes filtered by type/status. (5) `SearchNodesTool::execute()` returns FTS5 search results. Each tool is tested via its `execute()` method with JSON params passing through the `Tool` trait interface. | 124 + | P1b.AC7.2 | integration | `tests/graph_tools_test.rs` | **High-level tools**: (1) `LogDecisionTool::execute()` with 2 options creates 1 Decision node + 2 Option nodes + 2 LeadsTo edges. (2) `ChooseOptionTool::execute()` adds Chosen edge to selected option, Rejected edges to others, updates Decision status to Decided. (3) `RecordOutcomeTool::execute()` creates Outcome node + LeadsTo edge from parent. (4) `RecordObservationTool::execute()` creates Observation node + Informs edge to related node. (5) `RevisitTool::execute()` creates Revisit node + LeadsTo edge from failed outcome, optionally creates new Decision node. | 125 + 126 + **Implementation task:** Phase 1b, Task 7 (Graph tools for agents). 127 + 128 + --- 129 + 130 + ## Phase 1c: Sessions + Export + Interchange 131 + 132 + ### P1c.AC1: Session management 133 + 134 + | AC | Type | Test File | Description | 135 + |----|------|-----------|-------------| 136 + | P1c.AC1.1 | integration | `tests/session_test.rs` | `SessionStore::create_session(project_id, goal_id)` returns a `Session` with a non-empty ID, correct goal_id, non-null `started_at`, and `ended_at = None`. | 137 + | P1c.AC1.2 | integration | `tests/session_test.rs` | Set up a goal with tasks (some Completed, some Pending) and a Decided Decision. Call `end_session(session_id, graph_store)`. Session now has non-null `handoff_notes` and `ended_at`. | 138 + | P1c.AC1.3 | integration | `tests/session_test.rs` | Verify the handoff notes string from P1c.AC1.2 contains all 4 sections: "## Done" (lists completed tasks), "## Remaining" (lists pending/in-progress tasks), "## Blocked" (lists blocked tasks or shows none), "## Decisions Made" (lists decided decisions with chosen option). Exact content validated against the test data setup. | 139 + | P1c.AC1.4 | integration | `tests/session_test.rs` | Create 2 sessions for the same goal (create first, end it, create second). `get_latest_session(goal_id)` returns the second session (most recent by `started_at`). | 140 + 141 + **Implementation task:** Phase 1c, Task 1 (Session management and handoff notes). 142 + 143 + ### P1c.AC2: ADR export 144 + 145 + | AC | Type | Test File | Description | 146 + |----|------|-----------|-------------| 147 + | P1c.AC2.1 | integration | `tests/adr_export_test.rs` | Create 2 Decision nodes in a project (each with Options, one Decided). Call `export_adrs(project_id, tempdir)`. Two files are created at `tempdir/001-*.md` and `tempdir/002-*.md`. Files are numbered sequentially by creation date. | 148 + | P1c.AC2.2 | integration | `tests/adr_export_test.rs` | Read the exported markdown. Verify it contains: (1) `# ADR-001:` title header, (2) `## Status:` section, (3) `## Context:` from Decision description, (4) `## Options Considered:` with CHOSEN/REJECTED labels and pros/cons from Option metadata, (5) `## Outcome:` section (if Outcome node exists), (6) `## Related Tasks:` listing associated task IDs. | 149 + 150 + **Implementation task:** Phase 1c, Task 2 (ADR export). 151 + 152 + ### P1c.AC3: TOML graph interchange 153 + 154 + | AC | Type | Test File | Description | 155 + |----|------|-----------|-------------| 156 + | P1c.AC3.1 | integration | `tests/interchange_test.rs` | Create a goal with tasks, decisions, options, and edges. Call `export_goal(goal_id, project_name)`. Parse the returned TOML string. Verify `[meta]` section has version, goal_id, project, exported_at, content_hash. Verify `[nodes.*]` section contains all nodes with correct fields. Verify `[edges.*]` section contains all edges. | 157 + | P1c.AC3.2 | integration | `tests/interchange_test.rs` | Export the same goal twice without modifications. Assert the two TOML strings are byte-identical (`assert_eq!`). This validates deterministic key ordering (BTreeMap), deterministic timestamps (no re-generation), and omitted null fields. | 158 + | P1c.AC3.3 | integration | `tests/interchange_test.rs` | Export a goal. Modify a node's title in the TOML string (string manipulation). Import with `ImportStrategy::Theirs`. Verify the DB now has the modified title. Also test `Ours` strategy: modify a node, import with Ours, verify DB retains the original value. | 159 + | P1c.AC3.4 | integration | `tests/interchange_test.rs` | Export goal from DB A. Import into a fresh DB B. Export from DB B. Assert both TOML strings are byte-identical (round-trip property). | 160 + | P1c.AC3.5 | integration | `tests/interchange_test.rs` | Create a goal in DB. Export to TOML. Add a new node and change an existing node's title in the TOML string. Call `diff_goal(toml_content)`. Verify `DiffResult.added_nodes` has 1 entry, `changed_nodes` has 1 entry with the changed field listed, and `unchanged_nodes` count matches expectations. | 161 + | P1c.AC3.6 | integration | `tests/interchange_test.rs` | Craft a TOML string containing an edge whose `to_node` references a nonexistent node ID (e.g., a cross-goal reference). Import it. Verify the edge appears in `ImportResult.skipped_edges` with a descriptive message, and the edge is NOT created in the DB. | 162 + 163 + **Implementation tasks:** Phase 1c, Task 3 (TOML export), Task 4 (TOML import), Task 5 (TOML diff). 164 + 165 + ### P1c.AC4: Node decay 166 + 167 + | AC | Type | Test File | Description | 168 + |----|------|-----------|-------------| 169 + | P1c.AC4.1 | unit | `tests/decay_test.rs` | Create a `GraphNode` with `completed_at` 2 days ago. Call `decay_node(node, now, default_config)`. Result has `DecayDetail::Full` containing description and metadata. | 170 + | P1c.AC4.2 | unit | `tests/decay_test.rs` | Create a `GraphNode` with `completed_at` 15 days ago. Call `decay_node(node, now, default_config)`. Result has `DecayDetail::Summary` with title and status but no full description. | 171 + | P1c.AC4.3 | unit | `tests/decay_test.rs` | Create a `GraphNode` with `completed_at` 45 days ago. Call `decay_node(node, now, default_config)`. Result has `DecayDetail::Minimal` with only title and status. | 172 + | P1c.AC4.4 | unit | `tests/decay_test.rs` | Custom config: `DecayConfig { recent_days: 3, older_days: 10 }`. Node completed 5 days ago. `decay_node` returns `Summary` (not `Full`, since 5 > 3). Node completed 2 days ago returns `Full` (2 < 3). Node completed 15 days ago returns `Minimal` (15 > 10). | 173 + 174 + **Implementation task:** Phase 1c, Task 6 (Node decay for context injection). 175 + 176 + ### P1c.AC5: CLI commands 177 + 178 + | AC | Type | Test File | Description | 179 + |----|------|-----------|-------------| 180 + | P1c.AC5.1 | human | N/A | See Human Verification table below. | 181 + | P1c.AC5.2 | human | N/A | See Human Verification table below. | 182 + | P1c.AC5.3 | human | N/A | See Human Verification table below. | 183 + | P1c.AC5.4 | human | N/A | See Human Verification table below. | 184 + 185 + **Implementation task:** Phase 1c, Task 7 (Wire up CLI commands). 186 + 187 + --- 188 + 189 + ## Phase 1d: Agent Runtime + Single-Agent Execution 190 + 191 + ### P1d.AC1: Cherry-pick and adapt existing modules 192 + 193 + | AC | Type | Test File | Description | 194 + |----|------|-----------|-------------| 195 + | P1d.AC1.1 | unit | N/A (compile check) | `cargo check` succeeds after removing TUI imports from `ralph/mod.rs`. No references to `ratatui`, `crossterm`, or `tui` remain. Verified by `cargo check` during Task 1 and by the full `cargo test` run. | 196 + | P1d.AC1.2 | unit | `tests/profile_test.rs` | `SecurityScope` type compiles, deserializes from TOML, and `Default` returns permissive scope (verified alongside P1d.AC3.1 tests). | 197 + | P1d.AC1.3 | integration | `tests/graph_tools_test.rs` | The v2 tool registry factory (`create_v2_registry`) includes graph tools alongside existing file/shell/signal tools. Verified indirectly by the graph tools tests which construct tools through the factory. | 198 + 199 + **Implementation tasks:** Phase 1d, Task 1 (Clean up cherry-picked modules), Task 3 (SecurityScope). 200 + 201 + **Rationale for P1d.AC1.1:** This is a compile-time property. The test suite implicitly verifies it because `cargo test` runs `cargo check` first. No runtime test is needed -- if the TUI references remain, nothing compiles. 202 + 203 + ### P1d.AC2: Agent trait and types 204 + 205 + | AC | Type | Test File | Description | 206 + |----|------|-----------|-------------| 207 + | P1d.AC2.1 | unit | `tests/agent_types_test.rs` | A mock struct implementing the `Agent` trait compiles and can return values from `id()`, `profile()`, `run()`, and `cancel()`. This verifies the trait's method signatures. | 208 + | P1d.AC2.2 | unit | `tests/agent_types_test.rs` | `AgentContext` struct can be constructed with all required fields: `work_package_tasks` (Vec<GraphNode>), `relevant_decisions` (Vec<GraphNode>), `handoff_notes` (Option<String>), `agents_md_summaries` (Vec<(String, String)>), `profile` (AgentProfile), `project_path` (PathBuf), `graph_store` (Arc<dyn GraphStore>). | 209 + | P1d.AC2.3 | unit | `tests/agent_types_test.rs` | All 4 `AgentOutcome` variants can be constructed and pattern-matched: `Completed { summary }`, `Blocked { reason }`, `Failed { error }`, `TokenBudgetExhausted { summary, tokens_used }`. | 210 + 211 + **Implementation task:** Phase 1d, Task 2 (Agent trait and AgentOutcome). 212 + 213 + ### P1d.AC3: Agent profiles 214 + 215 + | AC | Type | Test File | Description | 216 + |----|------|-----------|-------------| 217 + | P1d.AC3.1 | unit | `tests/profile_test.rs` | `AgentProfile` deserializes from a TOML string containing all fields (name, extends, role, system_prompt, allowed_tools, security, llm, turn_limit, token_budget). All values round-trip correctly. | 218 + | P1d.AC3.2 | unit | `tests/profile_test.rs` | `resolve_profile("coder", None)` returns the built-in coder profile. Same for all 5 built-in profiles: planner, coder, reviewer, tester, researcher. Each has a non-empty system_prompt and role. | 219 + | P1d.AC3.3 | integration | `tests/profile_test.rs` | Create a `tempfile::TempDir` with `.rustagent/profiles/custom.toml` containing a valid profile TOML. `resolve_profile("custom", Some(tempdir_path))` returns the custom profile with correct fields. | 220 + | P1d.AC3.4 | integration | `tests/profile_test.rs` | Create a project-level profile file named `coder.toml` that overrides the built-in coder. `resolve_profile("coder", Some(project_path))` returns the project-level profile (not the built-in). Verify by checking a distinctive field value. | 221 + | P1d.AC3.5 | integration | `tests/profile_test.rs` | Create a custom profile with `extends = "coder"`. Resolve it. Verify: (1) `system_prompt` is parent's prompt + separator + child's prompt (appended). (2) `allowed_tools` is the child's list only (replaced, not merged). (3) Scalar fields like `role` take the child's value. (4) Optional fields like `turn_limit` fall through to parent if child is `None`. | 222 + 223 + **Implementation tasks:** Phase 1d, Task 3 (AgentProfile + SecurityScope), Task 5 (Built-in profiles and resolution). 224 + 225 + ### P1d.AC4: AgentRuntime (agentic loop) 226 + 227 + | AC | Type | Test File | Description | 228 + |----|------|-----------|-------------| 229 + | P1d.AC4.1 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient`: queue a text response followed by a `signal_completion` tool call. Run the runtime. Returns `AgentOutcome::Completed` with a summary. Verify the tool was executed through the registry. | 230 + | P1d.AC4.2 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient`: queue 3 consecutive responses that each request an unknown/invalid tool name. Run with `max_consecutive_tool_failures = 3`. Returns `AgentOutcome::Blocked` with reason mentioning consecutive tool failures. | 231 + | P1d.AC4.3 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient` with configurable token counts: (1) Set budget to 1000, warning at 80% (800). Queue responses that cumulatively reach 800+ tokens. Verify a "wrap up" system message is injected into the conversation. (2) Set budget to 500. Queue responses exceeding 500 tokens. Returns `AgentOutcome::TokenBudgetExhausted` with tokens_used >= 500. | 232 + | P1d.AC4.4 | integration | `tests/agent_runtime_test.rs` | Using `MockLlmClient`: configure the mock to return errors on `chat()`. Set `max_consecutive_llm_failures = 3`. Run. After 3 consecutive LLM errors, returns `AgentOutcome::Blocked` with reason mentioning LLM failures. | 233 + | P1d.AC4.5 | integration | `tests/agent_runtime_test.rs` | Set `max_turns = 3`. Queue responses that never call `signal_completion` (e.g., just text or non-terminating tool calls). Run. Returns after exactly 3 turns with a completion summary mentioning "turn limit". | 234 + 235 + **Implementation task:** Phase 1d, Task 6 (AgentRuntime with error handling). 236 + 237 + ### P1d.AC5: Context assembly 238 + 239 + | AC | Type | Test File | Description | 240 + |----|------|-----------|-------------| 241 + | P1d.AC5.1 | unit | `tests/context_test.rs` | Construct an `AgentContext` with: 2 work package tasks, 1 relevant decision, handoff notes text, and 2 AGENTS.md summaries. Call `ContextBuilder::build_system_prompt(ctx)`. Verify the output string contains all sections: `## Role`, `## Task` (with `[TASK]` and `[CRITERIA]` markers for each task), `## Session Continuity` (with `[HANDOFF]`), `## Active Decisions` (with `[DECISION]`), `## Project Conventions` (with AGENTS.md paths and headings), `## Rules`. | 242 + | P1d.AC5.2 | unit | `tests/context_test.rs` | Create a `tempfile::TempDir` representing a project with `AGENTS.md` at root and `src/AGENTS.md` nested inside. Call `resolve_agents_md(project_root, &["src/auth/handler.rs"])`. Returns 2 summaries. The `src/AGENTS.md` summary appears first (closest-to-file). Both summaries contain extracted top-level headings. | 243 + 244 + **Implementation task:** Phase 1d, Task 7 (ContextBuilder and AGENTS.md resolution). 245 + 246 + ### P1d.AC6: Single-agent CLI 247 + 248 + | AC | Type | Test File | Description | 249 + |----|------|-----------|-------------| 250 + | P1d.AC6.1 | human | N/A | See Human Verification table below. | 251 + | P1d.AC6.2 | human | N/A | See Human Verification table below. | 252 + 253 + **Implementation task:** Phase 1d, Task 8 (Wire up `rustagent run`). 254 + 255 + --- 256 + 257 + ## Human Verification Required 258 + 259 + The following acceptance criteria cannot be fully automated because they depend on CLI output formatting, user-facing presentation, or live LLM interaction that requires API keys and subjective evaluation. 260 + 261 + | AC | Phase | Reason | Verification Approach | 262 + |----|-------|--------|----------------------| 263 + | P1c.AC5.1 | 1c | CLI output formatting for `rustagent sessions` is presentation-level. The underlying `SessionStore::list_sessions` is tested in `tests/session_test.rs`; the CLI wiring is a thin print layer. | Run `cargo run -- sessions --goal <goal_id>` after creating test data. Verify the output lists sessions with IDs, start/end times, and goal references. Verify `--help` shows the subcommands. | 264 + | P1c.AC5.2 | 1c | CLI output formatting for `rustagent sessions latest` is presentation-level. The underlying `SessionStore::get_latest_session` is tested in `tests/session_test.rs`. | Run `cargo run -- sessions latest --goal <goal_id>` after ending a session. Verify the output displays the handoff notes with all 4 sections (Done, Remaining, Blocked, Decisions Made). | 265 + | P1c.AC5.3 | 1c | CLI wiring for `rustagent decisions export` involves file writes to a user-specified directory. The underlying `export_adrs` function is tested in `tests/adr_export_test.rs`. | Run `cargo run -- decisions export --project <name> --output /tmp/adrs`. Verify ADR files appear in the output directory. Inspect file contents. | 266 + | P1c.AC5.4 | 1c | CLI wiring for `rustagent graph export/import/diff`. The underlying interchange functions are tested in `tests/interchange_test.rs`. | Run `cargo run -- graph export --goal <id>` and verify TOML output. Run `cargo run -- graph import <file>` and verify import summary. Run `cargo run -- graph diff <file>` and verify diff output. Verify `--help` for each subcommand. | 267 + | P1d.AC1.1 | 1d | Compile-time property. Not a runtime test -- verified implicitly by `cargo check` / `cargo test` succeeding. | Run `cargo check`. If it compiles, the criterion is met. Search for `ratatui`, `crossterm`, `tui` in `src/` to confirm removal. | 268 + | P1d.AC6.1 | 1d | End-to-end `rustagent run` requires a live LLM API key (Anthropic/OpenAI) to execute the agent loop. The agentic loop itself is tested with `MockLlmClient` in `tests/agent_runtime_test.rs`, but the CLI integration layer (DB open, goal creation, session management, profile resolution, outcome handling) is a thin orchestration layer that is impractical to mock in an automated test without significant test infrastructure. | **Manual steps**: (1) Set `ANTHROPIC_API_KEY` env var. (2) Run `cargo run -- project add test-proj .` (3) Run `cargo run -- run --project test-proj "Create a hello world program"`. (4) Verify: a goal node is created in the DB, a session is created, the agent executes tool calls (visible in logs at `RUST_LOG=rustagent=debug`), and an outcome is recorded. (5) Run `cargo run -- sessions latest --goal <id>` to verify handoff notes were generated. | 269 + | P1d.AC6.2 | 1d | Profile selection via `--profile` flag requires end-to-end CLI execution. | **Manual steps**: (1) Run `cargo run -- run --project test-proj --profile reviewer "Review the codebase"`. (2) Verify the agent uses the reviewer profile's system prompt (visible in debug logs). (3) Run with `--profile nonexistent` and verify a clear error message. | 270 + 271 + --- 272 + 273 + ## Test File Summary 274 + 275 + | Test File | Phase | Acceptance Criteria Covered | 276 + |-----------|-------|-----------------------------| 277 + | `tests/db_test.rs` | 1a | P1a.AC1.1, P1a.AC1.2, P1a.AC1.3, P1a.AC1.4, P1a.AC2.1, P1a.AC2.2, P1a.AC2.3 | 278 + | `tests/project_test.rs` | 1a | P1a.AC3.1, P1a.AC3.2, P1a.AC3.3, P1a.AC3.4, P1a.AC3.5, P1a.AC3.6 | 279 + | `tests/graph_types_test.rs` | 1b | P1b.AC1.1, P1b.AC1.2, P1b.AC1.3, P1b.AC1.4, P1b.AC1.5, P1b.AC2.1, P1b.AC2.2, P1b.AC2.3, P1b.AC2.4 | 280 + | `tests/graph_store_test.rs` | 1b | P1b.AC3.1, P1b.AC3.2, P1b.AC3.3, P1b.AC3.4, P1b.AC3.5, P1b.AC3.6 | 281 + | `tests/graph_dependency_test.rs` | 1b | P1b.AC4.1, P1b.AC4.2, P1b.AC4.3 | 282 + | `tests/graph_claim_search_test.rs` | 1b | P1b.AC5.1, P1b.AC5.2, P1b.AC6.1, P1b.AC6.2 | 283 + | `tests/graph_concurrency_test.rs` | 1b | P1b.AC5.1, P1b.AC5.2 (concurrency aspect) | 284 + | `tests/graph_tools_test.rs` | 1b | P1b.AC7.1, P1b.AC7.2 | 285 + | `tests/session_test.rs` | 1c | P1c.AC1.1, P1c.AC1.2, P1c.AC1.3, P1c.AC1.4 | 286 + | `tests/adr_export_test.rs` | 1c | P1c.AC2.1, P1c.AC2.2 | 287 + | `tests/interchange_test.rs` | 1c | P1c.AC3.1, P1c.AC3.2, P1c.AC3.3, P1c.AC3.4, P1c.AC3.5, P1c.AC3.6 | 288 + | `tests/decay_test.rs` | 1c | P1c.AC4.1, P1c.AC4.2, P1c.AC4.3, P1c.AC4.4 | 289 + | `tests/agent_types_test.rs` | 1d | P1d.AC2.1, P1d.AC2.2, P1d.AC2.3 | 290 + | `tests/profile_test.rs` | 1d | P1d.AC1.2, P1d.AC3.1, P1d.AC3.2, P1d.AC3.3, P1d.AC3.4, P1d.AC3.5 | 291 + | `tests/agent_runtime_test.rs` | 1d | P1d.AC4.1, P1d.AC4.2, P1d.AC4.3, P1d.AC4.4, P1d.AC4.5 | 292 + | `tests/context_test.rs` | 1d | P1d.AC5.1, P1d.AC5.2 | 293 + 294 + --- 295 + 296 + ## Coverage Audit 297 + 298 + **Total acceptance criteria:** 53 299 + 300 + **Automated test coverage:** 44 criteria (83%) 301 + 302 + **Human verification only:** 7 criteria (13%) -- P1c.AC5.1, P1c.AC5.2, P1c.AC5.3, P1c.AC5.4, P1d.AC6.1, P1d.AC6.2, P1d.AC1.1 303 + 304 + **Compile-time verification:** 1 criterion (2%) -- P1d.AC1.1 (verified implicitly by `cargo check`) 305 + 306 + **Hybrid (automated + human):** 1 criterion -- P1d.AC1.3 (verified indirectly through graph tools tests, but full registry integration is a compile-time property) 307 + 308 + All 53 acceptance criteria are mapped to either an automated test or a documented human verification procedure. No criteria are left unaddressed. 309 + 310 + --- 311 + 312 + ## Implementation Notes 313 + 314 + ### Test Infrastructure Patterns 315 + 316 + All integration tests follow these patterns established in the implementation plans: 317 + 318 + 1. **In-memory database:** `Database::open_in_memory()` for all tests except P1a.AC1.1 (which specifically tests file creation and uses `tempfile::TempDir`). 319 + 2. **Async runtime:** All integration tests use `#[tokio::test]`. 320 + 3. **Test helpers:** Each test file should define helper functions to create test nodes/edges with sensible defaults, reducing boilerplate. 321 + 4. **MockLlmClient:** Phase 1d runtime tests use the existing `src/llm/mock.rs` with queued responses and configurable token counts (token count support added in Phase 1d Task 4). 322 + 323 + ### Architecture Deviations Reflected in Tests 324 + 325 + The following implementation deviations from the architecture are reflected in the test design: 326 + 327 + - **`update_node` partial-update signature (Phase 1b):** Tests pass `Option` fields rather than full `GraphNode` structs, matching the implementation's partial-update approach that avoids read-modify-write races. 328 + - **Project `config_overrides` and `metadata` as JSON strings (Phase 1a):** Tests treat these as `String` / `Option<String>` rather than typed structs, matching the DB-first representation. 329 + - **Session TOML export deferred (Phase 1c):** No tests for `.rustagent/sessions/*.toml` file generation. Session tests cover DB records and handoff notes only. 330 + - **`cancel()` as no-op (Phase 1d):** Tests verify `cancel()` compiles and can be called but do not test actual cancellation behavior (deferred to Phase 2 with CancellationToken).
+288
docs/plans/v2-architecture-review.md
··· 1 + # V2 Architecture Review: Gap Analysis 2 + 3 + **Date:** 2026-02-06 4 + **Reviewer:** Claude (technical product owner perspective) 5 + **Document under review:** `docs/plans/v2-architecture.md` 6 + 7 + --- 8 + 9 + ## Critical Issues (Will Cause System Failures) 10 + 11 + ### 1. Hash-Based ID Scheme Is Mathematically Broken 12 + 13 + The plan specifies 4 hex characters (16 bits, 65,536 possibilities) truncated from UUID v4 and claims it's "collision-safe enough for <10 concurrent agents." **This is provably wrong.** 14 + 15 + | Node Count | P(collision) | Scenario | 16 + |-----------|-------------|----------| 17 + | 50 | 1.85% | Tiny single-agent project | 18 + | 100 | 7.27% | Small project | 19 + | 300 | ~50% | Medium project, 1 agent | 20 + | 600 | ~94% | Medium project, 3 agents | 21 + | 1,500 | ~100% | 10 agents (the stated use case) | 22 + 23 + A medium project generates 300-600 nodes (goals + tasks + subtasks + decisions + options + outcomes + observations + revisits). The birthday bound is ~301 IDs for a 50% collision probability. With 10 concurrent agents the situation is dramatically worse, not better, since each agent generates IDs independently. 24 + 25 + **Impact:** Collisions hit the PRIMARY KEY constraint. Either INSERTs fail silently (agent loses work), or with upsert semantics, one node overwrites another. The work graph — the central coordination mechanism — becomes corrupt. 26 + 27 + **Fix:** Use 8 hex characters (32 bits). At 10,000 nodes the collision probability is 1.16%. This matches the convention git uses for short hashes and is still human-typeable (`ra-a3f8b2c1.1.3`). Alternatively, clarify that the 4-char hash only needs sibling-uniqueness (within one parent) and use the full hierarchical path as the PK. 28 + 29 + ### 2. SQLite Write Contention Is Not Addressed 30 + 31 + The plan says "WAL mode for concurrent reads" but **WAL does not enable concurrent writes**. SQLite remains single-writer even in WAL mode. With 4+ workers writing simultaneously (task claims, status updates, node creation, edge creation), you'll hit `SQLITE_BUSY` errors. 32 + 33 + Specific dangers: 34 + - **Transaction upgrade deadlocks**: If a transaction starts as a reader then tries to write, SQLite returns `SQLITE_BUSY` *immediately* without respecting `busy_timeout` 35 + - **"Atomic claim"** (`claim_task`) is described but no implementation strategy is given. A naive read-then-write is NOT atomic without `BEGIN IMMEDIATE` 36 + - **tokio-rusqlite does not serialize writes** — the application must handle this 37 + 38 + **Missing from the plan:** 39 + - Write serialization strategy (single connection with mutex? Channel-based write queue?) 40 + - Transaction isolation level choices (`IMMEDIATE` vs `DEFERRED`) 41 + - `busy_timeout` configuration 42 + - Retry logic for `SQLITE_BUSY` 43 + - Checkpoint tuning (`wal_autocheckpoint`) 44 + 45 + **Fix:** Add a "Database Concurrency Strategy" section specifying: single `tokio-rusqlite` connection, all write transactions use `BEGIN IMMEDIATE`, `busy_timeout` of 5000ms, and application-level write queue for high-contention operations. 46 + 47 + ### 3. No Error Handling Strategy for LLM Failures Mid-Task 48 + 49 + The plan details worker lifecycle states but never addresses what happens when: 50 + - An LLM API call fails mid-task execution (network error, rate limit, context window exceeded) 51 + - A worker's LLM returns malformed tool calls 52 + - A worker hallucinates and generates invalid graph operations 53 + - A worker exceeds its token budget 54 + 55 + The current codebase has `llm/retry.rs` with rate-limit handling, but there's no specification for how the **orchestrator** should handle persistent LLM failures vs transient ones. `max_retries_per_task: 2` is mentioned but retry semantics aren't defined — does it re-spawn a fresh worker? Reuse the same context? Reset the task fully? 56 + 57 + --- 58 + 59 + ## Significant Gaps (Incomplete Specifications) 60 + 61 + ### 4. Agent Profiles: Referenced But Not Specified 62 + 63 + The plan mentions "Agent profiles in TOML config" and `builtin_profiles.rs` with planner/coder/reviewer/tester/researcher, but never specifies: 64 + - What fields an `AgentProfile` contains beyond what's in the struct 65 + - What system prompts each built-in profile uses 66 + - How profiles control which tools an agent can access 67 + - How `SecurityScope` maps to profiles 68 + - The TOML schema for custom profiles 69 + - Whether profiles can inherit from/extend built-in profiles 70 + 71 + This is one of the most important design surfaces — it determines how agents *behave* — and it's entirely hand-waved. 72 + 73 + ### 5. Autonomy Levels: Named But Not Defined 74 + 75 + `config/autonomy.rs` is listed with types `AutonomyLevel` and `ApprovalGate`. The orchestrator mentions "configurable approval gates." But nowhere does the plan define: 76 + - What autonomy levels exist 77 + - What actions each level permits/restricts 78 + - What approval gates are available 79 + - How gates interact with the orchestrator state machine 80 + - Whether autonomy is per-project, per-goal, or per-agent 81 + - How approval requests are surfaced to users (CLI? Web UI? Both?) 82 + 83 + ### 6. ContextBuilder: The Glue That's Missing 84 + 85 + `context/mod.rs` is supposed to combine AGENTS.md, memories, task details, and decisions into an `AgentContext` for each worker. But there's no specification for: 86 + - How context is prioritized when it exceeds the LLM's context window 87 + - Token budgeting strategy (how much of the window goes to system prompt vs memories vs task details vs conversation history) 88 + - How AGENTS.md sections are matched to a worker's file scope 89 + - Whether context is static (built once at spawn) or dynamic (refreshed during execution) 90 + - How the memory decay thresholds work concretely (age-based? access-based? what are "recent", "older", "ancient"?) 91 + 92 + ### 7. AGENTS.md Format: Undefined 93 + 94 + The plan mentions "AGENTS.md parser + resolver" and "closest-to-file resolution" but never specifies: 95 + - The expected format of AGENTS.md files 96 + - What sections/headings are recognized 97 + - How inheritance works when multiple AGENTS.md files exist in a directory hierarchy 98 + - Whether this follows any existing convention (Cursor rules? Claude's own CLAUDE.md?) or is a new format 99 + 100 + ### 8. Memory System: Embedding Dimension Mismatch 101 + 102 + The schema hardcodes `FLOAT[1536]` for embeddings (OpenAI's `text-embedding-ada-002` dimension). But: 103 + - The plan supports Ollama embeddings, which use different dimensions (e.g., `nomic-embed-text` = 768, `mxbai-embed-large` = 1024) 104 + - There's no strategy for mixed-dimension embeddings 105 + - Switching providers would require re-embedding all existing memories or supporting multiple virtual tables 106 + - No embedding model is specified in the config schema 107 + 108 + ### 9. Cross-Goal Edge Import: Warning-Not-Error Is Dangerous 109 + 110 + The plan says cross-goal edges produce "warnings, not errors" when the referenced goal isn't imported yet. But this means: 111 + - The graph can be in an inconsistent state after import 112 + - Dangling edge references violate referential integrity 113 + - There's no mechanism to later resolve these warnings 114 + - A user could import files in any order and get a silently broken graph 115 + 116 + --- 117 + 118 + ## Missing Specifications 119 + 120 + ### 10. No Testing Strategy 121 + 122 + The current codebase has 17 test files. The v2 plan mentions zero tests. For a system this complex, there's no mention of: 123 + - Unit testing approach for graph operations 124 + - Integration testing for multi-agent orchestration 125 + - How to test concurrent worker behavior 126 + - Mock strategies for LLM calls during testing 127 + - How to test the daemon/API endpoints 128 + - Performance/load testing for SQLite under concurrent access 129 + - End-to-end testing strategy 130 + 131 + ### 11. No Migration Path from V1 132 + 133 + The current system uses JSON spec files. The plan says nothing about: 134 + - Whether existing specs can be imported into the new graph 135 + - Whether the CLI remains backward-compatible during transition 136 + - How users transition from `rustagent run <spec>` to `rustagent run --project <name> "goal"` 137 + - Whether the TUI (which is already built with 15 files) is carried forward or abandoned 138 + 139 + The TUI is a significant existing investment (ratatui-based, multiple views) that isn't mentioned in the v2 plan at all. Is it replaced by the web UI? Does it coexist? 140 + 141 + ### 12. No Token/Cost Management 142 + 143 + For a system that spawns multiple concurrent LLM workers, there's no mention of: 144 + - Token budget per worker, per task, per goal, or per session 145 + - Cost tracking or reporting 146 + - Circuit breakers if spending exceeds thresholds 147 + - How `max_tokens` interacts with context window management 148 + - Whether different workers can use different models (cheap model for planning, expensive for coding) 149 + 150 + ### 13. No Observability Beyond WebSocket Events 151 + 152 + The plan lists WebSocket events for the web UI but doesn't address: 153 + - Structured logging for the orchestrator and workers 154 + - Metrics collection (task completion rate, agent utilization, retry rates) 155 + - How to debug a misbehaving worker after the fact 156 + - Whether worker LLM conversations are persisted for audit/debugging 157 + - Alerting on failures 158 + 159 + ### 14. No Git Integration Details 160 + 161 + `tools/git.rs` is listed but never specified. For a coding agent, git is critical: 162 + - What git operations are supported? (commit, branch, diff, status, merge?) 163 + - How do parallel workers interact with git? (separate branches? worktrees?) 164 + - What happens when two workers modify the same file through git? 165 + - How does git interact with the file ownership map? 166 + - Is there a strategy for atomic commits per work package? 167 + 168 + ### 15. No Graceful Degradation 169 + 170 + The plan describes the happy path thoroughly but doesn't address: 171 + - What happens if sqlite-vec fails to load? (The plan mentions `instant-distance` as fallback but with zero detail) 172 + - What happens if the embedding provider is unavailable? Does the system work without memory? 173 + - What happens if the daemon crashes while workers are running? 174 + - What happens if disk is full and SQLite can't write? 175 + 176 + --- 177 + 178 + ## Questionable Technical Decisions 179 + 180 + ### 16. sqlite-vec: Pre-v1 With Maintenance Concerns 181 + 182 + Research findings: 183 + - Pre-v1 with explicit warning: "expect breaking changes" 184 + - No updates for ~6 months as of recent reports 185 + - **Brute-force only** — no ANN indexing (linear scan) 186 + - Performance degrades significantly beyond 500K vectors 187 + - This is fine for the memory system (small scale), but the "pre-v1 with stale maintenance" risk should be acknowledged 188 + 189 + The fallback to `instant-distance` is mentioned once without any detail on how switching would work, what the API differences are, or whether the schema changes. 190 + 191 + ### 17. Deterministic Orchestrator: Power vs. Adaptability Trade-off 192 + 193 + The plan explicitly states the orchestrator is "NOT an LLM agent" but a "deterministic state machine." This is presented as purely beneficial. But it creates blind spots: 194 + 195 + - **Work package grouping** requires predicting file scope before execution — but an agent often discovers it needs to modify files not in its original scope 196 + - **Task dependency resolution** is static — but tasks discovered during execution need dynamic re-planning 197 + - A deterministic scheduler can't handle "this task turned out to be three tasks" without a planner agent re-intervening 198 + - The plan's own `NeedsDecision` message implies the orchestrator sometimes needs judgment it can't provide 199 + 200 + The plan should specify: what triggers re-planning? How does the orchestrator handle scope changes? Is there a feedback loop where workers can request work package modifications? 201 + 202 + ### 18. "Hybrid Messaging" Needs More Rigor 203 + 204 + The plan says "Orchestrator controls lifecycle + agents can message peers directly" but peer messaging is only mentioned for review workflows. This creates ambiguity: 205 + - Can a coder worker ask another coder worker a question? 206 + - What's the message delivery guarantee? (fire-and-forget? at-least-once?) 207 + - What happens to in-flight messages when a worker is cancelled? 208 + - Is the message bus persisted or in-memory only? 209 + 210 + The plan says communication is "primarily through the shared SQLite database" but then defines a `WorkerMessage` enum with multiple variants for direct messaging. Which is it? 211 + 212 + ### 19. Session Model: Handoff Notes Are LLM-Generated 213 + 214 + "Orchestrator generates handoff notes summarizing: what was done, what's left, blockers, decisions made." But the orchestrator is explicitly NOT an LLM agent. So who generates these notes? If it's a final LLM call, that's not specified. If it's template-based from graph state, that's not specified either. 215 + 216 + --- 217 + 218 + ## Structural Concerns 219 + 220 + ### 20. Phase 1 Is Too Large 221 + 222 + Phase 1 contains 11 items including: full database schema, project management, complete graph model with 7 node types and 7 edge types, dependency resolution, ready surfacing, ADR export, TOML import/export/diff, session management, agent trait definition, runtime refactoring, config extension, cherry-picking existing modules, all graph tools, CLI wiring. 223 + 224 + This is realistically 3-4 phases of work collapsed into one. There's no way to get feedback on the database design before building the TOML interchange format on top of it. Recommended split: 225 + - Phase 1a: Database + schema + basic CRUD 226 + - Phase 1b: Graph model + node lifecycle + dependency resolution 227 + - Phase 1c: Session model + ADR export + TOML interchange 228 + - Phase 1d: Agent trait + runtime refactor + CLI 229 + 230 + ### 21. Web UI Tech Choices Need Justification 231 + 232 + "React 19 + TypeScript + Bun + Vite" is stated without discussing: 233 + - Why React over lighter alternatives (the UI is essentially a dashboard) 234 + - State management approach (the stores are listed but no library is specified — Zustand? Redux? React Context?) 235 + - Whether server-side rendering matters 236 + - Bundle size considerations 237 + - Whether Bun is production-ready enough for the build toolchain 238 + 239 + ### 22. No Versioning/Schema Migration Strategy 240 + 241 + The database schema is defined once. There's no mention of: 242 + - Schema versioning 243 + - Migration tooling (hand-rolled? refinery? sqlx-migrate?) 244 + - Backward compatibility when schema changes 245 + - How TOML export format versioning works (`version = 1` is mentioned but no evolution strategy) 246 + 247 + --- 248 + 249 + ## Summary 250 + 251 + | Category | Count | Severity | 252 + |----------|-------|----------| 253 + | Critical (will cause failures) | 3 | Must fix before implementation | 254 + | Significant gaps | 6 | Will block implementation of specific phases | 255 + | Missing specifications | 6 | Will require design decisions during implementation | 256 + | Questionable decisions | 4 | Should be revisited with explicit trade-off analysis | 257 + | Structural concerns | 3 | Affect project execution, not correctness | 258 + 259 + **Top 5 action items:** 260 + 1. Fix the ID scheme (8 hex chars minimum, or sibling-only uniqueness with full-path PKs) 261 + 2. Add a database concurrency section (write serialization, `BEGIN IMMEDIATE`, retry logic) 262 + 3. Specify agent profiles and autonomy levels fully 263 + 4. Split Phase 1 into 4 sub-phases 264 + 5. Add LLM failure handling and token budget management to the orchestrator spec 265 + 266 + --- 267 + 268 + ## Research Sources 269 + 270 + ### sqlite-vec 271 + - [GitHub - asg017/sqlite-vec](https://github.com/asg017/sqlite-vec) 272 + - [Introducing sqlite-vec v0.1.0](https://alexgarcia.xyz/blog/2024/sqlite-vec-stable-release/index.html) 273 + - [Using sqlite-vec in Rust](https://alexgarcia.xyz/sqlite-vec/rust.html) 274 + - [API Reference](https://alexgarcia.xyz/sqlite-vec/api-reference.html) 275 + - [GitHub - djc/instant-distance](https://github.com/djc/instant-distance) 276 + 277 + ### SQLite Concurrency 278 + - [SQLite WAL Documentation](https://sqlite.org/wal.html) 279 + - [SQLite File Locking (Locking v3)](https://sqlite.org/lockingv3.html) 280 + - [Bert Hubert - SQLITE_BUSY Despite Timeout](https://berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout/) 281 + - [tenthousandmeters - SQLite Concurrent Writes](https://tenthousandmeters.com/blog/sqlite-concurrent-writes-and-database-is-locked-errors/) 282 + - [tokio-rusqlite Documentation](https://docs.rs/tokio-rusqlite/latest/tokio_rusqlite/) 283 + - [rusqlite Transaction Behavior](https://docs.rs/rusqlite/latest/rusqlite/enum.TransactionBehavior.html) 284 + - [SQLite Atomic Commit](https://sqlite.org/atomiccommit.html) 285 + 286 + ### Birthday Problem / ID Collisions 287 + - Standard birthday problem formula: P(collision) = 1 - e^(-N(N-1) / (2D)) where D = 2^16 = 65,536 288 + - Birthday bound for 50% collision: N = sqrt(2D * ln(2)) ≈ 301 IDs
+2170
docs/plans/v2-architecture.md
··· 1 + # Rustagent v2: Autonomous Multi-Agent Coding System 2 + 3 + ## Vision 4 + Clean-slate redesign of rustagent as an autonomous coding agent with multi-agent orchestration, task tracking, decision graphs, AGENTS.md support, and configurable autonomy. 5 + 6 + Inspired by: **Deciduous** (decision graphs), **Chainlink** (session-based task tracking with handoff notes), **Beads** (hash-based hierarchical IDs, dependency-aware task graphs, `ready` surfacing). 7 + 8 + ### Relationship to V1 9 + 10 + V2 is a **clean break**, not a migration. The v1 JSON spec format, planning agent, Ralph loop, and ratatui TUI are all superseded. There is no import tooling or backward compatibility — v1 was only used by the author. Selected modules are cherry-picked into v2 (LLM clients, security, tools) as listed in the "Files to Cherry-Pick" section. The TUI is replaced entirely by the web UI. 11 + 12 + ## Key Design Decisions 13 + 14 + - **Tokio channels over actor frameworks** - Agent count is small (<10), broadcast + per-agent mpsc 15 + - **SQLite for all persistence** - Single binary, WAL mode for concurrent reads 16 + - **FTS5 for full-text search** - SQLite built-in, no external dependencies; covers graph node search without embedding infrastructure 17 + - **Agent profiles in TOML config** - Customizable without recompiling; builtin defaults as fallbacks 18 + - **Hybrid messaging** - Orchestrator controls lifecycle + agents can message peers directly 19 + - **Unified work graph** - Tasks, decisions, outcomes, and observations are all nodes in a single DAG. 7 node types, 7 edge types. Eliminates duplication between separate task and decision systems (inspired by Deciduous, Chainlink, Beads) 20 + - **Session model from Chainlink** - Handoff notes preserve context across sessions (temporal, separate from graph) 21 + - **Hash-based IDs from Beads** - Merge-safe, hierarchical (goal.task.subtask) 22 + 23 + --- 24 + 25 + ## Daemon + Web UI Architecture 26 + 27 + ### Dual-Mode Operation 28 + 29 + Rustagent supports two modes: 30 + 31 + 1. **Standalone CLI**: Direct execution for one-off commands, scripting, CI. Works without a running daemon. 32 + 2. **Daemon mode**: Long-running background process with HTTP API + WebSocket. Required for the web UI and for long-running orchestration. 33 + 34 + The CLI auto-detects whether a daemon is running (via PID file / health check) and routes commands accordingly: 35 + - Daemon running → CLI becomes thin client, sends API requests 36 + - No daemon → CLI executes directly (standalone mode) 37 + 38 + ### Daemon 39 + 40 + ``` 41 + rustagent daemon start # Start daemon in background 42 + rustagent daemon stop # Stop daemon 43 + rustagent daemon status # Check if daemon is running 44 + rustagent daemon logs # Tail daemon logs 45 + ``` 46 + 47 + The daemon is the same Rust binary with a `daemon` subcommand. It: 48 + - Starts an HTTP server (axum) on a configurable port (default: `127.0.0.1:7400`) 49 + - Runs the orchestrator for active goals 50 + - Exposes REST API for CRUD operations 51 + - Exposes WebSocket endpoint for real-time updates 52 + - Writes PID file to `~/.local/share/rustagent/rustagent.pid` 53 + - Logs to `~/.local/state/rustagent/logs/` (same as current) 54 + 55 + ```rust 56 + // src/daemon/mod.rs 57 + 58 + pub struct Daemon { 59 + config: Config, 60 + db: Arc<Database>, 61 + orchestrators: HashMap<String, Orchestrator>, // One per active goal 62 + ws_broadcaster: broadcast::Sender<WsEvent>, 63 + } 64 + ``` 65 + 66 + ### HTTP API 67 + 68 + ``` 69 + # Projects 70 + GET /api/projects # List all projects 71 + POST /api/projects # Register a project 72 + GET /api/projects/:id # Get project details 73 + DELETE /api/projects/:id # Remove project 74 + 75 + # Work Graph (unified nodes + edges) 76 + GET /api/projects/:id/goals # List goal nodes for project 77 + POST /api/projects/:id/goals # Create a goal (starts orchestration) 78 + GET /api/nodes/:id # Get any node with its edges 79 + PATCH /api/nodes/:id # Update node (status, metadata) 80 + POST /api/nodes/:id/children # Create child node 81 + GET /api/goals/:id/tree # Get full node tree under goal 82 + 83 + # Task Views (projections of the work graph) 84 + GET /api/goals/:id/tasks # List task nodes for goal 85 + GET /api/goals/:id/tasks/ready # Get ready task nodes 86 + GET /api/goals/:id/tasks/next # Get recommended next task 87 + 88 + # Decision Views (projections of the work graph) 89 + GET /api/projects/:id/decisions # Active decisions (now mode) 90 + GET /api/projects/:id/decisions/history # Full decision graph (history mode) 91 + POST /api/projects/:id/decisions/export # Export ADRs to project dir 92 + 93 + # Graph Import/Export 94 + GET /api/projects/:id/graph/export # Export all goals as TOML 95 + GET /api/goals/:id/export # Export single goal as TOML 96 + POST /api/projects/:id/graph/import # Import TOML (body: file content) 97 + POST /api/projects/:id/graph/diff # Diff TOML against DB state 98 + 99 + # Sessions 100 + GET /api/goals/:id/sessions # List sessions 101 + GET /api/sessions/:id # Get session with handoff notes 102 + 103 + # Search (full-text search over graph nodes) 104 + POST /api/projects/:id/search # FTS5 search over node titles/descriptions 105 + 106 + # Agents (real-time) 107 + GET /api/goals/:id/agents # List active agents for goal 108 + 109 + # WebSocket 110 + WS /ws # Real-time event stream 111 + ``` 112 + 113 + ### WebSocket Events 114 + 115 + ```typescript 116 + type WsEvent = 117 + | { type: "agent_spawned"; agentId: string; profile: string; goalId: string } 118 + | { type: "agent_progress"; agentId: string; turn: number; summary: string } 119 + | { type: "agent_completed"; agentId: string; outcome: AgentOutcome } 120 + | { type: "node_created"; node: GraphNode } 121 + | { type: "node_status_changed"; nodeId: string; nodeType: string; oldStatus: string; newStatus: string } 122 + | { type: "edge_created"; edge: GraphEdge } 123 + | { type: "session_ended"; sessionId: string; handoffNotes: string } 124 + | { type: "tool_execution"; agentId: string; tool: string; args: object; result: string } 125 + | { type: "orchestrator_state_changed"; goalId: string; state: string } 126 + ``` 127 + 128 + ### Web UI 129 + 130 + **Stack**: TypeScript + Bun + Vite + Svelte 5 131 + 132 + **Rationale**: Svelte for minimal boilerplate, built-in reactivity (runes — no separate state management library needed), and smallest runtime footprint. WebSocket-driven updates integrate naturally with Svelte's reactive stores. Graph visualization uses Cytoscape.js (framework-agnostic, handles pan/zoom/drag/expand-collapse for interactive decision and goal graph views). Bun is build toolchain only (via Vite); runtime is the browser. No SSR — this is a locally-served SPA. 133 + 134 + **Location**: `web/` directory in the repo (separate from `src/`) 135 + 136 + ``` 137 + web/ 138 + ├── package.json 139 + ├── svelte.config.js 140 + ├── tsconfig.json 141 + ├── vite.config.ts 142 + ├── bun.lock 143 + ├── index.html 144 + ├── src/ 145 + │ ├── main.ts # Entry point 146 + │ ├── App.svelte # Root component + routing 147 + │ ├── api/ 148 + │ │ ├── client.ts # HTTP API client 149 + │ │ └── websocket.ts # WebSocket connection + event handling 150 + │ ├── stores/ # Svelte runes-based reactive stores 151 + │ │ ├── projects.svelte.ts 152 + │ │ ├── graph.svelte.ts # Unified: nodes, edges, goals, tasks, decisions 153 + │ │ ├── agents.svelte.ts 154 + │ │ └── search.svelte.ts # Full-text search over graph nodes 155 + │ ├── views/ 156 + │ │ ├── Dashboard.svelte # Overview: active goals, agent status, recent activity 157 + │ │ ├── ProjectList.svelte # All projects 158 + │ │ ├── ProjectDetail.svelte # Goals, tasks, decisions for a project 159 + │ │ ├── TaskTree.svelte # Projection: task nodes with hierarchy and status 160 + │ │ ├── DecisionGraph.svelte # Projection: decision/option/outcome nodes (now + history modes) 161 + │ │ ├── GraphSearch.svelte # Full-text search across graph nodes 162 + │ │ ├── AgentMonitor.svelte # Real-time agent activity (tool calls, progress) 163 + │ │ └── SessionHistory.svelte # Past sessions with handoff notes 164 + │ ├── components/ 165 + │ │ ├── GraphNodeCard.svelte 166 + │ │ ├── CytoscapeGraph.svelte # Wrapper for Cytoscape.js (pan/zoom/drag graph views) 167 + │ │ ├── AgentStatusBadge.svelte 168 + │ │ ├── SearchResult.svelte 169 + │ │ └── ... 170 + │ └── styles/ 171 + └── public/ 172 + ``` 173 + 174 + **Key views:** 175 + 176 + - **Dashboard**: At-a-glance view of all active goals across projects, running agents, task completion %, recent decisions 177 + - **Task Tree**: Projection of the work graph showing goal → tasks → subtasks with dependency edges, status colors, agent assignments 178 + - **Decision Graph**: Projection of the work graph filtering to decision/option/outcome/revisit nodes. Toggle between Now mode (active decisions only) and History mode (full evolution). Click nodes to see details. 179 + - **Agent Monitor**: Real-time feed of agent activity - tool calls, file changes, progress reports. Like watching multiple terminal sessions. 180 + - **Graph Search**: Full-text search across all graph nodes (observations, outcomes, decisions). Filter by node type, project, status. 181 + 182 + ### Serving the Web UI 183 + 184 + Two modes, controlled by a Cargo feature flag: 185 + 186 + 1. **Development** (default): No frontend build during `cargo build`. Developers run `bun run dev` in `web/` for Vite's dev server with HMR, proxying API calls to the daemon. The daemon does not serve the UI — if no embedded assets exist and no `web/dist/` is found, `/*` returns a message directing the user to start the Vite dev server or build with `--features bundle-ui`. 187 + 188 + 2. **Release / single-binary** (`--features bundle-ui`): `build.rs` runs `bun install && bun run build` in `web/`, then `rust-embed` compiles `web/dist/` into the binary. The daemon serves embedded assets directly — no external files needed. One binary, fully self-contained. 189 + 190 + **`build.rs`** (frontend build, only with `bundle-ui` feature): 191 + 192 + ```rust 193 + fn main() { 194 + #[cfg(feature = "bundle-ui")] 195 + { 196 + println!("cargo:rerun-if-changed=web/src"); 197 + println!("cargo:rerun-if-changed=web/package.json"); 198 + 199 + let web_dir = "web"; 200 + 201 + let status = std::process::Command::new("bun") 202 + .args(["install"]) 203 + .current_dir(web_dir) 204 + .status() 205 + .expect("bun must be installed to build with bundle-ui"); 206 + assert!(status.success(), "bun install failed"); 207 + 208 + let status = std::process::Command::new("bun") 209 + .args(["run", "build"]) 210 + .current_dir(web_dir) 211 + .status() 212 + .expect("bun run build failed"); 213 + assert!(status.success(), "frontend build failed"); 214 + } 215 + } 216 + ``` 217 + 218 + **Embedded assets** (behind `bundle-ui` feature): 219 + 220 + ```rust 221 + #[cfg(feature = "bundle-ui")] 222 + #[derive(rust_embed::Embed)] 223 + #[folder = "web/dist/"] 224 + struct UiAssets; 225 + ``` 226 + 227 + The daemon's axum fallback handler tries `UiAssets::get(path)`, falling back to `UiAssets::get("index.html")` for SPA routing. Content types are inferred by `rust-embed`. 228 + 229 + **The daemon's axum server serves:** 230 + - `/api/*` → REST API 231 + - `/ws` → WebSocket 232 + - `/*` → Embedded UI assets (with `bundle-ui`) or "UI not bundled" message (without) 233 + 234 + ### New Rust Dependencies for Daemon 235 + 236 + ```toml 237 + axum = { version = "0.8", features = ["ws"] } # HTTP server + WebSocket 238 + tower = "0.5" # Middleware 239 + tower-http = { version = "0.6", features = ["cors", "fs"] } # CORS + static files 240 + rust-embed = { version = "8", features = ["axum"], optional = true } # Static asset embedding 241 + ``` 242 + 243 + --- 244 + 245 + ## Multi-Project Architecture 246 + 247 + ### Central Database 248 + 249 + All data lives in a single SQLite database at `~/.local/share/rustagent/rustagent.db` (XDG data dir). This enables: 250 + - Cross-project search (observations from project A are findable when working on project B) 251 + - Unified task/decision views across all projects 252 + - Single source of truth 253 + 254 + ### Project Registration 255 + 256 + Projects are explicitly registered: 257 + 258 + ``` 259 + rustagent project add my-api /Users/david/code/my-api 260 + rustagent project add frontend /Users/david/code/frontend 261 + rustagent project list 262 + rustagent project remove my-api 263 + rustagent project show my-api 264 + ``` 265 + 266 + ```rust 267 + pub struct Project { 268 + pub id: String, // Auto-generated hash (ra-xxxx) 269 + pub name: String, // Friendly name (e.g., "my-api") 270 + pub path: PathBuf, // Absolute path to project root 271 + pub registered_at: DateTime<Utc>, 272 + pub config_overrides: Option<ProjectConfig>, // Per-project config 273 + pub metadata: HashMap<String, String>, 274 + } 275 + ``` 276 + 277 + **Project resolution**: When running commands, specify project by name: 278 + ``` 279 + rustagent run --project my-api "Add user authentication" 280 + rustagent tasks --project my-api 281 + rustagent status --project my-api 282 + ``` 283 + 284 + If no `--project` flag, rustagent looks for a registered project matching the current working directory. 285 + 286 + ### Data Scoping 287 + 288 + All entities are scoped to a project via `nodes.project_id`: 289 + - Goals, tasks, decisions, options, outcomes — all node types inherit project scope 290 + - Sessions also carry `project_id` 291 + - Queries default to the current project 292 + - Full-text search can optionally span all projects (for cross-project learnings) 293 + 294 + ### ADR Export 295 + 296 + Despite the central database, ADR export writes to the project's directory: 297 + ``` 298 + rustagent decisions export --project my-api 299 + # → /Users/david/code/my-api/decisions/001-auth-approach.md 300 + ``` 301 + 302 + ### Database Schema Addition 303 + 304 + ```sql 305 + CREATE TABLE projects ( 306 + id TEXT PRIMARY KEY, 307 + name TEXT NOT NULL UNIQUE, 308 + path TEXT NOT NULL, 309 + registered_at TEXT NOT NULL, 310 + config_overrides TEXT, -- JSON 311 + metadata TEXT NOT NULL DEFAULT '{}' 312 + ); 313 + 314 + -- Other tables with project_id: 315 + -- nodes.project_id REFERENCES projects(id) (all node types: goals, tasks, decisions, etc.) 316 + -- sessions.project_id REFERENCES projects(id) 317 + ``` 318 + 319 + --- 320 + 321 + ## Database Concurrency Strategy 322 + 323 + ### Single Connection, WAL Mode 324 + 325 + All database access goes through a single `tokio_rusqlite::Connection` shared via `Arc`. This is the entire write serialization strategy — `tokio-rusqlite` runs one SQLite connection on a dedicated background thread and processes `.call()` closures sequentially through an internal channel. No additional write queue or mutex is needed. 326 + 327 + **Configuration at connection open:** 328 + ```sql 329 + PRAGMA journal_mode = WAL; -- Concurrent reads, serialized writes 330 + PRAGMA busy_timeout = 5000; -- 5s safety net (shouldn't trigger with single connection) 331 + PRAGMA foreign_keys = ON; 332 + PRAGMA wal_autocheckpoint = 1000; -- Default, tune only if profiling shows need 333 + ``` 334 + 335 + ### Transaction Discipline 336 + 337 + All write transactions use `BEGIN IMMEDIATE` to prevent the reader-to-writer upgrade deadlock (where a `DEFERRED` transaction that starts reading, then tries to write, gets an immediate `SQLITE_BUSY` that `busy_timeout` cannot help with). 338 + 339 + ```rust 340 + conn.call(|conn| { 341 + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; 342 + // ... writes ... 343 + tx.commit()?; 344 + Ok(()) 345 + }).await?; 346 + ``` 347 + 348 + ### Atomic Task Claiming 349 + 350 + `claim_task` is implemented as a single conditional UPDATE — no read-then-write race: 351 + 352 + ```sql 353 + UPDATE nodes SET status = 'claimed', assigned_to = ?1, started_at = ?2 354 + WHERE id = ?3 AND status = 'ready'; 355 + ``` 356 + 357 + If `changes() == 1`, the claim succeeded. If `changes() == 0`, another worker got there first. The caller retries with a different ready task. 358 + 359 + ### Future Scaling 360 + 361 + At <10 agents, a single connection is sufficient. If read latency ever becomes an issue, the first optimization would be a second read-only connection (WAL allows concurrent readers alongside one writer). This is an optimization to add if profiling shows need, not an upfront design requirement. 362 + 363 + --- 364 + 365 + ## Unified Work Graph 366 + 367 + ### Core Model 368 + 369 + The work graph is a single directed acyclic graph (DAG) that unifies task tracking and decision recording. Every entity — goals, tasks, decisions, options, outcomes, observations, and pivots — is a node in the same graph. Relationships between them are edges. 370 + 371 + This eliminates the duplication between separate task and decision systems: dependencies, blocking, hierarchy, and cross-references are all expressed once through edges. Task trees and decision histories are different views (projections) of the same underlying graph. 372 + 373 + ### IDs 374 + 375 + Hierarchical IDs with sibling-unique segments (inspired by Beads): 376 + - `ra-a3f8` (Goal) 377 + - `ra-a3f8.1` (Task or Decision under that goal) 378 + - `ra-a3f8.1.3` (Subtask or Option) 379 + 380 + The **full dotted path is the primary key** (e.g., `ra-a3f8.1.3`). Each segment only needs to be unique among its siblings under one parent, not globally unique. This means: 381 + 382 + - **Goal IDs**: `ra-` prefix + 4 hex chars from UUID v4. With 65,536 possibilities, collision at 50% requires ~301 *goals* — more than enough headroom. 383 + - **Child IDs**: Sequential integer counter per parent (`.1`, `.2`, `.3`). The parent node serializes child creation, so no concurrency concern. Counter is stored on the parent node in metadata (`next_child_seq`). 384 + - **Primary key**: The full path string (`ra-a3f8.1.3`). Globally unique by construction since each segment is sibling-unique and the path encodes the full hierarchy. 385 + 386 + This keeps IDs short, human-friendly, and collision-safe at any scale. The hierarchical structure is encoded directly in the ID rather than being a display convenience — `Contains` edges still exist for queryability, but the ID itself is authoritative for parentage. 387 + 388 + **Edge IDs**: `e-` prefix + 8 hex chars from UUID v4 (e.g., `e-a3f8b2c1`). Edges are global, not hierarchical, so they use a flat namespace with enough entropy to avoid collisions. 389 + 390 + ### Node Types 391 + 392 + 7 node types cover the full workflow: 393 + 394 + ```rust 395 + // src/graph/mod.rs 396 + 397 + pub enum NodeType { 398 + Goal, // High-level objective driving all work 399 + Task, // Work item to be executed by an agent 400 + Decision, // Choice point where alternatives are evaluated 401 + Option, // Specific approach considered for a decision 402 + Outcome, // Result of completed work or action 403 + Observation, // Discovery or insight during development 404 + Revisit, // Pivot point — abandoned approach leads to new decision 405 + } 406 + ``` 407 + 408 + **Goal**: Top-level objective. Contains Tasks and Decisions via `Contains` edges. Has a priority. 409 + 410 + **Task**: Concrete work item. Can contain subtasks (also Task nodes). The workhorse — agents claim and execute these. Carries acceptance criteria and assignment in metadata. 411 + 412 + **Decision**: A choice point. Connected to Option nodes via `LeadsTo` edges. Resolved when an option is chosen. 413 + 414 + **Option**: An alternative considered for a Decision. Carries pros/cons in metadata. Status becomes `Chosen` or `Rejected`. 415 + 416 + **Outcome**: The result of completing a Task or choosing an Option. Records success/failure and what happened. 417 + 418 + **Observation**: An insight or discovery. Connected to any node via `Informs` edges. Replaces the old `learnings` field — observations are first-class graph citizens. 419 + 420 + **Revisit**: A pivot point created when an Outcome is bad. Connects the failed path to a new Decision, preserving the full reasoning chain. 421 + 422 + ### Edge Types 423 + 424 + 7 edge types express all relationships: 425 + 426 + ```rust 427 + pub enum EdgeType { 428 + Contains, // Hierarchical: Goal → Task, Task → Subtask, Goal → Decision 429 + DependsOn, // Sequencing: B depends on A (A must complete first) 430 + LeadsTo, // Narrative: Decision → Options, Task → Outcome, Outcome → Revisit 431 + Chosen, // Selection: Decision → the selected Option 432 + Rejected, // Selection: Decision → a rejected Option (label has reason) 433 + Supersedes, // Evolution: new node replaces old 434 + Informs, // Context: Observation/Outcome provides context to another node 435 + } 436 + 437 + pub struct GraphEdge { 438 + pub id: String, 439 + pub edge_type: EdgeType, 440 + pub from_node: String, 441 + pub to_node: String, 442 + pub label: Option<String>, // e.g., rejection reason, dependency description 443 + pub created_at: DateTime<Utc>, 444 + } 445 + ``` 446 + 447 + ### Node Status 448 + 449 + A single status enum with type-appropriate semantics: 450 + 451 + ```rust 452 + pub enum NodeStatus { 453 + // Lifecycle (all node types) 454 + Pending, // Created, not yet actionable 455 + Active, // Currently relevant 456 + Completed, // Done successfully 457 + Cancelled, // No longer needed 458 + 459 + // Task workflow 460 + Ready, // All dependencies met, available for claiming 461 + Claimed, // Agent has atomically claimed this 462 + InProgress, // Active work underway 463 + Review, // Work done, awaiting review 464 + Blocked, // Cannot proceed (blocked_reason set) 465 + Failed, // Attempted and failed 466 + 467 + // Decision workflow 468 + Decided, // Decision resolved (a chosen option exists) 469 + Superseded, // Replaced by newer approach 470 + Abandoned, // Tried and rejected 471 + 472 + // Option workflow 473 + Chosen, // This option was selected 474 + Rejected, // This option was not selected 475 + } 476 + 477 + pub enum Priority { 478 + Critical, // Must do immediately 479 + High, // Should do soon 480 + Medium, // Normal priority 481 + Low, // Nice to have 482 + } 483 + ``` 484 + 485 + Not all statuses apply to all node types. Validation rules: 486 + - **Goal**: Pending, Active, Completed, Cancelled 487 + - **Task**: Pending, Ready, Claimed, InProgress, Review, Completed, Blocked, Failed, Cancelled 488 + - **Decision**: Pending, Active, Decided, Superseded 489 + - **Option**: Pending, Active, Chosen, Rejected, Abandoned 490 + - **Outcome**: Active, Completed 491 + - **Observation**: Active 492 + - **Revisit**: Active, Completed 493 + 494 + ### The Unified Node 495 + 496 + ```rust 497 + pub struct GraphNode { 498 + pub id: String, // Hash-based: ra-xxxx, ra-xxxx.1, etc. 499 + pub project_id: String, 500 + pub node_type: NodeType, 501 + pub title: String, 502 + pub description: String, 503 + pub status: NodeStatus, 504 + pub priority: Option<Priority>, // Goals and Tasks 505 + pub assigned_to: Option<AgentId>, // Tasks 506 + pub created_by: Option<AgentId>, 507 + pub labels: Vec<String>, 508 + pub created_at: DateTime<Utc>, 509 + pub started_at: Option<DateTime<Utc>>, 510 + pub completed_at: Option<DateTime<Utc>>, 511 + pub blocked_reason: Option<String>, 512 + pub metadata: HashMap<String, String>, // Type-specific data 513 + } 514 + ``` 515 + 516 + **Metadata by node type:** 517 + - **Task**: `acceptance_criteria` (JSON array), `estimated_complexity` 518 + - **Option**: `pros` (JSON array), `cons` (JSON array) 519 + - **Outcome**: `success` (bool) 520 + - **Revisit**: `pivot_reason` 521 + 522 + ### How It All Connects 523 + 524 + ``` 525 + ┌──────┐ contains ┌──────────┐ contains ┌──────────┐ 526 + │ Goal │────────────→│ Task A │────────────→│Subtask A1│ 527 + └──────┘ └──────────┘ └──────────┘ 528 + │ │ 529 + │ contains leads_to 530 + ↓ ↓ 531 + ┌──────────┐ ┌──────────┐ 532 + │ Decision │ │ Outcome │──→ (if bad) ──→ Revisit ──→ New Decision 533 + └──────────┘ └──────────┘ 534 + 535 + │ leads_to 536 + ├───────────────────┐ 537 + ↓ ↓ 538 + ┌──────────┐ ┌──────────┐ 539 + │ Option A │ │ Option B │ 540 + │ (chosen) │ │(rejected)│ 541 + └──────────┘ └──────────┘ 542 + 543 + ┌──────────┐ depends_on ┌──────────┐ 544 + │ Task B │─────────────→│ Task A │ 545 + └──────────┘ └──────────┘ 546 + ``` 547 + 548 + ### Task Lifecycle (State Machine) 549 + 550 + Task nodes follow this state machine: 551 + 552 + ``` 553 + ┌──────────┐ 554 + │ Cancelled│ 555 + └──────────┘ 556 + 557 + ┌─────────┐ deps met ┌───────┐ agent ┌─────────┐ 558 + │ Pending │──────────────→│ Ready │─claims──→│ Claimed │ 559 + └─────────┘ └───────┘ └─────────┘ 560 + ↑ │ 561 + │ work starts 562 + │ ↓ 563 + unblocked ┌────────────┐ 564 + │ │ InProgress │ 565 + │ └────────────┘ 566 + ┌─────────┐ │ │ 567 + │ Blocked │←───────────┘ │ 568 + └─────────┘ work done │ 569 + 570 + ┌────────┐ 571 + ┌─────────┐ │ Review │ 572 + │ Failed │←─────────┴────────┘ 573 + └─────────┘ │ 574 + approved 575 + 576 + ┌──────────┐ 577 + │ Complete │ 578 + └──────────┘ 579 + ``` 580 + 581 + Key transitions: 582 + - **Pending → Ready**: Automatic when all `DependsOn` edges point to Completed nodes 583 + - **Ready → Claimed**: Atomic (sets assigned_to + status) — prevents two agents claiming same task 584 + - **Claimed → InProgress**: Agent begins work 585 + - **InProgress → Blocked**: Agent encounters blocker, creates Observation node explaining why 586 + - **Blocked → Ready**: Blocker resolved, re-enters the queue 587 + - **InProgress → Review**: Work complete, optional review gate 588 + - **Review → Complete**: Reviewer approves (or auto-complete if no review gate) 589 + - **Review → Failed**: Reviewer rejects, needs rework 590 + - **Any → Cancelled**: Goal changed, task no longer needed 591 + 592 + ### Decision Workflow (Forward Logging) 593 + 594 + Agents follow a forward-logging pattern (from Deciduous): 595 + 596 + 1. **Log intention**: Create Decision node under current Goal (via `Contains` edge), add Option nodes via `LeadsTo` edges 597 + 2. **Choose**: Add `Chosen` edge from Decision to selected Option, `Rejected` edges (with reason labels) to others. Decision status → `Decided`. 598 + 3. **Execute**: Chosen Option may generate Task nodes (via `Contains` edges from the Goal) 599 + 4. **Record outcome**: Task completion creates Outcome node (via `LeadsTo` edge from Task) 600 + 5. **Pivot if needed**: Bad Outcome → create Revisit node (via `LeadsTo`) → new Decision node (via `LeadsTo`) 601 + 602 + This creates an unbroken reasoning chain explaining why the codebase looks the way it does. 603 + 604 + ### When Decisions Are Created 605 + 606 + Agents create decision records when: 607 + - **Architectural choices**: Picking patterns, libraries, data structures 608 + - **Trade-off moments**: Performance vs. readability, simplicity vs. flexibility 609 + - **Multiple valid approaches**: Agent considers 2+ options before choosing 610 + - **Blockers encountered**: Why something was blocked and what was tried 611 + - **Pivots**: Abandoning one approach for another (Revisit node) 612 + 613 + The orchestrator can also require decisions at approval gates (configurable). 614 + 615 + ### ADR Export 616 + 617 + Despite the unified graph, ADR export works by filtering to Decision nodes and gathering their connected Options (Chosen/Rejected), Outcomes, and related Tasks: 618 + 619 + ``` 620 + decisions/ 621 + ├── 001-authentication-approach.md 622 + ├── 002-database-choice.md 623 + └── 003-api-design.md 624 + ``` 625 + 626 + Each ADR follows the format: 627 + ```markdown 628 + # ADR-001: Authentication Approach 629 + 630 + ## Status: Active 631 + ## Context: [from Decision node description] 632 + ## Options Considered: 633 + - **Option A**: [description] - CHOSEN 634 + - Pros: ... 635 + - Cons: ... 636 + - **Option B**: [description] - REJECTED 637 + - Reason: ... 638 + ## Outcome: [from Outcome node] 639 + ## Related Tasks: ra-a3f8.2, ra-a3f8.3 640 + ``` 641 + 642 + ### Two Decision Visualization Modes (from Deciduous) 643 + 644 + **Now Mode** (`rustagent decisions now`): Filter the graph to Active/Decided Decision and Chosen Option nodes. Shows how the system works today. 645 + 646 + **History Mode** (`rustagent decisions history`): Full graph including Abandoned, Superseded, and Rejected nodes. Explains why things changed. 647 + 648 + ### Session Model (from Chainlink) 649 + 650 + Sessions are separate from the graph — they're temporal, not structural: 651 + 652 + ```rust 653 + pub struct Session { 654 + pub id: String, 655 + pub goal_id: String, 656 + pub started_at: DateTime<Utc>, 657 + pub ended_at: Option<DateTime<Utc>>, 658 + pub handoff_notes: Option<String>, // Generated at session end 659 + pub agent_ids: Vec<AgentId>, // Agents that participated 660 + pub summary: Option<String>, // Auto-generated session summary 661 + } 662 + ``` 663 + 664 + Each `rustagent run` creates a session. When a session ends (goal complete, user interrupt, error): 665 + 1. Orchestrator generates handoff notes **deterministically from graph state** (no LLM call). The template queries nodes by status and formats them: 666 + 667 + ``` 668 + ## Done 669 + - ra-a3f8.1: Design auth schema (completed) 670 + - ra-a3f8.2: JWT vs sessions → JWT chosen 671 + 672 + ## Remaining 673 + - ra-a3f8.3: Implement JWT middleware (ready) 674 + - ra-a3f8.4: Add refresh token rotation (pending, blocked by ra-a3f8.3) 675 + 676 + ## Blocked 677 + - None 678 + 679 + ## Decisions Made 680 + - ra-a3f8.2: JWT tokens chosen over server-side sessions (stateless, scalable) 681 + ``` 682 + 683 + 2. Handoff notes are stored in the session record 684 + 3. Next `rustagent run` on the same goal loads previous handoff notes into worker context 685 + 686 + ### Smart Task Surfacing 687 + 688 + **`ready` command** (from Beads): Query Task nodes where all `DependsOn` targets are Completed and status is Ready. 689 + 690 + **`next` command** (from Chainlink): Recommend the highest-priority Ready task, considering: 691 + 1. Priority level (Critical > High > Medium > Low) 692 + 2. Number of downstream nodes it unblocks (prefer tasks that unblock the most) 693 + 3. Estimated complexity (simpler tasks first for momentum) 694 + 695 + ### Node Decay for Context (from Beads) 696 + 697 + Old completed nodes are compacted when injected into agent context to save tokens: 698 + - Recent nodes (< 7 days): full detail (description, criteria, outcomes) 699 + - Older nodes (7-30 days): summary only (title, status, key outcomes) 700 + - Ancient nodes (> 30 days): just title and status 701 + - Thresholds configurable in `.rustagent/config.toml` 702 + - Full detail is always available via `query_nodes` regardless of age 703 + 704 + ### User Visibility 705 + 706 + ``` 707 + rustagent status # Goal tree + agent states + session info 708 + rustagent tasks # List task nodes (filterable by status, label, priority) 709 + rustagent tasks ready # Show ready-to-claim tasks 710 + rustagent tasks next # Recommend next task 711 + rustagent tasks tree # Tree view: goal → tasks → subtasks 712 + rustagent decisions # List active decisions 713 + rustagent decisions now # Current truth — active decisions only 714 + rustagent decisions history # Full evolution including abandoned paths 715 + rustagent decisions show <id> # Show decision with options and outcome 716 + rustagent decisions export # Export as markdown ADR files 717 + rustagent sessions # List sessions with handoff notes 718 + rustagent sessions latest # Show most recent session's handoff notes 719 + ``` 720 + 721 + --- 722 + 723 + ## Work Graph Import/Export 724 + 725 + ### Motivation 726 + 727 + The canonical work graph lives in SQLite, but collaborators need to: 728 + - Share graph state through git (review task breakdowns and decisions in PRs) 729 + - Hand off work between team members (beyond just handoff notes) 730 + - Back up and restore graph state 731 + - Resolve divergent graph states after independent work 732 + 733 + ### Format: TOML, One File Per Goal 734 + 735 + **Why TOML**: Excellent Rust support (`toml` crate + serde), human-readable, unambiguous spec, typed values. Table-per-entity maps naturally to git-friendly diffs where each node/edge is an independent hunk. 736 + 737 + **Why one file per goal**: Goals are the natural collaboration boundary. Independent goals produce independent files with zero merge conflicts across goals. File-level git operations (blame, log) work well at this granularity. 738 + 739 + ### File Structure 740 + 741 + ``` 742 + .rustagent/ 743 + ├── config.toml # Project config (autonomy level, default profile, etc.) 744 + ├── profiles/ # Custom agent profiles (version-controlled) 745 + │ └── rust-coder.toml 746 + ├── graph/ 747 + │ ├── ra-a3f8.toml # Goal: "Add user authentication" 748 + │ └── ra-b2c1.toml # Goal: "Optimize database queries" 749 + └── sessions/ 750 + ├── ra-a3f8-2025-01-15.toml 751 + └── ra-a3f8-2025-01-16.toml 752 + ``` 753 + 754 + The `.rustagent/` directory lives at the project root and is intended to be committed to git (like `.github/`). 755 + 756 + ### Goal File Format 757 + 758 + ```toml 759 + [meta] 760 + version = 1 761 + goal_id = "ra-a3f8" 762 + project = "my-api" 763 + exported_at = "2025-01-15T10:30:00Z" 764 + content_hash = "abc123def456" # Blake3 hash of nodes+edges for quick change detection 765 + 766 + # ─── Nodes ────────────────────────────────────────────── 767 + # Each [nodes."<id>"] block is an independent git hunk. 768 + # Sorted by ID for deterministic output. 769 + 770 + [nodes."ra-a3f8"] 771 + type = "goal" 772 + title = "Add user authentication" 773 + description = "Implement JWT-based auth with refresh tokens" 774 + status = "active" 775 + priority = "high" 776 + labels = ["security", "mvp"] 777 + created_at = "2025-01-15T10:00:00Z" 778 + 779 + [nodes."ra-a3f8.1"] 780 + type = "task" 781 + title = "Design auth schema" 782 + description = "Create database tables for users, tokens, sessions" 783 + status = "completed" 784 + priority = "high" 785 + assigned_to = "coder-1" 786 + created_at = "2025-01-15T10:01:00Z" 787 + started_at = "2025-01-15T10:02:00Z" 788 + completed_at = "2025-01-15T10:30:00Z" 789 + 790 + [nodes."ra-a3f8.1".metadata] 791 + acceptance_criteria = [ 792 + "Users table with email + hashed password", 793 + "Refresh token table with expiry", 794 + ] 795 + 796 + [nodes."ra-a3f8.2"] 797 + type = "decision" 798 + title = "JWT vs session tokens" 799 + description = "Choose authentication token strategy" 800 + status = "decided" 801 + created_at = "2025-01-15T10:05:00Z" 802 + 803 + [nodes."ra-a3f8.2.1"] 804 + type = "option" 805 + title = "JWT tokens" 806 + description = "Stateless JWT with short-lived access + long-lived refresh" 807 + status = "chosen" 808 + created_at = "2025-01-15T10:05:00Z" 809 + 810 + [nodes."ra-a3f8.2.1".metadata] 811 + pros = ["Stateless", "Horizontally scalable", "No server-side session store"] 812 + cons = ["Can't revoke individual tokens", "Larger payload than session ID"] 813 + 814 + [nodes."ra-a3f8.2.2"] 815 + type = "option" 816 + title = "Server-side sessions" 817 + description = "Traditional session cookie with server-side store" 818 + status = "rejected" 819 + created_at = "2025-01-15T10:05:00Z" 820 + 821 + [nodes."ra-a3f8.2.2".metadata] 822 + pros = ["Simple revocation", "Small cookie size"] 823 + cons = ["Requires session store", "Horizontal scaling needs shared store"] 824 + 825 + # ─── Edges ────────────────────────────────────────────── 826 + # Each [edges."<id>"] block is an independent git hunk. 827 + # Sorted by ID for deterministic output. 828 + 829 + [edges."e-0001"] 830 + type = "contains" 831 + from = "ra-a3f8" 832 + to = "ra-a3f8.1" 833 + 834 + [edges."e-0002"] 835 + type = "contains" 836 + from = "ra-a3f8" 837 + to = "ra-a3f8.2" 838 + 839 + [edges."e-0003"] 840 + type = "leads_to" 841 + from = "ra-a3f8.2" 842 + to = "ra-a3f8.2.1" 843 + 844 + [edges."e-0004"] 845 + type = "leads_to" 846 + from = "ra-a3f8.2" 847 + to = "ra-a3f8.2.2" 848 + 849 + [edges."e-0005"] 850 + type = "chosen" 851 + from = "ra-a3f8.2" 852 + to = "ra-a3f8.2.1" 853 + 854 + [edges."e-0006"] 855 + type = "rejected" 856 + from = "ra-a3f8.2" 857 + to = "ra-a3f8.2.2" 858 + label = "Requires server-side session store, adds operational complexity" 859 + ``` 860 + 861 + ### Session File Format 862 + 863 + Sessions export alongside their goal in a separate directory: 864 + 865 + ```toml 866 + # .rustagent/sessions/ra-a3f8-2025-01-15.toml 867 + 868 + [meta] 869 + session_id = "s-1234" 870 + goal_id = "ra-a3f8" 871 + started_at = "2025-01-15T10:00:00Z" 872 + ended_at = "2025-01-15T12:30:00Z" 873 + agents = ["planner-1", "coder-1"] 874 + 875 + summary = "Completed auth schema design and JWT decision" 876 + 877 + handoff_notes = """ 878 + ## Done 879 + - Designed auth schema (ra-a3f8.1) 880 + - Decided on JWT tokens (ra-a3f8.2) 881 + 882 + ## Remaining 883 + - Implement JWT middleware (ra-a3f8.3) 884 + - Add refresh token rotation (ra-a3f8.4) 885 + 886 + ## Blockers 887 + - None 888 + """ 889 + ``` 890 + 891 + ### Git-Friendliness Properties 892 + 893 + 1. **One table per entity**: Each `[nodes."id"]` and `[edges."id"]` block is an independent git hunk. Adding a node = adding lines at a predictable location (auto-mergeable). Modifying a node = changing lines within one block (conflicts only if the same entity is modified by both sides). 894 + 895 + 2. **Deterministic ordering**: Nodes and edges sorted lexicographically by ID. Re-exporting unchanged state produces byte-identical output. No spurious diffs. 896 + 897 + 3. **Hash-based IDs**: Concurrent node creation by different collaborators won't produce ID collisions (UUID v4-based). Two people can independently add tasks to the same goal and merge cleanly. 898 + 899 + 4. **Content hash**: `content_hash` in `[meta]` enables quick "has anything changed?" checks without diffing the full file. Useful for CI hooks and auto-sync. 900 + 901 + 5. **Omitted fields**: Null/empty fields are omitted entirely (no `assigned_to = ""` noise). Fields only appear when they carry meaningful data, keeping diffs minimal. 902 + 903 + ### Conflict Resolution 904 + 905 + **Automatic (git handles it):** 906 + - Collaborator A adds `ra-a3f8.3`, Collaborator B adds `ra-a3f8.4` → different hunks, clean merge 907 + - Collaborator A adds edge `e-0007`, Collaborator B adds `e-0008` → different hunks, clean merge 908 + - Collaborator A modifies `ra-a3f8.1`, Collaborator B adds `ra-a3f8.5` → different hunks, clean merge 909 + 910 + **Manual (git conflict markers):** 911 + - Both modify the status of `ra-a3f8.1` → standard git conflict on the `status` line 912 + - Both edit the description of the same node → standard conflict, human picks winner 913 + 914 + **Import-level conflict resolution:** 915 + 916 + When `rustagent graph import` encounters a node that exists in the DB with different content than the file, it applies one of three strategies: 917 + 918 + | Mode | New nodes | Changed nodes | Unchanged nodes | 919 + |------|-----------|---------------|-----------------| 920 + | `--merge` (default) | Added | Flagged as conflicts | Skipped | 921 + | `--theirs` | Added | File wins | Skipped | 922 + | `--ours` | Added | DB wins | Skipped | 923 + | `--dry-run` | Shown | Shown | Shown | 924 + 925 + Conflict output (in `--merge` mode): 926 + ``` 927 + $ rustagent graph import .rustagent/graph/ra-a3f8.toml 928 + Added: ra-a3f8.5 (task: "Add rate limiting") 929 + Added: e-0009 (depends_on: ra-a3f8.5 → ra-a3f8.3) 930 + CONFLICT: ra-a3f8.1 status differs (db: in_progress, file: completed) 931 + Skipped: 4 unchanged nodes, 6 unchanged edges 932 + 933 + 1 conflict. Resolve with: 934 + rustagent graph import --theirs ra-a3f8.toml # Accept file version 935 + rustagent graph import --ours ra-a3f8.toml # Keep DB version 936 + ``` 937 + 938 + ### CLI 939 + 940 + ``` 941 + # Export 942 + rustagent graph export # Export all goals for current project 943 + rustagent graph export --goal ra-a3f8 # Export specific goal 944 + rustagent graph export --output ./shared/ # Custom output directory 945 + rustagent graph export --sessions # Include session files 946 + 947 + # Import 948 + rustagent graph import .rustagent/graph/ # Import all goal files in directory 949 + rustagent graph import .rustagent/graph/ra-a3f8.toml # Import specific file 950 + rustagent graph import --dry-run ra-a3f8.toml # Preview what would change 951 + rustagent graph import --theirs ra-a3f8.toml # File wins on conflicts 952 + rustagent graph import --ours ra-a3f8.toml # DB wins on conflicts 953 + 954 + # Diff (compare file state to DB state) 955 + rustagent graph diff .rustagent/graph/ra-a3f8.toml # Show differences for one goal 956 + rustagent graph diff .rustagent/graph/ # Diff all files against DB 957 + ``` 958 + 959 + ### Cross-Goal References 960 + 961 + Edges may occasionally cross goal boundaries (e.g., an Observation in Goal A `Informs` a Decision in Goal B). These edges are stored in the file of the `from_node`'s goal. 962 + 963 + On import, cross-goal node references are resolved by ID lookup in the DB. If the referenced node doesn't exist, the edge is **skipped with a clear error** — not silently dropped, not deferred: 964 + 965 + ``` 966 + $ rustagent graph import .rustagent/graph/ra-a3f8.toml 967 + Added: 5 nodes, 8 edges 968 + SKIPPED: 1 edge (e-0012: informs ra-b2c1.3 — node not found) 969 + 970 + To resolve: import the goal containing ra-b2c1 first, then re-import this file. 971 + ``` 972 + 973 + Re-importing after the referenced goal exists will create the edge (edge creation is idempotent). No deferred state or pending tables — the simplest correct behavior for a rare case. 974 + 975 + ### Auto-Export Hook 976 + 977 + The daemon can optionally auto-export after graph mutations: 978 + 979 + ```toml 980 + # In rustagent config 981 + [export] 982 + auto_export = true # Write .rustagent/graph/ on every graph change 983 + auto_export_sessions = false # Sessions only on explicit export 984 + auto_export_debounce_ms = 1000 # Batch rapid changes 985 + ``` 986 + 987 + This keeps `.rustagent/graph/` in sync with the DB automatically. Combined with git hooks, teams can enforce that graph state is always committed alongside code changes. 988 + 989 + ### Implementation Notes 990 + 991 + - Uses the `toml` crate with serde `Serialize`/`Deserialize` on graph types 992 + - Custom serializer sorts keys lexicographically within `[nodes.*]` and `[edges.*]` sections 993 + - Export is a pure read: `GraphStore::get_subtree(goal_id)` → TOML serialization → write file 994 + - Import is TOML parse → diff against DB → apply with conflict strategy 995 + - Content hash uses Blake3 over the serialized nodes+edges (excluding `[meta]`) 996 + - Round-trip property: `export → import → export` produces identical files 997 + 998 + --- 999 + 1000 + ## Agent Orchestration System (Detailed) 1001 + 1002 + Inspired by: **Gastown** (Mayor + ephemeral workers, convoys, recovery-first), **Loom** (state machine coordination, thread persistence), plus first-principles thinking about what LLM-based agents actually need. 1003 + 1004 + ### Core Insight: Ephemeral Workers Beat Long-Running Agents 1005 + 1006 + LLM agents degrade with long context windows - they lose focus, hallucinate more, and waste tokens on irrelevant history. The orchestration model embraces this: 1007 + 1008 + - **Workers are ephemeral**: Spawn fresh for each task, execute, report, terminate 1009 + - **State lives in SQLite, not in agent memory**: If anything crashes, resume from DB 1010 + - **Fresh context = focused work**: Each worker gets only the context it needs 1011 + 1012 + ### Architecture: Orchestrator + Worker Pool 1013 + 1014 + ``` 1015 + ┌─────────────────────────────┐ 1016 + │ Orchestrator │ 1017 + │ (persistent, state-machine │ 1018 + │ based coordination loop) │ 1019 + └──────────┬──────────────────┘ 1020 + 1021 + ┌──────────────┼──────────────┐ 1022 + │ │ │ 1023 + ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ 1024 + │ Worker A │ │ Worker B │ │ Worker C │ 1025 + │ (planner) │ │ (coder) │ │ (coder) │ 1026 + │ ephemeral │ │ ephemeral │ │ ephemeral │ 1027 + └────────────┘ └────────────┘ └────────────┘ 1028 + │ │ │ 1029 + └──────────────┼──────────────┘ 1030 + 1031 + ┌──────────▼──────────────┐ 1032 + │ SQLite (shared │ 1033 + │ state: work graph, │ 1034 + │ sessions) │ 1035 + └─────────────────────────┘ 1036 + ``` 1037 + 1038 + ### Orchestrator Design 1039 + 1040 + The orchestrator is NOT an LLM agent. It's a **deterministic state machine** that coordinates work. This is a deliberate choice - the orchestrator doesn't burn tokens on coordination logic; it follows rules. 1041 + 1042 + ```rust 1043 + // src/agent/orchestrator.rs 1044 + 1045 + pub struct Orchestrator { 1046 + config: OrchestratorConfig, 1047 + graph_store: Arc<dyn GraphStore>, 1048 + message_bus: Arc<dyn MessageBus>, 1049 + active_workers: HashMap<AgentId, WorkerHandle>, 1050 + file_locks: FileOwnershipMap, // Which agent owns which files 1051 + } 1052 + 1053 + pub struct OrchestratorConfig { 1054 + pub max_concurrent_workers: usize, // Default: 4 1055 + pub max_retries_per_task: usize, // Default: 2 1056 + pub worker_turn_limit: usize, // Default: 100 1057 + pub check_in_interval: usize, // Worker reports every N turns 1058 + pub review_required: bool, // Spawn reviewer after coder finishes 1059 + 1060 + // Error handling thresholds 1061 + pub max_consecutive_llm_failures: usize, // Default: 3. Persistent failures → TaskBlocked 1062 + pub max_consecutive_tool_failures: usize, // Default: 3. Bad tool calls → TaskBlocked 1063 + pub worker_token_budget: usize, // Default: 200_000. Per-worker token limit 1064 + pub token_budget_warning_pct: u8, // Default: 80. Inject "wrap up" at this % 1065 + pub max_tokens_per_goal: Option<usize>, // Optional. Pause + approval request if exceeded 1066 + } 1067 + ``` 1068 + 1069 + ### Orchestrator State Machine 1070 + 1071 + ``` 1072 + ┌───────────┐ 1073 + │ Startup │ Load config, connect DB, recover interrupted session 1074 + └─────┬─────┘ 1075 + 1076 + 1077 + ┌───────────┐ no goal found 1078 + │ Loading │──────────────→ Create new goal from user input 1079 + └─────┬─────┘ 1080 + │ goal exists (possibly from previous session) 1081 + 1082 + ┌───────────┐ no tasks yet 1083 + │ Planning │──────────────→ Spawn planner worker 1084 + └─────┬─────┘ 1085 + │ tasks exist 1086 + 1087 + ┌───────────┐ 1088 + │ Scheduling │ Query ready tasks, group into work packages, 1089 + └─────┬─────┘ check file ownership constraints, spawn workers 1090 + 1091 + 1092 + ┌───────────┐ workers running 1093 + │ Monitoring │ Wait for worker messages (progress, completion, blocks) 1094 + └─────┬─────┘ Handle timeouts, failures, new task creation 1095 + 1096 + ├──→ Worker completed → update task, back to Scheduling 1097 + ├──→ Worker blocked → record reason, back to Scheduling 1098 + ├──→ Worker failed → retry or mark failed, back to Scheduling 1099 + ├──→ All tasks complete → to Reviewing 1100 + └──→ Session interrupted → generate handoff notes, persist state 1101 + 1102 + 1103 + ┌───────────┐ 1104 + │ Reviewing │ (if review_required) Spawn reviewer workers 1105 + └─────┬─────┘ 1106 + 1107 + 1108 + ┌───────────┐ 1109 + │ Completing │ Generate session summary, store observations, 1110 + └───────────┘ report results to user 1111 + ``` 1112 + 1113 + ### Work Packages 1114 + 1115 + Instead of assigning one task to one worker (wasteful if tasks are small), the orchestrator groups related tasks into **work packages**: 1116 + 1117 + ```rust 1118 + pub struct WorkPackage { 1119 + pub id: String, 1120 + pub task_ids: Vec<String>, 1121 + pub file_scope: Vec<PathBuf>, // Files this package touches 1122 + pub profile: String, // Which agent profile to use 1123 + pub priority: Priority, 1124 + pub estimated_complexity: Complexity, 1125 + } 1126 + 1127 + pub enum Complexity { 1128 + Small, // 1-2 file changes, straightforward 1129 + Medium, // Multiple files, some decision-making 1130 + Large, // Architectural changes, many files 1131 + } 1132 + ``` 1133 + 1134 + **Grouping rules:** 1135 + 1. Tasks that modify the same files → same work package (prevents conflicts) 1136 + 2. Tasks with sequential dependencies → same work package (one worker handles the chain) 1137 + 3. Independent tasks with separate file scopes → separate work packages (parallel execution) 1138 + 4. A single large task → its own work package 1139 + 1140 + ### File Ownership 1141 + 1142 + To prevent two workers from modifying the same file simultaneously: 1143 + 1144 + ```rust 1145 + pub struct FileOwnershipMap { 1146 + locks: HashMap<PathBuf, AgentId>, 1147 + } 1148 + 1149 + impl FileOwnershipMap { 1150 + /// Try to acquire ownership of files for an agent. 1151 + /// Returns Err if any file is already owned by another agent. 1152 + pub fn acquire(&mut self, agent_id: &AgentId, files: &[PathBuf]) -> Result<()>; 1153 + 1154 + /// Release all files owned by an agent (when worker completes). 1155 + pub fn release(&mut self, agent_id: &AgentId); 1156 + 1157 + /// Check if a file write is permitted for an agent. 1158 + pub fn can_write(&self, agent_id: &AgentId, file: &Path) -> bool; 1159 + } 1160 + ``` 1161 + 1162 + The security layer checks file ownership before allowing writes. Workers declare their file scope when spawned; the orchestrator validates no overlaps before starting the worker. 1163 + 1164 + ### Worker Lifecycle 1165 + 1166 + ```rust 1167 + pub struct WorkerHandle { 1168 + pub id: AgentId, 1169 + pub profile: String, 1170 + pub work_package: WorkPackage, 1171 + pub state: WorkerState, 1172 + pub join_handle: JoinHandle<Result<AgentOutcome>>, 1173 + pub cancel_token: CancellationToken, 1174 + pub spawned_at: DateTime<Utc>, 1175 + pub last_check_in: DateTime<Utc>, 1176 + } 1177 + 1178 + pub enum WorkerState { 1179 + Spawning, // Tokio task created, agent initializing 1180 + Initializing, // Loading context (AGENTS.md, graph nodes, task details) 1181 + Working, // Active LLM loop (calling tools, writing code) 1182 + Reporting, // Generating completion report 1183 + Completed(AgentOutcome), 1184 + Failed(String), 1185 + } 1186 + ``` 1187 + 1188 + Workers are tokio tasks. Each worker: 1189 + 1. Receives an `AgentContext` with its work package, relevant observations, decisions, and AGENTS.md context 1190 + 2. Runs the standard agentic loop (LLM call → tool execution → repeat) 1191 + 3. Reports progress every N turns to the orchestrator via the message bus 1192 + 4. Signals completion or blocking via the signal tool 1193 + 5. Gets terminated (cancel token) if it exceeds turn limits or the session ends 1194 + 1195 + ### Error Handling Strategy 1196 + 1197 + Errors are handled at two levels: the **AgentRuntime** (within a worker) and the **Orchestrator** (across workers). 1198 + 1199 + **Within a worker (AgentRuntime):** 1200 + 1201 + - **Transient LLM failures** (network errors, rate limits): Handled by the existing `llm/retry.rs` retry logic with exponential backoff. A single failed API call does not kill the worker. 1202 + - **Persistent LLM failures** (`max_consecutive_llm_failures` consecutive failures, default 3): Worker gives up and signals `TaskBlocked` with the error details. The orchestrator marks the task Blocked. 1203 + - **Malformed tool calls**: AgentRuntime catches these and sends the error back to the LLM as a tool result (e.g., "invalid tool call: unknown tool 'foo'"). The LLM gets a chance to self-correct. Counts toward a "confusion counter" — after `max_consecutive_tool_failures` consecutive bad calls (default 3), worker signals blocked. 1204 + - **Invalid graph operations**: GraphStore validates inputs and returns errors. AgentRuntime feeds the error back to the LLM as a tool result. Normal self-correction applies. 1205 + - **Token budget**: AgentRuntime tracks cumulative tokens (input + output) per worker. At `token_budget_warning_pct` (default 80%), a system message is injected: "You are approaching your token budget. Wrap up your current work and signal completion with what you've accomplished." At 100% of `worker_token_budget` (default 200,000), the worker is force-stopped and signals incomplete — the orchestrator treats this as a partial completion, not a failure. 1206 + 1207 + **Across workers (Orchestrator):** 1208 + 1209 + - **Task retry semantics**: When a task fails or a worker is blocked, the orchestrator checks the retry count against `max_retries_per_task` (default 2). If retries remain: task status resets to Ready, a fresh worker is spawned with fresh context. The previous attempt's Outcome node (with `success: false`) is visible in the graph, so the new worker can learn from it. 1210 + - **Exhausted retries**: Task is marked Failed. The orchestrator creates an Observation node documenting the failure pattern and continues with other tasks. If the failed task blocks downstream work, those tasks become Blocked with reason referencing the failed task. 1211 + - **Worker crash** (panic, OOM): The tokio task's `JoinHandle` returns an error. Orchestrator treats this identically to a persistent failure — resets task to Ready if retries remain. 1212 + 1213 + ### Token Accounting 1214 + 1215 + Each worker tracks cumulative token usage (input + output) during execution. On completion, the total is reported to the orchestrator and stored on the session record. 1216 + 1217 + **Goal-level budget**: If `max_tokens_per_goal` is set, the orchestrator checks cumulative usage across all workers for that goal before spawning new workers. If the budget is exceeded, the orchestrator pauses and surfaces an approval request — regardless of autonomy level. The user can approve continued spending, adjust the budget, or stop the goal. 1218 + 1219 + **Visibility**: `rustagent status` shows cumulative tokens for the active goal. Session records include total tokens consumed. No dollar-cost calculation — token counts are provider-agnostic, and pricing changes too frequently to maintain a rate table. Users multiply by their provider's rate. 1220 + 1221 + ### Communication Model 1222 + 1223 + **Two channels, clearly separated:** 1224 + 1225 + 1. **SQLite (source of truth)**: All durable state — graph nodes, edges, task status, observations, outcomes. Workers write here; the orchestrator reads here. If anything crashes, the DB has the complete picture. 1226 + 2. **Message bus (real-time signals)**: In-memory, fire-and-forget notifications. Used to wake the orchestrator immediately rather than waiting for it to poll the DB. 1227 + 1228 + The message bus has **no durability guarantees**. Messages are best-effort, in-memory only. If a message is lost (worker cancelled, bus drops it), no state is corrupted — the orchestrator discovers the same information by querying the DB on its next scheduling pass. This means the system is correct even if the message bus fails completely; it's just slower. 1229 + 1230 + **Message directions:** 1231 + - Worker → Orchestrator: "I'm done", "I'm blocked", "I need a scope change" 1232 + - Orchestrator → Worker: "Cancel", "Here's additional context" 1233 + - Worker ↔ Worker: Only for review workflows (reviewer sends feedback to coder) 1234 + - Workers never message other workers directly outside of review flow 1235 + 1236 + ```rust 1237 + pub enum WorkerMessage { 1238 + // Worker → Orchestrator 1239 + ProgressReport { turn: usize, summary: String }, 1240 + TaskCompleted { task_id: String, summary: String }, 1241 + TaskBlocked { task_id: String, reason: String }, 1242 + NeedsDecision { task_id: String, decision: GraphNode }, 1243 + NodeCreated { parent_id: String, node: GraphNode }, 1244 + 1245 + // Orchestrator → Worker 1246 + Cancel { reason: String }, 1247 + AdditionalContext { content: String }, 1248 + 1249 + // Worker ↔ Worker (review flow) 1250 + ReviewRequest { work_package_id: String, changed_files: Vec<String> }, 1251 + ReviewFeedback { approved: bool, comments: Vec<String> }, 1252 + } 1253 + ``` 1254 + 1255 + ### Recovery 1256 + 1257 + **Every piece of state is in SQLite.** Recovery is straightforward: 1258 + 1259 + 1. Orchestrator starts → checks for interrupted session 1260 + 2. Loads goal, node tree, and last session's handoff notes 1261 + 3. Task nodes that were InProgress when the crash happened → reset to Ready 1262 + 4. All graph nodes from the interrupted session are preserved 1263 + 5. Resume from Scheduling state 1264 + 1265 + Workers that die mid-execution: their tasks are reset to Ready and will be reassigned. Any files they partially modified are detectable via `git diff` and can be rolled back if needed. 1266 + 1267 + ### Keeping Workers On Task 1268 + 1269 + 1. **Focused context**: ContextBuilder gives each worker only relevant info (task details, related observations, applicable AGENTS.md sections, relevant past decisions) 1270 + 2. **Acceptance criteria**: Workers check their own work against criteria before signaling completion 1271 + 3. **File scope enforcement**: Security layer prevents writes outside the work package's declared file scope 1272 + 4. **Turn limits**: Workers have a configurable max turn count; if exceeded, they must report what they accomplished and what's remaining 1273 + 5. **Check-in intervals**: Every N turns, workers send a progress summary to the orchestrator 1274 + 6. **Decision logging**: For significant choices, workers must log a decision node before proceeding (encouraged via system prompt, not enforced) 1275 + 1276 + ### Dynamic Adaptation 1277 + 1278 + The deterministic orchestrator cannot make judgment calls, but it handles scope changes and emergent work through defined mechanisms: 1279 + 1280 + **File scope expansion**: If a worker needs files outside its declared scope, the security layer blocks the write. The worker signals `NeedsDecision` with the requested files. The orchestrator checks for conflicts with other active workers — if no conflict, it expands the scope and sends `AdditionalContext` to the worker to resume. If there's a conflict, the task is re-queued for after the conflicting worker finishes. 1281 + 1282 + **Task splitting**: Workers can create subtask nodes under their current task via `create_node`. When the worker completes, the orchestrator discovers the new nodes and schedules them through the normal Scheduling flow. This handles "this task turned out to be three tasks" without requiring re-planning. 1283 + 1284 + **Re-planning trigger**: When workers create more than `re_plan_threshold` new tasks during a session (configurable, default 5), or when a worker signals `NeedsDecision` about overall approach (not just file scope), the orchestrator spawns a fresh planner worker to reassess the full task tree. The planner sees the current graph state — completed tasks, new subtasks, observations — and can reorganize remaining work. 1285 + 1286 + ### Scaling Considerations 1287 + 1288 + The orchestrator can handle many workers because: 1289 + - Workers are independent tokio tasks with no shared mutable state 1290 + - All coordination goes through SQLite (WAL mode for concurrent reads) 1291 + - File ownership map is the only shared in-process state (behind a mutex, very fast operations) 1292 + - Message bus is fire-and-forget for monitoring (no back-pressure issues) 1293 + 1294 + Config option `max_concurrent_workers` controls parallelism. Default 4, but can be increased for larger projects with well-separated tasks. 1295 + 1296 + --- 1297 + 1298 + ## Observability 1299 + 1300 + ### Structured Logging 1301 + 1302 + The existing `src/logging.rs` (tracing + daily rotation to `~/.local/state/rustagent/logs/`) is carried forward and extended. All log spans are tagged with `agent_id`, `goal_id`, and `task_id` where applicable, so logs can be filtered per worker after the fact. 1303 + 1304 + ### Worker Conversation Persistence 1305 + 1306 + The full LLM conversation for each worker (all messages, tool calls, and tool results) is stored in a dedicated table. This is the single most useful debugging artifact — it allows replaying exactly what an agent saw and did. 1307 + 1308 + ```sql 1309 + CREATE TABLE worker_conversations ( 1310 + id TEXT PRIMARY KEY, 1311 + session_id TEXT NOT NULL REFERENCES sessions(id), 1312 + agent_id TEXT NOT NULL, 1313 + task_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of task IDs in the work package 1314 + messages TEXT NOT NULL, -- JSON array of the full conversation 1315 + total_input_tokens INTEGER NOT NULL DEFAULT 0, 1316 + total_output_tokens INTEGER NOT NULL DEFAULT 0, 1317 + started_at TEXT NOT NULL, 1318 + completed_at TEXT 1319 + ); 1320 + ``` 1321 + 1322 + Conversations are written on worker completion (or failure). They are not streamed incrementally — the full conversation is available after the worker finishes. 1323 + 1324 + ### What's Deferred 1325 + 1326 + - **Metrics collection** (task completion rate, agent utilization, retry rates): Not for v2. Premature until we know what to measure from real usage. 1327 + - **Alerting**: Not for v2. Failures are surfaced through WebSocket events and the CLI. 1328 + 1329 + --- 1330 + 1331 + ## Git Integration 1332 + 1333 + ### No Dedicated Git Tool 1334 + 1335 + Agents interact with git through the shell tool (bash), not a dedicated `git.rs` tool. This avoids constraining agents to a limited git API — they can run any git command their workflow requires, and users don't hit friction where the dedicated tool doesn't cover their specific needs. Git command access is controlled through the profile's `allowed_commands` patterns (e.g., `"git *"`). 1336 + 1337 + ### Worktree-Based Parallel Isolation 1338 + 1339 + Parallel workers operate in separate **git worktrees**, providing true filesystem isolation rather than relying solely on the file ownership map as policy enforcement. 1340 + 1341 + ``` 1342 + project/ # Main worktree (user's working directory, untouched) 1343 + .git/worktrees/ 1344 + ├── ra-a3f8-wp-001/ # Worktree for work package 001 1345 + ├── ra-a3f8-wp-002/ # Worktree for work package 002 1346 + └── ra-a3f8-wp-003/ # Worktree for work package 003 1347 + ``` 1348 + 1349 + **Lifecycle:** 1350 + 1351 + 1. **Goal start**: Orchestrator creates a goal branch (`rustagent/ra-a3f8`) from the current HEAD 1352 + 2. **Work package spawn**: For each work package, the orchestrator creates a worktree branching from the goal branch (`rustagent/ra-a3f8/wp-001`). The worker's `AgentContext` includes the worktree path — all file operations are rooted there. 1353 + 3. **Worker execution**: Each worker reads and writes files in its own worktree. Git commands (status, diff, add, commit) operate on the worktree's branch. Workers commit their changes on completion as part of the Reporting state, with a conventional message referencing task IDs. 1354 + 4. **Work package merge**: On worker completion, the orchestrator merges the work package branch into the goal branch. Since work packages have non-overlapping file scopes (enforced by the file ownership map), merges are always clean. 1355 + 5. **Goal completion**: All work package branches are merged. The goal branch contains the combined work. The user decides whether to merge into their main branch, push, or take other action. 1356 + 6. **Cleanup**: Worktrees and work package branches are removed after successful merge into the goal branch. 1357 + 1358 + ### Commit Strategy 1359 + 1360 + - One commit per work package completion, with message: `rustagent: <task titles> (ra-a3f8.1, ra-a3f8.2)` 1361 + - In Supervised/Gated mode, the PreCommit approval gate fires before the commit is finalized 1362 + - In Full autonomy mode, agents can also push if the workflow requires it 1363 + 1364 + ### Why Worktrees 1365 + 1366 + - **True isolation**: Workers can't accidentally read or write each other's uncommitted changes 1367 + - **No merge conflicts between workers**: Non-overlapping file scopes + separate branches = always-clean merges 1368 + - **User's working directory is untouched**: The main worktree stays clean — agents work in their own space 1369 + - **Native git**: No custom locking or coordination — git handles everything 1370 + 1371 + --- 1372 + 1373 + ## Autonomy Levels 1374 + 1375 + ### Levels 1376 + 1377 + Three autonomy levels control how much human oversight the orchestrator requires: 1378 + 1379 + ```rust 1380 + // src/config/autonomy.rs 1381 + 1382 + pub enum AutonomyLevel { 1383 + Full, // No gates. Orchestrator runs to completion autonomously. 1384 + Supervised, // Gates at key milestones. User reviews and can redirect. 1385 + Gated, // Gates at every state transition. User approves each step. 1386 + } 1387 + ``` 1388 + 1389 + ### Approval Gates 1390 + 1391 + ```rust 1392 + pub enum ApprovalGate { 1393 + PlanReview, // After planner creates task tree, before execution begins 1394 + PreCommit, // Before code changes are committed to git 1395 + TaskComplete, // Before a task is marked Complete (review the work) 1396 + DecisionPoint, // When an agent logs a Decision with multiple options 1397 + GoalComplete, // Before the goal is marked Complete (final review) 1398 + WorkerSpawn, // Before each new worker is spawned 1399 + } 1400 + ``` 1401 + 1402 + **Which gates are active per level:** 1403 + 1404 + | Gate | Full | Supervised | Gated | 1405 + |------|------|------------|-------| 1406 + | PlanReview | - | Yes | Yes | 1407 + | PreCommit | - | Yes | Yes | 1408 + | TaskComplete | - | Yes | Yes | 1409 + | DecisionPoint | - | - | Yes | 1410 + | GoalComplete | - | Yes | Yes | 1411 + | WorkerSpawn | - | - | Yes | 1412 + 1413 + ### Orchestrator Integration 1414 + 1415 + When the orchestrator hits an active gate: 1416 + 1417 + 1. Emits an `ApprovalRequest` event (includes gate type, context summary, proposed action) 1418 + 2. Pauses the relevant operation (but other workers on unrelated tasks continue) 1419 + 3. Waits for a response: `Approve`, `Reject(reason)`, or `Modify(instructions)` 1420 + 1421 + - **Approve**: Orchestrator proceeds as planned 1422 + - **Reject**: Operation is cancelled. For PlanReview, planner is re-spawned with rejection feedback. For TaskComplete, task returns to InProgress. For PreCommit, changes are kept but not committed. 1423 + - **Modify**: Orchestrator incorporates the instructions. For PlanReview, planner is re-spawned with modification guidance. For DecisionPoint, the user's choice is recorded as the chosen option. 1424 + 1425 + ### How Approval Requests Reach the User 1426 + 1427 + - **CLI mode**: Orchestrator blocks and prints the approval request to stdout. User responds interactively. 1428 + - **Daemon mode**: Approval request is emitted as a WebSocket event and stored in a pending approvals table. Web UI shows a notification. CLI can also poll: `rustagent approvals` lists pending, `rustagent approve <id>` / `rustagent reject <id> --reason "..."` responds. 1429 + 1430 + ### Scoping 1431 + 1432 + - **Default**: Set per-project in project config (`.rustagent/config.toml` or at registration time) 1433 + - **Override**: Per-goal at run time: `rustagent run --autonomy supervised "Add auth"` 1434 + - **Fallback**: If no project default is set, defaults to `Supervised` 1435 + 1436 + --- 1437 + 1438 + ## Module Structure (Updated) 1439 + 1440 + ``` 1441 + src/ 1442 + ├── main.rs # CLI: project, run, plan, status, tasks, sessions, decisions, graph (import/export/diff), search, daemon 1443 + ├── lib.rs 1444 + ├── config/ 1445 + │ ├── mod.rs # Config loading + env var expansion 1446 + │ ├── agents.rs # Agent profile parsing 1447 + │ └── autonomy.rs # AutonomyLevel, ApprovalGate types 1448 + ├── project.rs # Project type, registration, cwd resolution 1449 + ├── logging.rs # Carry forward 1450 + ├── llm/ # Carry forward entirely 1451 + │ ├── mod.rs, anthropic.rs, openai.rs, ollama.rs, mock.rs 1452 + │ ├── error.rs, retry.rs, factory.rs 1453 + ├── agent/ 1454 + │ ├── mod.rs # Agent trait, AgentId, AgentContext, AgentOutcome, WorkerState 1455 + │ ├── profile.rs # AgentProfile (role, prompt, tools, LLM config, security scope) 1456 + │ ├── runtime.rs # AgentRuntime - generic agentic loop (the worker's brain) 1457 + │ ├── orchestrator.rs # Deterministic state machine: scheduling, monitoring, recovery 1458 + │ ├── work_package.rs # WorkPackage grouping + file ownership map 1459 + │ └── builtin_profiles.rs # Default planner/coder/reviewer/tester/researcher 1460 + ├── message/ 1461 + │ ├── mod.rs # Envelope, AgentMessage, MessageBus trait 1462 + │ ├── bus.rs # TokioMessageBus 1463 + │ └── envelope.rs # Envelope type 1464 + ├── graph/ 1465 + │ ├── mod.rs # GraphNode, GraphEdge, NodeType, EdgeType, NodeStatus, Priority 1466 + │ ├── store.rs # GraphStore trait + SQLite impl 1467 + │ ├── query.rs # Query builders (node queries, edge traversal) 1468 + │ ├── dependency.rs # Dependency resolution + ready surfacing for task nodes 1469 + │ ├── session.rs # Session management + handoff notes 1470 + │ ├── decay.rs # Node compaction for context injection (age-based detail levels) 1471 + │ ├── export.rs # Export decisions to markdown ADR files 1472 + │ └── interchange.rs # TOML import/export for collaboration (graph ↔ .rustagent/graph/) 1473 + ├── context/ 1474 + │ ├── mod.rs # ContextBuilder (compact structured format + on-demand expansion) 1475 + │ └── agents_md.rs # AGENTS.md parser + resolver (per https://agents.md/ spec) 1476 + ├── security/ 1477 + │ ├── mod.rs # SecurityValidator (carry forward) 1478 + │ ├── permission.rs # PermissionHandler (carry forward) 1479 + │ └── scope.rs # Per-agent SecurityScope 1480 + ├── tools/ 1481 + │ ├── mod.rs # Tool trait + ToolRegistry (carry forward) 1482 + │ ├── factory.rs # Extended factory 1483 + │ ├── file.rs, shell.rs, signal.rs, permission_check.rs # Carry forward 1484 + │ ├── search.rs # Code search 1485 + │ ├── graph_tools.rs # Unified: create_node, update_node, add_edge, claim_task, log_decision, choose_option, record_outcome, query_nodes, search_nodes 1486 + │ └── agent_tools.rs # spawn_sub_agent, send_message, query_agent_status 1487 + ├── daemon/ 1488 + │ ├── mod.rs # Daemon startup, shutdown, PID management 1489 + │ ├── server.rs # Axum HTTP server setup + routes 1490 + │ ├── api/ 1491 + │ │ ├── mod.rs # API route handlers 1492 + │ │ ├── projects.rs # Project CRUD endpoints 1493 + │ │ ├── graph.rs # Node/edge CRUD, task views, decision views 1494 + │ │ ├── search.rs # Full-text search endpoint 1495 + │ │ └── agents.rs # Agent status endpoints 1496 + │ └── ws.rs # WebSocket handler + event broadcasting 1497 + ├── db/ 1498 + │ ├── mod.rs # Database init + connection pool 1499 + │ └── migrations.rs # Schema (projects, nodes, edges, sessions, nodes_fts, worker_conversations) 1500 + ``` 1501 + 1502 + ## Agent Profiles 1503 + 1504 + ### AgentProfile Type 1505 + 1506 + ```rust 1507 + // src/agent/profile.rs 1508 + 1509 + pub struct AgentProfile { 1510 + pub name: String, // e.g., "coder", "planner", "my-rust-coder" 1511 + pub extends: Option<String>, // Built-in to inherit from 1512 + pub role: String, // One-line role description 1513 + pub system_prompt: String, // Full system prompt (or template with {{variables}}) 1514 + pub allowed_tools: Vec<String>, // Tool group names: "file", "shell", "graph", "signal", "search", "agent" 1515 + pub security: SecurityScope, 1516 + pub llm: ProfileLlmConfig, 1517 + pub turn_limit: Option<usize>, // Override OrchestratorConfig.worker_turn_limit 1518 + pub token_budget: Option<usize>, // Override OrchestratorConfig.worker_token_budget 1519 + } 1520 + 1521 + pub struct SecurityScope { 1522 + pub allowed_paths: Vec<String>, // Glob patterns for file access (e.g., "src/**", "tests/**") 1523 + pub denied_paths: Vec<String>, // Explicit denials (e.g., ".env", "**/*.key") 1524 + pub allowed_commands: Vec<String>, // Shell command patterns (e.g., "cargo *", "git diff *") 1525 + pub read_only: bool, // If true, file writes are blocked regardless of path 1526 + pub can_create_files: bool, // Can create new files (vs only editing existing) 1527 + pub network_access: bool, // Can make outbound network requests via tools 1528 + } 1529 + 1530 + pub struct ProfileLlmConfig { 1531 + pub model: Option<String>, // Override default model (e.g., use cheaper model for planning) 1532 + pub temperature: Option<f64>, 1533 + pub max_tokens: Option<usize>, // Per-response max tokens 1534 + } 1535 + ``` 1536 + 1537 + ### Built-in Profiles 1538 + 1539 + Five built-in profiles cover the standard workflow. Custom profiles can extend these. 1540 + 1541 + | Profile | Role | Tools | File Access | Key Behavior | 1542 + |---------|------|-------|-------------|--------------| 1543 + | **planner** | Breaks goals into tasks and decisions | graph, signal | Read-only | Creates task tree, decision nodes, dependency edges. No code changes. | 1544 + | **coder** | Implements tasks by writing code | file, shell, graph, signal | Write (scoped to work package) | Executes tasks, logs decisions for non-trivial choices, records outcomes. Git via shell. | 1545 + | **reviewer** | Reviews completed work for correctness | file, shell, graph, signal | Read-only | Reads code, runs tests/lints, creates Observation nodes for issues found. | 1546 + | **tester** | Writes and runs tests | file, shell, graph, signal | Write (scoped to test dirs) | Writes tests against acceptance criteria, runs them, reports coverage. | 1547 + | **researcher** | Gathers information and context | file, shell, search, graph, signal | Read-only | Reads code, searches, stores findings as observations. | 1548 + 1549 + **System prompt structure** for all built-in profiles follows a common template: 1550 + 1551 + ``` 1552 + You are a {role} agent working on project "{project_name}". 1553 + 1554 + ## Your Task 1555 + {task_description} 1556 + 1557 + ## Acceptance Criteria 1558 + {acceptance_criteria} 1559 + 1560 + ## Context 1561 + {relevant_decisions} 1562 + {agents_md_content} 1563 + 1564 + ## Rules 1565 + - {profile-specific rules} 1566 + - When you make a non-trivial choice between alternatives, log a decision using log_decision. 1567 + - When you discover something noteworthy, record it using record_observation. 1568 + - Signal completion or blocking using the signal tool. Do not simply stop. 1569 + ``` 1570 + 1571 + Profile-specific rules (examples): 1572 + - **planner**: "Break work into tasks that can be completed independently. Keep tasks small enough for a single focused session. Specify acceptance criteria for every task." 1573 + - **coder**: "Check your work against the acceptance criteria before signaling completion. Only modify files within your declared scope. Commit logical units of work." 1574 + - **reviewer**: "Do not modify files. Report issues as Observation nodes. Approve or reject via the signal tool with specific feedback." 1575 + 1576 + ### Custom Profiles (TOML) 1577 + 1578 + Custom profiles live in two locations, both using one TOML file per profile: 1579 + 1580 + ``` 1581 + # Project-level (version-controlled, shared with team) 1582 + .rustagent/ 1583 + ├── profiles/ 1584 + │ ├── rust-coder.toml 1585 + │ └── docs-writer.toml 1586 + ├── config.toml # Project config (autonomy level, defaults) 1587 + ├── graph/ # Graph export (already specified) 1588 + │ └── ... 1589 + └── sessions/ 1590 + └── ... 1591 + 1592 + # User-level (personal preferences, not version-controlled) 1593 + ~/.config/rustagent/profiles/ 1594 + ├── my-coder.toml 1595 + └── my-planner.toml 1596 + ``` 1597 + 1598 + Example project-level profile: 1599 + 1600 + ```toml 1601 + # .rustagent/profiles/rust-coder.toml 1602 + 1603 + name = "rust-coder" 1604 + extends = "coder" # Inherits all coder defaults 1605 + role = "Rust implementation specialist" 1606 + system_prompt = """ 1607 + You are a senior Rust developer. Follow these project conventions: 1608 + - Use thiserror for error types, anyhow in binaries 1609 + - Prefer &str over String in function parameters 1610 + - Write doc comments for all public items 1611 + """ 1612 + 1613 + allowed_tools = ["file", "shell", "graph", "signal"] 1614 + 1615 + [security] 1616 + allowed_paths = ["src/**", "tests/**", "Cargo.toml", "Cargo.lock"] 1617 + denied_paths = [".env", "**/*.key", "**/*.pem"] 1618 + allowed_commands = ["cargo *", "rustfmt *", "git diff *", "git status"] 1619 + read_only = false 1620 + can_create_files = true 1621 + network_access = false 1622 + 1623 + [llm] 1624 + model = "claude-sonnet-4-20250514" 1625 + temperature = 0.3 1626 + ``` 1627 + 1628 + ### Inheritance Rules 1629 + 1630 + When a profile specifies `extends`: 1631 + 1632 + 1. **All fields start as copies of the parent profile** 1633 + 2. **Scalar fields** (role, system_prompt, read_only, etc.): child value replaces parent if specified 1634 + 3. **List fields** (allowed_tools, allowed_paths, allowed_commands): child value **replaces** parent entirely (not merged). This prevents accidentally widening access by inheriting a broad parent and adding more. 1635 + 4. **system_prompt**: If the child specifies a system_prompt, it is **appended** to the parent's prompt (separated by a newline section header `## Project-Specific Instructions`). This preserves the structural template while allowing customization. To fully replace, set `extends` to null. 1636 + 5. **Unset optional fields** (turn_limit, token_budget, llm overrides): fall through to parent, then to orchestrator defaults. 1637 + 1638 + ### Profile Resolution 1639 + 1640 + When the orchestrator spawns a worker, profiles are resolved in priority order: 1641 + 1642 + 1. **Project-level**: `.rustagent/profiles/*.toml` in the project root (checked into git) 1643 + 2. **User-level**: `~/.config/rustagent/profiles/*.toml` (personal overrides) 1644 + 3. **Built-in profiles**: Compiled-in defaults (planner, coder, reviewer, tester, researcher) 1645 + 1646 + First match wins. If the resolved profile has `extends`, the parent is resolved through the same chain and inheritance rules are applied. 1647 + 1648 + **Validation** (at resolution time): 1649 + - `allowed_tools` only references known tool groups 1650 + - `allowed_paths` doesn't escape the project root 1651 + - `extends` doesn't create cycles 1652 + - Profile names are unique within each level (duplicate across levels is fine — higher priority wins) 1653 + 1654 + --- 1655 + 1656 + ## Context Assembly 1657 + 1658 + ### ContextBuilder 1659 + 1660 + The `ContextBuilder` assembles an `AgentContext` for each worker at spawn time. It uses two strategies to minimize token consumption while keeping all context accessible: 1661 + 1662 + - **Compact structured format** for essential context (task details, decisions, handoff notes) — always included inline 1663 + - **Summary + on-demand expansion** for reference context (observations, AGENTS.md) — titles/one-liners inline, full detail available via tools 1664 + 1665 + ### Context Template 1666 + 1667 + The assembled context is injected as the system prompt. Target: **3-4K tokens** for priorities 1-7, leaving the rest of the context window for the agentic loop. 1668 + 1669 + ``` 1670 + ## Role 1671 + {profile.role} 1672 + 1673 + ## Task 1674 + [TASK] {task.id} | {task.title} | priority={task.priority} 1675 + [CRITERIA] {acceptance_criteria, semicolon-separated} 1676 + [DEP:DONE] {completed_dependency_id} → {title} (completed) 1677 + [DEP:PENDING] {pending_dependency_id} → {title} (status) 1678 + 1679 + ## Previous Attempt (if retry) 1680 + [PREV_ATTEMPT] {outcome.description} 1681 + 1682 + ## Session Continuity 1683 + [HANDOFF] {handoff_notes, compressed to key facts} 1684 + 1685 + ## Active Decisions 1686 + [DECISION] {id} | {title} | chosen: {chosen_option} | reason: {rationale} 1687 + 1688 + ## Relevant Observations (use query_nodes(id) for full detail) 1689 + - {node_id}: {one-line summary} 1690 + - {node_id}: {one-line summary} 1691 + 1692 + ## Project Conventions (use read_agents_md(path) for full text) 1693 + - {path}: {section_title} ({rule_count} rules) 1694 + - {path}: {section_title} ({rule_count} rules) 1695 + 1696 + ## Rules 1697 + {profile.system_prompt rules section} 1698 + ``` 1699 + 1700 + ### Token Budget Allocation 1701 + 1702 + | Priority | Content | Strategy | Budget | 1703 + |----------|---------|----------|--------| 1704 + | 1 (required) | System prompt (profile template + rules) | Compact inline | ~800 tokens | 1705 + | 2 (required) | Task details + acceptance criteria | Compact structured | ~400 tokens | 1706 + | 3 (high) | Previous attempt outcomes (if retry) | Compact structured | ~300 tokens | 1707 + | 4 (high) | Handoff notes from last session | Compact structured | ~300 tokens | 1708 + | 5 (medium) | Active decisions relevant to this task | Compact structured | ~400 tokens | 1709 + | 6 (medium) | Relevant observations | Summary only; `query_nodes(id)` for detail | ~300 tokens | 1710 + | 7 (medium) | AGENTS.md sections | Summary only; `read_agents_md(path)` for detail | ~200 tokens | 1711 + | 8 (remainder) | Conversation history (agentic loop) | Grows during execution | Everything else | 1712 + 1713 + **Overflow handling**: If the total for priorities 1-7 exceeds the budget (e.g., many decisions, many observations), items are trimmed from the bottom of each section (least relevant first, determined by recency and graph distance from the current task). 1714 + 1715 + ### On-Demand Expansion Tools 1716 + 1717 + Workers can pull full detail during execution using existing tools: 1718 + 1719 + - `query_nodes(node_id)` → Returns full node detail with edges (in graph_tools) 1720 + - `search_nodes(query)` → Full-text search over all node titles/descriptions (in graph_tools) 1721 + - `read_agents_md(path)` → Returns full AGENTS.md content for a directory path (in context module) 1722 + 1723 + This means workers aren't missing anything — they just have to ask for it. The initial context tells them *what exists* so they know what to ask for. 1724 + 1725 + ### Static Context 1726 + 1727 + Context is built once at worker spawn and does not change during execution. If a worker needs fresh information mid-execution (e.g., checking if a dependency was completed by another worker), it uses `query_nodes` to read current graph state. This avoids the confusion of context shifting under the agent. 1728 + 1729 + ### AGENTS.md Support 1730 + 1731 + Rustagent follows the [AGENTS.md specification](https://agents.md/) — a simple, open format for guiding coding agents adopted by 60,000+ open-source projects. AGENTS.md files are free-form Markdown with no required sections or special syntax. They serve as project-specific instructions for agents (build commands, code style, testing conventions, security rules, etc.). 1732 + 1733 + **Scoping**: Per the spec, "the closest AGENTS.md to the edited file wins." Rustagent resolves this by walking the directory hierarchy toward the worker's file scope: 1734 + 1735 + 1. Start at project root, collect `AGENTS.md` if present 1736 + 2. Walk toward each path in the work package's file scope 1737 + 3. Collect `AGENTS.md` at each intermediate directory 1738 + 4. The closest file to the target takes precedence; root-level `AGENTS.md` is always included as baseline context 1739 + 1740 + For a worker scoped to `src/auth/**`: 1741 + - `AGENTS.md` (project root) → general project rules 1742 + - `src/AGENTS.md` → source conventions (if exists) 1743 + - `src/auth/AGENTS.md` → auth-specific rules (if exists, takes precedence for auth-related guidance) 1744 + 1745 + **Context injection**: Only file paths and top-level heading summaries are included in the initial compact context. The agent uses `read_agents_md("src/auth")` to retrieve full content on demand. 1746 + 1747 + **Implementation note**: The parser (`src/context/agents_md.rs`) should handle the format as plain Markdown text — no semantic parsing of sections beyond extracting headings for the summary. Explicit user prompts (the task description and acceptance criteria) override AGENTS.md instructions per the spec's conventions. 1748 + 1749 + ### Node Decay Thresholds 1750 + 1751 + Completed graph nodes are compacted based on age for context injection. These thresholds are configurable in `.rustagent/config.toml`: 1752 + 1753 + | Age | Detail Level | Example | 1754 + |-----|-------------|---------| 1755 + | Recent (< 7 days) | Full: description, criteria, outcomes | "Implemented JWT validation using jsonwebtoken crate. Tokens validated against RS256 keys from JWKS endpoint." | 1756 + | Older (7-30 days) | Summary: title, status, key outcome | "JWT validation — completed, success" | 1757 + | Ancient (> 30 days) | Minimal: title, status | "JWT validation — completed" | 1758 + 1759 + ```toml 1760 + # .rustagent/config.toml 1761 + [context.decay] 1762 + recent_days = 7 1763 + older_days = 30 1764 + # Nodes older than older_days get minimal detail 1765 + ``` 1766 + 1767 + Decay applies when nodes are included in context assembly. Full detail is always available via `query_nodes` regardless of age. 1768 + 1769 + --- 1770 + 1771 + ## Core Traits 1772 + 1773 + ### Agent 1774 + ```rust 1775 + #[async_trait] 1776 + pub trait Agent: Send + Sync { 1777 + fn id(&self) -> &AgentId; 1778 + fn profile(&self) -> &AgentProfile; 1779 + async fn run(&self, ctx: AgentContext) -> Result<AgentOutcome>; 1780 + fn cancel(&self); 1781 + } 1782 + ``` 1783 + 1784 + ### GraphStore 1785 + ```rust 1786 + #[async_trait] 1787 + pub trait GraphStore: Send + Sync { 1788 + // Node operations 1789 + async fn create_node(&self, node: &GraphNode) -> Result<()>; 1790 + async fn update_node(&self, node: &GraphNode) -> Result<()>; 1791 + async fn get_node(&self, id: &str) -> Result<Option<GraphNode>>; 1792 + async fn query_nodes(&self, query: NodeQuery) -> Result<Vec<GraphNode>>; 1793 + 1794 + // Task-specific convenience methods 1795 + async fn claim_task(&self, node_id: &str, agent_id: &AgentId) -> Result<bool>; // Atomic claim 1796 + async fn get_ready_tasks(&self, goal_id: &str) -> Result<Vec<GraphNode>>; 1797 + async fn get_next_task(&self, goal_id: &str) -> Result<Option<GraphNode>>; // Priority-based 1798 + 1799 + // Edge operations 1800 + async fn add_edge(&self, edge: &GraphEdge) -> Result<()>; 1801 + async fn remove_edge(&self, edge_id: &str) -> Result<()>; 1802 + async fn get_edges(&self, node_id: &str, direction: EdgeDirection) -> Result<Vec<(GraphEdge, GraphNode)>>; 1803 + 1804 + // Graph queries 1805 + async fn get_children(&self, node_id: &str) -> Result<Vec<(GraphNode, EdgeType)>>; 1806 + async fn get_subtree(&self, node_id: &str) -> Result<Vec<GraphNode>>; 1807 + async fn get_active_decisions(&self, project_id: &str) -> Result<Vec<GraphNode>>; // Now mode 1808 + async fn get_full_graph(&self, goal_id: &str) -> Result<WorkGraph>; // History mode 1809 + async fn search_nodes(&self, query: &str, project_id: Option<&str>, limit: usize) -> Result<Vec<GraphNode>>; // FTS5 full-text search 1810 + 1811 + // Session management 1812 + async fn create_session(&self, session: &Session) -> Result<()>; 1813 + async fn end_session(&self, session_id: &str, handoff_notes: &str) -> Result<()>; 1814 + async fn get_latest_session(&self, goal_id: &str) -> Result<Option<Session>>; 1815 + } 1816 + 1817 + pub enum EdgeDirection { 1818 + Outgoing, // Edges where this node is from_node 1819 + Incoming, // Edges where this node is to_node 1820 + Both, 1821 + } 1822 + ``` 1823 + 1824 + ### MessageBus 1825 + ```rust 1826 + #[async_trait] 1827 + pub trait MessageBus: Send + Sync { 1828 + async fn send(&self, to: &AgentId, msg: WorkerMessage) -> Result<()>; 1829 + async fn broadcast(&self, msg: WorkerMessage) -> Result<()>; 1830 + fn subscribe(&self, agent_id: &AgentId) -> mpsc::Receiver<WorkerMessage>; 1831 + } 1832 + ``` 1833 + 1834 + See [Communication Model](#communication-model) for `WorkerMessage` variants and delivery semantics. 1835 + 1836 + --- 1837 + 1838 + ## Database Schema 1839 + 1840 + ### Schema Versioning and Migrations 1841 + 1842 + ```sql 1843 + -- Schema version tracking (single row) 1844 + CREATE TABLE schema_version ( 1845 + version INTEGER NOT NULL, 1846 + migrated_at TEXT NOT NULL 1847 + ); 1848 + ``` 1849 + 1850 + At startup, the binary checks `schema_version.version` against its expected version: 1851 + - **Match**: Proceed normally 1852 + - **DB is older**: Run sequential migrations forward (e.g., `migrate_001_to_002`, `migrate_002_to_003`). Each migration is a SQL function in `src/db/migrations.rs`. No external migration tooling — hand-rolled, simple, single-binary friendly. 1853 + - **DB is newer**: Error with "your database was created by a newer version of rustagent, please upgrade" 1854 + - **No table**: Fresh database, run full schema creation and set version to current 1855 + 1856 + The binary supports reading schema version N-1 (one version back) for graceful upgrades. Older than that produces a clear upgrade error. 1857 + 1858 + ### TOML Export Format Versioning 1859 + 1860 + The `[meta]` section already includes `version = 1`. On format changes: 1861 + - Bump the version number 1862 + - Import checks the version and applies format-specific parsing 1863 + - Old format files produce: "this file uses format v1, run `rustagent graph upgrade` to convert to v2" 1864 + - The binary supports reading format version N-1 1865 + 1866 + ### Tables 1867 + 1868 + ```sql 1869 + -- Projects 1870 + CREATE TABLE projects ( 1871 + id TEXT PRIMARY KEY, 1872 + name TEXT NOT NULL UNIQUE, 1873 + path TEXT NOT NULL, 1874 + registered_at TEXT NOT NULL, 1875 + config_overrides TEXT, 1876 + metadata TEXT NOT NULL DEFAULT '{}' 1877 + ); 1878 + 1879 + -- Unified work graph: nodes 1880 + -- All entity types (goal, task, decision, option, outcome, observation, revisit) 1881 + -- live in one table. Type-specific data goes in metadata (JSON). 1882 + CREATE TABLE nodes ( 1883 + id TEXT PRIMARY KEY, 1884 + project_id TEXT NOT NULL REFERENCES projects(id), 1885 + node_type TEXT NOT NULL, -- goal, task, decision, option, outcome, observation, revisit 1886 + title TEXT NOT NULL, 1887 + description TEXT NOT NULL, 1888 + status TEXT NOT NULL DEFAULT 'pending', 1889 + priority TEXT, -- goals and tasks 1890 + assigned_to TEXT, -- tasks 1891 + created_by TEXT, 1892 + labels TEXT NOT NULL DEFAULT '[]', 1893 + created_at TEXT NOT NULL, 1894 + started_at TEXT, 1895 + completed_at TEXT, 1896 + blocked_reason TEXT, 1897 + metadata TEXT NOT NULL DEFAULT '{}' 1898 + ); 1899 + 1900 + CREATE INDEX idx_nodes_project ON nodes(project_id); 1901 + CREATE INDEX idx_nodes_type ON nodes(node_type); 1902 + CREATE INDEX idx_nodes_status ON nodes(status); 1903 + 1904 + -- Unified work graph: edges 1905 + -- All relationships (contains, depends_on, leads_to, chosen, rejected, etc.) 1906 + CREATE TABLE edges ( 1907 + id TEXT PRIMARY KEY, 1908 + edge_type TEXT NOT NULL, -- contains, depends_on, leads_to, chosen, rejected, supersedes, informs 1909 + from_node TEXT NOT NULL REFERENCES nodes(id), 1910 + to_node TEXT NOT NULL REFERENCES nodes(id), 1911 + label TEXT, -- e.g., rejection reason 1912 + created_at TEXT NOT NULL 1913 + ); 1914 + 1915 + CREATE INDEX idx_edges_from ON edges(from_node); 1916 + CREATE INDEX idx_edges_to ON edges(to_node); 1917 + CREATE INDEX idx_edges_type ON edges(edge_type); 1918 + 1919 + -- Sessions (temporal, not part of the graph) 1920 + CREATE TABLE sessions ( 1921 + id TEXT PRIMARY KEY, 1922 + project_id TEXT NOT NULL REFERENCES projects(id), 1923 + goal_id TEXT NOT NULL REFERENCES nodes(id), 1924 + started_at TEXT NOT NULL, 1925 + ended_at TEXT, 1926 + handoff_notes TEXT, 1927 + agent_ids TEXT NOT NULL DEFAULT '[]', 1928 + summary TEXT 1929 + ); 1930 + 1931 + -- Full-text search over graph nodes (titles + descriptions) 1932 + CREATE VIRTUAL TABLE nodes_fts USING fts5( 1933 + title, 1934 + description, 1935 + content='nodes', 1936 + content_rowid='rowid' 1937 + ); 1938 + 1939 + -- Triggers to keep FTS index in sync with nodes table 1940 + CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN 1941 + INSERT INTO nodes_fts(rowid, title, description) 1942 + VALUES (new.rowid, new.title, new.description); 1943 + END; 1944 + CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN 1945 + INSERT INTO nodes_fts(nodes_fts, rowid, title, description) 1946 + VALUES ('delete', old.rowid, old.title, old.description); 1947 + END; 1948 + CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN 1949 + INSERT INTO nodes_fts(nodes_fts, rowid, title, description) 1950 + VALUES ('delete', old.rowid, old.title, old.description); 1951 + INSERT INTO nodes_fts(rowid, title, description) 1952 + VALUES (new.rowid, new.title, new.description); 1953 + END; 1954 + 1955 + -- Worker conversation persistence (see Observability section) 1956 + CREATE TABLE worker_conversations ( 1957 + id TEXT PRIMARY KEY, 1958 + session_id TEXT NOT NULL REFERENCES sessions(id), 1959 + agent_id TEXT NOT NULL, 1960 + task_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of task IDs in the work package 1961 + messages TEXT NOT NULL, -- JSON array of the full conversation 1962 + total_input_tokens INTEGER NOT NULL DEFAULT 0, 1963 + total_output_tokens INTEGER NOT NULL DEFAULT 0, 1964 + started_at TEXT NOT NULL, 1965 + completed_at TEXT 1966 + ); 1967 + ``` 1968 + 1969 + --- 1970 + 1971 + ## Work Graph Tools for Agents 1972 + 1973 + Agents interact with the unified work graph through these tools. Low-level tools operate on raw nodes/edges; high-level tools provide workflow shortcuts. 1974 + 1975 + ### Low-Level (generic graph operations) 1976 + 1977 + ``` 1978 + create_node - Create any node in the graph 1979 + Params: { node_type, title, description, parent_id?, priority?, metadata? } 1980 + 1981 + update_node - Update a node's status or metadata 1982 + Params: { node_id, status?, title?, description?, metadata? } 1983 + 1984 + add_edge - Create a relationship between two nodes 1985 + Params: { edge_type, from_node, to_node, label? } 1986 + 1987 + query_nodes - Search nodes by type, status, project, text 1988 + Params: { node_type?, status?, project_id?, query?, parent_id? } 1989 + 1990 + search_nodes - Full-text search over node titles and descriptions (FTS5) 1991 + Params: { query, project_id?, node_type?, limit? } 1992 + ``` 1993 + 1994 + ### High-Level (workflow shortcuts) 1995 + 1996 + ``` 1997 + claim_task - Atomically claim a Ready task node 1998 + Params: { node_id } 1999 + 2000 + log_decision - Shortcut: create Decision + Option nodes in one call 2001 + Params: { title, description, options: [{ title, description, pros?, cons? }], parent_id? } 2002 + 2003 + choose_option - Mark an Option as chosen, reject others, update Decision status 2004 + Params: { decision_id, option_id, rationale } 2005 + 2006 + record_outcome - Create Outcome node linked to a Task or Option 2007 + Params: { parent_id, title, description, success: bool } 2008 + 2009 + record_observation - Create Observation node linked to any node 2010 + Params: { title, description, related_node_id? } 2011 + 2012 + revisit - Create Revisit from bad Outcome, optionally start new Decision 2013 + Params: { outcome_id, reason, new_decision_title? } 2014 + ``` 2015 + 2016 + --- 2017 + 2018 + ## Implementation Phases 2019 + 2020 + ### Phase 1a: Database + Projects 2021 + 1. Set up `src/db/` - Central SQLite at `~/.local/share/rustagent/rustagent.db`, WAL mode, `BEGIN IMMEDIATE` discipline 2022 + 2. Implement `src/db/migrations.rs` - Full schema (projects, nodes, edges, sessions, nodes_fts, worker_conversations) 2023 + 3. Implement `src/project.rs` - Project type, registration, resolution from cwd 2024 + 4. Wire up basic CLI - `project` subcommand (add/list/remove/show) 2025 + 2026 + **Verification:** `rustagent project add test-proj .` registers a project. `rustagent project list` shows it. `rustagent project show test-proj` returns details. Database created at XDG data dir with correct schema. 2027 + 2028 + ### Phase 1b: Graph Model + Node Lifecycle 2029 + 1. Implement `src/graph/mod.rs` - GraphNode, GraphEdge, NodeType, EdgeType, NodeStatus, Priority types 2030 + 2. Implement `src/graph/store.rs` - GraphStore trait + SQLite implementation (CRUD for nodes and edges) 2031 + 3. Implement `src/graph/query.rs` - Query builders (node queries, edge traversal, FTS5 search) 2032 + 4. Implement `src/graph/dependency.rs` - Dependency resolution, ready surfacing, next task recommendation 2033 + 5. Build `src/tools/graph_tools.rs` - All graph tools (create_node, update_node, add_edge, claim_task, log_decision, choose_option, record_outcome, record_observation, revisit, query_nodes, search_nodes) 2034 + 6. Wire up CLI - `tasks` (list/ready/next/tree), `decisions` (list/now/history/show), `status` 2035 + 2036 + **Verification:** Create nodes and edges via tests. Dependency resolution correctly surfaces ready tasks. `claim_task` is atomic (concurrent claims test). FTS5 search finds nodes by content. CLI commands display graph state. 2037 + 2038 + ### Phase 1c: Sessions + Export + Interchange 2039 + 1. Implement `src/graph/session.rs` - Session management, template-based handoff notes generation 2040 + 2. Implement `src/graph/export.rs` - ADR export (decision nodes → markdown files) 2041 + 3. Implement `src/graph/interchange.rs` - TOML import/export/diff for `.rustagent/graph/` 2042 + 4. Implement `src/graph/decay.rs` - Node compaction by age for context injection 2043 + 5. Wire up CLI - `sessions` (list/latest), `decisions export`, `graph` (export/import/diff) 2044 + 2045 + **Verification:** Session creates and ends with accurate handoff notes. `rustagent decisions export` generates readable ADR files. `rustagent graph export` produces TOML; `rustagent graph import` round-trips cleanly. Decay returns appropriate detail levels by node age. 2046 + 2047 + ### Phase 1d: Agent Runtime + Single-Agent Execution 2048 + 1. Cherry-pick `src/llm/`, `src/security/`, `src/tools/` with interface adjustments 2049 + 2. Define `src/agent/mod.rs` - Agent trait, AgentId, AgentContext, AgentOutcome 2050 + 3. Build `src/agent/profile.rs` - AgentProfile type, built-in profile definitions, profile resolution (project → user → built-in) 2051 + 4. Build `src/agent/runtime.rs` - Refactor Ralph loop into generic AgentRuntime with error handling (confusion counter, token budget tracking) 2052 + 5. Extend `src/config/` - Agent profiles, autonomy mode 2053 + 6. Update AgentRuntime to encourage decision logging in system prompts 2054 + 7. Wire up CLI - `run` (single-agent mode), `search` 2055 + 2056 + **Verification:** `rustagent run --project test-proj "goal"` executes with a single agent. Agent creates tasks, makes decisions, records observations. Profile resolution works (project `.rustagent/profiles/` overrides built-ins). Token budget warning triggers at configured threshold. 2057 + 2058 + ### Phase 2: Multi-Agent Orchestration 2059 + 1. Build `src/agent/work_package.rs` - WorkPackage type, file ownership map, grouping logic 2060 + 2. Build `src/agent/orchestrator.rs` - Full state machine (Startup → Loading → Planning → Scheduling → Monitoring → Reviewing → Completing), recovery logic 2061 + 3. Build `src/message/` - WorkerMessage types, TokioMessageBus (broadcast + per-agent mpsc) 2062 + 4. Build `src/agent/builtin_profiles.rs` - System prompts for planner, coder, reviewer, tester, researcher 2063 + 5. Build `src/tools/agent_tools.rs` - spawn_sub_agent, send_message, query_agent_status 2064 + 6. Update orchestrator ↔ runtime integration: check-in intervals, turn limits, file scope enforcement 2065 + 7. Update CLI - `run` uses orchestrator, `status` shows active workers + task progress 2066 + 2067 + **Verification:** `rustagent run "goal"` → orchestrator spawns planner → planner creates tasks → orchestrator groups into work packages → spawns concurrent coder workers for independent packages → workers complete → orchestrator marks tasks done. Recovery test: kill mid-execution, restart, verify it resumes. 2068 + 2069 + ### Phase 3: Daemon + HTTP API 2070 + 1. Build `src/daemon/mod.rs` - Daemon lifecycle (start, stop, PID file) 2071 + 2. Build `src/daemon/server.rs` - Axum server setup, route mounting, static file serving 2072 + 3. Build `src/daemon/api/` - All REST endpoints (projects, graph nodes/edges, search, agents) 2073 + 4. Build `src/daemon/ws.rs` - WebSocket handler + broadcast integration with message bus 2074 + 5. Update CLI to detect daemon and route commands through API when available 2075 + 6. Add `daemon` subcommand to main.rs (start, stop, status, logs) 2076 + 2077 + **Verification:** `rustagent daemon start` starts server. `curl localhost:7400/api/projects` returns data. WebSocket connects and receives events during `rustagent run`. 2078 + 2079 + ### Phase 4: Web UI 2080 + 1. Initialize `web/` with Bun + Vite + Svelte 5 + TypeScript 2081 + 2. Build API client and WebSocket connection handler 2082 + 3. Build Dashboard view (overview across projects) 2083 + 4. Build Task Tree view (projection of work graph: task nodes with hierarchy) 2084 + 5. Build Decision Graph view (projection of work graph: decision/option/outcome nodes, now/history toggle) 2085 + 6. Build Agent Monitor view (real-time agent activity feed) 2086 + 7. Build Graph Search view (FTS5 search + filter) 2087 + 8. Build Session History view 2088 + 9. Configure Vite proxy for development, static serving for production 2089 + 2090 + **Verification:** `bun run dev` in `web/` shows dashboard. Creating a goal via API updates the UI in real-time via WebSocket. 2091 + 2092 + ### Phase 5: AGENTS.md + Context + Polish 2093 + 1. Build `src/context/agents_md.rs` - Parse AGENTS.md, closest-to-file resolution 2094 + 2. Build `src/context/mod.rs` - ContextBuilder combining all context sources 2095 + 3. Build `src/config/autonomy.rs` - Autonomy levels + approval gates 2096 + 4. Build `src/security/scope.rs` - Per-agent security boundaries 2097 + 5. Build `src/tools/search.rs` 2098 + 6. Agent error recovery, task reassignment 2099 + 2100 + **Verification:** AGENTS.md content appears in agent context. Gated mode prompts at configured gates. 2101 + 2102 + --- 2103 + 2104 + ## Testing Strategy 2105 + 2106 + ### Methodology 2107 + 2108 + All implementation follows **test-driven development (TDD)** with strict red-green-refactor: 2109 + 2110 + 1. **Red**: Write a failing test that defines the expected behavior 2111 + 2. **Green**: Write the minimum code to make the test pass 2112 + 3. **Refactor**: Clean up while keeping tests green 2113 + 2114 + Tests are written *before* implementation, not after. This applies to all phases — graph operations, orchestrator logic, API endpoints, CLI commands, and tools. No feature is considered complete without tests that were written first and observed to fail. 2115 + 2116 + ### Test Layers 2117 + 2118 + - **Unit tests**: Pure logic in isolation — graph node lifecycle state machine, dependency resolution, ID generation, TOML serialization round-trips, FTS5 queries, profile inheritance, context assembly, node decay. Use `llm/mock.rs` (carried forward from v1) for LLM interactions. 2119 + - **Integration tests**: Components working together — single-agent pipeline (goal → plan → execute → complete), multi-agent orchestration with 2-3 workers, daemon HTTP endpoints (using axum's built-in test utilities), WebSocket event delivery, graph import/export with conflict resolution. 2120 + - **Concurrency tests**: Race conditions that matter — multiple workers claiming the same task (exactly one succeeds), concurrent child node creation under one parent (no ID collisions), orchestrator handling simultaneous worker completions. 2121 + 2122 + ### What We Don't Test in CI 2123 + 2124 + No real LLM calls in automated tests. Too slow, too expensive, too flaky. Integration tests use the mock LLM client. Real LLM testing is manual, run against a live provider before releases. 2125 + 2126 + --- 2127 + 2128 + ## New Dependencies 2129 + 2130 + ### Rust (Cargo.toml) 2131 + ```toml 2132 + rusqlite = { version = "0.32", features = ["bundled"] } 2133 + tokio-rusqlite = "0.6" 2134 + tokio-util = "0.7" # CancellationToken 2135 + pulldown-cmark = "0.12" # AGENTS.md parsing 2136 + walkdir = "2.5" # Directory traversal 2137 + glob = "0.3" # File pattern matching 2138 + toml = "0.8" # TOML serialization for graph interchange 2139 + blake3 = "1" # Content hashing for export change detection 2140 + axum = { version = "0.8", features = ["ws"] } # HTTP server + WebSocket 2141 + tower = "0.5" # Middleware 2142 + tower-http = { version = "0.6", features = ["cors", "fs"] } # CORS + static files 2143 + rust-embed = { version = "8", features = ["axum"], optional = true } # Static asset embedding (bundle-ui feature) 2144 + ``` 2145 + 2146 + ### Web UI (web/package.json) 2147 + ```json 2148 + { 2149 + "dependencies": { 2150 + "cytoscape": "^3.x" 2151 + }, 2152 + "devDependencies": { 2153 + "svelte": "^5.x", 2154 + "@sveltejs/vite-plugin-svelte": "^5.x", 2155 + "typescript": "^5.x", 2156 + "vite": "^6.x" 2157 + } 2158 + } 2159 + ``` 2160 + 2161 + ## Files to Cherry-Pick from Current Codebase 2162 + - `src/llm/*` - All LLM client code 2163 + - `src/security/mod.rs` - SecurityValidator 2164 + - `src/security/permission.rs` - PermissionHandler trait + impls 2165 + - `src/tools/mod.rs` - Tool trait + ToolRegistry 2166 + - `src/tools/file.rs` - File operation tools 2167 + - `src/tools/shell.rs` - RunCommandTool 2168 + - `src/tools/signal.rs` - SignalTool 2169 + - `src/tools/permission_check.rs` - FilePermissionChecker 2170 + - `src/logging.rs` - Tracing setup
+143
docs/test-plans/2026-02-07-v2-phase1.md
··· 1 + # Human Test Plan: V2 Phase 1 2 + 3 + **Generated:** 2026-02-09 4 + **Implementation plan:** `docs/implementation-plans/2026-02-07-v2-phase1/` 5 + **Test requirements:** `docs/implementation-plans/2026-02-07-v2-phase1/test-requirements.md` 6 + 7 + ## Automated Test Summary 8 + 9 + **Total acceptance criteria:** 53 10 + **Automated coverage:** 44 criteria (83%) across 16 test files 11 + **Human verification:** 7 criteria (13%) 12 + **Compile-time verification:** 1 criterion (2%) 13 + **Hybrid:** 1 criterion 14 + 15 + All 283 automated tests pass (`cargo test`). 16 + 17 + --- 18 + 19 + ## Human Verification Checklist 20 + 21 + ### Prerequisites 22 + 23 + 1. Build the project: `cargo build` 24 + 2. Set up a test project: 25 + ```bash 26 + cargo run -- project add test-proj /tmp/test-proj 27 + ``` 28 + 3. Create test data (goal + tasks + decisions) by running: 29 + ```bash 30 + cargo run -- run --project test-proj "Create a hello world program" 31 + ``` 32 + (Requires `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` environment variable) 33 + 34 + --- 35 + 36 + ### P1d.AC1.1: TUI removal (compile-time) 37 + 38 + **Criterion:** No references to `ratatui`, `crossterm`, or `tui` remain in source. 39 + 40 + - [ ] Run `cargo check` -- should succeed 41 + - [ ] Search for removed dependencies: 42 + ```bash 43 + grep -r "ratatui\|crossterm\|tui" src/ --include="*.rs" 44 + ``` 45 + Expected: No matches 46 + 47 + --- 48 + 49 + ### P1c.AC5.1: Session listing CLI 50 + 51 + **Criterion:** `rustagent sessions` lists sessions with IDs, start/end times, and goal references. 52 + 53 + - [ ] Run `cargo run -- sessions --help` -- verify subcommands listed 54 + - [ ] Run `cargo run -- sessions --goal <goal_id>` (use goal from prerequisite) 55 + - [ ] Verify output lists session(s) with: 56 + - Session ID 57 + - Start time 58 + - End time (or "ongoing") 59 + - Goal reference 60 + 61 + --- 62 + 63 + ### P1c.AC5.2: Session latest CLI 64 + 65 + **Criterion:** `rustagent sessions latest` displays handoff notes with all 4 sections. 66 + 67 + - [ ] Run `cargo run -- sessions latest --goal <goal_id>` 68 + - [ ] Verify output contains handoff notes with sections: 69 + - `## Done` (lists completed tasks) 70 + - `## Remaining` (lists pending/in-progress tasks) 71 + - `## Blocked` (lists blocked tasks or shows none) 72 + - `## Decisions Made` (lists decisions with chosen options) 73 + 74 + --- 75 + 76 + ### P1c.AC5.3: ADR export CLI 77 + 78 + **Criterion:** `rustagent decisions export` writes ADR markdown files. 79 + 80 + - [ ] Run `cargo run -- decisions export --project test-proj --output /tmp/adrs` 81 + - [ ] Verify ADR files appear in `/tmp/adrs/` 82 + - [ ] Inspect file contents for: 83 + - `# ADR-NNN:` title header 84 + - `## Status:` section 85 + - `## Context:` section 86 + - `## Options Considered:` with labels 87 + - `## Outcome:` section (if applicable) 88 + 89 + --- 90 + 91 + ### P1c.AC5.4: Graph interchange CLI 92 + 93 + **Criterion:** `rustagent graph export/import/diff` commands work end-to-end. 94 + 95 + - [ ] Run `cargo run -- graph export --goal <goal_id>` -- verify TOML output 96 + - [ ] Save output to file: `cargo run -- graph export --goal <goal_id> > /tmp/graph.toml` 97 + - [ ] Run `cargo run -- graph import /tmp/graph.toml` -- verify import summary 98 + - [ ] Modify a node title in `/tmp/graph.toml` 99 + - [ ] Run `cargo run -- graph diff /tmp/graph.toml` -- verify diff shows the change 100 + - [ ] Run `cargo run -- graph --help` -- verify subcommands listed 101 + 102 + --- 103 + 104 + ### P1d.AC6.1: Single-agent run CLI 105 + 106 + **Criterion:** `rustagent run` creates goal, session, executes agent loop, records outcome. 107 + 108 + - [ ] Set `ANTHROPIC_API_KEY` environment variable 109 + - [ ] Run: 110 + ```bash 111 + RUST_LOG=rustagent=debug cargo run -- run --project test-proj "Create a hello world program" 112 + ``` 113 + - [ ] Verify in logs/output: 114 + - Goal node created in DB 115 + - Session created 116 + - Agent executes tool calls (visible in debug logs) 117 + - Outcome recorded (Completed, Blocked, or TokenBudgetExhausted) 118 + - [ ] Run `cargo run -- sessions latest --goal <goal_id>` -- verify handoff notes generated 119 + 120 + --- 121 + 122 + ### P1d.AC6.2: Profile selection CLI 123 + 124 + **Criterion:** `--profile` flag selects agent profile; invalid profile gives clear error. 125 + 126 + - [ ] Run with explicit profile: 127 + ```bash 128 + cargo run -- run --project test-proj --profile reviewer "Review the codebase" 129 + ``` 130 + Verify the agent uses the reviewer profile's system prompt (visible in debug logs with `RUST_LOG=rustagent=debug`) 131 + - [ ] Run with invalid profile: 132 + ```bash 133 + cargo run -- run --project test-proj --profile nonexistent "Do something" 134 + ``` 135 + Verify a clear error message (not a panic or stack trace) 136 + 137 + --- 138 + 139 + ## Notes 140 + 141 + - All human verification items are CLI integration tests that require either live LLM API keys or are thin presentation layers over already-tested business logic. 142 + - The underlying business logic for every human verification item is covered by automated tests (see test-requirements.md for mappings). 143 + - If any human verification step fails, check the corresponding automated test first to isolate whether the issue is in the business logic or the CLI wiring.
+45
hk.pkl
··· 1 + amends "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Config.pkl" 2 + import "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Builtins.pkl" 3 + 4 + local linters = new Mapping<String, Step> { 5 + // uses builtin prettier linter config 6 + ["prettier"] = Builtins.prettier 7 + 8 + // define a custom linter 9 + ["pkl"] { 10 + glob = "*.pkl" 11 + check = "pkl eval {{files}} >/dev/null" 12 + } 13 + } 14 + 15 + hooks { 16 + ["pre-commit"] { 17 + fix = true // automatically modify files with available linter fixes 18 + stash = "git" // stashes unstaged changes while running fix steps 19 + steps { 20 + // "prelint" here is simply a name to define the step 21 + ["prelint"] { 22 + // if a step has a "check" script it will execute that 23 + check = "mise run prelint" 24 + exclusive = true // ensures that the step runs in isolation 25 + } 26 + ...linters // add all linters defined above 27 + ["postlint"] { 28 + check = "mise run postlint" 29 + exclusive = true 30 + } 31 + } 32 + } 33 + // instead of pre-commit, you can instead define pre-push hooks 34 + ["pre-push"] { 35 + steps = linters 36 + } 37 + // "fix" and "check" are special steps for `hk fix` and `hk check` commands 38 + ["fix"] { 39 + fix = true 40 + steps = linters 41 + } 42 + ["check"] { 43 + steps = linters 44 + } 45 + }
mise.toml

This is a binary file and will not be displayed.

+33
src/agent/AGENTS.md
··· 1 + # Agent Module 2 + 3 + Last verified: 2026-02-09 4 + 5 + ## Purpose 6 + Defines the agent abstraction and runtime loop for autonomous task execution. Agents are configured via profiles that control their role, allowed tools, security scope, LLM settings, and resource budgets. 7 + 8 + ## Contracts 9 + - **Exposes**: `Agent` trait, `AgentProfile`, `AgentContext`, `AgentOutcome`, `AgentRuntime`, `resolve_profile()`, 5 built-in profiles 10 + - **Guarantees**: Runtime stops on token budget exhaustion (returns `TokenBudgetExhausted`). Consecutive LLM/tool failures trigger `Blocked` outcome (configurable thresholds). Profile inheritance detects cycles. `signal_completion` tool ends the loop cleanly. 11 + - **Expects**: An `Arc<dyn LlmClient>`, a `ToolRegistry`, and an `AgentContext` with work package tasks and graph store access. 12 + 13 + ## Dependencies 14 + - **Uses**: `llm::LlmClient`, `tools::ToolRegistry`, `context::ContextBuilder`, `graph::GraphNode`, `graph::store::GraphStore`, `security::SecurityScope` 15 + - **Used by**: `main.rs` (V2 `run` command wires up the runtime) 16 + - **Boundary**: Does NOT directly access the database; uses `GraphStore` trait 17 + 18 + ## Key Decisions 19 + - Profile resolution chain (project -> user -> built-in): Enables per-project customization without forking built-ins 20 + - Inheritance via `extends`: system_prompt appends (child after parent), lists replace, optionals fall through 21 + - SecurityScope per profile: Each agent type has explicit path/command/network restrictions 22 + - Confusion counter pattern: Consecutive failures tracked separately for LLM and tool errors 23 + 24 + ## Invariants 25 + - AgentOutcome is always returned (never panics): Completed, Blocked, Failed, or TokenBudgetExhausted 26 + - Token budget warning fires at 80% (configurable), hard stop at 100% 27 + - Built-in profiles: planner (read-only, graph-only), coder (file+shell+graph), reviewer (read-only), tester (file+shell+graph), researcher (read-only) 28 + 29 + ## Key Files 30 + - `mod.rs` - Agent trait, AgentId, AgentContext, AgentOutcome enum 31 + - `profile.rs` - AgentProfile struct, ProfileLlmConfig, resolve_profile() with cycle detection 32 + - `builtin_profiles.rs` - planner(), coder(), reviewer(), tester(), researcher() 33 + - `runtime.rs` - AgentRuntime, RuntimeConfig (defaults: 100 turns, 200k tokens, 3 failure threshold)
+1
src/agent/CLAUDE.md
··· 1 + Read @./AGENTS.md and treat its contents as if they were in CLAUDE.md
+136
src/agent/builtin_profiles.rs
··· 1 + use crate::agent::profile::{AgentProfile, ProfileLlmConfig}; 2 + use crate::security::SecurityScope; 3 + 4 + /// Built-in "planner" profile for task breakdown and planning 5 + pub fn planner() -> AgentProfile { 6 + AgentProfile { 7 + name: "planner".to_string(), 8 + extends: None, 9 + role: "Task breakdown specialist".to_string(), 10 + system_prompt: "You are a task breakdown specialist. Your role is to analyze high-level goals and break them into concrete, actionable tasks. Each task should have clear acceptance criteria and be assigned to the most appropriate agent type (coder, reviewer, tester, or researcher). Prioritize tasks based on dependencies and criticality.".to_string(), 11 + allowed_tools: vec![ 12 + "graph".to_string(), 13 + "signal_completion".to_string(), 14 + ], 15 + security: SecurityScope { 16 + allowed_paths: vec!["*".to_string()], 17 + denied_paths: vec![], 18 + allowed_commands: vec!["*".to_string()], 19 + read_only: true, 20 + can_create_files: false, 21 + network_access: false, 22 + }, 23 + llm: ProfileLlmConfig::default(), 24 + turn_limit: Some(100), 25 + token_budget: Some(200_000), 26 + } 27 + } 28 + 29 + /// Built-in "coder" profile for implementation work 30 + pub fn coder() -> AgentProfile { 31 + AgentProfile { 32 + name: "coder".to_string(), 33 + extends: None, 34 + role: "Implementation specialist".to_string(), 35 + system_prompt: "You are an implementation specialist. Your role is to implement features and fix bugs by writing and modifying code. Follow the project's conventions and code style. Test your changes before marking tasks complete. Prioritize clarity and maintainability over clever solutions.".to_string(), 36 + allowed_tools: vec![ 37 + "file".to_string(), 38 + "shell".to_string(), 39 + "graph".to_string(), 40 + "signal_completion".to_string(), 41 + ], 42 + security: SecurityScope { 43 + allowed_paths: vec!["*".to_string()], 44 + denied_paths: vec![], 45 + allowed_commands: vec!["*".to_string()], 46 + read_only: false, 47 + can_create_files: true, 48 + network_access: false, 49 + }, 50 + llm: ProfileLlmConfig::default(), 51 + turn_limit: Some(100), 52 + token_budget: Some(300_000), 53 + } 54 + } 55 + 56 + /// Built-in "reviewer" profile for code review 57 + pub fn reviewer() -> AgentProfile { 58 + AgentProfile { 59 + name: "reviewer".to_string(), 60 + extends: None, 61 + role: "Code review specialist".to_string(), 62 + system_prompt: "You are a code review specialist. Your role is to review code changes and provide constructive feedback. Check for: correctness, performance, security issues, adherence to project conventions, test coverage, and documentation. Point out both issues and good practices.".to_string(), 63 + allowed_tools: vec![ 64 + "file".to_string(), 65 + "shell".to_string(), 66 + "graph".to_string(), 67 + "signal_completion".to_string(), 68 + ], 69 + security: SecurityScope { 70 + allowed_paths: vec!["*".to_string()], 71 + denied_paths: vec![], 72 + allowed_commands: vec!["*".to_string()], 73 + read_only: true, 74 + can_create_files: false, 75 + network_access: false, 76 + }, 77 + llm: ProfileLlmConfig::default(), 78 + turn_limit: Some(100), 79 + token_budget: Some(200_000), 80 + } 81 + } 82 + 83 + /// Built-in "tester" profile for test writing and quality assurance 84 + pub fn tester() -> AgentProfile { 85 + AgentProfile { 86 + name: "tester".to_string(), 87 + extends: None, 88 + role: "Test implementation specialist".to_string(), 89 + system_prompt: "You are a test implementation specialist. Your role is to write comprehensive tests including unit tests, integration tests, and edge cases. Ensure tests are clear, maintainable, and provide good coverage. Focus on testing behavior, not implementation details.".to_string(), 90 + allowed_tools: vec![ 91 + "file".to_string(), 92 + "shell".to_string(), 93 + "graph".to_string(), 94 + "signal_completion".to_string(), 95 + ], 96 + security: SecurityScope { 97 + allowed_paths: vec!["*".to_string()], 98 + denied_paths: vec![], 99 + allowed_commands: vec!["*".to_string()], 100 + read_only: false, 101 + can_create_files: true, 102 + network_access: false, 103 + }, 104 + llm: ProfileLlmConfig::default(), 105 + turn_limit: Some(100), 106 + token_budget: Some(250_000), 107 + } 108 + } 109 + 110 + /// Built-in "researcher" profile for information gathering and investigation 111 + pub fn researcher() -> AgentProfile { 112 + AgentProfile { 113 + name: "researcher".to_string(), 114 + extends: None, 115 + role: "Information gathering specialist".to_string(), 116 + system_prompt: "You are an information gathering specialist. Your role is to investigate issues, gather requirements, explore solutions, and compile findings. Use available tools to explore the codebase, run searches, and gather context. Document your findings clearly.".to_string(), 117 + allowed_tools: vec![ 118 + "file".to_string(), 119 + "shell".to_string(), 120 + "graph".to_string(), 121 + "signal_completion".to_string(), 122 + // NOTE: search tool deferred to Phase 5 123 + ], 124 + security: SecurityScope { 125 + allowed_paths: vec!["*".to_string()], 126 + denied_paths: vec![], 127 + allowed_commands: vec!["*".to_string()], 128 + read_only: true, 129 + can_create_files: false, 130 + network_access: false, 131 + }, 132 + llm: ProfileLlmConfig::default(), 133 + turn_limit: Some(100), 134 + token_budget: Some(200_000), 135 + } 136 + }
+86
src/agent/mod.rs
··· 1 + pub mod builtin_profiles; 2 + pub mod profile; 3 + pub mod runtime; 4 + 5 + use crate::graph::GraphNode; 6 + use crate::graph::store::GraphStore; 7 + use anyhow::Result; 8 + use async_trait::async_trait; 9 + use serde::{Deserialize, Serialize}; 10 + use std::path::PathBuf; 11 + use std::sync::Arc; 12 + 13 + pub use profile::AgentProfile; 14 + 15 + /// Type alias for agent identifiers 16 + pub type AgentId = String; 17 + 18 + /// Agent trait defining the interface for executing work 19 + #[async_trait] 20 + pub trait Agent: Send + Sync { 21 + /// Get the agent's unique identifier 22 + fn id(&self) -> &AgentId; 23 + 24 + /// Get the agent's profile (configuration) 25 + fn profile(&self) -> &AgentProfile; 26 + 27 + /// Run the agent with the given context 28 + async fn run(&self, ctx: AgentContext) -> Result<AgentOutcome>; 29 + 30 + /// Cancel the agent's execution (no-op stub in Phase 1d) 31 + fn cancel(&self); 32 + } 33 + 34 + /// Outcome of an agent run 35 + #[derive(Debug, Clone, Serialize, Deserialize)] 36 + pub enum AgentOutcome { 37 + /// Task completed successfully 38 + Completed { summary: String }, 39 + 40 + /// Agent blocked due to unresolvable issues 41 + Blocked { reason: String }, 42 + 43 + /// Task failed with error 44 + Failed { error: String }, 45 + 46 + /// Token budget was exhausted 47 + TokenBudgetExhausted { summary: String, tokens_used: usize }, 48 + } 49 + 50 + /// Context provided to an agent when running 51 + #[derive(Clone)] 52 + pub struct AgentContext { 53 + /// Tasks to work on in this package 54 + pub work_package_tasks: Vec<GraphNode>, 55 + 56 + /// Relevant decisions from the graph 57 + pub relevant_decisions: Vec<GraphNode>, 58 + 59 + /// Handoff notes from previous agent or orchestrator 60 + pub handoff_notes: Option<String>, 61 + 62 + /// Summaries extracted from AGENTS.md files (path, heading summary) 63 + pub agents_md_summaries: Vec<(String, String)>, 64 + 65 + /// Agent profile controlling behavior 66 + pub profile: AgentProfile, 67 + 68 + /// Project path for file operations 69 + pub project_path: PathBuf, 70 + 71 + /// Graph store for querying and updating nodes 72 + pub graph_store: Arc<dyn GraphStore>, 73 + } 74 + 75 + impl std::fmt::Debug for AgentContext { 76 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 77 + f.debug_struct("AgentContext") 78 + .field("work_package_tasks", &self.work_package_tasks.len()) 79 + .field("relevant_decisions", &self.relevant_decisions.len()) 80 + .field("handoff_notes", &self.handoff_notes) 81 + .field("agents_md_summaries", &self.agents_md_summaries.len()) 82 + .field("profile", &self.profile) 83 + .field("project_path", &self.project_path) 84 + .finish() 85 + } 86 + }
+185
src/agent/profile.rs
··· 1 + use crate::security::SecurityScope; 2 + use anyhow::{Result, bail}; 3 + use serde::{Deserialize, Serialize}; 4 + use std::collections::HashSet; 5 + use std::path::Path; 6 + 7 + /// LLM configuration for an agent profile 8 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 9 + pub struct ProfileLlmConfig { 10 + /// Model name to use (e.g., "claude-3-sonnet-20250219") 11 + pub model: Option<String>, 12 + 13 + /// Temperature for sampling (0.0 to 1.0+) 14 + pub temperature: Option<f64>, 15 + 16 + /// Maximum tokens to generate 17 + pub max_tokens: Option<usize>, 18 + } 19 + 20 + /// Agent profile describing behavior and capabilities 21 + #[derive(Debug, Clone, Serialize, Deserialize)] 22 + pub struct AgentProfile { 23 + /// Name of the profile (e.g., "coder", "reviewer") 24 + pub name: String, 25 + 26 + /// Optional parent profile to inherit from 27 + pub extends: Option<String>, 28 + 29 + /// Role description (e.g., "Implementation specialist") 30 + pub role: String, 31 + 32 + /// System prompt to guide behavior 33 + pub system_prompt: String, 34 + 35 + /// List of tools the agent is allowed to use 36 + pub allowed_tools: Vec<String>, 37 + 38 + /// Security configuration for this profile 39 + pub security: SecurityScope, 40 + 41 + /// LLM configuration overrides 42 + #[serde(default)] 43 + pub llm: ProfileLlmConfig, 44 + 45 + /// Maximum turns before stopping (None = no limit) 46 + pub turn_limit: Option<usize>, 47 + 48 + /// Token budget for this run (None = no limit) 49 + pub token_budget: Option<usize>, 50 + } 51 + 52 + impl AgentProfile { 53 + /// Apply inheritance from a parent profile 54 + /// 55 + /// Rules: 56 + /// - Scalar fields: only override if self has a meaningful value 57 + /// - List fields: child replaces parent entirely (not merged) 58 + /// - system_prompt: child appended to parent with separator 59 + /// - Optional fields: Some in child wins, falls through to parent if None 60 + pub fn apply_inheritance(&mut self, parent: &AgentProfile) { 61 + // Scalar fields: child wins only if non-empty 62 + if self.role.is_empty() { 63 + self.role.clone_from(&parent.role); 64 + } 65 + 66 + // system_prompt: append child to parent 67 + if !self.system_prompt.is_empty() && !parent.system_prompt.is_empty() { 68 + self.system_prompt = format!( 69 + "{}\n\n## Project-Specific Instructions\n{}", 70 + parent.system_prompt, self.system_prompt 71 + ); 72 + } else if self.system_prompt.is_empty() { 73 + self.system_prompt.clone_from(&parent.system_prompt); 74 + } 75 + 76 + // List fields: child replaces parent entirely 77 + if self.allowed_tools.is_empty() { 78 + self.allowed_tools.clone_from(&parent.allowed_tools); 79 + } 80 + 81 + // Security: take from parent if not set in child 82 + // Simple heuristic: if child has default values, use parent's 83 + if self.security.allowed_paths == vec!["*"] && parent.security.allowed_paths != vec!["*"] { 84 + self.security 85 + .allowed_paths 86 + .clone_from(&parent.security.allowed_paths); 87 + } 88 + if self.security.denied_paths.is_empty() && !parent.security.denied_paths.is_empty() { 89 + self.security 90 + .denied_paths 91 + .clone_from(&parent.security.denied_paths); 92 + } 93 + if self.security.allowed_commands == vec!["*"] 94 + && parent.security.allowed_commands != vec!["*"] 95 + { 96 + self.security 97 + .allowed_commands 98 + .clone_from(&parent.security.allowed_commands); 99 + } 100 + 101 + // LLM config: child Some wins, falls through to parent if None 102 + if self.llm.model.is_none() { 103 + self.llm.model.clone_from(&parent.llm.model); 104 + } 105 + if self.llm.temperature.is_none() { 106 + self.llm.temperature = parent.llm.temperature; 107 + } 108 + if self.llm.max_tokens.is_none() { 109 + self.llm.max_tokens = parent.llm.max_tokens; 110 + } 111 + 112 + // Optional numeric fields: child Some wins, falls through to parent if None 113 + if self.turn_limit.is_none() { 114 + self.turn_limit = parent.turn_limit; 115 + } 116 + if self.token_budget.is_none() { 117 + self.token_budget = parent.token_budget; 118 + } 119 + } 120 + } 121 + 122 + /// Resolve a profile by name, checking in order: project-level, user-level, built-in. 123 + /// 124 + /// Supports inheritance via `extends` field. Returns error on cycles or unknown profiles. 125 + pub fn resolve_profile(name: &str, project_path: Option<&Path>) -> Result<AgentProfile> { 126 + let mut visited = HashSet::new(); 127 + resolve_profile_impl(name, project_path, &mut visited) 128 + } 129 + 130 + fn resolve_profile_impl( 131 + name: &str, 132 + project_path: Option<&Path>, 133 + visited: &mut HashSet<String>, 134 + ) -> Result<AgentProfile> { 135 + // Check for cycles in inheritance 136 + if visited.contains(name) { 137 + bail!( 138 + "inheritance cycle detected: profile '{}' extends itself", 139 + name 140 + ); 141 + } 142 + visited.insert(name.to_string()); 143 + 144 + // 1. Project-level: .rustagent/profiles/{name}.toml 145 + if let Some(path) = project_path { 146 + let profile_path = path 147 + .join(".rustagent/profiles") 148 + .join(format!("{}.toml", name)); 149 + if profile_path.exists() { 150 + let content = std::fs::read_to_string(&profile_path)?; 151 + let mut profile: AgentProfile = toml::from_str(&content)?; 152 + if let Some(parent_name) = &profile.extends.clone() { 153 + let parent = resolve_profile_impl(parent_name, project_path, visited)?; 154 + profile.apply_inheritance(&parent); 155 + } 156 + return Ok(profile); 157 + } 158 + } 159 + 160 + // 2. User-level: ~/.config/rustagent/profiles/{name}.toml 161 + if let Some(config_dir) = dirs::config_dir() { 162 + let profile_path = config_dir 163 + .join("rustagent/profiles") 164 + .join(format!("{}.toml", name)); 165 + if profile_path.exists() { 166 + let content = std::fs::read_to_string(&profile_path)?; 167 + let mut profile: AgentProfile = toml::from_str(&content)?; 168 + if let Some(parent_name) = &profile.extends.clone() { 169 + let parent = resolve_profile_impl(parent_name, project_path, visited)?; 170 + profile.apply_inheritance(&parent); 171 + } 172 + return Ok(profile); 173 + } 174 + } 175 + 176 + // 3. Built-in profiles 177 + match name { 178 + "planner" => Ok(crate::agent::builtin_profiles::planner()), 179 + "coder" => Ok(crate::agent::builtin_profiles::coder()), 180 + "reviewer" => Ok(crate::agent::builtin_profiles::reviewer()), 181 + "tester" => Ok(crate::agent::builtin_profiles::tester()), 182 + "researcher" => Ok(crate::agent::builtin_profiles::researcher()), 183 + _ => bail!("Unknown profile: {}", name), 184 + } 185 + }
+219
src/agent/runtime.rs
··· 1 + use crate::agent::{AgentContext, AgentOutcome, AgentProfile}; 2 + use crate::context::ContextBuilder; 3 + use crate::llm::{LlmClient, Message, ResponseContent}; 4 + use crate::tools::ToolRegistry; 5 + use anyhow::Result; 6 + use std::sync::Arc; 7 + 8 + /// Configuration for the AgentRuntime 9 + #[derive(Debug, Clone)] 10 + pub struct RuntimeConfig { 11 + /// Maximum number of turns to run (default: 100) 12 + pub max_turns: usize, 13 + /// Maximum consecutive LLM failures before blocking (default: 3) 14 + pub max_consecutive_llm_failures: usize, 15 + /// Maximum consecutive tool failures before blocking (default: 3) 16 + pub max_consecutive_tool_failures: usize, 17 + /// Token budget for this run (default: 200_000) 18 + pub token_budget: usize, 19 + /// Warning threshold as percentage of budget (default: 80) 20 + pub token_budget_warning_pct: u8, 21 + } 22 + 23 + impl Default for RuntimeConfig { 24 + fn default() -> Self { 25 + Self { 26 + max_turns: 100, 27 + max_consecutive_llm_failures: 3, 28 + max_consecutive_tool_failures: 3, 29 + token_budget: 200_000, 30 + token_budget_warning_pct: 80, 31 + } 32 + } 33 + } 34 + 35 + /// The agentic loop: LLM call -> tool execution -> repeat 36 + pub struct AgentRuntime { 37 + client: Arc<dyn LlmClient>, 38 + tools: ToolRegistry, 39 + #[allow(dead_code)] // Stored for multi-agent orchestration in later phases 40 + profile: AgentProfile, 41 + config: RuntimeConfig, 42 + } 43 + 44 + impl AgentRuntime { 45 + /// Create a new AgentRuntime 46 + pub fn new( 47 + client: Arc<dyn LlmClient>, 48 + tools: ToolRegistry, 49 + profile: AgentProfile, 50 + config: RuntimeConfig, 51 + ) -> Self { 52 + Self { 53 + client, 54 + tools, 55 + profile, 56 + config, 57 + } 58 + } 59 + 60 + /// Run the agentic loop 61 + pub async fn run(&self, ctx: AgentContext) -> Result<AgentOutcome> { 62 + let system_prompt = ContextBuilder::build_system_prompt(&ctx); 63 + let mut messages = vec![Message::system(system_prompt)]; 64 + let mut cumulative_tokens: usize = 0; 65 + let mut warned_about_budget = false; 66 + let mut consecutive_llm_failures = 0; 67 + let mut consecutive_tool_failures = 0; 68 + let mut turn = 0; 69 + 70 + loop { 71 + // Check turn limit 72 + if turn >= self.config.max_turns { 73 + return Ok(AgentOutcome::Completed { 74 + summary: format!("Turn limit reached after {} turns", self.config.max_turns), 75 + }); 76 + } 77 + turn += 1; 78 + 79 + // Check token budget warning threshold 80 + let token_warning_threshold = 81 + (self.config.token_budget * self.config.token_budget_warning_pct as usize) / 100; 82 + if cumulative_tokens >= token_warning_threshold && !warned_about_budget { 83 + warned_about_budget = true; 84 + messages.push(Message::system( 85 + "You are approaching your token budget. Wrap up your current work and signal completion.".to_string() 86 + )); 87 + } 88 + 89 + // Check token budget exhausted 90 + if cumulative_tokens >= self.config.token_budget { 91 + return Ok(AgentOutcome::TokenBudgetExhausted { 92 + summary: "Token budget exhausted".to_string(), 93 + tokens_used: cumulative_tokens, 94 + }); 95 + } 96 + 97 + // Call LLM 98 + let tool_definitions = self.tools.definitions(); 99 + let response = match self.client.chat(messages.clone(), &tool_definitions).await { 100 + Ok(resp) => { 101 + consecutive_llm_failures = 0; 102 + resp 103 + } 104 + Err(e) => { 105 + consecutive_llm_failures += 1; 106 + if consecutive_llm_failures >= self.config.max_consecutive_llm_failures { 107 + return Ok(AgentOutcome::Blocked { 108 + reason: format!( 109 + "LLM failures: {} consecutive failures ({:?})", 110 + self.config.max_consecutive_llm_failures, e 111 + ), 112 + }); 113 + } 114 + // Send error back to LLM for self-correction 115 + messages.push(Message::assistant(format!("Error: {}", e))); 116 + continue; 117 + } 118 + }; 119 + 120 + // Track token usage 121 + if let Some(input_tokens) = response.input_tokens { 122 + cumulative_tokens += input_tokens; 123 + } 124 + if let Some(output_tokens) = response.output_tokens { 125 + cumulative_tokens += output_tokens; 126 + } 127 + 128 + // Process response content 129 + match response.content { 130 + ResponseContent::Text(text) => { 131 + messages.push(Message::assistant(text)); 132 + } 133 + ResponseContent::ToolCalls(tool_calls) => { 134 + // Add the assistant's tool calls to the message history 135 + let tool_calls_json = serde_json::to_string(&tool_calls)?; 136 + messages.push(Message::assistant(tool_calls_json)); 137 + 138 + // Execute each tool 139 + for tool_call in tool_calls { 140 + // Check for signal_completion 141 + if tool_call.name == "signal_completion" { 142 + if let Some(tool) = self.tools.get(&tool_call.name) { 143 + match tool.execute(tool_call.parameters).await { 144 + Ok(output) => { 145 + if output.contains("SIGNAL:complete") { 146 + // Extract message from output 147 + let message = output 148 + .strip_prefix("SIGNAL:complete:") 149 + .unwrap_or("Task completed") 150 + .to_string(); 151 + return Ok(AgentOutcome::Completed { 152 + summary: message, 153 + }); 154 + } else if output.contains("SIGNAL:blocked") { 155 + let reason = output 156 + .strip_prefix("SIGNAL:blocked:") 157 + .unwrap_or("Task blocked") 158 + .to_string(); 159 + return Ok(AgentOutcome::Blocked { reason }); 160 + } 161 + } 162 + Err(e) => { 163 + consecutive_tool_failures += 1; 164 + let error_msg = format!("Tool execution failed: {}", e); 165 + messages.push(Message::tool_result( 166 + tool_call.id.clone(), 167 + error_msg, 168 + )); 169 + } 170 + } 171 + } 172 + continue; 173 + } 174 + 175 + // Execute regular tool 176 + match self.tools.get(&tool_call.name) { 177 + Some(tool) => match tool.execute(tool_call.parameters).await { 178 + Ok(output) => { 179 + consecutive_tool_failures = 0; 180 + messages.push(Message::tool_result(tool_call.id, output)); 181 + } 182 + Err(e) => { 183 + consecutive_tool_failures += 1; 184 + if consecutive_tool_failures 185 + >= self.config.max_consecutive_tool_failures 186 + { 187 + return Ok(AgentOutcome::Blocked { 188 + reason: format!( 189 + "Tool failures: {} consecutive failures", 190 + self.config.max_consecutive_tool_failures 191 + ), 192 + }); 193 + } 194 + let error_msg = format!("Tool error: {}", e); 195 + messages.push(Message::tool_result(tool_call.id, error_msg)); 196 + } 197 + }, 198 + None => { 199 + consecutive_tool_failures += 1; 200 + if consecutive_tool_failures 201 + >= self.config.max_consecutive_tool_failures 202 + { 203 + return Ok(AgentOutcome::Blocked { 204 + reason: format!( 205 + "Tool failures: {} consecutive failures", 206 + self.config.max_consecutive_tool_failures 207 + ), 208 + }); 209 + } 210 + let error_msg = format!("Unknown tool: {}", tool_call.name); 211 + messages.push(Message::tool_result(tool_call.id, error_msg)); 212 + } 213 + } 214 + } 215 + } 216 + } 217 + } 218 + } 219 + }
+191
src/context/agents_md.rs
··· 1 + use anyhow::Result; 2 + use std::collections::HashSet; 3 + use std::path::{Path, PathBuf}; 4 + 5 + /// Resolve AGENTS.md files for a given scope of files 6 + /// 7 + /// This walks the directory hierarchy from the project root towards each file in scope, 8 + /// collecting all AGENTS.md files encountered. Returns tuples of (path, heading_summary) where 9 + /// heading_summary is a comma-separated list of top-level headings. Results are deduplicated 10 + /// and ordered with closest-to-file first. 11 + pub fn resolve_agents_md( 12 + project_root: &Path, 13 + file_scope: &[PathBuf], 14 + ) -> Result<Vec<(String, String)>> { 15 + let mut summaries: Vec<(String, String)> = Vec::new(); 16 + let mut seen_paths: HashSet<PathBuf> = HashSet::new(); 17 + 18 + // For each file in scope, walk from project root to the file 19 + for file_path in file_scope { 20 + // Normalize the file path relative to project root 21 + let absolute_path = if file_path.is_absolute() { 22 + file_path.clone() 23 + } else { 24 + project_root.join(file_path) 25 + }; 26 + 27 + // Walk from the file's parent directory up to project root, collecting directories 28 + let file_parent = absolute_path.parent().unwrap_or(project_root); 29 + let mut current = file_parent.to_path_buf(); 30 + let mut dirs_to_check = Vec::new(); 31 + 32 + // Collect all directories from file parent up to project root 33 + while current.starts_with(project_root) { 34 + dirs_to_check.push(current.clone()); 35 + if current == project_root { 36 + break; 37 + } 38 + match current.parent() { 39 + Some(p) => current = p.to_path_buf(), 40 + None => break, 41 + } 42 + } 43 + 44 + // Check each directory for AGENTS.md (closest to file first) 45 + for dir in &dirs_to_check { 46 + let agents_md_path = dir.join("AGENTS.md"); 47 + if agents_md_path.exists() && !seen_paths.contains(&agents_md_path) { 48 + seen_paths.insert(agents_md_path.clone()); 49 + let headings = extract_headings(&agents_md_path)?; 50 + let heading_summary = headings.join(", "); 51 + let path_str = agents_md_path.to_string_lossy().to_string(); 52 + summaries.push((path_str, heading_summary)); 53 + } 54 + } 55 + } 56 + 57 + Ok(summaries) 58 + } 59 + 60 + /// Extract top-level headings (lines starting with "# ") from a markdown file 61 + fn extract_headings(path: &Path) -> Result<Vec<String>> { 62 + let content = std::fs::read_to_string(path)?; 63 + let mut headings = Vec::new(); 64 + 65 + for line in content.lines() { 66 + if let Some(heading) = line.strip_prefix("# ") { 67 + headings.push(heading.trim().to_string()); 68 + } 69 + } 70 + 71 + Ok(headings) 72 + } 73 + 74 + #[cfg(test)] 75 + mod tests { 76 + use super::*; 77 + use std::fs; 78 + use tempfile::TempDir; 79 + 80 + #[test] 81 + fn test_extract_headings() -> Result<()> { 82 + let tmpdir = TempDir::new()?; 83 + let agents_md_path = tmpdir.path().join("AGENTS.md"); 84 + fs::write( 85 + &agents_md_path, 86 + "# Introduction\n# Getting Started\n## Subsection\n# Advanced", 87 + )?; 88 + 89 + let headings = extract_headings(&agents_md_path)?; 90 + assert_eq!( 91 + headings, 92 + vec!["Introduction", "Getting Started", "Advanced"] 93 + ); 94 + Ok(()) 95 + } 96 + 97 + #[test] 98 + fn test_resolve_agents_md_single_file() -> Result<()> { 99 + let tmpdir = TempDir::new()?; 100 + let project_root = tmpdir.path(); 101 + 102 + // Create AGENTS.md at root 103 + fs::write(project_root.join("AGENTS.md"), "# Root\n# Guidelines")?; 104 + 105 + // Create a file to scope 106 + fs::write(project_root.join("main.rs"), "fn main() {}")?; 107 + 108 + let summaries = resolve_agents_md(project_root, &[PathBuf::from("main.rs")])?; 109 + assert_eq!(summaries.len(), 1); 110 + assert_eq!(summaries[0].1, "Root, Guidelines"); 111 + Ok(()) 112 + } 113 + 114 + #[test] 115 + fn test_resolve_agents_md_hierarchy() -> Result<()> { 116 + let tmpdir = TempDir::new()?; 117 + let project_root = tmpdir.path(); 118 + 119 + // Create root AGENTS.md 120 + fs::write(project_root.join("AGENTS.md"), "# Root Guidelines")?; 121 + 122 + // Create src directory with AGENTS.md 123 + fs::create_dir(project_root.join("src"))?; 124 + fs::write(project_root.join("src/AGENTS.md"), "# Rust Guidelines")?; 125 + 126 + // Create a file in src 127 + fs::write(project_root.join("src/main.rs"), "fn main() {}")?; 128 + 129 + let summaries = resolve_agents_md(project_root, &[PathBuf::from("src/main.rs")])?; 130 + 131 + // Should have both files, with src/AGENTS.md first (closest to file) 132 + assert_eq!(summaries.len(), 2); 133 + assert!(summaries[0].0.contains("src/AGENTS.md")); 134 + assert!(summaries[1].0.contains("AGENTS.md")); 135 + Ok(()) 136 + } 137 + 138 + #[test] 139 + fn test_resolve_agents_md_deduplication() -> Result<()> { 140 + let tmpdir = TempDir::new()?; 141 + let project_root = tmpdir.path(); 142 + 143 + // Create root AGENTS.md 144 + fs::write(project_root.join("AGENTS.md"), "# Root Guidelines")?; 145 + 146 + // Create two files in root 147 + fs::write(project_root.join("file1.rs"), "fn main() {}")?; 148 + fs::write(project_root.join("file2.rs"), "fn main() {}")?; 149 + 150 + let summaries = resolve_agents_md( 151 + project_root, 152 + &[PathBuf::from("file1.rs"), PathBuf::from("file2.rs")], 153 + )?; 154 + 155 + // Should have AGENTS.md only once despite two files in scope 156 + assert_eq!(summaries.len(), 1); 157 + Ok(()) 158 + } 159 + 160 + #[test] 161 + fn test_resolve_agents_md_nested_three_levels() -> Result<()> { 162 + let tmpdir = TempDir::new()?; 163 + let project_root = tmpdir.path(); 164 + 165 + // Create AGENTS.md at root 166 + fs::write(project_root.join("AGENTS.md"), "# Root Guidelines")?; 167 + 168 + // Create src directory with AGENTS.md 169 + fs::create_dir(project_root.join("src"))?; 170 + fs::write(project_root.join("src/AGENTS.md"), "# Rust Guidelines")?; 171 + 172 + // Create src/auth directory with AGENTS.md 173 + fs::create_dir(project_root.join("src/auth"))?; 174 + fs::write( 175 + project_root.join("src/auth/AGENTS.md"), 176 + "# Auth Module Guidelines", 177 + )?; 178 + 179 + // Create a file deep in the hierarchy 180 + fs::write(project_root.join("src/auth/handler.rs"), "fn handle() {}")?; 181 + 182 + let summaries = resolve_agents_md(project_root, &[PathBuf::from("src/auth/handler.rs")])?; 183 + 184 + // Should have all three AGENTS.md files, in order: closest to file first 185 + assert_eq!(summaries.len(), 3); 186 + assert!(summaries[0].0.contains("src/auth/AGENTS.md")); 187 + assert!(summaries[1].0.contains("src/AGENTS.md")); 188 + assert!(summaries[2].0.contains("AGENTS.md")); 189 + Ok(()) 190 + } 191 + }
+487
src/context/mod.rs
··· 1 + pub mod agents_md; 2 + 3 + use crate::agent::AgentContext; 4 + use crate::tools::Tool; 5 + use anyhow::Result; 6 + use async_trait::async_trait; 7 + use serde_json::json; 8 + use std::path::PathBuf; 9 + 10 + pub use agents_md::resolve_agents_md; 11 + 12 + /// Builds a compact structured system prompt from an AgentContext 13 + pub struct ContextBuilder; 14 + 15 + impl ContextBuilder { 16 + /// Build a system prompt string from the given agent context 17 + pub fn build_system_prompt(ctx: &AgentContext) -> String { 18 + let mut prompt = String::new(); 19 + 20 + // Role section 21 + prompt.push_str("## Role\n"); 22 + prompt.push_str(&ctx.profile.role); 23 + prompt.push('\n'); 24 + prompt.push('\n'); 25 + 26 + // Task section - show work package tasks 27 + if !ctx.work_package_tasks.is_empty() { 28 + prompt.push_str("## Task\n"); 29 + for task in &ctx.work_package_tasks { 30 + prompt.push_str(&format!( 31 + "[TASK] {} | {} | priority={}\n", 32 + task.id, 33 + task.title, 34 + task.priority 35 + .map(|p| p.to_string()) 36 + .unwrap_or_else(|| "medium".to_string()) 37 + )); 38 + 39 + // Add acceptance criteria if present in metadata 40 + if let Some(criteria) = task.metadata.get("acceptance_criteria") { 41 + prompt.push_str(&format!("[CRITERIA] {}\n", criteria)); 42 + } 43 + } 44 + prompt.push('\n'); 45 + } 46 + 47 + // Session continuity - handoff notes 48 + if let Some(handoff) = &ctx.handoff_notes { 49 + prompt.push_str("## Session Continuity\n"); 50 + prompt.push_str(&format!("[HANDOFF] {}\n", handoff)); 51 + prompt.push('\n'); 52 + } 53 + 54 + // Active decisions section 55 + if !ctx.relevant_decisions.is_empty() { 56 + prompt.push_str("## Active Decisions\n"); 57 + for decision in &ctx.relevant_decisions { 58 + prompt.push_str(&format!( 59 + "[DECISION] {} | {} | status={}\n", 60 + decision.id, decision.title, decision.status 61 + )); 62 + 63 + // Add chosen option if present 64 + if let Some(chosen) = decision.metadata.get("chosen_option") { 65 + prompt.push_str(&format!(" chosen: {}\n", chosen)); 66 + } 67 + } 68 + prompt.push('\n'); 69 + } 70 + 71 + // Relevant observations 72 + if !ctx.work_package_tasks.is_empty() { 73 + prompt.push_str("## Relevant Observations (use query_nodes(id) for full detail)\n"); 74 + for task in &ctx.work_package_tasks { 75 + prompt.push_str(&format!("- {}: {}\n", task.id, task.description)); 76 + } 77 + prompt.push('\n'); 78 + } 79 + 80 + // Project conventions 81 + if !ctx.agents_md_summaries.is_empty() { 82 + prompt.push_str("## Project Conventions (use read_agents_md(path) for full text)\n"); 83 + for (path, heading_summary) in &ctx.agents_md_summaries { 84 + prompt.push_str(&format!("- {}: {}\n", path, heading_summary)); 85 + } 86 + prompt.push('\n'); 87 + } 88 + 89 + // Rules from the profile 90 + prompt.push_str("## Rules\n"); 91 + prompt.push_str(&ctx.profile.system_prompt); 92 + prompt.push('\n'); 93 + 94 + prompt 95 + } 96 + } 97 + 98 + /// Tool for reading AGENTS.md files 99 + pub struct ReadAgentsMdTool; 100 + 101 + impl ReadAgentsMdTool { 102 + pub fn new() -> Self { 103 + Self 104 + } 105 + } 106 + 107 + #[async_trait] 108 + impl Tool for ReadAgentsMdTool { 109 + fn name(&self) -> &str { 110 + "read_agents_md" 111 + } 112 + 113 + fn description(&self) -> &str { 114 + "Read the full contents of an AGENTS.md file to see detailed project conventions and guidelines" 115 + } 116 + 117 + fn parameters(&self) -> serde_json::Value { 118 + json!({ 119 + "type": "object", 120 + "properties": { 121 + "path": { 122 + "type": "string", 123 + "description": "Path to the AGENTS.md file to read" 124 + } 125 + }, 126 + "required": ["path"] 127 + }) 128 + } 129 + 130 + async fn execute(&self, params: serde_json::Value) -> Result<String> { 131 + let path = params 132 + .get("path") 133 + .and_then(|v| v.as_str()) 134 + .ok_or_else(|| anyhow::anyhow!("missing 'path' parameter"))?; 135 + 136 + let path_buf = PathBuf::from(path); 137 + 138 + // Validate that the file is named AGENTS.md 139 + if path_buf.file_name() != Some(std::ffi::OsStr::new("AGENTS.md")) { 140 + return Err(anyhow::anyhow!( 141 + "read_agents_md can only read AGENTS.md files" 142 + )); 143 + } 144 + 145 + let content = std::fs::read_to_string(&path_buf) 146 + .map_err(|e| anyhow::anyhow!("failed to read {}: {}", path, e))?; 147 + 148 + Ok(content) 149 + } 150 + } 151 + 152 + impl Default for ReadAgentsMdTool { 153 + fn default() -> Self { 154 + Self::new() 155 + } 156 + } 157 + 158 + #[cfg(test)] 159 + mod tests { 160 + use super::*; 161 + 162 + #[test] 163 + fn test_read_agents_md_tool_name() { 164 + let tool = ReadAgentsMdTool::new(); 165 + assert_eq!(tool.name(), "read_agents_md"); 166 + } 167 + 168 + #[test] 169 + fn test_read_agents_md_tool_description() { 170 + let tool = ReadAgentsMdTool::new(); 171 + let desc = tool.description(); 172 + assert!(!desc.is_empty()); 173 + assert!(desc.contains("AGENTS.md")); 174 + } 175 + 176 + #[test] 177 + fn test_read_agents_md_tool_parameters() { 178 + let tool = ReadAgentsMdTool::new(); 179 + let params = tool.parameters(); 180 + assert!(params.is_object()); 181 + assert!(params["properties"]["path"].is_object()); 182 + assert_eq!(params["required"][0], "path"); 183 + } 184 + 185 + #[tokio::test] 186 + async fn test_read_agents_md_tool_execute() -> Result<()> { 187 + let tmpdir = tempfile::TempDir::new()?; 188 + let agents_md = tmpdir.path().join("AGENTS.md"); 189 + std::fs::write(&agents_md, "# Test Guidelines\n\nContent here")?; 190 + 191 + let tool = ReadAgentsMdTool::new(); 192 + let result = tool 193 + .execute(json!({ 194 + "path": agents_md.to_string_lossy().to_string() 195 + })) 196 + .await?; 197 + 198 + assert!(result.contains("Test Guidelines")); 199 + assert!(result.contains("Content here")); 200 + Ok(()) 201 + } 202 + 203 + #[tokio::test] 204 + async fn test_read_agents_md_tool_missing_file() { 205 + let tool = ReadAgentsMdTool::new(); 206 + let result = tool 207 + .execute(json!({ 208 + "path": "/nonexistent/AGENTS.md" 209 + })) 210 + .await; 211 + 212 + assert!(result.is_err()); 213 + } 214 + 215 + #[tokio::test] 216 + async fn test_read_agents_md_tool_missing_path_param() { 217 + let tool = ReadAgentsMdTool::new(); 218 + let result = tool.execute(json!({})).await; 219 + assert!(result.is_err()); 220 + } 221 + 222 + #[tokio::test] 223 + async fn test_read_agents_md_tool_invalid_filename() { 224 + let tool = ReadAgentsMdTool::new(); 225 + let result = tool 226 + .execute(json!({ 227 + "path": "/some/path/README.md" 228 + })) 229 + .await; 230 + 231 + assert!(result.is_err()); 232 + assert!(result.unwrap_err().to_string().contains("AGENTS.md")); 233 + } 234 + 235 + #[tokio::test] 236 + async fn test_read_agents_md_tool_restricts_to_agents_md() { 237 + let tool = ReadAgentsMdTool::new(); 238 + 239 + // Try to read a different file 240 + let result = tool 241 + .execute(json!({ 242 + "path": "/etc/passwd" 243 + })) 244 + .await; 245 + 246 + assert!(result.is_err()); 247 + assert!(result.unwrap_err().to_string().contains("AGENTS.md")); 248 + } 249 + 250 + #[test] 251 + fn test_build_system_prompt_output_format() { 252 + use crate::agent::profile::{AgentProfile, ProfileLlmConfig}; 253 + use crate::graph::store::GraphStore; 254 + use crate::graph::{GraphNode, NodeStatus, NodeType, Priority}; 255 + use crate::security::SecurityScope; 256 + use anyhow::Result; 257 + use async_trait::async_trait; 258 + use chrono::Utc; 259 + use std::collections::HashMap; 260 + use std::sync::Arc; 261 + 262 + // Minimal mock GraphStore for testing 263 + struct TestGraphStore; 264 + 265 + #[async_trait] 266 + impl GraphStore for TestGraphStore { 267 + async fn create_node(&self, _node: &GraphNode) -> Result<()> { 268 + Ok(()) 269 + } 270 + async fn update_node( 271 + &self, 272 + _id: &str, 273 + _status: Option<NodeStatus>, 274 + _title: Option<&str>, 275 + _description: Option<&str>, 276 + _blocked_reason: Option<&str>, 277 + _metadata: Option<&HashMap<String, String>>, 278 + ) -> Result<()> { 279 + Ok(()) 280 + } 281 + async fn get_node(&self, _id: &str) -> Result<Option<GraphNode>> { 282 + Ok(None) 283 + } 284 + async fn query_nodes( 285 + &self, 286 + _query: &crate::graph::store::NodeQuery, 287 + ) -> Result<Vec<GraphNode>> { 288 + Ok(vec![]) 289 + } 290 + async fn claim_task(&self, _node_id: &str, _agent_id: &str) -> Result<bool> { 291 + Ok(false) 292 + } 293 + async fn get_ready_tasks(&self, _goal_id: &str) -> Result<Vec<GraphNode>> { 294 + Ok(vec![]) 295 + } 296 + async fn get_next_task(&self, _goal_id: &str) -> Result<Option<GraphNode>> { 297 + Ok(None) 298 + } 299 + async fn add_edge(&self, _edge: &crate::graph::GraphEdge) -> Result<()> { 300 + Ok(()) 301 + } 302 + async fn remove_edge(&self, _edge_id: &str) -> Result<()> { 303 + Ok(()) 304 + } 305 + async fn get_edges( 306 + &self, 307 + _node_id: &str, 308 + _direction: crate::graph::store::EdgeDirection, 309 + ) -> Result<Vec<(crate::graph::GraphEdge, GraphNode)>> { 310 + Ok(vec![]) 311 + } 312 + async fn get_children( 313 + &self, 314 + _node_id: &str, 315 + ) -> Result<Vec<(GraphNode, crate::graph::EdgeType)>> { 316 + Ok(vec![]) 317 + } 318 + async fn get_subtree(&self, _node_id: &str) -> Result<Vec<GraphNode>> { 319 + Ok(vec![]) 320 + } 321 + async fn get_active_decisions(&self, _project_id: &str) -> Result<Vec<GraphNode>> { 322 + Ok(vec![]) 323 + } 324 + async fn get_full_graph( 325 + &self, 326 + _goal_id: &str, 327 + ) -> Result<crate::graph::store::WorkGraph> { 328 + Ok(crate::graph::store::WorkGraph { 329 + nodes: vec![], 330 + edges: vec![], 331 + }) 332 + } 333 + async fn search_nodes( 334 + &self, 335 + _query: &str, 336 + _project_id: Option<&str>, 337 + _node_type: Option<NodeType>, 338 + _limit: usize, 339 + ) -> Result<Vec<GraphNode>> { 340 + Ok(vec![]) 341 + } 342 + async fn next_child_seq(&self, _parent_id: &str) -> Result<u32> { 343 + Ok(1) 344 + } 345 + } 346 + 347 + // Create mock profile 348 + let profile = AgentProfile { 349 + name: "test_coder".to_string(), 350 + extends: None, 351 + role: "You are a helpful code assistant".to_string(), 352 + system_prompt: "Follow these rules carefully".to_string(), 353 + allowed_tools: vec!["read_file".to_string(), "write_file".to_string()], 354 + security: SecurityScope { 355 + allowed_paths: vec!["*".to_string()], 356 + denied_paths: vec![], 357 + allowed_commands: vec!["*".to_string()], 358 + read_only: false, 359 + can_create_files: true, 360 + network_access: false, 361 + }, 362 + llm: ProfileLlmConfig::default(), 363 + turn_limit: Some(100), 364 + token_budget: Some(100_000), 365 + }; 366 + 367 + // Create mock work package tasks 368 + let mut task_metadata = HashMap::new(); 369 + task_metadata.insert( 370 + "acceptance_criteria".to_string(), 371 + "AC1: Task should pass tests".to_string(), 372 + ); 373 + 374 + let work_package_tasks = vec![GraphNode { 375 + id: "task-1".to_string(), 376 + project_id: "proj-1".to_string(), 377 + node_type: NodeType::Task, 378 + title: "Implement feature".to_string(), 379 + description: "Implement a new feature".to_string(), 380 + status: NodeStatus::Ready, 381 + priority: Some(Priority::High), 382 + assigned_to: None, 383 + created_by: None, 384 + labels: vec![], 385 + created_at: Utc::now(), 386 + started_at: None, 387 + completed_at: None, 388 + blocked_reason: None, 389 + metadata: task_metadata, 390 + }]; 391 + 392 + // Create mock decisions 393 + let mut decision_metadata = HashMap::new(); 394 + decision_metadata.insert("chosen_option".to_string(), "Option B".to_string()); 395 + 396 + let relevant_decisions = vec![GraphNode { 397 + id: "decision-1".to_string(), 398 + project_id: "proj-1".to_string(), 399 + node_type: NodeType::Decision, 400 + title: "Architecture decision".to_string(), 401 + description: "Choose architecture".to_string(), 402 + status: NodeStatus::Decided, 403 + priority: None, 404 + assigned_to: None, 405 + created_by: None, 406 + labels: vec![], 407 + created_at: Utc::now(), 408 + started_at: None, 409 + completed_at: None, 410 + blocked_reason: None, 411 + metadata: decision_metadata, 412 + }]; 413 + 414 + // Create agent context 415 + let ctx = AgentContext { 416 + work_package_tasks, 417 + relevant_decisions, 418 + handoff_notes: Some("Previous session notes".to_string()), 419 + agents_md_summaries: vec![("src/AGENTS.md".to_string(), "Code standards".to_string())], 420 + profile, 421 + project_path: PathBuf::from("/test/project"), 422 + graph_store: Arc::new(TestGraphStore), 423 + }; 424 + 425 + // Build system prompt 426 + let prompt = ContextBuilder::build_system_prompt(&ctx); 427 + 428 + // Verify expected sections are present 429 + assert!(prompt.contains("## Role"), "Should contain Role section"); 430 + assert!( 431 + prompt.contains("You are a helpful code assistant"), 432 + "Should contain profile role" 433 + ); 434 + 435 + assert!(prompt.contains("## Task"), "Should contain Task section"); 436 + assert!(prompt.contains("[TASK]"), "Should contain task marker"); 437 + assert!(prompt.contains("task-1"), "Should contain task ID"); 438 + assert!( 439 + prompt.contains("[CRITERIA]"), 440 + "Should contain acceptance criteria marker" 441 + ); 442 + 443 + assert!( 444 + prompt.contains("## Session Continuity"), 445 + "Should contain Session Continuity section" 446 + ); 447 + assert!( 448 + prompt.contains("[HANDOFF]"), 449 + "Should contain handoff marker" 450 + ); 451 + assert!( 452 + prompt.contains("Previous session notes"), 453 + "Should contain handoff notes" 454 + ); 455 + 456 + assert!( 457 + prompt.contains("## Active Decisions"), 458 + "Should contain Active Decisions section" 459 + ); 460 + assert!( 461 + prompt.contains("[DECISION]"), 462 + "Should contain decision marker" 463 + ); 464 + assert!(prompt.contains("decision-1"), "Should contain decision ID"); 465 + assert!(prompt.contains("chosen:"), "Should contain chosen option"); 466 + 467 + assert!( 468 + prompt.contains("## Relevant Observations"), 469 + "Should contain Relevant Observations section" 470 + ); 471 + 472 + assert!( 473 + prompt.contains("## Project Conventions"), 474 + "Should contain Project Conventions section" 475 + ); 476 + assert!( 477 + prompt.contains("src/AGENTS.md"), 478 + "Should contain agents_md path" 479 + ); 480 + 481 + assert!(prompt.contains("## Rules"), "Should contain Rules section"); 482 + assert!( 483 + prompt.contains("Follow these rules carefully"), 484 + "Should contain system prompt rules" 485 + ); 486 + } 487 + }
+201
src/db/migrations.rs
··· 1 + use anyhow::Result; 2 + use tokio_rusqlite::Connection; 3 + 4 + const CURRENT_VERSION: u32 = 1; 5 + 6 + /// Run migrations to set up or upgrade the database schema 7 + pub async fn run_migrations(conn: &Connection) -> Result<()> { 8 + conn.call(|c| check_and_migrate(c).map_err(tokio_rusqlite::Error::Rusqlite)) 9 + .await 10 + .map_err(|e| anyhow::anyhow!(e))?; 11 + 12 + Ok(()) 13 + } 14 + 15 + /// Check schema version and apply migrations if needed (exposed for testing) 16 + pub fn check_and_migrate(conn: &rusqlite::Connection) -> rusqlite::Result<()> { 17 + // Check if schema_version table exists 18 + let mut stmt = conn 19 + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'")?; 20 + 21 + let exists = stmt.exists([])?; 22 + 23 + if !exists { 24 + // Fresh database - create full schema 25 + create_schema_v1(conn)?; 26 + return Ok(()); 27 + } 28 + 29 + // Read current version 30 + let mut stmt = conn.prepare("SELECT version FROM schema_version LIMIT 1")?; 31 + let db_version: u32 = stmt.query_row([], |row| row.get(0))?; 32 + 33 + if db_version == CURRENT_VERSION { 34 + // Already at current version 35 + return Ok(()); 36 + } 37 + 38 + if db_version > CURRENT_VERSION { 39 + // Database is newer than this binary 40 + // Return an error to signal this condition 41 + return Err(rusqlite::Error::SqliteFailure( 42 + rusqlite::ffi::Error::new(1), 43 + Some( 44 + "your database was created by a newer version of rustagent, please upgrade" 45 + .to_string(), 46 + ), 47 + )); 48 + } 49 + 50 + // Handle future migrations if needed (e.g., db_version < CURRENT_VERSION) 51 + // For now, this is a fresh v1 implementation 52 + Ok(()) 53 + } 54 + 55 + /// Create the initial database schema (version 1) 56 + fn create_schema_v1(conn: &rusqlite::Connection) -> rusqlite::Result<()> { 57 + // Wrap schema creation in a transaction for atomicity using BEGIN/COMMIT 58 + conn.execute_batch("BEGIN IMMEDIATE")?; 59 + 60 + // Schema version table 61 + conn.execute( 62 + "CREATE TABLE schema_version ( 63 + version INTEGER NOT NULL, 64 + migrated_at TEXT NOT NULL 65 + )", 66 + [], 67 + )?; 68 + 69 + conn.execute( 70 + "INSERT INTO schema_version (version, migrated_at) VALUES (?, ?)", 71 + ["1", &chrono::Utc::now().to_rfc3339()], 72 + )?; 73 + 74 + // Projects table 75 + conn.execute( 76 + "CREATE TABLE projects ( 77 + id TEXT PRIMARY KEY, 78 + name TEXT NOT NULL UNIQUE, 79 + path TEXT NOT NULL, 80 + registered_at TEXT NOT NULL, 81 + config_overrides TEXT, 82 + metadata TEXT NOT NULL DEFAULT '{}' 83 + )", 84 + [], 85 + )?; 86 + 87 + // Nodes table (unified work graph) 88 + conn.execute( 89 + "CREATE TABLE nodes ( 90 + id TEXT PRIMARY KEY, 91 + project_id TEXT NOT NULL REFERENCES projects(id), 92 + node_type TEXT NOT NULL, 93 + title TEXT NOT NULL, 94 + description TEXT NOT NULL, 95 + status TEXT NOT NULL DEFAULT 'pending', 96 + priority TEXT, 97 + assigned_to TEXT, 98 + created_by TEXT, 99 + labels TEXT NOT NULL DEFAULT '[]', 100 + created_at TEXT NOT NULL, 101 + started_at TEXT, 102 + completed_at TEXT, 103 + blocked_reason TEXT, 104 + metadata TEXT NOT NULL DEFAULT '{}' 105 + )", 106 + [], 107 + )?; 108 + 109 + conn.execute("CREATE INDEX idx_nodes_project ON nodes(project_id)", [])?; 110 + conn.execute("CREATE INDEX idx_nodes_type ON nodes(node_type)", [])?; 111 + conn.execute("CREATE INDEX idx_nodes_status ON nodes(status)", [])?; 112 + 113 + // Edges table (unified relationships) 114 + conn.execute( 115 + "CREATE TABLE edges ( 116 + id TEXT PRIMARY KEY, 117 + edge_type TEXT NOT NULL, 118 + from_node TEXT NOT NULL REFERENCES nodes(id), 119 + to_node TEXT NOT NULL REFERENCES nodes(id), 120 + label TEXT, 121 + created_at TEXT NOT NULL 122 + )", 123 + [], 124 + )?; 125 + 126 + conn.execute("CREATE INDEX idx_edges_from ON edges(from_node)", [])?; 127 + conn.execute("CREATE INDEX idx_edges_to ON edges(to_node)", [])?; 128 + conn.execute("CREATE INDEX idx_edges_type ON edges(edge_type)", [])?; 129 + 130 + // Sessions table (temporal) 131 + conn.execute( 132 + "CREATE TABLE sessions ( 133 + id TEXT PRIMARY KEY, 134 + project_id TEXT NOT NULL REFERENCES projects(id), 135 + goal_id TEXT NOT NULL REFERENCES nodes(id), 136 + started_at TEXT NOT NULL, 137 + ended_at TEXT, 138 + handoff_notes TEXT, 139 + agent_ids TEXT NOT NULL DEFAULT '[]', 140 + summary TEXT 141 + )", 142 + [], 143 + )?; 144 + 145 + // Full-text search virtual table 146 + conn.execute( 147 + "CREATE VIRTUAL TABLE nodes_fts USING fts5( 148 + title, 149 + description, 150 + content='nodes', 151 + content_rowid='rowid' 152 + )", 153 + [], 154 + )?; 155 + 156 + // FTS sync triggers 157 + conn.execute( 158 + "CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN 159 + INSERT INTO nodes_fts(rowid, title, description) 160 + VALUES (new.rowid, new.title, new.description); 161 + END", 162 + [], 163 + )?; 164 + 165 + conn.execute( 166 + "CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN 167 + INSERT INTO nodes_fts(nodes_fts, rowid, title, description) 168 + VALUES ('delete', old.rowid, old.title, old.description); 169 + END", 170 + [], 171 + )?; 172 + 173 + conn.execute( 174 + "CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN 175 + INSERT INTO nodes_fts(nodes_fts, rowid, title, description) 176 + VALUES ('delete', old.rowid, old.title, old.description); 177 + INSERT INTO nodes_fts(rowid, title, description) 178 + VALUES (new.rowid, new.title, new.description); 179 + END", 180 + [], 181 + )?; 182 + 183 + // Worker conversations table 184 + conn.execute( 185 + "CREATE TABLE worker_conversations ( 186 + id TEXT PRIMARY KEY, 187 + session_id TEXT NOT NULL REFERENCES sessions(id), 188 + agent_id TEXT NOT NULL, 189 + task_ids TEXT NOT NULL DEFAULT '[]', 190 + messages TEXT NOT NULL, 191 + total_input_tokens INTEGER NOT NULL DEFAULT 0, 192 + total_output_tokens INTEGER NOT NULL DEFAULT 0, 193 + started_at TEXT NOT NULL, 194 + completed_at TEXT 195 + )", 196 + [], 197 + )?; 198 + 199 + conn.execute_batch("COMMIT")?; 200 + Ok(()) 201 + }
+67
src/db/mod.rs
··· 1 + use anyhow::Result; 2 + use std::path::Path; 3 + use tokio_rusqlite::Connection; 4 + 5 + pub mod migrations; 6 + 7 + /// Database wrapper providing async access to SQLite 8 + #[derive(Clone)] 9 + pub struct Database { 10 + conn: Connection, 11 + } 12 + 13 + impl Database { 14 + /// Open a database at the given path 15 + pub async fn open(path: &Path) -> Result<Self> { 16 + // Create parent directories if they don't exist 17 + if let Some(parent) = path.parent() { 18 + tokio::fs::create_dir_all(parent).await?; 19 + } 20 + 21 + // Open the connection 22 + let conn = Connection::open(path).await?; 23 + 24 + // Initialize pragmas 25 + Self::init_pragmas(&conn).await?; 26 + 27 + // Run migrations 28 + migrations::run_migrations(&conn).await?; 29 + 30 + Ok(Self { conn }) 31 + } 32 + 33 + /// Open an in-memory database (useful for testing) 34 + pub async fn open_in_memory() -> Result<Self> { 35 + let conn = Connection::open_in_memory().await?; 36 + 37 + // Initialize pragmas 38 + Self::init_pragmas(&conn).await?; 39 + 40 + // Run migrations 41 + migrations::run_migrations(&conn).await?; 42 + 43 + Ok(Self { conn }) 44 + } 45 + 46 + /// Get a reference to the connection 47 + pub fn connection(&self) -> &Connection { 48 + &self.conn 49 + } 50 + 51 + /// Initialize pragma settings for WAL mode and safety 52 + async fn init_pragmas(conn: &Connection) -> Result<()> { 53 + conn.call(|c| { 54 + c.execute_batch( 55 + "PRAGMA journal_mode = WAL; 56 + PRAGMA foreign_keys = ON; 57 + PRAGMA busy_timeout = 5000; 58 + PRAGMA wal_autocheckpoint = 1000;", 59 + )?; 60 + 61 + Ok(()) 62 + }) 63 + .await?; 64 + 65 + Ok(()) 66 + } 67 + }
+34
src/graph/AGENTS.md
··· 1 + # Graph Module 2 + 3 + Last verified: 2026-02-09 4 + 5 + ## Purpose 6 + Provides a persistent, typed work graph for tracking goals, tasks, decisions, and their relationships. Replaces V1's flat JSON spec with a relational model that supports dependency resolution, atomic task claiming, and temporal decay for context injection. 7 + 8 + ## Contracts 9 + - **Exposes**: `GraphStore` trait (async CRUD for nodes/edges), `SqliteGraphStore` impl, `SessionStore`, `DecayConfig`, TOML interchange (export/import/diff), ADR export 10 + - **Guarantees**: All writes are atomic (BEGIN IMMEDIATE). Task claiming is race-safe. Node status is validated against node type. Child IDs are hierarchical (`parent.seq`). FTS5 index stays in sync via triggers. 11 + - **Expects**: A `Database` instance (from `db` module). Valid `project_id` for nodes. Parent node must exist before creating children. 12 + 13 + ## Dependencies 14 + - **Uses**: `db::Database`, `chrono`, `blake3` (interchange hashing), `uuid` (ID generation) 15 + - **Used by**: `agent::runtime` (via `Arc<dyn GraphStore>`), `tools::graph_tools`, `main.rs` (CLI commands) 16 + - **Boundary**: Does NOT depend on `llm`, `agent`, or `tools` 17 + 18 + ## Key Decisions 19 + - SQLite over external DB: Single-file persistence, no daemon, WAL for concurrent reads 20 + - Hierarchical IDs (`ra-XXXX.N.M`): Encode parent-child without extra queries 21 + - Trait-based store: `GraphStore` trait enables test doubles and future backends 22 + - TOML interchange: Git-friendly, deterministic output via BTreeMap, content-hashed for change detection 23 + 24 + ## Invariants 25 + - Node status must be valid for its NodeType (enforced by `validate_status`) 26 + - Every child node has a Contains edge to its parent (auto-created in `create_node`) 27 + - Completing a task auto-promotes Pending dependents to Ready (inside same transaction) 28 + - Decay levels: Full (<7d), Summary (7-30d), Minimal (>30d) -- configurable via DecayConfig 29 + 30 + ## Key Files 31 + - `mod.rs` - NodeType (7 variants), EdgeType (7 variants), NodeStatus (15 variants), GraphNode, GraphEdge 32 + - `store.rs` - GraphStore trait (17 methods), SqliteGraphStore, NodeQuery, EdgeDirection, WorkGraph 33 + - `session.rs` - Session, SessionStore, deterministic handoff note generation 34 + - `interchange.rs` - TOML export/import, content hashing, conflict strategies (Skip/Overwrite/Error)
+1
src/graph/CLAUDE.md
··· 1 + Read @./AGENTS.md and treat its contents as if they were in CLAUDE.md
+345
src/graph/decay.rs
··· 1 + //! Node decay for context injection based on age thresholds. 2 + //! 3 + //! This module provides functions to "decay" node details based on how old they are, 4 + //! reducing detail for old nodes to save context tokens when injecting nodes into LLM prompts. 5 + //! 6 + //! # Decay Levels 7 + //! 8 + //! - **Full** (< 7 days): title, description, status, metadata 9 + //! - **Summary** (7-30 days): title, status, key outcome from metadata 10 + //! - **Minimal** (> 30 days): title and status only 11 + //! 12 + //! Thresholds are configurable via `DecayConfig`. 13 + 14 + use crate::graph::{GraphNode, NodeStatus}; 15 + use chrono::{DateTime, Utc}; 16 + use std::collections::HashMap; 17 + 18 + /// Configuration for node decay thresholds 19 + #[derive(Debug, Clone)] 20 + pub struct DecayConfig { 21 + /// Days before recent threshold (default: 7). Nodes older than this but 22 + /// younger than `older_days` show Summary detail. 23 + pub recent_days: i64, 24 + 25 + /// Days before old threshold (default: 30). Nodes older than this show 26 + /// Minimal detail. 27 + pub older_days: i64, 28 + } 29 + 30 + impl Default for DecayConfig { 31 + fn default() -> Self { 32 + Self { 33 + recent_days: 7, 34 + older_days: 30, 35 + } 36 + } 37 + } 38 + 39 + /// Severity level of node detail 40 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 41 + pub enum DecayLevel { 42 + /// Full detail: description and metadata included 43 + Full, 44 + /// Summary: title, status, key outcome only 45 + Summary, 46 + /// Minimal: title and status only 47 + Minimal, 48 + } 49 + 50 + /// Detailed information for a decayed node 51 + #[derive(Debug, Clone)] 52 + pub enum DecayDetail { 53 + /// Full detail with description and metadata 54 + Full { 55 + /// Node description 56 + description: String, 57 + /// Additional metadata 58 + metadata: HashMap<String, String>, 59 + }, 60 + /// Summary with key outcome extracted from metadata 61 + Summary { 62 + /// Key outcome if present in metadata (under "key_outcome" field) 63 + key_outcome: Option<String>, 64 + }, 65 + /// Minimal detail - no additional fields 66 + Minimal, 67 + } 68 + 69 + /// A graph node with decayed details based on age 70 + #[derive(Debug, Clone)] 71 + pub struct DecayedNode { 72 + /// Node ID 73 + pub id: String, 74 + /// Node title 75 + pub title: String, 76 + /// Node status 77 + pub status: NodeStatus, 78 + /// Detail level based on age 79 + pub detail: DecayDetail, 80 + } 81 + 82 + /// Determine the decay level for a node based on its age 83 + fn decay_level_for_age(age_days: i64, config: &DecayConfig) -> DecayLevel { 84 + if age_days < config.recent_days { 85 + DecayLevel::Full 86 + } else if age_days < config.older_days { 87 + DecayLevel::Summary 88 + } else { 89 + DecayLevel::Minimal 90 + } 91 + } 92 + 93 + /// Calculate the age of a node in days 94 + /// 95 + /// Uses `completed_at` if available, otherwise `created_at`. Returns the number 96 + /// of complete days between the node's reference date and the provided `now`. 97 + fn node_age_days(node: &GraphNode, now: DateTime<Utc>) -> i64 { 98 + let reference_time = node.completed_at.unwrap_or(node.created_at); 99 + let duration = now.signed_duration_since(reference_time); 100 + duration.num_days() 101 + } 102 + 103 + /// Apply decay to a single node 104 + /// 105 + /// Computes the node's age and selects a decay level. Returns a `DecayedNode` 106 + /// with details appropriate to the age threshold. 107 + /// 108 + /// # Arguments 109 + /// 110 + /// * `node` - The graph node to decay 111 + /// * `now` - Current time for age calculation 112 + /// * `config` - Decay configuration with thresholds 113 + /// 114 + /// # Example 115 + /// 116 + /// ```ignore 117 + /// let config = DecayConfig::default(); 118 + /// let decayed = decay_node(&node, Utc::now(), &config); 119 + /// match decayed.detail { 120 + /// DecayDetail::Full { .. } => println!("Recent node"), 121 + /// DecayDetail::Summary { .. } => println!("Older node"), 122 + /// DecayDetail::Minimal => println!("Very old node"), 123 + /// } 124 + /// ``` 125 + pub fn decay_node(node: &GraphNode, now: DateTime<Utc>, config: &DecayConfig) -> DecayedNode { 126 + let age_days = node_age_days(node, now); 127 + let level = decay_level_for_age(age_days, config); 128 + 129 + let detail = match level { 130 + DecayLevel::Full => DecayDetail::Full { 131 + description: node.description.clone(), 132 + metadata: node.metadata.clone(), 133 + }, 134 + DecayLevel::Summary => { 135 + let key_outcome = node.metadata.get("key_outcome").cloned(); 136 + DecayDetail::Summary { key_outcome } 137 + } 138 + DecayLevel::Minimal => DecayDetail::Minimal, 139 + }; 140 + 141 + DecayedNode { 142 + id: node.id.clone(), 143 + title: node.title.clone(), 144 + status: node.status, 145 + detail, 146 + } 147 + } 148 + 149 + /// Apply decay to multiple nodes 150 + /// 151 + /// Applies `decay_node` to each node in the slice and returns a vector 152 + /// of decayed nodes. 153 + /// 154 + /// # Arguments 155 + /// 156 + /// * `nodes` - Slice of graph nodes to decay 157 + /// * `now` - Current time for age calculation 158 + /// * `config` - Decay configuration with thresholds 159 + pub fn decay_nodes( 160 + nodes: &[GraphNode], 161 + now: DateTime<Utc>, 162 + config: &DecayConfig, 163 + ) -> Vec<DecayedNode> { 164 + nodes.iter().map(|n| decay_node(n, now, config)).collect() 165 + } 166 + 167 + #[cfg(test)] 168 + mod tests { 169 + use super::*; 170 + use chrono::Duration; 171 + 172 + fn create_test_node( 173 + id: &str, 174 + created_days_ago: i64, 175 + completed_days_ago: Option<i64>, 176 + ) -> GraphNode { 177 + let now = Utc::now(); 178 + let created_at = now - Duration::days(created_days_ago); 179 + let completed_at = completed_days_ago.map(|d| now - Duration::days(d)); 180 + 181 + GraphNode { 182 + id: id.to_string(), 183 + project_id: "proj-test".to_string(), 184 + node_type: crate::graph::NodeType::Task, 185 + title: format!("Task {}", id), 186 + description: "Test description".to_string(), 187 + status: NodeStatus::Completed, 188 + priority: None, 189 + assigned_to: None, 190 + created_by: None, 191 + labels: vec![], 192 + created_at, 193 + started_at: None, 194 + completed_at, 195 + blocked_reason: None, 196 + metadata: { 197 + let mut m = HashMap::new(); 198 + m.insert("key_outcome".to_string(), "Important result".to_string()); 199 + m 200 + }, 201 + } 202 + } 203 + 204 + #[test] 205 + fn test_decay_full_detail_recent_node() { 206 + let config = DecayConfig::default(); 207 + let node = create_test_node("n1", 10, Some(2)); // created 10 days ago, completed 2 days ago 208 + let decayed = decay_node(&node, Utc::now(), &config); 209 + 210 + assert_eq!(decayed.id, "n1"); 211 + assert_eq!(decayed.title, "Task n1"); 212 + assert_eq!(decayed.status, NodeStatus::Completed); 213 + 214 + match decayed.detail { 215 + DecayDetail::Full { 216 + description, 217 + metadata, 218 + } => { 219 + assert_eq!(description, "Test description"); 220 + assert!(metadata.contains_key("key_outcome")); 221 + } 222 + _ => panic!("Expected Full detail for recent node"), 223 + } 224 + } 225 + 226 + #[test] 227 + fn test_decay_summary_older_node() { 228 + let config = DecayConfig::default(); 229 + let node = create_test_node("n2", 20, Some(15)); // completed 15 days ago 230 + let decayed = decay_node(&node, Utc::now(), &config); 231 + 232 + assert_eq!(decayed.id, "n2"); 233 + assert_eq!(decayed.status, NodeStatus::Completed); 234 + 235 + match decayed.detail { 236 + DecayDetail::Summary { key_outcome } => { 237 + assert_eq!(key_outcome, Some("Important result".to_string())); 238 + } 239 + _ => panic!("Expected Summary detail for older node"), 240 + } 241 + } 242 + 243 + #[test] 244 + fn test_decay_minimal_very_old_node() { 245 + let config = DecayConfig::default(); 246 + let node = create_test_node("n3", 50, Some(45)); // completed 45 days ago 247 + let decayed = decay_node(&node, Utc::now(), &config); 248 + 249 + assert_eq!(decayed.id, "n3"); 250 + assert_eq!(decayed.status, NodeStatus::Completed); 251 + 252 + match decayed.detail { 253 + DecayDetail::Minimal => { 254 + // Expected 255 + } 256 + _ => panic!("Expected Minimal detail for very old node"), 257 + } 258 + } 259 + 260 + #[test] 261 + fn test_decay_custom_config() { 262 + let config = DecayConfig { 263 + recent_days: 3, 264 + older_days: 10, 265 + }; 266 + 267 + let node = create_test_node("n4", 10, Some(5)); // completed 5 days ago 268 + let decayed = decay_node(&node, Utc::now(), &config); 269 + 270 + // With custom config, 5 days ago is between 3 and 10, so should be Summary 271 + match decayed.detail { 272 + DecayDetail::Summary { .. } => { 273 + // Expected 274 + } 275 + _ => panic!("Expected Summary detail with custom config"), 276 + } 277 + } 278 + 279 + #[test] 280 + fn test_decay_multiple_nodes() { 281 + let config = DecayConfig::default(); 282 + let nodes = vec![ 283 + create_test_node("n1", 10, Some(2)), // Full 284 + create_test_node("n2", 20, Some(15)), // Summary 285 + create_test_node("n3", 50, Some(45)), // Minimal 286 + ]; 287 + 288 + let decayed = decay_nodes(&nodes, Utc::now(), &config); 289 + 290 + assert_eq!(decayed.len(), 3); 291 + assert!(matches!(decayed[0].detail, DecayDetail::Full { .. })); 292 + assert!(matches!(decayed[1].detail, DecayDetail::Summary { .. })); 293 + assert!(matches!(decayed[2].detail, DecayDetail::Minimal)); 294 + } 295 + 296 + #[test] 297 + fn test_decay_uses_completed_time_if_available() { 298 + let config = DecayConfig::default(); 299 + let now = Utc::now(); 300 + 301 + // Node created 100 days ago but completed 2 days ago should be Full 302 + let node = create_test_node("n5", 100, Some(2)); 303 + let decayed = decay_node(&node, now, &config); 304 + 305 + match decayed.detail { 306 + DecayDetail::Full { .. } => { 307 + // Expected - uses completed_at (2 days old) 308 + } 309 + _ => panic!("Should use completed_at for age calculation"), 310 + } 311 + } 312 + 313 + #[test] 314 + fn test_decay_node_without_completion() { 315 + let config = DecayConfig::default(); 316 + let mut node = create_test_node("n6", 2, None); // No completion time 317 + node.status = NodeStatus::InProgress; 318 + 319 + let decayed = decay_node(&node, Utc::now(), &config); 320 + 321 + // Should use created_at - 2 days old, so Full 322 + match decayed.detail { 323 + DecayDetail::Full { .. } => { 324 + // Expected 325 + } 326 + _ => panic!("Should use created_at when completed_at is None"), 327 + } 328 + } 329 + 330 + #[test] 331 + fn test_summary_without_key_outcome() { 332 + let config = DecayConfig::default(); 333 + let mut node = create_test_node("n7", 10, Some(15)); 334 + node.metadata.clear(); // Remove key_outcome 335 + 336 + let decayed = decay_node(&node, Utc::now(), &config); 337 + 338 + match decayed.detail { 339 + DecayDetail::Summary { key_outcome } => { 340 + assert_eq!(key_outcome, None); 341 + } 342 + _ => panic!("Expected Summary without key_outcome"), 343 + } 344 + } 345 + }
+20
src/graph/dependency.rs
··· 1 + /// Check if all dependencies for a node are satisfied (completed) 2 + /// 3 + /// Queries all DependsOn edges FROM the given node_id and checks if all 4 + /// target nodes have status 'completed'. Returns true if all dependencies are met, 5 + /// or if the node has no dependencies. 6 + pub fn check_dependencies_met( 7 + conn: &rusqlite::Connection, 8 + node_id: &str, 9 + ) -> rusqlite::Result<bool> { 10 + // Count how many DependsOn edges FROM this node point to non-completed nodes 11 + let unmet_deps_count: u32 = conn.query_row( 12 + "SELECT COUNT(*) FROM edges e 13 + JOIN nodes n ON e.to_node = n.id 14 + WHERE e.from_node = ?1 AND e.edge_type = 'depends_on' AND n.status != 'completed'", 15 + rusqlite::params![node_id], 16 + |row| row.get(0), 17 + )?; 18 + 19 + Ok(unmet_deps_count == 0) 20 + }
+218
src/graph/export.rs
··· 1 + use crate::graph::store::{GraphStore, NodeQuery, SqliteGraphStore}; 2 + use anyhow::Result; 3 + use std::fs; 4 + use std::path::{Path, PathBuf}; 5 + 6 + /// Export all decisions from a project as ADR markdown files 7 + pub async fn export_adrs( 8 + graph_store: &SqliteGraphStore, 9 + project_id: &str, 10 + output_dir: &Path, 11 + ) -> Result<Vec<PathBuf>> { 12 + // Create output directory if it doesn't exist 13 + fs::create_dir_all(output_dir)?; 14 + 15 + // Query all decision nodes for the project 16 + let decisions = graph_store 17 + .query_nodes(&NodeQuery { 18 + node_type: Some(crate::graph::NodeType::Decision), 19 + status: None, 20 + project_id: Some(project_id.to_string()), 21 + parent_id: None, 22 + query: None, 23 + }) 24 + .await?; 25 + 26 + // Sort by created_at for sequential numbering 27 + let mut decisions = decisions; 28 + decisions.sort_by_key(|d| d.created_at); 29 + 30 + let mut output_files = vec![]; 31 + 32 + for (index, decision) in decisions.iter().enumerate() { 33 + let number = index + 1; 34 + let number_str = format!("{:03}", number); 35 + let slug = slugify(&decision.title); 36 + let filename = format!("{}-{}.md", number_str, slug); 37 + let file_path = output_dir.join(&filename); 38 + 39 + // Generate markdown content 40 + let content = generate_adr_markdown(graph_store, decision, &number_str).await?; 41 + 42 + // Write file 43 + fs::write(&file_path, &content)?; 44 + output_files.push(file_path); 45 + } 46 + 47 + Ok(output_files) 48 + } 49 + 50 + /// Generate ADR markdown for a decision node 51 + async fn generate_adr_markdown( 52 + graph_store: &SqliteGraphStore, 53 + decision: &crate::graph::GraphNode, 54 + number: &str, 55 + ) -> Result<String> { 56 + let mut content = String::new(); 57 + 58 + // Title 59 + content.push_str(&format!("# ADR {}: {}\n\n", number, decision.title)); 60 + 61 + // Status 62 + content.push_str(&format!("**Status:** {}\n\n", decision.status)); 63 + 64 + // Context 65 + content.push_str(&format!("## Context\n\n{}\n\n", decision.description)); 66 + 67 + // Options Considered 68 + content.push_str("## Options Considered\n\n"); 69 + 70 + // Get all edges once (both LeadsTo for options and Chosen/Rejected for status) 71 + let all_edges = graph_store 72 + .get_edges(&decision.id, crate::graph::store::EdgeDirection::Outgoing) 73 + .await?; 74 + 75 + // Separate edges by type for efficient lookup 76 + let mut option_edges = Vec::new(); 77 + let mut status_edges_map: std::collections::HashMap<String, Vec<_>> = 78 + std::collections::HashMap::new(); 79 + 80 + for (edge, node) in &all_edges { 81 + if edge.edge_type == crate::graph::EdgeType::LeadsTo { 82 + option_edges.push((edge, node)); 83 + } else if edge.edge_type == crate::graph::EdgeType::Chosen 84 + || edge.edge_type == crate::graph::EdgeType::Rejected 85 + { 86 + status_edges_map 87 + .entry(edge.to_node.clone()) 88 + .or_insert_with(Vec::new) 89 + .push(edge); 90 + } 91 + } 92 + 93 + let mut has_options = false; 94 + for (_edge, option_node) in option_edges { 95 + has_options = true; 96 + 97 + // Look up status for this option from pre-fetched edges 98 + let mut is_chosen = false; 99 + let mut rationale = String::new(); 100 + 101 + if let Some(status_edges) = status_edges_map.get(&option_node.id) { 102 + for status_edge in status_edges { 103 + if status_edge.edge_type == crate::graph::EdgeType::Chosen { 104 + is_chosen = true; 105 + if let Some(label) = &status_edge.label { 106 + rationale = label.clone(); 107 + } 108 + } 109 + } 110 + } 111 + 112 + let status_label = if is_chosen { "CHOSEN" } else { "REJECTED" }; 113 + 114 + content.push_str(&format!("### {} ({})\n\n", option_node.title, status_label)); 115 + 116 + if !option_node.description.is_empty() { 117 + content.push_str(&format!("{}\n\n", option_node.description)); 118 + } 119 + 120 + if !rationale.is_empty() { 121 + content.push_str(&format!("**Rationale:** {}\n\n", rationale)); 122 + } 123 + 124 + // Add pros/cons from metadata if available 125 + if let Some(pros) = option_node.metadata.get("pros") { 126 + content.push_str(&format!("**Pros:**\n{}\n\n", pros)); 127 + } 128 + if let Some(cons) = option_node.metadata.get("cons") { 129 + content.push_str(&format!("**Cons:**\n{}\n\n", cons)); 130 + } 131 + } 132 + 133 + if !has_options { 134 + content.push_str("(None documented)\n\n"); 135 + } 136 + 137 + // Outcome 138 + content.push_str("## Outcome\n\n"); 139 + if let Some(outcome) = &decision.metadata.get("outcome") { 140 + content.push_str(outcome); 141 + } else { 142 + content.push_str("(Pending)"); 143 + } 144 + content.push_str("\n\n"); 145 + 146 + // Related Tasks 147 + content.push_str("## Related Tasks\n\n"); 148 + let related_tasks = graph_store 149 + .get_edges(&decision.id, crate::graph::store::EdgeDirection::Both) 150 + .await?; 151 + 152 + let mut task_lines = vec![]; 153 + for (edge, node) in &related_tasks { 154 + if node.node_type == crate::graph::NodeType::Task { 155 + match edge.edge_type { 156 + crate::graph::EdgeType::LeadsTo => { 157 + task_lines.push(format!("- {} (leads to task)", node.id)); 158 + } 159 + crate::graph::EdgeType::DependsOn => { 160 + task_lines.push(format!("- {} (depends on task)", node.id)); 161 + } 162 + crate::graph::EdgeType::Informs => { 163 + task_lines.push(format!("- {} (informs task)", node.id)); 164 + } 165 + _ => {} 166 + } 167 + } 168 + } 169 + 170 + if task_lines.is_empty() { 171 + content.push_str("(None)\n\n"); 172 + } else { 173 + for line in task_lines { 174 + content.push_str(&format!("{}\n", line)); 175 + } 176 + content.push('\n'); 177 + } 178 + 179 + Ok(content) 180 + } 181 + 182 + /// Slugify a title for use in filenames 183 + fn slugify(title: &str) -> String { 184 + title 185 + .to_lowercase() 186 + .chars() 187 + .map(|c| { 188 + if c.is_alphanumeric() { 189 + c 190 + } else if c.is_whitespace() { 191 + '-' 192 + } else { 193 + ' ' // Will be filtered out below 194 + } 195 + }) 196 + .collect::<String>() 197 + .split_whitespace() 198 + .collect::<Vec<_>>() 199 + .join("-") 200 + .chars() 201 + .filter(|c| c.is_alphanumeric() || *c == '-') 202 + .collect::<String>() 203 + .trim_matches('-') 204 + .to_string() 205 + } 206 + 207 + #[cfg(test)] 208 + mod tests { 209 + use super::*; 210 + 211 + #[test] 212 + fn test_slugify() { 213 + assert_eq!(slugify("My Decision"), "my-decision"); 214 + assert_eq!(slugify("Use Rust Framework"), "use-rust-framework"); 215 + assert_eq!(slugify(" Leading Spaces "), "leading-spaces"); 216 + assert_eq!(slugify("Special-Characters!@#"), "special-characters"); 217 + } 218 + }
+620
src/graph/interchange.rs
··· 1 + /// TOML-based graph interchange format for goal-level export/import 2 + /// 3 + /// This module provides deterministic, git-friendly graph serialization. 4 + /// TOML files are per-goal, with sorted keys (BTreeMap) for reproducible output. 5 + /// Content hash enables detecting changes, and conflict strategies handle imports. 6 + use crate::graph::store::{GraphStore, SqliteGraphStore}; 7 + use crate::graph::{EdgeType, GraphEdge, GraphNode}; 8 + use anyhow::{Context, Result}; 9 + use chrono::Utc; 10 + use serde::{Deserialize, Serialize}; 11 + use std::collections::{BTreeMap, HashMap}; 12 + 13 + /// Version of the TOML interchange format 14 + const INTERCHANGE_VERSION: u32 = 1; 15 + 16 + /// Metadata about an exported goal file 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 18 + pub struct Meta { 19 + pub version: u32, 20 + pub goal_id: String, 21 + pub project: String, 22 + pub exported_at: String, 23 + pub content_hash: String, 24 + } 25 + 26 + /// A node as represented in the TOML format 27 + #[derive(Debug, Clone, Serialize, Deserialize)] 28 + pub struct TomlNode { 29 + pub project_id: String, 30 + pub node_type: String, 31 + pub title: String, 32 + pub description: String, 33 + pub status: String, 34 + #[serde(skip_serializing_if = "Option::is_none")] 35 + pub priority: Option<String>, 36 + #[serde(skip_serializing_if = "Option::is_none")] 37 + pub assigned_to: Option<String>, 38 + #[serde(skip_serializing_if = "Option::is_none")] 39 + pub created_by: Option<String>, 40 + #[serde(skip_serializing_if = "Option::is_none")] 41 + pub labels: Option<Vec<String>>, 42 + pub created_at: String, 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub started_at: Option<String>, 45 + #[serde(skip_serializing_if = "Option::is_none")] 46 + pub completed_at: Option<String>, 47 + #[serde(skip_serializing_if = "Option::is_none")] 48 + pub blocked_reason: Option<String>, 49 + #[serde(skip_serializing_if = "Option::is_none")] 50 + pub metadata: Option<BTreeMap<String, String>>, 51 + } 52 + 53 + /// An edge as represented in the TOML format 54 + #[derive(Debug, Clone, Serialize, Deserialize)] 55 + pub struct TomlEdge { 56 + pub edge_type: String, 57 + pub from_node: String, 58 + pub to_node: String, 59 + #[serde(skip_serializing_if = "Option::is_none")] 60 + pub label: Option<String>, 61 + pub created_at: String, 62 + } 63 + 64 + /// A complete goal file in TOML format 65 + #[derive(Debug, Clone, Serialize, Deserialize)] 66 + pub struct GoalFile { 67 + pub meta: Meta, 68 + pub nodes: BTreeMap<String, TomlNode>, 69 + pub edges: BTreeMap<String, TomlEdge>, 70 + } 71 + 72 + /// Conflict strategy for importing TOML data 73 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 74 + pub enum ImportStrategy { 75 + /// Flag conflicts and let the user decide manually 76 + Merge, 77 + /// File version wins (overwrite DB) 78 + Theirs, 79 + /// DB version wins (skip import) 80 + Ours, 81 + } 82 + 83 + /// A conflict detected during import 84 + #[derive(Debug, Clone)] 85 + pub struct ImportConflict { 86 + pub node_id: String, 87 + pub field: String, 88 + pub db_value: String, 89 + pub file_value: String, 90 + } 91 + 92 + /// Result of importing TOML data 93 + #[derive(Debug, Clone)] 94 + pub struct ImportResult { 95 + pub added_nodes: usize, 96 + pub added_edges: usize, 97 + pub conflicts: Vec<ImportConflict>, 98 + pub skipped_edges: Vec<String>, // Messages about edges referencing nonexistent nodes 99 + pub unchanged: usize, 100 + } 101 + 102 + /// Difference between TOML and DB state 103 + #[derive(Debug, Clone)] 104 + pub struct DiffResult { 105 + pub added_nodes: Vec<String>, // In file but not in DB 106 + pub changed_nodes: Vec<(String, Vec<String>)>, // (id, changed_fields) 107 + pub removed_nodes: Vec<String>, // In DB but not in file 108 + pub added_edges: Vec<String>, // Edge IDs in file but not in DB 109 + pub removed_edges: Vec<String>, // Edge IDs in DB but not in file 110 + pub unchanged_nodes: usize, 111 + pub unchanged_edges: usize, 112 + } 113 + 114 + /// Export a goal and its descendants to TOML format 115 + /// 116 + /// This produces a deterministic TOML string with: 117 + /// - BTreeMap-based sorted keys 118 + /// - Content hash computed from nodes + edges 119 + /// - Null/empty fields omitted 120 + pub async fn export_goal( 121 + graph_store: &SqliteGraphStore, 122 + goal_id: &str, 123 + project_name: &str, 124 + ) -> Result<String> { 125 + // Get all nodes in the goal's subtree 126 + let mut nodes_vec = graph_store.get_subtree(goal_id).await?; 127 + // Include the goal itself 128 + if let Some(goal_node) = graph_store.get_node(goal_id).await? 129 + && !nodes_vec.iter().any(|n| n.id == goal_id) 130 + { 131 + nodes_vec.insert(0, goal_node); 132 + } 133 + 134 + // Get the full graph (nodes + edges) 135 + let graph = graph_store.get_full_graph(goal_id).await?; 136 + 137 + // Build the node map for TOML 138 + let mut toml_nodes = BTreeMap::new(); 139 + for node in nodes_vec { 140 + toml_nodes.insert(node.id.clone(), graph_node_to_toml(&node)); 141 + } 142 + 143 + // Build the edge map for TOML (only edges where both endpoints are in our subtree) 144 + let node_ids: std::collections::HashSet<_> = toml_nodes.keys().cloned().collect(); 145 + let mut toml_edges = BTreeMap::new(); 146 + for edge in graph.edges { 147 + if node_ids.contains(&edge.from_node) && node_ids.contains(&edge.to_node) { 148 + toml_edges.insert(edge.id.clone(), graph_edge_to_toml(&edge)); 149 + } 150 + } 151 + 152 + // Compute content hash (serialize nodes + edges, hash, convert to hex) 153 + // This hash is deterministic and should be identical for identical content 154 + let nodes_json = serde_json::to_string(&toml_nodes)?; 155 + let edges_json = serde_json::to_string(&toml_edges)?; 156 + let content_to_hash = format!("{}{}", nodes_json, edges_json); 157 + let content_hash = blake3::hash(content_to_hash.as_bytes()) 158 + .to_hex() 159 + .to_string(); 160 + 161 + // Record the export time (will vary on each export, so not byte-identical for timestamps) 162 + // The content hash remains deterministic based on node/edge data 163 + let exported_at = Utc::now().to_rfc3339(); 164 + 165 + let goal_file = GoalFile { 166 + meta: Meta { 167 + version: INTERCHANGE_VERSION, 168 + goal_id: goal_id.to_string(), 169 + project: project_name.to_string(), 170 + exported_at, 171 + content_hash, 172 + }, 173 + nodes: toml_nodes, 174 + edges: toml_edges, 175 + }; 176 + 177 + // Serialize to TOML 178 + let toml_string = toml::to_string_pretty(&goal_file)?; 179 + Ok(toml_string) 180 + } 181 + 182 + /// Import TOML data into the graph store 183 + /// 184 + /// Applies the given conflict strategy: 185 + /// - Merge: conflicts are flagged but import proceeds 186 + /// - Theirs: file version overwrites DB 187 + /// - Ours: keep DB version, skip import 188 + /// 189 + /// All writes in a single BEGIN IMMEDIATE transaction. 190 + pub async fn import_goal( 191 + graph_store: &SqliteGraphStore, 192 + toml_content: &str, 193 + strategy: ImportStrategy, 194 + ) -> Result<ImportResult> { 195 + let goal_file: GoalFile = 196 + toml::from_str(toml_content).context("Failed to parse TOML goal file")?; 197 + 198 + let mut result = ImportResult { 199 + added_nodes: 0, 200 + added_edges: 0, 201 + conflicts: Vec::new(), 202 + skipped_edges: Vec::new(), 203 + unchanged: 0, 204 + }; 205 + 206 + // Collect nodes to import in a single transaction 207 + let mut nodes_to_add = Vec::new(); 208 + 209 + // Process nodes to determine what to add 210 + for (node_id, toml_node) in &goal_file.nodes { 211 + match graph_store.get_node(node_id).await? { 212 + None => { 213 + // New node: will add in transaction 214 + let node = toml_to_graph_node(node_id, toml_node)?; 215 + nodes_to_add.push(node); 216 + result.added_nodes += 1; 217 + } 218 + Some(existing_node) => { 219 + // Check if changed 220 + let changed_fields = detect_node_changes(&existing_node, toml_node)?; 221 + if changed_fields.is_empty() { 222 + result.unchanged += 1; 223 + } else { 224 + match strategy { 225 + ImportStrategy::Merge => { 226 + for field in &changed_fields { 227 + result.conflicts.push(ImportConflict { 228 + node_id: node_id.clone(), 229 + field: field.clone(), 230 + db_value: get_node_field_value(&existing_node, field), 231 + file_value: get_toml_node_field_value(toml_node, field), 232 + }); 233 + } 234 + } 235 + ImportStrategy::Theirs => { 236 + let node = toml_to_graph_node(node_id, toml_node)?; 237 + // Perform update with the new values 238 + graph_store 239 + .update_node( 240 + node_id, 241 + Some(node.status), 242 + Some(&node.title), 243 + Some(&node.description), 244 + node.blocked_reason.as_deref(), 245 + Some(&node.metadata), 246 + ) 247 + .await?; 248 + } 249 + ImportStrategy::Ours => { 250 + // Skip this node 251 + } 252 + } 253 + } 254 + } 255 + } 256 + } 257 + 258 + // Collect edges to import 259 + let mut edges_to_add = Vec::new(); 260 + 261 + for (edge_id, toml_edge) in &goal_file.edges { 262 + // Check if both endpoints exist 263 + let from_exists = graph_store.get_node(&toml_edge.from_node).await?.is_some(); 264 + let to_exists = graph_store.get_node(&toml_edge.to_node).await?.is_some(); 265 + 266 + if !from_exists || !to_exists { 267 + result.skipped_edges.push(format!( 268 + "Edge {} skips unresolved reference: {} -> {} (source exists: {}, target exists: {})", 269 + edge_id, toml_edge.from_node, toml_edge.to_node, from_exists, to_exists 270 + )); 271 + continue; 272 + } 273 + 274 + // Convert to GraphEdge 275 + let edge_type: EdgeType = toml_edge.edge_type.parse()?; 276 + let edge = GraphEdge { 277 + id: edge_id.clone(), 278 + edge_type, 279 + from_node: toml_edge.from_node.clone(), 280 + to_node: toml_edge.to_node.clone(), 281 + label: toml_edge.label.clone(), 282 + created_at: chrono::DateTime::parse_from_rfc3339(&toml_edge.created_at)? 283 + .with_timezone(&Utc), 284 + }; 285 + 286 + edges_to_add.push(edge); 287 + result.added_edges += 1; 288 + } 289 + 290 + // Import all nodes and edges in a single transaction 291 + if !nodes_to_add.is_empty() || !edges_to_add.is_empty() { 292 + graph_store 293 + .import_nodes_and_edges(nodes_to_add, edges_to_add) 294 + .await?; 295 + } 296 + 297 + Ok(result) 298 + } 299 + 300 + /// Diff TOML file against current DB state 301 + /// 302 + /// Shows what would change if the TOML were imported without making changes. 303 + pub async fn diff_goal(graph_store: &SqliteGraphStore, toml_content: &str) -> Result<DiffResult> { 304 + let goal_file: GoalFile = 305 + toml::from_str(toml_content).context("Failed to parse TOML goal file")?; 306 + 307 + let mut result = DiffResult { 308 + added_nodes: Vec::new(), 309 + changed_nodes: Vec::new(), 310 + removed_nodes: Vec::new(), 311 + added_edges: Vec::new(), 312 + removed_edges: Vec::new(), 313 + unchanged_nodes: 0, 314 + unchanged_edges: 0, 315 + }; 316 + 317 + // Check which nodes would be added or changed 318 + for (node_id, toml_node) in &goal_file.nodes { 319 + match graph_store.get_node(node_id).await? { 320 + None => { 321 + result.added_nodes.push(node_id.clone()); 322 + } 323 + Some(existing_node) => { 324 + let changed_fields = detect_node_changes(&existing_node, toml_node)?; 325 + if changed_fields.is_empty() { 326 + result.unchanged_nodes += 1; 327 + } else { 328 + result.changed_nodes.push((node_id.clone(), changed_fields)); 329 + } 330 + } 331 + } 332 + } 333 + 334 + // Find removed nodes (in DB but not in file) 335 + let goal_id = &goal_file.meta.goal_id; 336 + let db_nodes = graph_store.get_subtree(goal_id).await?; 337 + let file_node_ids: std::collections::HashSet<_> = goal_file.nodes.keys().cloned().collect(); 338 + 339 + for node in db_nodes { 340 + if !file_node_ids.contains(&node.id) { 341 + result.removed_nodes.push(node.id); 342 + } 343 + } 344 + 345 + // Check edges 346 + let graph = graph_store.get_full_graph(goal_id).await?; 347 + let file_edge_ids: std::collections::HashSet<_> = goal_file.edges.keys().cloned().collect(); 348 + 349 + for edge in &graph.edges { 350 + if !file_edge_ids.contains(&edge.id) { 351 + result.removed_edges.push(edge.id.clone()); 352 + } 353 + } 354 + 355 + // Check for added edges 356 + for edge_id in goal_file.edges.keys() { 357 + if !graph.edges.iter().any(|e| &e.id == edge_id) { 358 + result.added_edges.push(edge_id.clone()); 359 + } 360 + } 361 + 362 + // Count unchanged edges 363 + result.unchanged_edges = graph.edges.len() - result.removed_edges.len(); 364 + 365 + Ok(result) 366 + } 367 + 368 + // ===== Helper functions ===== 369 + 370 + /// Convert a GraphNode to TomlNode 371 + fn graph_node_to_toml(node: &GraphNode) -> TomlNode { 372 + TomlNode { 373 + project_id: node.project_id.clone(), 374 + node_type: node.node_type.to_string(), 375 + title: node.title.clone(), 376 + description: node.description.clone(), 377 + status: node.status.to_string(), 378 + priority: node.priority.map(|p| p.to_string()), 379 + assigned_to: node.assigned_to.clone(), 380 + created_by: node.created_by.clone(), 381 + labels: if node.labels.is_empty() { 382 + None 383 + } else { 384 + Some(node.labels.clone()) 385 + }, 386 + created_at: node.created_at.to_rfc3339(), 387 + started_at: node.started_at.map(|t| t.to_rfc3339()), 388 + completed_at: node.completed_at.map(|t| t.to_rfc3339()), 389 + blocked_reason: node.blocked_reason.clone(), 390 + metadata: if node.metadata.is_empty() { 391 + None 392 + } else { 393 + Some(node.metadata.clone().into_iter().collect()) 394 + }, 395 + } 396 + } 397 + 398 + /// Convert a GraphEdge to TomlEdge 399 + fn graph_edge_to_toml(edge: &GraphEdge) -> TomlEdge { 400 + TomlEdge { 401 + edge_type: edge.edge_type.to_string(), 402 + from_node: edge.from_node.clone(), 403 + to_node: edge.to_node.clone(), 404 + label: edge.label.clone(), 405 + created_at: edge.created_at.to_rfc3339(), 406 + } 407 + } 408 + 409 + /// Convert TomlNode to GraphNode 410 + fn toml_to_graph_node(id: &str, toml: &TomlNode) -> Result<GraphNode> { 411 + Ok(GraphNode { 412 + id: id.to_string(), 413 + project_id: toml.project_id.clone(), 414 + node_type: toml.node_type.parse()?, 415 + title: toml.title.clone(), 416 + description: toml.description.clone(), 417 + status: toml.status.parse()?, 418 + priority: toml.priority.as_ref().and_then(|p| p.parse().ok()), 419 + assigned_to: toml.assigned_to.clone(), 420 + created_by: toml.created_by.clone(), 421 + labels: toml.labels.clone().unwrap_or_default(), 422 + created_at: chrono::DateTime::parse_from_rfc3339(&toml.created_at)?.with_timezone(&Utc), 423 + started_at: toml.started_at.as_ref().and_then(|s| { 424 + chrono::DateTime::parse_from_rfc3339(s) 425 + .ok() 426 + .map(|dt| dt.with_timezone(&Utc)) 427 + }), 428 + completed_at: toml.completed_at.as_ref().and_then(|s| { 429 + chrono::DateTime::parse_from_rfc3339(s) 430 + .ok() 431 + .map(|dt| dt.with_timezone(&Utc)) 432 + }), 433 + blocked_reason: toml.blocked_reason.clone(), 434 + metadata: toml 435 + .metadata 436 + .clone() 437 + .unwrap_or_default() 438 + .into_iter() 439 + .collect(), 440 + }) 441 + } 442 + 443 + /// Detect which fields have changed between DB and TOML 444 + fn detect_node_changes(db_node: &GraphNode, toml_node: &TomlNode) -> Result<Vec<String>> { 445 + let mut changed = Vec::new(); 446 + 447 + if db_node.title != toml_node.title { 448 + changed.push("title".to_string()); 449 + } 450 + if db_node.description != toml_node.description { 451 + changed.push("description".to_string()); 452 + } 453 + if db_node.status.to_string() != toml_node.status { 454 + changed.push("status".to_string()); 455 + } 456 + if db_node.priority.map(|p| p.to_string()) != toml_node.priority { 457 + changed.push("priority".to_string()); 458 + } 459 + if db_node.assigned_to != toml_node.assigned_to { 460 + changed.push("assigned_to".to_string()); 461 + } 462 + if db_node.created_by != toml_node.created_by { 463 + changed.push("created_by".to_string()); 464 + } 465 + // Check labels: both empty/None means no change 466 + let db_has_labels = !db_node.labels.is_empty(); 467 + let toml_has_labels = 468 + toml_node.labels.is_some() && !toml_node.labels.as_ref().unwrap().is_empty(); 469 + if db_has_labels != toml_has_labels 470 + || (db_has_labels && Some(&db_node.labels) != toml_node.labels.as_ref()) 471 + { 472 + changed.push("labels".to_string()); 473 + } 474 + if db_node.blocked_reason != toml_node.blocked_reason { 475 + changed.push("blocked_reason".to_string()); 476 + } 477 + 478 + let toml_metadata: HashMap<String, String> = toml_node 479 + .metadata 480 + .clone() 481 + .unwrap_or_default() 482 + .into_iter() 483 + .collect(); 484 + if db_node.metadata != toml_metadata { 485 + changed.push("metadata".to_string()); 486 + } 487 + 488 + Ok(changed) 489 + } 490 + 491 + /// Get field value from a GraphNode as a string for display 492 + fn get_node_field_value(node: &GraphNode, field: &str) -> String { 493 + match field { 494 + "title" => node.title.clone(), 495 + "description" => node.description.clone(), 496 + "status" => node.status.to_string(), 497 + "priority" => node.priority.map(|p| p.to_string()).unwrap_or_default(), 498 + "assigned_to" => node.assigned_to.clone().unwrap_or_default(), 499 + "created_by" => node.created_by.clone().unwrap_or_default(), 500 + "labels" => serde_json::to_string(&node.labels).unwrap_or_default(), 501 + "blocked_reason" => node.blocked_reason.clone().unwrap_or_default(), 502 + "metadata" => serde_json::to_string(&node.metadata).unwrap_or_default(), 503 + _ => String::new(), 504 + } 505 + } 506 + 507 + /// Get field value from a TomlNode as a string for display 508 + fn get_toml_node_field_value(node: &TomlNode, field: &str) -> String { 509 + match field { 510 + "title" => node.title.clone(), 511 + "description" => node.description.clone(), 512 + "status" => node.status.clone(), 513 + "priority" => node.priority.clone().unwrap_or_default(), 514 + "assigned_to" => node.assigned_to.clone().unwrap_or_default(), 515 + "created_by" => node.created_by.clone().unwrap_or_default(), 516 + "labels" => serde_json::to_string(&node.labels).unwrap_or_default(), 517 + "blocked_reason" => node.blocked_reason.clone().unwrap_or_default(), 518 + "metadata" => serde_json::to_string(&node.metadata).unwrap_or_default(), 519 + _ => String::new(), 520 + } 521 + } 522 + 523 + #[cfg(test)] 524 + mod tests { 525 + use super::*; 526 + use crate::graph::{NodeStatus, NodeType}; 527 + 528 + #[test] 529 + fn test_graph_node_to_toml_conversion() { 530 + let node = GraphNode { 531 + id: "test-1".to_string(), 532 + project_id: "proj-1".to_string(), 533 + node_type: NodeType::Task, 534 + title: "Test Task".to_string(), 535 + description: "A test task".to_string(), 536 + status: NodeStatus::Pending, 537 + priority: Some(crate::graph::Priority::High), 538 + assigned_to: Some("user1".to_string()), 539 + created_by: Some("user2".to_string()), 540 + labels: vec!["label1".to_string()], 541 + created_at: Utc::now(), 542 + started_at: None, 543 + completed_at: None, 544 + blocked_reason: None, 545 + metadata: HashMap::new(), 546 + }; 547 + 548 + let toml_node = graph_node_to_toml(&node); 549 + assert_eq!(toml_node.title, "Test Task"); 550 + assert_eq!(toml_node.node_type, "task"); 551 + assert_eq!(toml_node.priority, Some("high".to_string())); 552 + } 553 + 554 + #[test] 555 + fn test_toml_to_graph_node_conversion() { 556 + let toml_node = TomlNode { 557 + project_id: "proj-1".to_string(), 558 + node_type: "task".to_string(), 559 + title: "Test Task".to_string(), 560 + description: "A test task".to_string(), 561 + status: "pending".to_string(), 562 + priority: Some("high".to_string()), 563 + assigned_to: Some("user1".to_string()), 564 + created_by: Some("user2".to_string()), 565 + labels: Some(vec!["label1".to_string()]), 566 + created_at: Utc::now().to_rfc3339(), 567 + started_at: None, 568 + completed_at: None, 569 + blocked_reason: None, 570 + metadata: None, 571 + }; 572 + 573 + let node = toml_to_graph_node("test-1", &toml_node).expect("Failed to convert"); 574 + assert_eq!(node.id, "test-1"); 575 + assert_eq!(node.title, "Test Task"); 576 + assert_eq!(node.node_type, NodeType::Task); 577 + } 578 + 579 + #[test] 580 + fn test_detect_node_changes() { 581 + let db_node = GraphNode { 582 + id: "test-1".to_string(), 583 + project_id: "proj-1".to_string(), 584 + node_type: NodeType::Task, 585 + title: "Original Title".to_string(), 586 + description: "Original description".to_string(), 587 + status: NodeStatus::Pending, 588 + priority: Some(crate::graph::Priority::High), 589 + assigned_to: None, 590 + created_by: None, 591 + labels: vec![], 592 + created_at: Utc::now(), 593 + started_at: None, 594 + completed_at: None, 595 + blocked_reason: None, 596 + metadata: HashMap::new(), 597 + }; 598 + 599 + let toml_node = TomlNode { 600 + project_id: "proj-1".to_string(), 601 + node_type: "task".to_string(), 602 + title: "New Title".to_string(), 603 + description: "Original description".to_string(), 604 + status: "pending".to_string(), 605 + priority: Some("high".to_string()), 606 + assigned_to: None, 607 + created_by: None, 608 + labels: None, 609 + created_at: db_node.created_at.to_rfc3339(), 610 + started_at: None, 611 + completed_at: None, 612 + blocked_reason: None, 613 + metadata: None, 614 + }; 615 + 616 + let changes = detect_node_changes(&db_node, &toml_node).expect("Failed"); 617 + assert!(changes.contains(&"title".to_string())); 618 + assert!(!changes.contains(&"description".to_string())); 619 + } 620 + }
+507
src/graph/mod.rs
··· 1 + use anyhow::{Result, anyhow}; 2 + use chrono::{DateTime, Utc}; 3 + use serde::{Deserialize, Serialize}; 4 + use std::collections::HashMap; 5 + use std::str::FromStr; 6 + 7 + pub mod decay; 8 + pub mod dependency; 9 + pub mod export; 10 + pub mod interchange; 11 + pub mod session; 12 + pub mod store; 13 + 14 + /// Node type in the work graph 15 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 16 + #[serde(rename_all = "lowercase")] 17 + pub enum NodeType { 18 + Goal, 19 + Task, 20 + Decision, 21 + Option, 22 + Outcome, 23 + Observation, 24 + Revisit, 25 + } 26 + 27 + impl std::fmt::Display for NodeType { 28 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 + match self { 30 + NodeType::Goal => write!(f, "goal"), 31 + NodeType::Task => write!(f, "task"), 32 + NodeType::Decision => write!(f, "decision"), 33 + NodeType::Option => write!(f, "option"), 34 + NodeType::Outcome => write!(f, "outcome"), 35 + NodeType::Observation => write!(f, "observation"), 36 + NodeType::Revisit => write!(f, "revisit"), 37 + } 38 + } 39 + } 40 + 41 + impl FromStr for NodeType { 42 + type Err = anyhow::Error; 43 + 44 + fn from_str(s: &str) -> Result<Self> { 45 + match s.to_lowercase().as_str() { 46 + "goal" => Ok(NodeType::Goal), 47 + "task" => Ok(NodeType::Task), 48 + "decision" => Ok(NodeType::Decision), 49 + "option" => Ok(NodeType::Option), 50 + "outcome" => Ok(NodeType::Outcome), 51 + "observation" => Ok(NodeType::Observation), 52 + "revisit" => Ok(NodeType::Revisit), 53 + _ => Err(anyhow!("Unknown node type: {}", s)), 54 + } 55 + } 56 + } 57 + 58 + /// Edge type connecting nodes in the work graph 59 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 60 + #[serde(rename_all = "lowercase")] 61 + pub enum EdgeType { 62 + Contains, 63 + DependsOn, 64 + LeadsTo, 65 + Chosen, 66 + Rejected, 67 + Supersedes, 68 + Informs, 69 + } 70 + 71 + impl std::fmt::Display for EdgeType { 72 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 73 + match self { 74 + EdgeType::Contains => write!(f, "contains"), 75 + EdgeType::DependsOn => write!(f, "depends_on"), 76 + EdgeType::LeadsTo => write!(f, "leads_to"), 77 + EdgeType::Chosen => write!(f, "chosen"), 78 + EdgeType::Rejected => write!(f, "rejected"), 79 + EdgeType::Supersedes => write!(f, "supersedes"), 80 + EdgeType::Informs => write!(f, "informs"), 81 + } 82 + } 83 + } 84 + 85 + impl FromStr for EdgeType { 86 + type Err = anyhow::Error; 87 + 88 + fn from_str(s: &str) -> Result<Self> { 89 + match s.to_lowercase().as_str() { 90 + "contains" => Ok(EdgeType::Contains), 91 + "depends_on" | "dependson" => Ok(EdgeType::DependsOn), 92 + "leads_to" | "leadsto" => Ok(EdgeType::LeadsTo), 93 + "chosen" => Ok(EdgeType::Chosen), 94 + "rejected" => Ok(EdgeType::Rejected), 95 + "supersedes" => Ok(EdgeType::Supersedes), 96 + "informs" => Ok(EdgeType::Informs), 97 + _ => Err(anyhow!("Unknown edge type: {}", s)), 98 + } 99 + } 100 + } 101 + 102 + /// Status of a node in the work graph 103 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 104 + #[serde(rename_all = "snake_case")] 105 + pub enum NodeStatus { 106 + // Lifecycle (all node types) 107 + Pending, 108 + Active, 109 + Completed, 110 + Cancelled, 111 + 112 + // Task workflow 113 + Ready, 114 + Claimed, 115 + InProgress, 116 + Review, 117 + Blocked, 118 + Failed, 119 + 120 + // Decision workflow 121 + Decided, 122 + Superseded, 123 + Abandoned, 124 + 125 + // Option workflow 126 + Chosen, 127 + Rejected, 128 + } 129 + 130 + impl std::fmt::Display for NodeStatus { 131 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 132 + match self { 133 + NodeStatus::Pending => write!(f, "pending"), 134 + NodeStatus::Active => write!(f, "active"), 135 + NodeStatus::Completed => write!(f, "completed"), 136 + NodeStatus::Cancelled => write!(f, "cancelled"), 137 + NodeStatus::Ready => write!(f, "ready"), 138 + NodeStatus::Claimed => write!(f, "claimed"), 139 + NodeStatus::InProgress => write!(f, "in_progress"), 140 + NodeStatus::Review => write!(f, "review"), 141 + NodeStatus::Blocked => write!(f, "blocked"), 142 + NodeStatus::Failed => write!(f, "failed"), 143 + NodeStatus::Decided => write!(f, "decided"), 144 + NodeStatus::Superseded => write!(f, "superseded"), 145 + NodeStatus::Abandoned => write!(f, "abandoned"), 146 + NodeStatus::Chosen => write!(f, "chosen"), 147 + NodeStatus::Rejected => write!(f, "rejected"), 148 + } 149 + } 150 + } 151 + 152 + impl FromStr for NodeStatus { 153 + type Err = anyhow::Error; 154 + 155 + fn from_str(s: &str) -> Result<Self> { 156 + match s.to_lowercase().as_str() { 157 + "pending" => Ok(NodeStatus::Pending), 158 + "active" => Ok(NodeStatus::Active), 159 + "completed" => Ok(NodeStatus::Completed), 160 + "cancelled" => Ok(NodeStatus::Cancelled), 161 + "ready" => Ok(NodeStatus::Ready), 162 + "claimed" => Ok(NodeStatus::Claimed), 163 + "in_progress" | "inprogress" => Ok(NodeStatus::InProgress), 164 + "review" => Ok(NodeStatus::Review), 165 + "blocked" => Ok(NodeStatus::Blocked), 166 + "failed" => Ok(NodeStatus::Failed), 167 + "decided" => Ok(NodeStatus::Decided), 168 + "superseded" => Ok(NodeStatus::Superseded), 169 + "abandoned" => Ok(NodeStatus::Abandoned), 170 + "chosen" => Ok(NodeStatus::Chosen), 171 + "rejected" => Ok(NodeStatus::Rejected), 172 + _ => Err(anyhow!("Unknown node status: {}", s)), 173 + } 174 + } 175 + } 176 + 177 + /// Priority level for a node 178 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 179 + #[serde(rename_all = "lowercase")] 180 + pub enum Priority { 181 + Critical, 182 + High, 183 + Medium, 184 + Low, 185 + } 186 + 187 + impl std::fmt::Display for Priority { 188 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 189 + match self { 190 + Priority::Critical => write!(f, "critical"), 191 + Priority::High => write!(f, "high"), 192 + Priority::Medium => write!(f, "medium"), 193 + Priority::Low => write!(f, "low"), 194 + } 195 + } 196 + } 197 + 198 + impl FromStr for Priority { 199 + type Err = anyhow::Error; 200 + 201 + fn from_str(s: &str) -> Result<Self> { 202 + match s.to_lowercase().as_str() { 203 + "critical" => Ok(Priority::Critical), 204 + "high" => Ok(Priority::High), 205 + "medium" => Ok(Priority::Medium), 206 + "low" => Ok(Priority::Low), 207 + _ => Err(anyhow!("Unknown priority: {}", s)), 208 + } 209 + } 210 + } 211 + 212 + /// A node in the work graph 213 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 214 + pub struct GraphNode { 215 + pub id: String, 216 + pub project_id: String, 217 + pub node_type: NodeType, 218 + pub title: String, 219 + pub description: String, 220 + pub status: NodeStatus, 221 + pub priority: Option<Priority>, 222 + pub assigned_to: Option<String>, 223 + pub created_by: Option<String>, 224 + pub labels: Vec<String>, 225 + pub created_at: DateTime<Utc>, 226 + pub started_at: Option<DateTime<Utc>>, 227 + pub completed_at: Option<DateTime<Utc>>, 228 + pub blocked_reason: Option<String>, 229 + pub metadata: HashMap<String, String>, 230 + } 231 + 232 + /// An edge connecting two nodes in the work graph 233 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 234 + pub struct GraphEdge { 235 + pub id: String, 236 + pub edge_type: EdgeType, 237 + pub from_node: String, 238 + pub to_node: String, 239 + pub label: Option<String>, 240 + pub created_at: DateTime<Utc>, 241 + } 242 + 243 + /// Returns the valid statuses for a given node type 244 + pub fn valid_statuses(node_type: &NodeType) -> Vec<NodeStatus> { 245 + match node_type { 246 + NodeType::Goal => vec![ 247 + NodeStatus::Pending, 248 + NodeStatus::Active, 249 + NodeStatus::Completed, 250 + NodeStatus::Cancelled, 251 + ], 252 + NodeType::Task => vec![ 253 + NodeStatus::Pending, 254 + NodeStatus::Ready, 255 + NodeStatus::Claimed, 256 + NodeStatus::InProgress, 257 + NodeStatus::Review, 258 + NodeStatus::Completed, 259 + NodeStatus::Blocked, 260 + NodeStatus::Failed, 261 + NodeStatus::Cancelled, 262 + ], 263 + NodeType::Decision => vec![ 264 + NodeStatus::Pending, 265 + NodeStatus::Active, 266 + NodeStatus::Decided, 267 + NodeStatus::Superseded, 268 + ], 269 + NodeType::Option => vec![ 270 + NodeStatus::Pending, 271 + NodeStatus::Active, 272 + NodeStatus::Chosen, 273 + NodeStatus::Rejected, 274 + NodeStatus::Abandoned, 275 + ], 276 + NodeType::Outcome => vec![NodeStatus::Active, NodeStatus::Completed], 277 + NodeType::Observation => vec![NodeStatus::Active], 278 + NodeType::Revisit => vec![NodeStatus::Active, NodeStatus::Completed], 279 + } 280 + } 281 + 282 + /// Validates that a status is valid for a given node type 283 + pub fn validate_status(node_type: &NodeType, status: &NodeStatus) -> Result<()> { 284 + if valid_statuses(node_type).contains(status) { 285 + Ok(()) 286 + } else { 287 + Err(anyhow!( 288 + "Status {:?} is not valid for node type {:?}", 289 + status, 290 + node_type 291 + )) 292 + } 293 + } 294 + 295 + /// Generate a goal ID (ra-xxxx where xxxx is 4 hex chars from UUID v4) 296 + pub fn generate_goal_id() -> String { 297 + format!("ra-{}", &uuid::Uuid::new_v4().simple().to_string()[..4]) 298 + } 299 + 300 + /// Generate a child ID from a parent ID and sequence number 301 + pub fn generate_child_id(parent_id: &str, seq: u32) -> String { 302 + format!("{}.{}", parent_id, seq) 303 + } 304 + 305 + /// Generate an edge ID (e-xxxxxxxx where xxxxxxxx is 8 hex chars from UUID v4) 306 + pub fn generate_edge_id() -> String { 307 + format!("e-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]) 308 + } 309 + 310 + /// Extract the parent ID from a hierarchical ID 311 + /// E.g., "ra-a3f8.1.3" -> Some("ra-a3f8.1") 312 + /// "ra-a3f8" -> None 313 + pub fn parent_id(id: &str) -> Option<&str> { 314 + if let Some(last_dot) = id.rfind('.') { 315 + Some(&id[..last_dot]) 316 + } else { 317 + None 318 + } 319 + } 320 + 321 + #[cfg(test)] 322 + mod tests { 323 + use super::*; 324 + 325 + #[test] 326 + fn test_node_type_display_and_fromstr() { 327 + let node_types = vec![ 328 + NodeType::Goal, 329 + NodeType::Task, 330 + NodeType::Decision, 331 + NodeType::Option, 332 + NodeType::Outcome, 333 + NodeType::Observation, 334 + NodeType::Revisit, 335 + ]; 336 + 337 + for nt in node_types { 338 + let s = nt.to_string(); 339 + let parsed: NodeType = s.parse().expect("Failed to parse NodeType"); 340 + assert_eq!(nt, parsed); 341 + } 342 + } 343 + 344 + #[test] 345 + fn test_edge_type_display_and_fromstr() { 346 + let edge_types = vec![ 347 + EdgeType::Contains, 348 + EdgeType::DependsOn, 349 + EdgeType::LeadsTo, 350 + EdgeType::Chosen, 351 + EdgeType::Rejected, 352 + EdgeType::Supersedes, 353 + EdgeType::Informs, 354 + ]; 355 + 356 + for et in edge_types { 357 + let s = et.to_string(); 358 + let parsed: EdgeType = s.parse().expect("Failed to parse EdgeType"); 359 + assert_eq!(et, parsed); 360 + } 361 + } 362 + 363 + #[test] 364 + fn test_node_status_display_and_fromstr() { 365 + let statuses = vec![ 366 + NodeStatus::Pending, 367 + NodeStatus::Active, 368 + NodeStatus::Completed, 369 + NodeStatus::Cancelled, 370 + NodeStatus::Ready, 371 + NodeStatus::Claimed, 372 + NodeStatus::InProgress, 373 + NodeStatus::Review, 374 + NodeStatus::Blocked, 375 + NodeStatus::Failed, 376 + NodeStatus::Decided, 377 + NodeStatus::Superseded, 378 + NodeStatus::Abandoned, 379 + NodeStatus::Chosen, 380 + NodeStatus::Rejected, 381 + ]; 382 + 383 + for status in statuses { 384 + let s = status.to_string(); 385 + let parsed: NodeStatus = s.parse().expect("Failed to parse NodeStatus"); 386 + assert_eq!(status, parsed); 387 + } 388 + } 389 + 390 + #[test] 391 + fn test_priority_display_and_fromstr() { 392 + let priorities = vec![ 393 + Priority::Critical, 394 + Priority::High, 395 + Priority::Medium, 396 + Priority::Low, 397 + ]; 398 + 399 + for priority in priorities { 400 + let s = priority.to_string(); 401 + let parsed: Priority = s.parse().expect("Failed to parse Priority"); 402 + assert_eq!(priority, parsed); 403 + } 404 + } 405 + 406 + #[test] 407 + fn test_valid_statuses_task() { 408 + let valid = valid_statuses(&NodeType::Task); 409 + assert!(valid.contains(&NodeStatus::Ready)); 410 + assert!(valid.contains(&NodeStatus::Claimed)); 411 + assert!(valid.contains(&NodeStatus::InProgress)); 412 + } 413 + 414 + #[test] 415 + fn test_valid_statuses_goal() { 416 + let valid = valid_statuses(&NodeType::Goal); 417 + assert!(valid.contains(&NodeStatus::Pending)); 418 + assert!(valid.contains(&NodeStatus::Active)); 419 + assert!(valid.contains(&NodeStatus::Completed)); 420 + assert!(!valid.contains(&NodeStatus::Ready)); 421 + } 422 + 423 + #[test] 424 + fn test_validate_status_valid() { 425 + let result = validate_status(&NodeType::Task, &NodeStatus::Ready); 426 + assert!(result.is_ok()); 427 + } 428 + 429 + #[test] 430 + fn test_validate_status_invalid() { 431 + let result = validate_status(&NodeType::Goal, &NodeStatus::Ready); 432 + assert!(result.is_err()); 433 + } 434 + 435 + #[test] 436 + fn test_generate_goal_id() { 437 + let id = generate_goal_id(); 438 + assert!(id.starts_with("ra-")); 439 + assert_eq!(id.len(), 7); // "ra-" + 4 hex chars 440 + } 441 + 442 + #[test] 443 + fn test_generate_child_id() { 444 + let child = generate_child_id("ra-a3f8", 1); 445 + assert_eq!(child, "ra-a3f8.1"); 446 + 447 + let grandchild = generate_child_id("ra-a3f8.1", 3); 448 + assert_eq!(grandchild, "ra-a3f8.1.3"); 449 + } 450 + 451 + #[test] 452 + fn test_generate_edge_id() { 453 + let id = generate_edge_id(); 454 + assert!(id.starts_with("e-")); 455 + assert_eq!(id.len(), 10); // "e-" + 8 hex chars 456 + } 457 + 458 + #[test] 459 + fn test_parent_id_extraction() { 460 + assert_eq!(parent_id("ra-a3f8.1.3"), Some("ra-a3f8.1")); 461 + assert_eq!(parent_id("ra-a3f8.1"), Some("ra-a3f8")); 462 + assert_eq!(parent_id("ra-a3f8"), None); 463 + } 464 + 465 + #[test] 466 + fn test_graph_node_serialization() { 467 + let node = GraphNode { 468 + id: "ra-a3f8".to_string(), 469 + project_id: "proj-1".to_string(), 470 + node_type: NodeType::Goal, 471 + title: "Test Goal".to_string(), 472 + description: "A test goal".to_string(), 473 + status: NodeStatus::Active, 474 + priority: Some(Priority::High), 475 + assigned_to: Some("user1".to_string()), 476 + created_by: Some("user2".to_string()), 477 + labels: vec!["label1".to_string()], 478 + created_at: Utc::now(), 479 + started_at: None, 480 + completed_at: None, 481 + blocked_reason: None, 482 + metadata: HashMap::new(), 483 + }; 484 + 485 + let json = serde_json::to_string(&node).expect("Failed to serialize"); 486 + let deserialized: GraphNode = serde_json::from_str(&json).expect("Failed to deserialize"); 487 + assert_eq!(node.id, deserialized.id); 488 + assert_eq!(node.title, deserialized.title); 489 + } 490 + 491 + #[test] 492 + fn test_graph_edge_serialization() { 493 + let edge = GraphEdge { 494 + id: "e-12345678".to_string(), 495 + edge_type: EdgeType::DependsOn, 496 + from_node: "ra-a3f8.1".to_string(), 497 + to_node: "ra-a3f8.2".to_string(), 498 + label: Some("blocks".to_string()), 499 + created_at: Utc::now(), 500 + }; 501 + 502 + let json = serde_json::to_string(&edge).expect("Failed to serialize"); 503 + let deserialized: GraphEdge = serde_json::from_str(&json).expect("Failed to deserialize"); 504 + assert_eq!(edge.id, deserialized.id); 505 + assert_eq!(edge.edge_type, deserialized.edge_type); 506 + } 507 + }
+448
src/graph/session.rs
··· 1 + use crate::db::Database; 2 + use crate::graph::store::SqliteGraphStore; 3 + use anyhow::{Result, anyhow}; 4 + use chrono::{DateTime, Utc}; 5 + use serde::{Deserialize, Serialize}; 6 + use tokio_rusqlite::OptionalExtension; 7 + use uuid::Uuid; 8 + 9 + /// A session represents a work period for a goal 10 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 11 + pub struct Session { 12 + pub id: String, 13 + pub project_id: String, 14 + pub goal_id: String, 15 + pub started_at: DateTime<Utc>, 16 + pub ended_at: Option<DateTime<Utc>>, 17 + pub handoff_notes: Option<String>, 18 + pub agent_ids: Vec<String>, 19 + pub summary: Option<String>, 20 + } 21 + 22 + /// Store for managing sessions 23 + pub struct SessionStore { 24 + db: Database, 25 + } 26 + 27 + impl SessionStore { 28 + /// Create a new SessionStore 29 + pub fn new(db: Database) -> Self { 30 + Self { db } 31 + } 32 + 33 + /// Create a new session for a goal 34 + pub async fn create_session(&self, project_id: &str, goal_id: &str) -> Result<Session> { 35 + let session_id = format!("sess-{}", &Uuid::new_v4().simple().to_string()[..8]); 36 + let now = Utc::now(); 37 + let now_rfc3339 = now.to_rfc3339(); 38 + let project_id_owned = project_id.to_string(); 39 + let goal_id_owned = goal_id.to_string(); 40 + let session_id_clone = session_id.clone(); 41 + 42 + self.db 43 + .connection() 44 + .call(move |conn| { 45 + conn.execute_batch("BEGIN IMMEDIATE") 46 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 47 + 48 + conn.execute( 49 + "INSERT INTO sessions (id, project_id, goal_id, started_at, agent_ids) 50 + VALUES (?, ?, ?, ?, ?)", 51 + rusqlite::params![ 52 + &session_id_clone, 53 + &project_id_owned, 54 + &goal_id_owned, 55 + &now_rfc3339, 56 + "[]" 57 + ], 58 + ) 59 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 60 + 61 + conn.execute_batch("COMMIT") 62 + .map_err(tokio_rusqlite::Error::Rusqlite) 63 + }) 64 + .await 65 + .map_err(|e| anyhow!("failed to insert session: {}", e))?; 66 + 67 + Ok(Session { 68 + id: session_id, 69 + project_id: project_id.to_string(), 70 + goal_id: goal_id.to_string(), 71 + started_at: now, 72 + ended_at: None, 73 + handoff_notes: None, 74 + agent_ids: vec![], 75 + summary: None, 76 + }) 77 + } 78 + 79 + /// End a session and generate handoff notes 80 + pub async fn end_session( 81 + &self, 82 + session_id: &str, 83 + _graph_store: &SqliteGraphStore, 84 + ) -> Result<()> { 85 + // First, get the session to find the goal_id 86 + let session = self 87 + .get_session(session_id) 88 + .await? 89 + .ok_or_else(|| anyhow!("session not found: {}", session_id))?; 90 + 91 + let goal_id = session.goal_id.clone(); 92 + let now = Utc::now(); 93 + let now_rfc3339 = now.to_rfc3339(); 94 + let session_id_owned = session_id.to_string(); 95 + 96 + // Generate handoff notes within a transaction 97 + self.db 98 + .connection() 99 + .call(move |conn| { 100 + conn.execute_batch("BEGIN IMMEDIATE") 101 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 102 + let notes = generate_handoff_notes(conn, &goal_id) 103 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 104 + conn.execute( 105 + "UPDATE sessions SET ended_at = ?, handoff_notes = ? WHERE id = ?", 106 + rusqlite::params![&now_rfc3339, &notes, &session_id_owned], 107 + ) 108 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 109 + conn.execute_batch("COMMIT") 110 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 111 + Ok::<(), tokio_rusqlite::Error>(()) 112 + }) 113 + .await 114 + .map_err(|e| anyhow!("database error: {}", e))?; 115 + 116 + Ok(()) 117 + } 118 + 119 + /// Get a session by ID 120 + pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>> { 121 + let session_id_owned = session_id.to_string(); 122 + 123 + let result = self 124 + .db 125 + .connection() 126 + .call(move |conn| { 127 + let mut stmt = conn.prepare( 128 + "SELECT id, project_id, goal_id, started_at, ended_at, handoff_notes, agent_ids, summary 129 + FROM sessions WHERE id = ?", 130 + )?; 131 + 132 + let session: Option<Session> = stmt 133 + .query_row([&session_id_owned], map_session_row) 134 + .optional()?; 135 + 136 + Ok(session) 137 + }) 138 + .await 139 + .map_err(|e| anyhow!("database error: {}", e))?; 140 + 141 + Ok(result) 142 + } 143 + 144 + /// Get the most recent session for a goal 145 + pub async fn get_latest_session(&self, goal_id: &str) -> Result<Option<Session>> { 146 + let goal_id_owned = goal_id.to_string(); 147 + 148 + let result = self 149 + .db 150 + .connection() 151 + .call(move |conn| { 152 + let mut stmt = conn.prepare( 153 + "SELECT id, project_id, goal_id, started_at, ended_at, handoff_notes, agent_ids, summary 154 + FROM sessions WHERE goal_id = ? 155 + ORDER BY started_at DESC LIMIT 1", 156 + )?; 157 + 158 + let session: Option<Session> = stmt 159 + .query_row([&goal_id_owned], map_session_row) 160 + .optional()?; 161 + 162 + Ok(session) 163 + }) 164 + .await 165 + .map_err(|e| anyhow!("database error: {}", e))?; 166 + 167 + Ok(result) 168 + } 169 + 170 + /// List all sessions for a goal 171 + pub async fn list_sessions(&self, goal_id: &str) -> Result<Vec<Session>> { 172 + let goal_id_owned = goal_id.to_string(); 173 + 174 + self.db 175 + .connection() 176 + .call(move |conn| { 177 + let mut stmt = conn.prepare( 178 + "SELECT id, project_id, goal_id, started_at, ended_at, handoff_notes, agent_ids, summary 179 + FROM sessions WHERE goal_id = ? 180 + ORDER BY started_at DESC", 181 + )?; 182 + 183 + let mut sessions = vec![]; 184 + let rows = stmt.query_map([&goal_id_owned], map_session_row)?; 185 + 186 + for session_result in rows { 187 + sessions.push(session_result?); 188 + } 189 + 190 + Ok(sessions) 191 + }) 192 + .await 193 + .map_err(|e| anyhow!("database error: {}", e)) 194 + } 195 + } 196 + 197 + /// Map a database row to a Session struct 198 + fn map_session_row(row: &rusqlite::Row) -> rusqlite::Result<Session> { 199 + let started_at_str: String = row.get(3)?; 200 + let started_at = chrono::DateTime::parse_from_rfc3339(&started_at_str) 201 + .ok() 202 + .map(|dt| dt.with_timezone(&Utc)) 203 + .ok_or(rusqlite::Error::InvalidQuery)?; 204 + 205 + let ended_at_str: Option<String> = row.get(4)?; 206 + let ended_at = ended_at_str.and_then(|s| { 207 + chrono::DateTime::parse_from_rfc3339(&s) 208 + .ok() 209 + .map(|dt| dt.with_timezone(&Utc)) 210 + }); 211 + 212 + let agent_ids_json: String = row.get(6)?; 213 + let agent_ids: Vec<String> = serde_json::from_str(&agent_ids_json).unwrap_or_default(); 214 + 215 + Ok(Session { 216 + id: row.get(0)?, 217 + project_id: row.get(1)?, 218 + goal_id: row.get(2)?, 219 + started_at, 220 + ended_at, 221 + handoff_notes: row.get(5)?, 222 + agent_ids, 223 + summary: row.get(7)?, 224 + }) 225 + } 226 + 227 + /// Generate handoff notes from the current graph state 228 + /// This runs synchronously within a transaction on the raw rusqlite connection 229 + fn generate_handoff_notes(conn: &rusqlite::Connection, goal_id: &str) -> rusqlite::Result<String> { 230 + let mut notes = String::new(); 231 + 232 + // Query all descendants of the goal 233 + let descendants = get_descendants(conn, goal_id)?; 234 + let descendant_ids: Vec<String> = descendants.iter().map(|d| d.0.clone()).collect(); 235 + 236 + if descendant_ids.is_empty() { 237 + // No descendants, return empty template 238 + notes.push_str("## Done\n\n"); 239 + notes.push_str("## Remaining\n\n"); 240 + notes.push_str("## Blocked\n\n"); 241 + notes.push_str("## Decisions Made\n\n"); 242 + return Ok(notes); 243 + } 244 + 245 + // Build placeholders for SQL IN clause 246 + let placeholders = descendant_ids 247 + .iter() 248 + .map(|_| "?") 249 + .collect::<Vec<_>>() 250 + .join(","); 251 + 252 + // Query for Done nodes (Completed or Decided) 253 + let done_query = format!( 254 + "SELECT id, title, status FROM nodes WHERE id IN ({}) 255 + AND (status = 'completed' OR status = 'decided') 256 + ORDER BY completed_at ASC, created_at ASC", 257 + placeholders 258 + ); 259 + notes.push_str("## Done\n"); 260 + let mut stmt = conn.prepare(&done_query)?; 261 + let done_nodes: Vec<_> = stmt 262 + .query_map( 263 + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), 264 + |row| { 265 + Ok(( 266 + row.get::<_, String>(0)?, 267 + row.get::<_, String>(1)?, 268 + row.get::<_, String>(2)?, 269 + )) 270 + }, 271 + )? 272 + .collect::<Result<Vec<_>, _>>()?; 273 + if done_nodes.is_empty() { 274 + notes.push_str("(none)\n"); 275 + } else { 276 + for (id, title, status) in done_nodes { 277 + notes.push_str(&format!("- {}: {} ({})\n", id, title, status)); 278 + } 279 + } 280 + notes.push('\n'); 281 + 282 + // Query for Remaining nodes (Ready, Pending, InProgress) 283 + let remaining_query = format!( 284 + "SELECT id, title, status FROM nodes WHERE id IN ({}) 285 + AND (status = 'ready' OR status = 'pending' OR status = 'in_progress') 286 + ORDER BY created_at ASC", 287 + placeholders 288 + ); 289 + notes.push_str("## Remaining\n"); 290 + let mut stmt = conn.prepare(&remaining_query)?; 291 + let remaining_nodes: Vec<_> = stmt 292 + .query_map( 293 + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), 294 + |row| { 295 + Ok(( 296 + row.get::<_, String>(0)?, 297 + row.get::<_, String>(1)?, 298 + row.get::<_, String>(2)?, 299 + )) 300 + }, 301 + )? 302 + .collect::<Result<Vec<_>, _>>()?; 303 + if remaining_nodes.is_empty() { 304 + notes.push_str("(none)\n"); 305 + } else { 306 + for (id, title, status) in remaining_nodes { 307 + notes.push_str(&format!("- {}: {} [{}]\n", id, title, status)); 308 + } 309 + } 310 + notes.push('\n'); 311 + 312 + // Query for Blocked nodes 313 + let blocked_query = format!( 314 + "SELECT id, title, blocked_reason FROM nodes WHERE id IN ({}) 315 + AND status = 'blocked' 316 + ORDER BY created_at ASC", 317 + placeholders 318 + ); 319 + notes.push_str("## Blocked\n"); 320 + let mut stmt = conn.prepare(&blocked_query)?; 321 + let blocked_nodes: Vec<_> = stmt 322 + .query_map( 323 + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), 324 + |row| { 325 + Ok(( 326 + row.get::<_, String>(0)?, 327 + row.get::<_, String>(1)?, 328 + row.get::<_, Option<String>>(2)?, 329 + )) 330 + }, 331 + )? 332 + .collect::<Result<Vec<_>, _>>()?; 333 + if blocked_nodes.is_empty() { 334 + notes.push_str("(none)\n"); 335 + } else { 336 + for (id, title, blocked_reason) in blocked_nodes { 337 + if let Some(reason) = blocked_reason { 338 + notes.push_str(&format!("- {}: {} — {}\n", id, title, reason)); 339 + } else { 340 + notes.push_str(&format!("- {}: {}\n", id, title)); 341 + } 342 + } 343 + } 344 + notes.push('\n'); 345 + 346 + // Query for Decisions Made (Decision nodes with Decided status) 347 + let decisions_query = format!( 348 + "SELECT id, title FROM nodes WHERE id IN ({}) 349 + AND node_type = 'decision' AND status = 'decided' 350 + ORDER BY created_at ASC", 351 + placeholders 352 + ); 353 + notes.push_str("## Decisions Made\n"); 354 + let mut stmt = conn.prepare(&decisions_query)?; 355 + let decision_nodes: Vec<_> = stmt 356 + .query_map( 357 + rusqlite::params_from_iter(descendant_ids.iter().map(|s| s.as_str())), 358 + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), 359 + )? 360 + .collect::<Result<Vec<_>, _>>()?; 361 + if decision_nodes.is_empty() { 362 + notes.push_str("(none)\n"); 363 + } else { 364 + for (decision_id, decision_title) in decision_nodes { 365 + // Find the chosen option 366 + let mut chosen_stmt = conn.prepare( 367 + "SELECT n.title, e.label FROM edges e 368 + JOIN nodes n ON e.to_node = n.id 369 + WHERE e.from_node = ? AND e.edge_type = 'chosen'", 370 + )?; 371 + 372 + let chosen_option: Option<(String, Option<String>)> = chosen_stmt 373 + .query_row([&decision_id], |row| { 374 + Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)) 375 + }) 376 + .optional()?; 377 + 378 + if let Some((option_title, rationale)) = chosen_option { 379 + if let Some(r) = rationale { 380 + notes.push_str(&format!( 381 + "- {}: {} → {} ({})\n", 382 + decision_id, decision_title, option_title, r 383 + )); 384 + } else { 385 + notes.push_str(&format!( 386 + "- {}: {} → {}\n", 387 + decision_id, decision_title, option_title 388 + )); 389 + } 390 + } else { 391 + notes.push_str(&format!("- {}: {}\n", decision_id, decision_title)); 392 + } 393 + } 394 + } 395 + notes.push('\n'); 396 + 397 + Ok(notes) 398 + } 399 + 400 + /// Get all descendants of a node via Contains edges (helper for handoff notes) 401 + fn get_descendants( 402 + conn: &rusqlite::Connection, 403 + parent_id: &str, 404 + ) -> rusqlite::Result<Vec<(String, String)>> { 405 + // Recursive CTE to get all descendants 406 + let query = " 407 + WITH RECURSIVE descendants AS ( 408 + SELECT id, node_type FROM nodes WHERE id = ? 409 + UNION ALL 410 + SELECT n.id, n.node_type FROM nodes n 411 + JOIN edges e ON n.id = e.to_node 412 + JOIN descendants d ON e.from_node = d.id 413 + WHERE e.edge_type = 'contains' 414 + ) 415 + SELECT id, node_type FROM descendants WHERE id != ? 416 + "; 417 + 418 + let mut stmt = conn.prepare(query)?; 419 + let descendants = stmt 420 + .query_map(rusqlite::params![parent_id, parent_id], |row| { 421 + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) 422 + })? 423 + .collect::<Result<Vec<_>, _>>()?; 424 + 425 + Ok(descendants) 426 + } 427 + 428 + #[cfg(test)] 429 + mod tests { 430 + use super::*; 431 + 432 + #[tokio::test] 433 + async fn test_session_creation() { 434 + // This test structure will be used in session_test.rs 435 + // Just verify Session struct can be created 436 + let session = Session { 437 + id: "sess-test".to_string(), 438 + project_id: "proj-1".to_string(), 439 + goal_id: "ra-1234".to_string(), 440 + started_at: Utc::now(), 441 + ended_at: None, 442 + handoff_notes: None, 443 + agent_ids: vec![], 444 + summary: None, 445 + }; 446 + assert_eq!(session.id, "sess-test"); 447 + } 448 + }
+1105
src/graph/store.rs
··· 1 + use crate::db::Database; 2 + use crate::graph::{GraphEdge, GraphNode, NodeStatus, NodeType, parent_id, validate_status}; 3 + use anyhow::{Context, Result}; 4 + use async_trait::async_trait; 5 + use chrono::Utc; 6 + use std::collections::HashMap; 7 + use tokio_rusqlite::OptionalExtension; 8 + 9 + /// Direction for edge queries 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 + pub enum EdgeDirection { 12 + /// Edges going out from a node (from_node = id) 13 + Outgoing, 14 + /// Edges coming in to a node (to_node = id) 15 + Incoming, 16 + /// Both directions 17 + Both, 18 + } 19 + 20 + /// A graph containing nodes and edges (used in history/full graph queries) 21 + #[derive(Debug, Clone)] 22 + pub struct WorkGraph { 23 + pub nodes: Vec<GraphNode>, 24 + pub edges: Vec<GraphEdge>, 25 + } 26 + 27 + /// Query builder for flexible node searches 28 + #[derive(Debug, Clone)] 29 + pub struct NodeQuery { 30 + pub node_type: Option<NodeType>, 31 + pub status: Option<NodeStatus>, 32 + pub project_id: Option<String>, 33 + pub parent_id: Option<String>, 34 + pub query: Option<String>, 35 + } 36 + 37 + /// The GraphStore trait defines all operations on the work graph. 38 + /// Implementations use BEGIN IMMEDIATE transactions for atomic writes. 39 + #[async_trait] 40 + pub trait GraphStore: Send + Sync { 41 + // ===== Node CRUD ===== 42 + 43 + /// Create a new node in the store 44 + async fn create_node(&self, node: &GraphNode) -> Result<()>; 45 + 46 + /// Update node fields. Only provided fields are updated. 47 + async fn update_node( 48 + &self, 49 + id: &str, 50 + status: Option<NodeStatus>, 51 + title: Option<&str>, 52 + description: Option<&str>, 53 + blocked_reason: Option<&str>, 54 + metadata: Option<&HashMap<String, String>>, 55 + ) -> Result<()>; 56 + 57 + /// Retrieve a node by ID 58 + async fn get_node(&self, id: &str) -> Result<Option<GraphNode>>; 59 + 60 + /// Query nodes with optional filters 61 + async fn query_nodes(&self, query: &NodeQuery) -> Result<Vec<GraphNode>>; 62 + 63 + // ===== Task-specific operations ===== 64 + 65 + /// Atomically claim a task (status Ready -> Claimed). 66 + /// Returns true if claimed, false if task was not in Ready state (another worker got it first). 67 + async fn claim_task(&self, node_id: &str, agent_id: &str) -> Result<bool>; 68 + 69 + /// Get all ready tasks under a goal 70 + async fn get_ready_tasks(&self, goal_id: &str) -> Result<Vec<GraphNode>>; 71 + 72 + /// Get the next recommended task: highest priority, break ties by downstream unblock count 73 + async fn get_next_task(&self, goal_id: &str) -> Result<Option<GraphNode>>; 74 + 75 + // ===== Edge operations ===== 76 + 77 + /// Add an edge connecting two nodes 78 + async fn add_edge(&self, edge: &GraphEdge) -> Result<()>; 79 + 80 + /// Remove an edge by ID 81 + async fn remove_edge(&self, edge_id: &str) -> Result<()>; 82 + 83 + /// Get edges involving a node in the specified direction, with the related nodes 84 + async fn get_edges( 85 + &self, 86 + node_id: &str, 87 + direction: EdgeDirection, 88 + ) -> Result<Vec<(GraphEdge, GraphNode)>>; 89 + 90 + // ===== Graph queries ===== 91 + 92 + /// Get immediate children of a node via Contains edges 93 + async fn get_children(&self, node_id: &str) 94 + -> Result<Vec<(GraphNode, crate::graph::EdgeType)>>; 95 + 96 + /// Get all descendants recursively via Contains edges 97 + async fn get_subtree(&self, node_id: &str) -> Result<Vec<GraphNode>>; 98 + 99 + /// Get active decisions for a project (decision_active or option_active, no abandoned/superseded) 100 + async fn get_active_decisions(&self, project_id: &str) -> Result<Vec<GraphNode>>; 101 + 102 + /// Get the full graph: goal + all descendants + all edges among them 103 + async fn get_full_graph(&self, goal_id: &str) -> Result<WorkGraph>; 104 + 105 + /// Full-text search nodes by query, optionally filtered by project and node_type 106 + async fn search_nodes( 107 + &self, 108 + query: &str, 109 + project_id: Option<&str>, 110 + node_type: Option<NodeType>, 111 + limit: usize, 112 + ) -> Result<Vec<GraphNode>>; 113 + 114 + // ===== Utility ===== 115 + 116 + /// Get the next child sequence number for a parent, atomically increment, and return old value 117 + async fn next_child_seq(&self, parent_id: &str) -> Result<u32>; 118 + } 119 + 120 + /// SQLite implementation of GraphStore 121 + pub struct SqliteGraphStore { 122 + db: Database, 123 + } 124 + 125 + impl SqliteGraphStore { 126 + /// Create a new SqliteGraphStore wrapping the given database 127 + pub fn new(db: Database) -> Self { 128 + Self { db } 129 + } 130 + 131 + /// Helper to convert a database row to a GraphNode 132 + fn row_to_node(row: &rusqlite::Row) -> rusqlite::Result<GraphNode> { 133 + let labels_json: String = row.get(10)?; 134 + let labels: Vec<String> = serde_json::from_str(&labels_json).unwrap_or_default(); 135 + 136 + let metadata_json: String = row.get(14)?; 137 + let metadata: HashMap<String, String> = 138 + serde_json::from_str(&metadata_json).unwrap_or_default(); 139 + 140 + let created_at_str: String = row.get(11)?; 141 + let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str) 142 + .ok() 143 + .map(|dt| dt.with_timezone(&Utc)) 144 + .unwrap_or_else(Utc::now); 145 + 146 + let started_at_str: Option<String> = row.get(12)?; 147 + let started_at = started_at_str.and_then(|s| { 148 + chrono::DateTime::parse_from_rfc3339(&s) 149 + .ok() 150 + .map(|dt| dt.with_timezone(&Utc)) 151 + }); 152 + 153 + let completed_at_str: Option<String> = row.get(13)?; 154 + let completed_at = completed_at_str.and_then(|s| { 155 + chrono::DateTime::parse_from_rfc3339(&s) 156 + .ok() 157 + .map(|dt| dt.with_timezone(&Utc)) 158 + }); 159 + 160 + let node_type_str: String = row.get(2)?; 161 + let node_type = node_type_str 162 + .parse() 163 + .map_err(|_| rusqlite::Error::InvalidParameterName("Invalid node_type".to_string()))?; 164 + 165 + let status_str: String = row.get(5)?; 166 + let status = status_str 167 + .parse() 168 + .map_err(|_| rusqlite::Error::InvalidParameterName("Invalid status".to_string()))?; 169 + 170 + Ok(GraphNode { 171 + id: row.get(0)?, 172 + project_id: row.get(1)?, 173 + node_type, 174 + title: row.get(3)?, 175 + description: row.get(4)?, 176 + status, 177 + priority: row 178 + .get::<_, Option<String>>(6)? 179 + .and_then(|p| p.parse().ok()), 180 + assigned_to: row.get(7)?, 181 + created_by: row.get(8)?, 182 + labels, 183 + created_at, 184 + started_at, 185 + completed_at, 186 + blocked_reason: row.get(9)?, 187 + metadata, 188 + }) 189 + } 190 + 191 + /// Helper to convert a database row to a GraphEdge 192 + fn row_to_edge(row: &rusqlite::Row) -> rusqlite::Result<GraphEdge> { 193 + let created_at_str: String = row.get(5)?; 194 + let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str) 195 + .ok() 196 + .map(|dt| dt.with_timezone(&Utc)) 197 + .unwrap_or_else(Utc::now); 198 + 199 + let edge_type_str: String = row.get(1)?; 200 + let edge_type = edge_type_str 201 + .parse() 202 + .map_err(|_| rusqlite::Error::InvalidParameterName("Invalid edge_type".to_string()))?; 203 + 204 + Ok(GraphEdge { 205 + id: row.get(0)?, 206 + edge_type, 207 + from_node: row.get(2)?, 208 + to_node: row.get(3)?, 209 + label: row.get(4)?, 210 + created_at, 211 + }) 212 + } 213 + } 214 + 215 + #[async_trait] 216 + impl GraphStore for SqliteGraphStore { 217 + async fn create_node(&self, node: &GraphNode) -> Result<()> { 218 + let labels_json = serde_json::to_string(&node.labels)?; 219 + let metadata_json = serde_json::to_string(&node.metadata)?; 220 + let created_at = node.created_at.to_rfc3339(); 221 + let started_at = node.started_at.map(|dt| dt.to_rfc3339()); 222 + let completed_at = node.completed_at.map(|dt| dt.to_rfc3339()); 223 + let priority = node.priority.map(|p| p.to_string()); 224 + let node_type_str = node.node_type.to_string(); 225 + let status_str = node.status.to_string(); 226 + 227 + let db = self.db.clone(); 228 + let node_id = node.id.clone(); 229 + let project_id = node.project_id.clone(); 230 + let title = node.title.clone(); 231 + let description = node.description.clone(); 232 + let assigned_to = node.assigned_to.clone(); 233 + let created_by = node.created_by.clone(); 234 + let blocked_reason = node.blocked_reason.clone(); 235 + 236 + db.connection() 237 + .call(move |conn| { 238 + let tx = 239 + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 240 + 241 + // Insert the node 242 + tx.execute( 243 + "INSERT INTO nodes ( 244 + id, project_id, node_type, title, description, status, 245 + priority, assigned_to, created_by, blocked_reason, 246 + labels, created_at, started_at, completed_at, metadata 247 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", 248 + rusqlite::params![ 249 + &node_id, 250 + &project_id, 251 + &node_type_str, 252 + &title, 253 + &description, 254 + &status_str, 255 + &priority, 256 + &assigned_to, 257 + &created_by, 258 + &blocked_reason, 259 + &labels_json, 260 + &created_at, 261 + &started_at, 262 + &completed_at, 263 + &metadata_json, 264 + ], 265 + )?; 266 + 267 + // If this is a child node (has a parent), create a Contains edge and update parent's next_child_seq 268 + if let Some(p_id) = parent_id(&node_id) { 269 + // Get current next_child_seq from parent metadata 270 + let current_seq: Option<String> = tx.query_row( 271 + "SELECT metadata FROM nodes WHERE id = ?1", 272 + rusqlite::params![p_id], 273 + |row| row.get(0), 274 + )?; 275 + 276 + let metadata: HashMap<String, String> = current_seq 277 + .and_then(|s| serde_json::from_str(&s).ok()) 278 + .unwrap_or_default(); 279 + 280 + let next_seq = metadata 281 + .get("next_child_seq") 282 + .and_then(|s| s.parse::<u32>().ok()) 283 + .unwrap_or(1); 284 + 285 + // Update parent's next_child_seq in metadata 286 + let mut new_metadata = metadata; 287 + new_metadata.insert("next_child_seq".to_string(), (next_seq + 1).to_string()); 288 + let new_metadata_json = serde_json::to_string(&new_metadata) 289 + .map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?; 290 + 291 + tx.execute( 292 + "UPDATE nodes SET metadata = ?1 WHERE id = ?2", 293 + rusqlite::params![&new_metadata_json, p_id], 294 + )?; 295 + 296 + // Create a Contains edge from parent to child 297 + let edge_id = crate::graph::generate_edge_id(); 298 + let now = Utc::now().to_rfc3339(); 299 + tx.execute( 300 + "INSERT INTO edges (id, edge_type, from_node, to_node, created_at) 301 + VALUES (?1, ?2, ?3, ?4, ?5)", 302 + rusqlite::params![&edge_id, "contains", p_id, &node_id, &now], 303 + )?; 304 + } 305 + 306 + tx.commit()?; 307 + Ok(()) 308 + }) 309 + .await?; 310 + 311 + Ok(()) 312 + } 313 + 314 + async fn update_node( 315 + &self, 316 + id: &str, 317 + status: Option<NodeStatus>, 318 + title: Option<&str>, 319 + description: Option<&str>, 320 + blocked_reason: Option<&str>, 321 + metadata: Option<&HashMap<String, String>>, 322 + ) -> Result<()> { 323 + let id = id.to_string(); 324 + let status_str = status.map(|s| s.to_string()); 325 + let title_owned = title.map(|t| t.to_string()); 326 + let description_owned = description.map(|d| d.to_string()); 327 + let blocked_reason_owned = blocked_reason.map(|r| r.to_string()); 328 + let metadata_json = metadata.map(serde_json::to_string).transpose()?; 329 + 330 + self.db 331 + .connection() 332 + .call(move |conn| { 333 + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 334 + 335 + // Validate status if provided (inside transaction to avoid TOCTOU) 336 + if let Some(s) = &status_str { 337 + // Get the node to determine its type 338 + let node_type_str: String = tx.query_row( 339 + "SELECT node_type FROM nodes WHERE id = ?1", 340 + rusqlite::params![&id], 341 + |row| row.get(0), 342 + )?; 343 + 344 + let node_type: NodeType = node_type_str.parse() 345 + .map_err(|_| rusqlite::Error::InvalidParameterName("Invalid node_type".to_string()))?; 346 + let new_status: NodeStatus = s.parse() 347 + .map_err(|_| rusqlite::Error::InvalidParameterName("Invalid status".to_string()))?; 348 + 349 + validate_status(&node_type, &new_status) 350 + .map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?; 351 + } 352 + 353 + // Build dynamic UPDATE statement 354 + let mut updates = Vec::new(); 355 + let mut params: Vec<&dyn rusqlite::ToSql> = Vec::new(); 356 + 357 + if let Some(s) = &status_str { 358 + updates.push("status = ?"); 359 + params.push(s); 360 + } 361 + if let Some(t) = &title_owned { 362 + updates.push("title = ?"); 363 + params.push(t); 364 + } 365 + if let Some(d) = &description_owned { 366 + updates.push("description = ?"); 367 + params.push(d); 368 + } 369 + if let Some(r) = &blocked_reason_owned { 370 + updates.push("blocked_reason = ?"); 371 + params.push(r); 372 + } 373 + if let Some(m) = &metadata_json { 374 + updates.push("metadata = ?"); 375 + params.push(m); 376 + } 377 + 378 + if updates.is_empty() { 379 + return Ok(()); 380 + } 381 + 382 + let sql = format!("UPDATE nodes SET {} WHERE id = ?", updates.join(", ")); 383 + params.push(&id); 384 + 385 + tx.execute(&sql, params.as_slice())?; 386 + 387 + // If status changed to Completed, check for dependent tasks to move to Ready 388 + if let Some(NodeStatus::Completed) = status { 389 + // Find all nodes that DependsOn this node 390 + let mut stmt = tx.prepare( 391 + "SELECT from_node FROM edges WHERE to_node = ?1 AND edge_type = 'depends_on'" 392 + )?; 393 + 394 + let dependent_ids: Vec<String> = stmt 395 + .query_map(rusqlite::params![&id], |row| row.get(0))? 396 + .collect::<Result<Vec<_>, _>>()?; 397 + 398 + for dependent_id in dependent_ids { 399 + // Check if all dependencies are now completed 400 + let all_deps_completed: bool = tx 401 + .query_row( 402 + "SELECT COUNT(*) = 0 FROM edges 403 + WHERE from_node = ?1 AND edge_type = 'depends_on' 404 + AND to_node NOT IN (SELECT id FROM nodes WHERE status = 'completed')", 405 + rusqlite::params![&dependent_id], 406 + |row| row.get(0), 407 + )?; 408 + 409 + if all_deps_completed { 410 + // Move this task to Ready if it's currently Pending 411 + tx.execute( 412 + "UPDATE nodes SET status = 'ready' 413 + WHERE id = ?1 AND status = 'pending'", 414 + rusqlite::params![&dependent_id], 415 + )?; 416 + } 417 + } 418 + } 419 + 420 + tx.commit()?; 421 + Ok(()) 422 + }) 423 + .await?; 424 + 425 + Ok(()) 426 + } 427 + 428 + async fn get_node(&self, id: &str) -> Result<Option<GraphNode>> { 429 + let id = id.to_string(); 430 + 431 + let result = self 432 + .db 433 + .connection() 434 + .call(move |conn| { 435 + let mut stmt = conn.prepare( 436 + "SELECT id, project_id, node_type, title, description, status, 437 + priority, assigned_to, created_by, blocked_reason, 438 + labels, created_at, started_at, completed_at, metadata 439 + FROM nodes WHERE id = ?1", 440 + )?; 441 + 442 + let node = stmt 443 + .query_row(rusqlite::params![&id], Self::row_to_node) 444 + .optional()?; 445 + 446 + Ok(node) 447 + }) 448 + .await?; 449 + 450 + Ok(result) 451 + } 452 + 453 + async fn query_nodes(&self, query: &NodeQuery) -> Result<Vec<GraphNode>> { 454 + let node_type = query.node_type; 455 + let status = query.status; 456 + let project_id = query.project_id.clone(); 457 + let parent_id_filter = query.parent_id.clone(); 458 + 459 + let result = self 460 + .db 461 + .connection() 462 + .call(move |conn| { 463 + let mut sql = "SELECT id, project_id, node_type, title, description, status, 464 + priority, assigned_to, created_by, blocked_reason, 465 + labels, created_at, started_at, completed_at, metadata 466 + FROM nodes WHERE 1=1".to_string(); 467 + 468 + let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); 469 + 470 + if let Some(nt) = node_type { 471 + sql.push_str(" AND node_type = ?"); 472 + params.push(Box::new(nt.to_string())); 473 + } 474 + if let Some(s) = status { 475 + sql.push_str(" AND status = ?"); 476 + params.push(Box::new(s.to_string())); 477 + } 478 + if let Some(pid) = &project_id { 479 + sql.push_str(" AND project_id = ?"); 480 + params.push(Box::new(pid.clone())); 481 + } 482 + if let Some(parent) = &parent_id_filter { 483 + // Find nodes whose parent is the given parent_id 484 + sql.push_str(" AND id IN (SELECT to_node FROM edges WHERE from_node = ? AND edge_type = 'contains')"); 485 + params.push(Box::new(parent.clone())); 486 + } 487 + 488 + let mut stmt = conn.prepare(&sql)?; 489 + let rows = stmt 490 + .query_map( 491 + rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())), 492 + Self::row_to_node, 493 + )? 494 + .collect::<Result<Vec<_>, _>>()?; 495 + 496 + Ok(rows) 497 + }) 498 + .await?; 499 + 500 + Ok(result) 501 + } 502 + 503 + async fn claim_task(&self, node_id: &str, agent_id: &str) -> Result<bool> { 504 + let node_id = node_id.to_string(); 505 + let agent_id = agent_id.to_string(); 506 + let now = Utc::now().to_rfc3339(); 507 + 508 + let claimed = self 509 + .db 510 + .connection() 511 + .call(move |conn| { 512 + let tx = 513 + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 514 + 515 + tx.execute( 516 + "UPDATE nodes SET status = 'claimed', assigned_to = ?1, started_at = ?2 517 + WHERE id = ?3 AND status = 'ready'", 518 + rusqlite::params![&agent_id, &now, &node_id], 519 + )?; 520 + 521 + let success = tx.changes() > 0; 522 + tx.commit()?; 523 + Ok(success) 524 + }) 525 + .await?; 526 + 527 + Ok(claimed) 528 + } 529 + 530 + async fn get_ready_tasks(&self, goal_id: &str) -> Result<Vec<GraphNode>> { 531 + let goal_id = goal_id.to_string(); 532 + 533 + let result = self 534 + .db 535 + .connection() 536 + .call(move |conn| { 537 + // Get all nodes under the goal that are Ready 538 + let mut stmt = conn.prepare( 539 + "WITH RECURSIVE subtree AS ( 540 + SELECT id FROM nodes WHERE id = ?1 541 + UNION ALL 542 + SELECT e.to_node FROM edges e 543 + JOIN subtree s ON e.from_node = s.id 544 + WHERE e.edge_type = 'contains' 545 + ) 546 + SELECT id, project_id, node_type, title, description, status, 547 + priority, assigned_to, created_by, blocked_reason, 548 + labels, created_at, started_at, completed_at, metadata 549 + FROM nodes 550 + WHERE id IN (SELECT id FROM subtree) 551 + AND status = 'ready' 552 + ORDER BY created_at", 553 + )?; 554 + 555 + let nodes = stmt 556 + .query_map(rusqlite::params![&goal_id], Self::row_to_node)? 557 + .collect::<Result<Vec<_>, _>>()?; 558 + 559 + Ok(nodes) 560 + }) 561 + .await?; 562 + 563 + Ok(result) 564 + } 565 + 566 + async fn get_next_task(&self, goal_id: &str) -> Result<Option<GraphNode>> { 567 + let goal_id = goal_id.to_string(); 568 + 569 + let result = self 570 + .db 571 + .connection() 572 + .call(move |conn| { 573 + let mut stmt = conn.prepare( 574 + "WITH RECURSIVE subtree AS ( 575 + SELECT id FROM nodes WHERE id = ?1 576 + UNION ALL 577 + SELECT e.to_node FROM edges e 578 + JOIN subtree s ON e.from_node = s.id 579 + WHERE e.edge_type = 'contains' 580 + ), 581 + ready_tasks AS ( 582 + SELECT id, priority FROM nodes 583 + WHERE id IN (SELECT id FROM subtree) 584 + AND status = 'ready' 585 + ), 586 + downstream_counts AS ( 587 + SELECT rt.id, COUNT(DISTINCT e.from_node) as downstream_count 588 + FROM ready_tasks rt 589 + LEFT JOIN edges e ON rt.id = e.to_node AND e.edge_type = 'depends_on' 590 + GROUP BY rt.id 591 + ) 592 + SELECT n.id, n.project_id, n.node_type, n.title, n.description, n.status, 593 + n.priority, n.assigned_to, n.created_by, n.blocked_reason, 594 + n.labels, n.created_at, n.started_at, n.completed_at, n.metadata 595 + FROM nodes n 596 + JOIN downstream_counts dc ON n.id = dc.id 597 + ORDER BY 598 + CASE WHEN n.priority = 'critical' THEN 0 599 + WHEN n.priority = 'high' THEN 1 600 + WHEN n.priority = 'medium' THEN 2 601 + WHEN n.priority = 'low' THEN 3 602 + ELSE 4 END, 603 + dc.downstream_count DESC, 604 + n.created_at ASC 605 + LIMIT 1", 606 + )?; 607 + 608 + let node = stmt 609 + .query_row(rusqlite::params![&goal_id], Self::row_to_node) 610 + .optional()?; 611 + 612 + Ok(node) 613 + }) 614 + .await?; 615 + 616 + Ok(result) 617 + } 618 + 619 + async fn add_edge(&self, edge: &GraphEdge) -> Result<()> { 620 + let edge_id = edge.id.clone(); 621 + let edge_type = edge.edge_type.to_string(); 622 + let from_node_id = edge.from_node.clone(); 623 + let to_node_id = edge.to_node.clone(); 624 + let label = edge.label.clone(); 625 + let created_at = edge.created_at.to_rfc3339(); 626 + 627 + self.db 628 + .connection() 629 + .call(move |conn| { 630 + let tx = 631 + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 632 + 633 + // Validate that both nodes exist (inside transaction) 634 + let from_exists: bool = tx 635 + .query_row( 636 + "SELECT COUNT(*) > 0 FROM nodes WHERE id = ?1", 637 + rusqlite::params![&from_node_id], 638 + |row| row.get(0), 639 + ) 640 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 641 + 642 + if !from_exists { 643 + return Err(tokio_rusqlite::Error::Rusqlite( 644 + rusqlite::Error::InvalidParameterName(format!( 645 + "from_node does not exist: {}", 646 + from_node_id 647 + )), 648 + )); 649 + } 650 + 651 + let to_exists: bool = tx 652 + .query_row( 653 + "SELECT COUNT(*) > 0 FROM nodes WHERE id = ?1", 654 + rusqlite::params![&to_node_id], 655 + |row| row.get(0), 656 + ) 657 + .map_err(tokio_rusqlite::Error::Rusqlite)?; 658 + 659 + if !to_exists { 660 + return Err(tokio_rusqlite::Error::Rusqlite( 661 + rusqlite::Error::InvalidParameterName(format!( 662 + "to_node does not exist: {}", 663 + to_node_id 664 + )), 665 + )); 666 + } 667 + 668 + tx.execute( 669 + "INSERT INTO edges (id, edge_type, from_node, to_node, label, created_at) 670 + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", 671 + rusqlite::params![ 672 + &edge_id, 673 + &edge_type, 674 + &from_node_id, 675 + &to_node_id, 676 + &label, 677 + &created_at 678 + ], 679 + )?; 680 + tx.commit()?; 681 + Ok(()) 682 + }) 683 + .await 684 + .context("Failed to add edge")?; 685 + 686 + Ok(()) 687 + } 688 + 689 + async fn remove_edge(&self, edge_id: &str) -> Result<()> { 690 + let edge_id = edge_id.to_string(); 691 + 692 + self.db 693 + .connection() 694 + .call(move |conn| { 695 + conn.execute( 696 + "DELETE FROM edges WHERE id = ?1", 697 + rusqlite::params![&edge_id], 698 + )?; 699 + Ok(()) 700 + }) 701 + .await?; 702 + 703 + Ok(()) 704 + } 705 + 706 + async fn get_edges( 707 + &self, 708 + node_id: &str, 709 + direction: EdgeDirection, 710 + ) -> Result<Vec<(GraphEdge, GraphNode)>> { 711 + let node_id = node_id.to_string(); 712 + 713 + let result = self 714 + .db 715 + .connection() 716 + .call(move |conn| { 717 + // Get edges first 718 + let mut edge_stmt = match direction { 719 + EdgeDirection::Outgoing => conn.prepare( 720 + "SELECT id, edge_type, from_node, to_node, label, created_at 721 + FROM edges WHERE from_node = ?1", 722 + )?, 723 + EdgeDirection::Incoming => conn.prepare( 724 + "SELECT id, edge_type, from_node, to_node, label, created_at 725 + FROM edges WHERE to_node = ?1", 726 + )?, 727 + EdgeDirection::Both => conn.prepare( 728 + "SELECT id, edge_type, from_node, to_node, label, created_at 729 + FROM edges WHERE from_node = ?1 OR to_node = ?1", 730 + )?, 731 + }; 732 + 733 + let edges: Vec<GraphEdge> = edge_stmt 734 + .query_map(rusqlite::params![&node_id], Self::row_to_edge)? 735 + .collect::<Result<Vec<_>, _>>()?; 736 + 737 + // For each edge, get the related node 738 + let mut pairs = Vec::new(); 739 + for edge in edges { 740 + let related_id = match direction { 741 + EdgeDirection::Outgoing => &edge.to_node, 742 + EdgeDirection::Incoming => &edge.from_node, 743 + EdgeDirection::Both => { 744 + if edge.from_node == node_id { 745 + &edge.to_node 746 + } else { 747 + &edge.from_node 748 + } 749 + } 750 + }; 751 + 752 + let node = conn.query_row( 753 + "SELECT id, project_id, node_type, title, description, status, 754 + priority, assigned_to, created_by, blocked_reason, 755 + labels, created_at, started_at, completed_at, metadata 756 + FROM nodes WHERE id = ?1", 757 + rusqlite::params![related_id], 758 + Self::row_to_node, 759 + )?; 760 + 761 + pairs.push((edge, node)); 762 + } 763 + 764 + Ok(pairs) 765 + }) 766 + .await?; 767 + 768 + Ok(result) 769 + } 770 + 771 + async fn get_children( 772 + &self, 773 + node_id: &str, 774 + ) -> Result<Vec<(GraphNode, crate::graph::EdgeType)>> { 775 + let node_id = node_id.to_string(); 776 + 777 + let result = self 778 + .db 779 + .connection() 780 + .call(move |conn| { 781 + let mut stmt = conn.prepare( 782 + "SELECT n.id, n.project_id, n.node_type, n.title, n.description, n.status, 783 + n.priority, n.assigned_to, n.created_by, n.blocked_reason, 784 + n.labels, n.created_at, n.started_at, n.completed_at, n.metadata, 785 + e.edge_type 786 + FROM nodes n 787 + JOIN edges e ON e.to_node = n.id 788 + WHERE e.from_node = ?1 AND e.edge_type = 'contains' 789 + ORDER BY n.created_at", 790 + )?; 791 + 792 + let children = stmt 793 + .query_map(rusqlite::params![&node_id], |row| { 794 + let node = Self::row_to_node(row)?; 795 + let edge_type_str: String = row.get(15)?; 796 + let edge_type = edge_type_str.parse().map_err(|_| { 797 + rusqlite::Error::InvalidParameterName("Invalid edge_type".to_string()) 798 + })?; 799 + Ok((node, edge_type)) 800 + })? 801 + .collect::<Result<Vec<_>, _>>()?; 802 + 803 + Ok(children) 804 + }) 805 + .await?; 806 + 807 + Ok(result) 808 + } 809 + 810 + async fn get_subtree(&self, node_id: &str) -> Result<Vec<GraphNode>> { 811 + let node_id = node_id.to_string(); 812 + 813 + let result = self 814 + .db 815 + .connection() 816 + .call(move |conn| { 817 + let mut stmt = conn.prepare( 818 + "WITH RECURSIVE subtree AS ( 819 + SELECT id FROM nodes WHERE id = ?1 820 + UNION ALL 821 + SELECT e.to_node FROM edges e 822 + JOIN subtree s ON e.from_node = s.id 823 + WHERE e.edge_type = 'contains' 824 + ) 825 + SELECT id, project_id, node_type, title, description, status, 826 + priority, assigned_to, created_by, blocked_reason, 827 + labels, created_at, started_at, completed_at, metadata 828 + FROM nodes 829 + WHERE id IN (SELECT id FROM subtree) 830 + ORDER BY created_at", 831 + )?; 832 + 833 + let nodes = stmt 834 + .query_map(rusqlite::params![&node_id], Self::row_to_node)? 835 + .collect::<Result<Vec<_>, _>>()?; 836 + 837 + Ok(nodes) 838 + }) 839 + .await?; 840 + 841 + Ok(result) 842 + } 843 + 844 + async fn get_active_decisions(&self, project_id: &str) -> Result<Vec<GraphNode>> { 845 + let project_id = project_id.to_string(); 846 + 847 + let result = self 848 + .db 849 + .connection() 850 + .call(move |conn| { 851 + let mut stmt = conn.prepare( 852 + "SELECT id, project_id, node_type, title, description, status, 853 + priority, assigned_to, created_by, blocked_reason, 854 + labels, created_at, started_at, completed_at, metadata 855 + FROM nodes 856 + WHERE project_id = ?1 857 + AND node_type = 'decision' 858 + AND (status = 'active' OR status = 'decided') 859 + ORDER BY created_at", 860 + )?; 861 + 862 + let nodes = stmt 863 + .query_map(rusqlite::params![&project_id], Self::row_to_node)? 864 + .collect::<Result<Vec<_>, _>>()?; 865 + 866 + Ok(nodes) 867 + }) 868 + .await?; 869 + 870 + Ok(result) 871 + } 872 + 873 + async fn get_full_graph(&self, goal_id: &str) -> Result<WorkGraph> { 874 + let goal_id = goal_id.to_string(); 875 + 876 + let result = self 877 + .db 878 + .connection() 879 + .call(move |conn| { 880 + // Get all nodes in the subtree 881 + let mut stmt = conn.prepare( 882 + "WITH RECURSIVE node_tree AS ( 883 + SELECT id FROM nodes WHERE id = ?1 884 + UNION ALL 885 + SELECT e.to_node FROM edges e 886 + JOIN node_tree nt ON e.from_node = nt.id 887 + WHERE e.edge_type = 'contains' 888 + ) 889 + SELECT id, project_id, node_type, title, description, status, 890 + priority, assigned_to, created_by, blocked_reason, 891 + labels, created_at, started_at, completed_at, metadata 892 + FROM nodes 893 + WHERE id IN (SELECT id FROM node_tree) 894 + ORDER BY created_at", 895 + )?; 896 + 897 + let nodes = stmt 898 + .query_map(rusqlite::params![&goal_id], Self::row_to_node)? 899 + .collect::<Result<Vec<_>, _>>()?; 900 + 901 + // Get all edges between nodes in the subtree 902 + let mut stmt = conn.prepare( 903 + "WITH RECURSIVE node_tree AS ( 904 + SELECT id FROM nodes WHERE id = ?1 905 + UNION ALL 906 + SELECT e.to_node FROM edges e 907 + JOIN node_tree nt ON e.from_node = nt.id 908 + WHERE e.edge_type = 'contains' 909 + ) 910 + SELECT e.id, e.edge_type, e.from_node, e.to_node, e.label, e.created_at 911 + FROM edges e 912 + WHERE e.from_node IN (SELECT id FROM node_tree) 913 + OR e.to_node IN (SELECT id FROM node_tree)", 914 + )?; 915 + 916 + let edges = stmt 917 + .query_map(rusqlite::params![&goal_id], Self::row_to_edge)? 918 + .collect::<Result<Vec<_>, _>>()?; 919 + 920 + Ok(WorkGraph { nodes, edges }) 921 + }) 922 + .await?; 923 + 924 + Ok(result) 925 + } 926 + 927 + async fn search_nodes( 928 + &self, 929 + query: &str, 930 + project_id: Option<&str>, 931 + node_type: Option<NodeType>, 932 + limit: usize, 933 + ) -> Result<Vec<GraphNode>> { 934 + let query_str = query.to_string(); 935 + let project_id_filter = project_id.map(|p| p.to_string()); 936 + let node_type_filter = node_type.map(|nt| nt.to_string()); 937 + let limit_val = std::cmp::max(limit, 1); 938 + 939 + let result = self 940 + .db 941 + .connection() 942 + .call(move |conn| { 943 + let mut sql = "SELECT n.id, n.project_id, n.node_type, n.title, n.description, n.status, 944 + n.priority, n.assigned_to, n.created_by, n.blocked_reason, 945 + n.labels, n.created_at, n.started_at, n.completed_at, n.metadata 946 + FROM nodes n 947 + JOIN nodes_fts fts ON n.rowid = fts.rowid 948 + WHERE nodes_fts MATCH ?1" 949 + .to_string(); 950 + 951 + let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(query_str)]; 952 + 953 + if let Some(pid) = &project_id_filter { 954 + sql.push_str(" AND n.project_id = ?"); 955 + params.push(Box::new(pid.clone())); 956 + } 957 + if let Some(nt) = &node_type_filter { 958 + sql.push_str(" AND n.node_type = ?"); 959 + params.push(Box::new(nt.clone())); 960 + } 961 + 962 + sql.push_str(&format!(" LIMIT {}", limit_val)); 963 + 964 + let mut stmt = conn.prepare(&sql)?; 965 + let nodes = stmt 966 + .query_map( 967 + rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())), 968 + Self::row_to_node, 969 + )? 970 + .collect::<Result<Vec<_>, _>>()?; 971 + 972 + Ok(nodes) 973 + }) 974 + .await?; 975 + 976 + Ok(result) 977 + } 978 + 979 + async fn next_child_seq(&self, parent_id: &str) -> Result<u32> { 980 + let parent_id = parent_id.to_string(); 981 + 982 + let seq = self 983 + .db 984 + .connection() 985 + .call(move |conn| { 986 + let tx = 987 + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 988 + 989 + // Get current metadata 990 + let metadata_json: String = tx.query_row( 991 + "SELECT metadata FROM nodes WHERE id = ?1", 992 + rusqlite::params![&parent_id], 993 + |row| row.get(0), 994 + )?; 995 + 996 + let mut metadata: HashMap<String, String> = 997 + serde_json::from_str(&metadata_json).unwrap_or_default(); 998 + 999 + let current_seq = metadata 1000 + .get("next_child_seq") 1001 + .and_then(|s| s.parse::<u32>().ok()) 1002 + .unwrap_or(1); 1003 + 1004 + // Update to next value 1005 + metadata.insert("next_child_seq".to_string(), (current_seq + 1).to_string()); 1006 + let new_metadata_json = serde_json::to_string(&metadata) 1007 + .map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?; 1008 + 1009 + tx.execute( 1010 + "UPDATE nodes SET metadata = ?1 WHERE id = ?2", 1011 + rusqlite::params![&new_metadata_json, &parent_id], 1012 + )?; 1013 + 1014 + tx.commit()?; 1015 + Ok(current_seq) 1016 + }) 1017 + .await?; 1018 + 1019 + Ok(seq) 1020 + } 1021 + } 1022 + 1023 + impl SqliteGraphStore { 1024 + /// Import nodes and edges in a single BEGIN IMMEDIATE transaction 1025 + /// This ensures atomic import: either all succeed or all fail 1026 + pub async fn import_nodes_and_edges( 1027 + &self, 1028 + nodes: Vec<GraphNode>, 1029 + edges: Vec<GraphEdge>, 1030 + ) -> Result<()> { 1031 + let db = self.db.clone(); 1032 + 1033 + db.connection() 1034 + .call(move |conn| { 1035 + let tx = 1036 + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 1037 + 1038 + // Insert all nodes (skip if they already exist) 1039 + // Note: We don't recreate parent-child edges here because they should be 1040 + // explicitly included in the edges vector and will be inserted separately 1041 + for node in &nodes { 1042 + let labels_json = serde_json::to_string(&node.labels) 1043 + .map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?; 1044 + let metadata_json = serde_json::to_string(&node.metadata) 1045 + .map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?; 1046 + let created_at = node.created_at.to_rfc3339(); 1047 + let started_at = node.started_at.map(|dt| dt.to_rfc3339()); 1048 + let completed_at = node.completed_at.map(|dt| dt.to_rfc3339()); 1049 + let priority = node.priority.map(|p| p.to_string()); 1050 + let node_type_str = node.node_type.to_string(); 1051 + let status_str = node.status.to_string(); 1052 + 1053 + tx.execute( 1054 + "INSERT OR IGNORE INTO nodes ( 1055 + id, project_id, node_type, title, description, status, 1056 + priority, assigned_to, created_by, blocked_reason, 1057 + labels, created_at, started_at, completed_at, metadata 1058 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", 1059 + rusqlite::params![ 1060 + &node.id, 1061 + &node.project_id, 1062 + &node_type_str, 1063 + &node.title, 1064 + &node.description, 1065 + &status_str, 1066 + &priority, 1067 + &node.assigned_to, 1068 + &node.created_by, 1069 + &node.blocked_reason, 1070 + &labels_json, 1071 + &created_at, 1072 + &started_at, 1073 + &completed_at, 1074 + &metadata_json, 1075 + ], 1076 + )?; 1077 + } 1078 + 1079 + // Insert all edges (ignore if already exist) 1080 + for edge in &edges { 1081 + let edge_type_str = edge.edge_type.to_string(); 1082 + let created_at = edge.created_at.to_rfc3339(); 1083 + 1084 + tx.execute( 1085 + "INSERT OR IGNORE INTO edges (id, edge_type, from_node, to_node, label, created_at) 1086 + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", 1087 + rusqlite::params![ 1088 + &edge.id, 1089 + &edge_type_str, 1090 + &edge.from_node, 1091 + &edge.to_node, 1092 + &edge.label, 1093 + &created_at, 1094 + ], 1095 + )?; 1096 + } 1097 + 1098 + tx.commit()?; 1099 + Ok::<(), tokio_rusqlite::Error>(()) 1100 + }) 1101 + .await?; 1102 + 1103 + Ok(()) 1104 + } 1105 + }
+5 -1
src/lib.rs
··· 1 + pub mod agent; 1 2 pub mod config; 3 + pub mod context; 4 + pub mod db; 5 + pub mod graph; 2 6 pub mod llm; 3 7 pub mod logging; 4 8 pub mod planning; 9 + pub mod project; 5 10 pub mod ralph; 6 11 pub mod security; 7 12 pub mod spec; 8 13 pub mod tools; 9 - pub mod tui;
+12
src/llm/anthropic.rs
··· 46 46 struct AnthropicResponse { 47 47 content: Vec<ContentBlock>, 48 48 stop_reason: Option<String>, 49 + #[serde(default)] 50 + usage: UsageInfo, 51 + } 52 + 53 + #[derive(Debug, Deserialize, Default)] 54 + struct UsageInfo { 55 + #[serde(default)] 56 + input_tokens: usize, 57 + #[serde(default)] 58 + output_tokens: usize, 49 59 } 50 60 51 61 #[derive(Debug, Deserialize)] ··· 191 201 Ok(Response { 192 202 content, 193 203 stop_reason: anthropic_response.stop_reason, 204 + input_tokens: Some(anthropic_response.usage.input_tokens), 205 + output_tokens: Some(anthropic_response.usage.output_tokens), 194 206 }) 195 207 } 196 208 }
+28 -13
src/llm/mock.rs
··· 4 4 use std::sync::{Arc, Mutex}; 5 5 6 6 type RecordedCalls = Vec<(Vec<Message>, Vec<ToolDefinition>)>; 7 + type MockResponseQueue = VecDeque<(ResponseContent, Option<String>)>; 7 8 8 9 pub struct MockLlmClient { 9 - responses: Arc<Mutex<VecDeque<Response>>>, 10 + responses: Arc<Mutex<MockResponseQueue>>, 10 11 recorded_calls: Arc<Mutex<RecordedCalls>>, 12 + token_counts: Arc<Mutex<Option<(usize, usize)>>>, // (input_tokens, output_tokens) 11 13 } 12 14 13 15 impl MockLlmClient { ··· 15 17 Self { 16 18 responses: Arc::new(Mutex::new(VecDeque::new())), 17 19 recorded_calls: Arc::new(Mutex::new(Vec::new())), 20 + token_counts: Arc::new(Mutex::new(None)), 18 21 } 19 22 } 20 23 21 24 pub fn queue_text_response(&self, text: &str) { 22 - let response = Response { 23 - content: ResponseContent::Text(text.to_string()), 24 - stop_reason: Some("end_turn".to_string()), 25 - }; 26 - self.responses.lock().unwrap().push_back(response); 25 + self.responses.lock().unwrap().push_back(( 26 + ResponseContent::Text(text.to_string()), 27 + Some("end_turn".to_string()), 28 + )); 27 29 } 28 30 29 31 pub fn queue_tool_call(&self, name: &str, params: serde_json::Value) { 30 - let response = Response { 31 - content: ResponseContent::ToolCalls(vec![ToolCall { 32 + self.responses.lock().unwrap().push_back(( 33 + ResponseContent::ToolCalls(vec![ToolCall { 32 34 id: format!("call_{}", uuid::Uuid::new_v4()), 33 35 name: name.to_string(), 34 36 parameters: params, 35 37 }]), 36 - stop_reason: Some("tool_use".to_string()), 37 - }; 38 - self.responses.lock().unwrap().push_back(response); 38 + Some("tool_use".to_string()), 39 + )); 40 + } 41 + 42 + pub fn set_token_counts(&self, input: usize, output: usize) { 43 + *self.token_counts.lock().unwrap() = Some((input, output)); 39 44 } 40 45 41 46 pub fn get_recorded_calls(&self) -> Vec<(Vec<Message>, Vec<ToolDefinition>)> { ··· 61 66 .unwrap() 62 67 .push((messages, tools.to_vec())); 63 68 64 - self.responses 69 + let (content, stop_reason) = self 70 + .responses 65 71 .lock() 66 72 .unwrap() 67 73 .pop_front() 68 - .ok_or_else(|| anyhow::anyhow!("No more mock responses queued")) 74 + .ok_or_else(|| anyhow::anyhow!("No more mock responses queued"))?; 75 + 76 + let token_counts = *self.token_counts.lock().unwrap(); 77 + 78 + Ok(Response { 79 + content, 80 + stop_reason, 81 + input_tokens: token_counts.map(|(i, _)| i), 82 + output_tokens: token_counts.map(|(_, o)| o), 83 + }) 69 84 } 70 85 }
+4 -2
src/llm/mod.rs
··· 64 64 } 65 65 66 66 /// A tool call requested by the LLM 67 - #[derive(Debug, Clone, Serialize, Deserialize)] 67 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 68 68 pub struct ToolCall { 69 69 pub id: String, 70 70 pub name: String, ··· 79 79 } 80 80 81 81 /// Content of a response from the LLM 82 - #[derive(Debug, Clone, Serialize, Deserialize)] 82 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 83 83 #[serde(untagged)] 84 84 pub enum ResponseContent { 85 85 Text(String), ··· 91 91 pub struct Response { 92 92 pub content: ResponseContent, 93 93 pub stop_reason: Option<String>, 94 + pub input_tokens: Option<usize>, 95 + pub output_tokens: Option<usize>, 94 96 } 95 97 96 98 /// Trait for LLM client implementations
+14
src/llm/ollama.rs
··· 50 50 #[allow(dead_code)] 51 51 done: bool, 52 52 done_reason: Option<String>, 53 + #[serde(default)] 54 + prompt_eval_count: usize, 55 + #[serde(default)] 56 + eval_count: usize, 53 57 } 54 58 55 59 #[derive(Debug, Deserialize)] ··· 181 185 Ok(Response { 182 186 content, 183 187 stop_reason: ollama_response.done_reason, 188 + input_tokens: if ollama_response.prompt_eval_count > 0 { 189 + Some(ollama_response.prompt_eval_count) 190 + } else { 191 + None 192 + }, 193 + output_tokens: if ollama_response.eval_count > 0 { 194 + Some(ollama_response.eval_count) 195 + } else { 196 + None 197 + }, 184 198 }) 185 199 } 186 200 }
+12
src/llm/openai.rs
··· 51 51 #[derive(Debug, Deserialize)] 52 52 struct OpenAiResponse { 53 53 choices: Vec<OpenAiChoice>, 54 + #[serde(default)] 55 + usage: OpenAiUsage, 56 + } 57 + 58 + #[derive(Debug, Deserialize, Default)] 59 + struct OpenAiUsage { 60 + #[serde(default)] 61 + prompt_tokens: usize, 62 + #[serde(default)] 63 + completion_tokens: usize, 54 64 } 55 65 56 66 #[derive(Debug, Deserialize)] ··· 191 201 Ok(Response { 192 202 content, 193 203 stop_reason: choice.finish_reason.clone(), 204 + input_tokens: Some(openai_response.usage.prompt_tokens), 205 + output_tokens: Some(openai_response.usage.completion_tokens), 194 206 }) 195 207 } 196 208 }
+809 -25
src/main.rs
··· 1 - use rustagent::{config, logging, planning, ralph}; 1 + use rustagent::graph::store::GraphStore; 2 + use rustagent::{config, db, logging, planning, project}; 2 3 3 - use clap::{Parser, Subcommand}; 4 + use clap::{CommandFactory, Parser, Subcommand}; 4 5 use std::path::PathBuf; 5 6 6 7 #[derive(Parser)] 7 8 #[command(name = "rustagent")] 8 9 #[command(about = "A Rust-based AI agent for task execution", long_about = None)] 9 10 struct Cli { 11 + /// Project name (if omitted, resolves from current directory) 12 + #[arg(long, global = true)] 13 + project: Option<String>, 14 + 10 15 #[command(subcommand)] 11 16 command: Option<Commands>, 12 17 } ··· 25 30 #[arg(long)] 26 31 spec_dir: Option<String>, 27 32 }, 28 - /// Execute a plan 33 + /// Execute a goal with an agent 29 34 Run { 30 - /// Path to the specification file 31 - spec_file: String, 32 - /// Maximum number of iterations 35 + /// Goal description 36 + goal: String, 37 + /// Agent profile to use 38 + #[arg(long, default_value = "coder")] 39 + profile: String, 40 + /// Maximum iterations 33 41 #[arg(long)] 34 42 max_iterations: Option<usize>, 35 43 }, 36 - /// Launch interactive TUI 37 - Tui, 44 + /// Manage projects 45 + Project { 46 + #[command(subcommand)] 47 + action: ProjectAction, 48 + }, 49 + /// View and manage tasks 50 + Tasks { 51 + #[command(subcommand)] 52 + action: Option<TaskAction>, 53 + }, 54 + /// View and manage decisions 55 + Decisions { 56 + #[command(subcommand)] 57 + action: Option<DecisionAction>, 58 + }, 59 + /// Show project status 60 + Status, 61 + /// Search graph nodes 62 + Search { 63 + /// Search query 64 + query: String, 65 + }, 66 + /// View sessions and handoff notes 67 + Sessions { 68 + #[command(subcommand)] 69 + action: Option<SessionAction>, 70 + }, 71 + /// Import/export graph data 72 + Graph { 73 + #[command(subcommand)] 74 + action: GraphAction, 75 + }, 76 + } 77 + 78 + #[derive(Subcommand)] 79 + enum ProjectAction { 80 + /// Register a project 81 + Add { 82 + /// Friendly name for the project 83 + name: String, 84 + /// Path to the project directory 85 + path: String, 86 + }, 87 + /// List all registered projects 88 + List, 89 + /// Show project details 90 + Show { 91 + /// Project name 92 + name: String, 93 + }, 94 + /// Remove a registered project 95 + Remove { 96 + /// Project name 97 + name: String, 98 + }, 99 + } 100 + 101 + #[derive(Subcommand)] 102 + enum TaskAction { 103 + /// List all tasks (filterable) 104 + List { 105 + #[arg(long)] 106 + status: Option<String>, 107 + }, 108 + /// Show ready tasks 109 + Ready, 110 + /// Recommend next task 111 + Next, 112 + /// Show task tree 113 + Tree, 114 + } 115 + 116 + #[derive(Subcommand)] 117 + enum DecisionAction { 118 + /// List active decisions 119 + List, 120 + /// Current truth — active decisions only 121 + Now, 122 + /// Full evolution including abandoned paths 123 + History, 124 + /// Show decision details 125 + Show { id: String }, 126 + /// Export decisions as ADR markdown files 127 + Export { 128 + /// Output directory for markdown files 129 + #[arg(long)] 130 + output: Option<String>, 131 + }, 132 + } 133 + 134 + #[derive(Subcommand)] 135 + enum SessionAction { 136 + /// List sessions for current goal 137 + List { 138 + /// Goal ID (if omitted, uses current goal context) 139 + #[arg(long)] 140 + goal: Option<String>, 141 + }, 142 + /// Show most recent handoff notes 143 + Latest { 144 + /// Goal ID (if omitted, uses current goal context) 145 + #[arg(long)] 146 + goal: Option<String>, 147 + }, 148 + } 149 + 150 + #[derive(Subcommand)] 151 + enum GraphAction { 152 + /// Export goals to TOML files 153 + Export { 154 + /// Goal ID (if omitted, exports all goals) 155 + #[arg(long)] 156 + goal: Option<String>, 157 + /// Output directory 158 + #[arg(long)] 159 + output: Option<String>, 160 + }, 161 + /// Import TOML files 162 + Import { 163 + /// Path to TOML file 164 + path: String, 165 + /// Show changes without applying 166 + #[arg(long)] 167 + dry_run: bool, 168 + /// Use file version in conflicts 169 + #[arg(long)] 170 + theirs: bool, 171 + /// Keep database version in conflicts 172 + #[arg(long)] 173 + ours: bool, 174 + }, 175 + /// Diff TOML file against DB 176 + Diff { 177 + /// Path to TOML file 178 + path: String, 179 + }, 38 180 } 39 181 40 182 /// Find config file in standard locations ··· 66 208 ) 67 209 } 68 210 211 + /// Get the database path in XDG data directory 212 + fn db_path() -> anyhow::Result<PathBuf> { 213 + let data_dir = dirs::data_dir() 214 + .ok_or_else(|| anyhow::anyhow!("Could not determine XDG data directory"))?; 215 + let db_dir = data_dir.join("rustagent"); 216 + std::fs::create_dir_all(&db_dir)?; 217 + Ok(db_dir.join("rustagent.db")) 218 + } 219 + 220 + /// Resolve project from --project flag or current working directory 221 + async fn resolve_project( 222 + db: &db::Database, 223 + project_name: Option<&str>, 224 + ) -> anyhow::Result<Option<project::Project>> { 225 + let store = project::ProjectStore::new(db.clone()); 226 + 227 + if let Some(name) = project_name { 228 + // Look up by name 229 + store.get_by_name(name).await 230 + } else { 231 + // Look up by current working directory 232 + let cwd = std::env::current_dir()?; 233 + store.get_by_path(&cwd).await 234 + } 235 + } 236 + 69 237 #[tokio::main] 70 238 async fn main() -> anyhow::Result<()> { 71 239 let _log_guard = logging::init_logging()?; 72 240 73 241 let cli = Cli::parse(); 74 242 75 - // Default to TUI if no command specified 76 - let command = cli.command.unwrap_or(Commands::Tui); 243 + // If no command specified, print help 244 + let Some(command) = cli.command else { 245 + Cli::command().print_help()?; 246 + return Ok(()); 247 + }; 77 248 78 249 match command { 79 250 Commands::Init { spec_dir } => { ··· 118 289 agent.run().await?; 119 290 } 120 291 Commands::Run { 121 - spec_file, 292 + goal, 293 + profile, 122 294 max_iterations, 123 295 } => { 124 296 // Load config from standard locations 125 297 let config_path = find_config_path()?; 126 298 let config = config::Config::load(&config_path)?; 127 299 128 - // Create and run Ralph loop 129 - let ralph = ralph::RalphLoop::new(config, spec_file.clone(), max_iterations)?; 130 - ralph.run().await?; 300 + // Open database 301 + let db_path = db_path()?; 302 + let database = db::Database::open(&db_path).await?; 303 + 304 + // Resolve project 305 + let project_opt = resolve_project(&database, cli.project.as_deref()).await?; 306 + let project = project_opt.ok_or_else(|| { 307 + anyhow::anyhow!("No project specified or found in current directory") 308 + })?; 309 + 310 + // Create goal node in graph 311 + let graph_store = std::sync::Arc::new(rustagent::graph::store::SqliteGraphStore::new( 312 + database.clone(), 313 + )); 314 + 315 + let goal_id = rustagent::graph::generate_goal_id(); 316 + let goal_node = rustagent::graph::GraphNode { 317 + id: goal_id.clone(), 318 + project_id: project.id.clone(), 319 + node_type: rustagent::graph::NodeType::Goal, 320 + title: goal.clone(), 321 + description: goal.clone(), 322 + status: rustagent::graph::NodeStatus::Active, 323 + priority: None, 324 + assigned_to: None, 325 + created_by: None, 326 + labels: vec!["agent_run".to_string()], 327 + created_at: chrono::Utc::now(), 328 + started_at: Some(chrono::Utc::now()), 329 + completed_at: None, 330 + blocked_reason: None, 331 + metadata: std::collections::HashMap::new(), 332 + }; 333 + 334 + graph_store.create_node(&goal_node).await?; 335 + println!("Created goal: {} ({})", goal, goal_id); 336 + 337 + // Create session 338 + let session_store = rustagent::graph::session::SessionStore::new(database.clone()); 339 + let session = session_store.create_session(&project.id, &goal_id).await?; 340 + println!("Started session: {}", session.id); 341 + 342 + // Resolve profile 343 + let resolved_profile = 344 + rustagent::agent::profile::resolve_profile(&profile, Some(&project.path))?; 345 + 346 + // Build AgentContext 347 + let agents_md_summaries = 348 + rustagent::context::resolve_agents_md(&project.path, &[]).unwrap_or_default(); 349 + 350 + let ctx = rustagent::agent::AgentContext { 351 + work_package_tasks: vec![goal_node], 352 + relevant_decisions: vec![], 353 + handoff_notes: session.handoff_notes.clone(), 354 + agents_md_summaries, 355 + profile: resolved_profile.clone(), 356 + project_path: project.path.clone(), 357 + graph_store: graph_store.clone(), 358 + }; 359 + 360 + // Create LLM client 361 + let llm_client = rustagent::llm::factory::create_client(&config, &config.llm)?; 362 + 363 + // Create tool registry 364 + let security_validator = std::sync::Arc::new( 365 + rustagent::security::SecurityValidator::new(config.security.clone())?, 366 + ); 367 + let permission_handler = 368 + std::sync::Arc::new(rustagent::security::permission::CliPermissionHandler {}); 369 + 370 + let tool_registry = rustagent::tools::factory::create_v2_registry( 371 + security_validator, 372 + permission_handler, 373 + graph_store.clone(), 374 + ); 375 + 376 + // Create AgentRuntime 377 + let runtime_config = rustagent::agent::runtime::RuntimeConfig { 378 + max_turns: max_iterations.unwrap_or(100), 379 + max_consecutive_llm_failures: 3, 380 + max_consecutive_tool_failures: 3, 381 + token_budget: resolved_profile.token_budget.unwrap_or(200_000), 382 + token_budget_warning_pct: 80, 383 + }; 384 + 385 + let runtime = rustagent::agent::runtime::AgentRuntime::new( 386 + llm_client, 387 + tool_registry, 388 + resolved_profile.clone(), 389 + runtime_config, 390 + ); 391 + 392 + // Run the runtime 393 + println!("Running agent with profile: {}", profile); 394 + let outcome = runtime.run(ctx).await?; 395 + 396 + // Handle outcome 397 + match outcome { 398 + rustagent::agent::AgentOutcome::Completed { summary } => { 399 + println!("Agent completed: {}", summary); 400 + graph_store 401 + .update_node( 402 + &goal_id, 403 + Some(rustagent::graph::NodeStatus::Completed), 404 + None, 405 + None, 406 + None, 407 + None, 408 + ) 409 + .await?; 410 + } 411 + rustagent::agent::AgentOutcome::Blocked { reason } => { 412 + println!("Agent blocked: {}", reason); 413 + graph_store 414 + .update_node( 415 + &goal_id, 416 + Some(rustagent::graph::NodeStatus::Blocked), 417 + None, 418 + None, 419 + Some(&reason), 420 + None, 421 + ) 422 + .await?; 423 + } 424 + rustagent::agent::AgentOutcome::Failed { error } => { 425 + println!("Agent failed: {}", error); 426 + graph_store 427 + .update_node( 428 + &goal_id, 429 + Some(rustagent::graph::NodeStatus::Failed), 430 + None, 431 + None, 432 + Some(&error), 433 + None, 434 + ) 435 + .await?; 436 + } 437 + rustagent::agent::AgentOutcome::TokenBudgetExhausted { 438 + summary, 439 + tokens_used, 440 + } => { 441 + println!("Token budget exhausted ({}): {}", tokens_used, summary); 442 + graph_store 443 + .update_node( 444 + &goal_id, 445 + Some(rustagent::graph::NodeStatus::Completed), 446 + None, 447 + None, 448 + Some(&format!( 449 + "Token budget exhausted after {} tokens", 450 + tokens_used 451 + )), 452 + None, 453 + ) 454 + .await?; 455 + } 456 + } 457 + 458 + // End session 459 + session_store.end_session(&session.id, &graph_store).await?; 460 + println!("Session ended"); 131 461 } 132 - Commands::Tui => { 133 - let config_path = find_config_path()?; 134 - let config = config::Config::load(&config_path)?; 135 - let spec_dir = config.rustagent.spec_dir.clone(); 462 + Commands::Project { action } => { 463 + // Open database 464 + let db_path = db_path()?; 465 + let database = db::Database::open(&db_path).await?; 466 + let store = project::ProjectStore::new(database); 136 467 137 - use rustagent::tui::{self, agent_channel}; 468 + match action { 469 + ProjectAction::Add { name, path } => { 470 + let path_obj = std::path::Path::new(&path); 471 + let canonical_path = path_obj.canonicalize()?; 472 + let proj = store.add(&name, &canonical_path).await?; 473 + println!( 474 + "Registered project '{}' ({}) at {}", 475 + proj.name, 476 + proj.id, 477 + proj.path.display() 478 + ); 479 + } 480 + ProjectAction::List => { 481 + let projects = store.list().await?; 482 + if projects.is_empty() { 483 + println!("No projects registered"); 484 + } else { 485 + println!("{:<20} {:<10} {:<40}", "Name", "ID", "Path"); 486 + println!("{}", "=".repeat(70)); 487 + for proj in projects { 488 + let path_str = proj.path.display().to_string(); 489 + let path_display = if path_str.len() > 40 { 490 + format!("{}...", &path_str[..37]) 491 + } else { 492 + path_str 493 + }; 494 + println!("{:<20} {:<10} {:<40}", proj.name, proj.id, path_display); 495 + } 496 + } 497 + } 498 + ProjectAction::Show { name } => match store.get_by_name(&name).await? { 499 + Some(proj) => { 500 + println!("Project: {}", proj.name); 501 + println!(" ID: {}", proj.id); 502 + println!(" Path: {}", proj.path.display()); 503 + println!(" Registered: {}", proj.registered_at); 504 + } 505 + None => { 506 + println!("Project '{}' not found", name); 507 + } 508 + }, 509 + ProjectAction::Remove { name } => match store.remove(&name).await? { 510 + true => { 511 + println!("Removed project '{}'", name); 512 + } 513 + false => { 514 + println!("Project '{}' not found", name); 515 + } 516 + }, 517 + } 518 + } 519 + Commands::Tasks { action } => { 520 + // Open database 521 + let db_path = db_path()?; 522 + let database = db::Database::open(&db_path).await?; 523 + let graph_store = rustagent::graph::store::SqliteGraphStore::new(database.clone()); 138 524 139 - let mut terminal = tui::setup_terminal()?; 140 - let (tx, mut rx) = agent_channel(); 141 - let mut app = tui::App::new(&spec_dir, tx, Some(config)); 525 + match action { 526 + Some(TaskAction::List { status }) => { 527 + let query = rustagent::graph::store::NodeQuery { 528 + node_type: Some(rustagent::graph::NodeType::Task), 529 + status: status.and_then(|s| s.parse().ok()), 530 + project_id: cli.project.clone(), 531 + parent_id: None, 532 + query: None, 533 + }; 534 + 535 + let tasks = graph_store.query_nodes(&query).await?; 536 + if tasks.is_empty() { 537 + println!("No tasks found"); 538 + } else { 539 + println!("{:<20} {:<15} {:<30}", "ID", "Status", "Title"); 540 + println!("{}", "=".repeat(65)); 541 + for task in tasks { 542 + println!("{:<20} {:<15} {:<30}", task.id, task.status, task.title); 543 + } 544 + } 545 + } 546 + Some(TaskAction::Ready) => { 547 + if let Some(proj) = cli.project { 548 + let tasks = graph_store.get_ready_tasks(&proj).await?; 549 + if tasks.is_empty() { 550 + println!("No ready tasks"); 551 + } else { 552 + println!("Ready tasks for {}:", proj); 553 + println!("{:<20} {:<30}", "ID", "Title"); 554 + println!("{}", "=".repeat(50)); 555 + for task in tasks { 556 + println!("{:<20} {:<30}", task.id, task.title); 557 + } 558 + } 559 + } else { 560 + println!("Project must be specified with --project flag"); 561 + } 562 + } 563 + Some(TaskAction::Next) => { 564 + if let Some(proj) = cli.project { 565 + if let Some(task) = graph_store.get_next_task(&proj).await? { 566 + println!("Recommended next task:"); 567 + println!(" ID: {}", task.id); 568 + println!(" Title: {}", task.title); 569 + println!(" Description: {}", task.description); 570 + if let Some(priority) = task.priority { 571 + println!(" Priority: {}", priority); 572 + } 573 + } else { 574 + println!("No ready tasks"); 575 + } 576 + } else { 577 + println!("Project must be specified with --project flag"); 578 + } 579 + } 580 + Some(TaskAction::Tree) => { 581 + if let Some(proj) = cli.project { 582 + let subtree = graph_store.get_subtree(&proj).await?; 583 + println!("Task tree for {}:", proj); 584 + for node in subtree { 585 + println!(" - {} ({}): {}", node.id, node.status, node.title); 586 + } 587 + } else { 588 + println!("Project must be specified with --project flag"); 589 + } 590 + } 591 + None => { 592 + println!("Please specify a task action: list, ready, next, or tree"); 593 + } 594 + } 595 + } 596 + Commands::Decisions { action } => { 597 + // Open database 598 + let db_path = db_path()?; 599 + let database = db::Database::open(&db_path).await?; 600 + let graph_store = rustagent::graph::store::SqliteGraphStore::new(database.clone()); 601 + 602 + match action { 603 + Some(DecisionAction::List) => { 604 + if let Some(proj) = cli.project { 605 + let decisions = graph_store.get_active_decisions(&proj).await?; 606 + if decisions.is_empty() { 607 + println!("No decisions found"); 608 + } else { 609 + println!("Decisions for {}:", proj); 610 + println!("{:<20} {:<15} {:<30}", "ID", "Status", "Title"); 611 + println!("{}", "=".repeat(65)); 612 + for decision in decisions { 613 + println!( 614 + "{:<20} {:<15} {:<30}", 615 + decision.id, decision.status, decision.title 616 + ); 617 + } 618 + } 619 + } else { 620 + println!("Project must be specified with --project flag"); 621 + } 622 + } 623 + Some(DecisionAction::Now) => { 624 + if let Some(proj) = cli.project { 625 + let decisions = graph_store.get_active_decisions(&proj).await?; 626 + println!("Current active decisions for {}:", proj); 627 + for decision in decisions { 628 + println!(" - {}: {}", decision.id, decision.title); 629 + } 630 + } else { 631 + println!("Project must be specified with --project flag"); 632 + } 633 + } 634 + Some(DecisionAction::History) => { 635 + if let Some(proj) = cli.project { 636 + let graph = graph_store.get_full_graph(&proj).await?; 637 + println!("Full decision history for {}:", proj); 638 + println!("Nodes: {}", graph.nodes.len()); 639 + println!("Edges: {}", graph.edges.len()); 640 + } else { 641 + println!("Project must be specified with --project flag"); 642 + } 643 + } 644 + Some(DecisionAction::Show { id }) => { 645 + if let Some(decision) = graph_store.get_node(&id).await? { 646 + println!("Decision: {}", decision.title); 647 + println!(" ID: {}", decision.id); 648 + println!(" Status: {}", decision.status); 649 + println!(" Description: {}", decision.description); 650 + } else { 651 + println!("Decision '{}' not found", id); 652 + } 653 + } 654 + Some(DecisionAction::Export { output }) => { 655 + if let Some(proj_id) = cli.project { 656 + let output_dir = output.unwrap_or_else(|| ".".to_string()); 657 + let output_path = std::path::PathBuf::from(&output_dir); 658 + 659 + match rustagent::graph::export::export_adrs( 660 + &graph_store, 661 + &proj_id, 662 + &output_path, 663 + ) 664 + .await 665 + { 666 + Ok(files) => { 667 + println!("Exported {} decision(s) to {}:", files.len(), output_dir); 668 + for file in files { 669 + println!(" {}", file.display()); 670 + } 671 + } 672 + Err(e) => { 673 + println!("Export failed: {}", e); 674 + } 675 + } 676 + } else { 677 + println!("Project must be specified with --project flag"); 678 + } 679 + } 680 + None => { 681 + println!( 682 + "Please specify a decision action: list, now, history, show, or export" 683 + ); 684 + } 685 + } 686 + } 687 + Commands::Status => { 688 + // Open database 689 + let db_path = db_path()?; 690 + let database = db::Database::open(&db_path).await?; 691 + let graph_store = rustagent::graph::store::SqliteGraphStore::new(database.clone()); 692 + 693 + if let Some(proj) = cli.project { 694 + let graph = graph_store.get_full_graph(&proj).await?; 695 + println!("Status for {}:", proj); 696 + println!(" Total nodes: {}", graph.nodes.len()); 697 + println!(" Total edges: {}", graph.edges.len()); 698 + 699 + println!("\nBreakdown:"); 700 + let mut pending_count = 0; 701 + let mut ready_count = 0; 702 + let mut completed_count = 0; 703 + for node in &graph.nodes { 704 + match node.status { 705 + rustagent::graph::NodeStatus::Pending => pending_count += 1, 706 + rustagent::graph::NodeStatus::Ready => ready_count += 1, 707 + rustagent::graph::NodeStatus::Completed => completed_count += 1, 708 + _ => {} 709 + } 710 + } 711 + println!(" Pending: {}", pending_count); 712 + println!(" Ready: {}", ready_count); 713 + println!(" Completed: {}", completed_count); 714 + } else { 715 + println!("Project must be specified with --project flag"); 716 + } 717 + } 718 + Commands::Search { query } => { 719 + // Open database 720 + let db_path = db_path()?; 721 + let database = db::Database::open(&db_path).await?; 722 + let graph_store = rustagent::graph::store::SqliteGraphStore::new(database.clone()); 723 + 724 + let results = graph_store 725 + .search_nodes(&query, cli.project.as_deref(), None, 50) 726 + .await?; 142 727 143 - let result = tui::run(&mut terminal, &mut app, &mut rx).await; 728 + if results.is_empty() { 729 + println!("No results found for '{}'", query); 730 + } else { 731 + println!("Search results for '{}':", query); 732 + println!("{:<20} {:<15} {:<30}", "ID", "Type", "Title"); 733 + println!("{}", "=".repeat(65)); 734 + for node in results { 735 + println!("{:<20} {:<15} {:<30}", node.id, node.node_type, node.title); 736 + } 737 + } 738 + } 739 + Commands::Sessions { action } => { 740 + // Open database 741 + let db_path = db_path()?; 742 + let database = db::Database::open(&db_path).await?; 743 + let session_store = rustagent::graph::session::SessionStore::new(database.clone()); 144 744 145 - tui::restore_terminal(&mut terminal)?; 745 + match action { 746 + Some(SessionAction::List { goal }) => { 747 + if let Some(goal_id) = goal { 748 + match session_store.list_sessions(&goal_id).await { 749 + Ok(sessions) => { 750 + if sessions.is_empty() { 751 + println!("No sessions found for goal {}", goal_id); 752 + } else { 753 + println!("Sessions for {}:", goal_id); 754 + println!("{:<20} {:<25} {:<15}", "ID", "Started", "Status"); 755 + println!("{}", "=".repeat(60)); 756 + for session in sessions { 757 + let status = if session.ended_at.is_some() { 758 + "Ended" 759 + } else { 760 + "Active" 761 + }; 762 + println!( 763 + "{:<20} {:<25} {:<15}", 764 + session.id, 765 + session.started_at.format("%Y-%m-%d %H:%M"), 766 + status 767 + ); 768 + } 769 + } 770 + } 771 + Err(e) => println!("Error listing sessions: {}", e), 772 + } 773 + } else { 774 + println!("Goal ID must be specified with --goal flag"); 775 + } 776 + } 777 + Some(SessionAction::Latest { goal }) => { 778 + if let Some(goal_id) = goal { 779 + match session_store.get_latest_session(&goal_id).await { 780 + Ok(Some(session)) => { 781 + println!("Latest session for {}:", goal_id); 782 + println!(" ID: {}", session.id); 783 + println!(" Started: {}", session.started_at); 784 + if let Some(ended) = session.ended_at { 785 + println!(" Ended: {}", ended); 786 + } 787 + if let Some(notes) = session.handoff_notes { 788 + println!("\nHandoff Notes:"); 789 + println!("{}", notes); 790 + } 791 + } 792 + Ok(None) => println!("No sessions found for goal {}", goal_id), 793 + Err(e) => println!("Error retrieving session: {}", e), 794 + } 795 + } else { 796 + println!("Goal ID must be specified with --goal flag"); 797 + } 798 + } 799 + None => { 800 + println!("Please specify a session action: list or latest"); 801 + } 802 + } 803 + } 804 + Commands::Graph { action } => { 805 + // Open database 806 + let db_path = db_path()?; 807 + let database = db::Database::open(&db_path).await?; 808 + let graph_store = rustagent::graph::store::SqliteGraphStore::new(database.clone()); 146 809 147 - result?; 810 + match action { 811 + GraphAction::Export { goal, output } => { 812 + if let Some(goal_id) = goal { 813 + let project_name = 814 + cli.project.clone().unwrap_or_else(|| "unknown".to_string()); 815 + match rustagent::graph::interchange::export_goal( 816 + &graph_store, 817 + &goal_id, 818 + &project_name, 819 + ) 820 + .await 821 + { 822 + Ok(toml_content) => { 823 + if let Some(output_path) = output { 824 + // Write to file 825 + match std::fs::write(&output_path, &toml_content) { 826 + Ok(_) => println!("Exported goal to {}", output_path), 827 + Err(e) => println!("Failed to write file: {}", e), 828 + } 829 + } else { 830 + // Print to stdout 831 + println!("{}", toml_content); 832 + } 833 + } 834 + Err(e) => println!("Export failed: {}", e), 835 + } 836 + } else { 837 + println!("Goal ID must be specified with --goal flag"); 838 + } 839 + } 840 + GraphAction::Import { 841 + path, 842 + dry_run, 843 + theirs, 844 + ours, 845 + } => match std::fs::read_to_string(&path) { 846 + Ok(content) => { 847 + let strategy = if theirs { 848 + rustagent::graph::interchange::ImportStrategy::Theirs 849 + } else if ours { 850 + rustagent::graph::interchange::ImportStrategy::Ours 851 + } else { 852 + rustagent::graph::interchange::ImportStrategy::Merge 853 + }; 854 + 855 + if dry_run { 856 + // Parse the TOML and show what would be imported without writing 857 + match toml::from_str::<rustagent::graph::interchange::GoalFile>( 858 + &content, 859 + ) { 860 + Ok(goal_file) => { 861 + println!("[DRY RUN] Changes that would be applied:"); 862 + println!(" Nodes to process: {}", goal_file.nodes.len()); 863 + println!(" Edges to process: {}", goal_file.edges.len()); 864 + println!(" Import strategy: {:?}", strategy); 865 + } 866 + Err(e) => println!("Failed to parse TOML: {}", e), 867 + } 868 + } else { 869 + match rustagent::graph::interchange::import_goal( 870 + &graph_store, 871 + &content, 872 + strategy, 873 + ) 874 + .await 875 + { 876 + Ok(result) => { 877 + println!(" Added nodes: {}", result.added_nodes); 878 + println!(" Added edges: {}", result.added_edges); 879 + println!(" Unchanged: {}", result.unchanged); 880 + if !result.conflicts.is_empty() { 881 + println!(" Conflicts: {}", result.conflicts.len()); 882 + } 883 + if !result.skipped_edges.is_empty() { 884 + println!(" Skipped edges: {}", result.skipped_edges.len()); 885 + } 886 + } 887 + Err(e) => println!("Import failed: {}", e), 888 + } 889 + } 890 + } 891 + Err(e) => println!("Failed to read file: {}", e), 892 + }, 893 + GraphAction::Diff { path } => match std::fs::read_to_string(&path) { 894 + Ok(content) => { 895 + match rustagent::graph::interchange::diff_goal(&graph_store, &content).await 896 + { 897 + Ok(result) => { 898 + println!("Diff results for {}:", path); 899 + if !result.added_nodes.is_empty() { 900 + println!(" Added nodes: {}", result.added_nodes.len()); 901 + for node_id in &result.added_nodes { 902 + println!(" + {}", node_id); 903 + } 904 + } 905 + if !result.changed_nodes.is_empty() { 906 + println!(" Changed nodes: {}", result.changed_nodes.len()); 907 + for (node_id, fields) in &result.changed_nodes { 908 + println!(" ~ {} ({})", node_id, fields.join(", ")); 909 + } 910 + } 911 + if !result.removed_nodes.is_empty() { 912 + println!(" Removed nodes: {}", result.removed_nodes.len()); 913 + for node_id in &result.removed_nodes { 914 + println!(" - {}", node_id); 915 + } 916 + } 917 + if !result.added_edges.is_empty() { 918 + println!(" Added edges: {}", result.added_edges.len()); 919 + } 920 + if !result.removed_edges.is_empty() { 921 + println!(" Removed edges: {}", result.removed_edges.len()); 922 + } 923 + println!(" Unchanged nodes: {}", result.unchanged_nodes); 924 + println!(" Unchanged edges: {}", result.unchanged_edges); 925 + } 926 + Err(e) => println!("Diff failed: {}", e), 927 + } 928 + } 929 + Err(e) => println!("Failed to read file: {}", e), 930 + }, 931 + } 148 932 } 149 933 } 150 934
-85
src/planning/mod.rs
··· 4 4 use crate::security::{SecurityValidator, permission::CliPermissionHandler}; 5 5 use crate::tools::ToolRegistry; 6 6 use crate::tools::factory::create_default_registry; 7 - use crate::tui::messages::{AgentMessage, AgentSender}; 8 7 use std::io::{self, Write}; 9 8 use std::sync::Arc; 10 9 ··· 91 90 } 92 91 93 92 Ok(()) 94 - } 95 - 96 - /// Run planning with a message sender for TUI integration 97 - pub async fn run_with_sender( 98 - &mut self, 99 - tx: AgentSender, 100 - initial_message: String, 101 - ) -> anyhow::Result<()> { 102 - tx.send(AgentMessage::PlanningStarted).await?; 103 - 104 - self.conversation.push(Message::user(&initial_message)); 105 - 106 - loop { 107 - let tools = self.registry.definitions(); 108 - let response = self.client.chat(self.conversation.clone(), &tools).await?; 109 - 110 - match response.content { 111 - ResponseContent::Text(text) => { 112 - self.conversation.push(Message::assistant(text.clone())); 113 - tx.send(AgentMessage::PlanningResponse(text)).await?; 114 - 115 - // Check for end of turn 116 - if matches!(response.stop_reason.as_deref(), Some("end_turn")) { 117 - break; 118 - } 119 - break; 120 - } 121 - ResponseContent::ToolCalls(tool_calls) => { 122 - for tool_call in &tool_calls { 123 - tx.send(AgentMessage::PlanningToolCall { 124 - name: tool_call.name.clone(), 125 - args: tool_call.parameters.to_string(), 126 - }) 127 - .await?; 128 - 129 - let tool = self 130 - .registry 131 - .get(&tool_call.name) 132 - .ok_or_else(|| anyhow::anyhow!("Tool not found: {}", tool_call.name))?; 133 - 134 - let result = tool.execute(tool_call.parameters.clone()).await; 135 - 136 - let output = match result { 137 - Ok(output) => output, 138 - Err(e) => format!("Error: {}", e), 139 - }; 140 - 141 - tx.send(AgentMessage::PlanningToolResult { 142 - name: tool_call.name.clone(), 143 - output: output.clone(), 144 - }) 145 - .await?; 146 - 147 - if tool_call.name == "write_file" 148 - && let Some(path) = 149 - tool_call.parameters.get("path").and_then(|p| p.as_str()) 150 - && (path.ends_with("spec.json") || path.ends_with(".json")) 151 - { 152 - tx.send(AgentMessage::PlanningComplete { 153 - spec_path: path.to_string(), 154 - }) 155 - .await?; 156 - } 157 - 158 - self.conversation.push(Message::user(format!( 159 - "Tool result for {}:\n{}", 160 - tool_call.name, output 161 - ))); 162 - } 163 - // Continue loop for next LLM response 164 - } 165 - } 166 - } 167 - 168 - Ok(()) 169 - } 170 - 171 - /// Continue conversation with additional user input 172 - pub async fn continue_with_sender( 173 - &mut self, 174 - tx: AgentSender, 175 - user_message: String, 176 - ) -> anyhow::Result<()> { 177 - self.run_with_sender(tx, user_message).await 178 93 } 179 94 180 95 /// Process a single conversation turn
+248
src/project.rs
··· 1 + use anyhow::Result; 2 + use chrono::{DateTime, Utc}; 3 + use std::path::{Path, PathBuf}; 4 + use uuid::Uuid; 5 + 6 + use crate::db::Database; 7 + 8 + /// Represents a registered project in the system 9 + #[derive(Clone, Debug)] 10 + pub struct Project { 11 + pub id: String, 12 + pub name: String, 13 + pub path: PathBuf, 14 + pub registered_at: DateTime<Utc>, 15 + pub config_overrides: Option<String>, 16 + pub metadata: String, 17 + } 18 + 19 + /// Project storage and retrieval operations 20 + #[derive(Clone)] 21 + pub struct ProjectStore { 22 + db: Database, 23 + } 24 + 25 + impl ProjectStore { 26 + /// Create a new ProjectStore 27 + pub fn new(db: Database) -> Self { 28 + Self { db } 29 + } 30 + 31 + /// Register a new project 32 + /// 33 + /// Generates a unique ID with prefix "ra-" followed by 4 hex characters. 34 + /// Uses BEGIN IMMEDIATE transaction for write safety. 35 + pub async fn add(&self, name: &str, path: &Path) -> Result<Project> { 36 + let db = self.db.clone(); 37 + let name = name.to_string(); 38 + let path = path.to_path_buf(); 39 + 40 + let result = db 41 + .connection() 42 + .call(move |conn| { 43 + let tx = 44 + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 45 + 46 + let id = generate_project_id(); 47 + let now = Utc::now(); 48 + let registered_at = now.to_rfc3339(); 49 + 50 + tx.execute( 51 + "INSERT INTO projects (id, name, path, registered_at, metadata) 52 + VALUES (?, ?, ?, ?, ?)", 53 + rusqlite::params![&id, &name, path.to_string_lossy(), &registered_at, "{}"], 54 + )?; 55 + 56 + tx.commit()?; 57 + 58 + Ok(Project { 59 + id, 60 + name, 61 + path, 62 + registered_at: now, 63 + config_overrides: None, 64 + metadata: "{}".to_string(), 65 + }) 66 + }) 67 + .await; 68 + 69 + result.map_err(|e| anyhow::anyhow!(e)) 70 + } 71 + 72 + /// List all projects ordered by name 73 + pub async fn list(&self) -> Result<Vec<Project>> { 74 + let db = self.db.clone(); 75 + 76 + let result = db 77 + .connection() 78 + .call(|conn| { 79 + let mut stmt = conn.prepare( 80 + "SELECT id, name, path, registered_at, config_overrides, metadata 81 + FROM projects 82 + ORDER BY name", 83 + )?; 84 + 85 + let projects = stmt.query_map([], |row| { 86 + let registered_at_str: String = row.get(3)?; 87 + let registered_at = DateTime::parse_from_rfc3339(&registered_at_str) 88 + .map(|dt| dt.with_timezone(&Utc)) 89 + .unwrap_or_else(|_| Utc::now()); 90 + 91 + Ok(Project { 92 + id: row.get(0)?, 93 + name: row.get(1)?, 94 + path: PathBuf::from(row.get::<_, String>(2)?), 95 + registered_at, 96 + config_overrides: row.get(4)?, 97 + metadata: row.get(5)?, 98 + }) 99 + })?; 100 + 101 + let mut projects_vec = Vec::new(); 102 + for project in projects { 103 + projects_vec.push(project?); 104 + } 105 + Ok(projects_vec) 106 + }) 107 + .await; 108 + 109 + result.map_err(|e| anyhow::anyhow!(e)) 110 + } 111 + 112 + /// Get a project by name 113 + pub async fn get_by_name(&self, name: &str) -> Result<Option<Project>> { 114 + let db = self.db.clone(); 115 + let name = name.to_string(); 116 + 117 + let result = db 118 + .connection() 119 + .call(move |conn| { 120 + let mut stmt = conn.prepare( 121 + "SELECT id, name, path, registered_at, config_overrides, metadata 122 + FROM projects 123 + WHERE name = ?", 124 + )?; 125 + 126 + let project = stmt.query_row([&name], |row| { 127 + let registered_at_str: String = row.get(3)?; 128 + let registered_at = DateTime::parse_from_rfc3339(&registered_at_str) 129 + .map(|dt| dt.with_timezone(&Utc)) 130 + .unwrap_or_else(|_| Utc::now()); 131 + 132 + Ok(Project { 133 + id: row.get(0)?, 134 + name: row.get(1)?, 135 + path: PathBuf::from(row.get::<_, String>(2)?), 136 + registered_at, 137 + config_overrides: row.get(4)?, 138 + metadata: row.get(5)?, 139 + }) 140 + }); 141 + 142 + match project { 143 + Ok(p) => Ok(Some(p)), 144 + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), 145 + Err(e) => Err(tokio_rusqlite::Error::Rusqlite(e)), 146 + } 147 + }) 148 + .await; 149 + 150 + result.map_err(|e| anyhow::anyhow!(e)) 151 + } 152 + 153 + /// Get a project by path (canonicalized comparison) 154 + pub async fn get_by_path(&self, path: &Path) -> Result<Option<Project>> { 155 + let db = self.db.clone(); 156 + let path_buf = path.to_path_buf(); 157 + let canonical_query = path_buf.canonicalize().ok(); 158 + 159 + let result = db 160 + .connection() 161 + .call(move |conn| { 162 + let mut stmt = conn.prepare( 163 + "SELECT id, name, path, registered_at, config_overrides, metadata 164 + FROM projects", 165 + )?; 166 + 167 + let projects = stmt.query_map([], |row| { 168 + let registered_at_str: String = row.get(3)?; 169 + let registered_at = DateTime::parse_from_rfc3339(&registered_at_str) 170 + .map(|dt| dt.with_timezone(&Utc)) 171 + .unwrap_or_else(|_| Utc::now()); 172 + 173 + Ok(Project { 174 + id: row.get(0)?, 175 + name: row.get(1)?, 176 + path: PathBuf::from(row.get::<_, String>(2)?), 177 + registered_at, 178 + config_overrides: row.get(4)?, 179 + metadata: row.get(5)?, 180 + }) 181 + })?; 182 + 183 + for project_result in projects { 184 + let project = project_result?; 185 + // Try both exact match and canonicalized match 186 + if project.path == path_buf { 187 + return Ok(Some(project)); 188 + } 189 + if let Ok(canonical_stored) = project.path.canonicalize() 190 + && let Some(ref canonical_query_ref) = canonical_query 191 + && canonical_stored == *canonical_query_ref 192 + { 193 + return Ok(Some(project)); 194 + } 195 + } 196 + Ok(None) 197 + }) 198 + .await; 199 + 200 + result.map_err(|e| anyhow::anyhow!(e)) 201 + } 202 + 203 + /// Remove a project by name 204 + /// 205 + /// Returns true if a project was deleted, false if no project with that name existed. 206 + pub async fn remove(&self, name: &str) -> Result<bool> { 207 + let db = self.db.clone(); 208 + let name = name.to_string(); 209 + 210 + let result = db 211 + .connection() 212 + .call(move |conn| { 213 + let tx = 214 + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; 215 + 216 + let rows_affected = tx.execute( 217 + "DELETE FROM projects WHERE name = ?", 218 + rusqlite::params![&name], 219 + )?; 220 + 221 + tx.commit()?; 222 + 223 + Ok(rows_affected > 0) 224 + }) 225 + .await; 226 + 227 + result.map_err(|e| anyhow::anyhow!(e)) 228 + } 229 + } 230 + 231 + /// Generate a project ID with "ra-" prefix and 4 hex characters 232 + fn generate_project_id() -> String { 233 + let uuid = Uuid::new_v4(); 234 + let hex_str = uuid.to_string().replace("-", ""); 235 + format!("ra-{}", &hex_str[..4]) 236 + } 237 + 238 + #[cfg(test)] 239 + mod tests { 240 + use super::*; 241 + 242 + #[tokio::test] 243 + async fn test_generate_project_id() { 244 + let id = generate_project_id(); 245 + assert!(id.starts_with("ra-")); 246 + assert_eq!(id.len(), 7); // "ra-" + 4 hex chars 247 + } 248 + }
-191
src/ralph/mod.rs
··· 5 5 use crate::spec::{Spec, TaskStatus}; 6 6 use crate::tools::ToolRegistry; 7 7 use crate::tools::factory::create_default_registry; 8 - use crate::tui::messages::{AgentMessage, AgentSender}; 9 8 use anyhow::{Context, Result}; 10 9 use chrono::Utc; 11 10 use std::sync::Arc; ··· 138 137 139 138 println!("Ralph Loop finished"); 140 139 Ok(()) 141 - } 142 - 143 - /// Run execution with a message sender for TUI integration 144 - pub async fn run_with_sender(&self, tx: AgentSender) -> Result<()> { 145 - tx.send(AgentMessage::ExecutionStarted { 146 - spec_path: self.spec_path.clone(), 147 - }) 148 - .await?; 149 - 150 - let mut iteration = 0; 151 - 152 - loop { 153 - iteration += 1; 154 - if iteration > self.max_iterations { 155 - tx.send(AgentMessage::ExecutionError(format!( 156 - "Reached max iterations ({})", 157 - self.max_iterations 158 - ))) 159 - .await?; 160 - break; 161 - } 162 - 163 - let mut spec = Spec::load(&self.spec_path).context("Failed to load spec")?; 164 - 165 - let task = match spec.find_next_task() { 166 - Some(t) => t.clone(), 167 - None => { 168 - tx.send(AgentMessage::ExecutionComplete).await?; 169 - break; 170 - } 171 - }; 172 - 173 - tx.send(AgentMessage::TaskStarted { 174 - task_id: task.id.clone(), 175 - title: task.title.clone(), 176 - }) 177 - .await?; 178 - 179 - // Mark task as in progress 180 - { 181 - let task_mut = spec 182 - .find_task_mut(&task.id) 183 - .context("Task not found in spec")?; 184 - task_mut.status = TaskStatus::InProgress; 185 - } 186 - spec.save(&self.spec_path).context("Failed to save spec")?; 187 - 188 - match self.execute_task_with_sender(&task.id, &tx).await { 189 - Ok((signal, reason)) => { 190 - let mut spec = Spec::load(&self.spec_path)?; 191 - 192 - match signal.as_str() { 193 - "TASK_COMPLETE" => { 194 - let task_mut = 195 - spec.find_task_mut(&task.id).context("Task not found")?; 196 - task_mut.status = TaskStatus::Complete; 197 - task_mut.completed_at = Some(Utc::now()); 198 - spec.save(&self.spec_path)?; 199 - tx.send(AgentMessage::TaskComplete { task_id: task.id }) 200 - .await?; 201 - } 202 - "TASK_BLOCKED" => { 203 - let task_mut = 204 - spec.find_task_mut(&task.id).context("Task not found")?; 205 - task_mut.status = TaskStatus::Blocked; 206 - spec.save(&self.spec_path)?; 207 - tx.send(AgentMessage::TaskBlocked { 208 - task_id: task.id, 209 - reason: reason 210 - .unwrap_or_else(|| "Task reported blocked".to_string()), 211 - }) 212 - .await?; 213 - } 214 - _ => { 215 - let task_mut = 216 - spec.find_task_mut(&task.id).context("Task not found")?; 217 - task_mut.status = TaskStatus::Pending; 218 - spec.save(&self.spec_path)?; 219 - tx.send(AgentMessage::TaskResponse(format!( 220 - "Unknown signal '{}', resetting task to pending", 221 - signal 222 - ))) 223 - .await?; 224 - } 225 - } 226 - } 227 - Err(e) => { 228 - tx.send(AgentMessage::ExecutionError(e.to_string())).await?; 229 - break; 230 - } 231 - } 232 - } 233 - 234 - Ok(()) 235 - } 236 - 237 - async fn execute_task_with_sender( 238 - &self, 239 - task_id: &str, 240 - tx: &AgentSender, 241 - ) -> Result<(String, Option<String>)> { 242 - let context = self.build_context(task_id)?; 243 - let tool_definitions = self.tools.definitions(); 244 - 245 - let mut messages = vec![Message::user(context)]; 246 - 247 - let max_turns = 50; 248 - for _turn in 0..max_turns { 249 - let response = self 250 - .client 251 - .chat(messages.clone(), &tool_definitions) 252 - .await?; 253 - 254 - match response.content { 255 - ResponseContent::Text(text) => { 256 - tx.send(AgentMessage::TaskResponse(text.clone())).await?; 257 - 258 - if text.contains("TASK_COMPLETE") { 259 - return Ok(("TASK_COMPLETE".to_string(), None)); 260 - } 261 - if text.contains("TASK_BLOCKED") { 262 - return Ok(("TASK_BLOCKED".to_string(), Some(text))); 263 - } 264 - 265 - messages.push(Message::assistant(text)); 266 - } 267 - ResponseContent::ToolCalls(tool_calls) => { 268 - for tool_call in &tool_calls { 269 - if tool_call.name == "signal_completion" { 270 - let tool = self 271 - .tools 272 - .get(&tool_call.name) 273 - .context("signal_completion tool not found")?; 274 - let result = tool.execute(tool_call.parameters.clone()).await?; 275 - 276 - if result.starts_with("SIGNAL:complete:") { 277 - return Ok(("TASK_COMPLETE".to_string(), None)); 278 - } else if result.starts_with("SIGNAL:blocked:") { 279 - let reason = result 280 - .strip_prefix("SIGNAL:blocked:") 281 - .map(|s| s.to_string()); 282 - return Ok(("TASK_BLOCKED".to_string(), reason)); 283 - } 284 - } 285 - } 286 - 287 - let mut results = Vec::new(); 288 - for tool_call in tool_calls { 289 - if tool_call.name == "signal_completion" { 290 - continue; 291 - } 292 - 293 - tx.send(AgentMessage::TaskToolCall { 294 - name: tool_call.name.clone(), 295 - args: tool_call.parameters.to_string(), 296 - }) 297 - .await?; 298 - 299 - let tool = self.tools.get(&tool_call.name).context("Tool not found")?; 300 - 301 - match tool.execute(tool_call.parameters).await { 302 - Ok(output) => { 303 - tx.send(AgentMessage::TaskToolResult { 304 - name: tool_call.name.clone(), 305 - output: output.clone(), 306 - }) 307 - .await?; 308 - results 309 - .push(format!("Tool: {}\nResult: {}", tool_call.name, output)); 310 - } 311 - Err(e) => { 312 - tx.send(AgentMessage::TaskToolResult { 313 - name: tool_call.name.clone(), 314 - output: format!("Error: {}", e), 315 - }) 316 - .await?; 317 - results.push(format!("Tool: {}\nError: {}", tool_call.name, e)); 318 - } 319 - } 320 - } 321 - 322 - let results_text = results.join("\n\n"); 323 - if !results_text.is_empty() { 324 - messages.push(Message::user(results_text)); 325 - } 326 - } 327 - } 328 - } 329 - 330 - Err(anyhow::anyhow!("Reached max turns without completion")) 331 140 } 332 141 333 142 async fn execute_task(&self, task_id: &str) -> Result<String> {
+3
src/security/mod.rs
··· 4 4 use std::path::{Component, Path, PathBuf}; 5 5 6 6 pub mod permission; 7 + pub mod scope; 8 + 9 + pub use scope::SecurityScope; 7 10 8 11 pub struct SecurityValidator { 9 12 config: SecurityConfig,
+36
src/security/scope.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + /// Security scope for an agent, controlling what it can access 4 + #[derive(Debug, Clone, Serialize, Deserialize)] 5 + pub struct SecurityScope { 6 + /// Paths the agent is allowed to access 7 + pub allowed_paths: Vec<String>, 8 + 9 + /// Paths the agent is explicitly denied access to 10 + pub denied_paths: Vec<String>, 11 + 12 + /// Shell commands the agent is allowed to run 13 + pub allowed_commands: Vec<String>, 14 + 15 + /// Whether the agent has read-only access 16 + pub read_only: bool, 17 + 18 + /// Whether the agent can create new files 19 + pub can_create_files: bool, 20 + 21 + /// Whether the agent can access network resources 22 + pub network_access: bool, 23 + } 24 + 25 + impl Default for SecurityScope { 26 + fn default() -> Self { 27 + Self { 28 + allowed_paths: vec!["*".to_string()], 29 + denied_paths: vec![], 30 + allowed_commands: vec!["*".to_string()], 31 + read_only: false, 32 + can_create_files: true, 33 + network_access: false, 34 + } 35 + } 36 + }
+33
src/tools/factory.rs
··· 1 + use crate::context::ReadAgentsMdTool; 2 + use crate::graph::store::GraphStore; 1 3 use crate::security::SecurityValidator; 2 4 use crate::security::permission::PermissionHandler; 3 5 use crate::tools::ToolRegistry; 4 6 use crate::tools::file::{ListFilesTool, ReadFileTool, WriteFileTool}; 7 + use crate::tools::graph_tools::{ 8 + AddEdgeTool, ChooseOptionTool, ClaimTaskTool, CreateNodeTool, LogDecisionTool, QueryNodesTool, 9 + RecordObservationTool, RecordOutcomeTool, RevisitTool, SearchNodesTool, UpdateNodeTool, 10 + }; 5 11 use crate::tools::shell::RunCommandTool; 6 12 use crate::tools::signal::SignalTool; 7 13 use std::sync::Arc; ··· 32 38 33 39 registry 34 40 } 41 + 42 + /// Create a v2 registry for agent runtime with graph tools registered 43 + pub fn create_v2_registry( 44 + validator: Arc<SecurityValidator>, 45 + permission_handler: Arc<dyn PermissionHandler>, 46 + graph_store: Arc<dyn GraphStore>, 47 + ) -> ToolRegistry { 48 + let registry = create_default_registry(validator, permission_handler); 49 + 50 + // Register graph tools 51 + registry.register(Arc::new(CreateNodeTool::new(graph_store.clone()))); 52 + registry.register(Arc::new(UpdateNodeTool::new(graph_store.clone()))); 53 + registry.register(Arc::new(AddEdgeTool::new(graph_store.clone()))); 54 + registry.register(Arc::new(QueryNodesTool::new(graph_store.clone()))); 55 + registry.register(Arc::new(SearchNodesTool::new(graph_store.clone()))); 56 + registry.register(Arc::new(ClaimTaskTool::new(graph_store.clone()))); 57 + registry.register(Arc::new(LogDecisionTool::new(graph_store.clone()))); 58 + registry.register(Arc::new(ChooseOptionTool::new(graph_store.clone()))); 59 + registry.register(Arc::new(RecordOutcomeTool::new(graph_store.clone()))); 60 + registry.register(Arc::new(RecordObservationTool::new(graph_store.clone()))); 61 + registry.register(Arc::new(RevisitTool::new(graph_store))); 62 + 63 + // Register context tools 64 + registry.register(Arc::new(ReadAgentsMdTool::new())); 65 + 66 + registry 67 + }
+1181
src/tools/graph_tools.rs
··· 1 + use crate::graph::store::{GraphStore, NodeQuery}; 2 + use crate::graph::{ 3 + EdgeType, GraphEdge, GraphNode, NodeStatus, NodeType, generate_child_id, generate_edge_id, 4 + generate_goal_id, 5 + }; 6 + use anyhow::{Context, Result}; 7 + use async_trait::async_trait; 8 + use chrono::Utc; 9 + use serde_json::{Value, json}; 10 + use std::collections::HashMap; 11 + use std::sync::Arc; 12 + 13 + use super::Tool; 14 + 15 + /// Low-level tool for creating nodes 16 + pub struct CreateNodeTool { 17 + store: Arc<dyn GraphStore>, 18 + } 19 + 20 + impl CreateNodeTool { 21 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 22 + Self { store } 23 + } 24 + } 25 + 26 + #[async_trait] 27 + impl Tool for CreateNodeTool { 28 + fn name(&self) -> &str { 29 + "create_node" 30 + } 31 + 32 + fn description(&self) -> &str { 33 + "Create a new node in the work graph" 34 + } 35 + 36 + fn parameters(&self) -> Value { 37 + json!({ 38 + "type": "object", 39 + "properties": { 40 + "node_type": { 41 + "type": "string", 42 + "enum": ["goal", "task", "decision", "option", "outcome", "observation", "revisit"], 43 + "description": "Type of node to create" 44 + }, 45 + "title": { 46 + "type": "string", 47 + "description": "Title of the node" 48 + }, 49 + "description": { 50 + "type": "string", 51 + "description": "Description of the node" 52 + }, 53 + "project_id": { 54 + "type": "string", 55 + "description": "Project ID (required)" 56 + }, 57 + "parent_id": { 58 + "type": "string", 59 + "description": "Parent node ID (optional, creates as child if provided)" 60 + }, 61 + "priority": { 62 + "type": "string", 63 + "enum": ["critical", "high", "medium", "low"], 64 + "description": "Priority level (optional)" 65 + }, 66 + "metadata": { 67 + "type": "object", 68 + "description": "Additional metadata (optional)" 69 + } 70 + }, 71 + "required": ["node_type", "title", "description", "project_id"] 72 + }) 73 + } 74 + 75 + async fn execute(&self, params: Value) -> Result<String> { 76 + let node_type_str = params["node_type"] 77 + .as_str() 78 + .context("Missing 'node_type' parameter")?; 79 + let node_type: NodeType = node_type_str.parse()?; 80 + 81 + let title = params["title"] 82 + .as_str() 83 + .context("Missing 'title' parameter")? 84 + .to_string(); 85 + 86 + let description = params["description"] 87 + .as_str() 88 + .context("Missing 'description' parameter")? 89 + .to_string(); 90 + 91 + let project_id = params["project_id"] 92 + .as_str() 93 + .context("Missing 'project_id' parameter")? 94 + .to_string(); 95 + 96 + let parent_id_opt = params["parent_id"].as_str().map(|s| s.to_string()); 97 + 98 + // Generate ID based on parent 99 + let id = if let Some(p_id) = &parent_id_opt { 100 + let seq = self.store.next_child_seq(p_id).await?; 101 + generate_child_id(p_id, seq) 102 + } else { 103 + generate_goal_id() 104 + }; 105 + 106 + // Parse priority if provided 107 + let priority = params["priority"].as_str().and_then(|p| p.parse().ok()); 108 + 109 + // Parse metadata if provided 110 + let metadata: HashMap<String, String> = params["metadata"] 111 + .as_object() 112 + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.to_string())).collect()) 113 + .unwrap_or_default(); 114 + 115 + // Create the node 116 + let node = GraphNode { 117 + id: id.clone(), 118 + project_id, 119 + node_type, 120 + title, 121 + description, 122 + status: NodeStatus::Pending, 123 + priority, 124 + assigned_to: None, 125 + created_by: None, 126 + labels: vec![], 127 + created_at: Utc::now(), 128 + started_at: None, 129 + completed_at: None, 130 + blocked_reason: None, 131 + metadata, 132 + }; 133 + 134 + self.store.create_node(&node).await?; 135 + 136 + // If parent was provided, a Contains edge is created automatically in create_node 137 + Ok(json!({ 138 + "id": id, 139 + "message": "Node created successfully" 140 + }) 141 + .to_string()) 142 + } 143 + } 144 + 145 + /// Low-level tool for updating nodes 146 + pub struct UpdateNodeTool { 147 + store: Arc<dyn GraphStore>, 148 + } 149 + 150 + impl UpdateNodeTool { 151 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 152 + Self { store } 153 + } 154 + } 155 + 156 + #[async_trait] 157 + impl Tool for UpdateNodeTool { 158 + fn name(&self) -> &str { 159 + "update_node" 160 + } 161 + 162 + fn description(&self) -> &str { 163 + "Update a node in the work graph" 164 + } 165 + 166 + fn parameters(&self) -> Value { 167 + json!({ 168 + "type": "object", 169 + "properties": { 170 + "node_id": { 171 + "type": "string", 172 + "description": "ID of node to update" 173 + }, 174 + "status": { 175 + "type": "string", 176 + "enum": ["pending", "active", "completed", "cancelled", "ready", "claimed", 177 + "in_progress", "review", "blocked", "failed", "decided", "superseded", 178 + "abandoned", "chosen", "rejected"], 179 + "description": "New status (optional)" 180 + }, 181 + "title": { 182 + "type": "string", 183 + "description": "New title (optional)" 184 + }, 185 + "description": { 186 + "type": "string", 187 + "description": "New description (optional)" 188 + }, 189 + "metadata": { 190 + "type": "object", 191 + "description": "New metadata (optional)" 192 + } 193 + }, 194 + "required": ["node_id"] 195 + }) 196 + } 197 + 198 + async fn execute(&self, params: Value) -> Result<String> { 199 + let node_id = params["node_id"] 200 + .as_str() 201 + .context("Missing 'node_id' parameter")?; 202 + 203 + let status = params["status"].as_str().and_then(|s| s.parse().ok()); 204 + 205 + let title = params["title"].as_str(); 206 + let description = params["description"].as_str(); 207 + 208 + let metadata: Option<HashMap<String, String>> = params["metadata"] 209 + .as_object() 210 + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.to_string())).collect()); 211 + 212 + self.store 213 + .update_node(node_id, status, title, description, None, metadata.as_ref()) 214 + .await?; 215 + 216 + Ok(json!({ 217 + "message": "Node updated successfully" 218 + }) 219 + .to_string()) 220 + } 221 + } 222 + 223 + /// Low-level tool for adding edges 224 + pub struct AddEdgeTool { 225 + store: Arc<dyn GraphStore>, 226 + } 227 + 228 + impl AddEdgeTool { 229 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 230 + Self { store } 231 + } 232 + } 233 + 234 + #[async_trait] 235 + impl Tool for AddEdgeTool { 236 + fn name(&self) -> &str { 237 + "add_edge" 238 + } 239 + 240 + fn description(&self) -> &str { 241 + "Add an edge between two nodes in the work graph" 242 + } 243 + 244 + fn parameters(&self) -> Value { 245 + json!({ 246 + "type": "object", 247 + "properties": { 248 + "edge_type": { 249 + "type": "string", 250 + "enum": ["contains", "depends_on", "leads_to", "chosen", "rejected", "supersedes", "informs"], 251 + "description": "Type of edge" 252 + }, 253 + "from_node": { 254 + "type": "string", 255 + "description": "Source node ID" 256 + }, 257 + "to_node": { 258 + "type": "string", 259 + "description": "Target node ID" 260 + }, 261 + "label": { 262 + "type": "string", 263 + "description": "Edge label (optional)" 264 + } 265 + }, 266 + "required": ["edge_type", "from_node", "to_node"] 267 + }) 268 + } 269 + 270 + async fn execute(&self, params: Value) -> Result<String> { 271 + let edge_type_str = params["edge_type"] 272 + .as_str() 273 + .context("Missing 'edge_type' parameter")?; 274 + let edge_type: EdgeType = edge_type_str.parse()?; 275 + 276 + let from_node = params["from_node"] 277 + .as_str() 278 + .context("Missing 'from_node' parameter")? 279 + .to_string(); 280 + 281 + let to_node = params["to_node"] 282 + .as_str() 283 + .context("Missing 'to_node' parameter")? 284 + .to_string(); 285 + 286 + let label = params["label"].as_str().map(|s| s.to_string()); 287 + 288 + let edge = GraphEdge { 289 + id: generate_edge_id(), 290 + edge_type, 291 + from_node, 292 + to_node, 293 + label, 294 + created_at: Utc::now(), 295 + }; 296 + 297 + self.store.add_edge(&edge).await?; 298 + 299 + Ok(json!({ 300 + "id": edge.id, 301 + "message": "Edge created successfully" 302 + }) 303 + .to_string()) 304 + } 305 + } 306 + 307 + /// Low-level tool for querying nodes 308 + pub struct QueryNodesTool { 309 + store: Arc<dyn GraphStore>, 310 + } 311 + 312 + impl QueryNodesTool { 313 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 314 + Self { store } 315 + } 316 + } 317 + 318 + #[async_trait] 319 + impl Tool for QueryNodesTool { 320 + fn name(&self) -> &str { 321 + "query_nodes" 322 + } 323 + 324 + fn description(&self) -> &str { 325 + "Query nodes in the work graph with flexible filters" 326 + } 327 + 328 + fn parameters(&self) -> Value { 329 + json!({ 330 + "type": "object", 331 + "properties": { 332 + "node_type": { 333 + "type": "string", 334 + "enum": ["goal", "task", "decision", "option", "outcome", "observation", "revisit"], 335 + "description": "Filter by node type (optional)" 336 + }, 337 + "status": { 338 + "type": "string", 339 + "enum": ["pending", "active", "completed", "cancelled", "ready", "claimed", 340 + "in_progress", "review", "blocked", "failed", "decided", "superseded", 341 + "abandoned", "chosen", "rejected"], 342 + "description": "Filter by status (optional)" 343 + }, 344 + "project_id": { 345 + "type": "string", 346 + "description": "Filter by project (optional)" 347 + }, 348 + "parent_id": { 349 + "type": "string", 350 + "description": "Filter by parent node (optional)" 351 + } 352 + }, 353 + "required": [] 354 + }) 355 + } 356 + 357 + async fn execute(&self, params: Value) -> Result<String> { 358 + let query = NodeQuery { 359 + node_type: params["node_type"].as_str().and_then(|s| s.parse().ok()), 360 + status: params["status"].as_str().and_then(|s| s.parse().ok()), 361 + project_id: params["project_id"].as_str().map(|s| s.to_string()), 362 + parent_id: params["parent_id"].as_str().map(|s| s.to_string()), 363 + query: None, 364 + }; 365 + 366 + let nodes = self.store.query_nodes(&query).await?; 367 + 368 + Ok(json!(nodes).to_string()) 369 + } 370 + } 371 + 372 + /// Low-level tool for full-text search 373 + pub struct SearchNodesTool { 374 + store: Arc<dyn GraphStore>, 375 + } 376 + 377 + impl SearchNodesTool { 378 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 379 + Self { store } 380 + } 381 + } 382 + 383 + #[async_trait] 384 + impl Tool for SearchNodesTool { 385 + fn name(&self) -> &str { 386 + "search_nodes" 387 + } 388 + 389 + fn description(&self) -> &str { 390 + "Full-text search for nodes in the work graph" 391 + } 392 + 393 + fn parameters(&self) -> Value { 394 + json!({ 395 + "type": "object", 396 + "properties": { 397 + "query": { 398 + "type": "string", 399 + "description": "Search query" 400 + }, 401 + "project_id": { 402 + "type": "string", 403 + "description": "Filter by project (optional)" 404 + }, 405 + "node_type": { 406 + "type": "string", 407 + "enum": ["goal", "task", "decision", "option", "outcome", "observation", "revisit"], 408 + "description": "Filter by node type (optional)" 409 + }, 410 + "limit": { 411 + "type": "integer", 412 + "description": "Maximum results to return (default: 50)" 413 + } 414 + }, 415 + "required": ["query"] 416 + }) 417 + } 418 + 419 + async fn execute(&self, params: Value) -> Result<String> { 420 + let query = params["query"] 421 + .as_str() 422 + .context("Missing 'query' parameter")?; 423 + 424 + let project_id = params["project_id"].as_str(); 425 + let node_type = params["node_type"].as_str().and_then(|s| s.parse().ok()); 426 + let limit = params["limit"].as_u64().map(|n| n as usize).unwrap_or(50); 427 + 428 + let results = self 429 + .store 430 + .search_nodes(query, project_id, node_type, limit) 431 + .await?; 432 + 433 + Ok(json!(results).to_string()) 434 + } 435 + } 436 + 437 + /// High-level tool for claiming a task 438 + pub struct ClaimTaskTool { 439 + store: Arc<dyn GraphStore>, 440 + } 441 + 442 + impl ClaimTaskTool { 443 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 444 + Self { store } 445 + } 446 + } 447 + 448 + #[async_trait] 449 + impl Tool for ClaimTaskTool { 450 + fn name(&self) -> &str { 451 + "claim_task" 452 + } 453 + 454 + fn description(&self) -> &str { 455 + "Atomically claim a task for execution" 456 + } 457 + 458 + fn parameters(&self) -> Value { 459 + json!({ 460 + "type": "object", 461 + "properties": { 462 + "node_id": { 463 + "type": "string", 464 + "description": "Task node ID" 465 + }, 466 + "agent_id": { 467 + "type": "string", 468 + "description": "Agent ID claiming the task" 469 + } 470 + }, 471 + "required": ["node_id", "agent_id"] 472 + }) 473 + } 474 + 475 + async fn execute(&self, params: Value) -> Result<String> { 476 + let node_id = params["node_id"] 477 + .as_str() 478 + .context("Missing 'node_id' parameter")?; 479 + 480 + let agent_id = params["agent_id"] 481 + .as_str() 482 + .context("Missing 'agent_id' parameter")?; 483 + 484 + let claimed = self.store.claim_task(node_id, agent_id).await?; 485 + 486 + Ok(json!({ 487 + "claimed": claimed, 488 + "message": if claimed { 489 + "Task claimed successfully" 490 + } else { 491 + "Task was not in Ready state (may have been claimed by another agent)" 492 + } 493 + }) 494 + .to_string()) 495 + } 496 + } 497 + 498 + /// High-level tool for logging a decision with options 499 + pub struct LogDecisionTool { 500 + store: Arc<dyn GraphStore>, 501 + } 502 + 503 + impl LogDecisionTool { 504 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 505 + Self { store } 506 + } 507 + } 508 + 509 + #[async_trait] 510 + impl Tool for LogDecisionTool { 511 + fn name(&self) -> &str { 512 + "log_decision" 513 + } 514 + 515 + fn description(&self) -> &str { 516 + "Log a decision point with multiple options" 517 + } 518 + 519 + fn parameters(&self) -> Value { 520 + json!({ 521 + "type": "object", 522 + "properties": { 523 + "title": { 524 + "type": "string", 525 + "description": "Decision title" 526 + }, 527 + "description": { 528 + "type": "string", 529 + "description": "Decision description" 530 + }, 531 + "project_id": { 532 + "type": "string", 533 + "description": "Project ID" 534 + }, 535 + "parent_id": { 536 + "type": "string", 537 + "description": "Parent node ID (optional)" 538 + }, 539 + "options": { 540 + "type": "array", 541 + "items": { 542 + "type": "object", 543 + "properties": { 544 + "title": { "type": "string" }, 545 + "description": { "type": "string" }, 546 + "pros": { "type": "string" }, 547 + "cons": { "type": "string" } 548 + }, 549 + "required": ["title", "description"] 550 + }, 551 + "description": "Options for this decision" 552 + } 553 + }, 554 + "required": ["title", "description", "project_id", "options"] 555 + }) 556 + } 557 + 558 + async fn execute(&self, params: Value) -> Result<String> { 559 + let title = params["title"] 560 + .as_str() 561 + .context("Missing 'title' parameter")? 562 + .to_string(); 563 + 564 + let description = params["description"] 565 + .as_str() 566 + .context("Missing 'description' parameter")? 567 + .to_string(); 568 + 569 + let project_id = params["project_id"] 570 + .as_str() 571 + .context("Missing 'project_id' parameter")? 572 + .to_string(); 573 + 574 + let parent_id_opt = params["parent_id"].as_str().map(|s| s.to_string()); 575 + 576 + let options_arr = params["options"] 577 + .as_array() 578 + .context("Missing 'options' parameter")?; 579 + 580 + // Create the decision node 581 + let decision_id = if let Some(p_id) = &parent_id_opt { 582 + let seq = self.store.next_child_seq(p_id).await?; 583 + generate_child_id(p_id, seq) 584 + } else { 585 + generate_goal_id() 586 + }; 587 + 588 + let decision_node = GraphNode { 589 + id: decision_id.clone(), 590 + project_id: project_id.clone(), 591 + node_type: NodeType::Decision, 592 + title, 593 + description, 594 + status: NodeStatus::Active, 595 + priority: None, 596 + assigned_to: None, 597 + created_by: None, 598 + labels: vec![], 599 + created_at: Utc::now(), 600 + started_at: None, 601 + completed_at: None, 602 + blocked_reason: None, 603 + metadata: HashMap::new(), 604 + }; 605 + 606 + self.store.create_node(&decision_node).await?; 607 + 608 + // Create option nodes and LeadsTo edges 609 + let mut option_ids = vec![]; 610 + for option in options_arr { 611 + let opt_title = option["title"] 612 + .as_str() 613 + .context("Missing option title")? 614 + .to_string(); 615 + 616 + let opt_desc = option["description"] 617 + .as_str() 618 + .context("Missing option description")? 619 + .to_string(); 620 + 621 + let seq = self.store.next_child_seq(&decision_id).await?; 622 + let option_id = generate_child_id(&decision_id, seq); 623 + 624 + let mut opt_metadata = HashMap::new(); 625 + if let Some(pros) = option["pros"].as_str() { 626 + opt_metadata.insert("pros".to_string(), pros.to_string()); 627 + } 628 + if let Some(cons) = option["cons"].as_str() { 629 + opt_metadata.insert("cons".to_string(), cons.to_string()); 630 + } 631 + 632 + let option_node = GraphNode { 633 + id: option_id.clone(), 634 + project_id: project_id.clone(), 635 + node_type: NodeType::Option, 636 + title: opt_title, 637 + description: opt_desc, 638 + status: NodeStatus::Active, 639 + priority: None, 640 + assigned_to: None, 641 + created_by: None, 642 + labels: vec![], 643 + created_at: Utc::now(), 644 + started_at: None, 645 + completed_at: None, 646 + blocked_reason: None, 647 + metadata: opt_metadata, 648 + }; 649 + 650 + self.store.create_node(&option_node).await?; 651 + 652 + // Create LeadsTo edge from decision to option 653 + let edge = GraphEdge { 654 + id: generate_edge_id(), 655 + edge_type: EdgeType::LeadsTo, 656 + from_node: decision_id.clone(), 657 + to_node: option_id.clone(), 658 + label: None, 659 + created_at: Utc::now(), 660 + }; 661 + 662 + self.store.add_edge(&edge).await?; 663 + option_ids.push(option_id); 664 + } 665 + 666 + Ok(json!({ 667 + "decision_id": decision_id, 668 + "option_ids": option_ids, 669 + "message": "Decision and options created successfully" 670 + }) 671 + .to_string()) 672 + } 673 + } 674 + 675 + /// High-level tool for choosing an option 676 + pub struct ChooseOptionTool { 677 + store: Arc<dyn GraphStore>, 678 + } 679 + 680 + impl ChooseOptionTool { 681 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 682 + Self { store } 683 + } 684 + } 685 + 686 + #[async_trait] 687 + impl Tool for ChooseOptionTool { 688 + fn name(&self) -> &str { 689 + "choose_option" 690 + } 691 + 692 + fn description(&self) -> &str { 693 + "Choose an option for a decision" 694 + } 695 + 696 + fn parameters(&self) -> Value { 697 + json!({ 698 + "type": "object", 699 + "properties": { 700 + "decision_id": { 701 + "type": "string", 702 + "description": "Decision node ID" 703 + }, 704 + "option_id": { 705 + "type": "string", 706 + "description": "Option node ID to choose" 707 + }, 708 + "rationale": { 709 + "type": "string", 710 + "description": "Rationale for the choice" 711 + } 712 + }, 713 + "required": ["decision_id", "option_id", "rationale"] 714 + }) 715 + } 716 + 717 + async fn execute(&self, params: Value) -> Result<String> { 718 + let decision_id = params["decision_id"] 719 + .as_str() 720 + .context("Missing 'decision_id' parameter")?; 721 + 722 + let option_id = params["option_id"] 723 + .as_str() 724 + .context("Missing 'option_id' parameter")?; 725 + 726 + let rationale = params["rationale"] 727 + .as_str() 728 + .context("Missing 'rationale' parameter")?; 729 + 730 + // Add Chosen edge from decision to chosen option 731 + let chosen_edge = GraphEdge { 732 + id: generate_edge_id(), 733 + edge_type: EdgeType::Chosen, 734 + from_node: decision_id.to_string(), 735 + to_node: option_id.to_string(), 736 + label: Some(rationale.to_string()), 737 + created_at: Utc::now(), 738 + }; 739 + 740 + self.store.add_edge(&chosen_edge).await?; 741 + 742 + // Update chosen option status to Chosen 743 + self.store 744 + .update_node(option_id, Some(NodeStatus::Chosen), None, None, None, None) 745 + .await?; 746 + 747 + // Find other options and add Rejected edges 748 + // Query all options under this decision 749 + let query = NodeQuery { 750 + node_type: Some(NodeType::Option), 751 + status: None, 752 + project_id: None, 753 + parent_id: Some(decision_id.to_string()), 754 + query: None, 755 + }; 756 + 757 + let options = self.store.query_nodes(&query).await?; 758 + for option in options { 759 + if option.id != option_id { 760 + // Add Rejected edge from decision to this option 761 + let rejected_edge = GraphEdge { 762 + id: generate_edge_id(), 763 + edge_type: EdgeType::Rejected, 764 + from_node: decision_id.to_string(), 765 + to_node: option.id.clone(), 766 + label: None, 767 + created_at: Utc::now(), 768 + }; 769 + 770 + self.store.add_edge(&rejected_edge).await?; 771 + 772 + // Update option status to Rejected 773 + self.store 774 + .update_node( 775 + &option.id, 776 + Some(NodeStatus::Rejected), 777 + None, 778 + None, 779 + None, 780 + None, 781 + ) 782 + .await?; 783 + } 784 + } 785 + 786 + // Update decision status to Decided 787 + self.store 788 + .update_node( 789 + decision_id, 790 + Some(NodeStatus::Decided), 791 + None, 792 + None, 793 + None, 794 + None, 795 + ) 796 + .await?; 797 + 798 + Ok(json!({ 799 + "message": "Option chosen successfully" 800 + }) 801 + .to_string()) 802 + } 803 + } 804 + 805 + /// High-level tool for recording an outcome 806 + pub struct RecordOutcomeTool { 807 + store: Arc<dyn GraphStore>, 808 + } 809 + 810 + impl RecordOutcomeTool { 811 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 812 + Self { store } 813 + } 814 + } 815 + 816 + #[async_trait] 817 + impl Tool for RecordOutcomeTool { 818 + fn name(&self) -> &str { 819 + "record_outcome" 820 + } 821 + 822 + fn description(&self) -> &str { 823 + "Record the outcome of a task or decision" 824 + } 825 + 826 + fn parameters(&self) -> Value { 827 + json!({ 828 + "type": "object", 829 + "properties": { 830 + "parent_id": { 831 + "type": "string", 832 + "description": "Parent node ID (task or decision)" 833 + }, 834 + "title": { 835 + "type": "string", 836 + "description": "Outcome title" 837 + }, 838 + "description": { 839 + "type": "string", 840 + "description": "Outcome description" 841 + }, 842 + "project_id": { 843 + "type": "string", 844 + "description": "Project ID" 845 + }, 846 + "success": { 847 + "type": "boolean", 848 + "description": "Whether the outcome was successful" 849 + } 850 + }, 851 + "required": ["parent_id", "title", "description", "project_id", "success"] 852 + }) 853 + } 854 + 855 + async fn execute(&self, params: Value) -> Result<String> { 856 + let parent_id = params["parent_id"] 857 + .as_str() 858 + .context("Missing 'parent_id' parameter")?; 859 + 860 + let title = params["title"] 861 + .as_str() 862 + .context("Missing 'title' parameter")? 863 + .to_string(); 864 + 865 + let description = params["description"] 866 + .as_str() 867 + .context("Missing 'description' parameter")? 868 + .to_string(); 869 + 870 + let project_id = params["project_id"] 871 + .as_str() 872 + .context("Missing 'project_id' parameter")? 873 + .to_string(); 874 + 875 + let success = params["success"] 876 + .as_bool() 877 + .context("Missing 'success' parameter")?; 878 + 879 + // Create outcome node 880 + let seq = self.store.next_child_seq(parent_id).await?; 881 + let outcome_id = generate_child_id(parent_id, seq); 882 + 883 + let mut metadata = HashMap::new(); 884 + metadata.insert("success".to_string(), success.to_string()); 885 + 886 + let outcome_node = GraphNode { 887 + id: outcome_id.clone(), 888 + project_id, 889 + node_type: NodeType::Outcome, 890 + title, 891 + description, 892 + status: NodeStatus::Completed, 893 + priority: None, 894 + assigned_to: None, 895 + created_by: None, 896 + labels: vec![], 897 + created_at: Utc::now(), 898 + started_at: None, 899 + completed_at: Some(Utc::now()), 900 + blocked_reason: None, 901 + metadata, 902 + }; 903 + 904 + self.store.create_node(&outcome_node).await?; 905 + 906 + // Create LeadsTo edge from parent to outcome 907 + let edge = GraphEdge { 908 + id: generate_edge_id(), 909 + edge_type: EdgeType::LeadsTo, 910 + from_node: parent_id.to_string(), 911 + to_node: outcome_id.clone(), 912 + label: None, 913 + created_at: Utc::now(), 914 + }; 915 + 916 + self.store.add_edge(&edge).await?; 917 + 918 + Ok(json!({ 919 + "outcome_id": outcome_id, 920 + "message": "Outcome recorded successfully" 921 + }) 922 + .to_string()) 923 + } 924 + } 925 + 926 + /// High-level tool for recording an observation 927 + pub struct RecordObservationTool { 928 + store: Arc<dyn GraphStore>, 929 + } 930 + 931 + impl RecordObservationTool { 932 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 933 + Self { store } 934 + } 935 + } 936 + 937 + #[async_trait] 938 + impl Tool for RecordObservationTool { 939 + fn name(&self) -> &str { 940 + "record_observation" 941 + } 942 + 943 + fn description(&self) -> &str { 944 + "Record an observation about the work" 945 + } 946 + 947 + fn parameters(&self) -> Value { 948 + json!({ 949 + "type": "object", 950 + "properties": { 951 + "title": { 952 + "type": "string", 953 + "description": "Observation title" 954 + }, 955 + "description": { 956 + "type": "string", 957 + "description": "Observation description" 958 + }, 959 + "project_id": { 960 + "type": "string", 961 + "description": "Project ID" 962 + }, 963 + "related_node_id": { 964 + "type": "string", 965 + "description": "Related node ID for Informs edge (optional)" 966 + } 967 + }, 968 + "required": ["title", "description", "project_id"] 969 + }) 970 + } 971 + 972 + async fn execute(&self, params: Value) -> Result<String> { 973 + let title = params["title"] 974 + .as_str() 975 + .context("Missing 'title' parameter")? 976 + .to_string(); 977 + 978 + let description = params["description"] 979 + .as_str() 980 + .context("Missing 'description' parameter")? 981 + .to_string(); 982 + 983 + let project_id = params["project_id"] 984 + .as_str() 985 + .context("Missing 'project_id' parameter")? 986 + .to_string(); 987 + 988 + let related_node_id = params["related_node_id"].as_str().map(|s| s.to_string()); 989 + 990 + // Create observation node 991 + let observation_id = generate_goal_id(); 992 + 993 + let observation_node = GraphNode { 994 + id: observation_id.clone(), 995 + project_id, 996 + node_type: NodeType::Observation, 997 + title, 998 + description, 999 + status: NodeStatus::Active, 1000 + priority: None, 1001 + assigned_to: None, 1002 + created_by: None, 1003 + labels: vec![], 1004 + created_at: Utc::now(), 1005 + started_at: None, 1006 + completed_at: None, 1007 + blocked_reason: None, 1008 + metadata: HashMap::new(), 1009 + }; 1010 + 1011 + self.store.create_node(&observation_node).await?; 1012 + 1013 + // Create Informs edge if related node provided 1014 + if let Some(related_id) = related_node_id { 1015 + let edge = GraphEdge { 1016 + id: generate_edge_id(), 1017 + edge_type: EdgeType::Informs, 1018 + from_node: observation_id.clone(), 1019 + to_node: related_id, 1020 + label: None, 1021 + created_at: Utc::now(), 1022 + }; 1023 + 1024 + self.store.add_edge(&edge).await?; 1025 + } 1026 + 1027 + Ok(json!({ 1028 + "observation_id": observation_id, 1029 + "message": "Observation recorded successfully" 1030 + }) 1031 + .to_string()) 1032 + } 1033 + } 1034 + 1035 + /// High-level tool for revisiting a decision 1036 + pub struct RevisitTool { 1037 + store: Arc<dyn GraphStore>, 1038 + } 1039 + 1040 + impl RevisitTool { 1041 + pub fn new(store: Arc<dyn GraphStore>) -> Self { 1042 + Self { store } 1043 + } 1044 + } 1045 + 1046 + #[async_trait] 1047 + impl Tool for RevisitTool { 1048 + fn name(&self) -> &str { 1049 + "revisit" 1050 + } 1051 + 1052 + fn description(&self) -> &str { 1053 + "Revisit and potentially revise a past decision based on an outcome" 1054 + } 1055 + 1056 + fn parameters(&self) -> Value { 1057 + json!({ 1058 + "type": "object", 1059 + "properties": { 1060 + "outcome_id": { 1061 + "type": "string", 1062 + "description": "Outcome node ID" 1063 + }, 1064 + "project_id": { 1065 + "type": "string", 1066 + "description": "Project ID" 1067 + }, 1068 + "reason": { 1069 + "type": "string", 1070 + "description": "Reason for revisiting" 1071 + }, 1072 + "new_decision_title": { 1073 + "type": "string", 1074 + "description": "Title for new decision if creating one (optional)" 1075 + } 1076 + }, 1077 + "required": ["outcome_id", "project_id", "reason"] 1078 + }) 1079 + } 1080 + 1081 + async fn execute(&self, params: Value) -> Result<String> { 1082 + let outcome_id = params["outcome_id"] 1083 + .as_str() 1084 + .context("Missing 'outcome_id' parameter")?; 1085 + 1086 + let project_id = params["project_id"] 1087 + .as_str() 1088 + .context("Missing 'project_id' parameter")? 1089 + .to_string(); 1090 + 1091 + let reason = params["reason"] 1092 + .as_str() 1093 + .context("Missing 'reason' parameter")? 1094 + .to_string(); 1095 + 1096 + let new_decision_title = params["new_decision_title"].as_str(); 1097 + 1098 + // Create revisit node 1099 + let revisit_id = generate_goal_id(); 1100 + 1101 + let revisit_node = GraphNode { 1102 + id: revisit_id.clone(), 1103 + project_id: project_id.clone(), 1104 + node_type: NodeType::Revisit, 1105 + title: format!("Revisit of {}", outcome_id), 1106 + description: reason, 1107 + status: NodeStatus::Active, 1108 + priority: None, 1109 + assigned_to: None, 1110 + created_by: None, 1111 + labels: vec![], 1112 + created_at: Utc::now(), 1113 + started_at: None, 1114 + completed_at: None, 1115 + blocked_reason: None, 1116 + metadata: HashMap::new(), 1117 + }; 1118 + 1119 + self.store.create_node(&revisit_node).await?; 1120 + 1121 + // Create LeadsTo edge from outcome to revisit 1122 + let edge = GraphEdge { 1123 + id: generate_edge_id(), 1124 + edge_type: EdgeType::LeadsTo, 1125 + from_node: outcome_id.to_string(), 1126 + to_node: revisit_id.clone(), 1127 + label: None, 1128 + created_at: Utc::now(), 1129 + }; 1130 + 1131 + self.store.add_edge(&edge).await?; 1132 + 1133 + let mut result = json!({ 1134 + "revisit_id": revisit_id, 1135 + "message": "Revisit recorded successfully" 1136 + }); 1137 + 1138 + // Create new decision if title provided 1139 + if let Some(title) = new_decision_title { 1140 + let decision_id = generate_goal_id(); 1141 + 1142 + let decision_node = GraphNode { 1143 + id: decision_id.clone(), 1144 + project_id, 1145 + node_type: NodeType::Decision, 1146 + title: title.to_string(), 1147 + description: "Decision created from revisit".to_string(), 1148 + status: NodeStatus::Pending, 1149 + priority: None, 1150 + assigned_to: None, 1151 + created_by: None, 1152 + labels: vec![], 1153 + created_at: Utc::now(), 1154 + started_at: None, 1155 + completed_at: None, 1156 + blocked_reason: None, 1157 + metadata: HashMap::new(), 1158 + }; 1159 + 1160 + self.store.create_node(&decision_node).await?; 1161 + 1162 + // Create LeadsTo edge from revisit to new decision 1163 + let decision_edge = GraphEdge { 1164 + id: generate_edge_id(), 1165 + edge_type: EdgeType::LeadsTo, 1166 + from_node: revisit_id, 1167 + to_node: decision_id.clone(), 1168 + label: None, 1169 + created_at: Utc::now(), 1170 + }; 1171 + 1172 + self.store.add_edge(&decision_edge).await?; 1173 + 1174 + if let Some(obj) = result.as_object_mut() { 1175 + obj.insert("decision_id".to_string(), json!(decision_id)); 1176 + } 1177 + } 1178 + 1179 + Ok(result.to_string()) 1180 + } 1181 + }
+1
src/tools/mod.rs
··· 82 82 83 83 pub mod factory; 84 84 pub mod file; 85 + pub mod graph_tools; 85 86 pub mod permission_check; 86 87 pub mod shell; 87 88 pub mod signal;
-377
src/tui/app.rs
··· 1 - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 2 - 3 - use crate::config::Config; 4 - use crate::tui::messages::{AgentMessage, AgentSender}; 5 - use crate::tui::views::{ 6 - DashboardMode, DashboardState, ExecutionState, MessageRole, NavDirection, OutputItem, 7 - PlanningState, ToolCall, 8 - }; 9 - use crate::tui::widgets::{HelpOverlay, SidePanel, Spinner}; 10 - 11 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 12 - pub enum ActiveTab { 13 - Dashboard, 14 - Planning, 15 - Execution, 16 - } 17 - 18 - pub struct App { 19 - pub running: bool, 20 - pub active_tab: ActiveTab, 21 - pub dashboard: DashboardState, 22 - pub planning: PlanningState, 23 - pub execution: ExecutionState, 24 - pub side_panel: SidePanel, 25 - pub spec_dir: String, 26 - pub agent_tx: AgentSender, 27 - pub config: Option<Config>, 28 - pub spinner: Spinner, 29 - pub help: HelpOverlay, 30 - pub planning_active: bool, 31 - pub execution_active: bool, 32 - } 33 - 34 - impl App { 35 - pub fn new(spec_dir: &str, agent_tx: AgentSender, config: Option<Config>) -> Self { 36 - let mut dashboard = DashboardState::new(); 37 - dashboard.load_specs(spec_dir); 38 - 39 - Self { 40 - running: true, 41 - active_tab: ActiveTab::Dashboard, 42 - dashboard, 43 - planning: PlanningState::new(), 44 - execution: ExecutionState::new(), 45 - side_panel: SidePanel::new(), 46 - spec_dir: spec_dir.to_string(), 47 - agent_tx, 48 - config, 49 - spinner: Spinner::new(), 50 - help: HelpOverlay::new(), 51 - planning_active: false, 52 - execution_active: false, 53 - } 54 - } 55 - 56 - /// Handle key events. Uses full KeyEvent to preserve modifiers. 57 - pub fn handle_key(&mut self, key: KeyEvent) { 58 - // Help overlay is modal - only Esc closes it 59 - if self.help.visible { 60 - if key.code == KeyCode::Esc { 61 - self.help.visible = false; 62 - } 63 - return; 64 - } 65 - 66 - // Handle planning insert mode separately 67 - if self.active_tab == ActiveTab::Planning && self.planning.insert_mode { 68 - match key.code { 69 - KeyCode::Esc => { 70 - self.planning.insert_mode = false; 71 - } 72 - KeyCode::Enter => { 73 - if let Some(text) = self.planning.submit_input() { 74 - self.planning.add_message(MessageRole::User, text.clone()); 75 - 76 - // Don't spawn if already running 77 - if self.planning.thinking { 78 - return; 79 - } 80 - 81 - // Spawn planning agent if we have config 82 - if let Some(ref config) = self.config { 83 - self.planning.thinking = true; 84 - let tx = self.agent_tx.clone(); 85 - let spec_dir = self.spec_dir.clone(); 86 - let config = config.clone(); 87 - 88 - tokio::spawn(async move { 89 - match crate::planning::PlanningAgent::new(config, spec_dir) { 90 - Ok(mut agent) => { 91 - if let Err(e) = 92 - agent.run_with_sender(tx.clone(), text).await 93 - { 94 - let _ = tx 95 - .send(AgentMessage::PlanningError(e.to_string())) 96 - .await; 97 - } 98 - } 99 - Err(e) => { 100 - let _ = tx 101 - .send(AgentMessage::PlanningError(e.to_string())) 102 - .await; 103 - } 104 - } 105 - }); 106 - } 107 - } 108 - } 109 - _ => { 110 - self.planning.input.input(key); 111 - } 112 - } 113 - return; 114 - } 115 - 116 - match (key.code, key.modifiers) { 117 - (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.running = false, 118 - (KeyCode::Char('q'), KeyModifiers::NONE) => self.running = false, 119 - (KeyCode::Char('1'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Dashboard, 120 - (KeyCode::Char('2'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Planning, 121 - (KeyCode::Char('3'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Execution, 122 - (KeyCode::Tab, _) => { 123 - self.active_tab = match self.active_tab { 124 - ActiveTab::Dashboard => ActiveTab::Planning, 125 - ActiveTab::Planning => ActiveTab::Execution, 126 - ActiveTab::Execution => ActiveTab::Dashboard, 127 - }; 128 - } 129 - (KeyCode::Char('K'), KeyModifiers::SHIFT) 130 - if self.active_tab == ActiveTab::Dashboard => 131 - { 132 - self.dashboard.mode = DashboardMode::Kanban; 133 - } 134 - (KeyCode::Char('A'), KeyModifiers::SHIFT) 135 - if self.active_tab == ActiveTab::Dashboard => 136 - { 137 - self.dashboard.mode = DashboardMode::Activity; 138 - } 139 - (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) 140 - if self.active_tab == ActiveTab::Dashboard => 141 - { 142 - self.dashboard.move_selection(NavDirection::Up); 143 - } 144 - (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) 145 - if self.active_tab == ActiveTab::Dashboard => 146 - { 147 - self.dashboard.move_selection(NavDirection::Down); 148 - } 149 - (KeyCode::Left, KeyModifiers::NONE) | (KeyCode::Char('h'), KeyModifiers::NONE) 150 - if self.active_tab == ActiveTab::Dashboard => 151 - { 152 - self.dashboard.move_selection(NavDirection::Left); 153 - } 154 - (KeyCode::Right, KeyModifiers::NONE) | (KeyCode::Char('l'), KeyModifiers::NONE) 155 - if self.active_tab == ActiveTab::Dashboard => 156 - { 157 - self.dashboard.move_selection(NavDirection::Right); 158 - } 159 - (KeyCode::Enter, KeyModifiers::NONE) if self.active_tab == ActiveTab::Dashboard => { 160 - // Don't spawn if execution already running 161 - if self.execution.running { 162 - return; 163 - } 164 - 165 - if let Some(spec) = self.dashboard.selected_spec() { 166 - let spec_path = spec.path.clone(); 167 - 168 - if let Some(ref config) = self.config { 169 - let tx = self.agent_tx.clone(); 170 - let config = config.clone(); 171 - 172 - // Switch to execution tab 173 - self.active_tab = ActiveTab::Execution; 174 - 175 - tokio::spawn(async move { 176 - match crate::ralph::RalphLoop::new(config, spec_path, None) { 177 - Ok(ralph) => { 178 - if let Err(e) = ralph.run_with_sender(tx.clone()).await { 179 - let _ = tx 180 - .send(AgentMessage::ExecutionError(e.to_string())) 181 - .await; 182 - } 183 - } 184 - Err(e) => { 185 - let _ = 186 - tx.send(AgentMessage::ExecutionError(e.to_string())).await; 187 - } 188 - } 189 - }); 190 - } 191 - } 192 - } 193 - (KeyCode::Char('i'), KeyModifiers::NONE) if self.active_tab == ActiveTab::Planning => { 194 - self.planning.insert_mode = true; 195 - } 196 - // Planning tab scrolling (not in insert mode) 197 - (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) 198 - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 199 - { 200 - self.planning.scroll_up(1); 201 - } 202 - (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) 203 - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 204 - { 205 - self.planning.scroll_down(1); 206 - } 207 - (KeyCode::PageUp, KeyModifiers::NONE) 208 - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 209 - { 210 - let page_size = self.planning.viewport_height.saturating_sub(1).max(1); 211 - self.planning.scroll_up(page_size); 212 - } 213 - (KeyCode::PageDown, KeyModifiers::NONE) 214 - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 215 - { 216 - let page_size = self.planning.viewport_height.saturating_sub(1).max(1); 217 - self.planning.scroll_down(page_size); 218 - } 219 - (KeyCode::Home, KeyModifiers::NONE) 220 - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 221 - { 222 - self.planning.scroll_offset = 0; 223 - self.planning.auto_scroll = false; 224 - } 225 - (KeyCode::End, KeyModifiers::NONE) 226 - if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 227 - { 228 - self.planning.scroll_to_bottom(); 229 - } 230 - // Execution tab scrolling 231 - (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) 232 - if self.active_tab == ActiveTab::Execution => 233 - { 234 - self.execution.scroll_up(1); 235 - } 236 - (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) 237 - if self.active_tab == ActiveTab::Execution => 238 - { 239 - self.execution.scroll_down(1); 240 - } 241 - (KeyCode::PageUp, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 242 - let page_size = self.execution.viewport_height.saturating_sub(1).max(1); 243 - self.execution.scroll_up(page_size); 244 - } 245 - (KeyCode::PageDown, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 246 - let page_size = self.execution.viewport_height.saturating_sub(1).max(1); 247 - self.execution.scroll_down(page_size); 248 - } 249 - (KeyCode::Home, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 250 - self.execution.scroll_offset = 0; 251 - self.execution.auto_scroll = false; 252 - } 253 - (KeyCode::End, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 254 - self.execution.scroll_to_bottom(); 255 - } 256 - (KeyCode::Char('['), KeyModifiers::NONE) | (KeyCode::Char(']'), KeyModifiers::NONE) => { 257 - self.side_panel.toggle(); 258 - } 259 - (KeyCode::Char('?'), KeyModifiers::NONE) => { 260 - self.help.toggle(); 261 - } 262 - (KeyCode::Esc, _) => { 263 - if self.side_panel.visible { 264 - self.side_panel.visible = false; 265 - } 266 - } 267 - _ => {} 268 - } 269 - } 270 - 271 - pub fn handle_agent_message(&mut self, msg: AgentMessage) { 272 - match msg { 273 - // Planning messages 274 - AgentMessage::PlanningStarted => { 275 - self.planning.thinking = true; 276 - } 277 - AgentMessage::PlanningResponse(text) => { 278 - self.planning.thinking = false; 279 - self.planning.add_message(MessageRole::Assistant, text); 280 - } 281 - AgentMessage::PlanningToolCall { name, args: _ } => { 282 - self.planning 283 - .add_message(MessageRole::Assistant, format!("[Calling tool: {}]", name)); 284 - } 285 - AgentMessage::PlanningToolResult { name, output } => { 286 - let preview = if output.len() > 100 { 287 - format!("{}...", &output[..100]) 288 - } else { 289 - output 290 - }; 291 - self.planning.add_message( 292 - MessageRole::Assistant, 293 - format!("[{} result: {}]", name, preview), 294 - ); 295 - } 296 - AgentMessage::PlanningComplete { spec_path } => { 297 - self.planning.thinking = false; 298 - self.planning.add_message( 299 - MessageRole::Assistant, 300 - format!("✓ Spec saved to {}", spec_path), 301 - ); 302 - self.dashboard.load_specs(&self.spec_dir); 303 - } 304 - AgentMessage::PlanningError(err) => { 305 - self.planning.thinking = false; 306 - self.planning 307 - .add_message(MessageRole::Assistant, format!("Error: {}", err)); 308 - } 309 - 310 - // Execution messages 311 - AgentMessage::ExecutionStarted { spec_path: _ } => { 312 - self.execution.running = true; 313 - self.execution.output.clear(); 314 - } 315 - AgentMessage::TaskStarted { task_id: _, title } => { 316 - self.execution.add_output(OutputItem::Message { 317 - role: "System".to_string(), 318 - content: format!("Starting task: {}", title), 319 - }); 320 - } 321 - AgentMessage::TaskResponse(text) => { 322 - self.execution.add_output(OutputItem::Message { 323 - role: "Assistant".to_string(), 324 - content: text, 325 - }); 326 - } 327 - AgentMessage::TaskToolCall { name, args } => { 328 - self.execution.add_output(OutputItem::ToolCall(ToolCall { 329 - name, 330 - output: format!("Args: {}", args), 331 - collapsed: true, 332 - })); 333 - } 334 - AgentMessage::TaskToolResult { name, output } => { 335 - self.execution.add_output(OutputItem::ToolCall(ToolCall { 336 - name, 337 - output, 338 - collapsed: false, 339 - })); 340 - } 341 - AgentMessage::TaskComplete { task_id } => { 342 - self.execution.add_output(OutputItem::Message { 343 - role: "System".to_string(), 344 - content: format!("✓ Task {} complete", task_id), 345 - }); 346 - } 347 - AgentMessage::TaskBlocked { task_id, reason } => { 348 - self.execution.add_output(OutputItem::Message { 349 - role: "System".to_string(), 350 - content: format!("✗ Task {} blocked: {}", task_id, reason), 351 - }); 352 - } 353 - AgentMessage::ExecutionComplete => { 354 - self.execution.running = false; 355 - self.execution.add_output(OutputItem::Message { 356 - role: "System".to_string(), 357 - content: "Execution complete".to_string(), 358 - }); 359 - self.dashboard.load_specs(&self.spec_dir); 360 - } 361 - AgentMessage::ExecutionError(err) => { 362 - self.execution.running = false; 363 - self.execution.add_output(OutputItem::Message { 364 - role: "Error".to_string(), 365 - content: err, 366 - }); 367 - } 368 - } 369 - } 370 - } 371 - 372 - impl Default for App { 373 - fn default() -> Self { 374 - let (tx, _) = crate::tui::messages::agent_channel(); 375 - Self::new("", tx, None) 376 - } 377 - }
-30
src/tui/messages.rs
··· 1 - use tokio::sync::mpsc; 2 - 3 - #[derive(Debug, Clone)] 4 - pub enum AgentMessage { 5 - // Planning agent messages 6 - PlanningStarted, 7 - PlanningResponse(String), 8 - PlanningToolCall { name: String, args: String }, 9 - PlanningToolResult { name: String, output: String }, 10 - PlanningComplete { spec_path: String }, 11 - PlanningError(String), 12 - 13 - // Execution agent messages 14 - ExecutionStarted { spec_path: String }, 15 - TaskStarted { task_id: String, title: String }, 16 - TaskResponse(String), 17 - TaskToolCall { name: String, args: String }, 18 - TaskToolResult { name: String, output: String }, 19 - TaskComplete { task_id: String }, 20 - TaskBlocked { task_id: String, reason: String }, 21 - ExecutionComplete, 22 - ExecutionError(String), 23 - } 24 - 25 - pub type AgentSender = mpsc::Sender<AgentMessage>; 26 - pub type AgentReceiver = mpsc::Receiver<AgentMessage>; 27 - 28 - pub fn agent_channel() -> (AgentSender, AgentReceiver) { 29 - mpsc::channel(100) 30 - }
-99
src/tui/mod.rs
··· 1 - mod app; 2 - pub mod messages; 3 - mod ui; 4 - pub mod views; 5 - pub mod widgets; 6 - 7 - pub use messages::{AgentMessage, AgentReceiver, AgentSender, agent_channel}; 8 - 9 - pub use app::{ActiveTab, App}; 10 - pub use ui::draw; 11 - 12 - use crossterm::{ 13 - event::{self, Event, KeyEventKind}, 14 - execute, 15 - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 16 - }; 17 - use ratatui::{Terminal, backend::CrosstermBackend}; 18 - use std::io; 19 - use std::panic; 20 - use std::time::Duration; 21 - use tokio::sync::mpsc::Receiver; 22 - 23 - pub type Tui = Terminal<CrosstermBackend<io::Stdout>>; 24 - 25 - pub fn setup_terminal() -> io::Result<Tui> { 26 - enable_raw_mode()?; 27 - let mut stdout = io::stdout(); 28 - execute!(stdout, EnterAlternateScreen)?; 29 - let backend = CrosstermBackend::new(stdout); 30 - Terminal::new(backend) 31 - } 32 - 33 - pub fn restore_terminal(terminal: &mut Tui) -> io::Result<()> { 34 - disable_raw_mode()?; 35 - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 36 - terminal.show_cursor()?; 37 - Ok(()) 38 - } 39 - 40 - /// Run the TUI event loop with panic safety. 41 - pub async fn run( 42 - terminal: &mut Tui, 43 - app: &mut App, 44 - agent_rx: &mut Receiver<AgentMessage>, 45 - ) -> anyhow::Result<()> { 46 - let original_hook = panic::take_hook(); 47 - panic::set_hook(Box::new(|info| { 48 - let _ = disable_raw_mode(); 49 - let _ = execute!(io::stdout(), LeaveAlternateScreen); 50 - eprintln!("Panic: {}", info); 51 - })); 52 - 53 - let result = run_loop(terminal, app, agent_rx).await; 54 - 55 - // Restore original panic hook 56 - let _ = panic::take_hook(); 57 - panic::set_hook(original_hook); 58 - 59 - result 60 - } 61 - 62 - async fn run_loop( 63 - terminal: &mut Tui, 64 - app: &mut App, 65 - agent_rx: &mut Receiver<AgentMessage>, 66 - ) -> anyhow::Result<()> { 67 - let mut interval = tokio::time::interval(Duration::from_millis(50)); 68 - 69 - loop { 70 - if !app.running { 71 - break; 72 - } 73 - 74 - terminal.draw(|frame| crate::tui::ui::draw(frame, app))?; 75 - 76 - tokio::select! { 77 - _ = interval.tick() => { 78 - app.spinner.tick(); 79 - 80 - while event::poll(Duration::ZERO)? { 81 - match event::read()? { 82 - Event::Key(key) if key.kind == KeyEventKind::Press => { 83 - app.handle_key(key); 84 - } 85 - Event::Resize(_, _) => { 86 - // Redraw handled next iteration 87 - } 88 - _ => {} 89 - } 90 - } 91 - } 92 - 93 - Some(msg) = agent_rx.recv() => { 94 - app.handle_agent_message(msg); 95 - } 96 - } 97 - } 98 - Ok(()) 99 - }
-53
src/tui/ui.rs
··· 1 - use ratatui::{ 2 - Frame, 3 - layout::{Constraint, Direction, Layout}, 4 - style::{Color, Style}, 5 - text::{Line, Span}, 6 - widgets::Paragraph, 7 - }; 8 - 9 - use crate::tui::views::{draw_dashboard, draw_execution, draw_planning}; 10 - use crate::tui::widgets::{TabBar, draw_help, draw_side_panel}; 11 - use crate::tui::{ActiveTab, App}; 12 - 13 - pub fn draw(frame: &mut Frame, app: &mut App) { 14 - let chunks = Layout::default() 15 - .direction(Direction::Vertical) 16 - .constraints([ 17 - Constraint::Length(1), // Tab bar 18 - Constraint::Min(0), // Main content 19 - Constraint::Length(1), // Status bar 20 - ]) 21 - .split(frame.area()); 22 - 23 - // Tab bar 24 - frame.render_widget(TabBar::new(app.active_tab), chunks[0]); 25 - 26 - // Main content area 27 - match app.active_tab { 28 - ActiveTab::Dashboard => { 29 - draw_dashboard(frame, chunks[1], &app.dashboard); 30 - } 31 - ActiveTab::Planning => { 32 - draw_planning(frame, chunks[1], &mut app.planning, app.spinner.current()); 33 - } 34 - ActiveTab::Execution => { 35 - draw_execution(frame, chunks[1], &mut app.execution, app.spinner.current()); 36 - } 37 - } 38 - 39 - // Side panel (rendered on top of main content) 40 - draw_side_panel(frame, chunks[1], &app.side_panel); 41 - 42 - // Help overlay (rendered on top of everything) 43 - if app.help.visible { 44 - draw_help(frame, frame.area()); 45 - } 46 - 47 - // Status bar 48 - let status = Line::from(vec![Span::raw( 49 - " q quit │ 1/2/3 switch tabs │ Tab cycle │ ? help ", 50 - )]); 51 - let status_bar = Paragraph::new(status).style(Style::default().bg(Color::DarkGray)); 52 - frame.render_widget(status_bar, chunks[2]); 53 - }
-295
src/tui/views/dashboard.rs
··· 1 - use std::fs; 2 - use std::path::Path; 3 - 4 - use ratatui::{ 5 - layout::{Constraint, Direction, Layout, Rect}, 6 - style::{Color, Modifier, Style}, 7 - text::{Line, Span}, 8 - widgets::{Block, Borders, List, ListItem, Paragraph}, 9 - }; 10 - 11 - use crate::spec::Spec; 12 - 13 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 - pub enum DashboardMode { 15 - Kanban, 16 - Activity, 17 - } 18 - 19 - pub struct DashboardState { 20 - pub mode: DashboardMode, 21 - pub specs: Vec<SpecSummary>, 22 - pub selected_column: usize, 23 - pub selected_row: usize, 24 - } 25 - 26 - #[derive(Debug, Clone)] 27 - pub struct SpecSummary { 28 - pub name: String, 29 - pub path: String, 30 - pub status: SpecStatus, 31 - pub task_progress: (usize, usize), 32 - } 33 - 34 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 35 - pub enum SpecStatus { 36 - Draft, 37 - Ready, 38 - Running, 39 - Completed, 40 - } 41 - 42 - impl DashboardState { 43 - pub fn new() -> Self { 44 - Self { 45 - mode: DashboardMode::Kanban, 46 - specs: Vec::new(), 47 - selected_column: 0, 48 - selected_row: 0, 49 - } 50 - } 51 - 52 - pub fn load_specs(&mut self, spec_dir: &str) { 53 - self.specs.clear(); 54 - 55 - let spec_path = Path::new(spec_dir); 56 - if !spec_path.exists() { 57 - return; 58 - } 59 - 60 - if let Ok(entries) = fs::read_dir(spec_path) { 61 - for entry in entries.flatten() { 62 - let path = entry.path(); 63 - 64 - // Look for spec.json files 65 - let spec_file = if path.is_dir() { 66 - path.join("spec.json") 67 - } else if path.extension().is_some_and(|e| e == "json") { 68 - path 69 - } else { 70 - continue; 71 - }; 72 - 73 - if let Ok(spec) = Spec::load(&spec_file) { 74 - let completed = spec 75 - .tasks 76 - .iter() 77 - .filter(|t| t.status == crate::spec::TaskStatus::Complete) 78 - .count(); 79 - let total = spec.tasks.len(); 80 - 81 - let status = if completed == total && total > 0 { 82 - SpecStatus::Completed 83 - } else if spec 84 - .tasks 85 - .iter() 86 - .any(|t| t.status == crate::spec::TaskStatus::InProgress) 87 - { 88 - SpecStatus::Running 89 - } else if total > 0 { 90 - SpecStatus::Ready 91 - } else { 92 - SpecStatus::Draft 93 - }; 94 - 95 - self.specs.push(SpecSummary { 96 - name: spec.name, 97 - path: spec_file.to_string_lossy().to_string(), 98 - status, 99 - task_progress: (completed, total), 100 - }); 101 - } 102 - } 103 - } 104 - 105 - // Clamp selection to valid range after reload 106 - self.clamp_selection(); 107 - } 108 - 109 - fn clamp_selection(&mut self) { 110 - let count = self.specs_in_current_column().len(); 111 - if count == 0 { 112 - self.selected_row = 0; 113 - } else { 114 - self.selected_row = self.selected_row.min(count - 1); 115 - } 116 - } 117 - 118 - pub fn selected_spec(&self) -> Option<&SpecSummary> { 119 - let statuses = [ 120 - SpecStatus::Draft, 121 - SpecStatus::Ready, 122 - SpecStatus::Running, 123 - SpecStatus::Completed, 124 - ]; 125 - let status = statuses.get(self.selected_column)?; 126 - 127 - self.specs 128 - .iter() 129 - .filter(|s| s.status == *status) 130 - .nth(self.selected_row) 131 - } 132 - 133 - pub fn move_selection(&mut self, direction: NavDirection) { 134 - match direction { 135 - NavDirection::Left => { 136 - self.selected_column = self.selected_column.saturating_sub(1); 137 - self.selected_row = 0; 138 - } 139 - NavDirection::Right => { 140 - self.selected_column = (self.selected_column + 1).min(3); 141 - self.selected_row = 0; 142 - } 143 - NavDirection::Up => { 144 - self.selected_row = self.selected_row.saturating_sub(1); 145 - } 146 - NavDirection::Down => { 147 - let count = self.specs_in_current_column().len(); 148 - if count > 0 { 149 - self.selected_row = (self.selected_row + 1).min(count - 1); 150 - } 151 - } 152 - } 153 - } 154 - 155 - fn specs_in_current_column(&self) -> Vec<&SpecSummary> { 156 - let statuses = [ 157 - SpecStatus::Draft, 158 - SpecStatus::Ready, 159 - SpecStatus::Running, 160 - SpecStatus::Completed, 161 - ]; 162 - if let Some(status) = statuses.get(self.selected_column) { 163 - self.specs.iter().filter(|s| s.status == *status).collect() 164 - } else { 165 - Vec::new() 166 - } 167 - } 168 - } 169 - 170 - #[derive(Debug, Clone, Copy)] 171 - pub enum NavDirection { 172 - Up, 173 - Down, 174 - Left, 175 - Right, 176 - } 177 - 178 - impl Default for DashboardState { 179 - fn default() -> Self { 180 - Self::new() 181 - } 182 - } 183 - 184 - pub fn draw_dashboard(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { 185 - let chunks = Layout::default() 186 - .direction(Direction::Vertical) 187 - .constraints([Constraint::Length(1), Constraint::Min(0)]) 188 - .split(area); 189 - 190 - let mode_text = match state.mode { 191 - DashboardMode::Kanban => " View: [K]anban │ Activity ", 192 - DashboardMode::Activity => " View: Kanban │ [A]ctivity ", 193 - }; 194 - let mode_bar = Paragraph::new(mode_text).style(Style::default().fg(Color::Cyan)); 195 - frame.render_widget(mode_bar, chunks[0]); 196 - 197 - match state.mode { 198 - DashboardMode::Kanban => draw_kanban(frame, chunks[1], state), 199 - DashboardMode::Activity => draw_activity(frame, chunks[1], state), 200 - } 201 - } 202 - 203 - fn draw_kanban(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { 204 - let columns = Layout::default() 205 - .direction(Direction::Horizontal) 206 - .constraints([ 207 - Constraint::Percentage(25), 208 - Constraint::Percentage(25), 209 - Constraint::Percentage(25), 210 - Constraint::Percentage(25), 211 - ]) 212 - .split(area); 213 - 214 - let column_titles = ["Draft", "Ready", "Running", "Completed"]; 215 - let statuses = [ 216 - SpecStatus::Draft, 217 - SpecStatus::Ready, 218 - SpecStatus::Running, 219 - SpecStatus::Completed, 220 - ]; 221 - 222 - for (i, (col_area, (title, status))) in columns 223 - .iter() 224 - .zip(column_titles.iter().zip(statuses.iter())) 225 - .enumerate() 226 - { 227 - let is_selected = i == state.selected_column; 228 - let style = if is_selected { 229 - Style::default().fg(Color::Yellow) 230 - } else { 231 - Style::default() 232 - }; 233 - 234 - let block = Block::default() 235 - .borders(Borders::ALL) 236 - .title(*title) 237 - .border_style(style); 238 - 239 - let specs_in_column: Vec<&SpecSummary> = 240 - state.specs.iter().filter(|s| s.status == *status).collect(); 241 - 242 - let items: Vec<ListItem> = specs_in_column 243 - .iter() 244 - .enumerate() 245 - .map(|(j, spec)| { 246 - let content = format!( 247 - "{} ({}/{})", 248 - spec.name, spec.task_progress.0, spec.task_progress.1 249 - ); 250 - let item_style = if is_selected && j == state.selected_row { 251 - Style::default() 252 - .bg(Color::DarkGray) 253 - .add_modifier(Modifier::BOLD) 254 - } else { 255 - Style::default() 256 - }; 257 - ListItem::new(content).style(item_style) 258 - }) 259 - .collect(); 260 - 261 - let list = List::new(items).block(block); 262 - frame.render_widget(list, *col_area); 263 - } 264 - } 265 - 266 - fn draw_activity(frame: &mut ratatui::Frame, area: Rect, _state: &DashboardState) { 267 - let block = Block::default() 268 - .borders(Borders::ALL) 269 - .title(" Activity Feed "); 270 - 271 - let header = Line::from(vec![ 272 - Span::styled("Time ", Style::default().add_modifier(Modifier::BOLD)), 273 - Span::styled("│ ", Style::default().fg(Color::DarkGray)), 274 - Span::styled( 275 - "Event ", 276 - Style::default().add_modifier(Modifier::BOLD), 277 - ), 278 - Span::styled("│ ", Style::default().fg(Color::DarkGray)), 279 - Span::styled( 280 - "Spec ", 281 - Style::default().add_modifier(Modifier::BOLD), 282 - ), 283 - Span::styled("│ ", Style::default().fg(Color::DarkGray)), 284 - Span::styled("Details", Style::default().add_modifier(Modifier::BOLD)), 285 - ]); 286 - 287 - let content = Paragraph::new(vec![ 288 - header, 289 - Line::from("─".repeat(area.width.saturating_sub(2) as usize)), 290 - Line::from(" No activity yet"), 291 - ]) 292 - .block(block); 293 - 294 - frame.render_widget(content, area); 295 - }
-313
src/tui/views/execution.rs
··· 1 - use ratatui::{ 2 - Frame, 3 - layout::{Constraint, Direction, Layout, Rect}, 4 - style::{Color, Modifier, Style}, 5 - text::{Line, Span}, 6 - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, 7 - }; 8 - 9 - use crate::spec::{Task, TaskStatus}; 10 - 11 - #[derive(Debug, Clone)] 12 - pub struct ToolCall { 13 - pub name: String, 14 - pub output: String, 15 - pub collapsed: bool, 16 - } 17 - 18 - #[derive(Debug, Clone)] 19 - pub enum OutputItem { 20 - Message { role: String, content: String }, 21 - ToolCall(ToolCall), 22 - } 23 - 24 - const MAX_OUTPUT_ITEMS: usize = 1000; 25 - 26 - pub struct ExecutionState { 27 - pub running: bool, 28 - pub current_task: Option<Task>, 29 - pub tasks: Vec<Task>, 30 - pub output: Vec<OutputItem>, 31 - pub scroll_offset: usize, 32 - pub auto_scroll: bool, 33 - pub scroll_to_bottom_pending: bool, 34 - pub content_height: usize, 35 - pub viewport_height: usize, 36 - pub last_area_width: u16, 37 - } 38 - 39 - impl ExecutionState { 40 - pub fn new() -> Self { 41 - Self { 42 - running: false, 43 - current_task: None, 44 - tasks: Vec::new(), 45 - output: Vec::new(), 46 - scroll_offset: 0, 47 - auto_scroll: true, 48 - scroll_to_bottom_pending: false, 49 - content_height: 0, 50 - viewport_height: 20, 51 - last_area_width: 0, 52 - } 53 - } 54 - 55 - pub fn task_progress(&self) -> (usize, usize) { 56 - let completed = self 57 - .tasks 58 - .iter() 59 - .filter(|t| t.status == TaskStatus::Complete) 60 - .count(); 61 - (completed, self.tasks.len()) 62 - } 63 - 64 - pub fn add_output(&mut self, item: OutputItem) { 65 - self.output.push(item); 66 - if self.output.len() > MAX_OUTPUT_ITEMS { 67 - self.output.remove(0); 68 - // Note: scroll_offset will be recalculated on next render 69 - // based on actual content height, so this removal is handled 70 - } 71 - if self.auto_scroll { 72 - // Set flag to scroll on next render 73 - self.scroll_to_bottom_pending = true; 74 - } 75 - } 76 - 77 - pub fn clamp_scroll(&mut self, content_height: usize, viewport_height: usize) { 78 - let max_scroll = content_height.saturating_sub(viewport_height); 79 - self.scroll_offset = self.scroll_offset.min(max_scroll); 80 - } 81 - 82 - pub fn scroll_up(&mut self, lines: usize) { 83 - self.scroll_offset = self.scroll_offset.saturating_sub(lines); 84 - // Disable auto-scroll when scrolling up from bottom 85 - let near_bottom_threshold = 3; 86 - if self.scroll_offset + near_bottom_threshold < self.max_scroll() { 87 - self.auto_scroll = false; 88 - } 89 - } 90 - 91 - pub fn scroll_down(&mut self, lines: usize) { 92 - let max_scroll = self.max_scroll(); 93 - self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); 94 - // Re-enable auto-scroll when near bottom (within 3 lines) 95 - let near_bottom_threshold = 3; 96 - if self.scroll_offset + near_bottom_threshold >= max_scroll { 97 - self.auto_scroll = true; 98 - } 99 - } 100 - 101 - pub fn max_scroll(&self) -> usize { 102 - // Guard against zero viewport height 103 - if self.viewport_height == 0 { 104 - return 0; 105 - } 106 - self.content_height.saturating_sub(self.viewport_height) 107 - } 108 - 109 - pub fn scroll_to_bottom(&mut self) { 110 - self.scroll_offset = self.max_scroll(); 111 - self.auto_scroll = true; 112 - } 113 - } 114 - 115 - impl Default for ExecutionState { 116 - fn default() -> Self { 117 - Self::new() 118 - } 119 - } 120 - 121 - pub fn draw_execution( 122 - frame: &mut Frame, 123 - area: Rect, 124 - state: &mut ExecutionState, 125 - spinner_char: char, 126 - ) { 127 - let chunks = Layout::default() 128 - .direction(Direction::Horizontal) 129 - .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) 130 - .split(area); 131 - 132 - draw_task_pane(frame, chunks[0], state, spinner_char); 133 - draw_output_pane(frame, chunks[1], state); 134 - } 135 - 136 - fn draw_task_pane(frame: &mut Frame, area: Rect, state: &ExecutionState, spinner_char: char) { 137 - let chunks = Layout::default() 138 - .direction(Direction::Vertical) 139 - .constraints([Constraint::Length(8), Constraint::Min(0)]) 140 - .split(area); 141 - 142 - let (completed, total) = state.task_progress(); 143 - let title = if state.running { 144 - format!(" Current Task {}/{} {} ", completed, total, spinner_char) 145 - } else { 146 - format!(" Current Task {}/{} ", completed, total) 147 - }; 148 - let current_block = Block::default().borders(Borders::ALL).title(title); 149 - 150 - let current_content = if let Some(task) = &state.current_task { 151 - let mut lines = vec![ 152 - Line::from(Span::styled( 153 - format!("▶ {}", task.title), 154 - Style::default().add_modifier(Modifier::BOLD), 155 - )), 156 - Line::from(""), 157 - Line::from(Span::styled( 158 - "Acceptance Criteria:", 159 - Style::default().fg(Color::Cyan), 160 - )), 161 - ]; 162 - for criterion in &task.acceptance_criteria { 163 - lines.push(Line::from(format!(" ☐ {}", criterion))); 164 - } 165 - lines 166 - } else { 167 - vec![Line::from("No task running")] 168 - }; 169 - 170 - let current = Paragraph::new(current_content) 171 - .block(current_block) 172 - .wrap(Wrap { trim: false }); 173 - frame.render_widget(current, chunks[0]); 174 - 175 - let tasks_block = Block::default().borders(Borders::ALL).title(" Tasks "); 176 - 177 - let items: Vec<ListItem> = state 178 - .tasks 179 - .iter() 180 - .map(|task| { 181 - let (icon, style) = match task.status { 182 - TaskStatus::Complete => ("✓", Style::default().fg(Color::Green)), 183 - TaskStatus::InProgress => ("▶", Style::default().fg(Color::Yellow)), 184 - TaskStatus::Pending => ("○", Style::default().fg(Color::DarkGray)), 185 - TaskStatus::Blocked => ("✗", Style::default().fg(Color::Red)), 186 - }; 187 - ListItem::new(format!(" {} {}", icon, task.title)).style(style) 188 - }) 189 - .collect(); 190 - 191 - let list = List::new(items).block(tasks_block); 192 - frame.render_widget(list, chunks[1]); 193 - } 194 - 195 - fn draw_output_pane(frame: &mut Frame, area: Rect, state: &mut ExecutionState) { 196 - let block = Block::default().borders(Borders::ALL).title(" Output "); 197 - 198 - let inner = block.inner(area); 199 - frame.render_widget(block, area); 200 - 201 - let mut lines: Vec<Line> = Vec::new(); 202 - 203 - for item in &state.output { 204 - match item { 205 - OutputItem::Message { role, content } => { 206 - let style = if role == "Assistant" { 207 - Style::default() 208 - .fg(Color::Yellow) 209 - .add_modifier(Modifier::BOLD) 210 - } else { 211 - Style::default() 212 - .fg(Color::Cyan) 213 - .add_modifier(Modifier::BOLD) 214 - }; 215 - lines.push(Line::from(Span::styled(role.clone(), style))); 216 - for line in content.lines() { 217 - lines.push(Line::from(format!(" {}", line))); 218 - } 219 - lines.push(Line::from("")); 220 - } 221 - OutputItem::ToolCall(tc) => { 222 - lines.push(Line::from(vec![ 223 - Span::raw("┌─ "), 224 - Span::styled( 225 - tc.name.clone(), 226 - Style::default() 227 - .fg(Color::Magenta) 228 - .add_modifier(Modifier::BOLD), 229 - ), 230 - Span::raw(" ─"), 231 - ])); 232 - if !tc.collapsed { 233 - for line in tc.output.lines().take(5) { 234 - lines.push(Line::from(format!("│ {}", line))); 235 - } 236 - } 237 - lines.push(Line::from("└────────────────────")); 238 - lines.push(Line::from("")); 239 - } 240 - } 241 - } 242 - 243 - if lines.is_empty() { 244 - lines.push(Line::from(" Waiting for execution...")); 245 - } 246 - 247 - // Calculate actual wrapped content height 248 - let viewport_height = inner.height as usize; 249 - let viewport_width = inner.width as usize; 250 - 251 - // Calculate wrapped line count 252 - let mut content_height = 0; 253 - for line in &lines { 254 - let line_width = line.width(); 255 - if line_width == 0 { 256 - content_height += 1; 257 - } else { 258 - // Account for wrapping 259 - content_height += (line_width + viewport_width - 1) / viewport_width.max(1); 260 - } 261 - } 262 - 263 - // Update state and handle pending scroll 264 - state.content_height = content_height; 265 - state.viewport_height = viewport_height; 266 - state.last_area_width = inner.width; 267 - 268 - if state.scroll_to_bottom_pending { 269 - state.scroll_offset = content_height.saturating_sub(viewport_height); 270 - state.scroll_to_bottom_pending = false; 271 - } 272 - 273 - // Clamp scroll 274 - state.clamp_scroll(content_height, viewport_height); 275 - 276 - let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; 277 - 278 - let paragraph = Paragraph::new(lines) 279 - .wrap(Wrap { trim: false }) 280 - .scroll((scroll_y, 0)); 281 - 282 - frame.render_widget(paragraph, inner); 283 - 284 - // Show scroll indicator if content is scrollable 285 - if content_height > viewport_height { 286 - let max_scroll = content_height.saturating_sub(viewport_height); 287 - let scroll_pct = if max_scroll > 0 { 288 - (state.scroll_offset * 100) / max_scroll 289 - } else { 290 - 100 291 - }; 292 - 293 - // Add [auto] indicator if auto-scroll is enabled 294 - let indicator = if state.auto_scroll { 295 - format!(" {}% [auto] ", scroll_pct) 296 - } else { 297 - format!(" {}% ", scroll_pct) 298 - }; 299 - 300 - let indicator_width = indicator.len() as u16; 301 - let indicator_area = Rect::new( 302 - inner.x + inner.width.saturating_sub(indicator_width), 303 - inner.y + inner.height.saturating_sub(1), 304 - indicator_width, 305 - 1, 306 - ); 307 - 308 - let indicator_widget = 309 - Paragraph::new(indicator).style(Style::default().bg(Color::DarkGray).fg(Color::White)); 310 - 311 - frame.render_widget(indicator_widget, indicator_area); 312 - } 313 - }
-9
src/tui/views/mod.rs
··· 1 - mod dashboard; 2 - mod execution; 3 - mod planning; 4 - 5 - pub use dashboard::{ 6 - DashboardMode, DashboardState, NavDirection, SpecStatus, SpecSummary, draw_dashboard, 7 - }; 8 - pub use execution::{ExecutionState, OutputItem, ToolCall, draw_execution}; 9 - pub use planning::{ChatMessage, MessageRole, PlanningState, draw_planning};
-268
src/tui/views/planning.rs
··· 1 - use ratatui::{ 2 - Frame, 3 - layout::{Constraint, Direction, Layout, Rect}, 4 - style::{Color, Modifier, Style}, 5 - text::{Line, Span}, 6 - widgets::{Block, Borders, Paragraph, Wrap}, 7 - }; 8 - use tui_textarea::TextArea; 9 - 10 - #[derive(Debug, Clone)] 11 - pub struct ChatMessage { 12 - pub role: MessageRole, 13 - pub content: String, 14 - } 15 - 16 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 17 - pub enum MessageRole { 18 - User, 19 - Assistant, 20 - } 21 - 22 - pub struct PlanningState { 23 - pub messages: Vec<ChatMessage>, 24 - pub input: TextArea<'static>, 25 - pub insert_mode: bool, 26 - pub scroll_offset: usize, 27 - pub thinking: bool, 28 - pub auto_scroll: bool, 29 - pub scroll_to_bottom_pending: bool, 30 - pub content_height: usize, 31 - pub viewport_height: usize, 32 - pub last_area_width: u16, 33 - } 34 - 35 - impl PlanningState { 36 - pub fn new() -> Self { 37 - let mut input = TextArea::default(); 38 - input.set_cursor_line_style(Style::default()); 39 - input.set_placeholder_text("Type your message..."); 40 - 41 - Self { 42 - messages: vec![ChatMessage { 43 - role: MessageRole::Assistant, 44 - content: "What would you like to build?".to_string(), 45 - }], 46 - input, 47 - insert_mode: false, 48 - scroll_offset: 0, 49 - thinking: false, 50 - auto_scroll: true, 51 - scroll_to_bottom_pending: false, 52 - content_height: 0, 53 - viewport_height: 20, 54 - last_area_width: 0, 55 - } 56 - } 57 - 58 - pub fn add_message(&mut self, role: MessageRole, content: String) { 59 - self.messages.push(ChatMessage { role, content }); 60 - if self.auto_scroll { 61 - // Set flag to scroll on next render 62 - self.scroll_to_bottom_pending = true; 63 - } 64 - } 65 - 66 - pub fn submit_input(&mut self) -> Option<String> { 67 - let text: String = self.input.lines().join("\n"); 68 - if text.trim().is_empty() { 69 - return None; 70 - } 71 - self.input.select_all(); 72 - self.input.cut(); 73 - Some(text) 74 - } 75 - 76 - pub fn scroll_up(&mut self, lines: usize) { 77 - self.scroll_offset = self.scroll_offset.saturating_sub(lines); 78 - // Disable auto-scroll when scrolling up from bottom 79 - let near_bottom_threshold = 3; 80 - if self.scroll_offset + near_bottom_threshold < self.max_scroll() { 81 - self.auto_scroll = false; 82 - } 83 - } 84 - 85 - pub fn scroll_down(&mut self, lines: usize) { 86 - let max_scroll = self.max_scroll(); 87 - self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); 88 - // Re-enable auto-scroll when near bottom (within 3 lines) 89 - let near_bottom_threshold = 3; 90 - if self.scroll_offset + near_bottom_threshold >= max_scroll { 91 - self.auto_scroll = true; 92 - } 93 - } 94 - 95 - pub fn max_scroll(&self) -> usize { 96 - // Guard against zero viewport height 97 - if self.viewport_height == 0 { 98 - return 0; 99 - } 100 - self.content_height.saturating_sub(self.viewport_height) 101 - } 102 - 103 - pub fn scroll_to_bottom(&mut self) { 104 - self.scroll_offset = self.max_scroll(); 105 - self.auto_scroll = true; 106 - } 107 - } 108 - 109 - impl Default for PlanningState { 110 - fn default() -> Self { 111 - Self::new() 112 - } 113 - } 114 - 115 - pub fn draw_planning(frame: &mut Frame, area: Rect, state: &mut PlanningState, spinner_char: char) { 116 - let chunks = Layout::default() 117 - .direction(Direction::Vertical) 118 - .constraints([ 119 - Constraint::Min(0), 120 - Constraint::Length(3), 121 - Constraint::Length(1), 122 - ]) 123 - .split(area); 124 - 125 - draw_chat_history(frame, chunks[0], state, spinner_char); 126 - 127 - let input_block = Block::default() 128 - .borders(Borders::ALL) 129 - .border_style(if state.insert_mode { 130 - Style::default().fg(Color::Green) 131 - } else { 132 - Style::default() 133 - }) 134 - .title(if state.insert_mode { 135 - " Input (INSERT) " 136 - } else { 137 - " Input " 138 - }); 139 - 140 - state.input.set_block(input_block); 141 - frame.render_widget(&state.input, chunks[1]); 142 - 143 - let hints = if state.insert_mode { 144 - " Esc exit insert │ Enter send " 145 - } else { 146 - " i insert │ ↑↓ scroll │ Ctrl+S save │ ] spec panel " 147 - }; 148 - let hints_bar = Paragraph::new(hints).style(Style::default().fg(Color::DarkGray)); 149 - frame.render_widget(hints_bar, chunks[2]); 150 - } 151 - 152 - fn draw_chat_history(frame: &mut Frame, area: Rect, state: &mut PlanningState, spinner_char: char) { 153 - let block = Block::default().borders(Borders::ALL).title(" Chat "); 154 - 155 - let inner = block.inner(area); 156 - frame.render_widget(block, area); 157 - 158 - let mut lines: Vec<Line> = Vec::new(); 159 - 160 - for msg in &state.messages { 161 - let (label, style) = match msg.role { 162 - MessageRole::User => ( 163 - "You", 164 - Style::default() 165 - .fg(Color::Cyan) 166 - .add_modifier(Modifier::BOLD), 167 - ), 168 - MessageRole::Assistant => ( 169 - "Assistant", 170 - Style::default() 171 - .fg(Color::Yellow) 172 - .add_modifier(Modifier::BOLD), 173 - ), 174 - }; 175 - 176 - lines.push(Line::from(Span::styled(label, style))); 177 - 178 - for line in msg.content.lines() { 179 - lines.push(Line::from(format!(" {}", line))); 180 - } 181 - lines.push(Line::from("")); 182 - } 183 - 184 - if state.thinking { 185 - lines.push(Line::from(vec![ 186 - Span::styled( 187 - "Assistant", 188 - Style::default() 189 - .fg(Color::Yellow) 190 - .add_modifier(Modifier::BOLD), 191 - ), 192 - Span::raw(" "), 193 - Span::styled(spinner_char.to_string(), Style::default().fg(Color::Yellow)), 194 - ])); 195 - } 196 - 197 - // Calculate actual wrapped content height 198 - let viewport_height = inner.height as usize; 199 - let viewport_width = inner.width as usize; 200 - 201 - // Calculate wrapped line count 202 - let mut content_height = 0; 203 - for line in &lines { 204 - let line_width = line.width(); 205 - if line_width == 0 { 206 - content_height += 1; 207 - } else { 208 - // Account for wrapping 209 - content_height += (line_width + viewport_width - 1) / viewport_width.max(1); 210 - } 211 - } 212 - 213 - // Update state dimensions 214 - state.content_height = content_height; 215 - state.viewport_height = viewport_height; 216 - state.last_area_width = inner.width; 217 - 218 - // Handle pending scroll to bottom 219 - if state.scroll_to_bottom_pending { 220 - state.scroll_offset = content_height.saturating_sub(viewport_height); 221 - state.scroll_to_bottom_pending = false; 222 - } 223 - 224 - // Clamp scroll to valid range 225 - let max_scroll = if viewport_height == 0 { 226 - 0 227 - } else { 228 - content_height.saturating_sub(viewport_height) 229 - }; 230 - state.scroll_offset = state.scroll_offset.min(max_scroll); 231 - 232 - let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; 233 - 234 - let paragraph = Paragraph::new(lines) 235 - .wrap(Wrap { trim: false }) 236 - .scroll((scroll_y, 0)); 237 - 238 - frame.render_widget(paragraph, inner); 239 - 240 - // Show scroll indicator if content is scrollable 241 - if content_height > viewport_height { 242 - let scroll_pct = if max_scroll > 0 { 243 - (state.scroll_offset * 100) / max_scroll 244 - } else { 245 - 100 246 - }; 247 - 248 - // Add [auto] indicator if auto-scroll is enabled 249 - let indicator = if state.auto_scroll { 250 - format!(" {}% [auto] ", scroll_pct) 251 - } else { 252 - format!(" {}% ", scroll_pct) 253 - }; 254 - 255 - let indicator_width = indicator.len() as u16; 256 - let indicator_area = Rect::new( 257 - inner.x + inner.width.saturating_sub(indicator_width), 258 - inner.y + inner.height.saturating_sub(1), 259 - indicator_width, 260 - 1, 261 - ); 262 - 263 - let indicator_widget = 264 - Paragraph::new(indicator).style(Style::default().bg(Color::DarkGray).fg(Color::White)); 265 - 266 - frame.render_widget(indicator_widget, indicator_area); 267 - } 268 - }
-84
src/tui/widgets/help.rs
··· 1 - use ratatui::{ 2 - Frame, 3 - layout::Rect, 4 - style::{Color, Modifier, Style}, 5 - text::{Line, Span}, 6 - widgets::{Block, Borders, Clear, Paragraph}, 7 - }; 8 - 9 - pub struct HelpOverlay { 10 - pub visible: bool, 11 - } 12 - 13 - impl HelpOverlay { 14 - pub fn new() -> Self { 15 - Self { visible: false } 16 - } 17 - 18 - pub fn toggle(&mut self) { 19 - self.visible = !self.visible; 20 - } 21 - } 22 - 23 - impl Default for HelpOverlay { 24 - fn default() -> Self { 25 - Self::new() 26 - } 27 - } 28 - 29 - pub fn draw_help(frame: &mut Frame, area: Rect) { 30 - let width = 50.min(area.width.saturating_sub(4)); 31 - let height = 16.min(area.height.saturating_sub(4)); 32 - let x = (area.width - width) / 2; 33 - let y = (area.height - height) / 2; 34 - let help_area = Rect::new(x, y, width, height); 35 - 36 - frame.render_widget(Clear, help_area); 37 - 38 - let block = Block::default() 39 - .borders(Borders::ALL) 40 - .border_style(Style::default().fg(Color::Cyan)) 41 - .title(" Help (Esc to close) "); 42 - 43 - let lines = vec![ 44 - Line::from(Span::styled( 45 - "Global", 46 - Style::default().add_modifier(Modifier::BOLD), 47 - )), 48 - Line::from(" q Quit"), 49 - Line::from(" 1/2/3 Switch tabs"), 50 - Line::from(" Tab Cycle tabs"), 51 - Line::from(" [ ] Toggle side panel"), 52 - Line::from(" ? Toggle help"), 53 - Line::from(""), 54 - Line::from(Span::styled( 55 - "Dashboard", 56 - Style::default().add_modifier(Modifier::BOLD), 57 - )), 58 - Line::from(" Shift+K/A Kanban/Activity view"), 59 - Line::from(" ↑↓←→/hjkl Navigate"), 60 - Line::from(" Enter Run selected spec"), 61 - Line::from(""), 62 - Line::from(Span::styled( 63 - "Planning", 64 - Style::default().add_modifier(Modifier::BOLD), 65 - )), 66 - Line::from(" i Enter insert mode"), 67 - Line::from(" Esc Exit insert mode"), 68 - Line::from(" Enter Send message"), 69 - Line::from(" ↑↓/jk Scroll (not in insert)"), 70 - Line::from(" PgUp/PgDn Page scroll"), 71 - Line::from(" Home/End Jump to top/bottom"), 72 - Line::from(""), 73 - Line::from(Span::styled( 74 - "Execution", 75 - Style::default().add_modifier(Modifier::BOLD), 76 - )), 77 - Line::from(" ↑↓/jk Scroll output"), 78 - Line::from(" PgUp/PgDn Page scroll"), 79 - Line::from(" Home/End Jump to top/bottom"), 80 - ]; 81 - 82 - let paragraph = Paragraph::new(lines).block(block); 83 - frame.render_widget(paragraph, help_area); 84 - }
-9
src/tui/widgets/mod.rs
··· 1 - mod help; 2 - mod panel; 3 - mod spinner; 4 - mod tabs; 5 - 6 - pub use help::{HelpOverlay, draw_help}; 7 - pub use panel::{SidePanel, draw_side_panel}; 8 - pub use spinner::Spinner; 9 - pub use tabs::TabBar;
-66
src/tui/widgets/panel.rs
··· 1 - use ratatui::{ 2 - Frame, 3 - layout::Rect, 4 - style::{Color, Style}, 5 - widgets::{Block, Borders, Clear, Paragraph, Wrap}, 6 - }; 7 - 8 - pub struct SidePanel { 9 - pub visible: bool, 10 - pub title: String, 11 - pub content: String, 12 - pub width_percent: u16, 13 - } 14 - 15 - impl SidePanel { 16 - pub fn new() -> Self { 17 - Self { 18 - visible: false, 19 - title: String::new(), 20 - content: String::new(), 21 - width_percent: 40, 22 - } 23 - } 24 - 25 - pub fn toggle(&mut self) { 26 - self.visible = !self.visible; 27 - } 28 - 29 - pub fn set_content(&mut self, title: &str, content: String) { 30 - self.title = title.to_string(); 31 - self.content = content; 32 - } 33 - } 34 - 35 - impl Default for SidePanel { 36 - fn default() -> Self { 37 - Self::new() 38 - } 39 - } 40 - 41 - pub fn draw_side_panel(frame: &mut Frame, area: Rect, panel: &SidePanel) { 42 - if !panel.visible { 43 - return; 44 - } 45 - 46 - let panel_width = (area.width as u32 * panel.width_percent as u32 / 100) as u16; 47 - let panel_area = Rect { 48 - x: area.x + area.width - panel_width, 49 - y: area.y, 50 - width: panel_width, 51 - height: area.height, 52 - }; 53 - 54 - frame.render_widget(Clear, panel_area); 55 - 56 - let block = Block::default() 57 - .borders(Borders::ALL) 58 - .border_style(Style::default().fg(Color::Cyan)) 59 - .title(format!(" {} ", panel.title)); 60 - 61 - let content = Paragraph::new(panel.content.clone()) 62 - .block(block) 63 - .wrap(Wrap { trim: false }); 64 - 65 - frame.render_widget(content, panel_area); 66 - }
-25
src/tui/widgets/spinner.rs
··· 1 - const SPINNER_FRAMES: &[char] = &['◐', '◓', '◑', '◒']; 2 - 3 - pub struct Spinner { 4 - frame: usize, 5 - } 6 - 7 - impl Spinner { 8 - pub fn new() -> Self { 9 - Self { frame: 0 } 10 - } 11 - 12 - pub fn tick(&mut self) { 13 - self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); 14 - } 15 - 16 - pub fn current(&self) -> char { 17 - SPINNER_FRAMES[self.frame] 18 - } 19 - } 20 - 21 - impl Default for Spinner { 22 - fn default() -> Self { 23 - Self::new() 24 - } 25 - }
-46
src/tui/widgets/tabs.rs
··· 1 - use ratatui::{ 2 - buffer::Buffer, 3 - layout::Rect, 4 - style::{Color, Modifier, Style}, 5 - text::{Line, Span}, 6 - widgets::{Tabs as RataTabs, Widget}, 7 - }; 8 - 9 - use crate::tui::ActiveTab; 10 - 11 - pub struct TabBar { 12 - active: ActiveTab, 13 - } 14 - 15 - impl TabBar { 16 - pub fn new(active: ActiveTab) -> Self { 17 - Self { active } 18 - } 19 - } 20 - 21 - impl Widget for TabBar { 22 - fn render(self, area: Rect, buf: &mut Buffer) { 23 - let titles = vec![ 24 - Line::from("[1] Dashboard"), 25 - Line::from("[2] Planning"), 26 - Line::from("[3] Execution"), 27 - ]; 28 - let selected = match self.active { 29 - ActiveTab::Dashboard => 0, 30 - ActiveTab::Planning => 1, 31 - ActiveTab::Execution => 2, 32 - }; 33 - 34 - let tabs = RataTabs::new(titles) 35 - .select(selected) 36 - .style(Style::default().fg(Color::White)) 37 - .highlight_style( 38 - Style::default() 39 - .fg(Color::Yellow) 40 - .add_modifier(Modifier::BOLD), 41 - ) 42 - .divider(Span::raw(" │ ")); 43 - 44 - tabs.render(area, buf); 45 - } 46 - }
+291
tests/adr_export_test.rs
··· 1 + use anyhow::Result; 2 + use chrono::Utc; 3 + use rustagent::graph::export::export_adrs; 4 + use rustagent::graph::store::GraphStore; 5 + use rustagent::graph::*; 6 + use std::collections::HashMap; 7 + use std::fs; 8 + use tempfile::TempDir; 9 + 10 + mod common; 11 + use common::create_test_goal; 12 + 13 + #[tokio::test] 14 + async fn test_ac2_1_export_adrs_creates_numbered_files() -> Result<()> { 15 + // P1c.AC2.1: export_adrs(project_id, output_dir) generates numbered markdown files (001-xxx.md, 002-xxx.md) 16 + let (_, graph_store) = common::setup_test_env().await?; 17 + let temp_dir = TempDir::new()?; 18 + 19 + // Create a goal to contain decisions 20 + let goal = create_test_goal("ra-goal-1", "proj-1", "Test Goal"); 21 + graph_store.create_node(&goal).await?; 22 + 23 + // Create first decision 24 + let decision1 = GraphNode { 25 + id: "ra-goal-1.1".to_string(), 26 + project_id: "proj-1".to_string(), 27 + node_type: NodeType::Decision, 28 + title: "Use Rust".to_string(), 29 + description: "Choose implementation language".to_string(), 30 + status: NodeStatus::Decided, 31 + priority: Some(Priority::High), 32 + assigned_to: None, 33 + created_by: None, 34 + labels: vec![], 35 + created_at: Utc::now(), 36 + started_at: None, 37 + completed_at: None, 38 + blocked_reason: None, 39 + metadata: HashMap::new(), 40 + }; 41 + graph_store.create_node(&decision1).await?; 42 + 43 + // Add a small delay to ensure different created_at times 44 + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; 45 + 46 + // Create second decision 47 + let decision2 = GraphNode { 48 + id: "ra-goal-1.2".to_string(), 49 + project_id: "proj-1".to_string(), 50 + node_type: NodeType::Decision, 51 + title: "PostgreSQL Database".to_string(), 52 + description: "Choose database system".to_string(), 53 + status: NodeStatus::Decided, 54 + priority: Some(Priority::High), 55 + assigned_to: None, 56 + created_by: None, 57 + labels: vec![], 58 + created_at: Utc::now(), 59 + started_at: None, 60 + completed_at: None, 61 + blocked_reason: None, 62 + metadata: HashMap::new(), 63 + }; 64 + graph_store.create_node(&decision2).await?; 65 + 66 + // Export ADRs 67 + let files = export_adrs(&graph_store, "proj-1", temp_dir.path()).await?; 68 + 69 + // Verify two files were created 70 + assert_eq!(files.len(), 2); 71 + 72 + // Verify file names start with 001 and 002 73 + let filenames: Vec<String> = files 74 + .iter() 75 + .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) 76 + .collect(); 77 + filenames.iter().for_each(|f| println!("File: {}", f)); 78 + 79 + // At least one file should start with 001 and one with 002 80 + let has_001 = filenames.iter().any(|f| f.starts_with("001-")); 81 + let has_002 = filenames.iter().any(|f| f.starts_with("002-")); 82 + assert!(has_001, "Should have 001- prefixed file"); 83 + assert!(has_002, "Should have 002- prefixed file"); 84 + 85 + Ok(()) 86 + } 87 + 88 + #[tokio::test] 89 + async fn test_ac2_2_export_adrs_contains_all_sections() -> Result<()> { 90 + // P1c.AC2.2: Each ADR contains Status, Context, Options Considered sections 91 + let (_, graph_store) = common::setup_test_env().await?; 92 + let temp_dir = TempDir::new()?; 93 + 94 + // Create a goal 95 + let goal = create_test_goal("ra-goal-2", "proj-1", "Test Goal"); 96 + graph_store.create_node(&goal).await?; 97 + 98 + // Create a decision 99 + let decision = GraphNode { 100 + id: "ra-goal-2.1".to_string(), 101 + project_id: "proj-1".to_string(), 102 + node_type: NodeType::Decision, 103 + title: "Web Framework Choice".to_string(), 104 + description: "Choosing a web framework for the API server".to_string(), 105 + status: NodeStatus::Decided, 106 + priority: Some(Priority::High), 107 + assigned_to: None, 108 + created_by: None, 109 + labels: vec![], 110 + created_at: Utc::now(), 111 + started_at: None, 112 + completed_at: None, 113 + blocked_reason: None, 114 + metadata: { 115 + let mut m = HashMap::new(); 116 + m.insert( 117 + "outcome".to_string(), 118 + "Selected Actix-web for performance".to_string(), 119 + ); 120 + m 121 + }, 122 + }; 123 + graph_store.create_node(&decision).await?; 124 + 125 + // Create options 126 + let option1 = GraphNode { 127 + id: "ra-goal-2.1.1".to_string(), 128 + project_id: "proj-1".to_string(), 129 + node_type: NodeType::Option, 130 + title: "Actix-web".to_string(), 131 + description: "High-performance async web framework".to_string(), 132 + status: NodeStatus::Chosen, 133 + priority: None, 134 + assigned_to: None, 135 + created_by: None, 136 + labels: vec![], 137 + created_at: Utc::now(), 138 + started_at: None, 139 + completed_at: None, 140 + blocked_reason: None, 141 + metadata: { 142 + let mut m = HashMap::new(); 143 + m.insert("pros".to_string(), "- Fast\n- Async".to_string()); 144 + m.insert("cons".to_string(), "- Smaller ecosystem".to_string()); 145 + m 146 + }, 147 + }; 148 + graph_store.create_node(&option1).await?; 149 + 150 + // Create edges: decision -> option via LeadsTo 151 + graph_store 152 + .add_edge(&GraphEdge { 153 + id: generate_edge_id(), 154 + edge_type: EdgeType::LeadsTo, 155 + from_node: "ra-goal-2.1".to_string(), 156 + to_node: "ra-goal-2.1.1".to_string(), 157 + label: None, 158 + created_at: Utc::now(), 159 + }) 160 + .await?; 161 + 162 + // Edge to mark as chosen with rationale 163 + graph_store 164 + .add_edge(&GraphEdge { 165 + id: generate_edge_id(), 166 + edge_type: EdgeType::Chosen, 167 + from_node: "ra-goal-2.1".to_string(), 168 + to_node: "ra-goal-2.1.1".to_string(), 169 + label: Some("Best performance characteristics".to_string()), 170 + created_at: Utc::now(), 171 + }) 172 + .await?; 173 + 174 + // Export ADRs 175 + let files = export_adrs(&graph_store, "proj-1", temp_dir.path()).await?; 176 + 177 + // Read the generated file 178 + assert_eq!(files.len(), 1); 179 + let content = fs::read_to_string(&files[0])?; 180 + 181 + // Verify all required sections are present 182 + assert!(content.contains("# ADR"), "Should have ADR title"); 183 + assert!( 184 + content.contains("**Status:**"), 185 + "Should have Status section" 186 + ); 187 + assert!( 188 + content.contains("## Context"), 189 + "Should have Context section" 190 + ); 191 + assert!( 192 + content.contains("## Options Considered"), 193 + "Should have Options Considered section" 194 + ); 195 + assert!( 196 + content.contains("## Outcome"), 197 + "Should have Outcome section" 198 + ); 199 + assert!( 200 + content.contains("## Related Tasks"), 201 + "Should have Related Tasks section" 202 + ); 203 + 204 + // Verify content includes option title and status label 205 + assert!( 206 + content.contains("Actix-web"), 207 + "Should reference chosen option" 208 + ); 209 + assert!(content.contains("CHOSEN"), "Should label option as CHOSEN"); 210 + 211 + // Verify rationale is included 212 + assert!( 213 + content.contains("Best performance characteristics"), 214 + "Should include rationale" 215 + ); 216 + 217 + // Verify pros/cons are included 218 + assert!(content.contains("Pros:"), "Should have Pros section"); 219 + assert!(content.contains("Cons:"), "Should have Cons section"); 220 + 221 + Ok(()) 222 + } 223 + 224 + #[tokio::test] 225 + async fn test_adr_export_with_no_decisions() -> Result<()> { 226 + // Test that export_adrs works correctly with no decisions 227 + let (_, graph_store) = common::setup_test_env().await?; 228 + let temp_dir = TempDir::new()?; 229 + 230 + // Create a goal but no decisions 231 + let goal = create_test_goal("ra-goal-3", "proj-1", "Test Goal"); 232 + graph_store.create_node(&goal).await?; 233 + 234 + // Export ADRs 235 + let files = export_adrs(&graph_store, "proj-1", temp_dir.path()).await?; 236 + 237 + // Should produce empty list 238 + assert_eq!(files.len(), 0); 239 + 240 + Ok(()) 241 + } 242 + 243 + #[tokio::test] 244 + async fn test_adr_export_slug_generation() -> Result<()> { 245 + // Test that filenames are properly slugified 246 + let (_, graph_store) = common::setup_test_env().await?; 247 + let temp_dir = TempDir::new()?; 248 + 249 + // Create a goal 250 + let goal = create_test_goal("ra-goal-4", "proj-1", "Test Goal"); 251 + graph_store.create_node(&goal).await?; 252 + 253 + // Create a decision with special characters in title 254 + let decision = GraphNode { 255 + id: "ra-goal-4.1".to_string(), 256 + project_id: "proj-1".to_string(), 257 + node_type: NodeType::Decision, 258 + title: "Use TypeScript/React!!!".to_string(), 259 + description: "Frontend technology choice".to_string(), 260 + status: NodeStatus::Decided, 261 + priority: Some(Priority::High), 262 + assigned_to: None, 263 + created_by: None, 264 + labels: vec![], 265 + created_at: Utc::now(), 266 + started_at: None, 267 + completed_at: None, 268 + blocked_reason: None, 269 + metadata: HashMap::new(), 270 + }; 271 + graph_store.create_node(&decision).await?; 272 + 273 + // Export ADRs 274 + let files = export_adrs(&graph_store, "proj-1", temp_dir.path()).await?; 275 + 276 + assert_eq!(files.len(), 1); 277 + let filename = files[0].file_name().unwrap().to_string_lossy().to_string(); 278 + 279 + // Verify filename is properly slugified 280 + assert!(filename.starts_with("001-")); 281 + assert!( 282 + filename.contains("typescript") || filename.contains("react"), 283 + "Filename should contain slugified keywords, got: {}", 284 + filename 285 + ); 286 + // Should not contain special characters except dash 287 + assert!(!filename.contains("!"), "Filename should not contain !"); 288 + assert!(!filename.contains("/"), "Filename should not contain /"); 289 + 290 + Ok(()) 291 + }
+327
tests/agent_runtime_test.rs
··· 1 + use rustagent::agent::runtime::{AgentRuntime, RuntimeConfig}; 2 + use rustagent::agent::{AgentContext, AgentOutcome, AgentProfile}; 3 + use rustagent::llm::mock::MockLlmClient; 4 + use rustagent::security::SecurityScope; 5 + use rustagent::tools::ToolRegistry; 6 + use serde_json::json; 7 + use std::path::PathBuf; 8 + use std::sync::Arc; 9 + 10 + mod common; 11 + use common::MockGraphStore; 12 + 13 + // Helper to create a mock agent context 14 + fn make_test_context() -> AgentContext { 15 + AgentContext { 16 + work_package_tasks: vec![], 17 + relevant_decisions: vec![], 18 + handoff_notes: None, 19 + agents_md_summaries: vec![], 20 + profile: AgentProfile { 21 + name: "test".to_string(), 22 + extends: None, 23 + role: "Test agent".to_string(), 24 + system_prompt: "You are a test agent.".to_string(), 25 + allowed_tools: vec!["signal_completion".to_string()], 26 + security: SecurityScope::default(), 27 + llm: Default::default(), 28 + turn_limit: None, 29 + token_budget: None, 30 + }, 31 + project_path: PathBuf::from("/tmp/test"), 32 + graph_store: Arc::new(MockGraphStore), 33 + } 34 + } 35 + 36 + #[tokio::test] 37 + async fn test_p1d_ac4_1_simple_completion() { 38 + // P1d.AC4.1: AgentRuntime runs LLM -> tool execution loop and returns Completed on signal_completion 39 + let mock_client = Arc::new(MockLlmClient::new()); 40 + 41 + // Queue responses: first a text response, then signal_completion 42 + mock_client.queue_text_response("I'll help you with this task."); 43 + mock_client.queue_tool_call( 44 + "signal_completion", 45 + json!({ 46 + "signal": "complete", 47 + "message": "Task completed successfully" 48 + }), 49 + ); 50 + 51 + let registry = ToolRegistry::new(); 52 + registry.register(Arc::new(rustagent::tools::signal::SignalTool::new())); 53 + 54 + let runtime = AgentRuntime::new( 55 + mock_client.clone(), 56 + registry, 57 + AgentProfile { 58 + name: "test".to_string(), 59 + extends: None, 60 + role: "Test".to_string(), 61 + system_prompt: "Test prompt".to_string(), 62 + allowed_tools: vec!["signal_completion".to_string()], 63 + security: SecurityScope::default(), 64 + llm: Default::default(), 65 + turn_limit: Some(100), 66 + token_budget: Some(200_000), 67 + }, 68 + RuntimeConfig::default(), 69 + ); 70 + 71 + let ctx = make_test_context(); 72 + let outcome = runtime.run(ctx).await.expect("Runtime failed"); 73 + 74 + match outcome { 75 + AgentOutcome::Completed { summary } => { 76 + assert!(summary.contains("Task completed successfully")); 77 + } 78 + _ => panic!("Expected Completed outcome, got {:?}", outcome), 79 + } 80 + } 81 + 82 + #[tokio::test] 83 + async fn test_p1d_ac4_2_confusion_counter() { 84 + // P1d.AC4.2: Consecutive bad tool calls (confusion counter) should return Blocked 85 + let mock_client = Arc::new(MockLlmClient::new()); 86 + 87 + // Queue 3 consecutive bad tool calls (unknown tool name) 88 + for _ in 0..3 { 89 + mock_client.queue_tool_call("unknown_tool", json!({"param": "value"})); 90 + } 91 + 92 + let registry = ToolRegistry::new(); 93 + registry.register(Arc::new(rustagent::tools::signal::SignalTool::new())); 94 + 95 + let mut config = RuntimeConfig::default(); 96 + config.max_consecutive_tool_failures = 2; // Lower threshold for testing 97 + 98 + let runtime = AgentRuntime::new( 99 + mock_client.clone(), 100 + registry, 101 + AgentProfile { 102 + name: "test".to_string(), 103 + extends: None, 104 + role: "Test".to_string(), 105 + system_prompt: "Test prompt".to_string(), 106 + allowed_tools: vec!["signal_completion".to_string()], 107 + security: SecurityScope::default(), 108 + llm: Default::default(), 109 + turn_limit: Some(100), 110 + token_budget: Some(200_000), 111 + }, 112 + config, 113 + ); 114 + 115 + let ctx = make_test_context(); 116 + let outcome = runtime.run(ctx).await.expect("Runtime failed"); 117 + 118 + match outcome { 119 + AgentOutcome::Blocked { reason } => { 120 + assert!(reason.contains("tool") || reason.contains("failure")); 121 + } 122 + _ => panic!("Expected Blocked outcome, got {:?}", outcome), 123 + } 124 + } 125 + 126 + #[tokio::test] 127 + async fn test_p1d_ac4_3_token_budget_warning() { 128 + // P1d.AC4.3: At warning threshold (80%), inject "wrap up" message 129 + let mock_client = Arc::new(MockLlmClient::new()); 130 + 131 + // set_token_counts is global (applies to all responses), not per-response 132 + // First call returns 800 tokens total (400+400), reaching 80% of 1000 budget 133 + mock_client.set_token_counts(400, 400); 134 + mock_client.queue_text_response("Processing..."); 135 + 136 + // Second call: wrap-up message should be injected before this call 137 + mock_client.queue_tool_call( 138 + "signal_completion", 139 + json!({ 140 + "signal": "complete", 141 + "message": "Done" 142 + }), 143 + ); 144 + 145 + let registry = ToolRegistry::new(); 146 + registry.register(Arc::new(rustagent::tools::signal::SignalTool::new())); 147 + 148 + let mut config = RuntimeConfig::default(); 149 + config.token_budget = 1000; 150 + config.token_budget_warning_pct = 80; 151 + 152 + let runtime = AgentRuntime::new( 153 + mock_client.clone(), 154 + registry, 155 + AgentProfile { 156 + name: "test".to_string(), 157 + extends: None, 158 + role: "Test".to_string(), 159 + system_prompt: "Test prompt".to_string(), 160 + allowed_tools: vec!["signal_completion".to_string()], 161 + security: SecurityScope::default(), 162 + llm: Default::default(), 163 + turn_limit: Some(100), 164 + token_budget: Some(1000), 165 + }, 166 + config, 167 + ); 168 + 169 + let ctx = make_test_context(); 170 + let outcome = runtime.run(ctx).await.expect("Runtime failed"); 171 + 172 + // Verify that the wrap-up logic was engaged by checking recorded LLM calls 173 + let calls = mock_client.get_recorded_calls(); 174 + assert!(calls.len() >= 2, "Expected at least 2 LLM calls"); 175 + 176 + // The second call's messages should contain the wrap-up warning injected by the runtime 177 + let second_call_messages = &calls[1].0; 178 + let has_wrap_up_message = second_call_messages 179 + .iter() 180 + .any(|msg| msg.role == rustagent::llm::Role::System && msg.content.contains("Wrap up")); 181 + assert!( 182 + has_wrap_up_message, 183 + "Expected wrap-up system message in second LLM call messages" 184 + ); 185 + 186 + // Verify outcome is valid completion or token exhaustion 187 + match outcome { 188 + AgentOutcome::Completed { .. } | AgentOutcome::TokenBudgetExhausted { .. } => {} 189 + _ => panic!("Unexpected outcome: {:?}", outcome), 190 + } 191 + } 192 + 193 + #[tokio::test] 194 + async fn test_p1d_ac4_3_token_budget_exhausted() { 195 + // P1d.AC4.3: At 100% budget, return TokenBudgetExhausted 196 + let mock_client = Arc::new(MockLlmClient::new()); 197 + 198 + // First call uses all remaining budget 199 + mock_client.set_token_counts(600, 400); // Total 1000 of budget 1000 200 + mock_client.queue_text_response("Using up budget"); 201 + 202 + let registry = ToolRegistry::new(); 203 + registry.register(Arc::new(rustagent::tools::signal::SignalTool::new())); 204 + 205 + let mut config = RuntimeConfig::default(); 206 + config.token_budget = 1000; 207 + 208 + let runtime = AgentRuntime::new( 209 + mock_client.clone(), 210 + registry, 211 + AgentProfile { 212 + name: "test".to_string(), 213 + extends: None, 214 + role: "Test".to_string(), 215 + system_prompt: "Test prompt".to_string(), 216 + allowed_tools: vec!["signal_completion".to_string()], 217 + security: SecurityScope::default(), 218 + llm: Default::default(), 219 + turn_limit: Some(100), 220 + token_budget: Some(1000), 221 + }, 222 + config, 223 + ); 224 + 225 + let ctx = make_test_context(); 226 + let outcome = runtime.run(ctx).await.expect("Runtime failed"); 227 + 228 + match outcome { 229 + AgentOutcome::TokenBudgetExhausted { tokens_used, .. } => { 230 + assert_eq!(tokens_used, 1000); 231 + } 232 + _ => panic!("Expected TokenBudgetExhausted, got {:?}", outcome), 233 + } 234 + } 235 + 236 + #[tokio::test] 237 + async fn test_p1d_ac4_4_llm_failure_threshold() { 238 + // P1d.AC4.4: After N consecutive LLM failures, worker signals blocked 239 + let mock_client = Arc::new(MockLlmClient::new()); 240 + 241 + // Queue 3 errors (consecutive LLM failures) 242 + for _ in 0..3 { 243 + // We'll simulate LLM errors by queueing nothing and then trying to use it 244 + // Actually, the mock client will return an error if no response is queued 245 + // Let's not queue any responses so chat() will error 246 + } 247 + 248 + let registry = ToolRegistry::new(); 249 + registry.register(Arc::new(rustagent::tools::signal::SignalTool::new())); 250 + 251 + let mut config = RuntimeConfig::default(); 252 + config.max_consecutive_llm_failures = 2; // Lower threshold for testing 253 + 254 + let runtime = AgentRuntime::new( 255 + mock_client.clone(), 256 + registry, 257 + AgentProfile { 258 + name: "test".to_string(), 259 + extends: None, 260 + role: "Test".to_string(), 261 + system_prompt: "Test prompt".to_string(), 262 + allowed_tools: vec!["signal_completion".to_string()], 263 + security: SecurityScope::default(), 264 + llm: Default::default(), 265 + turn_limit: Some(100), 266 + token_budget: Some(200_000), 267 + }, 268 + config, 269 + ); 270 + 271 + let ctx = make_test_context(); 272 + let outcome = runtime.run(ctx).await.expect("Runtime failed"); 273 + 274 + match outcome { 275 + AgentOutcome::Blocked { reason } => { 276 + assert!(reason.contains("LLM") || reason.contains("llm") || reason.contains("failure")); 277 + } 278 + _ => panic!("Expected Blocked outcome, got {:?}", outcome), 279 + } 280 + } 281 + 282 + #[tokio::test] 283 + async fn test_p1d_ac4_5_turn_limit() { 284 + // P1d.AC4.5: After max_turns, return Completed with "turn limit reached" 285 + let mock_client = Arc::new(MockLlmClient::new()); 286 + 287 + // Queue 4 text responses (more than max_turns) 288 + for _ in 0..4 { 289 + mock_client.queue_text_response("Continuing work..."); 290 + } 291 + 292 + let registry = ToolRegistry::new(); 293 + registry.register(Arc::new(rustagent::tools::signal::SignalTool::new())); 294 + 295 + let mut config = RuntimeConfig::default(); 296 + config.max_turns = 3; 297 + 298 + let runtime = AgentRuntime::new( 299 + mock_client.clone(), 300 + registry, 301 + AgentProfile { 302 + name: "test".to_string(), 303 + extends: None, 304 + role: "Test".to_string(), 305 + system_prompt: "Test prompt".to_string(), 306 + allowed_tools: vec!["signal_completion".to_string()], 307 + security: SecurityScope::default(), 308 + llm: Default::default(), 309 + turn_limit: Some(3), 310 + token_budget: None, 311 + }, 312 + config, 313 + ); 314 + 315 + let ctx = make_test_context(); 316 + let outcome = runtime.run(ctx).await.expect("Runtime failed"); 317 + 318 + match outcome { 319 + AgentOutcome::Completed { summary } => { 320 + assert!(summary.contains("turn") || summary.contains("limit")); 321 + } 322 + _ => panic!( 323 + "Expected Completed outcome with turn limit message, got {:?}", 324 + outcome 325 + ), 326 + } 327 + }
+191
tests/agent_types_test.rs
··· 1 + use async_trait::async_trait; 2 + use rustagent::agent::profile::AgentProfile; 3 + use rustagent::agent::{Agent, AgentContext, AgentId, AgentOutcome}; 4 + use rustagent::security::SecurityScope; 5 + use std::path::PathBuf; 6 + use std::sync::Arc; 7 + 8 + mod common; 9 + use common::MockGraphStore; 10 + 11 + /// Mock agent for testing trait implementation 12 + struct MockAgent { 13 + id: AgentId, 14 + profile: AgentProfile, 15 + } 16 + 17 + #[async_trait] 18 + impl Agent for MockAgent { 19 + fn id(&self) -> &AgentId { 20 + &self.id 21 + } 22 + 23 + fn profile(&self) -> &AgentProfile { 24 + &self.profile 25 + } 26 + 27 + async fn run(&self, _ctx: AgentContext) -> anyhow::Result<AgentOutcome> { 28 + Ok(AgentOutcome::Completed { 29 + summary: "mock completed".to_string(), 30 + }) 31 + } 32 + 33 + fn cancel(&self) { 34 + // No-op stub for Phase 1d 35 + } 36 + } 37 + 38 + #[test] 39 + fn test_agent_trait_compiles() { 40 + // P1d.AC2.1: Verify Agent trait can be implemented 41 + let profile = AgentProfile { 42 + name: "test".to_string(), 43 + extends: None, 44 + role: "test role".to_string(), 45 + system_prompt: "test prompt".to_string(), 46 + allowed_tools: vec![], 47 + security: SecurityScope::default(), 48 + llm: Default::default(), 49 + turn_limit: None, 50 + token_budget: None, 51 + }; 52 + 53 + let _agent = MockAgent { 54 + id: "agent-1".to_string(), 55 + profile, 56 + }; 57 + // If this compiles, the trait is correctly defined 58 + } 59 + 60 + #[test] 61 + fn test_agent_context_construction() { 62 + // P1d.AC2.2: Verify AgentContext can be constructed with all fields 63 + let profile = AgentProfile { 64 + name: "test".to_string(), 65 + extends: None, 66 + role: "test role".to_string(), 67 + system_prompt: "test prompt".to_string(), 68 + allowed_tools: vec![], 69 + security: SecurityScope::default(), 70 + llm: Default::default(), 71 + turn_limit: None, 72 + token_budget: None, 73 + }; 74 + 75 + let _ctx = AgentContext { 76 + work_package_tasks: vec![], 77 + relevant_decisions: vec![], 78 + handoff_notes: Some("test notes".to_string()), 79 + agents_md_summaries: vec![("path".to_string(), "summary".to_string())], 80 + profile, 81 + project_path: PathBuf::from("/tmp"), 82 + graph_store: Arc::new(MockGraphStore), 83 + }; 84 + // If this compiles, the struct is correctly defined 85 + } 86 + 87 + #[test] 88 + fn test_agent_outcome_completed() { 89 + // P1d.AC2.3: Verify Completed variant 90 + let outcome = AgentOutcome::Completed { 91 + summary: "task completed".to_string(), 92 + }; 93 + 94 + match outcome { 95 + AgentOutcome::Completed { summary } => { 96 + assert_eq!(summary, "task completed"); 97 + } 98 + _ => panic!("Expected Completed variant"), 99 + } 100 + } 101 + 102 + #[test] 103 + fn test_agent_outcome_blocked() { 104 + // P1d.AC2.3: Verify Blocked variant 105 + let outcome = AgentOutcome::Blocked { 106 + reason: "blocked by dependency".to_string(), 107 + }; 108 + 109 + match outcome { 110 + AgentOutcome::Blocked { reason } => { 111 + assert_eq!(reason, "blocked by dependency"); 112 + } 113 + _ => panic!("Expected Blocked variant"), 114 + } 115 + } 116 + 117 + #[test] 118 + fn test_agent_outcome_failed() { 119 + // P1d.AC2.3: Verify Failed variant 120 + let outcome = AgentOutcome::Failed { 121 + error: "something went wrong".to_string(), 122 + }; 123 + 124 + match outcome { 125 + AgentOutcome::Failed { error } => { 126 + assert_eq!(error, "something went wrong"); 127 + } 128 + _ => panic!("Expected Failed variant"), 129 + } 130 + } 131 + 132 + #[test] 133 + fn test_agent_outcome_token_budget_exhausted() { 134 + // P1d.AC2.3: Verify TokenBudgetExhausted variant 135 + let outcome = AgentOutcome::TokenBudgetExhausted { 136 + summary: "partial work done".to_string(), 137 + tokens_used: 5000, 138 + }; 139 + 140 + match outcome { 141 + AgentOutcome::TokenBudgetExhausted { 142 + summary, 143 + tokens_used, 144 + } => { 145 + assert_eq!(summary, "partial work done"); 146 + assert_eq!(tokens_used, 5000); 147 + } 148 + _ => panic!("Expected TokenBudgetExhausted variant"), 149 + } 150 + } 151 + 152 + #[tokio::test] 153 + async fn test_mock_agent_run() { 154 + // P1d.AC2.1 & P1d.AC2.3: Verify mock agent can be run 155 + let profile = AgentProfile { 156 + name: "test".to_string(), 157 + extends: None, 158 + role: "test role".to_string(), 159 + system_prompt: "test prompt".to_string(), 160 + allowed_tools: vec![], 161 + security: SecurityScope::default(), 162 + llm: Default::default(), 163 + turn_limit: None, 164 + token_budget: None, 165 + }; 166 + 167 + let agent = MockAgent { 168 + id: "agent-1".to_string(), 169 + profile, 170 + }; 171 + 172 + let ctx = AgentContext { 173 + work_package_tasks: vec![], 174 + relevant_decisions: vec![], 175 + handoff_notes: None, 176 + agents_md_summaries: vec![], 177 + profile: agent.profile().clone(), 178 + project_path: PathBuf::from("/tmp"), 179 + graph_store: Arc::new(MockGraphStore), 180 + }; 181 + 182 + let result = agent.run(ctx).await; 183 + assert!(result.is_ok()); 184 + 185 + match result.unwrap() { 186 + AgentOutcome::Completed { summary } => { 187 + assert_eq!(summary, "mock completed"); 188 + } 189 + _ => panic!("Expected Completed outcome"), 190 + } 191 + }
+293
tests/common/mod.rs
··· 1 + #![allow(dead_code)] 2 + 3 + use anyhow::Result; 4 + use async_trait::async_trait; 5 + use chrono::Utc; 6 + use rustagent::db::Database; 7 + use rustagent::graph::store::{EdgeDirection, GraphStore, NodeQuery, SqliteGraphStore, WorkGraph}; 8 + use rustagent::graph::*; 9 + use std::collections::HashMap; 10 + use std::sync::Arc; 11 + 12 + /// Helper to create a test goal node 13 + pub fn create_test_goal(id: &str, project_id: &str, title: &str) -> GraphNode { 14 + GraphNode { 15 + id: id.to_string(), 16 + project_id: project_id.to_string(), 17 + node_type: NodeType::Goal, 18 + title: title.to_string(), 19 + description: "Test goal".to_string(), 20 + status: NodeStatus::Pending, 21 + priority: Some(Priority::High), 22 + assigned_to: None, 23 + created_by: None, 24 + labels: vec![], 25 + created_at: Utc::now(), 26 + started_at: None, 27 + completed_at: None, 28 + blocked_reason: None, 29 + metadata: HashMap::new(), 30 + } 31 + } 32 + 33 + /// Helper to create a test task node (can optionally accept priority) 34 + pub fn create_test_task(id: &str, project_id: &str, title: &str, status: NodeStatus) -> GraphNode { 35 + create_test_task_with_priority(id, project_id, title, status, Some(Priority::Medium)) 36 + } 37 + 38 + /// Helper to create a test task node with specific priority 39 + pub fn create_test_task_with_priority( 40 + id: &str, 41 + project_id: &str, 42 + title: &str, 43 + status: NodeStatus, 44 + priority: Option<Priority>, 45 + ) -> GraphNode { 46 + GraphNode { 47 + id: id.to_string(), 48 + project_id: project_id.to_string(), 49 + node_type: NodeType::Task, 50 + title: title.to_string(), 51 + description: "Test task".to_string(), 52 + status, 53 + priority, 54 + assigned_to: None, 55 + created_by: None, 56 + labels: vec![], 57 + created_at: Utc::now(), 58 + started_at: None, 59 + completed_at: None, 60 + blocked_reason: None, 61 + metadata: HashMap::new(), 62 + } 63 + } 64 + 65 + /// Helper to create a test observation node 66 + pub fn create_test_observation( 67 + id: &str, 68 + project_id: &str, 69 + title: &str, 70 + description: &str, 71 + ) -> GraphNode { 72 + GraphNode { 73 + id: id.to_string(), 74 + project_id: project_id.to_string(), 75 + node_type: NodeType::Observation, 76 + title: title.to_string(), 77 + description: description.to_string(), 78 + status: NodeStatus::Active, 79 + priority: None, 80 + assigned_to: None, 81 + created_by: None, 82 + labels: vec![], 83 + created_at: Utc::now(), 84 + started_at: None, 85 + completed_at: None, 86 + blocked_reason: None, 87 + metadata: HashMap::new(), 88 + } 89 + } 90 + 91 + /// Helper to create a test decision node 92 + pub fn create_test_decision(id: &str, project_id: &str, title: &str) -> GraphNode { 93 + GraphNode { 94 + id: id.to_string(), 95 + project_id: project_id.to_string(), 96 + node_type: NodeType::Decision, 97 + title: title.to_string(), 98 + description: "Test decision".to_string(), 99 + status: NodeStatus::Pending, 100 + priority: None, 101 + assigned_to: None, 102 + created_by: None, 103 + labels: vec![], 104 + created_at: Utc::now(), 105 + started_at: None, 106 + completed_at: None, 107 + blocked_reason: None, 108 + metadata: HashMap::new(), 109 + } 110 + } 111 + 112 + /// Helper to set up a test database with a project (graph store only) 113 + pub async fn setup_test_env() -> Result<(Database, SqliteGraphStore)> { 114 + let db = Database::open_in_memory().await?; 115 + let graph_store = SqliteGraphStore::new(db.clone()); 116 + 117 + // Create a test project by directly inserting into the database 118 + let db_for_project = db.clone(); 119 + db_for_project 120 + .connection() 121 + .call(|conn| { 122 + let now = chrono::Utc::now().to_rfc3339(); 123 + conn.execute( 124 + "INSERT INTO projects (id, name, path, registered_at, config_overrides, metadata) 125 + VALUES (?, ?, ?, ?, ?, ?)", 126 + rusqlite::params![ 127 + "proj-1", 128 + "proj-1", 129 + "/tmp/proj-1", 130 + &now, 131 + None::<String>, 132 + "{}" 133 + ], 134 + )?; 135 + Ok(()) 136 + }) 137 + .await?; 138 + 139 + Ok((db, graph_store)) 140 + } 141 + 142 + /// Helper to set up a test database with a project (includes project store) 143 + pub async fn setup_test_env_with_project() 144 + -> Result<(Database, SqliteGraphStore, rustagent::project::ProjectStore)> { 145 + let db = Database::open_in_memory().await?; 146 + let proj_store = rustagent::project::ProjectStore::new(db.clone()); 147 + let graph_store = SqliteGraphStore::new(db.clone()); 148 + 149 + // Create a test project by directly inserting into the database 150 + let db_for_project = db.clone(); 151 + db_for_project 152 + .connection() 153 + .call(|conn| { 154 + let now = chrono::Utc::now().to_rfc3339(); 155 + conn.execute( 156 + "INSERT INTO projects (id, name, path, registered_at, config_overrides, metadata) 157 + VALUES (?, ?, ?, ?, ?, ?)", 158 + rusqlite::params![ 159 + "proj-1", 160 + "proj-1", 161 + "/tmp/proj-1", 162 + &now, 163 + None::<String>, 164 + "{}" 165 + ], 166 + )?; 167 + Ok(()) 168 + }) 169 + .await?; 170 + 171 + Ok((db, graph_store, proj_store)) 172 + } 173 + 174 + /// Helper to set up a test database with a project (wrapped in Arc for concurrency tests) 175 + pub async fn setup_test_env_concurrent() -> Result<(Database, Arc<SqliteGraphStore>)> { 176 + let db = Database::open_in_memory().await?; 177 + let graph_store = Arc::new(SqliteGraphStore::new(db.clone())); 178 + 179 + // Create a test project by directly inserting into the database 180 + let db_for_project = db.clone(); 181 + db_for_project 182 + .connection() 183 + .call(|conn| { 184 + let now = chrono::Utc::now().to_rfc3339(); 185 + conn.execute( 186 + "INSERT INTO projects (id, name, path, registered_at, config_overrides, metadata) 187 + VALUES (?, ?, ?, ?, ?, ?)", 188 + rusqlite::params![ 189 + "proj-1", 190 + "proj-1", 191 + "/tmp/proj-1", 192 + &now, 193 + None::<String>, 194 + "{}" 195 + ], 196 + )?; 197 + Ok(()) 198 + }) 199 + .await?; 200 + 201 + Ok((db, graph_store)) 202 + } 203 + 204 + /// Mock GraphStore for testing (returns empty/default values for all operations) 205 + pub struct MockGraphStore; 206 + 207 + #[async_trait] 208 + impl GraphStore for MockGraphStore { 209 + async fn create_node(&self, _node: &GraphNode) -> Result<()> { 210 + Ok(()) 211 + } 212 + 213 + async fn update_node( 214 + &self, 215 + _id: &str, 216 + _status: Option<NodeStatus>, 217 + _title: Option<&str>, 218 + _description: Option<&str>, 219 + _blocked_reason: Option<&str>, 220 + _metadata: Option<&HashMap<String, String>>, 221 + ) -> Result<()> { 222 + Ok(()) 223 + } 224 + 225 + async fn get_node(&self, _id: &str) -> Result<Option<GraphNode>> { 226 + Ok(None) 227 + } 228 + 229 + async fn query_nodes(&self, _query: &NodeQuery) -> Result<Vec<GraphNode>> { 230 + Ok(vec![]) 231 + } 232 + 233 + async fn claim_task(&self, _node_id: &str, _agent_id: &str) -> Result<bool> { 234 + Ok(false) 235 + } 236 + 237 + async fn get_ready_tasks(&self, _goal_id: &str) -> Result<Vec<GraphNode>> { 238 + Ok(vec![]) 239 + } 240 + 241 + async fn get_next_task(&self, _goal_id: &str) -> Result<Option<GraphNode>> { 242 + Ok(None) 243 + } 244 + 245 + async fn add_edge(&self, _edge: &GraphEdge) -> Result<()> { 246 + Ok(()) 247 + } 248 + 249 + async fn remove_edge(&self, _edge_id: &str) -> Result<()> { 250 + Ok(()) 251 + } 252 + 253 + async fn get_edges( 254 + &self, 255 + _node_id: &str, 256 + _direction: EdgeDirection, 257 + ) -> Result<Vec<(GraphEdge, GraphNode)>> { 258 + Ok(vec![]) 259 + } 260 + 261 + async fn get_children(&self, _node_id: &str) -> Result<Vec<(GraphNode, EdgeType)>> { 262 + Ok(vec![]) 263 + } 264 + 265 + async fn get_subtree(&self, _node_id: &str) -> Result<Vec<GraphNode>> { 266 + Ok(vec![]) 267 + } 268 + 269 + async fn get_active_decisions(&self, _project_id: &str) -> Result<Vec<GraphNode>> { 270 + Ok(vec![]) 271 + } 272 + 273 + async fn get_full_graph(&self, _goal_id: &str) -> Result<WorkGraph> { 274 + Ok(WorkGraph { 275 + nodes: vec![], 276 + edges: vec![], 277 + }) 278 + } 279 + 280 + async fn search_nodes( 281 + &self, 282 + _query: &str, 283 + _project_id: Option<&str>, 284 + _node_type: Option<NodeType>, 285 + _limit: usize, 286 + ) -> Result<Vec<GraphNode>> { 287 + Ok(vec![]) 288 + } 289 + 290 + async fn next_child_seq(&self, _parent_id: &str) -> Result<u32> { 291 + Ok(1) 292 + } 293 + }
+366
tests/db_test.rs
··· 1 + use rustagent::db::Database; 2 + use tempfile::TempDir; 3 + 4 + #[tokio::test] 5 + async fn test_database_open_creates_file() { 6 + let temp_dir = TempDir::new().unwrap(); 7 + let db_path = temp_dir.path().join("test.db"); 8 + 9 + let db = Database::open(&db_path).await.unwrap(); 10 + 11 + // Verify the file was created 12 + assert!(db_path.exists()); 13 + assert!(db_path.is_file()); 14 + 15 + // Connection should be accessible 16 + let conn = db.connection(); 17 + conn.call(|c| { 18 + // Verify basic pragma 19 + let mut stmt = c.prepare("PRAGMA database_list")?; 20 + let _db_name: String = stmt.query_row([], |row| row.get(1))?; 21 + Ok(()) 22 + }) 23 + .await 24 + .unwrap(); 25 + } 26 + 27 + #[tokio::test] 28 + async fn test_database_pragma_wal_mode() { 29 + let temp_dir = TempDir::new().unwrap(); 30 + let db_path = temp_dir.path().join("test.db"); 31 + let db = Database::open(&db_path).await.unwrap(); 32 + 33 + let conn = db.connection(); 34 + let journal_mode = conn 35 + .call(|c| { 36 + let mut stmt = c.prepare("PRAGMA journal_mode")?; 37 + let mode: String = stmt.query_row([], |row| row.get::<_, String>(0))?; 38 + Ok(mode) 39 + }) 40 + .await 41 + .unwrap(); 42 + 43 + assert_eq!(journal_mode.to_lowercase(), "wal"); 44 + } 45 + 46 + #[tokio::test] 47 + async fn test_database_pragma_foreign_keys() { 48 + let db = Database::open_in_memory().await.unwrap(); 49 + 50 + let conn = db.connection(); 51 + let foreign_keys = conn 52 + .call(|c| { 53 + let mut stmt = c.prepare("PRAGMA foreign_keys")?; 54 + let value: i32 = stmt.query_row([], |row| row.get::<_, i32>(0))?; 55 + Ok(value) 56 + }) 57 + .await 58 + .unwrap(); 59 + 60 + assert_eq!(foreign_keys, 1); 61 + } 62 + 63 + #[tokio::test] 64 + async fn test_database_pragma_busy_timeout() { 65 + let db = Database::open_in_memory().await.unwrap(); 66 + 67 + let conn = db.connection(); 68 + let busy_timeout = conn 69 + .call(|c| { 70 + let mut stmt = c.prepare("PRAGMA busy_timeout")?; 71 + let value: i32 = stmt.query_row([], |row| row.get::<_, i32>(0))?; 72 + Ok(value) 73 + }) 74 + .await 75 + .unwrap(); 76 + 77 + assert_eq!(busy_timeout, 5000); 78 + } 79 + 80 + #[tokio::test] 81 + async fn test_database_schema_tables_exist() { 82 + let db = Database::open_in_memory().await.unwrap(); 83 + 84 + let conn = db.connection(); 85 + let tables = conn 86 + .call(|c| { 87 + let mut stmt = 88 + c.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?; 89 + let tables = stmt 90 + .query_map([], |row| row.get::<_, String>(0))? 91 + .collect::<Result<Vec<String>, _>>()?; 92 + Ok(tables) 93 + }) 94 + .await 95 + .unwrap(); 96 + 97 + let expected_tables = vec![ 98 + "edges", 99 + "nodes", 100 + "nodes_fts", 101 + "projects", 102 + "schema_version", 103 + "sessions", 104 + "worker_conversations", 105 + ]; 106 + 107 + for expected in expected_tables { 108 + assert!( 109 + tables.contains(&expected.to_string()), 110 + "Missing table: {}", 111 + expected 112 + ); 113 + } 114 + } 115 + 116 + #[tokio::test] 117 + async fn test_schema_version_after_fresh_init() { 118 + let db = Database::open_in_memory().await.unwrap(); 119 + 120 + let conn = db.connection(); 121 + let version = conn 122 + .call(|c| { 123 + let mut stmt = c.prepare("SELECT version FROM schema_version LIMIT 1")?; 124 + let v: u32 = stmt.query_row([], |row| row.get::<_, u32>(0))?; 125 + Ok(v) 126 + }) 127 + .await 128 + .unwrap(); 129 + 130 + assert_eq!(version, 1); 131 + } 132 + 133 + #[tokio::test] 134 + async fn test_projects_table_has_correct_schema() { 135 + let db = Database::open_in_memory().await.unwrap(); 136 + 137 + let conn = db.connection(); 138 + let columns = conn 139 + .call(|c| { 140 + let mut stmt = c.prepare("PRAGMA table_info(projects)")?; 141 + let cols = stmt 142 + .query_map([], |row| { 143 + Ok((row.get::<_, String>(1)?, row.get::<_, String>(2)?)) 144 + })? 145 + .collect::<Result<Vec<_>, _>>()?; 146 + Ok(cols) 147 + }) 148 + .await 149 + .unwrap(); 150 + 151 + let expected_cols = vec![ 152 + "id", 153 + "name", 154 + "path", 155 + "registered_at", 156 + "config_overrides", 157 + "metadata", 158 + ]; 159 + 160 + for (col_name, _) in columns { 161 + assert!( 162 + expected_cols.contains(&col_name.as_str()), 163 + "Unexpected column: {}", 164 + col_name 165 + ); 166 + } 167 + } 168 + 169 + #[tokio::test] 170 + async fn test_nodes_table_has_correct_schema() { 171 + let db = Database::open_in_memory().await.unwrap(); 172 + 173 + let conn = db.connection(); 174 + let columns = conn 175 + .call(|c| { 176 + let mut stmt = c.prepare("PRAGMA table_info(nodes)")?; 177 + let cols = stmt 178 + .query_map([], |row| row.get::<_, String>(1))? 179 + .collect::<Result<Vec<_>, _>>()?; 180 + Ok(cols) 181 + }) 182 + .await 183 + .unwrap(); 184 + 185 + let expected_cols = vec![ 186 + "id", 187 + "project_id", 188 + "node_type", 189 + "title", 190 + "description", 191 + "status", 192 + "priority", 193 + "assigned_to", 194 + "created_by", 195 + "labels", 196 + "created_at", 197 + "started_at", 198 + "completed_at", 199 + "blocked_reason", 200 + "metadata", 201 + ]; 202 + 203 + for expected in expected_cols { 204 + assert!( 205 + columns.contains(&expected.to_string()), 206 + "Missing column in nodes table: {}", 207 + expected 208 + ); 209 + } 210 + } 211 + 212 + #[tokio::test] 213 + async fn test_fts_table_exists() { 214 + let db = Database::open_in_memory().await.unwrap(); 215 + 216 + let conn = db.connection(); 217 + let exists = conn 218 + .call(|c| { 219 + let mut stmt = c.prepare( 220 + "SELECT name FROM sqlite_master WHERE type='table' AND name='nodes_fts'", 221 + )?; 222 + let result = stmt.exists([])?; 223 + Ok(result) 224 + }) 225 + .await 226 + .unwrap(); 227 + 228 + assert!(exists, "nodes_fts virtual table does not exist"); 229 + } 230 + 231 + #[tokio::test] 232 + async fn test_triggers_exist() { 233 + let db = Database::open_in_memory().await.unwrap(); 234 + 235 + let conn = db.connection(); 236 + let triggers = conn 237 + .call(|c| { 238 + let mut stmt = 239 + c.prepare("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name")?; 240 + let triggers = stmt 241 + .query_map([], |row| row.get::<_, String>(0))? 242 + .collect::<Result<Vec<_>, _>>()?; 243 + Ok(triggers) 244 + }) 245 + .await 246 + .unwrap(); 247 + 248 + let expected_triggers = vec!["nodes_ai", "nodes_ad", "nodes_au"]; 249 + 250 + for expected in expected_triggers { 251 + assert!( 252 + triggers.contains(&expected.to_string()), 253 + "Missing trigger: {}", 254 + expected 255 + ); 256 + } 257 + } 258 + 259 + #[tokio::test] 260 + async fn test_opening_existing_database_does_not_error() { 261 + let temp_dir = TempDir::new().unwrap(); 262 + let db_path = temp_dir.path().join("test.db"); 263 + 264 + // Create database first time 265 + let _db1 = Database::open(&db_path).await.unwrap(); 266 + drop(_db1); 267 + 268 + // Open again - should work 269 + let _db2 = Database::open(&db_path).await.unwrap(); 270 + } 271 + 272 + #[tokio::test] 273 + async fn test_database_clone() { 274 + let db = Database::open_in_memory().await.unwrap(); 275 + 276 + let db_clone = db.clone(); 277 + 278 + // Both should be able to access the database 279 + let conn1 = db.connection(); 280 + let conn2 = db_clone.connection(); 281 + 282 + let result1 = conn1 283 + .call(|c| { 284 + let mut stmt = c.prepare("SELECT version FROM schema_version")?; 285 + let version: u32 = stmt.query_row([], |row| row.get::<_, u32>(0))?; 286 + Ok(version) 287 + }) 288 + .await 289 + .unwrap(); 290 + 291 + let result2 = conn2 292 + .call(|c| { 293 + let mut stmt = c.prepare("SELECT version FROM schema_version")?; 294 + let version: u32 = stmt.query_row([], |row| row.get::<_, u32>(0))?; 295 + Ok(version) 296 + }) 297 + .await 298 + .unwrap(); 299 + 300 + assert_eq!(result1, result2); 301 + } 302 + 303 + #[tokio::test] 304 + async fn test_indexes_exist() { 305 + let db = Database::open_in_memory().await.unwrap(); 306 + 307 + let conn = db.connection(); 308 + let indexes = conn 309 + .call(|c| { 310 + let mut stmt = c.prepare( 311 + "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%' ORDER BY name", 312 + )?; 313 + let indexes = stmt 314 + .query_map([], |row| row.get::<_, String>(0))? 315 + .collect::<Result<Vec<_>, _>>()?; 316 + Ok(indexes) 317 + }) 318 + .await 319 + .unwrap(); 320 + 321 + let expected_indexes = vec![ 322 + "idx_edges_from", 323 + "idx_edges_to", 324 + "idx_edges_type", 325 + "idx_nodes_project", 326 + "idx_nodes_status", 327 + "idx_nodes_type", 328 + ]; 329 + 330 + for expected in expected_indexes { 331 + assert!( 332 + indexes.contains(&expected.to_string()), 333 + "Missing index: {}", 334 + expected 335 + ); 336 + } 337 + } 338 + 339 + #[tokio::test] 340 + async fn test_newer_database_version_error() { 341 + let db = Database::open_in_memory().await.unwrap(); 342 + 343 + // Manually set version to 999 to simulate a newer database 344 + let conn = db.connection(); 345 + conn.call(|c| { 346 + c.execute("UPDATE schema_version SET version = 999", [])?; 347 + Ok(()) 348 + }) 349 + .await 350 + .unwrap(); 351 + 352 + // Now try to run migrations again - should fail with error containing "newer" 353 + let result = conn 354 + .call(|c| { 355 + rustagent::db::migrations::check_and_migrate(c).map_err(tokio_rusqlite::Error::Rusqlite) 356 + }) 357 + .await; 358 + 359 + assert!(result.is_err(), "Expected error for newer database version"); 360 + let error_msg = result.unwrap_err().to_string(); 361 + assert!( 362 + error_msg.contains("newer"), 363 + "Error message should contain 'newer', got: {}", 364 + error_msg 365 + ); 366 + }
+283
tests/decay_test.rs
··· 1 + //! Tests for node decay functionality (P1c.AC4.1 - P1c.AC4.4) 2 + 3 + use chrono::{Duration, Utc}; 4 + use rustagent::graph::decay::{DecayConfig, DecayDetail, decay_node, decay_nodes}; 5 + use rustagent::graph::{GraphNode, NodeStatus, NodeType}; 6 + use std::collections::HashMap; 7 + 8 + fn create_test_node(id: &str, created_days_ago: i64, completed_days_ago: Option<i64>) -> GraphNode { 9 + let now = Utc::now(); 10 + let created_at = now - Duration::days(created_days_ago); 11 + let completed_at = completed_days_ago.map(|d| now - Duration::days(d)); 12 + 13 + GraphNode { 14 + id: id.to_string(), 15 + project_id: "proj-test".to_string(), 16 + node_type: NodeType::Task, 17 + title: format!("Task {}", id), 18 + description: "Test description with important details".to_string(), 19 + status: NodeStatus::Completed, 20 + priority: None, 21 + assigned_to: None, 22 + created_by: None, 23 + labels: vec![], 24 + created_at, 25 + started_at: None, 26 + completed_at, 27 + blocked_reason: None, 28 + metadata: { 29 + let mut m = HashMap::new(); 30 + m.insert("key_outcome".to_string(), "Important result".to_string()); 31 + m.insert("context".to_string(), "Additional context".to_string()); 32 + m 33 + }, 34 + } 35 + } 36 + 37 + /// P1c.AC4.1: Nodes < 7 days old show Full detail 38 + #[test] 39 + fn test_full_detail_recent_node() { 40 + let config = DecayConfig::default(); 41 + let node = create_test_node("recent", 10, Some(2)); // completed 2 days ago 42 + 43 + let decayed = decay_node(&node, Utc::now(), &config); 44 + 45 + assert_eq!(decayed.id, "recent"); 46 + assert_eq!(decayed.title, "Task recent"); 47 + assert_eq!(decayed.status, NodeStatus::Completed); 48 + 49 + match decayed.detail { 50 + DecayDetail::Full { 51 + description, 52 + metadata, 53 + } => { 54 + assert_eq!(description, "Test description with important details"); 55 + assert_eq!( 56 + metadata.get("key_outcome"), 57 + Some(&"Important result".to_string()) 58 + ); 59 + assert_eq!( 60 + metadata.get("context"), 61 + Some(&"Additional context".to_string()) 62 + ); 63 + } 64 + other => panic!("Expected Full detail for recent node, got {:?}", other), 65 + } 66 + } 67 + 68 + /// P1c.AC4.2: Nodes 7-30 days old show Summary only 69 + #[test] 70 + fn test_summary_detail_older_node() { 71 + let config = DecayConfig::default(); 72 + let node = create_test_node("older", 20, Some(15)); // completed 15 days ago 73 + 74 + let decayed = decay_node(&node, Utc::now(), &config); 75 + 76 + assert_eq!(decayed.id, "older"); 77 + assert_eq!(decayed.title, "Task older"); 78 + assert_eq!(decayed.status, NodeStatus::Completed); 79 + 80 + match decayed.detail { 81 + DecayDetail::Summary { key_outcome } => { 82 + assert_eq!(key_outcome, Some("Important result".to_string())); 83 + } 84 + other => panic!("Expected Summary detail for older node, got {:?}", other), 85 + } 86 + } 87 + 88 + /// P1c.AC4.3: Nodes > 30 days old show Minimal detail 89 + #[test] 90 + fn test_minimal_detail_very_old_node() { 91 + let config = DecayConfig::default(); 92 + let node = create_test_node("very_old", 50, Some(45)); // completed 45 days ago 93 + 94 + let decayed = decay_node(&node, Utc::now(), &config); 95 + 96 + assert_eq!(decayed.id, "very_old"); 97 + assert_eq!(decayed.title, "Task very_old"); 98 + assert_eq!(decayed.status, NodeStatus::Completed); 99 + 100 + match decayed.detail { 101 + DecayDetail::Minimal => { 102 + // Expected - no additional fields 103 + } 104 + other => panic!("Expected Minimal detail for very old node, got {:?}", other), 105 + } 106 + } 107 + 108 + /// P1c.AC4.4: Configurable thresholds work correctly 109 + #[test] 110 + fn test_configurable_thresholds() { 111 + // Custom config: full up to 3 days, summary up to 10 days 112 + let config = DecayConfig { 113 + recent_days: 3, 114 + older_days: 10, 115 + }; 116 + 117 + let now = Utc::now(); 118 + 119 + // Node completed 2 days ago - should be Full with custom config 120 + let node_recent = create_test_node("n_recent", 10, Some(2)); 121 + let decayed_recent = decay_node(&node_recent, now, &config); 122 + assert!( 123 + matches!(decayed_recent.detail, DecayDetail::Full { .. }), 124 + "2 days old should be Full with recent_days=3" 125 + ); 126 + 127 + // Node completed 5 days ago - should be Summary with custom config 128 + let node_mid = create_test_node("n_mid", 10, Some(5)); 129 + let decayed_mid = decay_node(&node_mid, now, &config); 130 + assert!( 131 + matches!(decayed_mid.detail, DecayDetail::Summary { .. }), 132 + "5 days old should be Summary with recent_days=3, older_days=10" 133 + ); 134 + 135 + // Node completed 12 days ago - should be Minimal with custom config 136 + let node_old = create_test_node("n_old", 15, Some(12)); 137 + let decayed_old = decay_node(&node_old, now, &config); 138 + assert!( 139 + matches!(decayed_old.detail, DecayDetail::Minimal), 140 + "12 days old should be Minimal with older_days=10" 141 + ); 142 + } 143 + 144 + /// Test decay_nodes applies decay to multiple nodes 145 + #[test] 146 + fn test_decay_multiple_nodes() { 147 + let config = DecayConfig::default(); 148 + let nodes = vec![ 149 + create_test_node("n1", 10, Some(2)), // Full 150 + create_test_node("n2", 20, Some(15)), // Summary 151 + create_test_node("n3", 50, Some(45)), // Minimal 152 + ]; 153 + 154 + let decayed = decay_nodes(&nodes, Utc::now(), &config); 155 + 156 + assert_eq!(decayed.len(), 3); 157 + 158 + assert!(matches!(decayed[0].detail, DecayDetail::Full { .. })); 159 + assert_eq!(decayed[0].id, "n1"); 160 + 161 + assert!(matches!(decayed[1].detail, DecayDetail::Summary { .. })); 162 + assert_eq!(decayed[1].id, "n2"); 163 + 164 + assert!(matches!(decayed[2].detail, DecayDetail::Minimal)); 165 + assert_eq!(decayed[2].id, "n3"); 166 + } 167 + 168 + /// Test that completed_at is preferred over created_at for age calculation 169 + #[test] 170 + fn test_uses_completed_time_for_age() { 171 + let config = DecayConfig::default(); 172 + let now = Utc::now(); 173 + 174 + // Node created 100 days ago but completed 2 days ago should be Full 175 + let node = create_test_node("old_created", 100, Some(2)); 176 + let decayed = decay_node(&node, now, &config); 177 + 178 + match decayed.detail { 179 + DecayDetail::Full { .. } => { 180 + // Expected - uses completed_at (2 days old), not created_at (100 days old) 181 + } 182 + other => panic!("Should use completed_at for age: got {:?}", other), 183 + } 184 + } 185 + 186 + /// Test decay of node without completion time 187 + #[test] 188 + fn test_decay_node_without_completion() { 189 + let config = DecayConfig::default(); 190 + let mut node = create_test_node("in_progress", 2, None); 191 + node.status = NodeStatus::InProgress; 192 + 193 + let decayed = decay_node(&node, Utc::now(), &config); 194 + 195 + // Should use created_at - 2 days old, so Full 196 + match decayed.detail { 197 + DecayDetail::Full { .. } => { 198 + // Expected - uses created_at when completed_at is None 199 + } 200 + other => panic!( 201 + "Should use created_at when completed_at is None: got {:?}", 202 + other 203 + ), 204 + } 205 + } 206 + 207 + /// Test summary without key_outcome in metadata 208 + #[test] 209 + fn test_summary_without_key_outcome() { 210 + let config = DecayConfig::default(); 211 + let mut node = create_test_node("no_outcome", 10, Some(15)); 212 + node.metadata.remove("key_outcome"); 213 + 214 + let decayed = decay_node(&node, Utc::now(), &config); 215 + 216 + match decayed.detail { 217 + DecayDetail::Summary { key_outcome } => { 218 + assert_eq!(key_outcome, None); 219 + } 220 + other => panic!("Expected Summary without key_outcome: got {:?}", other), 221 + } 222 + } 223 + 224 + /// Test boundary condition at exact threshold (7 days = Summary) 225 + #[test] 226 + fn test_boundary_recent_to_summary() { 227 + let config = DecayConfig::default(); 228 + let now = Utc::now(); 229 + 230 + // Over 7 days ago should transition to Summary (not Full) 231 + let node = create_test_node("boundary_7", 10, Some(8)); 232 + let decayed = decay_node(&node, now, &config); 233 + 234 + assert!( 235 + matches!(decayed.detail, DecayDetail::Summary { .. }), 236 + "Node over 7 days old should be Summary" 237 + ); 238 + } 239 + 240 + /// Test boundary condition at exact threshold (30 days = Minimal) 241 + #[test] 242 + fn test_boundary_summary_to_minimal() { 243 + let config = DecayConfig::default(); 244 + let now = Utc::now(); 245 + 246 + // Over 30 days ago should be Minimal 247 + let node = create_test_node("boundary_30", 40, Some(31)); 248 + let decayed = decay_node(&node, now, &config); 249 + 250 + assert!( 251 + matches!(decayed.detail, DecayDetail::Minimal), 252 + "Node over 30 days old should be Minimal" 253 + ); 254 + } 255 + 256 + /// Test that all metadata fields are preserved in Full detail 257 + #[test] 258 + fn test_full_detail_preserves_all_metadata() { 259 + let config = DecayConfig::default(); 260 + let mut node = create_test_node("full_meta", 10, Some(1)); 261 + 262 + node.metadata 263 + .insert("custom_field".to_string(), "custom_value".to_string()); 264 + node.metadata 265 + .insert("tags".to_string(), "a,b,c".to_string()); 266 + 267 + let decayed = decay_node(&node, Utc::now(), &config); 268 + 269 + match decayed.detail { 270 + DecayDetail::Full { 271 + description: _, 272 + metadata, 273 + } => { 274 + assert_eq!(metadata.len(), 4); 275 + assert_eq!( 276 + metadata.get("custom_field"), 277 + Some(&"custom_value".to_string()) 278 + ); 279 + assert_eq!(metadata.get("tags"), Some(&"a,b,c".to_string())); 280 + } 281 + other => panic!("Expected Full detail with all metadata: got {:?}", other), 282 + } 283 + }
+275
tests/graph_claim_search_test.rs
··· 1 + mod common; 2 + 3 + use anyhow::Result; 4 + use common::{create_test_goal, create_test_observation, create_test_task, setup_test_env}; 5 + use rustagent::graph::store::GraphStore; 6 + use rustagent::graph::*; 7 + 8 + /// P1b.AC5.1: claim_task atomically sets status Ready->Claimed and assigned_to 9 + #[tokio::test] 10 + async fn test_claim_task_success() -> Result<()> { 11 + let (_db, store) = setup_test_env().await?; 12 + 13 + // Create a Ready task 14 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 15 + let task = create_test_task("ra-a1b2.1", "proj-1", "Claimable Task", NodeStatus::Ready); 16 + 17 + store.create_node(&goal).await?; 18 + store.create_node(&task).await?; 19 + 20 + // Claim the task 21 + let claimed = store.claim_task("ra-a1b2.1", "agent-1").await?; 22 + assert!(claimed, "Task should have been claimed successfully"); 23 + 24 + // Verify the node was updated 25 + let claimed_node = store.get_node("ra-a1b2.1").await?; 26 + assert!(claimed_node.is_some()); 27 + let node = claimed_node.unwrap(); 28 + assert_eq!(node.status, NodeStatus::Claimed); 29 + assert_eq!(node.assigned_to, Some("agent-1".to_string())); 30 + assert!(node.started_at.is_some(), "started_at should be set"); 31 + 32 + Ok(()) 33 + } 34 + 35 + /// P1b.AC5.2: If task is not Ready, claim returns false (another worker got it first) 36 + #[tokio::test] 37 + async fn test_claim_task_already_claimed() -> Result<()> { 38 + let (_db, store) = setup_test_env().await?; 39 + 40 + // Create a Ready task 41 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 42 + let task = create_test_task("ra-a1b2.1", "proj-1", "Claimable Task", NodeStatus::Ready); 43 + 44 + store.create_node(&goal).await?; 45 + store.create_node(&task).await?; 46 + 47 + // Claim the task once (should succeed) 48 + let first_claim = store.claim_task("ra-a1b2.1", "agent-1").await?; 49 + assert!(first_claim, "First claim should succeed"); 50 + 51 + // Try to claim the same task again (should fail because it's no longer Ready) 52 + let second_claim = store.claim_task("ra-a1b2.1", "agent-2").await?; 53 + assert!(!second_claim, "Second claim should fail"); 54 + 55 + // Verify the task is still assigned to the first agent 56 + let node = store.get_node("ra-a1b2.1").await?.unwrap(); 57 + assert_eq!(node.assigned_to, Some("agent-1".to_string())); 58 + 59 + Ok(()) 60 + } 61 + 62 + /// P1b.AC5.2 variant: Claiming a Pending task should fail 63 + #[tokio::test] 64 + async fn test_claim_task_not_ready() -> Result<()> { 65 + let (_db, store) = setup_test_env().await?; 66 + 67 + // Create a Pending task (not Ready) 68 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 69 + let task = create_test_task("ra-a1b2.1", "proj-1", "Pending Task", NodeStatus::Pending); 70 + 71 + store.create_node(&goal).await?; 72 + store.create_node(&task).await?; 73 + 74 + // Try to claim the task (should fail) 75 + let claimed = store.claim_task("ra-a1b2.1", "agent-1").await?; 76 + assert!(!claimed, "Cannot claim a Pending task"); 77 + 78 + // Verify the node is still Pending 79 + let node = store.get_node("ra-a1b2.1").await?.unwrap(); 80 + assert_eq!(node.status, NodeStatus::Pending); 81 + assert_eq!(node.assigned_to, None); 82 + 83 + Ok(()) 84 + } 85 + 86 + /// P1b.AC6.1: search_nodes returns nodes matching title or description via FTS5 87 + #[tokio::test] 88 + async fn test_search_nodes_by_title_and_description() -> Result<()> { 89 + let (_db, store) = setup_test_env().await?; 90 + 91 + // Create a goal and several nodes with different content 92 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 93 + store.create_node(&goal).await?; 94 + 95 + // Create nodes with searchable content 96 + let node1 = create_test_observation( 97 + "ra-a1b2.1", 98 + "proj-1", 99 + "Authentication Bug", 100 + "Found a critical authentication issue", 101 + ); 102 + let node2 = create_test_observation( 103 + "ra-a1b2.2", 104 + "proj-1", 105 + "Database Query", 106 + "Query performance issue with authentication", 107 + ); 108 + let node3 = create_test_observation("ra-a1b2.3", "proj-1", "UI Bug", "Button styling issue"); 109 + 110 + store.create_node(&node1).await?; 111 + store.create_node(&node2).await?; 112 + store.create_node(&node3).await?; 113 + 114 + // Search for "authentication" 115 + let results = store.search_nodes("authentication", None, None, 10).await?; 116 + 117 + // Should find nodes 1 and 2 (both have "authentication" in title or description) 118 + assert!( 119 + results.len() >= 2, 120 + "Should find at least 2 nodes with 'authentication'" 121 + ); 122 + let ids: Vec<String> = results.iter().map(|n| n.id.clone()).collect(); 123 + assert!(ids.contains(&"ra-a1b2.1".to_string())); 124 + assert!(ids.contains(&"ra-a1b2.2".to_string())); 125 + assert!(!ids.contains(&"ra-a1b2.3".to_string())); 126 + 127 + Ok(()) 128 + } 129 + 130 + /// P1b.AC6.2: Search can filter by project_id 131 + #[tokio::test] 132 + async fn test_search_nodes_filter_by_project() -> Result<()> { 133 + let (db, store) = setup_test_env().await?; 134 + 135 + // Create a second project 136 + let db_proj = db.clone(); 137 + db_proj 138 + .connection() 139 + .call(|conn| { 140 + let now = chrono::Utc::now().to_rfc3339(); 141 + conn.execute( 142 + "INSERT INTO projects (id, name, path, registered_at, config_overrides, metadata) 143 + VALUES (?, ?, ?, ?, ?, ?)", 144 + rusqlite::params![ 145 + "proj-2", 146 + "proj-2", 147 + "/tmp/proj-2", 148 + &now, 149 + None::<String>, 150 + "{}" 151 + ], 152 + )?; 153 + Ok(()) 154 + }) 155 + .await?; 156 + 157 + // Create nodes in different projects 158 + let goal1 = create_test_goal("ra-a1b2", "proj-1", "Goal 1"); 159 + let goal2 = create_test_goal("ra-c3d4", "proj-2", "Goal 2"); 160 + 161 + let node1 = create_test_observation( 162 + "ra-a1b2.1", 163 + "proj-1", 164 + "Authentication in Project 1", 165 + "Details", 166 + ); 167 + let node2 = create_test_observation( 168 + "ra-c3d4.1", 169 + "proj-2", 170 + "Authentication in Project 2", 171 + "Details", 172 + ); 173 + 174 + store.create_node(&goal1).await?; 175 + store.create_node(&goal2).await?; 176 + store.create_node(&node1).await?; 177 + store.create_node(&node2).await?; 178 + 179 + // Search for "authentication" in proj-1 only 180 + let results = store 181 + .search_nodes("authentication", Some("proj-1"), None, 10) 182 + .await?; 183 + 184 + // Should only find nodes from proj-1 185 + assert_eq!(results.len(), 1); 186 + assert_eq!(results[0].id, "ra-a1b2.1"); 187 + assert_eq!(results[0].project_id, "proj-1"); 188 + 189 + Ok(()) 190 + } 191 + 192 + /// P1b.AC6.2: Search can filter by node_type 193 + #[tokio::test] 194 + async fn test_search_nodes_filter_by_type() -> Result<()> { 195 + let (_db, store) = setup_test_env().await?; 196 + 197 + // Create a goal 198 + let goal = create_test_goal("ra-a1b2", "proj-1", "Goal with authentication"); 199 + store.create_node(&goal).await?; 200 + 201 + // Create a task and an observation, both with "authentication" in the content 202 + let task = create_test_task( 203 + "ra-a1b2.1", 204 + "proj-1", 205 + "Fix authentication", 206 + NodeStatus::Pending, 207 + ); 208 + let observation = create_test_observation( 209 + "ra-a1b2.2", 210 + "proj-1", 211 + "Authentication Overview", 212 + "System details", 213 + ); 214 + 215 + store.create_node(&task).await?; 216 + store.create_node(&observation).await?; 217 + 218 + // Search for "authentication" filtered by Task type 219 + let task_results = store 220 + .search_nodes("authentication", None, Some(NodeType::Task), 10) 221 + .await?; 222 + 223 + // Should find the task 224 + assert!(!task_results.is_empty()); 225 + assert!(task_results.iter().any(|n| n.id == "ra-a1b2.1")); 226 + 227 + // Search for "authentication" filtered by Observation type 228 + let observation_results = store 229 + .search_nodes("authentication", None, Some(NodeType::Observation), 10) 230 + .await?; 231 + 232 + // Should find the observation 233 + assert!(!observation_results.is_empty()); 234 + assert!(observation_results.iter().any(|n| n.id == "ra-a1b2.2")); 235 + 236 + // Search for "authentication" filtered by Goal type 237 + let goal_results = store 238 + .search_nodes("authentication", None, Some(NodeType::Goal), 10) 239 + .await?; 240 + 241 + // Should find the goal 242 + assert!(!goal_results.is_empty()); 243 + assert!(goal_results.iter().any(|n| n.id == "ra-a1b2")); 244 + 245 + Ok(()) 246 + } 247 + 248 + /// P1b.AC6.1: Search respects limit parameter 249 + #[tokio::test] 250 + async fn test_search_nodes_respects_limit() -> Result<()> { 251 + let (_db, store) = setup_test_env().await?; 252 + 253 + // Create a goal 254 + let goal = create_test_goal("ra-a1b2", "proj-1", "Goal"); 255 + store.create_node(&goal).await?; 256 + 257 + // Create 5 nodes with "test" in their content 258 + for i in 1..=5 { 259 + let node = create_test_observation( 260 + &format!("ra-a1b2.{}", i), 261 + "proj-1", 262 + &format!("Test Node {}", i), 263 + "This is a test", 264 + ); 265 + store.create_node(&node).await?; 266 + } 267 + 268 + // Search with limit 3 269 + let results = store.search_nodes("test", None, None, 3).await?; 270 + 271 + // Should return at most 3 results 272 + assert!(results.len() <= 3, "Results should respect limit of 3"); 273 + 274 + Ok(()) 275 + }
+251
tests/graph_concurrency_test.rs
··· 1 + mod common; 2 + 3 + use anyhow::Result; 4 + use common::{create_test_goal, create_test_task, setup_test_env_concurrent}; 5 + use rustagent::graph::store::GraphStore; 6 + use rustagent::graph::*; 7 + use std::sync::Arc; 8 + 9 + #[tokio::test] 10 + async fn test_concurrent_task_claiming() -> Result<()> { 11 + let (_db, store) = setup_test_env_concurrent().await?; 12 + 13 + // Create a goal and a task in Ready status 14 + let goal = create_test_goal("goal-1", "proj-1", "Test Goal"); 15 + let task = create_test_task("task-1", "proj-1", "Claimable Task", NodeStatus::Ready); 16 + 17 + store.create_node(&goal).await?; 18 + store.create_node(&task).await?; 19 + 20 + // Spawn 10 concurrent tasks all trying to claim the same task 21 + let mut handles = vec![]; 22 + for i in 0..10 { 23 + let store_clone = Arc::clone(&store); 24 + let handle = tokio::spawn(async move { 25 + store_clone 26 + .claim_task("task-1", &format!("agent-{}", i)) 27 + .await 28 + }); 29 + handles.push(handle); 30 + } 31 + 32 + // Collect results 33 + let mut results = vec![]; 34 + for handle in handles { 35 + let result = handle.await??; 36 + results.push(result); 37 + } 38 + 39 + // Verify exactly one claim succeeded and the rest failed 40 + let success_count = results.iter().filter(|&&r| r).count(); 41 + assert_eq!( 42 + success_count, 1, 43 + "Expected exactly 1 successful claim, got {}", 44 + success_count 45 + ); 46 + 47 + let failure_count = results.iter().filter(|&&r| !r).count(); 48 + assert_eq!( 49 + failure_count, 9, 50 + "Expected 9 failed claims, got {}", 51 + failure_count 52 + ); 53 + 54 + // Verify the task now has Claimed status and assigned_to is set to one agent 55 + let updated_task = store.get_node("task-1").await?; 56 + assert!( 57 + updated_task.is_some(), 58 + "Task should still exist after claiming" 59 + ); 60 + 61 + let updated_task = updated_task.unwrap(); 62 + assert_eq!( 63 + updated_task.status, 64 + NodeStatus::Claimed, 65 + "Task status should be Claimed" 66 + ); 67 + assert!( 68 + updated_task.assigned_to.is_some(), 69 + "Task should have assigned_to set" 70 + ); 71 + 72 + let assigned_agent = updated_task.assigned_to.unwrap(); 73 + assert!( 74 + assigned_agent.starts_with("agent-"), 75 + "assigned_to should be an agent ID" 76 + ); 77 + 78 + Ok(()) 79 + } 80 + 81 + #[tokio::test] 82 + async fn test_concurrent_claim_different_tasks() -> Result<()> { 83 + let (_db, store) = setup_test_env_concurrent().await?; 84 + 85 + // Create a goal and multiple tasks in Ready status 86 + let goal = create_test_goal("goal-2", "proj-1", "Test Goal 2"); 87 + store.create_node(&goal).await?; 88 + 89 + // Create 10 Ready tasks 90 + let mut task_ids = vec![]; 91 + for i in 0..10 { 92 + let task_id = format!("task-2-{}", i); 93 + let task = create_test_task( 94 + &task_id, 95 + "proj-1", 96 + &format!("Task {}", i), 97 + NodeStatus::Ready, 98 + ); 99 + store.create_node(&task).await?; 100 + task_ids.push(task_id); 101 + } 102 + 103 + // Spawn 10 concurrent claim attempts, each for a different task 104 + let mut handles = vec![]; 105 + for (i, task_id) in task_ids.iter().enumerate() { 106 + let store_clone = Arc::clone(&store); 107 + let task_id_clone = task_id.clone(); 108 + let handle = tokio::spawn(async move { 109 + store_clone 110 + .claim_task(&task_id_clone, &format!("agent-{}", i)) 111 + .await 112 + }); 113 + handles.push(handle); 114 + } 115 + 116 + // Collect results 117 + let mut results = vec![]; 118 + for handle in handles { 119 + let result = handle.await??; 120 + results.push(result); 121 + } 122 + 123 + // All claims should succeed since each is for a different task 124 + let success_count = results.iter().filter(|&&r| r).count(); 125 + assert_eq!( 126 + success_count, 10, 127 + "Expected all 10 claims to succeed, got {}", 128 + success_count 129 + ); 130 + 131 + // Verify each task is claimed by its corresponding agent 132 + for (i, task_id) in task_ids.iter().enumerate() { 133 + let task = store.get_node(task_id).await?; 134 + assert!(task.is_some(), "Task {} should exist", task_id); 135 + 136 + let task = task.unwrap(); 137 + assert_eq!( 138 + task.status, 139 + NodeStatus::Claimed, 140 + "Task {} should be Claimed", 141 + task_id 142 + ); 143 + 144 + let expected_agent = format!("agent-{}", i); 145 + assert_eq!( 146 + task.assigned_to.as_ref(), 147 + Some(&expected_agent), 148 + "Task {} should be assigned to {}", 149 + task_id, 150 + expected_agent 151 + ); 152 + } 153 + 154 + Ok(()) 155 + } 156 + 157 + #[tokio::test] 158 + async fn test_claim_non_ready_task_fails() -> Result<()> { 159 + let (_db, store) = setup_test_env_concurrent().await?; 160 + 161 + // Create a goal and a task that is NOT in Ready status 162 + let goal = create_test_goal("goal-3", "proj-1", "Test Goal 3"); 163 + let task = create_test_task("task-3", "proj-1", "Non-Ready Task", NodeStatus::Pending); 164 + 165 + store.create_node(&goal).await?; 166 + store.create_node(&task).await?; 167 + 168 + // Try to claim the task - should fail 169 + let result = store.claim_task("task-3", "agent-1").await?; 170 + assert!(!result, "Should not be able to claim a Pending task"); 171 + 172 + // Verify the task status is unchanged 173 + let task = store.get_node("task-3").await?; 174 + assert!(task.is_some(), "Task should still exist"); 175 + 176 + let task = task.unwrap(); 177 + assert_eq!( 178 + task.status, 179 + NodeStatus::Pending, 180 + "Task status should still be Pending" 181 + ); 182 + assert!(task.assigned_to.is_none(), "Task should not be assigned"); 183 + 184 + Ok(()) 185 + } 186 + 187 + #[tokio::test] 188 + async fn test_concurrent_claim_race_condition() -> Result<()> { 189 + let (_db, store) = setup_test_env_concurrent().await?; 190 + 191 + // Create a goal and one Ready task 192 + let goal = create_test_goal("goal-4", "proj-1", "Test Goal 4"); 193 + let task = create_test_task("task-4", "proj-1", "Race Task", NodeStatus::Ready); 194 + 195 + store.create_node(&goal).await?; 196 + store.create_node(&task).await?; 197 + 198 + // Spawn many more concurrent claim attempts to stress the atomicity 199 + let num_concurrent = 50; 200 + let mut handles = vec![]; 201 + 202 + for i in 0..num_concurrent { 203 + let store_clone = Arc::clone(&store); 204 + let handle = tokio::spawn(async move { 205 + store_clone 206 + .claim_task("task-4", &format!("agent-{}", i)) 207 + .await 208 + }); 209 + handles.push(handle); 210 + } 211 + 212 + // Collect results 213 + let mut results = vec![]; 214 + for handle in handles { 215 + let result = handle.await??; 216 + results.push(result); 217 + } 218 + 219 + // Verify exactly one claim succeeded 220 + let success_count = results.iter().filter(|&&r| r).count(); 221 + assert_eq!( 222 + success_count, 1, 223 + "Expected exactly 1 successful claim out of {}, got {}", 224 + num_concurrent, success_count 225 + ); 226 + 227 + let failure_count = results.iter().filter(|&&r| !r).count(); 228 + assert_eq!( 229 + failure_count, 230 + num_concurrent - 1, 231 + "Expected {} failed claims, got {}", 232 + num_concurrent - 1, 233 + failure_count 234 + ); 235 + 236 + // Verify the task has exactly one assigned_to 237 + let updated_task = store.get_node("task-4").await?; 238 + assert!(updated_task.is_some(), "Task should exist"); 239 + 240 + let updated_task = updated_task.unwrap(); 241 + assert_eq!(updated_task.status, NodeStatus::Claimed); 242 + assert!(updated_task.assigned_to.is_some()); 243 + 244 + // Verify started_at is set (claim_task sets it) 245 + assert!( 246 + updated_task.started_at.is_some(), 247 + "started_at should be set" 248 + ); 249 + 250 + Ok(()) 251 + }
+293
tests/graph_dependency_test.rs
··· 1 + mod common; 2 + 3 + use anyhow::Result; 4 + use chrono::Utc; 5 + use common::{create_test_goal, create_test_task_with_priority, setup_test_env}; 6 + use rustagent::graph::store::GraphStore; 7 + use rustagent::graph::*; 8 + use std::time::Duration; 9 + use tokio::time::sleep; 10 + 11 + /// P1b.AC4.1: Task moves from Pending to Ready when all DependsOn targets are Completed 12 + #[tokio::test] 13 + async fn test_task_pending_to_ready_when_deps_complete() -> Result<()> { 14 + let (_db, store) = setup_test_env().await?; 15 + 16 + // Create a goal and two tasks 17 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 18 + let task_a = create_test_task_with_priority( 19 + "ra-a1b2.1", 20 + "proj-1", 21 + "Task A", 22 + NodeStatus::Pending, 23 + Some(Priority::Medium), 24 + ); 25 + let task_b = create_test_task_with_priority( 26 + "ra-a1b2.2", 27 + "proj-1", 28 + "Task B", 29 + NodeStatus::Pending, 30 + Some(Priority::Medium), 31 + ); 32 + 33 + store.create_node(&goal).await?; 34 + store.create_node(&task_a).await?; 35 + store.create_node(&task_b).await?; 36 + 37 + // Create a DependsOn edge: Task B depends on Task A 38 + let edge = GraphEdge { 39 + id: generate_edge_id(), 40 + edge_type: EdgeType::DependsOn, 41 + from_node: "ra-a1b2.2".to_string(), 42 + to_node: "ra-a1b2.1".to_string(), 43 + label: None, 44 + created_at: Utc::now(), 45 + }; 46 + store.add_edge(&edge).await?; 47 + 48 + // Initially, Task B should still be Pending 49 + let task_b_before = store.get_node("ra-a1b2.2").await?; 50 + assert!(task_b_before.is_some()); 51 + assert_eq!(task_b_before.unwrap().status, NodeStatus::Pending); 52 + 53 + // Complete Task A 54 + store 55 + .update_node( 56 + "ra-a1b2.1", 57 + Some(NodeStatus::Completed), 58 + None, 59 + None, 60 + None, 61 + None, 62 + ) 63 + .await?; 64 + 65 + // Now Task B should be Ready (automatically promoted by the status transition hook) 66 + let task_b_after = store.get_node("ra-a1b2.2").await?; 67 + assert!(task_b_after.is_some()); 68 + let task_b_node = task_b_after.unwrap(); 69 + assert_eq!(task_b_node.status, NodeStatus::Ready); 70 + 71 + Ok(()) 72 + } 73 + 74 + /// P1b.AC4.2: get_ready_tasks returns only tasks in Ready status with all deps satisfied 75 + #[tokio::test] 76 + async fn test_get_ready_tasks_filters_correctly() -> Result<()> { 77 + let (_db, store) = setup_test_env().await?; 78 + 79 + // Create a goal 80 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 81 + store.create_node(&goal).await?; 82 + 83 + // Create three tasks: one Ready, one Pending (with unmet deps), one Completed 84 + let task_ready = create_test_task_with_priority( 85 + "ra-a1b2.1", 86 + "proj-1", 87 + "Task Ready", 88 + NodeStatus::Ready, 89 + Some(Priority::Medium), 90 + ); 91 + let task_pending = create_test_task_with_priority( 92 + "ra-a1b2.2", 93 + "proj-1", 94 + "Task Pending", 95 + NodeStatus::Pending, 96 + Some(Priority::Medium), 97 + ); 98 + let task_completed = create_test_task_with_priority( 99 + "ra-a1b2.3", 100 + "proj-1", 101 + "Task Completed", 102 + NodeStatus::Completed, 103 + Some(Priority::Medium), 104 + ); 105 + 106 + store.create_node(&task_ready).await?; 107 + store.create_node(&task_pending).await?; 108 + store.create_node(&task_completed).await?; 109 + 110 + // Create a DependsOn edge: Task Pending depends on Task Completed 111 + let edge = GraphEdge { 112 + id: generate_edge_id(), 113 + edge_type: EdgeType::DependsOn, 114 + from_node: "ra-a1b2.2".to_string(), 115 + to_node: "ra-a1b2.3".to_string(), 116 + label: None, 117 + created_at: Utc::now(), 118 + }; 119 + store.add_edge(&edge).await?; 120 + 121 + // get_ready_tasks should return only the one Ready task 122 + let ready_tasks = store.get_ready_tasks("ra-a1b2").await?; 123 + assert_eq!(ready_tasks.len(), 1); 124 + assert_eq!(ready_tasks[0].id, "ra-a1b2.1"); 125 + assert_eq!(ready_tasks[0].status, NodeStatus::Ready); 126 + 127 + Ok(()) 128 + } 129 + 130 + /// P1b.AC4.3: get_next_task returns highest-priority Ready task, breaking ties by downstream unblock count 131 + #[tokio::test] 132 + async fn test_get_next_task_priority_and_downstream() -> Result<()> { 133 + let (_db, store) = setup_test_env().await?; 134 + 135 + // Create a goal 136 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 137 + store.create_node(&goal).await?; 138 + 139 + // Create two ready tasks: one High priority (blocking 3 tasks), one Critical priority (blocking 0) 140 + let task_high_priority = create_test_task_with_priority( 141 + "ra-a1b2.1", 142 + "proj-1", 143 + "High Priority", 144 + NodeStatus::Ready, 145 + Some(Priority::High), 146 + ); 147 + let task_critical_priority = create_test_task_with_priority( 148 + "ra-a1b2.2", 149 + "proj-1", 150 + "Critical Priority", 151 + NodeStatus::Ready, 152 + Some(Priority::Critical), 153 + ); 154 + 155 + store.create_node(&task_high_priority).await?; 156 + store.create_node(&task_critical_priority).await?; 157 + 158 + // Create 3 more tasks that depend on the High priority task 159 + let dependent1 = create_test_task_with_priority( 160 + "ra-a1b2.3", 161 + "proj-1", 162 + "Dependent 1", 163 + NodeStatus::Pending, 164 + Some(Priority::Medium), 165 + ); 166 + let dependent2 = create_test_task_with_priority( 167 + "ra-a1b2.4", 168 + "proj-1", 169 + "Dependent 2", 170 + NodeStatus::Pending, 171 + Some(Priority::Medium), 172 + ); 173 + let dependent3 = create_test_task_with_priority( 174 + "ra-a1b2.5", 175 + "proj-1", 176 + "Dependent 3", 177 + NodeStatus::Pending, 178 + Some(Priority::Medium), 179 + ); 180 + 181 + store.create_node(&dependent1).await?; 182 + store.create_node(&dependent2).await?; 183 + store.create_node(&dependent3).await?; 184 + 185 + // Create DependsOn edges from the three dependents to the high priority task 186 + for i in 3..=5 { 187 + let edge = GraphEdge { 188 + id: generate_edge_id(), 189 + edge_type: EdgeType::DependsOn, 190 + from_node: format!("ra-a1b2.{}", i), 191 + to_node: "ra-a1b2.1".to_string(), 192 + label: None, 193 + created_at: Utc::now(), 194 + }; 195 + store.add_edge(&edge).await?; 196 + } 197 + 198 + // get_next_task should return the Critical priority task (priority wins over downstream count) 199 + let next_task = store.get_next_task("ra-a1b2").await?; 200 + assert!(next_task.is_some()); 201 + let task = next_task.unwrap(); 202 + assert_eq!(task.id, "ra-a1b2.2"); 203 + assert_eq!(task.priority, Some(Priority::Critical)); 204 + 205 + Ok(()) 206 + } 207 + 208 + /// P1b.AC4.3 variant: When priorities are equal, downstream count should be the tiebreaker 209 + /// This test creates tasks in reverse order (B before A) to ensure that created_at ordering 210 + /// would pick the wrong task without the downstream count logic. 211 + #[tokio::test] 212 + async fn test_get_next_task_tiebreak_by_downstream() -> Result<()> { 213 + let (_db, store) = setup_test_env().await?; 214 + 215 + // Create a goal 216 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 217 + store.create_node(&goal).await?; 218 + 219 + // Create Task B FIRST (so it has an earlier created_at) 220 + let task_b = create_test_task_with_priority( 221 + "ra-a1b2.2", 222 + "proj-1", 223 + "Task B", 224 + NodeStatus::Ready, 225 + Some(Priority::High), 226 + ); 227 + store.create_node(&task_b).await?; 228 + 229 + // Small delay to ensure Task A has a later created_at 230 + sleep(Duration::from_millis(10)).await; 231 + 232 + // Create Task A SECOND (so it has a later created_at) 233 + // Without downstream count logic, ordering by created_at would pick B 234 + let task_a = create_test_task_with_priority( 235 + "ra-a1b2.1", 236 + "proj-1", 237 + "Task A", 238 + NodeStatus::Ready, 239 + Some(Priority::High), 240 + ); 241 + store.create_node(&task_a).await?; 242 + 243 + // Create 3 tasks that depend on Task A (higher downstream count) 244 + let dep_a1 = create_test_task_with_priority( 245 + "ra-a1b2.3", 246 + "proj-1", 247 + "Dep A1", 248 + NodeStatus::Pending, 249 + Some(Priority::Medium), 250 + ); 251 + let dep_a2 = create_test_task_with_priority( 252 + "ra-a1b2.4", 253 + "proj-1", 254 + "Dep A2", 255 + NodeStatus::Pending, 256 + Some(Priority::Medium), 257 + ); 258 + let dep_a3 = create_test_task_with_priority( 259 + "ra-a1b2.5", 260 + "proj-1", 261 + "Dep A3", 262 + NodeStatus::Pending, 263 + Some(Priority::Medium), 264 + ); 265 + 266 + store.create_node(&dep_a1).await?; 267 + store.create_node(&dep_a2).await?; 268 + store.create_node(&dep_a3).await?; 269 + 270 + // Create edges: 3 tasks depend on A, 0 on B 271 + for i in 3..=5 { 272 + let edge = GraphEdge { 273 + id: generate_edge_id(), 274 + edge_type: EdgeType::DependsOn, 275 + from_node: format!("ra-a1b2.{}", i), 276 + to_node: "ra-a1b2.1".to_string(), 277 + label: None, 278 + created_at: Utc::now(), 279 + }; 280 + store.add_edge(&edge).await?; 281 + } 282 + 283 + // get_next_task should return Task A (higher downstream count), not Task B (earlier created_at) 284 + let next_task = store.get_next_task("ra-a1b2").await?; 285 + assert!(next_task.is_some()); 286 + let task = next_task.unwrap(); 287 + assert_eq!( 288 + task.id, "ra-a1b2.1", 289 + "Expected Task A with higher downstream count, not Task B with earlier created_at" 290 + ); 291 + 292 + Ok(()) 293 + }
+476
tests/graph_store_test.rs
··· 1 + mod common; 2 + 3 + use anyhow::Result; 4 + use chrono::Utc; 5 + use common::{create_test_goal, create_test_task, setup_test_env_with_project}; 6 + use rustagent::graph::store::GraphStore; 7 + use rustagent::graph::*; 8 + use std::collections::HashMap; 9 + 10 + #[tokio::test] 11 + async fn test_create_and_get_goal_node() -> Result<()> { 12 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 13 + 14 + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); 15 + store.create_node(&goal).await?; 16 + 17 + let retrieved = store.get_node("ra-a1b2").await?; 18 + assert!(retrieved.is_some()); 19 + 20 + let node = retrieved.unwrap(); 21 + assert_eq!(node.id, "ra-a1b2"); 22 + assert_eq!(node.title, "Test Goal"); 23 + assert_eq!(node.project_id, "proj-1"); 24 + 25 + Ok(()) 26 + } 27 + 28 + #[tokio::test] 29 + async fn test_get_nonexistent_node() -> Result<()> { 30 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 31 + 32 + let retrieved = store.get_node("nonexistent-id").await?; 33 + assert!(retrieved.is_none()); 34 + 35 + Ok(()) 36 + } 37 + 38 + #[tokio::test] 39 + async fn test_update_node_status() -> Result<()> { 40 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 41 + 42 + let goal = create_test_goal("ra-a1b2", "proj-1", "Goal"); 43 + store.create_node(&goal).await?; 44 + 45 + let task = create_test_task("ra-a1b2.1", "proj-1", "Test Task", NodeStatus::Pending); 46 + store.create_node(&task).await?; 47 + 48 + // Update status to InProgress (valid for Task) 49 + store 50 + .update_node( 51 + "ra-a1b2.1", 52 + Some(NodeStatus::InProgress), 53 + None, 54 + None, 55 + None, 56 + None, 57 + ) 58 + .await?; 59 + 60 + let updated = store.get_node("ra-a1b2.1").await?; 61 + assert!(updated.is_some()); 62 + assert_eq!(updated.unwrap().status, NodeStatus::InProgress); 63 + 64 + Ok(()) 65 + } 66 + 67 + #[tokio::test] 68 + async fn test_update_node_title() -> Result<()> { 69 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 70 + 71 + let goal = create_test_goal("ra-a1b2", "proj-1", "Goal"); 72 + store.create_node(&goal).await?; 73 + 74 + let task = create_test_task("ra-a1b2.1", "proj-1", "Original Title", NodeStatus::Pending); 75 + store.create_node(&task).await?; 76 + 77 + // Update title 78 + store 79 + .update_node("ra-a1b2.1", None, Some("New Title"), None, None, None) 80 + .await?; 81 + 82 + let updated = store.get_node("ra-a1b2.1").await?; 83 + assert!(updated.is_some()); 84 + assert_eq!(updated.unwrap().title, "New Title"); 85 + 86 + Ok(()) 87 + } 88 + 89 + #[tokio::test] 90 + async fn test_add_and_get_edge() -> Result<()> { 91 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 92 + 93 + let goal = create_test_goal("ra-a1b2", "proj-1", "Goal"); 94 + let task = create_test_task("ra-a1b2.1", "proj-1", "Task", NodeStatus::Pending); 95 + store.create_node(&goal).await?; 96 + store.create_node(&task).await?; 97 + 98 + // Add an edge (this should already be created via the parent relationship) 99 + let edge = GraphEdge { 100 + id: generate_edge_id(), 101 + edge_type: EdgeType::DependsOn, 102 + from_node: "ra-a1b2.1".to_string(), 103 + to_node: "ra-a1b2".to_string(), 104 + label: Some("depends".to_string()), 105 + created_at: Utc::now(), 106 + }; 107 + store.add_edge(&edge).await?; 108 + 109 + // Get edges 110 + let edges = store 111 + .get_edges("ra-a1b2", rustagent::graph::store::EdgeDirection::Incoming) 112 + .await?; 113 + assert!(!edges.is_empty()); 114 + assert_eq!(edges[0].0.edge_type, EdgeType::DependsOn); 115 + 116 + Ok(()) 117 + } 118 + 119 + #[tokio::test] 120 + async fn test_get_children() -> Result<()> { 121 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 122 + 123 + let goal = create_test_goal("ra-a1b2", "proj-1", "Goal"); 124 + store.create_node(&goal).await?; 125 + 126 + // Create children (via parent relationship in create_node) 127 + let task1 = create_test_task("ra-a1b2.1", "proj-1", "Task 1", NodeStatus::Pending); 128 + let task2 = create_test_task("ra-a1b2.2", "proj-1", "Task 2", NodeStatus::Pending); 129 + store.create_node(&task1).await?; 130 + store.create_node(&task2).await?; 131 + 132 + let children = store.get_children("ra-a1b2").await?; 133 + assert_eq!(children.len(), 2); 134 + assert_eq!(children[0].0.id, "ra-a1b2.1"); 135 + assert_eq!(children[1].0.id, "ra-a1b2.2"); 136 + 137 + Ok(()) 138 + } 139 + 140 + #[tokio::test] 141 + async fn test_get_subtree() -> Result<()> { 142 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 143 + 144 + let goal = create_test_goal("ra-a1b2", "proj-1", "Goal"); 145 + store.create_node(&goal).await?; 146 + 147 + let task = create_test_task("ra-a1b2.1", "proj-1", "Task", NodeStatus::Pending); 148 + store.create_node(&task).await?; 149 + 150 + // Create a subtask 151 + let subtask = GraphNode { 152 + id: "ra-a1b2.1.1".to_string(), 153 + project_id: "proj-1".to_string(), 154 + node_type: NodeType::Task, 155 + title: "Subtask".to_string(), 156 + description: "A subtask".to_string(), 157 + status: NodeStatus::Pending, 158 + priority: Some(Priority::Low), 159 + assigned_to: None, 160 + created_by: None, 161 + labels: vec![], 162 + created_at: Utc::now(), 163 + started_at: None, 164 + completed_at: None, 165 + blocked_reason: None, 166 + metadata: HashMap::new(), 167 + }; 168 + store.create_node(&subtask).await?; 169 + 170 + let subtree = store.get_subtree("ra-a1b2").await?; 171 + assert_eq!(subtree.len(), 3); // goal + task + subtask 172 + assert!(subtree.iter().any(|n| n.id == "ra-a1b2")); 173 + assert!(subtree.iter().any(|n| n.id == "ra-a1b2.1")); 174 + assert!(subtree.iter().any(|n| n.id == "ra-a1b2.1.1")); 175 + 176 + Ok(()) 177 + } 178 + 179 + #[tokio::test] 180 + async fn test_get_active_decisions() -> Result<()> { 181 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 182 + 183 + let active_decision = GraphNode { 184 + id: generate_goal_id(), 185 + project_id: "proj-1".to_string(), 186 + node_type: NodeType::Decision, 187 + title: "Active Decision".to_string(), 188 + description: "An active decision".to_string(), 189 + status: NodeStatus::Active, 190 + priority: None, 191 + assigned_to: None, 192 + created_by: None, 193 + labels: vec![], 194 + created_at: Utc::now(), 195 + started_at: None, 196 + completed_at: None, 197 + blocked_reason: None, 198 + metadata: HashMap::new(), 199 + }; 200 + 201 + let decided_decision = GraphNode { 202 + id: generate_goal_id(), 203 + project_id: "proj-1".to_string(), 204 + node_type: NodeType::Decision, 205 + title: "Decided Decision".to_string(), 206 + description: "A decided decision".to_string(), 207 + status: NodeStatus::Decided, 208 + priority: None, 209 + assigned_to: None, 210 + created_by: None, 211 + labels: vec![], 212 + created_at: Utc::now(), 213 + started_at: None, 214 + completed_at: None, 215 + blocked_reason: None, 216 + metadata: HashMap::new(), 217 + }; 218 + 219 + let superseded_decision = GraphNode { 220 + id: generate_goal_id(), 221 + project_id: "proj-1".to_string(), 222 + node_type: NodeType::Decision, 223 + title: "Superseded Decision".to_string(), 224 + description: "A superseded decision".to_string(), 225 + status: NodeStatus::Superseded, 226 + priority: None, 227 + assigned_to: None, 228 + created_by: None, 229 + labels: vec![], 230 + created_at: Utc::now(), 231 + started_at: None, 232 + completed_at: None, 233 + blocked_reason: None, 234 + metadata: HashMap::new(), 235 + }; 236 + 237 + store.create_node(&active_decision).await?; 238 + store.create_node(&decided_decision).await?; 239 + store.create_node(&superseded_decision).await?; 240 + 241 + let active = store.get_active_decisions("proj-1").await?; 242 + assert_eq!(active.len(), 2); // Active + Decided, but not Superseded 243 + assert!(active.iter().any(|n| n.status == NodeStatus::Active)); 244 + assert!(active.iter().any(|n| n.status == NodeStatus::Decided)); 245 + assert!(!active.iter().any(|n| n.status == NodeStatus::Superseded)); 246 + 247 + Ok(()) 248 + } 249 + 250 + #[tokio::test] 251 + async fn test_get_full_graph() -> Result<()> { 252 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 253 + 254 + let goal = create_test_goal("ra-goal1", "proj-1", "Goal"); 255 + store.create_node(&goal).await?; 256 + 257 + let task1 = create_test_task("ra-goal1.1", "proj-1", "Task 1", NodeStatus::Pending); 258 + let task2 = create_test_task("ra-goal1.2", "proj-1", "Task 2", NodeStatus::Pending); 259 + store.create_node(&task1).await?; 260 + store.create_node(&task2).await?; 261 + 262 + // Add an edge between tasks 263 + let edge = GraphEdge { 264 + id: generate_edge_id(), 265 + edge_type: EdgeType::DependsOn, 266 + from_node: "ra-goal1.2".to_string(), 267 + to_node: "ra-goal1.1".to_string(), 268 + label: None, 269 + created_at: Utc::now(), 270 + }; 271 + store.add_edge(&edge).await?; 272 + 273 + let graph = store.get_full_graph("ra-goal1").await?; 274 + assert_eq!(graph.nodes.len(), 3); // goal + 2 tasks 275 + assert!(!graph.edges.is_empty()); // Contains edges + DependsOn edge 276 + 277 + Ok(()) 278 + } 279 + 280 + #[tokio::test] 281 + async fn test_search_nodes_by_title() -> Result<()> { 282 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 283 + 284 + let goal = GraphNode { 285 + id: generate_goal_id(), 286 + project_id: "proj-1".to_string(), 287 + node_type: NodeType::Goal, 288 + title: "Authentication System".to_string(), 289 + description: "Build a secure auth system".to_string(), 290 + status: NodeStatus::Active, 291 + priority: Some(Priority::Critical), 292 + assigned_to: None, 293 + created_by: None, 294 + labels: vec![], 295 + created_at: Utc::now(), 296 + started_at: None, 297 + completed_at: None, 298 + blocked_reason: None, 299 + metadata: HashMap::new(), 300 + }; 301 + store.create_node(&goal).await?; 302 + 303 + // FTS5 might need a moment to sync, but our test should work 304 + let results = store 305 + .search_nodes("authentication", Some("proj-1"), None, 10) 306 + .await?; 307 + assert!(!results.is_empty()); 308 + assert!(results.iter().any(|n| n.title.contains("Authentication"))); 309 + 310 + Ok(()) 311 + } 312 + 313 + #[tokio::test] 314 + async fn test_search_nodes_with_type_filter() -> Result<()> { 315 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 316 + 317 + let goal = GraphNode { 318 + id: generate_goal_id(), 319 + project_id: "proj-1".to_string(), 320 + node_type: NodeType::Goal, 321 + title: "Test Goal".to_string(), 322 + description: "Test description".to_string(), 323 + status: NodeStatus::Active, 324 + priority: Some(Priority::Medium), 325 + assigned_to: None, 326 + created_by: None, 327 + labels: vec![], 328 + created_at: Utc::now(), 329 + started_at: None, 330 + completed_at: None, 331 + blocked_reason: None, 332 + metadata: HashMap::new(), 333 + }; 334 + 335 + let task = GraphNode { 336 + id: generate_goal_id(), 337 + project_id: "proj-1".to_string(), 338 + node_type: NodeType::Task, 339 + title: "Test Task".to_string(), 340 + description: "Test description".to_string(), 341 + status: NodeStatus::Pending, 342 + priority: Some(Priority::Low), 343 + assigned_to: None, 344 + created_by: None, 345 + labels: vec![], 346 + created_at: Utc::now(), 347 + started_at: None, 348 + completed_at: None, 349 + blocked_reason: None, 350 + metadata: HashMap::new(), 351 + }; 352 + 353 + store.create_node(&goal).await?; 354 + store.create_node(&task).await?; 355 + 356 + let goal_results = store 357 + .search_nodes("test", Some("proj-1"), Some(NodeType::Goal), 10) 358 + .await?; 359 + assert!(!goal_results.is_empty()); 360 + assert!(goal_results.iter().all(|n| n.node_type == NodeType::Goal)); 361 + 362 + let task_results = store 363 + .search_nodes("test", Some("proj-1"), Some(NodeType::Task), 10) 364 + .await?; 365 + assert!(!task_results.is_empty()); 366 + assert!(task_results.iter().all(|n| n.node_type == NodeType::Task)); 367 + 368 + Ok(()) 369 + } 370 + 371 + #[tokio::test] 372 + async fn test_claim_task() -> Result<()> { 373 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 374 + 375 + let task = create_test_task("ra-task1", "proj-1", "Test Task", NodeStatus::Ready); 376 + store.create_node(&task).await?; 377 + 378 + // Claim the task 379 + let claimed = store.claim_task("ra-task1", "agent-1").await?; 380 + assert!(claimed); 381 + 382 + // Verify status changed 383 + let updated = store.get_node("ra-task1").await?.unwrap(); 384 + assert_eq!(updated.status, NodeStatus::Claimed); 385 + assert_eq!(updated.assigned_to, Some("agent-1".to_string())); 386 + 387 + Ok(()) 388 + } 389 + 390 + #[tokio::test] 391 + async fn test_claim_task_already_claimed() -> Result<()> { 392 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 393 + 394 + let task = create_test_task("ra-task1", "proj-1", "Test Task", NodeStatus::Ready); 395 + store.create_node(&task).await?; 396 + 397 + // Claim it once 398 + let first = store.claim_task("ra-task1", "agent-1").await?; 399 + assert!(first); 400 + 401 + // Try to claim it again 402 + let second = store.claim_task("ra-task1", "agent-2").await?; 403 + assert!(!second); // Should fail 404 + 405 + Ok(()) 406 + } 407 + 408 + #[tokio::test] 409 + async fn test_next_child_seq() -> Result<()> { 410 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 411 + 412 + let parent = create_test_goal("ra-parent", "proj-1", "Parent"); 413 + store.create_node(&parent).await?; 414 + 415 + // Get next sequence 416 + let seq1 = store.next_child_seq("ra-parent").await?; 417 + assert_eq!(seq1, 1); 418 + 419 + // Get next sequence again 420 + let seq2 = store.next_child_seq("ra-parent").await?; 421 + assert_eq!(seq2, 2); 422 + 423 + // Get next sequence again 424 + let seq3 = store.next_child_seq("ra-parent").await?; 425 + assert_eq!(seq3, 3); 426 + 427 + Ok(()) 428 + } 429 + 430 + #[tokio::test] 431 + async fn test_query_nodes_by_type() -> Result<()> { 432 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 433 + 434 + let goal = create_test_goal("ra-g1", "proj-1", "Goal"); 435 + let task = create_test_task("ra-t1", "proj-1", "Task", NodeStatus::Pending); 436 + store.create_node(&goal).await?; 437 + store.create_node(&task).await?; 438 + 439 + let query = rustagent::graph::store::NodeQuery { 440 + node_type: Some(NodeType::Goal), 441 + status: None, 442 + project_id: Some("proj-1".to_string()), 443 + parent_id: None, 444 + query: None, 445 + }; 446 + 447 + let results = store.query_nodes(&query).await?; 448 + assert_eq!(results.len(), 1); 449 + assert_eq!(results[0].node_type, NodeType::Goal); 450 + 451 + Ok(()) 452 + } 453 + 454 + #[tokio::test] 455 + async fn test_query_nodes_by_status() -> Result<()> { 456 + let (_db, store, _proj_store) = setup_test_env_with_project().await?; 457 + 458 + let pending_task = create_test_task("ra-t1", "proj-1", "Pending Task", NodeStatus::Pending); 459 + let active_task = create_test_task("ra-t2", "proj-1", "Active Task", NodeStatus::Active); 460 + store.create_node(&pending_task).await?; 461 + store.create_node(&active_task).await?; 462 + 463 + let query = rustagent::graph::store::NodeQuery { 464 + node_type: Some(NodeType::Task), 465 + status: Some(NodeStatus::Pending), 466 + project_id: Some("proj-1".to_string()), 467 + parent_id: None, 468 + query: None, 469 + }; 470 + 471 + let results = store.query_nodes(&query).await?; 472 + assert_eq!(results.len(), 1); 473 + assert_eq!(results[0].id, "ra-t1"); 474 + 475 + Ok(()) 476 + }
+703
tests/graph_tools_test.rs
··· 1 + use rustagent::db::Database; 2 + use rustagent::graph::store::{GraphStore, SqliteGraphStore}; 3 + use rustagent::graph::{EdgeType, NodeStatus, NodeType}; 4 + use rustagent::tools::Tool; 5 + use rustagent::tools::graph_tools::*; 6 + use rustagent::tools::factory::create_v2_registry; 7 + use rustagent::security::SecurityValidator; 8 + use rustagent::security::permission::AutoApproveHandler; 9 + use rustagent::config::SecurityConfig; 10 + use serde_json::{Value, json}; 11 + use std::sync::Arc; 12 + 13 + mod common; 14 + use common::MockGraphStore; 15 + 16 + /// Create a test database in memory with a test project 17 + async fn setup_test_db() -> anyhow::Result<(Database, Arc<SqliteGraphStore>)> { 18 + let db = Database::open_in_memory().await?; 19 + 20 + // Insert a test project 21 + db.connection() 22 + .call(|conn| { 23 + let now = chrono::Utc::now().to_rfc3339(); 24 + conn.execute( 25 + "INSERT INTO projects (id, name, path, registered_at, config_overrides, metadata) 26 + VALUES (?, ?, ?, ?, ?, ?)", 27 + rusqlite::params![ 28 + "proj-1", 29 + "proj-1", 30 + "/tmp/proj-1", 31 + &now, 32 + None::<String>, 33 + "{}" 34 + ], 35 + )?; 36 + Ok(()) 37 + }) 38 + .await?; 39 + 40 + let store = Arc::new(SqliteGraphStore::new(db.clone())); 41 + Ok((db, store)) 42 + } 43 + 44 + #[tokio::test] 45 + async fn test_create_node_tool() -> anyhow::Result<()> { 46 + let (_db, store) = setup_test_db().await?; 47 + let tool = CreateNodeTool::new(store.clone()); 48 + 49 + let params = json!({ 50 + "node_type": "task", 51 + "title": "Test Task", 52 + "description": "A test task", 53 + "project_id": "proj-1" 54 + }); 55 + 56 + let result = tool.execute(params).await?; 57 + let parsed: Value = serde_json::from_str(&result)?; 58 + 59 + assert!(parsed["id"].as_str().is_some()); 60 + assert!(parsed["id"].as_str().unwrap().starts_with("ra-")); 61 + assert_eq!(parsed["message"], "Node created successfully"); 62 + 63 + // Verify node was created 64 + let node_id = parsed["id"].as_str().unwrap(); 65 + let node = store.get_node(node_id).await?.expect("Node not found"); 66 + 67 + assert_eq!(node.title, "Test Task"); 68 + assert_eq!(node.node_type, NodeType::Task); 69 + assert_eq!(node.status, NodeStatus::Pending); 70 + 71 + Ok(()) 72 + } 73 + 74 + #[tokio::test] 75 + async fn test_create_child_node_tool() -> anyhow::Result<()> { 76 + let (_db, store) = setup_test_db().await?; 77 + let tool = CreateNodeTool::new(store.clone()); 78 + 79 + // Create parent 80 + let parent_params = json!({ 81 + "node_type": "goal", 82 + "title": "Parent Goal", 83 + "description": "A parent goal", 84 + "project_id": "proj-1" 85 + }); 86 + 87 + let parent_result = tool.execute(parent_params).await?; 88 + let parent_parsed: Value = serde_json::from_str(&parent_result)?; 89 + let parent_id = parent_parsed["id"].as_str().unwrap(); 90 + 91 + // Create child with parent_id 92 + let child_params = json!({ 93 + "node_type": "task", 94 + "title": "Child Task", 95 + "description": "A child task", 96 + "project_id": "proj-1", 97 + "parent_id": parent_id 98 + }); 99 + 100 + let child_result = tool.execute(child_params).await?; 101 + let child_parsed: Value = serde_json::from_str(&child_result)?; 102 + let child_id = child_parsed["id"].as_str().unwrap(); 103 + 104 + // Verify child ID has parent prefix 105 + assert!(child_id.starts_with(parent_id)); 106 + assert!(child_id.contains(".")); 107 + 108 + // Verify Contains edge was created 109 + let edges = store 110 + .get_edges(parent_id, rustagent::graph::store::EdgeDirection::Outgoing) 111 + .await?; 112 + 113 + assert!(!edges.is_empty()); 114 + assert_eq!(edges[0].0.edge_type, EdgeType::Contains); 115 + 116 + Ok(()) 117 + } 118 + 119 + #[tokio::test] 120 + async fn test_update_node_tool() -> anyhow::Result<()> { 121 + let (_db, store) = setup_test_db().await?; 122 + let create_tool = CreateNodeTool::new(store.clone()); 123 + let update_tool = UpdateNodeTool::new(store.clone()); 124 + 125 + // Create a node 126 + let create_params = json!({ 127 + "node_type": "task", 128 + "title": "Original Title", 129 + "description": "Original description", 130 + "project_id": "proj-1" 131 + }); 132 + 133 + let create_result = create_tool.execute(create_params).await?; 134 + let parsed: Value = serde_json::from_str(&create_result)?; 135 + let node_id = parsed["id"].as_str().unwrap(); 136 + 137 + // Update the node 138 + let update_params = json!({ 139 + "node_id": node_id, 140 + "title": "Updated Title", 141 + "description": "Updated description", 142 + "status": "ready" 143 + }); 144 + 145 + update_tool.execute(update_params).await?; 146 + 147 + // Verify update 148 + let node = store.get_node(node_id).await?.expect("Node not found"); 149 + 150 + assert_eq!(node.title, "Updated Title"); 151 + assert_eq!(node.description, "Updated description"); 152 + assert_eq!(node.status, NodeStatus::Ready); 153 + 154 + Ok(()) 155 + } 156 + 157 + #[tokio::test] 158 + async fn test_add_edge_tool() -> anyhow::Result<()> { 159 + let (_db, store) = setup_test_db().await?; 160 + let create_tool = CreateNodeTool::new(store.clone()); 161 + let edge_tool = AddEdgeTool::new(store.clone()); 162 + 163 + // Create two nodes 164 + let params1 = json!({ 165 + "node_type": "task", 166 + "title": "Task 1", 167 + "description": "First task", 168 + "project_id": "proj-1" 169 + }); 170 + 171 + let result1 = create_tool.execute(params1).await?; 172 + let parsed1: Value = serde_json::from_str(&result1)?; 173 + let node1_id = parsed1["id"].as_str().unwrap(); 174 + 175 + let params2 = json!({ 176 + "node_type": "task", 177 + "title": "Task 2", 178 + "description": "Second task", 179 + "project_id": "proj-1" 180 + }); 181 + 182 + let result2 = create_tool.execute(params2).await?; 183 + let parsed2: Value = serde_json::from_str(&result2)?; 184 + let node2_id = parsed2["id"].as_str().unwrap(); 185 + 186 + // Add DependsOn edge 187 + let edge_params = json!({ 188 + "edge_type": "depends_on", 189 + "from_node": node2_id, 190 + "to_node": node1_id, 191 + "label": "blocks" 192 + }); 193 + 194 + let edge_result = edge_tool.execute(edge_params).await?; 195 + let edge_parsed: Value = serde_json::from_str(&edge_result)?; 196 + 197 + assert!(edge_parsed["id"].as_str().is_some()); 198 + assert_eq!(edge_parsed["message"], "Edge created successfully"); 199 + 200 + // Verify edge 201 + let edges = store 202 + .get_edges(node2_id, rustagent::graph::store::EdgeDirection::Outgoing) 203 + .await?; 204 + 205 + assert_eq!(edges.len(), 1); 206 + assert_eq!(edges[0].0.edge_type, EdgeType::DependsOn); 207 + 208 + Ok(()) 209 + } 210 + 211 + #[tokio::test] 212 + async fn test_query_nodes_tool() -> anyhow::Result<()> { 213 + let (_db, store) = setup_test_db().await?; 214 + let create_tool = CreateNodeTool::new(store.clone()); 215 + let query_tool = QueryNodesTool::new(store.clone()); 216 + 217 + // Create a few nodes 218 + for i in 1..=3 { 219 + let params = json!({ 220 + "node_type": "task", 221 + "title": format!("Task {}", i), 222 + "description": "Test task", 223 + "project_id": "proj-1" 224 + }); 225 + create_tool.execute(params).await?; 226 + } 227 + 228 + // Query all tasks 229 + let query_params = json!({ 230 + "node_type": "task", 231 + "project_id": "proj-1" 232 + }); 233 + 234 + let result = query_tool.execute(query_params).await?; 235 + let parsed: Vec<Value> = serde_json::from_str(&result)?; 236 + 237 + assert_eq!(parsed.len(), 3); 238 + assert_eq!(parsed[0]["node_type"], "task"); 239 + 240 + Ok(()) 241 + } 242 + 243 + #[tokio::test] 244 + async fn test_search_nodes_tool() -> anyhow::Result<()> { 245 + let (_db, store) = setup_test_db().await?; 246 + let create_tool = CreateNodeTool::new(store.clone()); 247 + let search_tool = SearchNodesTool::new(store.clone()); 248 + 249 + // Create nodes with specific titles 250 + let params = json!({ 251 + "node_type": "task", 252 + "title": "Authentication Task", 253 + "description": "Handle user authentication", 254 + "project_id": "proj-1" 255 + }); 256 + create_tool.execute(params).await?; 257 + 258 + // Search for "authentication" 259 + let search_params = json!({ 260 + "query": "authentication" 261 + }); 262 + 263 + let result = search_tool.execute(search_params).await?; 264 + let parsed: Vec<Value> = serde_json::from_str(&result)?; 265 + 266 + assert!(!parsed.is_empty()); 267 + assert_eq!(parsed[0]["title"], "Authentication Task"); 268 + 269 + Ok(()) 270 + } 271 + 272 + #[tokio::test] 273 + async fn test_claim_task_tool() -> anyhow::Result<()> { 274 + let (_db, store) = setup_test_db().await?; 275 + let create_tool = CreateNodeTool::new(store.clone()); 276 + let update_tool = UpdateNodeTool::new(store.clone()); 277 + let claim_tool = ClaimTaskTool::new(store.clone()); 278 + 279 + // Create a task 280 + let create_params = json!({ 281 + "node_type": "task", 282 + "title": "Test Task", 283 + "description": "To be claimed", 284 + "project_id": "proj-1" 285 + }); 286 + 287 + let create_result = create_tool.execute(create_params).await?; 288 + let parsed: Value = serde_json::from_str(&create_result)?; 289 + let task_id = parsed["id"].as_str().unwrap(); 290 + 291 + // Update status to Ready 292 + let update_params = json!({ 293 + "node_id": task_id, 294 + "status": "ready" 295 + }); 296 + update_tool.execute(update_params).await?; 297 + 298 + // Claim the task 299 + let claim_params = json!({ 300 + "node_id": task_id, 301 + "agent_id": "agent-1" 302 + }); 303 + 304 + let claim_result = claim_tool.execute(claim_params).await?; 305 + let claim_parsed: Value = serde_json::from_str(&claim_result)?; 306 + 307 + assert_eq!(claim_parsed["claimed"], true); 308 + 309 + // Verify node status 310 + let node = store.get_node(task_id).await?.expect("Node not found"); 311 + 312 + assert_eq!(node.status, NodeStatus::Claimed); 313 + assert_eq!(node.assigned_to, Some("agent-1".to_string())); 314 + 315 + Ok(()) 316 + } 317 + 318 + #[tokio::test] 319 + async fn test_log_decision_tool() -> anyhow::Result<()> { 320 + let (_db, store) = setup_test_db().await?; 321 + let tool = LogDecisionTool::new(store.clone()); 322 + 323 + let params = json!({ 324 + "title": "Architecture Decision", 325 + "description": "Choose between microservices or monolith", 326 + "project_id": "proj-1", 327 + "options": [ 328 + { 329 + "title": "Microservices", 330 + "description": "Multiple independent services", 331 + "pros": "Scalability, independence", 332 + "cons": "Complexity, latency" 333 + }, 334 + { 335 + "title": "Monolith", 336 + "description": "Single unified application", 337 + "pros": "Simplicity, performance", 338 + "cons": "Scalability limitations" 339 + } 340 + ] 341 + }); 342 + 343 + let result = tool.execute(params).await?; 344 + let parsed: Value = serde_json::from_str(&result)?; 345 + 346 + assert!(parsed["decision_id"].as_str().is_some()); 347 + assert!(parsed["option_ids"].is_array()); 348 + assert_eq!(parsed["option_ids"].as_array().unwrap().len(), 2); 349 + 350 + // Verify decision node was created 351 + let decision_id = parsed["decision_id"].as_str().unwrap(); 352 + let decision = store 353 + .get_node(decision_id) 354 + .await? 355 + .expect("Decision not found"); 356 + 357 + assert_eq!(decision.node_type, NodeType::Decision); 358 + assert_eq!(decision.status, NodeStatus::Active); 359 + 360 + // Verify option nodes were created 361 + let option_ids = parsed["option_ids"].as_array().unwrap(); 362 + for option_id_val in option_ids { 363 + let option_id = option_id_val.as_str().unwrap(); 364 + let option = store.get_node(option_id).await?.expect("Option not found"); 365 + 366 + assert_eq!(option.node_type, NodeType::Option); 367 + assert_eq!(option.status, NodeStatus::Active); 368 + } 369 + 370 + Ok(()) 371 + } 372 + 373 + #[tokio::test] 374 + async fn test_choose_option_tool() -> anyhow::Result<()> { 375 + let (_db, store) = setup_test_db().await?; 376 + let decision_tool = LogDecisionTool::new(store.clone()); 377 + let choose_tool = ChooseOptionTool::new(store.clone()); 378 + 379 + // Create a decision with options 380 + let decision_params = json!({ 381 + "title": "Test Decision", 382 + "description": "Test", 383 + "project_id": "proj-1", 384 + "options": [ 385 + { 386 + "title": "Option A", 387 + "description": "First option" 388 + }, 389 + { 390 + "title": "Option B", 391 + "description": "Second option" 392 + } 393 + ] 394 + }); 395 + 396 + let decision_result = decision_tool.execute(decision_params).await?; 397 + let decision_parsed: Value = serde_json::from_str(&decision_result)?; 398 + 399 + let decision_id = decision_parsed["decision_id"].as_str().unwrap(); 400 + let option_ids = decision_parsed["option_ids"].as_array().unwrap(); 401 + let chosen_option_id = option_ids[0].as_str().unwrap(); 402 + 403 + // Choose an option 404 + let choose_params = json!({ 405 + "decision_id": decision_id, 406 + "option_id": chosen_option_id, 407 + "rationale": "Best fit for our needs" 408 + }); 409 + 410 + choose_tool.execute(choose_params).await?; 411 + 412 + // Verify decision status changed to Decided 413 + let decision = store 414 + .get_node(decision_id) 415 + .await? 416 + .expect("Decision not found"); 417 + 418 + assert_eq!(decision.status, NodeStatus::Decided); 419 + 420 + // Verify chosen option has Chosen status 421 + let chosen_option = store 422 + .get_node(chosen_option_id) 423 + .await? 424 + .expect("Option not found"); 425 + 426 + assert_eq!(chosen_option.status, NodeStatus::Chosen); 427 + 428 + // Verify other options are Rejected 429 + let other_option_id = option_ids[1].as_str().unwrap(); 430 + let other_option = store 431 + .get_node(other_option_id) 432 + .await? 433 + .expect("Option not found"); 434 + 435 + assert_eq!(other_option.status, NodeStatus::Rejected); 436 + 437 + Ok(()) 438 + } 439 + 440 + #[tokio::test] 441 + async fn test_record_outcome_tool() -> anyhow::Result<()> { 442 + let (_db, store) = setup_test_db().await?; 443 + let create_tool = CreateNodeTool::new(store.clone()); 444 + let outcome_tool = RecordOutcomeTool::new(store.clone()); 445 + 446 + // Create a task 447 + let task_params = json!({ 448 + "node_type": "task", 449 + "title": "Test Task", 450 + "description": "Task to record outcome for", 451 + "project_id": "proj-1" 452 + }); 453 + 454 + let task_result = create_tool.execute(task_params).await?; 455 + let task_parsed: Value = serde_json::from_str(&task_result)?; 456 + let task_id = task_parsed["id"].as_str().unwrap(); 457 + 458 + // Record outcome 459 + let outcome_params = json!({ 460 + "parent_id": task_id, 461 + "title": "Task Completed", 462 + "description": "Successfully completed the task", 463 + "project_id": "proj-1", 464 + "success": true 465 + }); 466 + 467 + let outcome_result = outcome_tool.execute(outcome_params).await?; 468 + let outcome_parsed: Value = serde_json::from_str(&outcome_result)?; 469 + 470 + assert!(outcome_parsed["outcome_id"].as_str().is_some()); 471 + 472 + // Verify outcome node 473 + let outcome_id = outcome_parsed["outcome_id"].as_str().unwrap(); 474 + let outcome = store 475 + .get_node(outcome_id) 476 + .await? 477 + .expect("Outcome not found"); 478 + 479 + assert_eq!(outcome.node_type, NodeType::Outcome); 480 + assert_eq!(outcome.status, NodeStatus::Completed); 481 + assert_eq!(outcome.metadata.get("success"), Some(&"true".to_string())); 482 + 483 + Ok(()) 484 + } 485 + 486 + #[tokio::test] 487 + async fn test_record_observation_tool() -> anyhow::Result<()> { 488 + let (_db, store) = setup_test_db().await?; 489 + let create_tool = CreateNodeTool::new(store.clone()); 490 + let obs_tool = RecordObservationTool::new(store.clone()); 491 + 492 + // Create a task to observe 493 + let task_params = json!({ 494 + "node_type": "task", 495 + "title": "Test Task", 496 + "description": "Task to observe", 497 + "project_id": "proj-1" 498 + }); 499 + 500 + let task_result = create_tool.execute(task_params).await?; 501 + let task_parsed: Value = serde_json::from_str(&task_result)?; 502 + let task_id = task_parsed["id"].as_str().unwrap(); 503 + 504 + // Record observation related to task 505 + let obs_params = json!({ 506 + "title": "Performance Issue Observed", 507 + "description": "Task took longer than expected", 508 + "project_id": "proj-1", 509 + "related_node_id": task_id 510 + }); 511 + 512 + let obs_result = obs_tool.execute(obs_params).await?; 513 + let obs_parsed: Value = serde_json::from_str(&obs_result)?; 514 + 515 + assert!(obs_parsed["observation_id"].as_str().is_some()); 516 + 517 + // Verify observation node 518 + let obs_id = obs_parsed["observation_id"].as_str().unwrap(); 519 + let obs = store 520 + .get_node(obs_id) 521 + .await? 522 + .expect("Observation not found"); 523 + 524 + assert_eq!(obs.node_type, NodeType::Observation); 525 + assert_eq!(obs.status, NodeStatus::Active); 526 + 527 + // Verify Informs edge 528 + let edges = store 529 + .get_edges(obs_id, rustagent::graph::store::EdgeDirection::Outgoing) 530 + .await?; 531 + 532 + assert_eq!(edges.len(), 1); 533 + assert_eq!(edges[0].0.edge_type, EdgeType::Informs); 534 + 535 + Ok(()) 536 + } 537 + 538 + #[tokio::test] 539 + async fn test_revisit_tool() -> anyhow::Result<()> { 540 + let (_db, store) = setup_test_db().await?; 541 + let create_tool = CreateNodeTool::new(store.clone()); 542 + let outcome_tool = RecordOutcomeTool::new(store.clone()); 543 + let revisit_tool = RevisitTool::new(store.clone()); 544 + 545 + // Create a task and outcome 546 + let task_params = json!({ 547 + "node_type": "task", 548 + "title": "Test Task", 549 + "description": "Task", 550 + "project_id": "proj-1" 551 + }); 552 + 553 + let task_result = create_tool.execute(task_params).await?; 554 + let task_parsed: Value = serde_json::from_str(&task_result)?; 555 + let task_id = task_parsed["id"].as_str().unwrap(); 556 + 557 + let outcome_params = json!({ 558 + "parent_id": task_id, 559 + "title": "Outcome", 560 + "description": "Task outcome", 561 + "project_id": "proj-1", 562 + "success": true 563 + }); 564 + 565 + let outcome_result = outcome_tool.execute(outcome_params).await?; 566 + let outcome_parsed: Value = serde_json::from_str(&outcome_result)?; 567 + let outcome_id = outcome_parsed["outcome_id"].as_str().unwrap(); 568 + 569 + // Revisit with new decision 570 + let revisit_params = json!({ 571 + "outcome_id": outcome_id, 572 + "project_id": "proj-1", 573 + "reason": "Results not as expected", 574 + "new_decision_title": "Reconsider approach" 575 + }); 576 + 577 + let revisit_result = revisit_tool.execute(revisit_params).await?; 578 + let revisit_parsed: Value = serde_json::from_str(&revisit_result)?; 579 + 580 + assert!(revisit_parsed["revisit_id"].as_str().is_some()); 581 + assert!(revisit_parsed["decision_id"].as_str().is_some()); 582 + 583 + // Verify revisit node 584 + let revisit_id = revisit_parsed["revisit_id"].as_str().unwrap(); 585 + let revisit = store 586 + .get_node(revisit_id) 587 + .await? 588 + .expect("Revisit not found"); 589 + 590 + assert_eq!(revisit.node_type, NodeType::Revisit); 591 + assert_eq!(revisit.status, NodeStatus::Active); 592 + 593 + // Verify new decision was created 594 + let decision_id = revisit_parsed["decision_id"].as_str().unwrap(); 595 + let decision = store 596 + .get_node(decision_id) 597 + .await? 598 + .expect("Decision not found"); 599 + 600 + assert_eq!(decision.node_type, NodeType::Decision); 601 + 602 + Ok(()) 603 + } 604 + 605 + #[tokio::test] 606 + async fn test_tool_name_and_description() -> anyhow::Result<()> { 607 + let (_db, store) = setup_test_db().await?; 608 + 609 + let tools: Vec<(Box<dyn Tool + Send + Sync>, &str)> = vec![ 610 + (Box::new(CreateNodeTool::new(store.clone())), "create_node"), 611 + (Box::new(UpdateNodeTool::new(store.clone())), "update_node"), 612 + (Box::new(AddEdgeTool::new(store.clone())), "add_edge"), 613 + (Box::new(QueryNodesTool::new(store.clone())), "query_nodes"), 614 + ( 615 + Box::new(SearchNodesTool::new(store.clone())), 616 + "search_nodes", 617 + ), 618 + (Box::new(ClaimTaskTool::new(store.clone())), "claim_task"), 619 + ( 620 + Box::new(LogDecisionTool::new(store.clone())), 621 + "log_decision", 622 + ), 623 + ( 624 + Box::new(ChooseOptionTool::new(store.clone())), 625 + "choose_option", 626 + ), 627 + ( 628 + Box::new(RecordOutcomeTool::new(store.clone())), 629 + "record_outcome", 630 + ), 631 + ( 632 + Box::new(RecordObservationTool::new(store.clone())), 633 + "record_observation", 634 + ), 635 + (Box::new(RevisitTool::new(store.clone())), "revisit"), 636 + ]; 637 + 638 + for (tool, expected_name) in tools { 639 + assert_eq!(tool.name(), expected_name); 640 + assert!(!tool.description().is_empty()); 641 + let params = tool.parameters(); 642 + assert!(params.is_object()); 643 + } 644 + 645 + Ok(()) 646 + } 647 + 648 + #[test] 649 + fn test_v2_registry_includes_all_tools() { 650 + // Create a mock graph store 651 + let graph_store = Arc::new(MockGraphStore); 652 + 653 + // Create security config and validator 654 + let security_config = SecurityConfig { 655 + shell_policy: rustagent::config::ShellPolicy::Blocklist, 656 + allowed_commands: vec![], 657 + blocked_patterns: vec![], 658 + max_file_size_mb: 100, 659 + allowed_paths: vec![], 660 + }; 661 + let validator = Arc::new(SecurityValidator::new(security_config).expect("Failed to create validator")); 662 + let permission_handler = Arc::new(AutoApproveHandler); 663 + 664 + // Create the v2 registry 665 + let registry = create_v2_registry(validator, permission_handler, graph_store); 666 + 667 + // Expected tool names: graph tools + legacy tools + context tools 668 + let expected_tools = vec![ 669 + // Graph tools 670 + "create_node", 671 + "update_node", 672 + "add_edge", 673 + "query_nodes", 674 + "search_nodes", 675 + "claim_task", 676 + "log_decision", 677 + "choose_option", 678 + "record_outcome", 679 + "record_observation", 680 + "revisit", 681 + // Legacy tools 682 + "read_file", 683 + "write_file", 684 + "list_files", 685 + "run_command", 686 + "signal_completion", 687 + // Context tools 688 + "read_agents_md", 689 + ]; 690 + 691 + // Get all registered tool names 692 + let registered_names = registry.list(); 693 + 694 + // Verify each expected tool is registered 695 + for expected in expected_tools { 696 + assert!( 697 + registered_names.contains(&expected.to_string()), 698 + "Tool '{}' not found in v2 registry. Registered tools: {:?}", 699 + expected, 700 + registered_names 701 + ); 702 + } 703 + }
+301
tests/graph_types_test.rs
··· 1 + use chrono::Utc; 2 + use rustagent::graph::*; 3 + use std::collections::HashMap; 4 + 5 + #[test] 6 + fn test_node_type_all_variants_roundtrip() { 7 + let types = vec![ 8 + NodeType::Goal, 9 + NodeType::Task, 10 + NodeType::Decision, 11 + NodeType::Option, 12 + NodeType::Outcome, 13 + NodeType::Observation, 14 + NodeType::Revisit, 15 + ]; 16 + 17 + for node_type in types { 18 + let string_repr = node_type.to_string(); 19 + let parsed: NodeType = string_repr.parse().expect("Failed to parse"); 20 + assert_eq!(node_type, parsed, "Roundtrip failed for {:?}", node_type); 21 + } 22 + } 23 + 24 + #[test] 25 + fn test_edge_type_all_variants_roundtrip() { 26 + let types = vec![ 27 + EdgeType::Contains, 28 + EdgeType::DependsOn, 29 + EdgeType::LeadsTo, 30 + EdgeType::Chosen, 31 + EdgeType::Rejected, 32 + EdgeType::Supersedes, 33 + EdgeType::Informs, 34 + ]; 35 + 36 + for edge_type in types { 37 + let string_repr = edge_type.to_string(); 38 + let parsed: EdgeType = string_repr.parse().expect("Failed to parse"); 39 + assert_eq!(edge_type, parsed, "Roundtrip failed for {:?}", edge_type); 40 + } 41 + } 42 + 43 + #[test] 44 + fn test_validate_status_task_ready() { 45 + let result = validate_status(&NodeType::Task, &NodeStatus::Ready); 46 + assert!(result.is_ok(), "Task should allow Ready status"); 47 + } 48 + 49 + #[test] 50 + fn test_validate_status_goal_ready_invalid() { 51 + let result = validate_status(&NodeType::Goal, &NodeStatus::Ready); 52 + assert!(result.is_err(), "Goal should not allow Ready status"); 53 + } 54 + 55 + #[test] 56 + fn test_node_status_all_variants_roundtrip() { 57 + let statuses = vec![ 58 + NodeStatus::Pending, 59 + NodeStatus::Active, 60 + NodeStatus::Completed, 61 + NodeStatus::Cancelled, 62 + NodeStatus::Ready, 63 + NodeStatus::Claimed, 64 + NodeStatus::InProgress, 65 + NodeStatus::Review, 66 + NodeStatus::Blocked, 67 + NodeStatus::Failed, 68 + NodeStatus::Decided, 69 + NodeStatus::Superseded, 70 + NodeStatus::Abandoned, 71 + NodeStatus::Chosen, 72 + NodeStatus::Rejected, 73 + ]; 74 + 75 + for status in statuses { 76 + let string_repr = status.to_string(); 77 + let parsed: NodeStatus = string_repr.parse().expect("Failed to parse"); 78 + assert_eq!(status, parsed, "Roundtrip failed for {:?}", status); 79 + } 80 + } 81 + 82 + #[test] 83 + fn test_graph_node_serialization_and_deserialization() { 84 + let mut metadata = HashMap::new(); 85 + metadata.insert("key1".to_string(), "value1".to_string()); 86 + 87 + let node = GraphNode { 88 + id: "ra-a3f8".to_string(), 89 + project_id: "proj-001".to_string(), 90 + node_type: NodeType::Task, 91 + title: "Complete Feature X".to_string(), 92 + description: "Implement feature X with proper error handling".to_string(), 93 + status: NodeStatus::Active, 94 + priority: Some(Priority::High), 95 + assigned_to: Some("alice@example.com".to_string()), 96 + created_by: Some("bob@example.com".to_string()), 97 + labels: vec!["feature".to_string(), "high-priority".to_string()], 98 + created_at: Utc::now(), 99 + started_at: Some(Utc::now()), 100 + completed_at: None, 101 + blocked_reason: None, 102 + metadata, 103 + }; 104 + 105 + let json = serde_json::to_string(&node).expect("Serialization failed"); 106 + let deserialized: GraphNode = serde_json::from_str(&json).expect("Deserialization failed"); 107 + 108 + assert_eq!(node.id, deserialized.id); 109 + assert_eq!(node.project_id, deserialized.project_id); 110 + assert_eq!(node.node_type, deserialized.node_type); 111 + assert_eq!(node.title, deserialized.title); 112 + assert_eq!(node.description, deserialized.description); 113 + assert_eq!(node.status, deserialized.status); 114 + assert_eq!(node.priority, deserialized.priority); 115 + assert_eq!(node.assigned_to, deserialized.assigned_to); 116 + assert_eq!(node.created_by, deserialized.created_by); 117 + assert_eq!(node.labels, deserialized.labels); 118 + } 119 + 120 + #[test] 121 + fn test_graph_edge_serialization_and_deserialization() { 122 + let edge = GraphEdge { 123 + id: "e-12345678".to_string(), 124 + edge_type: EdgeType::DependsOn, 125 + from_node: "ra-a3f8.1".to_string(), 126 + to_node: "ra-a3f8.2".to_string(), 127 + label: Some("task_depends_on".to_string()), 128 + created_at: Utc::now(), 129 + }; 130 + 131 + let json = serde_json::to_string(&edge).expect("Serialization failed"); 132 + let deserialized: GraphEdge = serde_json::from_str(&json).expect("Deserialization failed"); 133 + 134 + assert_eq!(edge.id, deserialized.id); 135 + assert_eq!(edge.edge_type, deserialized.edge_type); 136 + assert_eq!(edge.from_node, deserialized.from_node); 137 + assert_eq!(edge.to_node, deserialized.to_node); 138 + assert_eq!(edge.label, deserialized.label); 139 + } 140 + 141 + #[test] 142 + fn test_generate_goal_id_format() { 143 + let id = generate_goal_id(); 144 + assert!(id.starts_with("ra-"), "Goal ID should start with 'ra-'"); 145 + assert_eq!(id.len(), 7, "Goal ID should be 7 characters (ra- + 4 hex)"); 146 + 147 + // Verify it's valid hex after the prefix 148 + let hex_part = &id[3..]; 149 + assert!( 150 + u32::from_str_radix(hex_part, 16).is_ok(), 151 + "ID suffix should be valid hex" 152 + ); 153 + } 154 + 155 + #[test] 156 + fn test_generate_goal_id_uniqueness() { 157 + let id1 = generate_goal_id(); 158 + let id2 = generate_goal_id(); 159 + assert_ne!(id1, id2, "Generated IDs should be unique"); 160 + } 161 + 162 + #[test] 163 + fn test_generate_child_id_format() { 164 + let parent = "ra-a3f8"; 165 + let child = generate_child_id(parent, 1); 166 + assert_eq!(child, "ra-a3f8.1"); 167 + 168 + let grandchild = generate_child_id(&child, 3); 169 + assert_eq!(grandchild, "ra-a3f8.1.3"); 170 + 171 + let great_grandchild = generate_child_id(&grandchild, 2); 172 + assert_eq!(great_grandchild, "ra-a3f8.1.3.2"); 173 + } 174 + 175 + #[test] 176 + fn test_generate_child_id_various_sequences() { 177 + for seq in 1..=100 { 178 + let child = generate_child_id("ra-a3f8", seq); 179 + assert_eq!(child, format!("ra-a3f8.{}", seq)); 180 + } 181 + } 182 + 183 + #[test] 184 + fn test_generate_edge_id_format() { 185 + let id = generate_edge_id(); 186 + assert!(id.starts_with("e-"), "Edge ID should start with 'e-'"); 187 + assert_eq!(id.len(), 10, "Edge ID should be 10 characters (e- + 8 hex)"); 188 + 189 + // Verify it's valid hex after the prefix 190 + let hex_part = &id[2..]; 191 + assert!( 192 + u32::from_str_radix(hex_part, 16).is_ok(), 193 + "Edge ID suffix should be valid hex" 194 + ); 195 + } 196 + 197 + #[test] 198 + fn test_generate_edge_id_uniqueness() { 199 + let id1 = generate_edge_id(); 200 + let id2 = generate_edge_id(); 201 + assert_ne!(id1, id2, "Generated edge IDs should be unique"); 202 + } 203 + 204 + #[test] 205 + fn test_parent_id_extraction_single_level() { 206 + let result = parent_id("ra-a3f8.1"); 207 + assert_eq!(result, Some("ra-a3f8")); 208 + } 209 + 210 + #[test] 211 + fn test_parent_id_extraction_multiple_levels() { 212 + assert_eq!(parent_id("ra-a3f8.1.3"), Some("ra-a3f8.1")); 213 + assert_eq!(parent_id("ra-a3f8.1.3.2"), Some("ra-a3f8.1.3")); 214 + } 215 + 216 + #[test] 217 + fn test_parent_id_extraction_root_returns_none() { 218 + assert_eq!(parent_id("ra-a3f8"), None); 219 + } 220 + 221 + #[test] 222 + fn test_parent_id_extraction_edge_ids() { 223 + // Edge IDs should not have extractable parents (no dots) 224 + assert_eq!(parent_id("e-12345678"), None); 225 + } 226 + 227 + #[test] 228 + fn test_hierarchical_id_path_building() { 229 + let root = generate_goal_id(); 230 + let child1 = generate_child_id(&root, 1); 231 + let child2 = generate_child_id(&child1, 1); 232 + let child3 = generate_child_id(&child2, 1); 233 + 234 + // Verify the chain 235 + assert_eq!(parent_id(&child1), Some(root.as_str())); 236 + assert_eq!(parent_id(&child2), Some(child1.as_str())); 237 + assert_eq!(parent_id(&child3), Some(child2.as_str())); 238 + } 239 + 240 + #[test] 241 + fn test_priority_all_variants() { 242 + let priorities = vec![ 243 + Priority::Critical, 244 + Priority::High, 245 + Priority::Medium, 246 + Priority::Low, 247 + ]; 248 + 249 + for priority in priorities { 250 + let string_repr = priority.to_string(); 251 + let parsed: Priority = string_repr.parse().expect("Failed to parse"); 252 + assert_eq!(priority, parsed, "Roundtrip failed for {:?}", priority); 253 + } 254 + } 255 + 256 + #[test] 257 + fn test_valid_statuses_for_all_node_types() { 258 + // Goal: Pending, Active, Completed, Cancelled 259 + let goal_statuses = valid_statuses(&NodeType::Goal); 260 + assert!(goal_statuses.contains(&NodeStatus::Pending)); 261 + assert!(goal_statuses.contains(&NodeStatus::Active)); 262 + assert!(goal_statuses.contains(&NodeStatus::Completed)); 263 + assert!(goal_statuses.contains(&NodeStatus::Cancelled)); 264 + assert!(!goal_statuses.contains(&NodeStatus::Ready)); 265 + 266 + // Task: Pending, Ready, Claimed, InProgress, Review, Completed, Blocked, Failed, Cancelled 267 + let task_statuses = valid_statuses(&NodeType::Task); 268 + assert!(task_statuses.contains(&NodeStatus::Ready)); 269 + assert!(task_statuses.contains(&NodeStatus::Claimed)); 270 + assert!(task_statuses.contains(&NodeStatus::InProgress)); 271 + assert!(!task_statuses.contains(&NodeStatus::Decided)); 272 + 273 + // Decision: Pending, Active, Decided, Superseded 274 + let decision_statuses = valid_statuses(&NodeType::Decision); 275 + assert!(decision_statuses.contains(&NodeStatus::Decided)); 276 + assert!(decision_statuses.contains(&NodeStatus::Superseded)); 277 + assert!(!decision_statuses.contains(&NodeStatus::Ready)); 278 + 279 + // Option: Pending, Active, Chosen, Rejected, Abandoned 280 + let option_statuses = valid_statuses(&NodeType::Option); 281 + assert!(option_statuses.contains(&NodeStatus::Chosen)); 282 + assert!(option_statuses.contains(&NodeStatus::Rejected)); 283 + assert!(option_statuses.contains(&NodeStatus::Abandoned)); 284 + 285 + // Outcome: Active, Completed 286 + let outcome_statuses = valid_statuses(&NodeType::Outcome); 287 + assert_eq!(outcome_statuses.len(), 2); 288 + assert!(outcome_statuses.contains(&NodeStatus::Active)); 289 + assert!(outcome_statuses.contains(&NodeStatus::Completed)); 290 + 291 + // Observation: Active 292 + let observation_statuses = valid_statuses(&NodeType::Observation); 293 + assert_eq!(observation_statuses.len(), 1); 294 + assert!(observation_statuses.contains(&NodeStatus::Active)); 295 + 296 + // Revisit: Active, Completed 297 + let revisit_statuses = valid_statuses(&NodeType::Revisit); 298 + assert_eq!(revisit_statuses.len(), 2); 299 + assert!(revisit_statuses.contains(&NodeStatus::Active)); 300 + assert!(revisit_statuses.contains(&NodeStatus::Completed)); 301 + }
+614
tests/interchange_test.rs
··· 1 + use anyhow::Result; 2 + use chrono::Utc; 3 + use rustagent::graph::interchange::{ImportStrategy, diff_goal, export_goal, import_goal}; 4 + use rustagent::graph::store::GraphStore; 5 + use rustagent::graph::*; 6 + mod common; 7 + use common::*; 8 + 9 + // ===== Task 3 Tests: Export ===== 10 + 11 + #[tokio::test] 12 + async fn test_export_basic_goal_structure() -> Result<()> { 13 + let (_db, graph_store) = setup_test_env().await?; 14 + 15 + // Create a goal node 16 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 17 + graph_store.create_node(&goal).await?; 18 + 19 + // Create a task under the goal 20 + let task = create_test_task("ra-test.1", "proj-1", "Task 1", NodeStatus::Pending); 21 + graph_store.create_node(&task).await?; 22 + 23 + // Add Contains edge 24 + let edge = GraphEdge { 25 + id: generate_edge_id(), 26 + edge_type: EdgeType::Contains, 27 + from_node: "ra-test".to_string(), 28 + to_node: "ra-test.1".to_string(), 29 + label: None, 30 + created_at: Utc::now(), 31 + }; 32 + graph_store.add_edge(&edge).await?; 33 + 34 + // Export 35 + let toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 36 + 37 + // Parse and verify structure 38 + let parsed: toml::Value = toml::from_str(&toml_str)?; 39 + assert!(parsed.get("meta").is_some()); 40 + assert!(parsed.get("nodes").is_some()); 41 + assert!(parsed.get("edges").is_some()); 42 + 43 + // Verify meta fields 44 + let meta = &parsed["meta"]; 45 + assert_eq!(meta["version"].as_integer(), Some(1)); 46 + assert_eq!(meta["goal_id"].as_str(), Some("ra-test")); 47 + assert_eq!(meta["project"].as_str(), Some("test-project")); 48 + assert!(meta["content_hash"].as_str().is_some()); 49 + assert!(meta["exported_at"].as_str().is_some()); 50 + 51 + // Verify nodes section has expected entries 52 + let nodes = &parsed["nodes"]; 53 + assert!(nodes.get("ra-test").is_some()); 54 + assert!(nodes.get("ra-test.1").is_some()); 55 + 56 + // Verify edges section 57 + let edges = &parsed["edges"]; 58 + assert!(!edges.as_table().unwrap().is_empty()); 59 + 60 + Ok(()) 61 + } 62 + 63 + #[tokio::test] 64 + async fn test_export_deterministic_output() -> Result<()> { 65 + let (_db, graph_store) = setup_test_env().await?; 66 + 67 + // Create goal and task 68 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 69 + graph_store.create_node(&goal).await?; 70 + 71 + let task = create_test_task("ra-test.1", "proj-1", "Task 1", NodeStatus::Pending); 72 + graph_store.create_node(&task).await?; 73 + 74 + // Export twice 75 + let export1 = export_goal(&graph_store, "ra-test", "test-project").await?; 76 + let export2 = export_goal(&graph_store, "ra-test", "test-project").await?; 77 + 78 + // Parse both to compare content (exported_at timestamp may differ) 79 + let parsed1: toml::Value = toml::from_str(&export1)?; 80 + let parsed2: toml::Value = toml::from_str(&export2)?; 81 + 82 + // Content hash should be identical 83 + assert_eq!( 84 + parsed1["meta"]["content_hash"], parsed2["meta"]["content_hash"], 85 + "Content hash should be identical for identical data" 86 + ); 87 + 88 + // Nodes and edges should be identical 89 + assert_eq!(parsed1["nodes"], parsed2["nodes"]); 90 + assert_eq!(parsed1["edges"], parsed2["edges"]); 91 + 92 + // Overall structure should be identical except possibly exported_at 93 + assert_eq!(parsed1["meta"]["version"], parsed2["meta"]["version"]); 94 + assert_eq!(parsed1["meta"]["goal_id"], parsed2["meta"]["goal_id"]); 95 + assert_eq!(parsed1["meta"]["project"], parsed2["meta"]["project"]); 96 + 97 + Ok(()) 98 + } 99 + 100 + #[tokio::test] 101 + async fn test_export_with_metadata() -> Result<()> { 102 + let (_db, graph_store) = setup_test_env().await?; 103 + 104 + // Create goal with metadata 105 + let mut goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 106 + goal.metadata 107 + .insert("key1".to_string(), "value1".to_string()); 108 + goal.metadata 109 + .insert("key2".to_string(), "value2".to_string()); 110 + graph_store.create_node(&goal).await?; 111 + 112 + // Export 113 + let toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 114 + 115 + // Verify metadata is preserved 116 + let parsed: toml::Value = toml::from_str(&toml_str)?; 117 + let metadata = &parsed["nodes"]["ra-test"]["metadata"]; 118 + assert_eq!(metadata["key1"].as_str(), Some("value1")); 119 + assert_eq!(metadata["key2"].as_str(), Some("value2")); 120 + 121 + Ok(()) 122 + } 123 + 124 + // ===== Task 4 Tests: Import ===== 125 + 126 + #[tokio::test] 127 + async fn test_import_new_nodes() -> Result<()> { 128 + let (_db, graph_store) = setup_test_env().await?; 129 + 130 + // Create initial goal 131 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 132 + graph_store.create_node(&goal).await?; 133 + 134 + // Export it 135 + let toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 136 + 137 + // Verify it's parseable and has expected structure 138 + let parsed: toml::Value = toml::from_str(&toml_str)?; 139 + assert!(parsed.get("nodes").is_some()); 140 + assert!(parsed.get("meta").is_some()); 141 + 142 + // Import into same DB 143 + let result = import_goal(&graph_store, &toml_str, ImportStrategy::Theirs).await?; 144 + 145 + // Should have no conflicts since we imported unchanged state 146 + assert_eq!(result.conflicts.len(), 0); 147 + 148 + Ok(()) 149 + } 150 + 151 + #[tokio::test] 152 + async fn test_import_with_theirs_strategy() -> Result<()> { 153 + let (_db, graph_store) = setup_test_env().await?; 154 + 155 + // Create goal and task 156 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 157 + graph_store.create_node(&goal).await?; 158 + 159 + let task = create_test_task("ra-test.1", "proj-1", "Original Title", NodeStatus::Pending); 160 + graph_store.create_node(&task).await?; 161 + 162 + // Export 163 + let mut toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 164 + 165 + // Modify the TOML to change the title 166 + toml_str = toml_str.replace("Original Title", "Modified Title"); 167 + 168 + // Import with Theirs strategy 169 + let result = import_goal(&graph_store, &toml_str, ImportStrategy::Theirs).await?; 170 + 171 + // Verify the change was applied 172 + let updated_task = graph_store.get_node("ra-test.1").await?; 173 + assert!(updated_task.is_some()); 174 + assert_eq!(updated_task.unwrap().title, "Modified Title"); 175 + 176 + // No conflicts should be recorded with Theirs strategy 177 + assert_eq!(result.conflicts.len(), 0); 178 + 179 + Ok(()) 180 + } 181 + 182 + #[tokio::test] 183 + async fn test_import_with_ours_strategy() -> Result<()> { 184 + let (_db, graph_store) = setup_test_env().await?; 185 + 186 + // Create goal and task with original title 187 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 188 + graph_store.create_node(&goal).await?; 189 + 190 + let task = create_test_task("ra-test.1", "proj-1", "Original Title", NodeStatus::Pending); 191 + graph_store.create_node(&task).await?; 192 + 193 + // Export and modify 194 + let mut toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 195 + toml_str = toml_str.replace("Original Title", "Modified Title"); 196 + 197 + // Import with Ours strategy 198 + let _result = import_goal(&graph_store, &toml_str, ImportStrategy::Ours).await?; 199 + 200 + // Task should still have original title 201 + let task_after = graph_store.get_node("ra-test.1").await?; 202 + assert!(task_after.is_some()); 203 + assert_eq!(task_after.unwrap().title, "Original Title"); 204 + 205 + Ok(()) 206 + } 207 + 208 + #[tokio::test] 209 + async fn test_import_with_merge_strategy_detects_conflicts() -> Result<()> { 210 + let (_db, graph_store) = setup_test_env().await?; 211 + 212 + // Create goal and task 213 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 214 + graph_store.create_node(&goal).await?; 215 + 216 + let task = create_test_task("ra-test.1", "proj-1", "Original Title", NodeStatus::Pending); 217 + graph_store.create_node(&task).await?; 218 + 219 + // Export and modify 220 + let mut toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 221 + toml_str = toml_str.replace("Original Title", "Modified Title"); 222 + 223 + // Import with Merge strategy 224 + let result = import_goal(&graph_store, &toml_str, ImportStrategy::Merge).await?; 225 + 226 + // Should have detected the conflict 227 + assert!(result.conflicts.len() > 0); 228 + // Find the title conflict (may not be the first due to iteration order) 229 + let title_conflict = result 230 + .conflicts 231 + .iter() 232 + .find(|c| c.field == "title") 233 + .expect("Should have title conflict"); 234 + assert_eq!(title_conflict.db_value, "Original Title"); 235 + assert_eq!(title_conflict.file_value, "Modified Title"); 236 + 237 + Ok(()) 238 + } 239 + 240 + #[tokio::test] 241 + async fn test_import_skips_edges_with_missing_nodes() -> Result<()> { 242 + let (_db, graph_store) = setup_test_env().await?; 243 + 244 + // Create a goal 245 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 246 + graph_store.create_node(&goal).await?; 247 + 248 + // Export it 249 + let toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 250 + 251 + // Modify the TOML to add a new node that doesn't exist and an edge to it 252 + let _toml_content = toml_str.clone(); 253 + 254 + // Parse and modify 255 + let mut parsed: toml::Value = toml::from_str(&toml_str)?; 256 + 257 + // Add a new node entry 258 + { 259 + let nodes = parsed.get_mut("nodes").unwrap().as_table_mut().unwrap(); 260 + let mut new_node = toml::Table::new(); 261 + new_node.insert( 262 + "project_id".to_string(), 263 + toml::Value::String("proj-1".to_string()), 264 + ); 265 + new_node.insert( 266 + "node_type".to_string(), 267 + toml::Value::String("task".to_string()), 268 + ); 269 + new_node.insert( 270 + "title".to_string(), 271 + toml::Value::String("New Task".to_string()), 272 + ); 273 + new_node.insert( 274 + "description".to_string(), 275 + toml::Value::String("New task desc".to_string()), 276 + ); 277 + new_node.insert( 278 + "status".to_string(), 279 + toml::Value::String("pending".to_string()), 280 + ); 281 + new_node.insert( 282 + "created_at".to_string(), 283 + toml::Value::String(Utc::now().to_rfc3339()), 284 + ); 285 + nodes.insert("ra-test.1".to_string(), toml::Value::Table(new_node)); 286 + } 287 + 288 + // Add an edge to a non-existent node 289 + { 290 + let edges = parsed.get_mut("edges").unwrap().as_table_mut().unwrap(); 291 + let mut bad_edge = toml::Table::new(); 292 + bad_edge.insert( 293 + "edge_type".to_string(), 294 + toml::Value::String("depends_on".to_string()), 295 + ); 296 + bad_edge.insert( 297 + "from_node".to_string(), 298 + toml::Value::String("ra-test".to_string()), 299 + ); 300 + bad_edge.insert( 301 + "to_node".to_string(), 302 + toml::Value::String("nonexistent".to_string()), 303 + ); 304 + bad_edge.insert( 305 + "created_at".to_string(), 306 + toml::Value::String(Utc::now().to_rfc3339()), 307 + ); 308 + edges.insert("e-badedge".to_string(), toml::Value::Table(bad_edge)); 309 + } 310 + 311 + let modified_toml = toml::to_string_pretty(&parsed)?; 312 + 313 + // Import 314 + let result = import_goal(&graph_store, &modified_toml, ImportStrategy::Theirs).await?; 315 + 316 + // Should have skipped the edge 317 + assert!(!result.skipped_edges.is_empty()); 318 + assert!( 319 + result.skipped_edges[0].contains("nonexistent") 320 + || result.skipped_edges[0].contains("unresolved") 321 + ); 322 + 323 + Ok(()) 324 + } 325 + 326 + #[tokio::test] 327 + async fn test_round_trip_export_import() -> Result<()> { 328 + let (_db, graph_store) = setup_test_env().await?; 329 + 330 + // Create goal, task, decision structure (no edges for simplicity) 331 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 332 + graph_store.create_node(&goal).await?; 333 + 334 + let task = create_test_task("ra-test.1", "proj-1", "Task 1", NodeStatus::Ready); 335 + graph_store.create_node(&task).await?; 336 + 337 + let decision = create_test_decision("ra-test.2", "proj-1", "Decision 1"); 338 + graph_store.create_node(&decision).await?; 339 + 340 + // Export 341 + let export1 = export_goal(&graph_store, "ra-test", "test-project").await?; 342 + 343 + // Parse the export 344 + let _parsed1: toml::Value = toml::from_str(&export1)?; 345 + 346 + // Verify we can round-trip through import 347 + let (_db2, graph_store2) = setup_test_env().await?; 348 + 349 + // Create minimal structure first 350 + let goal2 = create_test_goal("ra-test", "proj-1", "Test Goal"); 351 + graph_store2.create_node(&goal2).await?; 352 + 353 + // Now import the full graph 354 + let result = import_goal(&graph_store2, &export1, ImportStrategy::Theirs).await?; 355 + 356 + // Should have imported the nodes successfully 357 + assert!(result.added_nodes > 0 || result.unchanged > 0); 358 + assert_eq!(result.conflicts.len(), 0, "Should have no conflicts"); 359 + 360 + // Verify the imported nodes exist 361 + assert!(graph_store2.get_node("ra-test.1").await?.is_some()); 362 + assert!(graph_store2.get_node("ra-test.2").await?.is_some()); 363 + 364 + // Verify node properties are preserved 365 + let imported_task = graph_store2.get_node("ra-test.1").await?; 366 + assert!(imported_task.is_some()); 367 + let task_node = imported_task.unwrap(); 368 + assert_eq!(task_node.title, "Task 1"); 369 + assert_eq!(task_node.status, NodeStatus::Ready); 370 + 371 + // Re-export from the imported graph and verify nodes and edges match 372 + let export2 = export_goal(&graph_store2, "ra-test", "test-project").await?; 373 + 374 + // Parse both exports 375 + let parsed_export1: toml::Value = toml::from_str(&export1)?; 376 + let parsed_export2: toml::Value = toml::from_str(&export2)?; 377 + 378 + // Verify nodes are identical between exports (at minimum the counts should match) 379 + let nodes1 = parsed_export1["nodes"] 380 + .as_table() 381 + .expect("Export should have nodes"); 382 + let nodes2 = parsed_export2["nodes"] 383 + .as_table() 384 + .expect("Import export should have nodes"); 385 + 386 + // After round-trip, we should have at least the goal node and ideally all original nodes 387 + // Verify goal exists in both 388 + assert!(nodes1.get("ra-test").is_some()); 389 + assert!(nodes2.get("ra-test").is_some()); 390 + 391 + // Verify content hashes are identical when we export the same data 392 + // This tests that re-exporting unchanged state produces identical hashes 393 + let export3 = export_goal(&graph_store2, "ra-test", "test-project").await?; 394 + let parsed_export3: toml::Value = toml::from_str(&export3)?; 395 + let hash2 = parsed_export2["meta"]["content_hash"].as_str().unwrap(); 396 + let hash3 = parsed_export3["meta"]["content_hash"].as_str().unwrap(); 397 + assert_eq!( 398 + hash2, hash3, 399 + "Content hashes should be identical for unchanged data (re-export should be deterministic)" 400 + ); 401 + 402 + Ok(()) 403 + } 404 + 405 + // ===== Task 5 Tests: Diff ===== 406 + 407 + #[tokio::test] 408 + async fn test_diff_detects_added_nodes() -> Result<()> { 409 + let (_db, graph_store) = setup_test_env().await?; 410 + 411 + // Create goal 412 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 413 + graph_store.create_node(&goal).await?; 414 + 415 + // Create task in DB 416 + let task = create_test_task("ra-test.1", "proj-1", "Task 1", NodeStatus::Pending); 417 + graph_store.create_node(&task).await?; 418 + 419 + // Export 420 + let mut toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 421 + 422 + // Add another node to the TOML 423 + let mut parsed: toml::Value = toml::from_str(&toml_str)?; 424 + { 425 + let nodes = parsed.get_mut("nodes").unwrap().as_table_mut().unwrap(); 426 + let mut new_node = toml::Table::new(); 427 + new_node.insert( 428 + "project_id".to_string(), 429 + toml::Value::String("proj-1".to_string()), 430 + ); 431 + new_node.insert( 432 + "node_type".to_string(), 433 + toml::Value::String("task".to_string()), 434 + ); 435 + new_node.insert( 436 + "title".to_string(), 437 + toml::Value::String("New Task".to_string()), 438 + ); 439 + new_node.insert( 440 + "description".to_string(), 441 + toml::Value::String("New task desc".to_string()), 442 + ); 443 + new_node.insert( 444 + "status".to_string(), 445 + toml::Value::String("pending".to_string()), 446 + ); 447 + new_node.insert( 448 + "created_at".to_string(), 449 + toml::Value::String(Utc::now().to_rfc3339()), 450 + ); 451 + nodes.insert("ra-test.2".to_string(), toml::Value::Table(new_node)); 452 + } 453 + toml_str = toml::to_string_pretty(&parsed)?; 454 + 455 + // Diff 456 + let diff = diff_goal(&graph_store, &toml_str).await?; 457 + 458 + // Should show ra-test.2 as added 459 + assert!(diff.added_nodes.contains(&"ra-test.2".to_string())); 460 + assert_eq!(diff.added_nodes.len(), 1); 461 + 462 + Ok(()) 463 + } 464 + 465 + #[tokio::test] 466 + async fn test_diff_detects_changed_nodes() -> Result<()> { 467 + let (_db, graph_store) = setup_test_env().await?; 468 + 469 + // Create goal and task 470 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 471 + graph_store.create_node(&goal).await?; 472 + 473 + let task = create_test_task("ra-test.1", "proj-1", "Original Title", NodeStatus::Pending); 474 + graph_store.create_node(&task).await?; 475 + 476 + // Export and modify title 477 + let mut toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 478 + toml_str = toml_str.replace("Original Title", "Modified Title"); 479 + 480 + // Diff 481 + let diff = diff_goal(&graph_store, &toml_str).await?; 482 + 483 + // Should show ra-test.1 as changed with title field 484 + let task_change = diff 485 + .changed_nodes 486 + .iter() 487 + .find(|(id, _)| id == "ra-test.1") 488 + .expect("Should detect change in ra-test.1"); 489 + assert!(task_change.1.contains(&"title".to_string())); 490 + 491 + Ok(()) 492 + } 493 + 494 + #[tokio::test] 495 + async fn test_diff_detects_removed_nodes() -> Result<()> { 496 + let (_db, graph_store) = setup_test_env().await?; 497 + 498 + // Create goal and task 499 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 500 + graph_store.create_node(&goal).await?; 501 + 502 + let task = create_test_task("ra-test.1", "proj-1", "Task 1", NodeStatus::Pending); 503 + graph_store.create_node(&task).await?; 504 + 505 + // Export 506 + let toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 507 + 508 + // Now remove the task from TOML (just keep goal) 509 + let mut parsed: toml::Value = toml::from_str(&toml_str)?; 510 + { 511 + let nodes = parsed.get_mut("nodes").unwrap().as_table_mut().unwrap(); 512 + nodes.remove("ra-test.1"); 513 + } 514 + let modified_toml = toml::to_string_pretty(&parsed)?; 515 + 516 + // Diff 517 + let diff = diff_goal(&graph_store, &modified_toml).await?; 518 + 519 + // Should show ra-test.1 as removed 520 + assert!(diff.removed_nodes.contains(&"ra-test.1".to_string())); 521 + 522 + Ok(()) 523 + } 524 + 525 + #[tokio::test] 526 + async fn test_diff_counts_unchanged() -> Result<()> { 527 + let (_db, graph_store) = setup_test_env().await?; 528 + 529 + // Create goal and task 530 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 531 + graph_store.create_node(&goal).await?; 532 + 533 + let task = create_test_task("ra-test.1", "proj-1", "Task 1", NodeStatus::Pending); 534 + graph_store.create_node(&task).await?; 535 + 536 + // Export (no changes) 537 + let toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 538 + 539 + // Diff with unchanged content 540 + let diff = diff_goal(&graph_store, &toml_str).await?; 541 + 542 + // Should show no additions or removals 543 + assert_eq!(diff.added_nodes.len(), 0); 544 + assert_eq!(diff.removed_nodes.len(), 0); 545 + 546 + // Goal will have next_child_seq in metadata from create, which may show as changed 547 + // Task should be unchanged 548 + // Just verify the key properties 549 + assert_eq!(diff.unchanged_nodes + diff.changed_nodes.len(), 2); // goal + task total 550 + 551 + Ok(()) 552 + } 553 + 554 + #[tokio::test] 555 + async fn test_diff_detects_added_and_removed_edges() -> Result<()> { 556 + let (_db, graph_store) = setup_test_env().await?; 557 + 558 + // Create goal and two tasks 559 + let goal = create_test_goal("ra-test", "proj-1", "Test Goal"); 560 + graph_store.create_node(&goal).await?; 561 + 562 + let task1 = create_test_task("ra-test.1", "proj-1", "Task 1", NodeStatus::Pending); 563 + graph_store.create_node(&task1).await?; 564 + 565 + let task2 = create_test_task("ra-test.2", "proj-1", "Task 2", NodeStatus::Pending); 566 + graph_store.create_node(&task2).await?; 567 + 568 + // Add edge from task1 to task2 569 + let edge = GraphEdge { 570 + id: generate_edge_id(), 571 + edge_type: EdgeType::DependsOn, 572 + from_node: "ra-test.1".to_string(), 573 + to_node: "ra-test.2".to_string(), 574 + label: None, 575 + created_at: Utc::now(), 576 + }; 577 + graph_store.add_edge(&edge).await?; 578 + 579 + // Export 580 + let toml_str = export_goal(&graph_store, "ra-test", "test-project").await?; 581 + 582 + // Add another edge in the TOML (but both tasks exist) 583 + let mut parsed: toml::Value = toml::from_str(&toml_str)?; 584 + { 585 + let edges = parsed.get_mut("edges").unwrap().as_table_mut().unwrap(); 586 + let mut new_edge = toml::Table::new(); 587 + new_edge.insert( 588 + "edge_type".to_string(), 589 + toml::Value::String("contains".to_string()), 590 + ); 591 + new_edge.insert( 592 + "from_node".to_string(), 593 + toml::Value::String("ra-test".to_string()), 594 + ); 595 + new_edge.insert( 596 + "to_node".to_string(), 597 + toml::Value::String("ra-test.2".to_string()), 598 + ); 599 + new_edge.insert( 600 + "created_at".to_string(), 601 + toml::Value::String(Utc::now().to_rfc3339()), 602 + ); 603 + edges.insert("e-newedge".to_string(), toml::Value::Table(new_edge)); 604 + } 605 + let modified_toml = toml::to_string_pretty(&parsed)?; 606 + 607 + // Diff 608 + let diff = diff_goal(&graph_store, &modified_toml).await?; 609 + 610 + // Should detect the new edge 611 + assert!(diff.added_edges.len() > 0); 612 + 613 + Ok(()) 614 + }
+70
tests/llm_token_tracking_test.rs
··· 1 + use rustagent::llm::mock::MockLlmClient; 2 + use rustagent::llm::{LlmClient, Message, ResponseContent}; 3 + 4 + #[tokio::test] 5 + async fn test_response_has_token_fields() { 6 + // Verify Response struct contains input_tokens and output_tokens fields 7 + let client = MockLlmClient::new(); 8 + client.queue_text_response("Hello, world!"); 9 + 10 + let messages = vec![Message::user("Hi")]; 11 + let response = client.chat(messages, &[]).await.unwrap(); 12 + 13 + // Fields should exist, though they may be None for mock responses 14 + assert_eq!( 15 + response.content, 16 + ResponseContent::Text("Hello, world!".to_string()) 17 + ); 18 + // These fields should exist on Response 19 + let _ = response.input_tokens; 20 + let _ = response.output_tokens; 21 + } 22 + 23 + #[tokio::test] 24 + async fn test_mock_can_set_token_counts() { 25 + // Verify mock client supports setting token counts 26 + let client = MockLlmClient::new(); 27 + client.queue_text_response("Hello, world!"); 28 + client.set_token_counts(100, 50); 29 + 30 + let messages = vec![Message::user("Hi")]; 31 + let response = client.chat(messages, &[]).await.unwrap(); 32 + 33 + assert_eq!(response.input_tokens, Some(100)); 34 + assert_eq!(response.output_tokens, Some(50)); 35 + } 36 + 37 + #[tokio::test] 38 + async fn test_mock_token_counts_default_none() { 39 + // Verify mock responses have None by default for token counts 40 + let client = MockLlmClient::new(); 41 + client.queue_text_response("Hello"); 42 + 43 + let messages = vec![Message::user("Hi")]; 44 + let response = client.chat(messages, &[]).await.unwrap(); 45 + 46 + // Default should be None (no token info set) 47 + assert_eq!(response.input_tokens, None); 48 + assert_eq!(response.output_tokens, None); 49 + } 50 + 51 + #[tokio::test] 52 + async fn test_token_counts_with_tool_calls() { 53 + // Verify token counts work with tool call responses too 54 + let client = MockLlmClient::new(); 55 + client.queue_tool_call("read_file", serde_json::json!({"path": "test.txt"})); 56 + client.set_token_counts(75, 25); 57 + 58 + let messages = vec![Message::user("Read the file")]; 59 + let response = client.chat(messages, &[]).await.unwrap(); 60 + 61 + assert_eq!(response.input_tokens, Some(75)); 62 + assert_eq!(response.output_tokens, Some(25)); 63 + match response.content { 64 + ResponseContent::ToolCalls(calls) => { 65 + assert_eq!(calls.len(), 1); 66 + assert_eq!(calls[0].name, "read_file"); 67 + } 68 + _ => panic!("Expected tool call response"), 69 + } 70 + }
+570
tests/profile_test.rs
··· 1 + use rustagent::agent::profile::{AgentProfile, resolve_profile}; 2 + use rustagent::security::SecurityScope; 3 + use std::fs; 4 + use tempfile::TempDir; 5 + 6 + #[test] 7 + fn test_security_scope_default_is_permissive() { 8 + // P1d.AC1.2: Default SecurityScope should be permissive 9 + let scope = SecurityScope::default(); 10 + 11 + assert_eq!(scope.allowed_paths, vec!["*"]); 12 + assert!(scope.denied_paths.is_empty()); 13 + assert_eq!(scope.allowed_commands, vec!["*"]); 14 + assert!(!scope.read_only); 15 + assert!(scope.can_create_files); 16 + assert!(!scope.network_access); 17 + } 18 + 19 + #[test] 20 + fn test_security_scope_deserialize() { 21 + // P1d.AC1.2: SecurityScope can be deserialized from TOML 22 + let toml_str = r#" 23 + allowed_paths = ["/home/user/project"] 24 + denied_paths = ["/etc"] 25 + allowed_commands = ["ls", "cat"] 26 + read_only = true 27 + can_create_files = false 28 + network_access = false 29 + "#; 30 + 31 + let scope: SecurityScope = toml::from_str(toml_str).expect("Failed to deserialize"); 32 + 33 + assert_eq!(scope.allowed_paths, vec!["/home/user/project"]); 34 + assert_eq!(scope.denied_paths, vec!["/etc"]); 35 + assert_eq!(scope.allowed_commands, vec!["ls", "cat"]); 36 + assert!(scope.read_only); 37 + assert!(!scope.can_create_files); 38 + assert!(!scope.network_access); 39 + } 40 + 41 + #[test] 42 + fn test_agent_profile_deserialize() { 43 + // P1d.AC3.1: AgentProfile deserializes from TOML 44 + let toml_str = r#" 45 + name = "coder" 46 + role = "Implementation specialist" 47 + system_prompt = "You are a code implementation specialist" 48 + allowed_tools = ["file", "shell"] 49 + turn_limit = 50 50 + token_budget = 100000 51 + 52 + [security] 53 + allowed_paths = ["/project"] 54 + denied_paths = [] 55 + allowed_commands = ["ls", "cat"] 56 + read_only = false 57 + can_create_files = true 58 + network_access = false 59 + 60 + [llm] 61 + model = "claude-3-sonnet-20250219" 62 + temperature = 0.7 63 + max_tokens = 4096 64 + "#; 65 + 66 + let profile: AgentProfile = toml::from_str(toml_str).expect("Failed to deserialize"); 67 + 68 + assert_eq!(profile.name, "coder"); 69 + assert_eq!(profile.role, "Implementation specialist"); 70 + assert_eq!( 71 + profile.system_prompt, 72 + "You are a code implementation specialist" 73 + ); 74 + assert_eq!(profile.allowed_tools, vec!["file", "shell"]); 75 + assert_eq!(profile.turn_limit, Some(50)); 76 + assert_eq!(profile.token_budget, Some(100000)); 77 + assert_eq!(profile.security.allowed_paths, vec!["/project"]); 78 + assert_eq!(profile.security.allowed_commands, vec!["ls", "cat"]); 79 + assert_eq!( 80 + profile.llm.model, 81 + Some("claude-3-sonnet-20250219".to_string()) 82 + ); 83 + assert_eq!(profile.llm.temperature, Some(0.7)); 84 + assert_eq!(profile.llm.max_tokens, Some(4096)); 85 + } 86 + 87 + #[test] 88 + fn test_agent_profile_inheritance_scalar_fields() { 89 + // P1d.AC3.5: Scalar fields - child wins if non-empty 90 + let mut child = AgentProfile { 91 + name: "child".to_string(), 92 + extends: Some("parent".to_string()), 93 + role: "Child role".to_string(), 94 + system_prompt: "child prompt".to_string(), 95 + allowed_tools: vec![], 96 + security: SecurityScope::default(), 97 + llm: Default::default(), 98 + turn_limit: None, 99 + token_budget: None, 100 + }; 101 + 102 + let parent = AgentProfile { 103 + name: "parent".to_string(), 104 + extends: None, 105 + role: "Parent role".to_string(), 106 + system_prompt: "parent prompt".to_string(), 107 + allowed_tools: vec![], 108 + security: SecurityScope::default(), 109 + llm: Default::default(), 110 + turn_limit: None, 111 + token_budget: None, 112 + }; 113 + 114 + child.apply_inheritance(&parent); 115 + 116 + assert_eq!(child.role, "Child role"); 117 + assert!(child.system_prompt.contains("parent prompt")); 118 + assert!(child.system_prompt.contains("child prompt")); 119 + } 120 + 121 + #[test] 122 + fn test_agent_profile_inheritance_scalar_fields_empty() { 123 + // P1d.AC3.5: Scalar fields - parent used if child empty 124 + let mut child = AgentProfile { 125 + name: "child".to_string(), 126 + extends: Some("parent".to_string()), 127 + role: "".to_string(), 128 + system_prompt: "child prompt".to_string(), 129 + allowed_tools: vec![], 130 + security: SecurityScope::default(), 131 + llm: Default::default(), 132 + turn_limit: None, 133 + token_budget: None, 134 + }; 135 + 136 + let parent = AgentProfile { 137 + name: "parent".to_string(), 138 + extends: None, 139 + role: "Parent role".to_string(), 140 + system_prompt: "parent prompt".to_string(), 141 + allowed_tools: vec![], 142 + security: SecurityScope::default(), 143 + llm: Default::default(), 144 + turn_limit: None, 145 + token_budget: None, 146 + }; 147 + 148 + child.apply_inheritance(&parent); 149 + 150 + assert_eq!(child.role, "Parent role"); 151 + } 152 + 153 + #[test] 154 + fn test_agent_profile_inheritance_list_fields() { 155 + // P1d.AC3.5: List fields - child replaces parent entirely (not merged) 156 + let mut child = AgentProfile { 157 + name: "child".to_string(), 158 + extends: Some("parent".to_string()), 159 + role: "Child".to_string(), 160 + system_prompt: "".to_string(), 161 + allowed_tools: vec!["file".to_string()], 162 + security: SecurityScope::default(), 163 + llm: Default::default(), 164 + turn_limit: None, 165 + token_budget: None, 166 + }; 167 + 168 + let parent = AgentProfile { 169 + name: "parent".to_string(), 170 + extends: None, 171 + role: "Parent".to_string(), 172 + system_prompt: "".to_string(), 173 + allowed_tools: vec!["file".to_string(), "shell".to_string()], 174 + security: SecurityScope::default(), 175 + llm: Default::default(), 176 + turn_limit: None, 177 + token_budget: None, 178 + }; 179 + 180 + child.apply_inheritance(&parent); 181 + 182 + // Child has ["file"], so it should not inherit parent's ["file", "shell"] 183 + assert_eq!(child.allowed_tools, vec!["file"]); 184 + } 185 + 186 + #[test] 187 + fn test_agent_profile_inheritance_list_fields_empty() { 188 + // P1d.AC3.5: List fields - parent used if child empty 189 + let mut child = AgentProfile { 190 + name: "child".to_string(), 191 + extends: Some("parent".to_string()), 192 + role: "Child".to_string(), 193 + system_prompt: "".to_string(), 194 + allowed_tools: vec![], 195 + security: SecurityScope::default(), 196 + llm: Default::default(), 197 + turn_limit: None, 198 + token_budget: None, 199 + }; 200 + 201 + let parent = AgentProfile { 202 + name: "parent".to_string(), 203 + extends: None, 204 + role: "Parent".to_string(), 205 + system_prompt: "".to_string(), 206 + allowed_tools: vec!["file".to_string(), "shell".to_string()], 207 + security: SecurityScope::default(), 208 + llm: Default::default(), 209 + turn_limit: None, 210 + token_budget: None, 211 + }; 212 + 213 + child.apply_inheritance(&parent); 214 + 215 + assert_eq!(child.allowed_tools, vec!["file", "shell"]); 216 + } 217 + 218 + #[test] 219 + fn test_agent_profile_inheritance_system_prompt_appends() { 220 + // P1d.AC3.5: system_prompt appends with separator 221 + let mut child = AgentProfile { 222 + name: "child".to_string(), 223 + extends: Some("parent".to_string()), 224 + role: "Child".to_string(), 225 + system_prompt: "Child instructions".to_string(), 226 + allowed_tools: vec![], 227 + security: SecurityScope::default(), 228 + llm: Default::default(), 229 + turn_limit: None, 230 + token_budget: None, 231 + }; 232 + 233 + let parent = AgentProfile { 234 + name: "parent".to_string(), 235 + extends: None, 236 + role: "Parent".to_string(), 237 + system_prompt: "Parent instructions".to_string(), 238 + allowed_tools: vec![], 239 + security: SecurityScope::default(), 240 + llm: Default::default(), 241 + turn_limit: None, 242 + token_budget: None, 243 + }; 244 + 245 + child.apply_inheritance(&parent); 246 + 247 + assert!(child.system_prompt.contains("Parent instructions")); 248 + assert!( 249 + child 250 + .system_prompt 251 + .contains("Project-Specific Instructions") 252 + ); 253 + assert!(child.system_prompt.contains("Child instructions")); 254 + } 255 + 256 + #[test] 257 + fn test_agent_profile_inheritance_optional_fields() { 258 + // P1d.AC3.5: Optional fields - child Some wins, falls to parent if None 259 + let mut child = AgentProfile { 260 + name: "child".to_string(), 261 + extends: Some("parent".to_string()), 262 + role: "Child".to_string(), 263 + system_prompt: "".to_string(), 264 + allowed_tools: vec![], 265 + security: SecurityScope::default(), 266 + llm: Default::default(), 267 + turn_limit: Some(75), 268 + token_budget: None, 269 + }; 270 + 271 + let parent = AgentProfile { 272 + name: "parent".to_string(), 273 + extends: None, 274 + role: "Parent".to_string(), 275 + system_prompt: "".to_string(), 276 + allowed_tools: vec![], 277 + security: SecurityScope::default(), 278 + llm: Default::default(), 279 + turn_limit: Some(50), 280 + token_budget: Some(200000), 281 + }; 282 + 283 + child.apply_inheritance(&parent); 284 + 285 + assert_eq!(child.turn_limit, Some(75)); // child wins 286 + assert_eq!(child.token_budget, Some(200000)); // from parent 287 + } 288 + 289 + #[test] 290 + fn test_agent_profile_inheritance_llm_config() { 291 + // P1d.AC3.5: LLM config - child Some wins, falls to parent if None 292 + let mut child = AgentProfile { 293 + name: "child".to_string(), 294 + extends: Some("parent".to_string()), 295 + role: "Child".to_string(), 296 + system_prompt: "".to_string(), 297 + allowed_tools: vec![], 298 + security: SecurityScope::default(), 299 + llm: rustagent::agent::profile::ProfileLlmConfig { 300 + model: Some("child-model".to_string()), 301 + temperature: None, 302 + max_tokens: None, 303 + }, 304 + turn_limit: None, 305 + token_budget: None, 306 + }; 307 + 308 + let parent = AgentProfile { 309 + name: "parent".to_string(), 310 + extends: None, 311 + role: "Parent".to_string(), 312 + system_prompt: "".to_string(), 313 + allowed_tools: vec![], 314 + security: SecurityScope::default(), 315 + llm: rustagent::agent::profile::ProfileLlmConfig { 316 + model: Some("parent-model".to_string()), 317 + temperature: Some(0.5), 318 + max_tokens: Some(2000), 319 + }, 320 + turn_limit: None, 321 + token_budget: None, 322 + }; 323 + 324 + child.apply_inheritance(&parent); 325 + 326 + assert_eq!(child.llm.model, Some("child-model".to_string())); // child wins 327 + assert_eq!(child.llm.temperature, Some(0.5)); // from parent 328 + assert_eq!(child.llm.max_tokens, Some(2000)); // from parent 329 + } 330 + 331 + // Built-in profiles tests 332 + 333 + #[test] 334 + fn test_resolve_builtin_coder_profile() { 335 + // P1d.AC3.2: resolve_profile("coder", None) returns built-in coder profile 336 + let profile = resolve_profile("coder", None).expect("Failed to resolve coder profile"); 337 + 338 + assert_eq!(profile.name, "coder"); 339 + assert_eq!(profile.role, "Implementation specialist"); 340 + assert!(profile.system_prompt.len() > 0); 341 + assert!(profile.allowed_tools.contains(&"file".to_string())); 342 + assert!(profile.allowed_tools.contains(&"shell".to_string())); 343 + } 344 + 345 + #[test] 346 + fn test_resolve_builtin_planner_profile() { 347 + // P1d.AC3.2: resolve_profile("planner", None) returns built-in planner profile 348 + let profile = resolve_profile("planner", None).expect("Failed to resolve planner profile"); 349 + 350 + assert_eq!(profile.name, "planner"); 351 + assert_eq!(profile.role, "Task breakdown specialist"); 352 + assert!(profile.system_prompt.len() > 0); 353 + } 354 + 355 + #[test] 356 + fn test_resolve_builtin_reviewer_profile() { 357 + // P1d.AC3.2: resolve_profile("reviewer", None) returns built-in reviewer profile 358 + let profile = resolve_profile("reviewer", None).expect("Failed to resolve reviewer profile"); 359 + 360 + assert_eq!(profile.name, "reviewer"); 361 + assert_eq!(profile.role, "Code review specialist"); 362 + assert!(profile.system_prompt.len() > 0); 363 + } 364 + 365 + #[test] 366 + fn test_resolve_builtin_tester_profile() { 367 + // P1d.AC3.2: resolve_profile("tester", None) returns built-in tester profile 368 + let profile = resolve_profile("tester", None).expect("Failed to resolve tester profile"); 369 + 370 + assert_eq!(profile.name, "tester"); 371 + assert_eq!(profile.role, "Test implementation specialist"); 372 + assert!(profile.system_prompt.len() > 0); 373 + } 374 + 375 + #[test] 376 + fn test_resolve_builtin_researcher_profile() { 377 + // P1d.AC3.2: resolve_profile("researcher", None) returns built-in researcher profile 378 + let profile = 379 + resolve_profile("researcher", None).expect("Failed to resolve researcher profile"); 380 + 381 + assert_eq!(profile.name, "researcher"); 382 + assert_eq!(profile.role, "Information gathering specialist"); 383 + assert!(profile.system_prompt.len() > 0); 384 + } 385 + 386 + #[test] 387 + fn test_resolve_unknown_profile_fails() { 388 + // Unknown profile should fail 389 + let result = resolve_profile("nonexistent_profile", None); 390 + assert!(result.is_err()); 391 + assert!(result.unwrap_err().to_string().contains("Unknown profile")); 392 + } 393 + 394 + #[test] 395 + fn test_resolve_project_level_profile() { 396 + // P1d.AC3.3: Create a tempdir with .rustagent/profiles/custom.toml 397 + let tempdir = TempDir::new().expect("Failed to create tempdir"); 398 + let project_path = tempdir.path(); 399 + 400 + // Create .rustagent/profiles directory 401 + let profiles_dir = project_path.join(".rustagent").join("profiles"); 402 + fs::create_dir_all(&profiles_dir).expect("Failed to create profiles directory"); 403 + 404 + // Create custom.toml 405 + let custom_toml = r#" 406 + name = "custom" 407 + role = "Custom role" 408 + system_prompt = "Custom system prompt" 409 + allowed_tools = ["file", "shell"] 410 + turn_limit = 50 411 + token_budget = 100000 412 + 413 + [security] 414 + allowed_paths = ["/project"] 415 + denied_paths = [] 416 + allowed_commands = ["ls", "cat"] 417 + read_only = false 418 + can_create_files = true 419 + network_access = false 420 + 421 + [llm] 422 + model = "claude-3-sonnet-20250219" 423 + temperature = 0.7 424 + max_tokens = 4096 425 + "#; 426 + 427 + let profile_path = profiles_dir.join("custom.toml"); 428 + fs::write(&profile_path, custom_toml).expect("Failed to write custom.toml"); 429 + 430 + let profile = 431 + resolve_profile("custom", Some(project_path)).expect("Failed to resolve custom profile"); 432 + 433 + assert_eq!(profile.name, "custom"); 434 + assert_eq!(profile.role, "Custom role"); 435 + } 436 + 437 + #[test] 438 + fn test_resolve_project_level_overrides_builtin() { 439 + // P1d.AC3.4: Project-level "coder" profile should override built-in 440 + let tempdir = TempDir::new().expect("Failed to create tempdir"); 441 + let project_path = tempdir.path(); 442 + 443 + // Create .rustagent/profiles directory 444 + let profiles_dir = project_path.join(".rustagent").join("profiles"); 445 + fs::create_dir_all(&profiles_dir).expect("Failed to create profiles directory"); 446 + 447 + // Create project-level coder.toml 448 + let project_coder = r#" 449 + name = "coder" 450 + role = "Project-specific coder" 451 + system_prompt = "Project-specific system prompt" 452 + allowed_tools = ["file", "shell"] 453 + 454 + [security] 455 + allowed_paths = ["/project"] 456 + denied_paths = [] 457 + allowed_commands = ["*"] 458 + read_only = false 459 + can_create_files = true 460 + network_access = false 461 + "#; 462 + 463 + let profile_path = profiles_dir.join("coder.toml"); 464 + fs::write(&profile_path, project_coder).expect("Failed to write coder.toml"); 465 + 466 + let profile = 467 + resolve_profile("coder", Some(project_path)).expect("Failed to resolve coder profile"); 468 + 469 + assert_eq!(profile.role, "Project-specific coder"); 470 + } 471 + 472 + #[test] 473 + fn test_resolve_profile_with_inheritance() { 474 + // P1d.AC3.5: Custom profile extends built-in, inheritance applied 475 + let tempdir = TempDir::new().expect("Failed to create tempdir"); 476 + let project_path = tempdir.path(); 477 + 478 + // Create .rustagent/profiles directory 479 + let profiles_dir = project_path.join(".rustagent").join("profiles"); 480 + fs::create_dir_all(&profiles_dir).expect("Failed to create profiles directory"); 481 + 482 + // Create custom.toml that extends built-in "coder" 483 + let custom_toml = r#" 484 + name = "custom" 485 + extends = "coder" 486 + role = "" 487 + system_prompt = "Custom project instructions" 488 + allowed_tools = [] 489 + 490 + [security] 491 + allowed_paths = ["*"] 492 + denied_paths = [] 493 + allowed_commands = ["*"] 494 + read_only = false 495 + can_create_files = true 496 + network_access = false 497 + 498 + [llm] 499 + "#; 500 + 501 + let profile_path = profiles_dir.join("custom.toml"); 502 + fs::write(&profile_path, custom_toml).expect("Failed to write custom.toml"); 503 + 504 + let profile = 505 + resolve_profile("custom", Some(project_path)).expect("Failed to resolve custom profile"); 506 + 507 + // Should inherit role from coder (since custom is empty) 508 + assert_eq!(profile.role, "Implementation specialist"); 509 + // Should have coder's tools (since custom is empty) 510 + assert!(profile.allowed_tools.contains(&"file".to_string())); 511 + assert!(profile.allowed_tools.contains(&"shell".to_string())); 512 + // Should have combined system_prompt 513 + assert!( 514 + profile 515 + .system_prompt 516 + .contains("Custom project instructions") 517 + ); 518 + } 519 + 520 + #[test] 521 + fn test_resolve_profile_cycle_detection() { 522 + // P1d.AC3.5: Cycle detection in inheritance chain 523 + let tempdir = TempDir::new().expect("Failed to create tempdir"); 524 + let project_path = tempdir.path(); 525 + 526 + // Create .rustagent/profiles directory 527 + let profiles_dir = project_path.join(".rustagent").join("profiles"); 528 + fs::create_dir_all(&profiles_dir).expect("Failed to create profiles directory"); 529 + 530 + // Create a.toml that extends b 531 + let a_toml = r#" 532 + name = "a" 533 + extends = "b" 534 + role = "A" 535 + system_prompt = "" 536 + allowed_tools = [] 537 + 538 + [security] 539 + allowed_paths = ["*"] 540 + denied_paths = [] 541 + allowed_commands = ["*"] 542 + read_only = false 543 + can_create_files = true 544 + network_access = false 545 + "#; 546 + 547 + // Create b.toml that extends a (cycle!) 548 + let b_toml = r#" 549 + name = "b" 550 + extends = "a" 551 + role = "B" 552 + system_prompt = "" 553 + allowed_tools = [] 554 + 555 + [security] 556 + allowed_paths = ["*"] 557 + denied_paths = [] 558 + allowed_commands = ["*"] 559 + read_only = false 560 + can_create_files = true 561 + network_access = false 562 + "#; 563 + 564 + fs::write(profiles_dir.join("a.toml"), a_toml).expect("Failed to write a.toml"); 565 + fs::write(profiles_dir.join("b.toml"), b_toml).expect("Failed to write b.toml"); 566 + 567 + let result = resolve_profile("a", Some(project_path)); 568 + assert!(result.is_err()); 569 + assert!(result.unwrap_err().to_string().contains("cycle")); 570 + }
+205
tests/project_test.rs
··· 1 + use rustagent::db::Database; 2 + use rustagent::project::ProjectStore; 3 + use std::path::Path; 4 + 5 + /// Test: P1a.AC3.1 - add() creates project with ra- prefixed ID 6 + #[tokio::test] 7 + async fn test_add_creates_project_with_id() { 8 + let db = Database::open_in_memory() 9 + .await 10 + .expect("failed to open in-memory database"); 11 + let store = ProjectStore::new(db); 12 + 13 + let project = store 14 + .add("my-api", Path::new("/tmp/test")) 15 + .await 16 + .expect("failed to add project"); 17 + 18 + assert!(project.id.starts_with("ra-")); 19 + assert_eq!(project.id.len(), 7); // "ra-" + 4 hex chars 20 + assert_eq!(project.name, "my-api"); 21 + assert_eq!(project.path, Path::new("/tmp/test")); 22 + } 23 + 24 + /// Test: P1a.AC3.5 - Adding duplicate project name returns error 25 + #[tokio::test] 26 + async fn test_add_duplicate_name_fails() { 27 + let db = Database::open_in_memory() 28 + .await 29 + .expect("failed to open in-memory database"); 30 + let store = ProjectStore::new(db); 31 + 32 + let _ = store 33 + .add("my-api", Path::new("/tmp/test1")) 34 + .await 35 + .expect("first add should succeed"); 36 + 37 + let result = store.add("my-api", Path::new("/tmp/test2")).await; 38 + 39 + assert!(result.is_err(), "adding duplicate name should fail"); 40 + } 41 + 42 + /// Test: P1a.AC3.2 - list() returns all projects ordered by name 43 + #[tokio::test] 44 + async fn test_list_returns_all_projects_ordered() { 45 + let db = Database::open_in_memory() 46 + .await 47 + .expect("failed to open in-memory database"); 48 + let store = ProjectStore::new(db); 49 + 50 + // Add projects in reverse name order 51 + let _ = store 52 + .add("zebra-proj", Path::new("/tmp/zebra")) 53 + .await 54 + .expect("failed to add zebra project"); 55 + let _ = store 56 + .add("alpha-proj", Path::new("/tmp/alpha")) 57 + .await 58 + .expect("failed to add alpha project"); 59 + let _ = store 60 + .add("beta-proj", Path::new("/tmp/beta")) 61 + .await 62 + .expect("failed to add beta project"); 63 + 64 + let projects = store.list().await.expect("failed to list projects"); 65 + 66 + assert_eq!(projects.len(), 3); 67 + assert_eq!(projects[0].name, "alpha-proj"); 68 + assert_eq!(projects[1].name, "beta-proj"); 69 + assert_eq!(projects[2].name, "zebra-proj"); 70 + } 71 + 72 + /// Test: P1a.AC3.3 - get_by_name() returns project details 73 + #[tokio::test] 74 + async fn test_get_by_name_returns_project() { 75 + let db = Database::open_in_memory() 76 + .await 77 + .expect("failed to open in-memory database"); 78 + let store = ProjectStore::new(db); 79 + 80 + let added = store 81 + .add("my-api", Path::new("/tmp/test")) 82 + .await 83 + .expect("failed to add project"); 84 + 85 + let retrieved = store 86 + .get_by_name("my-api") 87 + .await 88 + .expect("failed to get project") 89 + .expect("project should exist"); 90 + 91 + assert_eq!(retrieved.id, added.id); 92 + assert_eq!(retrieved.name, "my-api"); 93 + assert_eq!(retrieved.path, Path::new("/tmp/test")); 94 + } 95 + 96 + /// Test: get_by_name() returns None for non-existent project 97 + #[tokio::test] 98 + async fn test_get_by_name_not_found() { 99 + let db = Database::open_in_memory() 100 + .await 101 + .expect("failed to open in-memory database"); 102 + let store = ProjectStore::new(db); 103 + 104 + let result = store 105 + .get_by_name("nonexistent") 106 + .await 107 + .expect("query should succeed"); 108 + 109 + assert!(result.is_none()); 110 + } 111 + 112 + /// Test: P1a.AC3.4 - remove() deletes a project 113 + #[tokio::test] 114 + async fn test_remove_deletes_project() { 115 + let db = Database::open_in_memory() 116 + .await 117 + .expect("failed to open in-memory database"); 118 + let store = ProjectStore::new(db); 119 + 120 + let _ = store 121 + .add("my-api", Path::new("/tmp/test")) 122 + .await 123 + .expect("failed to add project"); 124 + 125 + // Verify it exists 126 + let before = store 127 + .get_by_name("my-api") 128 + .await 129 + .expect("query should succeed"); 130 + assert!(before.is_some()); 131 + 132 + // Remove it 133 + let deleted = store 134 + .remove("my-api") 135 + .await 136 + .expect("failed to remove project"); 137 + assert!(deleted); 138 + 139 + // Verify it's gone 140 + let after = store 141 + .get_by_name("my-api") 142 + .await 143 + .expect("query should succeed"); 144 + assert!(after.is_none()); 145 + } 146 + 147 + /// Test: remove() returns false if project doesn't exist 148 + #[tokio::test] 149 + async fn test_remove_nonexistent_returns_false() { 150 + let db = Database::open_in_memory() 151 + .await 152 + .expect("failed to open in-memory database"); 153 + let store = ProjectStore::new(db); 154 + 155 + let deleted = store 156 + .remove("nonexistent") 157 + .await 158 + .expect("remove should not fail"); 159 + 160 + assert!(!deleted); 161 + } 162 + 163 + /// Test: P1a.AC3.6 - get_by_path() resolves project by path 164 + #[tokio::test] 165 + async fn test_get_by_path_resolves_project() { 166 + let db = Database::open_in_memory() 167 + .await 168 + .expect("failed to open in-memory database"); 169 + let store = ProjectStore::new(db); 170 + 171 + let added = store 172 + .add("my-proj", Path::new("/tmp/test-proj")) 173 + .await 174 + .expect("failed to add project"); 175 + 176 + let retrieved = store 177 + .get_by_path(Path::new("/tmp/test-proj")) 178 + .await 179 + .expect("failed to get project by path") 180 + .expect("project should be found"); 181 + 182 + assert_eq!(retrieved.id, added.id); 183 + assert_eq!(retrieved.name, "my-proj"); 184 + } 185 + 186 + /// Test: get_by_path() returns None for non-matching path 187 + #[tokio::test] 188 + async fn test_get_by_path_not_found() { 189 + let db = Database::open_in_memory() 190 + .await 191 + .expect("failed to open in-memory database"); 192 + let store = ProjectStore::new(db); 193 + 194 + let _ = store 195 + .add("my-proj", Path::new("/tmp/test-proj")) 196 + .await 197 + .expect("failed to add project"); 198 + 199 + let result = store 200 + .get_by_path(Path::new("/tmp/other-proj")) 201 + .await 202 + .expect("query should succeed"); 203 + 204 + assert!(result.is_none()); 205 + }
+353
tests/session_test.rs
··· 1 + use anyhow::Result; 2 + use chrono::Utc; 3 + use rustagent::graph::session::SessionStore; 4 + use rustagent::graph::store::GraphStore; 5 + use rustagent::graph::*; 6 + use std::collections::HashMap; 7 + 8 + mod common; 9 + use common::{create_test_goal, create_test_task}; 10 + 11 + #[tokio::test] 12 + async fn test_ac1_1_create_session_returns_valid_session() -> Result<()> { 13 + // P1c.AC1.1: create_session(goal_id) creates a session record with start time and goal reference 14 + let (db, graph_store) = common::setup_test_env().await?; 15 + let session_store = SessionStore::new(db); 16 + 17 + // Create a goal first 18 + let goal = create_test_goal("ra-1234", "proj-1", "Test Goal"); 19 + graph_store.create_node(&goal).await?; 20 + 21 + let session = session_store.create_session("proj-1", "ra-1234").await?; 22 + 23 + assert!(session.id.starts_with("sess-")); 24 + assert_eq!(session.project_id, "proj-1"); 25 + assert_eq!(session.goal_id, "ra-1234"); 26 + assert!(session.started_at <= Utc::now()); 27 + assert!(session.ended_at.is_none()); 28 + assert!(session.handoff_notes.is_none()); 29 + assert_eq!(session.agent_ids, vec![] as Vec<String>); 30 + 31 + Ok(()) 32 + } 33 + 34 + #[tokio::test] 35 + async fn test_ac1_2_end_session_generates_handoff_notes() -> Result<()> { 36 + // P1c.AC1.2: end_session(session_id) generates deterministic handoff notes from graph state 37 + let (db, graph_store) = common::setup_test_env().await?; 38 + let session_store = SessionStore::new(db); 39 + 40 + // Create a goal and some tasks 41 + let goal = create_test_goal("ra-1234", "proj-1", "Test Goal"); 42 + graph_store.create_node(&goal).await?; 43 + 44 + // Create some tasks with different statuses 45 + let task1 = create_test_task("ra-1234.1", "proj-1", "Task 1", NodeStatus::Completed); 46 + let task2 = create_test_task("ra-1234.2", "proj-1", "Task 2", NodeStatus::Ready); 47 + let task3 = create_test_task("ra-1234.3", "proj-1", "Task 3", NodeStatus::Blocked); 48 + 49 + graph_store.create_node(&task1).await?; 50 + graph_store.create_node(&task2).await?; 51 + graph_store.create_node(&task3).await?; 52 + 53 + // Add edges to make them part of the goal 54 + graph_store 55 + .add_edge(&GraphEdge { 56 + id: generate_edge_id(), 57 + edge_type: EdgeType::Contains, 58 + from_node: "ra-1234".to_string(), 59 + to_node: "ra-1234.1".to_string(), 60 + label: None, 61 + created_at: Utc::now(), 62 + }) 63 + .await?; 64 + 65 + graph_store 66 + .add_edge(&GraphEdge { 67 + id: generate_edge_id(), 68 + edge_type: EdgeType::Contains, 69 + from_node: "ra-1234".to_string(), 70 + to_node: "ra-1234.2".to_string(), 71 + label: None, 72 + created_at: Utc::now(), 73 + }) 74 + .await?; 75 + 76 + graph_store 77 + .add_edge(&GraphEdge { 78 + id: generate_edge_id(), 79 + edge_type: EdgeType::Contains, 80 + from_node: "ra-1234".to_string(), 81 + to_node: "ra-1234.3".to_string(), 82 + label: None, 83 + created_at: Utc::now(), 84 + }) 85 + .await?; 86 + 87 + // Create a session 88 + let session = session_store.create_session("proj-1", "ra-1234").await?; 89 + 90 + // End the session 91 + session_store.end_session(&session.id, &graph_store).await?; 92 + 93 + // Verify the session now has handoff notes 94 + let ended_session = session_store 95 + .get_session(&session.id) 96 + .await? 97 + .expect("session should exist"); 98 + 99 + assert!(ended_session.ended_at.is_some()); 100 + assert!(ended_session.handoff_notes.is_some()); 101 + 102 + Ok(()) 103 + } 104 + 105 + #[tokio::test] 106 + async fn test_ac1_3_handoff_notes_contain_all_sections() -> Result<()> { 107 + // P1c.AC1.3: Handoff notes contain Done, Remaining, Blocked, and Decisions Made sections 108 + let (db, graph_store) = common::setup_test_env().await?; 109 + let session_store = SessionStore::new(db); 110 + 111 + // Create a goal 112 + let goal = create_test_goal("ra-5678", "proj-1", "Complex Goal"); 113 + graph_store.create_node(&goal).await?; 114 + 115 + // Create tasks with different statuses 116 + let completed_task = create_test_task( 117 + "ra-5678.1", 118 + "proj-1", 119 + "Completed Task", 120 + NodeStatus::Completed, 121 + ); 122 + let pending_task = create_test_task("ra-5678.2", "proj-1", "Pending Task", NodeStatus::Pending); 123 + let blocked_task = create_test_task("ra-5678.3", "proj-1", "Blocked Task", NodeStatus::Blocked); 124 + 125 + graph_store.create_node(&completed_task).await?; 126 + graph_store.create_node(&pending_task).await?; 127 + let mut blocked_with_reason = blocked_task; 128 + blocked_with_reason.blocked_reason = Some("Waiting for external dependency".to_string()); 129 + graph_store.create_node(&blocked_with_reason).await?; 130 + 131 + // Create a decision 132 + let decision = GraphNode { 133 + id: "ra-5678.4".to_string(), 134 + project_id: "proj-1".to_string(), 135 + node_type: NodeType::Decision, 136 + title: "Choice of Framework".to_string(), 137 + description: "Deciding which framework to use".to_string(), 138 + status: NodeStatus::Decided, 139 + priority: Some(Priority::High), 140 + assigned_to: None, 141 + created_by: None, 142 + labels: vec![], 143 + created_at: Utc::now(), 144 + started_at: None, 145 + completed_at: None, 146 + blocked_reason: None, 147 + metadata: HashMap::new(), 148 + }; 149 + graph_store.create_node(&decision).await?; 150 + 151 + // Create an option and link it as chosen 152 + let option = GraphNode { 153 + id: "ra-5678.5".to_string(), 154 + project_id: "proj-1".to_string(), 155 + node_type: NodeType::Option, 156 + title: "Use Rust".to_string(), 157 + description: "Using Rust for performance".to_string(), 158 + status: NodeStatus::Chosen, 159 + priority: None, 160 + assigned_to: None, 161 + created_by: None, 162 + labels: vec![], 163 + created_at: Utc::now(), 164 + started_at: None, 165 + completed_at: None, 166 + blocked_reason: None, 167 + metadata: HashMap::new(), 168 + }; 169 + graph_store.create_node(&option).await?; 170 + 171 + // Add all edges 172 + for (from, to) in &[ 173 + ("ra-5678", "ra-5678.1"), 174 + ("ra-5678", "ra-5678.2"), 175 + ("ra-5678", "ra-5678.3"), 176 + ("ra-5678", "ra-5678.4"), 177 + ("ra-5678.4", "ra-5678.5"), 178 + ] { 179 + let edge_type = if from.ends_with(".4") && to.ends_with(".5") { 180 + EdgeType::Chosen 181 + } else { 182 + EdgeType::Contains 183 + }; 184 + 185 + graph_store 186 + .add_edge(&GraphEdge { 187 + id: generate_edge_id(), 188 + edge_type, 189 + from_node: from.to_string(), 190 + to_node: to.to_string(), 191 + label: if edge_type == EdgeType::Chosen { 192 + Some("Best option for this use case".to_string()) 193 + } else { 194 + None 195 + }, 196 + created_at: Utc::now(), 197 + }) 198 + .await?; 199 + } 200 + 201 + // Create and end session 202 + let session = session_store.create_session("proj-1", "ra-5678").await?; 203 + 204 + session_store.end_session(&session.id, &graph_store).await?; 205 + 206 + let ended_session = session_store 207 + .get_session(&session.id) 208 + .await? 209 + .expect("session should exist"); 210 + 211 + let notes = ended_session 212 + .handoff_notes 213 + .expect("handoff_notes should be present"); 214 + 215 + // Verify all sections exist 216 + assert!(notes.contains("## Done"), "Should have 'Done' section"); 217 + assert!( 218 + notes.contains("## Remaining"), 219 + "Should have 'Remaining' section" 220 + ); 221 + assert!( 222 + notes.contains("## Blocked"), 223 + "Should have 'Blocked' section" 224 + ); 225 + assert!( 226 + notes.contains("## Decisions Made"), 227 + "Should have 'Decisions Made' section" 228 + ); 229 + 230 + // Verify content 231 + assert!( 232 + notes.contains("Completed Task"), 233 + "Done section should contain completed task" 234 + ); 235 + assert!( 236 + notes.contains("Pending Task") || notes.contains("ra-5678.2"), 237 + "Remaining section should contain pending task" 238 + ); 239 + assert!( 240 + notes.contains("Blocked Task"), 241 + "Blocked section should contain blocked task" 242 + ); 243 + assert!( 244 + notes.contains("Waiting for external dependency"), 245 + "Blocked task should show reason" 246 + ); 247 + assert!( 248 + notes.contains("Use Rust") || notes.contains("Choice of Framework"), 249 + "Decisions Made section should reference chosen option" 250 + ); 251 + 252 + Ok(()) 253 + } 254 + 255 + #[tokio::test] 256 + async fn test_ac1_4_get_latest_session_returns_most_recent() -> Result<()> { 257 + // P1c.AC1.4: get_latest_session(goal_id) returns the most recent session 258 + let (db, graph_store) = common::setup_test_env().await?; 259 + let session_store = SessionStore::new(db); 260 + 261 + // Create a goal 262 + let goal = create_test_goal("ra-9999", "proj-1", "Test Goal"); 263 + graph_store.create_node(&goal).await?; 264 + 265 + // Create two sessions for the same goal 266 + let session1 = session_store.create_session("proj-1", "ra-9999").await?; 267 + 268 + // Add a small delay to ensure different timestamps 269 + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; 270 + 271 + let session2 = session_store.create_session("proj-1", "ra-9999").await?; 272 + 273 + // Get the latest session 274 + let latest = session_store 275 + .get_latest_session("ra-9999") 276 + .await? 277 + .expect("latest session should exist"); 278 + 279 + // Should be session2 (most recent) 280 + assert_eq!(latest.id, session2.id); 281 + assert_ne!(latest.id, session1.id); 282 + 283 + // Verify session1 still exists 284 + let first = session_store 285 + .get_session(&session1.id) 286 + .await? 287 + .expect("first session should still exist"); 288 + assert_eq!(first.id, session1.id); 289 + 290 + Ok(()) 291 + } 292 + 293 + #[tokio::test] 294 + async fn test_list_sessions() -> Result<()> { 295 + let (db, graph_store) = common::setup_test_env().await?; 296 + let session_store = SessionStore::new(db); 297 + 298 + // Create a goal 299 + let goal = create_test_goal("ra-list-test", "proj-1", "Test Goal"); 300 + graph_store.create_node(&goal).await?; 301 + 302 + // Create multiple sessions for the same goal 303 + let _session1 = session_store 304 + .create_session("proj-1", "ra-list-test") 305 + .await?; 306 + 307 + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; 308 + 309 + let _session2 = session_store 310 + .create_session("proj-1", "ra-list-test") 311 + .await?; 312 + 313 + // List sessions 314 + let sessions = session_store.list_sessions("ra-list-test").await?; 315 + 316 + assert_eq!(sessions.len(), 2); 317 + // Should be in descending order by start time 318 + assert!(sessions[0].started_at >= sessions[1].started_at); 319 + 320 + Ok(()) 321 + } 322 + 323 + #[tokio::test] 324 + async fn test_handoff_notes_with_no_tasks() -> Result<()> { 325 + // Ensure handoff notes work even with no tasks 326 + let (db, graph_store) = common::setup_test_env().await?; 327 + let session_store = SessionStore::new(db); 328 + 329 + // Create a goal with no tasks 330 + let goal = create_test_goal("ra-empty", "proj-1", "Empty Goal"); 331 + graph_store.create_node(&goal).await?; 332 + 333 + let session = session_store.create_session("proj-1", "ra-empty").await?; 334 + 335 + session_store.end_session(&session.id, &graph_store).await?; 336 + 337 + let ended_session = session_store 338 + .get_session(&session.id) 339 + .await? 340 + .expect("session should exist"); 341 + 342 + let notes = ended_session 343 + .handoff_notes 344 + .expect("handoff_notes should be present"); 345 + 346 + // Should have all sections but with "(none)" entries 347 + assert!(notes.contains("## Done")); 348 + assert!(notes.contains("## Remaining")); 349 + assert!(notes.contains("## Blocked")); 350 + assert!(notes.contains("## Decisions Made")); 351 + 352 + Ok(()) 353 + }

History

1 round 0 comments
sign up or login to add to the discussion
dathagerty.com submitted #0
42 commits
expand
chore: prep for new work
chore: add rusqlite and tokio-rusqlite, remove TUI deps
refactor: remove TUI module (replaced by web UI in v2)
feat(db): database initialization with WAL mode, schema v1, and migration framework
feat(project): Project type and ProjectStore with CRUD operations
feat(cli): add project subcommand (add/list/show/remove)
fix: address code review feedback for Phase 1a
feat(graph): core types — NodeType, EdgeType, NodeStatus, GraphNode, GraphEdge
feat(graph): GraphStore trait definition
feat(graph): SqliteGraphStore with node and edge CRUD operations
fix(graph): use JOIN instead of IN subquery in recursive CTEs
feat(graph): dependency resolution, ready surfacing, next task recommendation
feat(graph): atomic task claiming and FTS5 full-text search
feat(tools): graph tools for agents — create, update, query, search, claim, decision workflow
feat(cli): tasks, decisions, status, and search commands
test(graph): concurrent task claiming atomicity
fix: address Phase 1b code review feedback - Critical, Important, Minor issues
chore: fix clippy warnings and unused imports from Phase 1b review
feat(graph): session management with deterministic handoff notes
feat(graph): ADR export to markdown
feat(graph): TOML interchange - export, import, and diff
fix: address clippy warnings in interchange.rs
feat(graph): node decay for context injection with configurable thresholds
feat(cli): sessions, graph interchange, and ADR export commands
fix: address code review feedback for Phase 1c (Sessions + Export + Interchange)
fix: address Phase 1c re-review — dry_run guard, clippy, fmt
refactor: clean up modules for v2 compatibility, add v2 tool registry factory
feat(agent): Agent trait, AgentId, AgentContext, AgentOutcome types
feat(agent): AgentProfile type with SecurityScope and inheritance
style: format code with cargo fmt
feat(llm): add token usage tracking to Response type
feat(agent): 5 built-in profiles and profile resolution chain
feat(agent): AgentRuntime with confusion counter, token budget, and failure thresholds
feat(context): ContextBuilder with compact structured format and AGENTS.md resolution
feat(cli): v2 run command with single-agent execution
fix: address all 9 Phase 1d code review issues
style: run cargo fmt for proper formatting
fix: Phase 1d re-review issues — restore test helpers, fix assertions, clean imports
docs: update project context for V2 Phase 1 architecture
fix: address 8 code review issues for Rustagent V2 Phase 1
test: add missing tests for busy_timeout pragma and v2 registry
docs: add human test plan for V2 Phase 1
no conflicts, ready to merge
expand 0 comments