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:
2026-07-10 16:19:27 +02:00
commit 420f00494f
43 changed files with 2963 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "harness-app"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
harness-providers = { workspace = true }
harness-tools = { workspace = true }
harness-mcp = { workspace = true }
harness-lsp = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// Composition root: builds the Engine and registers providers/tools/mcp/lsp. Lands in M1+.
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "harness-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
schemars = { workspace = true }
rusqlite = { workspace = true }
globset = { workspace = true }
ulid = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
[lints]
workspace = true
+151
View File
@@ -0,0 +1,151 @@
use serde::Serialize;
use tokio::sync::broadcast;
use crate::types::{Message, Part, PartId, Session, SessionId};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RunOutcome {
Stopped,
Aborted,
Errored { message: String },
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct PermissionRequest {
pub id: String,
pub session_id: SessionId,
pub permission: String,
pub patterns: Vec<String>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct JobRecordEvent(pub serde_json::Value);
/// Serializable so a future HTTP server can pipe this straight to SSE.
#[derive(Debug, 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,
},
PartDelta {
part_id: PartId,
message_id: crate::types::MessageId,
delta: String,
},
RunStarted {
session_id: SessionId,
},
RunFinished {
session_id: SessionId,
outcome: RunOutcome,
},
PermissionAsked {
request: PermissionRequest,
},
PermissionResolved {
id: String,
},
JobUpdated {
job: JobRecordEvent,
},
AuthPrompt {
provider: String,
user_code: String,
verification_uri: String,
},
ServerNotice {
level: Level,
text: String,
},
}
const EVENT_BUS_CAPACITY: usize = 1024;
/// Thin wrapper over `broadcast`; subscribers that lag just miss old events (`RecvError::Lagged`).
#[derive(Clone)]
pub struct EventBus(broadcast::Sender<AppEvent>);
impl EventBus {
pub fn new() -> Self {
let (tx, _rx) = broadcast::channel(EVENT_BUS_CAPACITY);
Self(tx)
}
pub fn publish(&self, event: AppEvent) {
// No subscribers is a normal state (e.g. headless `harness run`); ignore the error.
let _ = self.0.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
self.0.subscribe()
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn publish_reaches_subscriber() {
let bus = EventBus::new();
let mut rx = bus.subscribe();
bus.publish(AppEvent::RunStarted {
session_id: SessionId::new(),
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, AppEvent::RunStarted { .. }));
}
#[tokio::test]
async fn publish_with_no_subscribers_does_not_panic() {
let bus = EventBus::new();
bus.publish(AppEvent::RunStarted {
session_id: SessionId::new(),
});
}
#[tokio::test]
async fn multiple_subscribers_each_get_the_event() {
let bus = EventBus::new();
let mut rx1 = bus.subscribe();
let mut rx2 = bus.subscribe();
bus.publish(AppEvent::PermissionResolved { id: "abc".into() });
assert!(matches!(
rx1.recv().await.unwrap(),
AppEvent::PermissionResolved { .. }
));
assert!(matches!(
rx2.recv().await.unwrap(),
AppEvent::PermissionResolved { .. }
));
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod event;
pub mod llm;
pub mod permission;
pub mod store;
pub mod types;
pub use event::{AppEvent, EventBus};
+12
View File
@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
// Full LlmEvent/LlmRequest/Provider trait land in M1 alongside harness-providers.
// FinishReason lives here (not types/) because it's part of the provider-facing vocabulary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FinishReason {
Stop,
ToolCalls,
Length,
ContentFilter,
Unknown(String),
}
@@ -0,0 +1,3 @@
pub mod rule;
pub use rule::{evaluate, Action, Rule, Ruleset};
+115
View File
@@ -0,0 +1,115 @@
use globset::GlobBuilder;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub permission: String,
pub pattern: String,
pub action: Action,
}
pub type Ruleset = Vec<Rule>;
/// Wildcard match, `*` crosses separators (opencode/globset `literal_separator(false)`).
fn glob_match(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
GlobBuilder::new(pattern)
.literal_separator(false)
.build()
.map(|g| g.compile_matcher().is_match(value))
.unwrap_or(false)
}
/// Flatten `stack` (lowest to highest precedence) and return the action of the *last*
/// rule whose `permission` and `pattern` both wildcard-match. Defaults to `Ask`.
pub fn evaluate(stack: &[&Ruleset], permission: &str, pattern: &str) -> Action {
let mut result = Action::Ask;
for ruleset in stack {
for rule in ruleset.iter() {
if glob_match(&rule.permission, permission) && glob_match(&rule.pattern, pattern) {
result = rule.action;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(permission: &str, pattern: &str, action: Action) -> Rule {
Rule {
permission: permission.to_string(),
pattern: pattern.to_string(),
action,
}
}
#[test]
fn defaults_to_ask_when_nothing_matches() {
let rules: Ruleset = vec![rule("bash", "ls*", Action::Allow)];
assert_eq!(evaluate(&[&rules], "bash", "rm -rf /"), Action::Ask);
}
#[test]
fn exact_match_wins() {
let rules: Ruleset = vec![rule("edit", "*.lock", Action::Deny)];
assert_eq!(evaluate(&[&rules], "edit", "Cargo.lock"), Action::Deny);
assert_eq!(evaluate(&[&rules], "edit", "main.rs"), Action::Ask);
}
#[test]
fn wildcard_crosses_path_separators() {
let rules: Ruleset = vec![rule("read", "src/*", Action::Allow)];
assert_eq!(evaluate(&[&rules], "read", "src/a/b/c.rs"), Action::Allow);
}
#[test]
fn last_match_within_a_ruleset_wins() {
let rules: Ruleset = vec![
rule("bash", "git *", Action::Allow),
rule("bash", "git push*", Action::Deny),
];
assert_eq!(evaluate(&[&rules], "bash", "git status"), Action::Allow);
assert_eq!(evaluate(&[&rules], "bash", "git push origin"), Action::Deny);
}
#[test]
fn later_ruleset_in_the_stack_overrides_earlier_ones() {
let config: Ruleset = vec![rule("edit", "*", Action::Ask)];
let agent: Ruleset = vec![rule("edit", "*", Action::Deny)];
let session: Ruleset = vec![rule("edit", "main.rs", Action::Allow)];
// config < agent < session precedence, matching engine's [config, agent, session] stack.
assert_eq!(
evaluate(&[&config, &agent, &session], "edit", "main.rs"),
Action::Allow
);
assert_eq!(
evaluate(&[&config, &agent, &session], "edit", "other.rs"),
Action::Deny
);
}
#[test]
fn permission_field_itself_can_wildcard_match() {
let rules: Ruleset = vec![rule("mcp", "*", Action::Ask)];
assert_eq!(evaluate(&[&rules], "mcp", "context7_search"), Action::Ask);
}
#[test]
fn empty_stack_defaults_to_ask() {
assert_eq!(evaluate(&[], "bash", "ls"), Action::Ask);
}
}
+130
View File
@@ -0,0 +1,130 @@
use rusqlite::{params, Connection};
use tokio::sync::oneshot;
use crate::types::{Message, MessageId, Part, Session, SessionId};
use super::api::StoreError;
const SCHEMA: &str = include_str!("schema.sql");
type Reply<T> = oneshot::Sender<Result<T, StoreError>>;
pub enum StoreCmd {
UpsertSession(Session, Reply<()>),
UpsertMessage(Message, Reply<()>),
UpsertPart(Part, Reply<()>),
Sessions(Reply<Vec<Session>>),
Messages(SessionId, Reply<Vec<Message>>),
Parts(MessageId, Reply<Vec<Part>>),
}
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA)
}
fn upsert_session(conn: &Connection, session: &Session) -> Result<(), StoreError> {
let data = serde_json::to_string(session)?;
conn.execute(
"INSERT INTO session (id, parent_id, created_at, updated_at, data) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET parent_id = ?2, updated_at = ?4, data = ?5",
params![
session.id.as_ref(),
session.parent_id.as_ref().map(|p| p.as_ref().to_string()),
session.created_at,
session.updated_at,
data
],
)?;
Ok(())
}
fn upsert_message(conn: &Connection, message: &Message) -> Result<(), StoreError> {
let data = serde_json::to_string(message)?;
conn.execute(
"INSERT INTO message (id, session_id, created_at, data) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET data = ?4",
params![
message.id.as_ref(),
message.session_id.as_ref(),
message.created_at,
data
],
)?;
Ok(())
}
fn upsert_part(conn: &Connection, part: &Part) -> Result<(), StoreError> {
let data = serde_json::to_string(part)?;
conn.execute(
"INSERT INTO part (id, message_id, session_id, idx, data) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET data = ?5",
params![
part.id.as_ref(),
part.message_id.as_ref(),
part.session_id.as_ref(),
part.idx,
data
],
)?;
Ok(())
}
fn list_sessions(conn: &Connection) -> Result<Vec<Session>, StoreError> {
let mut stmt = conn.prepare("SELECT data FROM session ORDER BY id")?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
rows.iter()
.map(|data| serde_json::from_str(data).map_err(StoreError::from))
.collect()
}
fn list_messages(conn: &Connection, session_id: &SessionId) -> Result<Vec<Message>, StoreError> {
let mut stmt = conn.prepare("SELECT data FROM message WHERE session_id = ?1 ORDER BY id")?;
let rows = stmt
.query_map(params![session_id.as_ref()], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
rows.iter()
.map(|data| serde_json::from_str(data).map_err(StoreError::from))
.collect()
}
fn list_parts(conn: &Connection, message_id: &MessageId) -> Result<Vec<Part>, StoreError> {
let mut stmt = conn.prepare("SELECT data FROM part WHERE message_id = ?1 ORDER BY idx")?;
let rows = stmt
.query_map(params![message_id.as_ref()], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
rows.iter()
.map(|data| serde_json::from_str(data).map_err(StoreError::from))
.collect()
}
/// Runs on a dedicated OS thread; the async facade in `api.rs` talks to it over `mpsc`.
pub fn run(conn: Connection, mut rx: tokio::sync::mpsc::Receiver<StoreCmd>) {
if let Err(e) = init_schema(&conn) {
tracing::error!("store: failed to initialize schema: {e}");
return;
}
while let Some(cmd) = rx.blocking_recv() {
match cmd {
StoreCmd::UpsertSession(session, reply) => {
let _ = reply.send(upsert_session(&conn, &session));
}
StoreCmd::UpsertMessage(message, reply) => {
let _ = reply.send(upsert_message(&conn, &message));
}
StoreCmd::UpsertPart(part, reply) => {
let _ = reply.send(upsert_part(&conn, &part));
}
StoreCmd::Sessions(reply) => {
let _ = reply.send(list_sessions(&conn));
}
StoreCmd::Messages(session_id, reply) => {
let _ = reply.send(list_messages(&conn, &session_id));
}
StoreCmd::Parts(message_id, reply) => {
let _ = reply.send(list_parts(&conn, &message_id));
}
}
}
}
+216
View File
@@ -0,0 +1,216 @@
use std::path::Path;
use rusqlite::Connection;
use tokio::sync::{mpsc, oneshot};
use crate::types::{Message, MessageId, Part, Session, SessionId};
use super::actor::{self, StoreCmd};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("serde: {0}")]
Serde(#[from] serde_json::Error),
#[error("store actor is not running")]
ActorGone,
}
const COMMAND_CHANNEL_CAPACITY: usize = 256;
/// Typed async facade over the storage actor thread (single writer, embedded db).
#[derive(Clone)]
pub struct Store {
tx: mpsc::Sender<StoreCmd>,
}
impl Store {
fn spawn(conn: Connection) -> Self {
let (tx, rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
std::thread::spawn(move || actor::run(conn, rx));
Self { tx }
}
pub fn open(path: &Path) -> Result<Self, StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
StoreError::Sqlite(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN),
Some(e.to_string()),
))
})?;
}
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
Ok(Self::spawn(conn))
}
pub fn open_in_memory() -> Result<Self, StoreError> {
let conn = Connection::open_in_memory()?;
Ok(Self::spawn(conn))
}
async fn call<T: Send + 'static>(
&self,
make_cmd: impl FnOnce(oneshot::Sender<Result<T, StoreError>>) -> StoreCmd,
) -> Result<T, StoreError> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(make_cmd(reply_tx))
.await
.map_err(|_| StoreError::ActorGone)?;
reply_rx.await.map_err(|_| StoreError::ActorGone)?
}
pub async fn upsert_session(&self, session: Session) -> Result<(), StoreError> {
self.call(|reply| StoreCmd::UpsertSession(session, reply))
.await
}
pub async fn upsert_message(&self, message: Message) -> Result<(), StoreError> {
self.call(|reply| StoreCmd::UpsertMessage(message, reply))
.await
}
pub async fn upsert_part(&self, part: Part) -> Result<(), StoreError> {
self.call(|reply| StoreCmd::UpsertPart(part, reply)).await
}
pub async fn sessions(&self) -> Result<Vec<Session>, StoreError> {
self.call(StoreCmd::Sessions).await
}
pub async fn messages(&self, session_id: SessionId) -> Result<Vec<Message>, StoreError> {
self.call(|reply| StoreCmd::Messages(session_id, reply))
.await
}
pub async fn parts(&self, message_id: MessageId) -> Result<Vec<Part>, StoreError> {
self.call(|reply| StoreCmd::Parts(message_id, reply)).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ModelRef, PartBody, Role};
#[tokio::test]
async fn session_roundtrips() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let id = session.id.clone();
store.upsert_session(session.clone()).await.unwrap();
let sessions = store.sessions().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, id);
assert_eq!(sessions[0].agent, "orchestrator");
}
#[tokio::test]
async fn session_upsert_overwrites_by_id() {
let store = Store::open_in_memory().unwrap();
let mut session =
Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
store.upsert_session(session.clone()).await.unwrap();
session.title = "renamed".into();
session.updated_at = 2;
store.upsert_session(session.clone()).await.unwrap();
let sessions = store.sessions().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].title, "renamed");
}
#[tokio::test]
async fn message_roundtrips_and_filters_by_session() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let other_session = Session::new_root("explorer", ModelRef::new("anthropic", "claude"), 1);
let msg1 = Message::new_user(session.id.clone(), 1);
let msg2 = Message::new_user(other_session.id.clone(), 2);
store.upsert_message(msg1.clone()).await.unwrap();
store.upsert_message(msg2).await.unwrap();
let messages = store.messages(session.id.clone()).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, msg1.id);
assert_eq!(messages[0].role, Role::User);
}
#[tokio::test]
async fn part_roundtrips_and_orders_by_idx() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let message = Message::new_user(session.id.clone(), 1);
let part_b = Part {
id: crate::types::PartId::new(),
message_id: message.id.clone(),
session_id: session.id.clone(),
idx: 1,
body: PartBody::Text {
text: "second".into(),
synthetic: false,
},
};
let part_a = Part {
id: crate::types::PartId::new(),
message_id: message.id.clone(),
session_id: session.id.clone(),
idx: 0,
body: PartBody::Text {
text: "first".into(),
synthetic: false,
},
};
store.upsert_part(part_b).await.unwrap();
store.upsert_part(part_a).await.unwrap();
let parts = store.parts(message.id.clone()).await.unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].idx, 0);
assert_eq!(parts[1].idx, 1);
}
#[tokio::test]
async fn tool_state_variants_roundtrip_through_json() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let message = Message::new_user(session.id.clone(), 1);
let part = Part {
id: crate::types::PartId::new(),
message_id: message.id.clone(),
session_id: session.id.clone(),
idx: 0,
body: PartBody::Tool {
call_id: "call_1".into(),
name: "bash".into(),
state: crate::types::ToolState::Completed {
input: serde_json::json!({"command": "ls"}),
title: "ls".into(),
output: "a.txt".into(),
metadata: serde_json::json!({}),
duration_ms: 12,
},
},
};
let part_id = part.id.clone();
store.upsert_part(part).await.unwrap();
let parts = store.parts(message.id.clone()).await.unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].id, part_id);
match &parts[0].body {
PartBody::Tool { name, state, .. } => {
assert_eq!(name, "bash");
assert!(matches!(state, crate::types::ToolState::Completed { .. }));
}
_ => panic!("expected Tool part"),
}
}
}
+4
View File
@@ -0,0 +1,4 @@
mod actor;
mod api;
pub use api::{Store, StoreError};
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
parent_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
idx INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job (
task_id TEXT PRIMARY KEY,
parent_session_id TEXT NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE INDEX IF NOT EXISTS message_session ON message(session_id);
CREATE INDEX IF NOT EXISTS part_message ON part(message_id);
CREATE INDEX IF NOT EXISTS session_parent ON session(parent_id);
+66
View File
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use ulid::Ulid;
macro_rules! id_newtype {
($name:ident, $prefix:literal) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub String);
impl $name {
pub fn new() -> Self {
Self(format!("{}_{}", $prefix, Ulid::new()))
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
Self(s)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
};
}
id_newtype!(SessionId, "ses");
id_newtype!(MessageId, "msg");
id_newtype!(PartId, "prt");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_prefixed_and_unique() {
let a = SessionId::new();
let b = SessionId::new();
assert!(a.0.starts_with("ses_"));
assert_ne!(a, b);
}
#[test]
fn ids_sort_chronologically() {
let a = MessageId::new();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = MessageId::new();
assert!(a < b);
}
}
+69
View File
@@ -0,0 +1,69 @@
use serde::{Deserialize, Serialize};
use super::ids::{MessageId, SessionId};
use super::model::{ModelRef, TokenUsage};
use crate::llm::FinishReason;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageError {
Aborted,
Provider(String),
OutputLength,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
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>,
pub error: Option<MessageError>,
pub created_at: i64,
}
impl Message {
pub fn new_user(session_id: SessionId, now: i64) -> Self {
Self {
id: MessageId::new(),
session_id,
role: Role::User,
model: None,
agent: None,
usage: TokenUsage::default(),
cost: 0.0,
finished: None,
error: None,
created_at: now,
}
}
pub fn new_assistant(
session_id: SessionId,
model: ModelRef,
agent: impl Into<String>,
now: i64,
) -> Self {
Self {
id: MessageId::new(),
session_id,
role: Role::Assistant,
model: Some(model),
agent: Some(agent.into()),
usage: TokenUsage::default(),
cost: 0.0,
finished: None,
error: None,
created_at: now,
}
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod ids;
pub mod message;
pub mod model;
pub mod part;
pub mod session;
pub use ids::{MessageId, PartId, SessionId};
pub use message::{Message, MessageError, Role};
pub use model::{ModelInfo, ModelRef, TokenUsage};
pub use part::{Part, PartBody, ToolState};
pub use session::Session;
+44
View File
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelRef {
pub provider_id: String,
pub model_id: String,
}
impl ModelRef {
pub fn new(provider_id: impl Into<String>, model_id: impl Into<String>) -> Self {
Self {
provider_id: provider_id.into(),
model_id: model_id.into(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input: u64,
pub output: u64,
pub reasoning: u64,
pub cache_read: u64,
pub cache_write: u64,
}
impl TokenUsage {
pub fn add(&mut self, other: &TokenUsage) {
self.input += other.input;
self.output += other.output;
self.reasoning += other.reasoning;
self.cache_read += other.cache_read;
self.cache_write += other.cache_write;
}
}
// Full cost/context-limit metadata is populated by harness-providers (models.dev) in M1/M3;
// this placeholder only carries what harness-core needs to key on.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelInfo {
pub model: ModelRef,
pub context_limit: u64,
pub output_limit: u64,
}
+72
View File
@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use super::ids::{MessageId, PartId, SessionId};
use super::model::TokenUsage;
use crate::llm::FinishReason;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Part {
pub id: PartId,
pub message_id: MessageId,
pub session_id: SessionId,
pub idx: u32,
pub body: PartBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PartBody {
Text {
text: String,
synthetic: bool,
},
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,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ToolState {
Pending {
partial_input: String,
},
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,
},
}
+54
View File
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use super::ids::SessionId;
use super::model::{ModelRef, TokenUsage};
use crate::permission::rule::Ruleset;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
pub parent_id: Option<SessionId>,
pub depth: u8,
pub title: String,
pub agent: String,
pub model: ModelRef,
pub usage: TokenUsage,
pub cost: f64,
pub extra_rules: Ruleset,
pub created_at: i64,
pub updated_at: i64,
}
impl Session {
pub fn new_root(agent: impl Into<String>, model: ModelRef, now: i64) -> Self {
Self {
id: SessionId::new(),
parent_id: None,
depth: 0,
title: String::new(),
agent: agent.into(),
model,
usage: TokenUsage::default(),
cost: 0.0,
extra_rules: Vec::new(),
created_at: now,
updated_at: now,
}
}
pub fn new_child(parent: &Session, agent: impl Into<String>, now: i64) -> Self {
Self {
id: SessionId::new(),
parent_id: Some(parent.id.clone()),
depth: parent.depth + 1,
title: String::new(),
agent: agent.into(),
model: parent.model.clone(),
usage: TokenUsage::default(),
cost: 0.0,
extra_rules: Vec::new(),
created_at: now,
updated_at: now,
}
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-lsp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// LSP client pool + diagnostics service land here in M5.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// MCP stdio client → Tool adapters land here in M5.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-providers"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// Provider implementations (Copilot, OpenAI, Anthropic) land here in M1/M3.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-tools"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// Built-in tools (bash, read, edit, write, glob, grep, todo, webfetch, task) land here in M1/M4.
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "harness-tui"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "harness"
path = "src/main.rs"
[dependencies]
harness-app = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("harness {}", env!("CARGO_PKG_VERSION"));
}