use crate::format::OutputFormat;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AutoAdvanceConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub target_state: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UnknownKeyBehavior {
Allow,
#[default]
Warn,
Reject,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentKeyDefinition {
pub mime: String,
#[serde(default = "default_append_mode")]
pub mode: String,
}
fn default_append_mode() -> String {
"append".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentsConfig {
#[serde(default)]
pub unknown_key: UnknownKeyBehavior,
#[serde(default = "AttachmentsConfig::default_definitions")]
pub definitions: HashMap<String, AttachmentKeyDefinition>,
}
impl Default for AttachmentsConfig {
fn default() -> Self {
Self {
unknown_key: UnknownKeyBehavior::default(),
definitions: Self::default_definitions(),
}
}
}
impl AttachmentsConfig {
pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
let mut defs = HashMap::new();
defs.insert(
"commit".to_string(),
AttachmentKeyDefinition {
mime: "text/git.hash".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"checkin".to_string(),
AttachmentKeyDefinition {
mime: "text/p4.changelist".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"meta".to_string(),
AttachmentKeyDefinition {
mime: "application/json".to_string(),
mode: "replace".to_string(),
},
);
defs.insert(
"note".to_string(),
AttachmentKeyDefinition {
mime: "text/plain".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"log".to_string(),
AttachmentKeyDefinition {
mime: "text/plain".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"error".to_string(),
AttachmentKeyDefinition {
mime: "text/plain".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"output".to_string(),
AttachmentKeyDefinition {
mime: "text/plain".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"diff".to_string(),
AttachmentKeyDefinition {
mime: "text/x-diff".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"changelist".to_string(),
AttachmentKeyDefinition {
mime: "text/plain".to_string(),
mode: "append".to_string(),
},
);
defs.insert(
"plan".to_string(),
AttachmentKeyDefinition {
mime: "text/markdown".to_string(),
mode: "replace".to_string(),
},
);
defs.insert(
"result".to_string(),
AttachmentKeyDefinition {
mime: "application/json".to_string(),
mode: "replace".to_string(),
},
);
defs.insert(
"context".to_string(),
AttachmentKeyDefinition {
mime: "text/plain".to_string(),
mode: "replace".to_string(),
},
);
defs
}
pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
self.definitions.get(key)
}
pub fn is_known_key(&self, key: &str) -> bool {
self.definitions.contains_key(key)
}
pub fn get_mime_default(&self, key: &str) -> &str {
self.definitions
.get(key)
.map(|d| d.mime.as_str())
.unwrap_or("text/plain")
}
pub fn get_mode_default(&self, key: &str) -> &str {
self.definitions
.get(key)
.map(|d| d.mode.as_str())
.unwrap_or("append")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub paths: PathsConfig,
#[serde(default)]
pub states: StatesConfig,
#[serde(default)]
pub dependencies: DependenciesConfig,
#[serde(default)]
pub auto_advance: AutoAdvanceConfig,
#[serde(default)]
pub attachments: AttachmentsConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
server: ServerConfig::default(),
paths: PathsConfig::default(),
states: StatesConfig::default(),
dependencies: DependenciesConfig::default(),
auto_advance: AutoAdvanceConfig::default(),
attachments: AttachmentsConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerPaths {
pub db_path: PathBuf,
pub media_dir: PathBuf,
pub log_dir: PathBuf,
pub config_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_db_path")]
pub db_path: PathBuf,
#[serde(default = "default_media_dir")]
pub media_dir: PathBuf,
#[serde(default = "default_claim_limit")]
pub claim_limit: i32,
#[serde(default = "default_stale_timeout")]
pub stale_timeout_seconds: i64,
#[serde(default)]
pub default_format: OutputFormat,
#[serde(default = "default_skills_dir")]
pub skills_dir: PathBuf,
#[serde(default = "default_log_dir")]
pub log_dir: PathBuf,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
db_path: default_db_path(),
media_dir: default_media_dir(),
claim_limit: default_claim_limit(),
stale_timeout_seconds: default_stale_timeout(),
default_format: OutputFormat::default(),
skills_dir: default_skills_dir(),
log_dir: default_log_dir(),
}
}
}
fn default_db_path() -> PathBuf {
PathBuf::from(".task-graph/tasks.db")
}
fn default_media_dir() -> PathBuf {
PathBuf::from(".task-graph/media")
}
fn default_skills_dir() -> PathBuf {
PathBuf::from(".task-graph/skills")
}
fn default_log_dir() -> PathBuf {
PathBuf::from(".task-graph/logs")
}
fn default_claim_limit() -> i32 {
5
}
fn default_stale_timeout() -> i64 {
900 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathsConfig {
#[serde(default)]
pub style: PathStyle,
}
impl Default for PathsConfig {
fn default() -> Self {
Self {
style: PathStyle::Relative,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PathStyle {
Relative,
ProjectPrefixed,
}
impl Default for PathStyle {
fn default() -> Self {
PathStyle::Relative
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatesConfig {
#[serde(default = "default_initial_state")]
pub initial: String,
#[serde(default = "default_disconnect_state")]
pub disconnect_state: String,
#[serde(default = "default_blocking_states")]
pub blocking_states: Vec<String>,
#[serde(default = "default_state_definitions")]
pub definitions: HashMap<String, StateDefinition>,
}
impl Default for StatesConfig {
fn default() -> Self {
Self {
initial: default_initial_state(),
disconnect_state: default_disconnect_state(),
blocking_states: default_blocking_states(),
definitions: default_state_definitions(),
}
}
}
fn default_initial_state() -> String {
"pending".to_string()
}
fn default_disconnect_state() -> String {
"pending".to_string()
}
fn default_blocking_states() -> Vec<String> {
vec!["pending".to_string(), "assigned".to_string(), "in_progress".to_string()]
}
fn default_state_definitions() -> HashMap<String, StateDefinition> {
let mut defs = HashMap::new();
defs.insert(
"pending".to_string(),
StateDefinition {
exits: vec!["assigned".to_string(), "in_progress".to_string(), "cancelled".to_string()],
timed: false,
},
);
defs.insert(
"assigned".to_string(),
StateDefinition {
exits: vec!["in_progress".to_string(), "pending".to_string(), "cancelled".to_string()],
timed: false,
},
);
defs.insert(
"in_progress".to_string(),
StateDefinition {
exits: vec![
"completed".to_string(),
"failed".to_string(),
"pending".to_string(),
],
timed: true,
},
);
defs.insert(
"completed".to_string(),
StateDefinition {
exits: vec![],
timed: false,
},
);
defs.insert(
"failed".to_string(),
StateDefinition {
exits: vec!["pending".to_string()],
timed: false,
},
);
defs.insert(
"cancelled".to_string(),
StateDefinition {
exits: vec![],
timed: false,
},
);
defs
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDefinition {
#[serde(default)]
pub exits: Vec<String>,
#[serde(default)]
pub timed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependenciesConfig {
#[serde(default = "default_dependency_definitions")]
pub definitions: HashMap<String, DependencyDefinition>,
}
impl Default for DependenciesConfig {
fn default() -> Self {
Self {
definitions: default_dependency_definitions(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyDefinition {
pub display: DependencyDisplay,
pub blocks: BlockTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DependencyDisplay {
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockTarget {
None,
Start,
Completion,
}
fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
let mut defs = HashMap::new();
defs.insert(
"blocks".to_string(),
DependencyDefinition {
display: DependencyDisplay::Horizontal,
blocks: BlockTarget::Start,
},
);
defs.insert(
"follows".to_string(),
DependencyDefinition {
display: DependencyDisplay::Horizontal,
blocks: BlockTarget::Start,
},
);
defs.insert(
"contains".to_string(),
DependencyDefinition {
display: DependencyDisplay::Vertical,
blocks: BlockTarget::Completion,
},
);
defs.insert(
"duplicate".to_string(),
DependencyDefinition {
display: DependencyDisplay::Horizontal,
blocks: BlockTarget::None,
},
);
defs.insert(
"see-also".to_string(),
DependencyDefinition {
display: DependencyDisplay::Horizontal,
blocks: BlockTarget::None,
},
);
defs.insert(
"relates-to".to_string(),
DependencyDefinition {
display: DependencyDisplay::Horizontal,
blocks: BlockTarget::None,
},
);
defs
}
impl DependenciesConfig {
pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
self.definitions.contains_key(dep_type)
}
pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
self.definitions.get(dep_type)
}
pub fn start_blocking_types(&self) -> Vec<&str> {
self.definitions
.iter()
.filter(|(_, def)| def.blocks == BlockTarget::Start)
.map(|(name, _)| name.as_str())
.collect()
}
pub fn completion_blocking_types(&self) -> Vec<&str> {
self.definitions
.iter()
.filter(|(_, def)| def.blocks == BlockTarget::Completion)
.map(|(name, _)| name.as_str())
.collect()
}
pub fn vertical_types(&self) -> Vec<&str> {
self.definitions
.iter()
.filter(|(_, def)| def.display == DependencyDisplay::Vertical)
.map(|(name, _)| name.as_str())
.collect()
}
pub fn dep_type_names(&self) -> Vec<&str> {
self.definitions.keys().map(|s| s.as_str()).collect()
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.definitions.is_empty() {
return Err(anyhow::anyhow!(
"At least one dependency type must be defined"
));
}
let has_start_blocking = self.definitions.values().any(|d| d.blocks == BlockTarget::Start);
if !has_start_blocking {
return Err(anyhow::anyhow!(
"At least one dependency type with blocks: start must be defined"
));
}
Ok(())
}
}
impl StatesConfig {
pub fn is_valid_state(&self, state: &str) -> bool {
self.definitions.contains_key(state)
}
pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
if let Some(def) = self.definitions.get(from) {
def.exits.contains(&to.to_string())
} else {
false
}
}
pub fn is_timed_state(&self, state: &str) -> bool {
self.definitions
.get(state)
.map(|d| d.timed)
.unwrap_or(false)
}
pub fn is_terminal_state(&self, state: &str) -> bool {
self.definitions
.get(state)
.map(|d| d.exits.is_empty())
.unwrap_or(false)
}
pub fn is_blocking_state(&self, state: &str) -> bool {
self.blocking_states.contains(&state.to_string())
}
pub fn state_names(&self) -> Vec<&str> {
self.definitions.keys().map(|s| s.as_str()).collect()
}
pub fn get_exits(&self, state: &str) -> Vec<&str> {
self.definitions
.get(state)
.map(|d| d.exits.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn untimed_state_names(&self) -> Vec<&str> {
self.definitions
.iter()
.filter(|(_, def)| !def.timed)
.map(|(name, _)| name.as_str())
.collect()
}
pub fn validate(&self) -> Result<()> {
if !self.definitions.contains_key(&self.initial) {
return Err(anyhow!(
"Initial state '{}' is not defined in state definitions",
self.initial
));
}
if !self.definitions.contains_key(&self.disconnect_state) {
return Err(anyhow!(
"Disconnect state '{}' is not defined in state definitions",
self.disconnect_state
));
}
if self.is_timed_state(&self.disconnect_state) {
return Err(anyhow!(
"Disconnect state '{}' must not be a timed state",
self.disconnect_state
));
}
for state in &self.blocking_states {
if !self.definitions.contains_key(state) {
return Err(anyhow!(
"Blocking state '{}' is not defined in state definitions",
state
));
}
}
for (state_name, def) in &self.definitions {
for exit in &def.exits {
if !self.definitions.contains_key(exit) {
return Err(anyhow!(
"State '{}' has exit '{}' which is not defined",
state_name,
exit
));
}
}
}
let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
if !has_terminal {
return Err(anyhow!(
"At least one terminal state (with empty exits) must be defined"
));
}
Ok(())
}
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Config = serde_yaml::from_str(&content)?;
Ok(config)
}
pub fn load_or_default() -> Self {
if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH") {
if let Ok(config) = Self::load(&config_path) {
return config;
}
}
if let Ok(config) = Self::load(".task-graph/config.yaml") {
return config;
}
let mut config = Self::default();
if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
config.server.db_path = PathBuf::from(db_path);
}
if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
config.server.media_dir = PathBuf::from(media_dir);
}
if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
config.server.log_dir = PathBuf::from(log_dir);
}
config
}
pub fn ensure_db_dir(&self) -> Result<()> {
if let Some(parent) = self.server.db_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}
pub fn ensure_media_dir(&self) -> Result<()> {
std::fs::create_dir_all(&self.server.media_dir)?;
Ok(())
}
pub fn ensure_log_dir(&self) -> Result<()> {
std::fs::create_dir_all(&self.server.log_dir)?;
Ok(())
}
pub fn media_dir(&self) -> &Path {
&self.server.media_dir
}
pub fn log_dir(&self) -> &Path {
&self.server.log_dir
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolPrompt {
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Prompts {
pub instructions: Option<String>,
#[serde(default)]
pub tools: HashMap<String, ToolPrompt>,
}
impl Prompts {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
Ok(prompts.unwrap_or_default())
}
pub fn load_or_default() -> Self {
if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
return prompts;
}
Self::default()
}
pub fn get_tool_description(&self, name: &str) -> Option<&str> {
self.tools.get(name).map(|t| t.description.as_str())
}
}