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,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
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod event;
|
||||
pub mod llm;
|
||||
pub mod permission;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
pub use event::{AppEvent, EventBus};
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod actor;
|
||||
mod api;
|
||||
|
||||
pub use api::{Store, StoreError};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user