mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-03 10:09:30 +00:00
feat(engine): Phase 2 — execution loop, capability system, thread runtime
Add the core execution engine to ironclaw_engine crate: - CapabilityRegistry: register/get/list capabilities and actions - LeaseManager: async lease lifecycle (grant, check, consume, revoke, expire) - PolicyEngine: deterministic effect-level allow/deny/approve - ThreadTree: parent-child relationship tracking - ThreadSignal/ThreadOutcome: inter-thread messaging via mpsc - ThreadManager: spawn threads as tokio tasks, stop, inject messages, join - ExecutionLoop: core loop replacing run_agentic_loop() with signals, context building, LLM calls, action execution, and event recording - Structured executor (Tier 0): lease lookup → policy check → effect execution - Tool intent nudge detection - MemoryStore + RetrievalEngine stubs for Phase 4 - Full 8-phase architecture plan in docs/plans/ - CLAUDE.md spec for the engine crate 74 tests passing, zero clippy warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
//! Thread manager — top-level orchestrator for thread lifecycle.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::capability::lease::LeaseManager;
|
||||
use crate::capability::policy::PolicyEngine;
|
||||
use crate::capability::registry::CapabilityRegistry;
|
||||
use crate::executor::ExecutionLoop;
|
||||
use crate::runtime::messaging::{self, SignalSender, ThreadOutcome, ThreadSignal};
|
||||
use crate::runtime::tree::ThreadTree;
|
||||
use crate::traits::effect::EffectExecutor;
|
||||
use crate::traits::llm::LlmBackend;
|
||||
use crate::traits::store::Store;
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::thread::{Thread, ThreadConfig, ThreadId, ThreadType};
|
||||
|
||||
/// Handle to a running thread for checking results.
|
||||
struct RunningThread {
|
||||
signal_tx: SignalSender,
|
||||
handle: tokio::task::JoinHandle<Result<ThreadOutcome, EngineError>>,
|
||||
}
|
||||
|
||||
/// Top-level orchestrator for thread lifecycle.
|
||||
///
|
||||
/// Manages thread spawning, supervision, signaling, and tree relationships.
|
||||
pub struct ThreadManager {
|
||||
llm: Arc<dyn LlmBackend>,
|
||||
effects: Arc<dyn EffectExecutor>,
|
||||
store: Arc<dyn Store>,
|
||||
pub capabilities: Arc<CapabilityRegistry>,
|
||||
pub leases: Arc<LeaseManager>,
|
||||
pub policy: Arc<PolicyEngine>,
|
||||
tree: RwLock<ThreadTree>,
|
||||
running: RwLock<HashMap<ThreadId, RunningThread>>,
|
||||
}
|
||||
|
||||
impl ThreadManager {
|
||||
pub fn new(
|
||||
llm: Arc<dyn LlmBackend>,
|
||||
effects: Arc<dyn EffectExecutor>,
|
||||
store: Arc<dyn Store>,
|
||||
capabilities: Arc<CapabilityRegistry>,
|
||||
leases: Arc<LeaseManager>,
|
||||
policy: Arc<PolicyEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
llm,
|
||||
effects,
|
||||
store,
|
||||
capabilities,
|
||||
leases,
|
||||
policy,
|
||||
tree: RwLock::new(ThreadTree::new()),
|
||||
running: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a new thread and start executing it.
|
||||
///
|
||||
/// Grants default capability leases for all registered capabilities.
|
||||
/// Returns the thread ID immediately; the thread runs in a background task.
|
||||
pub async fn spawn_thread(
|
||||
&self,
|
||||
goal: impl Into<String>,
|
||||
thread_type: ThreadType,
|
||||
project_id: ProjectId,
|
||||
config: ThreadConfig,
|
||||
parent_id: Option<ThreadId>,
|
||||
user_id: impl Into<String>,
|
||||
) -> Result<ThreadId, EngineError> {
|
||||
let mut thread = Thread::new(goal, thread_type, project_id, config);
|
||||
if let Some(pid) = parent_id {
|
||||
thread = thread.with_parent(pid);
|
||||
}
|
||||
let thread_id = thread.id;
|
||||
let user_id = user_id.into();
|
||||
|
||||
// Register in tree
|
||||
if let Some(pid) = parent_id {
|
||||
self.tree.write().await.add_child(pid, thread_id);
|
||||
}
|
||||
|
||||
// Grant leases for all registered capabilities
|
||||
for cap in self.capabilities.list() {
|
||||
let lease = self
|
||||
.leases
|
||||
.grant(thread_id, &cap.name, vec![], None, None)
|
||||
.await;
|
||||
thread.capability_leases.push(lease.id);
|
||||
}
|
||||
|
||||
// Persist
|
||||
self.store.save_thread(&thread).await?;
|
||||
|
||||
// Create signal channel
|
||||
let (tx, rx) = messaging::signal_channel(32);
|
||||
|
||||
// Build execution loop
|
||||
let llm = Arc::clone(&self.llm);
|
||||
let effects = Arc::clone(&self.effects);
|
||||
let leases = Arc::clone(&self.leases);
|
||||
let policy = Arc::clone(&self.policy);
|
||||
|
||||
let exec_loop = ExecutionLoop::new(thread, llm, effects, leases, policy, rx, user_id);
|
||||
|
||||
// Spawn background task
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut exec = exec_loop;
|
||||
let result = exec.run().await;
|
||||
debug!(thread_id = %thread_id, "thread execution finished");
|
||||
result
|
||||
});
|
||||
|
||||
self.running.write().await.insert(
|
||||
thread_id,
|
||||
RunningThread {
|
||||
signal_tx: tx,
|
||||
handle,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
/// Send a stop signal to a running thread.
|
||||
pub async fn stop_thread(&self, thread_id: ThreadId) -> Result<(), EngineError> {
|
||||
let running = self.running.read().await;
|
||||
if let Some(rt) = running.get(&thread_id) {
|
||||
let _ = rt.signal_tx.send(ThreadSignal::Stop).await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EngineError::ThreadNotFound(thread_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a user message into a running thread.
|
||||
pub async fn inject_message(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
message: ThreadMessage,
|
||||
) -> Result<(), EngineError> {
|
||||
let running = self.running.read().await;
|
||||
if let Some(rt) = running.get(&thread_id) {
|
||||
let _ = rt
|
||||
.signal_tx
|
||||
.send(ThreadSignal::InjectMessage(message))
|
||||
.await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EngineError::ThreadNotFound(thread_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a thread is still running.
|
||||
pub async fn is_running(&self, thread_id: ThreadId) -> bool {
|
||||
let running = self.running.read().await;
|
||||
running
|
||||
.get(&thread_id)
|
||||
.is_some_and(|rt| !rt.handle.is_finished())
|
||||
}
|
||||
|
||||
/// Wait for a thread to finish and return its outcome.
|
||||
/// Removes the thread from the running set.
|
||||
pub async fn join_thread(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<ThreadOutcome, EngineError> {
|
||||
let rt = {
|
||||
let mut running = self.running.write().await;
|
||||
running.remove(&thread_id)
|
||||
};
|
||||
|
||||
match rt {
|
||||
Some(rt) => match rt.handle.await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!(thread_id = %thread_id, "thread task panicked: {e}");
|
||||
Ok(ThreadOutcome::Failed {
|
||||
error: format!("thread task panicked: {e}"),
|
||||
})
|
||||
}
|
||||
},
|
||||
None => Err(EngineError::ThreadNotFound(thread_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get children of a thread.
|
||||
pub async fn children_of(&self, thread_id: ThreadId) -> Vec<ThreadId> {
|
||||
let tree = self.tree.read().await;
|
||||
tree.children_of(thread_id).to_vec()
|
||||
}
|
||||
|
||||
/// Get the parent of a thread.
|
||||
pub async fn parent_of(&self, thread_id: ThreadId) -> Option<ThreadId> {
|
||||
let tree = self.tree.read().await;
|
||||
tree.parent_of(thread_id)
|
||||
}
|
||||
|
||||
/// Clean up finished threads from the running set.
|
||||
pub async fn cleanup_finished(&self) -> Vec<ThreadId> {
|
||||
let mut running = self.running.write().await;
|
||||
let finished: Vec<ThreadId> = running
|
||||
.iter()
|
||||
.filter(|(_, rt)| rt.handle.is_finished())
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in &finished {
|
||||
running.remove(id);
|
||||
}
|
||||
finished
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::capability::{ActionDef, Capability, CapabilityLease, EffectType};
|
||||
use crate::types::event::ThreadEvent;
|
||||
use crate::types::memory::{DocId, MemoryDoc};
|
||||
use crate::types::project::Project;
|
||||
use crate::types::step::{ActionResult, LlmResponse, Step, TokenUsage};
|
||||
use crate::types::thread::ThreadState;
|
||||
use crate::traits::llm::{LlmCallConfig, LlmOutput};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Mocks ───────────────────────────────────────────────
|
||||
|
||||
struct MockLlm {
|
||||
responses: Mutex<Vec<LlmOutput>>,
|
||||
}
|
||||
|
||||
impl MockLlm {
|
||||
fn text(msg: &str) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(vec![LlmOutput {
|
||||
response: LlmResponse::Text(msg.into()),
|
||||
usage: TokenUsage::default(),
|
||||
}]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LlmBackend for MockLlm {
|
||||
async fn complete(
|
||||
&self,
|
||||
_: &[crate::types::message::ThreadMessage],
|
||||
_: &[ActionDef],
|
||||
_: &LlmCallConfig,
|
||||
) -> Result<LlmOutput, EngineError> {
|
||||
let mut r = self.responses.lock().unwrap();
|
||||
if r.is_empty() {
|
||||
Ok(LlmOutput {
|
||||
response: LlmResponse::Text("done".into()),
|
||||
usage: TokenUsage::default(),
|
||||
})
|
||||
} else {
|
||||
Ok(r.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn model_name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
}
|
||||
|
||||
struct MockEffects;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl EffectExecutor for MockEffects {
|
||||
async fn execute_action(
|
||||
&self,
|
||||
_: &str,
|
||||
_: serde_json::Value,
|
||||
_: &CapabilityLease,
|
||||
_: &crate::traits::effect::ThreadExecutionContext,
|
||||
) -> Result<ActionResult, EngineError> {
|
||||
Ok(ActionResult {
|
||||
call_id: String::new(),
|
||||
action_name: String::new(),
|
||||
output: serde_json::json!({}),
|
||||
is_error: false,
|
||||
duration: Duration::from_millis(1),
|
||||
})
|
||||
}
|
||||
|
||||
async fn available_actions(
|
||||
&self,
|
||||
_: &[CapabilityLease],
|
||||
) -> Result<Vec<ActionDef>, EngineError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
struct MockStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Store for MockStore {
|
||||
async fn save_thread(&self, _: &Thread) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_thread(&self, _: ThreadId) -> Result<Option<Thread>, EngineError> { Ok(None) }
|
||||
async fn list_threads(&self, _: ProjectId) -> Result<Vec<Thread>, EngineError> { Ok(vec![]) }
|
||||
async fn update_thread_state(&self, _: ThreadId, _: ThreadState) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn save_step(&self, _: &Step) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_steps(&self, _: ThreadId) -> Result<Vec<Step>, EngineError> { Ok(vec![]) }
|
||||
async fn append_events(&self, _: &[ThreadEvent]) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_events(&self, _: ThreadId) -> Result<Vec<ThreadEvent>, EngineError> { Ok(vec![]) }
|
||||
async fn save_project(&self, _: &Project) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_project(&self, _: ProjectId) -> Result<Option<Project>, EngineError> { Ok(None) }
|
||||
async fn save_memory_doc(&self, _: &MemoryDoc) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_memory_doc(&self, _: DocId) -> Result<Option<MemoryDoc>, EngineError> { Ok(None) }
|
||||
async fn list_memory_docs(&self, _: ProjectId) -> Result<Vec<MemoryDoc>, EngineError> { Ok(vec![]) }
|
||||
async fn save_lease(&self, _: &CapabilityLease) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_active_leases(&self, _: ThreadId) -> Result<Vec<CapabilityLease>, EngineError> { Ok(vec![]) }
|
||||
async fn revoke_lease(&self, _: crate::types::capability::LeaseId, _: &str) -> Result<(), EngineError> { Ok(()) }
|
||||
}
|
||||
|
||||
fn make_manager(llm: Arc<dyn LlmBackend>) -> ThreadManager {
|
||||
let mut caps = CapabilityRegistry::new();
|
||||
caps.register(Capability {
|
||||
name: "test".into(),
|
||||
description: "Test capability".into(),
|
||||
actions: vec![ActionDef {
|
||||
name: "test_tool".into(),
|
||||
description: "Test".into(),
|
||||
parameters_schema: serde_json::json!({}),
|
||||
effects: vec![EffectType::ReadLocal],
|
||||
requires_approval: false,
|
||||
}],
|
||||
knowledge: vec![],
|
||||
policies: vec![],
|
||||
});
|
||||
|
||||
ThreadManager::new(
|
||||
llm,
|
||||
Arc::new(MockEffects),
|
||||
Arc::new(MockStore),
|
||||
Arc::new(caps),
|
||||
Arc::new(LeaseManager::new()),
|
||||
Arc::new(PolicyEngine::new()),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_and_join() {
|
||||
let mgr = make_manager(MockLlm::text("Hello!"));
|
||||
let project = ProjectId::new();
|
||||
|
||||
let tid = mgr
|
||||
.spawn_thread("test", ThreadType::Foreground, project, ThreadConfig::default(), None, "user")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let outcome = mgr.join_thread(tid).await.unwrap();
|
||||
assert!(matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "Hello!"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_thread_works() {
|
||||
// LLM that returns many action responses
|
||||
let responses: Vec<LlmOutput> = (0..100)
|
||||
.map(|i| LlmOutput {
|
||||
response: LlmResponse::ActionCalls {
|
||||
calls: vec![crate::types::step::ActionCall {
|
||||
id: format!("c{i}"),
|
||||
action_name: "test_tool".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
}],
|
||||
content: None,
|
||||
},
|
||||
usage: TokenUsage::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mgr = make_manager(Arc::new(MockLlm {
|
||||
responses: Mutex::new(responses),
|
||||
}));
|
||||
let project = ProjectId::new();
|
||||
|
||||
let tid = mgr
|
||||
.spawn_thread("test", ThreadType::Foreground, project, ThreadConfig::default(), None, "user")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Give it a moment to start, then stop
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
mgr.stop_thread(tid).await.unwrap();
|
||||
|
||||
let outcome = mgr.join_thread(tid).await.unwrap();
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
ThreadOutcome::Stopped | ThreadOutcome::Completed { .. } | ThreadOutcome::MaxIterations
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_child_tree() {
|
||||
let mgr = make_manager(MockLlm::text("parent done"));
|
||||
let project = ProjectId::new();
|
||||
|
||||
let parent = mgr
|
||||
.spawn_thread("parent", ThreadType::Foreground, project, ThreadConfig::default(), None, "user")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let child = mgr
|
||||
.spawn_thread("child", ThreadType::Research, project, ThreadConfig::default(), Some(parent), "user")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(mgr.parent_of(child).await, Some(parent));
|
||||
assert_eq!(mgr.children_of(parent).await, vec![child]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Thread-to-thread messaging via channels.
|
||||
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// Signal sent to a running thread via its mailbox.
|
||||
#[derive(Debug)]
|
||||
pub enum ThreadSignal {
|
||||
/// Stop the thread gracefully.
|
||||
Stop,
|
||||
/// Pause execution (can be resumed later).
|
||||
Suspend,
|
||||
/// Resume a suspended thread.
|
||||
Resume,
|
||||
/// Inject a user message into the thread's context.
|
||||
InjectMessage(ThreadMessage),
|
||||
/// Notification that a child thread completed.
|
||||
ChildCompleted {
|
||||
child_id: ThreadId,
|
||||
outcome: ThreadOutcome,
|
||||
},
|
||||
}
|
||||
|
||||
/// Final outcome of a thread's execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ThreadOutcome {
|
||||
/// Completed with an optional text response.
|
||||
Completed { response: Option<String> },
|
||||
/// Thread was stopped by a signal.
|
||||
Stopped,
|
||||
/// Max iterations reached without completing.
|
||||
MaxIterations,
|
||||
/// Terminal failure.
|
||||
Failed { error: String },
|
||||
/// A capability action requires user approval before continuing.
|
||||
NeedApproval {
|
||||
action_name: String,
|
||||
call_id: String,
|
||||
parameters: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// A mailbox for sending signals to a running thread.
|
||||
///
|
||||
/// Each thread gets a `(sender, receiver)` pair. The `ThreadManager` holds
|
||||
/// the sender; the `ExecutionLoop` holds the receiver.
|
||||
pub type SignalSender = tokio::sync::mpsc::Sender<ThreadSignal>;
|
||||
pub type SignalReceiver = tokio::sync::mpsc::Receiver<ThreadSignal>;
|
||||
|
||||
/// Create a new signal channel with the given buffer size.
|
||||
pub fn signal_channel(buffer: usize) -> (SignalSender, SignalReceiver) {
|
||||
tokio::sync::mpsc::channel(buffer)
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
//! Thread lifecycle management.
|
||||
//!
|
||||
//! ThreadManager, thread tree, and inter-thread messaging.
|
||||
//! Implemented in Phase 2.
|
||||
//! - [`ThreadManager`] — top-level orchestrator for spawning and supervising threads
|
||||
//! - [`ThreadTree`] — parent-child relationship tracking
|
||||
//! - [`messaging`] — inter-thread signal channel
|
||||
|
||||
pub mod manager;
|
||||
pub mod messaging;
|
||||
pub mod tree;
|
||||
|
||||
pub use manager::ThreadManager;
|
||||
pub use messaging::ThreadOutcome;
|
||||
pub use tree::ThreadTree;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Thread tree — parent-child relationship tracking.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// Manages parent-child thread relationships.
|
||||
///
|
||||
/// Simple in-memory tree. Threads form a forest (multiple roots).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ThreadTree {
|
||||
/// child → parent
|
||||
parents: HashMap<ThreadId, ThreadId>,
|
||||
/// parent → children (ordered by insertion)
|
||||
children: HashMap<ThreadId, Vec<ThreadId>>,
|
||||
}
|
||||
|
||||
impl ThreadTree {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a parent-child relationship.
|
||||
pub fn add_child(&mut self, parent_id: ThreadId, child_id: ThreadId) {
|
||||
self.parents.insert(child_id, parent_id);
|
||||
self.children.entry(parent_id).or_default().push(child_id);
|
||||
}
|
||||
|
||||
/// Get the parent of a thread, if any.
|
||||
pub fn parent_of(&self, thread_id: ThreadId) -> Option<ThreadId> {
|
||||
self.parents.get(&thread_id).copied()
|
||||
}
|
||||
|
||||
/// Get the children of a thread.
|
||||
pub fn children_of(&self, thread_id: ThreadId) -> &[ThreadId] {
|
||||
self.children
|
||||
.get(&thread_id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Walk up the tree to collect all ancestors (parent, grandparent, ...).
|
||||
pub fn ancestors(&self, thread_id: ThreadId) -> Vec<ThreadId> {
|
||||
let mut result = Vec::new();
|
||||
let mut current = thread_id;
|
||||
while let Some(parent) = self.parents.get(¤t) {
|
||||
result.push(*parent);
|
||||
current = *parent;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove a thread from the tree. Does not remove its children.
|
||||
pub fn remove(&mut self, thread_id: ThreadId) {
|
||||
if let Some(parent) = self.parents.remove(&thread_id)
|
||||
&& let Some(siblings) = self.children.get_mut(&parent)
|
||||
{
|
||||
siblings.retain(|id| *id != thread_id);
|
||||
}
|
||||
// Orphan any children (their parent_id entries become stale)
|
||||
self.children.remove(&thread_id);
|
||||
}
|
||||
|
||||
/// Check if a thread is a root (no parent).
|
||||
pub fn is_root(&self, thread_id: ThreadId) -> bool {
|
||||
!self.parents.contains_key(&thread_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_and_query() {
|
||||
let mut tree = ThreadTree::new();
|
||||
let parent = ThreadId::new();
|
||||
let child1 = ThreadId::new();
|
||||
let child2 = ThreadId::new();
|
||||
|
||||
tree.add_child(parent, child1);
|
||||
tree.add_child(parent, child2);
|
||||
|
||||
assert_eq!(tree.parent_of(child1), Some(parent));
|
||||
assert_eq!(tree.parent_of(child2), Some(parent));
|
||||
assert_eq!(tree.children_of(parent).len(), 2);
|
||||
assert!(tree.is_root(parent));
|
||||
assert!(!tree.is_root(child1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestors_walk_up() {
|
||||
let mut tree = ThreadTree::new();
|
||||
let root = ThreadId::new();
|
||||
let mid = ThreadId::new();
|
||||
let leaf = ThreadId::new();
|
||||
|
||||
tree.add_child(root, mid);
|
||||
tree.add_child(mid, leaf);
|
||||
|
||||
let ancestors = tree.ancestors(leaf);
|
||||
assert_eq!(ancestors, vec![mid, root]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_detaches_from_parent() {
|
||||
let mut tree = ThreadTree::new();
|
||||
let parent = ThreadId::new();
|
||||
let child = ThreadId::new();
|
||||
|
||||
tree.add_child(parent, child);
|
||||
tree.remove(child);
|
||||
|
||||
assert_eq!(tree.parent_of(child), None);
|
||||
assert!(tree.children_of(parent).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_of_unknown_returns_empty() {
|
||||
let tree = ThreadTree::new();
|
||||
assert!(tree.children_of(ThreadId::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestors_of_root_is_empty() {
|
||||
let tree = ThreadTree::new();
|
||||
assert!(tree.ancestors(ThreadId::new()).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user