M0: scaffold Cargo workspace, core types, event bus, permission engine, storage actor
Sets up the Cargo workspace (harness-core, harness-tools, harness-providers, harness-mcp, harness-lsp, harness-app, harness-tui) and the ten architecture docs under docs/. harness-core gets its foundational types (session/message/part/model ids), an in-process event bus, a table-driven permission evaluate function, and a SQLite-backed storage actor with schema and roundtrip coverage. Every crate compiles empty and a bin stub prints its version.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
# 01 — Architecture
|
||||
|
||||
## Cargo workspace layout
|
||||
|
||||
```
|
||||
ai-harness/
|
||||
├── Cargo.toml # [workspace] members, workspace.dependencies, lints
|
||||
├── crates/
|
||||
│ ├── harness-core/ # domain types, event bus, permission engine, session engine,
|
||||
│ │ # agent loop, agent registry, job board, config, storage, auth.json
|
||||
│ ├── harness-providers/ # Provider impls: copilot, openai, anthropic; auth flows; models.dev
|
||||
│ ├── harness-tools/ # built-in tools: bash, read, edit, write, glob, grep, todo, webfetch, task
|
||||
│ ├── harness-mcp/ # rmcp stdio client → Tool adapters
|
||||
│ ├── harness-lsp/ # LSP client pool + diagnostics service
|
||||
│ ├── harness-app/ # composition root: builds Engine, registers providers/tools/mcp/lsp
|
||||
│ └── harness-tui/ # binary `harness`: ratatui frontend over EngineHandle + event bus
|
||||
└── assets/
|
||||
├── agents/*.md # bundled default agent definitions (embedded via include_str!)
|
||||
├── prompts/*.md # system prompt fragments (tool usage rules, environment header)
|
||||
└── models-snapshot.json # compiled fallback of models.dev/api.json
|
||||
```
|
||||
|
||||
### Boundary rationale
|
||||
|
||||
- **`harness-core` owns the vocabulary** (`Session`/`Message`/`Part`, `AppEvent`, `LlmEvent`, `Tool` trait, `Provider` trait, `Rule`/`Ruleset`) **and the engine** (run loop, stream processor, permission service, storage actor, job board). Everything depends inward on core; core depends on nothing else in the workspace. This is the crate a future `harness-server` wraps: `AppEvent` is `Serialize`, so an SSE endpoint is `bus.subscribe() → serde_json → SSE frames`, and `EngineHandle` is the future HTTP API surface (prompt, abort, permission reply, list sessions, login).
|
||||
- **Feature crates never depend on each other.** Tools reach LSP through a core trait (`DiagnosticsSource`, see [09-integrations.md](09-integrations.md)), keeping `harness-tools` decoupled from `harness-lsp`.
|
||||
- **`harness-app` is the only crate that knows about everything.** It reads config, constructs the `Engine`, registers built-ins. `harness-tui` today and `harness-server` tomorrow both depend on `harness-app` and get an identical engine.
|
||||
- **`harness-tui` contains zero business logic** — it reads `AppEvent`s and calls `EngineHandle` methods. This enforces the "TUI is a client" discipline opencode gets from its HTTP split, without the HTTP.
|
||||
|
||||
Config and storage live inside `harness-core` (modules `config`, `store`) rather than separate crates — they're consumed only by core and app; splitting creates dependency churn for no benefit at this size.
|
||||
|
||||
### `harness-core` module map
|
||||
|
||||
```
|
||||
src/
|
||||
types/ # ids.rs, session.rs, message.rs, part.rs, model.rs (ModelRef, ModelInfo, TokenUsage)
|
||||
event.rs # AppEvent + EventBus (broadcast wrapper)
|
||||
llm.rs # LlmEvent, LlmRequest, FinishReason, ProviderError, Provider trait
|
||||
tool.rs # Tool trait, ToolCtx, ToolOutput, ToolRegistry, truncation helper
|
||||
permission/ # rule.rs (evaluate), service.rs (pending asks, oneshots)
|
||||
agent/ # def.rs (AgentDef), registry.rs (bundled + override loading), frontmatter.rs
|
||||
engine/ # engine.rs (Engine, EngineHandle, RunHandle), loop.rs (outer loop),
|
||||
# processor.rs (stream state machine), retry.rs, doomloop.rs,
|
||||
# compaction.rs (seam), system.rs (system prompt assembly), jobs.rs (JobBoard)
|
||||
config/ # schema.rs (serde), load.rs (discovery + merge + interpolation), markdown.rs
|
||||
store/ # schema.sql, actor.rs (rusqlite thread + mpsc), api.rs (typed async facade)
|
||||
auth.rs # auth.json read/write, mode 0600
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Crate | Why |
|
||||
|---|---|
|
||||
| `tokio` (full) | async runtime; `sync::{broadcast, mpsc, oneshot, Mutex}`, `process`, `select!` |
|
||||
| `tokio-util` | `CancellationToken` (abort tree: session run → tool → subagent via `child_token()`) |
|
||||
| `futures`, `async-stream` | `Stream` combinators; `try_stream!` to write SSE decoders as readable generators |
|
||||
| `reqwest` (rustls-tls, stream, json, gzip) | all HTTP; `bytes_stream()` feeds SSE decoding |
|
||||
| `eventsource-stream` | SSE frame parsing over any byte stream (CRLF, multi-line `data:`, comments); mature, tiny |
|
||||
| `serde`, `serde_json` | everything; `RawValue` for pass-through tool schemas |
|
||||
| `schemars` | derive JSON Schema for tool parameter structs (single source of truth for tool inputs) |
|
||||
| `serde_yaml_ng` | YAML frontmatter of agent/command/skill markdown (`serde_yaml` proper is archived) |
|
||||
| `jsonc-parser` | JSONC config files (comments + trailing commas, like opencode) |
|
||||
| `rusqlite` (bundled) | storage; runs on a dedicated thread behind an mpsc actor — simpler than sqlx for a single-writer embedded DB |
|
||||
| `ratatui` + `crossterm` (event-stream) | TUI; `crossterm::event::EventStream` integrates with `tokio::select!` |
|
||||
| `tui-textarea` | multi-line prompt input widget (cursor, kill-ring) |
|
||||
| `pulldown-cmark` | markdown parsing for the chat renderer (we write the ratatui renderer) |
|
||||
| `rmcp` (client, transport-child-process) | official Rust MCP SDK, stdio client |
|
||||
| `lsp-types` | LSP protocol types; JSON-RPC framing is ~150 lines hand-rolled (client crates are immature) |
|
||||
| `ignore` + `globset` | glob tool (gitignore-aware walking) and permission wildcard matching |
|
||||
| `grep-searcher`, `grep-regex`, `grep-matcher` | ripgrep's engine as a library — no external `rg` binary dependency |
|
||||
| `similar` | unified diffs for edit/write permission metadata and part records |
|
||||
| `ulid` | sortable ids for sessions/messages/parts (chronological `ORDER BY id`) |
|
||||
| `thiserror` / `anyhow` | error enums in libs; `anyhow` only in binaries |
|
||||
| `tracing` + `tracing-subscriber` + `tracing-appender` | logging to file (the TUI owns the terminal) |
|
||||
| `dirs` | XDG paths (`~/.config/ai-harness`, `~/.local/share/ai-harness`, `~/.cache/ai-harness`) |
|
||||
| `htmd` (or `html2text`) | webfetch HTML → markdown |
|
||||
| `shell-words` | tokenize bash commands for permission pattern building |
|
||||
| `insta` (dev) | snapshot tests for decoders and TUI TestBackend renders |
|
||||
| `wiremock` (dev) | HTTP mocking for provider/auth tests |
|
||||
|
||||
Deliberately avoided: actor frameworks, generic middleware, `sqlx` (compile-time SQL is overkill for 5 tables). Native `async fn` in traits where object safety allows; `#[async_trait]` for `Tool`/`Provider` since `dyn` is needed.
|
||||
|
||||
## Domain model
|
||||
|
||||
```rust
|
||||
// types/ids.rs — newtypes over ULID strings
|
||||
pub struct SessionId(pub String); // "ses_01H..."
|
||||
pub struct MessageId(pub String);
|
||||
pub struct PartId(pub String);
|
||||
|
||||
// types/session.rs
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
pub parent_id: Option<SessionId>,
|
||||
pub depth: u8, // 0 = root; computed at creation from parent
|
||||
pub title: String,
|
||||
pub agent: String,
|
||||
pub model: ModelRef, // { provider_id, model_id }
|
||||
pub usage: TokenUsage, // running totals
|
||||
pub cost: f64,
|
||||
pub extra_rules: Ruleset, // session-scoped "always allow" rules
|
||||
pub created_at: i64, pub updated_at: i64,
|
||||
}
|
||||
|
||||
pub struct TokenUsage { pub input: u64, pub output: u64, pub reasoning: u64,
|
||||
pub cache_read: u64, pub cache_write: u64 }
|
||||
|
||||
// types/message.rs
|
||||
pub enum Role { User, Assistant }
|
||||
pub struct Message {
|
||||
pub id: MessageId, pub session_id: SessionId, pub role: Role,
|
||||
pub model: Option<ModelRef>, pub agent: Option<String>,
|
||||
pub usage: TokenUsage, pub cost: f64,
|
||||
pub finished: Option<FinishReason>, // None while streaming
|
||||
pub error: Option<MessageError>, // aborted | provider error | output-length
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
// types/part.rs — discriminated union, serde(tag = "type")
|
||||
pub struct Part { pub id: PartId, pub message_id: MessageId, pub session_id: SessionId,
|
||||
pub idx: u32, pub body: PartBody }
|
||||
|
||||
pub enum PartBody {
|
||||
Text { text: String, synthetic: bool }, // synthetic = injected (job board, reminders)
|
||||
Reasoning { text: String, signature: Option<String> },
|
||||
Tool { call_id: String, name: String, state: ToolState },
|
||||
StepStart,
|
||||
StepFinish{ usage: TokenUsage, cost: f64, reason: FinishReason },
|
||||
Subtask { child_session: SessionId, agent: String, description: String, background: bool },
|
||||
Compaction{ replaces_up_to: MessageId, summary: String }, // v1.5
|
||||
}
|
||||
|
||||
pub enum ToolState {
|
||||
Pending { partial_input: String }, // streaming JSON args
|
||||
Running { input: serde_json::Value, title: Option<String>, metadata: serde_json::Value },
|
||||
Completed { input: serde_json::Value, title: String, output: String,
|
||||
metadata: serde_json::Value, duration_ms: u64 },
|
||||
Error { input: serde_json::Value, error: String },
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// llm.rs — normalized provider stream
|
||||
pub enum LlmEvent {
|
||||
TextStart { id: String }, TextDelta { id: String, text: String }, TextEnd { id: String },
|
||||
ReasoningStart { id: String }, ReasoningDelta { id: String, text: String },
|
||||
ReasoningEnd { id: String, signature: Option<String> },
|
||||
ToolInputStart { call_id: String, name: String },
|
||||
ToolInputDelta { call_id: String, json: String },
|
||||
ToolCall { call_id: String, name: String, input: serde_json::Value }, // args complete
|
||||
Finish { reason: FinishReason, usage: TokenUsage },
|
||||
}
|
||||
pub enum FinishReason { Stop, ToolCalls, Length, ContentFilter, Unknown(String) }
|
||||
|
||||
pub type LlmEventStream = Pin<Box<dyn Stream<Item = Result<LlmEvent, ProviderError>> + Send>>;
|
||||
|
||||
pub enum ProviderError {
|
||||
RateLimited { retry_after: Option<Duration> },
|
||||
Overloaded, // 5xx / "overloaded_error"
|
||||
ContextOverflow, // NEVER retried
|
||||
Auth(String), // triggers token refresh path
|
||||
Http { status: u16, body: String },
|
||||
Network(String), Decode(String), Cancelled,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// tool.rs
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn description(&self) -> &str;
|
||||
fn parameters(&self) -> serde_json::Value; // JSON Schema (schemars)
|
||||
async fn execute(&self, input: serde_json::Value, ctx: ToolCtx) -> Result<ToolOutput, ToolError>;
|
||||
}
|
||||
|
||||
pub struct ToolCtx {
|
||||
pub session_id: SessionId, pub message_id: MessageId, pub call_id: String,
|
||||
pub agent: Arc<AgentDef>,
|
||||
pub cwd: PathBuf, pub worktree: PathBuf,
|
||||
pub cancel: CancellationToken, // linked to run token
|
||||
pub ask: PermissionHandle, // async ask(AskInput) -> Result<(), Denied|Rejected>
|
||||
pub metadata: MetadataSink, // set(Value) — live-updates Running ToolState + emits PartUpdated
|
||||
pub engine: EngineHandle, // needed by the task tool; cheap Arc clone
|
||||
}
|
||||
pub struct ToolOutput { pub title: String, pub output: String, pub metadata: serde_json::Value }
|
||||
```
|
||||
|
||||
```rust
|
||||
// event.rs — serializable so a future server can pipe it to SSE unchanged
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AppEvent {
|
||||
SessionCreated { session: Session }, SessionUpdated { session: Session },
|
||||
MessageCreated { message: Message }, MessageUpdated { message: Message },
|
||||
PartUpdated { part: Part }, // full-part upsert (simple, correct)
|
||||
PartDelta { part_id: PartId, message_id: MessageId, delta: String }, // cheap text streaming
|
||||
RunStarted { session_id: SessionId },
|
||||
RunFinished { session_id: SessionId, outcome: RunOutcome },
|
||||
PermissionAsked { request: PermissionRequest }, // {id, session_id, permission, patterns, metadata}
|
||||
PermissionResolved{ id: String },
|
||||
JobUpdated { job: JobRecord },
|
||||
AuthPrompt { provider: String, user_code: String, verification_uri: String },
|
||||
ServerNotice{ level: Level, text: String },
|
||||
}
|
||||
|
||||
pub struct EventBus(broadcast::Sender<AppEvent>); // capacity 1024; subscribers tolerate Lagged
|
||||
```
|
||||
|
||||
## Channel topology
|
||||
|
||||
```
|
||||
Provider SSE ──(Stream<LlmEvent>)──▶ Processor ──▶ StorageTx (mpsc<StoreCmd>, oneshot replies)
|
||||
│
|
||||
└──▶ EventBus (broadcast<AppEvent>) ──▶ TUI event loop
|
||||
└─▶ (future) SSE handler
|
||||
Tool.execute ── ctx.ask() ──▶ PermissionService.pending: HashMap<id, oneshot::Sender<Reply>>
|
||||
TUI keypress ── EngineHandle::permission_reply(id, Once|Always|Reject) ──▶ oneshot ──▶ tool resumes
|
||||
EngineHandle::abort(session) ──▶ RunHandle.cancel.cancel() ──▶ token tree (tools, subagents, HTTP)
|
||||
Background subagent: tokio::spawn(run_loop(child)) → completion → JobBoard.update → AppEvent::JobUpdated
|
||||
```
|
||||
Reference in New Issue
Block a user