Files
ai-harness/docs/01-architecture.md
darman 420f00494f 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.
2026-07-10 16:19:27 +02:00

12 KiB

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), 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 AppEvents 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

// 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 },
}
// 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,
}
// 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 }
// 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