#![warn(clippy::pedantic)]
#![allow(
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::doc_markdown
)]
pub mod agent;
pub mod board;
pub mod channels;
pub mod chat_history;
pub mod config;
pub mod config_db;
pub mod debug;
pub mod diff_parse;
pub mod embedder;
pub mod extraction;
pub mod gui;
pub mod logs;
pub mod maintainer;
pub mod management;
pub mod manager_queue;
pub mod prompt;
pub mod providers;
pub mod registry;
pub mod role;
pub mod search_engine;
pub mod self_update;
pub mod session;
pub mod shutdown;
pub mod skills;
pub mod stats;
pub mod ticket_buffer;
pub mod tools;
pub mod turso;
pub mod users;
pub mod util;
pub mod vector;
pub mod workspace;
use async_trait::async_trait;
use futures_util::stream;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use crate::session::Session;
use crate::util::UnwrapPoison;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticsCommands {
pub format: Option<String>,
pub format_check: Option<String>,
pub lint: Option<String>,
pub lint_fix: Option<String>,
pub type_check: Option<String>,
pub build: Option<String>,
pub unit_test: Option<String>,
}
impl DiagnosticsCommands {
#[must_use]
pub fn commands(&self) -> [(&'static str, Option<&str>); 7] {
[
("format", self.format.as_deref()),
("format-check", self.format_check.as_deref()),
("lint-fix", self.lint_fix.as_deref()),
("lint", self.lint.as_deref()),
("type-check", self.type_check.as_deref()),
("build", self.build.as_deref()),
("unit-test", self.unit_test.as_deref()),
]
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands().iter().all(|(_, cmd)| cmd.is_none())
}
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct Workspace {
pub name: String,
pub path: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub maintenance: bool,
pub paused: bool,
pub maintainer_debounce_mins: i64,
pub maintainer_last_run_at: Option<String>,
pub diagnostics: Option<String>,
pub diagnostics_updated_at: Option<String>,
}
impl Workspace {
#[must_use]
pub fn as_path(&self) -> &Path {
Path::new(&self.path)
}
#[must_use]
pub fn from_path(path: &Path) -> Self {
let stored = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
Self {
name: dir_name(&stored),
path: stored.to_string_lossy().to_string(),
maintainer_debounce_mins: 5,
..Default::default()
}
}
#[must_use]
pub fn display_name(&self) -> String {
dir_name(self.as_path())
}
}
fn dir_name(path: &Path) -> String {
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
}
const NANOID_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
#[must_use]
fn generate_nanoid(length: usize) -> String {
(0..length)
.map(|_| {
let idx = (rand::random::<u8>() % 64) as usize;
NANOID_ALPHABET[idx] as char
})
.collect()
}
#[must_use]
pub fn generate_id() -> String {
generate_nanoid(10)
}
#[must_use]
pub fn generate_suffix() -> String {
generate_nanoid(6)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Start,
}
impl Command {
#[must_use]
pub const fn needs_full_user(&self) -> bool {
false
}
}
#[derive(Debug, Clone)]
pub struct ChannelMessage {
pub user_name: String,
pub reply_target: String,
pub content: String,
pub source_channel: String,
pub workspace: String,
pub message_id: Option<String>,
pub callback_query_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SendMessage {
pub content: String,
pub recipient: String,
pub reply_markup: Option<serde_json::Value>,
pub agent_role: Option<String>,
pub workspace: String,
}
#[async_trait]
pub trait Channel: Send + Sync {
async fn send(&self, message: &SendMessage) -> anyhow::Result<()>;
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()>;
fn name(&self) -> &str;
fn as_any(&self) -> &dyn std::any::Any;
async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
fn resolve_recipient(&self, _user_name: &str, reply_target: &str) -> Option<String> {
Some(reply_target.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatDirection {
User,
Agent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChatEvent {
Message {
message_id: String,
user_name: String,
content: String,
direction: ChatDirection,
timestamp: String,
agent_role: Option<String>,
workspace: String,
optimistic_id: Option<String>,
#[serde(default)]
reply_markup: Option<serde_json::Value>,
},
Typing {
user_name: String,
is_typing: bool,
},
}
pub static CHAT_BROADCAST: OnceLock<broadcast::Sender<ChatEvent>> = OnceLock::new();
pub static MESSAGE_TX: OnceLock<tokio::sync::mpsc::Sender<ChannelMessage>> = OnceLock::new();
pub static GUI_MESSAGE_TX: OnceLock<tokio::sync::mpsc::UnboundedSender<ChannelMessage>> =
OnceLock::new();
#[derive(Default)]
pub struct ChannelRegistry {
channels: RwLock<HashMap<String, Arc<dyn Channel>>>,
}
impl ChannelRegistry {
pub fn register(&self, channel: Arc<dyn Channel>) {
let name = channel.name().to_string();
let mut map = self.channels.write().unwrap_poison();
if let std::collections::hash_map::Entry::Vacant(entry) = map.entry(name.clone()) {
entry.insert(channel);
} else {
tracing::warn!(channel = %name, "Channel registry: duplicate name — skipping register");
}
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.read().unwrap_poison().get(name).cloned()
}
pub fn replace(&self, channel: Arc<dyn Channel>) -> Option<Arc<dyn Channel>> {
let name = channel.name().to_string();
self.channels.write().unwrap_poison().insert(name, channel)
}
pub fn unregister(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.write().unwrap_poison().remove(name)
}
pub fn list(&self) -> Vec<(String, Arc<dyn Channel>)> {
self.channels
.read()
.unwrap_poison()
.iter()
.map(|(k, v)| (k.clone(), Arc::clone(v)))
.collect()
}
}
pub static CHANNEL_REGISTRY: OnceLock<ChannelRegistry> = OnceLock::new();
#[must_use]
pub fn channel_registry() -> &'static ChannelRegistry {
CHANNEL_REGISTRY
.get()
.expect("CHANNEL_REGISTRY not initialized — must be set during bootstrap")
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumIter, strum::IntoStaticStr,
)]
#[strum(serialize_all = "lowercase")]
pub enum Role {
Manager,
Engineer,
Analyst,
Coder,
Qa,
Reviewer,
Discovery,
Artist,
Maintainer,
}
#[derive(Debug, Clone, Default)]
pub struct ToolUsage {
pub call_count: u64,
pub errors: Vec<String>,
}
pub struct Agent {
pub(crate) id: String,
role: Role,
pub(crate) session: Session,
workspace: Arc<crate::Workspace>,
tools: Vec<Box<dyn crate::Tool>>,
pub(crate) tool_specs: Vec<ToolSpec>,
cancel_token: CancellationToken,
ticket: Option<crate::board::Ticket>,
generation: u64,
tool_stats: std::sync::Mutex<std::collections::HashMap<String, ToolUsage>>,
}
#[derive(serde::Deserialize)]
pub struct Verdict {
pub score: u8,
pub critique: Option<String>,
#[serde(rename = "issues")]
pub issues_detected: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> String {
crate::prompt::load_prompt(&format!("tool/{}.md", self.name()))
}
fn parameters_schema(&self) -> serde_json::Value;
async fn execute(
&self,
ws: &crate::Workspace,
args: serde_json::Value,
) -> anyhow::Result<String>;
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name().to_string(),
description: self.description(),
parameters: self.parameters_schema(),
}
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
true
}
fn side_effects(&self, _args: &serde_json::Value) -> bool {
true
}
fn media_marker(&self) -> Option<&'static str> {
None
}
fn format_output(&self, output: &str) -> String {
crate::util::format_tool_output(output)
}
fn debug_output(
&self,
phase: ToolOutputPhase,
args: &serde_json::Value,
outcome: Option<&crate::tools::ToolExecutionOutcome>,
) -> Option<String> {
match phase {
ToolOutputPhase::Before => {
let args_preview = crate::util::summarize_args(args);
Some(format!("🔧 `{}`({})", self.name(), args_preview))
}
ToolOutputPhase::After => {
let outcome = outcome?;
let status = if outcome.success { "✅" } else { "❌" };
let name = self.name();
let preview: String = outcome.output.chars().take(600).collect();
let preview = preview.trim();
if preview.is_empty() {
Some(format!("{status} `{name}`"))
} else {
Some(format!("{status} `{name}` → {preview}"))
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolOutputPhase {
Before,
After,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
impl ChatMessage {
#[must_use]
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".into(),
content: content.into(),
}
}
#[must_use]
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".into(),
content: content.into(),
}
}
#[must_use]
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".into(),
content: content.into(),
}
}
fn tool(content: impl Into<String>) -> Self {
Self {
role: "tool".into(),
content: content.into(),
}
}
#[must_use]
pub fn tool_result(tool_call_id: &str, content: &str) -> Self {
let payload = serde_json::json!({
"tool_call_id": tool_call_id,
"content": content,
});
Self::tool(payload.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Reasoning {
pub reasoning: Option<String>,
pub reasoning_content: Option<String>,
pub reasoning_details: Option<serde_json::Value>,
}
impl Reasoning {
#[must_use]
pub fn from_optional_parts(
reasoning: Option<String>,
reasoning_content: Option<String>,
reasoning_details: Option<serde_json::Value>,
) -> Option<Self> {
let details = reasoning_details.filter(|v| !v.is_null());
let this = Self {
reasoning,
reasoning_content,
reasoning_details: details,
};
(!this.is_empty()).then_some(this)
}
#[must_use]
const fn is_empty(&self) -> bool {
self.reasoning.is_none()
&& self.reasoning_content.is_none()
&& self.reasoning_details.is_none()
}
}
#[derive(Debug, Clone, Default)]
pub struct ProviderUsage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_input_tokens: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct ChatResponse {
pub text: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub usage: Option<ProviderUsage>,
pub reasoning: Option<Reasoning>,
}
impl ChatResponse {
#[must_use]
pub fn text_or_empty(&self) -> &str {
self.text.as_deref().unwrap_or("")
}
}
#[derive(Debug, Clone)]
pub struct ChatRequest {
pub messages: Vec<ChatMessage>,
pub tools: Option<Vec<ToolSpec>>,
pub model: String,
pub allow_image_parts: bool,
pub temperature: f32,
pub reasoning_effort: Option<String>,
pub provider_order: Option<String>,
pub provider_allow_fallbacks: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct StreamChunk {
pub delta: String,
pub reasoning: Option<Reasoning>,
}
#[derive(Debug, Clone)]
pub enum StreamEvent {
TextDelta(StreamChunk),
ToolCall(ToolCall),
Final,
}
#[async_trait]
pub trait Provider: Send + Sync {
async fn chat(&self, request: ChatRequest) -> anyhow::Result<ChatResponse>;
async fn warmup(&self) -> anyhow::Result<()> {
Ok(())
}
fn stream_chat(
&self,
request: ChatRequest,
) -> stream::BoxStream<'static, StreamResult<StreamEvent>>;
}
pub type StreamResult<T> = std::result::Result<T, StreamError>;
#[derive(Debug, thiserror::Error)]
pub enum StreamError {
#[error("HTTP error: {0}")]
Http(String),
#[error("JSON parse error: {0}")]
Json(serde_json::Error),
#[error("Provider error: {0}")]
Provider(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub location: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diagnostics_commands_returns_all_7_in_order() {
let diag = DiagnosticsCommands {
format: Some("cargo fmt".into()),
format_check: Some("cargo fmt -- --check".into()),
lint: Some("cargo clippy -- -D warnings".into()),
lint_fix: Some("cargo clippy --fix --allow-dirty".into()),
type_check: Some("cargo check".into()),
build: Some("cargo build".into()),
unit_test: Some("cargo test".into()),
};
let cmds = diag.commands();
assert_eq!(cmds.len(), 7);
assert_eq!(cmds[0].0, "format");
assert_eq!(cmds[1].0, "format-check");
assert_eq!(cmds[2].0, "lint-fix");
assert_eq!(cmds[3].0, "lint");
assert_eq!(cmds[4].0, "type-check");
assert_eq!(cmds[5].0, "build");
assert_eq!(cmds[6].0, "unit-test");
assert_eq!(cmds[0].1, Some("cargo fmt"));
assert_eq!(cmds[6].1, Some("cargo test"));
}
#[test]
fn diagnostics_is_empty() {
let empty = DiagnosticsCommands {
format: None,
format_check: None,
lint: None,
lint_fix: None,
type_check: None,
build: None,
unit_test: None,
};
assert!(empty.is_empty());
let partial = DiagnosticsCommands {
format: Some("cargo fmt".into()),
format_check: None,
lint: None,
lint_fix: None,
type_check: None,
build: None,
unit_test: None,
};
assert!(!partial.is_empty());
}
#[test]
fn command_needs_full_user() {
assert!(!Command::Start.needs_full_user());
}
}