From cc92cf681c898c9a61ee58161fc720d4c6c4a926 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:12:37 +0200 Subject: [PATCH] Auto deployments --- src/agents.rs | 85 +++++++++++++++++++++++- src/ai_response.rs | 14 ++++ src/conclusion.rs | 46 +++++++++++++ src/enums.rs | 3 + src/lib.rs | 9 ++- src/sync.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++ src/todo.rs | 97 ++++++++++++++++++++++++++- src/tools.rs | 77 +++++++++++++++++++++- 8 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 src/conclusion.rs create mode 100644 src/sync.rs diff --git a/src/agents.rs b/src/agents.rs index 0d9f42c..b4474df 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum AgentType { Planner, @@ -39,3 +39,86 @@ pub enum ApprovalMode { Batch, PerTool, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlannedAction { + pub tool_name: String, + pub arguments: serde_json::Value, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulationResult { + pub planned_actions: Vec, + pub estimated_tokens: u32, + pub estimated_duration: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentPolicy { + pub agent_type: AgentType, + pub auto_approve: bool, + pub max_depth: u32, + pub require_preview: bool, + pub timeout_seconds: u32, + pub max_tokens: u32, + pub allowed_tools: Vec, + pub forbidden_mutations: Vec, +} + +impl AgentPolicy { + pub fn defaults_for(agent_type: &AgentType) -> Self { + match agent_type { + AgentType::DocAgent => Self { + agent_type: agent_type.clone(), + auto_approve: true, + max_depth: 2, + require_preview: false, + timeout_seconds: 30, + max_tokens: 4096, + allowed_tools: vec!["read_file".into(), "write_file".into(), "edit_file".into()], + forbidden_mutations: vec!["Cargo.toml".into(), "package.json".into()], + }, + AgentType::TestJudge => Self { + agent_type: agent_type.clone(), + auto_approve: true, + max_depth: 3, + require_preview: true, + timeout_seconds: 60, + max_tokens: 4096, + allowed_tools: vec![], + forbidden_mutations: vec![], + }, + AgentType::BuildAgent => Self { + agent_type: agent_type.clone(), + auto_approve: false, + max_depth: 5, + require_preview: false, + timeout_seconds: 300, + max_tokens: 16384, + allowed_tools: vec![], + forbidden_mutations: vec!["Cargo.toml".into(), "package.json".into()], + }, + AgentType::Planner => Self { + agent_type: agent_type.clone(), + auto_approve: false, + max_depth: 3, + require_preview: true, + timeout_seconds: 120, + max_tokens: 16384, + allowed_tools: vec![], + forbidden_mutations: vec![], + }, + AgentType::ChatAgent => Self { + agent_type: agent_type.clone(), + auto_approve: true, + max_depth: 1, + require_preview: false, + timeout_seconds: 60, + max_tokens: 4096, + allowed_tools: vec![], + forbidden_mutations: vec![], + }, + } + } +} diff --git a/src/ai_response.rs b/src/ai_response.rs index 55889e2..95bdb93 100644 --- a/src/ai_response.rs +++ b/src/ai_response.rs @@ -25,6 +25,7 @@ pub struct StreamEvent { #[serde(rename = "type")] pub event_type: String, pub content: Option, + pub thinking: Option, pub error: Option, pub message_id: Option, } @@ -34,6 +35,17 @@ impl StreamEvent { Self { event_type: "content".to_string(), content: Some(content), + thinking: None, + error: None, + message_id: None, + } + } + + pub fn thinking(thinking: String) -> Self { + Self { + event_type: "thinking".to_string(), + content: None, + thinking: Some(thinking), error: None, message_id: None, } @@ -43,6 +55,7 @@ impl StreamEvent { Self { event_type: "error".to_string(), content: None, + thinking: None, error: Some(error), message_id: None, } @@ -52,6 +65,7 @@ impl StreamEvent { Self { event_type: "done".to_string(), content: None, + thinking: None, error: None, message_id: Some(message_id), } diff --git a/src/conclusion.rs b/src/conclusion.rs new file mode 100644 index 0000000..0dfb974 --- /dev/null +++ b/src/conclusion.rs @@ -0,0 +1,46 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConclusionCard { + pub id: Uuid, + pub project_id: Uuid, + pub title: String, // e.g., "Auth Module Implementation" + pub summary: String, // LLM-generated or template + pub child_run_ids: Vec, + pub artifacts: Vec, // files, docs, test results + pub metrics: ConclusionMetrics, // tokens, duration, agent counts + pub created_at: DateTime, + pub trigger: ConclusionTrigger, // AutoChainComplete | UserMilestone | BoardFinalize | ManualMerge +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Artifact { + pub name: String, + pub path: String, + pub artifact_type: ArtifactType, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ArtifactType { + File, + Documentation, + TestResult, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConclusionMetrics { + pub total_tokens: u32, + pub duration_seconds: u64, + pub agent_count: u32, + pub tool_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ConclusionTrigger { + AutoChainComplete, + UserMilestone, + BoardFinalize, + ManualMerge, +} diff --git a/src/enums.rs b/src/enums.rs index f1d9d3f..750892a 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -11,6 +11,7 @@ pub enum ToDoStatus { Delegated, Failed, PendingApproval, + Draft, } impl Default for ToDoStatus { @@ -29,6 +30,7 @@ impl fmt::Display for ToDoStatus { ToDoStatus::ReadyForAgent => write!(f, "ready_for_agent"), ToDoStatus::Delegated => write!(f, "delegated"), ToDoStatus::Failed => write!(f, "failed"), + ToDoStatus::Draft => write!(f, "draft"), ToDoStatus::PendingApproval => write!(f, "pending_approval"), } } @@ -45,6 +47,7 @@ impl std::str::FromStr for ToDoStatus { "ready_for_agent" => Ok(ToDoStatus::ReadyForAgent), "delegated" => Ok(ToDoStatus::Delegated), "failed" => Ok(ToDoStatus::Failed), + "draft" => Ok(ToDoStatus::Draft), "pending_approval" => Ok(ToDoStatus::PendingApproval), _ => Err(format!("unknown status: {}", s)), } diff --git a/src/lib.rs b/src/lib.rs index a58a4ed..1f79bb0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,24 +4,29 @@ pub use uuid::Uuid; pub mod agents; pub mod ai_response; +pub mod conclusion; pub mod coral; pub mod enums; pub mod errors; pub mod krill; pub mod project; +pub mod sync; pub mod task; pub mod todo; pub mod tools; -pub use agents::{AgentType, ApprovalMode}; +pub use agents::{AgentPolicy, AgentType, ApprovalMode, PlannedAction, SimulationResult}; pub use ai_response::{Conversation, Message}; +pub use conclusion::{ + Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger, +}; pub use coral::{Coral, CoralId}; pub use enums::*; pub use errors::{ShoalError, ValidationError}; pub use krill::{KrillConfig, KrillDescriptor, KrillId}; pub use project::{Project, ProjectFile, ProjectId, ProjectSettings}; pub use task::{Task, TaskResult}; -pub use todo::ToDo; +pub use todo::{Dependency, ToDo}; pub use tools::{ format_tool_error, format_tool_result, parse_tool_call_blocks, parse_tool_call_stream, tool_definitions, CompositionStep, ExecutionContext, MarketplaceSearchResults, ParsedToolCall, diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..08879bb --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,159 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ConflictResolution { + Required, + ResolvedLocal, + ResolvedRemote, + ResolvedManual, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileConflict { + pub file: String, + pub base_hash: String, + pub local_hash: String, + pub remote_hash: String, + pub resolution: ConflictResolution, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConflictManifest { + pub conflicts: Vec, + pub unconflicted_remote: Vec, + pub unconflicted_local: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrunkVersionSummary { + pub version_id: String, + pub parent_version: Option, + pub snapshot_hash: String, + pub merged_at: String, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TopicState { + Working, + AwaitingReview, + Approved, + Merged, + Rejected, + Conflict, +} + +impl TopicState { + pub fn as_str(&self) -> &'static str { + match self { + TopicState::Working => "working", + TopicState::AwaitingReview => "awaiting_review", + TopicState::Approved => "approved", + TopicState::Merged => "merged", + TopicState::Rejected => "rejected", + TopicState::Conflict => "conflict", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "working" => Some(TopicState::Working), + "awaiting_review" => Some(TopicState::AwaitingReview), + "approved" => Some(TopicState::Approved), + "merged" => Some(TopicState::Merged), + "rejected" => Some(TopicState::Rejected), + "conflict" => Some(TopicState::Conflict), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopicSummary { + pub topic_id: String, + pub parent_topic_id: Option, + pub root_task_id: String, + pub project_id: String, + pub state: TopicState, + pub trunk_version_before: Option, + pub change_set_hash: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopicChange { + pub change_id: String, + pub topic_id: String, + pub task_id: String, + pub parent_task_id: Option, + pub file_path: String, + pub operation: TopicChangeOperation, + pub blob_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TopicChangeOperation { + Create, + Modify, + Delete, +} + +impl TopicChangeOperation { + pub fn as_str(&self) -> &'static str { + match self { + TopicChangeOperation::Create => "create", + TopicChangeOperation::Modify => "modify", + TopicChangeOperation::Delete => "delete", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "create" => Some(TopicChangeOperation::Create), + "modify" => Some(TopicChangeOperation::Modify), + "delete" => Some(TopicChangeOperation::Delete), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MergeQueueStatus { + Queued, + Merging, + Conflict, + Completed, + Deferred, +} + +impl MergeQueueStatus { + pub fn as_str(&self) -> &'static str { + match self { + MergeQueueStatus::Queued => "queued", + MergeQueueStatus::Merging => "merging", + MergeQueueStatus::Conflict => "conflict", + MergeQueueStatus::Completed => "completed", + MergeQueueStatus::Deferred => "deferred", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "queued" => Some(MergeQueueStatus::Queued), + "merging" => Some(MergeQueueStatus::Merging), + "conflict" => Some(MergeQueueStatus::Conflict), + "completed" => Some(MergeQueueStatus::Completed), + "deferred" => Some(MergeQueueStatus::Deferred), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeQueueEntry { + pub request_id: String, + pub topic_id: Option, + pub dock_session_id: Option, + pub status: MergeQueueStatus, + pub created_at: String, +} diff --git a/src/todo.rs b/src/todo.rs index 80926c1..bb1a6be 100644 --- a/src/todo.rs +++ b/src/todo.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use crate::enums::{ToDoSource, ToDoStatus}; use crate::errors::ValidationError; use crate::{CommunicationType, CommunicationValue, DataTypes, DataValue}; +use std::collections::HashSet; const MAX_TITLE_LENGTH: usize = 500; const MAX_DESCRIPTION_LENGTH: usize = 10000; @@ -12,14 +13,22 @@ const MIN_PRIORITY: u32 = 1; const MAX_PRIORITY: u32 = 1000; const DEFAULT_PRIORITY: u32 = 100; +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Dependency { + pub todo_id: Uuid, + pub board_id: Uuid, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ToDo { pub id: Uuid, + pub board_id: Uuid, pub title: String, pub description: String, pub status: ToDoStatus, pub priority: u32, pub depends_on: Vec, + pub cross_board_depends_on: Vec, pub created_at: DateTime, pub updated_at: DateTime, pub created_by: Uuid, @@ -30,6 +39,7 @@ pub struct ToDo { pub source: ToDoSource, pub automation_chain_id: Option, pub tags: Vec, + pub estimated_tokens: u32, } impl Default for ToDo { @@ -37,11 +47,13 @@ impl Default for ToDo { let now = Utc::now(); Self { id: Uuid::new_v4(), + board_id: Uuid::nil(), title: String::new(), description: String::new(), status: ToDoStatus::default(), priority: DEFAULT_PRIORITY, depends_on: Vec::new(), + cross_board_depends_on: Vec::new(), created_at: now, updated_at: now, created_by: Uuid::nil(), @@ -52,6 +64,7 @@ impl Default for ToDo { source: ToDoSource::User, automation_chain_id: None, tags: Vec::new(), + estimated_tokens: 0, } } } @@ -61,11 +74,13 @@ impl ToDo { let now = Utc::now(); Self { id: Uuid::new_v4(), + board_id: Uuid::nil(), title: title.trim().to_string(), description, status: ToDoStatus::default(), priority: DEFAULT_PRIORITY, depends_on: Vec::new(), + cross_board_depends_on: Vec::new(), created_at: now, updated_at: now, created_by, @@ -76,6 +91,7 @@ impl ToDo { source: ToDoSource::User, automation_chain_id: None, tags: Vec::new(), + estimated_tokens: 0, } } @@ -109,12 +125,56 @@ impl ToDo { } fn has_circular_dependency(&self) -> bool { - if self.depends_on.is_empty() { - return false; + // Direct self-dependency + if self.depends_on.contains(&self.id) { + return true; } false } + /// Performs full graph cycle detection given a map of todo_id -> dependencies. + /// Returns the ID of the first todo that would participate in a cycle, or None. + pub fn detect_cycle_in_graph(deps: &std::collections::HashMap>) -> Option { + let mut visited: HashSet = HashSet::new(); + let mut in_stack: HashSet = HashSet::new(); + + fn dfs( + node: Uuid, + deps: &std::collections::HashMap>, + visited: &mut HashSet, + in_stack: &mut HashSet, + ) -> Option { + if in_stack.contains(&node) { + return Some(node); + } + if visited.contains(&node) { + return None; + } + visited.insert(node); + in_stack.insert(node); + + if let Some(children) = deps.get(&node) { + for child in children { + if let Some(cycle_node) = dfs(*child, deps, visited, in_stack) { + return Some(cycle_node); + } + } + } + + in_stack.remove(&node); + None + } + + for node in deps.keys() { + if !visited.contains(node) { + if let Some(cycle_node) = dfs(*node, deps, &mut visited, &mut in_stack) { + return Some(cycle_node); + } + } + } + None + } + pub fn set_status(&mut self, status: ToDoStatus) -> Result<(), ValidationError> { let valid_transition = match (&self.status, &status) { (ToDoStatus::Pending, ToDoStatus::InProgress) => true, @@ -350,4 +410,37 @@ mod tests { let result = todo.update(None, None, Some(0)); assert!(matches!(result, Err(ValidationError::InvalidPriority))); } + + #[test] + fn test_has_circular_dependency_self_reference() { + let mut todo = ToDo::default(); + todo.depends_on = vec![todo.id]; + assert!(todo.has_circular_dependency()); + } + + #[test] + fn test_has_circular_dependency_none() { + let todo = ToDo::default(); + assert!(!todo.has_circular_dependency()); + } + + #[test] + fn test_detect_cycle_in_graph_simple_cycle() { + let id_a = Uuid::new_v4(); + let id_b = Uuid::new_v4(); + let mut deps = std::collections::HashMap::new(); + deps.insert(id_a, vec![id_b]); + deps.insert(id_b, vec![id_a]); + assert!(ToDo::detect_cycle_in_graph(&deps).is_some()); + } + + #[test] + fn test_detect_cycle_in_graph_no_cycle() { + let id_a = Uuid::new_v4(); + let id_b = Uuid::new_v4(); + let mut deps = std::collections::HashMap::new(); + deps.insert(id_a, vec![id_b]); + deps.insert(id_b, vec![]); + assert!(ToDo::detect_cycle_in_graph(&deps).is_none()); + } } diff --git a/src/tools.rs b/src/tools.rs index 8b833a1..1772b7a 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -106,6 +106,20 @@ impl ToolDefinition { } } + /// Strip the board_id parameter from the tool's JSON schema. + /// This is used to hide the board context from agents that shouldn't + /// have to manage it manually (similar to project_id). + pub fn strip_board_id(&mut self) { + if let Some(obj) = self.parameters.as_object_mut() { + if let Some(properties) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) { + properties.remove("board_id"); + } + if let Some(required) = obj.get_mut("required").and_then(|r| r.as_array_mut()) { + required.retain(|v| v.as_str() != Some("board_id")); + } + } + } + /// Check if this tool version is deprecated pub fn is_deprecated(&self) -> bool { self.deprecated @@ -1077,7 +1091,8 @@ pub fn tool_definitions() -> Vec { serde_json::json!({ "type": "object", "properties": { - "project_id": { "type": "string", "description": "Project ID" } + "project_id": { "type": "string", "description": "Project ID" }, + "board_id": { "type": "string", "description": "Board ID (defaults to project's primary board)" } }, "required": ["project_id"] }) @@ -1091,6 +1106,7 @@ pub fn tool_definitions() -> Vec { "type": "object", "properties": { "project_id": { "type": "string", "description": "Project ID" }, + "board_id": { "type": "string", "description": "Board ID (defaults to project's primary board)" }, "column_id": { "type": "string", "description": "Column ID to place the todo" }, "title": { "type": "string", "description": "Title of the todo" }, "description": { "type": "string", "description": "Optional description" }, @@ -1208,6 +1224,7 @@ pub fn tool_definitions() -> Vec { "type": "object", "properties": { "project_id": { "type": "string", "description": "Project ID" }, + "board_id": { "type": "string", "description": "Board ID (defaults to project's primary board)" }, "name": { "type": "string", "description": "Column name" }, "column_order": { "type": "number", "description": "Display order" } }, @@ -1516,6 +1533,7 @@ pub fn tool_definitions() -> Vec { "type": "object", "properties": { "plan_id": { "type": "string", "description": "Auto-generated plan ID" }, + "todo_id": { "type": "string", "description": "The todo ID this plan belongs to" }, "steps": { "type": "array", "description": "List of mutation steps", "items": { "type": "object", "properties": { @@ -2016,4 +2034,61 @@ Done with tools. assert_eq!(ToolCategory::Agent.as_str(), "agent"); assert_eq!(ToolCategory::Other.as_str(), "other"); } + + #[test] + fn test_streaming_rejects_tool_result_at_start() { + let chunks = vec![ + "```tool-result:read_file\n", + "{\"call_id\": \"call_01\"}\n", + "```\nSome trailing text.", + ]; + let results = parse_tool_call_stream(chunks.into_iter()); + assert!(results.is_empty(), "tool-result: at stream start should be rejected"); + } + + #[test] + fn test_streaming_rejects_multiple_tool_results() { + let chunks = vec![ + "```tool-result:read_file\n{\"call_id\": \"c1\"}\n```\n", + "```tool-result:write_file\n{\"call_id\": \"c2\"}\n```\n", + ]; + let results = parse_tool_call_stream(chunks.into_iter()); + assert!(results.is_empty(), "multiple tool-result: blocks should all be rejected"); + } + + #[test] + fn test_streaming_mixed_tool_and_tool_result() { + let chunks = vec![ + "```tool:read_file\n{\"call_id\": \"c1\"}\n```\n", + "```tool-result:write_file\n{\"call_id\": \"c2\"}\n```\n", + "```tool:search_files\n{\"call_id\": \"c3\"}\n```\n", + ]; + let results = parse_tool_call_stream(chunks.into_iter()); + assert_eq!(results.len(), 2, "should only parse the tool: blocks, not tool-result:"); + assert_eq!(results[0].call_id, "c1"); + assert_eq!(results[1].call_id, "c3"); + } + + #[test] + fn test_streaming_tool_result_only_chunk() { + let results = parse_tool_call_stream(vec!["```tool-result:read_file\n{\"call_id\": \"c1\"}\n```\n"].into_iter()); + assert!(results.is_empty(), "tool-result: as only chunk content should be rejected"); + } + + #[test] + fn test_streaming_tool_result_in_middle_of_text() { + let chunks = vec![ + "Some text before.\n```tool-result:read_file\n", + "{\"call_id\": \"c1\"}\n```\n", + "Some text after.", + ]; + let results = parse_tool_call_stream(chunks.into_iter()); + assert!(results.is_empty(), "tool-result: in middle of text should be rejected"); + } + + #[test] + fn test_streaming_finish_rejects_tool_result_no_closing_fence() { + let results = parse_tool_call_stream(vec!["```tool-result:read_file\n{\"call_id\": \"c1\"}\n"].into_iter()); + assert!(results.is_empty(), "tool-result: without closing fence should be rejected by finish()"); + } }