Skip to main content

task_graph_mcp/config/
types.rs

1//! Configuration types and structures.
2//!
3//! This module contains all the configuration types used throughout the application.
4
5use crate::config::workflows::WorkflowsConfig;
6use crate::format::OutputFormat;
7use anyhow::{Result, anyhow};
8use heck::{ToKebabCase, ToLowerCamelCase, ToSnakeCase, ToTitleCase, ToUpperCamelCase};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14/// Default port for the web dashboard.
15pub const DEFAULT_UI_PORT: u16 = 31994;
16
17/// Default number of words for generated IDs.
18pub const DEFAULT_ID_WORDS: u8 = 2;
19
20/// Case style for generated IDs.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum IdCase {
24    /// kebab-case (default): happy-turtle-swift-fox
25    #[default]
26    KebabCase,
27    /// snake_case: happy_turtle_swift_fox
28    SnakeCase,
29    /// camelCase: happyTurtleSwiftFox
30    CamelCase,
31    /// PascalCase: HappyTurtleSwiftFox
32    PascalCase,
33    /// lowercase: happyturtleswiftfox
34    Lowercase,
35    /// UPPERCASE: HAPPYTURTLESWIFTFOX
36    Uppercase,
37    /// Title Case: Happy Turtle Swift Fox
38    TitleCase,
39}
40
41/// ID generation configuration.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct IdsConfig {
44    /// Number of words for generated task IDs (default: 2).
45    #[serde(default = "default_id_words")]
46    pub task_id_words: u8,
47
48    /// Number of words for generated agent IDs (default: 2).
49    #[serde(default = "default_id_words")]
50    pub agent_id_words: u8,
51
52    /// Case style for generated IDs (default: kebab-case).
53    #[serde(default)]
54    pub id_case: IdCase,
55
56    /// Case style for generated agent IDs (default: PascalCase).
57    #[serde(default = "default_agent_id_case")]
58    pub agent_id_case: IdCase,
59}
60
61fn default_id_words() -> u8 {
62    DEFAULT_ID_WORDS
63}
64
65fn default_agent_id_case() -> IdCase {
66    IdCase::PascalCase
67}
68
69impl Default for IdsConfig {
70    fn default() -> Self {
71        Self {
72            task_id_words: DEFAULT_ID_WORDS,
73            agent_id_words: DEFAULT_ID_WORDS,
74            id_case: IdCase::default(),
75            agent_id_case: default_agent_id_case(),
76        }
77    }
78}
79
80impl IdCase {
81    /// Convert a kebab-case string to the target case style.
82    /// Input is expected to be lowercase words separated by hyphens.
83    pub fn convert(&self, input: &str) -> String {
84        match self {
85            IdCase::KebabCase => input.to_kebab_case(),
86            IdCase::SnakeCase => input.to_snake_case(),
87            IdCase::CamelCase => input.to_lower_camel_case(),
88            IdCase::PascalCase => input.to_upper_camel_case(),
89            IdCase::Lowercase => input.replace('-', "").to_lowercase(),
90            IdCase::Uppercase => input.replace('-', "").to_uppercase(),
91            IdCase::TitleCase => input.to_title_case(),
92        }
93    }
94
95    /// Get the separator for this case style (used in petname generation).
96    /// Returns None for cases that don't use separators (camelCase, PascalCase, etc.).
97    pub fn separator(&self) -> Option<&'static str> {
98        match self {
99            IdCase::KebabCase => Some("-"),
100            IdCase::SnakeCase => Some("_"),
101            IdCase::TitleCase => Some(" "),
102            IdCase::CamelCase | IdCase::PascalCase | IdCase::Lowercase | IdCase::Uppercase => None,
103        }
104    }
105}
106
107/// UI mode for the server.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum UiMode {
111    /// No UI, MCP server only (default)
112    #[default]
113    None,
114    /// Enable web dashboard UI
115    Web,
116}
117
118/// UI configuration for the web dashboard.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct UiConfig {
121    /// UI mode: none (MCP only) or web (enable dashboard).
122    #[serde(default)]
123    pub mode: UiMode,
124
125    /// Port for the web dashboard (default: 31994).
126    #[serde(default = "default_ui_port")]
127    pub port: u16,
128
129    /// Initial retry delay in milliseconds when dashboard fails to start (default: 15000).
130    #[serde(default = "default_retry_initial_ms")]
131    pub retry_initial_ms: u64,
132
133    /// Jitter range in milliseconds for retry delay (default: 5000, meaning ±5s).
134    #[serde(default = "default_retry_jitter_ms")]
135    pub retry_jitter_ms: u64,
136
137    /// Maximum retry interval in milliseconds (default: 240000 = 4 minutes).
138    #[serde(default = "default_retry_max_ms")]
139    pub retry_max_ms: u64,
140
141    /// Exponential backoff multiplier (default: 2.0).
142    #[serde(default = "default_retry_multiplier")]
143    pub retry_multiplier: f64,
144}
145
146impl Default for UiConfig {
147    fn default() -> Self {
148        Self {
149            mode: UiMode::default(),
150            port: default_ui_port(),
151            retry_initial_ms: default_retry_initial_ms(),
152            retry_jitter_ms: default_retry_jitter_ms(),
153            retry_max_ms: default_retry_max_ms(),
154            retry_multiplier: default_retry_multiplier(),
155        }
156    }
157}
158
159fn default_ui_port() -> u16 {
160    DEFAULT_UI_PORT
161}
162
163fn default_retry_initial_ms() -> u64 {
164    15_000 // 15 seconds
165}
166
167fn default_retry_jitter_ms() -> u64 {
168    5_000 // ±5 seconds
169}
170
171fn default_retry_max_ms() -> u64 {
172    240_000 // 4 minutes
173}
174
175fn default_retry_multiplier() -> f64 {
176    2.0
177}
178
179/// Auto-advance configuration for automatically transitioning tasks when dependencies are satisfied.
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct AutoAdvanceConfig {
182    /// Enable auto-advance when dependencies are satisfied (default: false).
183    #[serde(default)]
184    pub enabled: bool,
185
186    /// Target state for auto-advanced tasks (e.g., "ready").
187    /// If None, tasks remain in their current state even when unblocked.
188    #[serde(default)]
189    pub target_state: Option<String>,
190}
191
192/// Agent feedback configuration.
193#[derive(Debug, Clone, Serialize, Deserialize, Default)]
194pub struct FeedbackConfig {
195    /// Enable agent feedback tools (default: false).
196    #[serde(default)]
197    pub enabled: bool,
198}
199
200/// Behavior for unknown attachment keys.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
202#[serde(rename_all = "snake_case")]
203pub enum UnknownKeyBehavior {
204    /// Silently use default mime/mode.
205    Allow,
206    /// Use defaults but return a warning in the response (default).
207    #[default]
208    Warn,
209    /// Reject unknown keys with an error.
210    Reject,
211}
212
213/// Enforcement level for workflow gates (checklists that must be satisfied before status transitions).
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
215#[serde(rename_all = "snake_case")]
216pub enum GateEnforcement {
217    /// Advisory only, never blocks transitions. Unsatisfied gates are reported but do not prevent status changes.
218    Allow,
219    /// Blocks transition unless force=true (default). Requires explicit override to proceed with unsatisfied gates.
220    #[default]
221    Warn,
222    /// Hard block, cannot be forced. Transition is rejected until gate requirements are satisfied.
223    Reject,
224}
225
226/// Definition of a gate (checklist item) for status or phase exits.
227///
228/// Gates are checked when transitioning out of a status or phase. A gate is satisfied
229/// when the task has an attachment with a matching type (e.g., "gate/tests", "gate/commit").
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GateDefinition {
232    /// Attachment type that satisfies this gate (e.g., "gate/tests", "gate/commit").
233    #[serde(rename = "type")]
234    pub gate_type: String,
235
236    /// Enforcement level for this gate.
237    #[serde(default)]
238    pub enforcement: GateEnforcement,
239
240    /// Human-readable description of what this gate requires.
241    #[serde(default)]
242    pub description: String,
243}
244
245/// Definition of a preconfigured attachment key.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct AttachmentKeyDefinition {
248    /// Default MIME type for this key.
249    pub mime: String,
250    /// Default mode: "append" or "replace".
251    #[serde(default = "default_append_mode")]
252    pub mode: String,
253}
254
255fn default_append_mode() -> String {
256    "append".to_string()
257}
258
259/// Attachments configuration with preconfigured key definitions.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct AttachmentsConfig {
262    /// Behavior for unknown attachment keys (allow, warn, reject).
263    #[serde(default)]
264    pub unknown_key: UnknownKeyBehavior,
265    /// Preconfigured attachment key definitions.
266    #[serde(default = "AttachmentsConfig::default_definitions")]
267    pub definitions: HashMap<String, AttachmentKeyDefinition>,
268}
269
270impl Default for AttachmentsConfig {
271    fn default() -> Self {
272        Self {
273            unknown_key: UnknownKeyBehavior::default(),
274            definitions: Self::default_definitions(),
275        }
276    }
277}
278
279impl AttachmentsConfig {
280    /// Default attachment key definitions.
281    pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
282        let mut defs = HashMap::new();
283
284        defs.insert(
285            "commit".to_string(),
286            AttachmentKeyDefinition {
287                mime: "text/git.hash".to_string(),
288                mode: "append".to_string(),
289            },
290        );
291
292        defs.insert(
293            "checkin".to_string(),
294            AttachmentKeyDefinition {
295                mime: "text/p4.changelist".to_string(),
296                mode: "append".to_string(),
297            },
298        );
299
300        defs.insert(
301            "meta".to_string(),
302            AttachmentKeyDefinition {
303                mime: "application/json".to_string(),
304                mode: "replace".to_string(),
305            },
306        );
307
308        defs.insert(
309            "note".to_string(),
310            AttachmentKeyDefinition {
311                mime: "text/plain".to_string(),
312                mode: "append".to_string(),
313            },
314        );
315
316        defs.insert(
317            "log".to_string(),
318            AttachmentKeyDefinition {
319                mime: "text/plain".to_string(),
320                mode: "append".to_string(),
321            },
322        );
323
324        defs.insert(
325            "error".to_string(),
326            AttachmentKeyDefinition {
327                mime: "text/plain".to_string(),
328                mode: "append".to_string(),
329            },
330        );
331
332        defs.insert(
333            "output".to_string(),
334            AttachmentKeyDefinition {
335                mime: "text/plain".to_string(),
336                mode: "append".to_string(),
337            },
338        );
339
340        defs.insert(
341            "diff".to_string(),
342            AttachmentKeyDefinition {
343                mime: "text/x-diff".to_string(),
344                mode: "append".to_string(),
345            },
346        );
347
348        defs.insert(
349            "changelist".to_string(),
350            AttachmentKeyDefinition {
351                mime: "text/plain".to_string(),
352                mode: "append".to_string(),
353            },
354        );
355
356        defs.insert(
357            "plan".to_string(),
358            AttachmentKeyDefinition {
359                mime: "text/markdown".to_string(),
360                mode: "replace".to_string(),
361            },
362        );
363
364        defs.insert(
365            "result".to_string(),
366            AttachmentKeyDefinition {
367                mime: "application/json".to_string(),
368                mode: "replace".to_string(),
369            },
370        );
371
372        defs.insert(
373            "context".to_string(),
374            AttachmentKeyDefinition {
375                mime: "text/plain".to_string(),
376                mode: "replace".to_string(),
377            },
378        );
379
380        // Gate attachments - for workflow gate satisfaction
381        defs.insert(
382            "gate/tests".to_string(),
383            AttachmentKeyDefinition {
384                mime: "text/plain".to_string(),
385                mode: "append".to_string(),
386            },
387        );
388
389        defs.insert(
390            "gate/commit".to_string(),
391            AttachmentKeyDefinition {
392                mime: "text/plain".to_string(),
393                mode: "append".to_string(),
394            },
395        );
396
397        defs.insert(
398            "gate/review".to_string(),
399            AttachmentKeyDefinition {
400                mime: "text/plain".to_string(),
401                mode: "append".to_string(),
402            },
403        );
404
405        defs
406    }
407
408    /// Get the definition for a key, if it exists.
409    pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
410        self.definitions.get(key)
411    }
412
413    /// Check if a key is a known/configured key.
414    pub fn is_known_key(&self, key: &str) -> bool {
415        self.definitions.contains_key(key)
416    }
417
418    /// Get the default MIME type for a key, or fallback to text/plain.
419    pub fn get_mime_default(&self, key: &str) -> &str {
420        self.definitions
421            .get(key)
422            .map(|d| d.mime.as_str())
423            .unwrap_or("text/plain")
424    }
425
426    /// Get the default mode for a key, or fallback to "append".
427    pub fn get_mode_default(&self, key: &str) -> &str {
428        self.definitions
429            .get(key)
430            .map(|d| d.mode.as_str())
431            .unwrap_or("append")
432    }
433}
434
435/// Definition of a preconfigured tag.
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct TagDefinition {
438    /// Category for grouping (e.g., "language", "domain", "type").
439    #[serde(default)]
440    pub category: Option<String>,
441    /// Human-readable description.
442    #[serde(default)]
443    pub description: Option<String>,
444}
445
446/// Tags configuration with preconfigured tag definitions.
447#[derive(Debug, Clone, Serialize, Deserialize, Default)]
448pub struct TagsConfig {
449    /// Behavior for unknown tags (allow, warn, reject).
450    #[serde(default)]
451    pub unknown_tag: UnknownKeyBehavior,
452    /// Preconfigured tag definitions.
453    #[serde(default)]
454    pub definitions: HashMap<String, TagDefinition>,
455}
456
457impl TagsConfig {
458    /// Check if a tag is a known/defined tag.
459    pub fn is_known_tag(&self, tag: &str) -> bool {
460        self.definitions.contains_key(tag)
461    }
462
463    /// Get all defined tag names.
464    pub fn tag_names(&self) -> Vec<&str> {
465        self.definitions.keys().map(|s| s.as_str()).collect()
466    }
467
468    /// Get all tags in a specific category.
469    pub fn tags_in_category(&self, category: &str) -> Vec<&str> {
470        self.definitions
471            .iter()
472            .filter(|(_, def)| def.category.as_deref() == Some(category))
473            .map(|(name, _)| name.as_str())
474            .collect()
475    }
476
477    /// Get all unique categories.
478    pub fn categories(&self) -> Vec<&str> {
479        let mut cats: Vec<&str> = self
480            .definitions
481            .values()
482            .filter_map(|def| def.category.as_deref())
483            .collect();
484        cats.sort();
485        cats.dedup();
486        cats
487    }
488
489    /// Validate a single tag, returning Ok(None) if valid, Ok(Some(warning)) for warn mode, or Err for reject.
490    pub fn validate_tag(&self, tag: &str) -> Result<Option<String>> {
491        if self.is_known_tag(tag) {
492            return Ok(None);
493        }
494
495        match self.unknown_tag {
496            UnknownKeyBehavior::Allow => Ok(None),
497            UnknownKeyBehavior::Warn => Ok(Some(format!(
498                "Unknown tag '{}'. Known tags: {:?}",
499                tag,
500                self.tag_names()
501            ))),
502            UnknownKeyBehavior::Reject => Err(anyhow!(
503                "Unknown tag '{}'. Configure in tags.definitions or set unknown_tag to 'allow' or 'warn'. Known tags: {:?}",
504                tag,
505                self.tag_names()
506            )),
507        }
508    }
509
510    /// Validate multiple tags, collecting warnings and stopping on first reject.
511    pub fn validate_tags(&self, tags: &[String]) -> Result<Vec<String>> {
512        let mut warnings = Vec::new();
513        for tag in tags {
514            if let Some(warning) = self.validate_tag(tag)? {
515                warnings.push(warning);
516            }
517        }
518        Ok(warnings)
519    }
520
521    /// Register workflow role tags as known tags.
522    /// Tags that already exist in definitions are left unchanged.
523    pub fn register_workflow_tags(&mut self, role_tags: &[String]) {
524        for tag in role_tags {
525            self.definitions
526                .entry(tag.clone())
527                .or_insert_with(|| TagDefinition {
528                    category: Some("workflow-role".to_string()),
529                    description: Some("Auto-registered from workflow role definition".to_string()),
530                });
531        }
532    }
533}
534
535/// Server configuration.
536#[derive(Debug, Clone, Serialize, Deserialize, Default)]
537pub struct Config {
538    #[serde(default)]
539    pub server: ServerConfig,
540
541    #[serde(default)]
542    pub paths: PathsConfig,
543
544    #[serde(default)]
545    pub states: StatesConfig,
546
547    #[serde(default)]
548    pub dependencies: DependenciesConfig,
549
550    #[serde(default)]
551    pub auto_advance: AutoAdvanceConfig,
552
553    #[serde(default)]
554    pub attachments: AttachmentsConfig,
555
556    #[serde(default)]
557    pub phases: PhasesConfig,
558
559    #[serde(default)]
560    pub tags: TagsConfig,
561
562    #[serde(default)]
563    pub ids: IdsConfig,
564
565    #[serde(default)]
566    pub feedback: FeedbackConfig,
567}
568
569/// Paths configured for the server, returned by connect.
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct ServerPaths {
572    /// Path to the SQLite database file.
573    pub db_path: PathBuf,
574    /// Path to the media directory for file attachments.
575    pub media_dir: PathBuf,
576    /// Path to the log directory.
577    pub log_dir: PathBuf,
578    /// Path to the configuration file (if one was loaded).
579    pub config_path: Option<PathBuf>,
580}
581
582/// Server-specific configuration.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct ServerConfig {
585    /// Path to the SQLite database file.
586    #[serde(default = "default_db_path")]
587    pub db_path: PathBuf,
588
589    /// Path to the media directory for file attachments.
590    #[serde(default = "default_media_dir")]
591    pub media_dir: PathBuf,
592
593    /// Maximum claims per agent.
594    #[serde(default = "default_claim_limit")]
595    pub claim_limit: i32,
596
597    /// Timeout for stale claims in seconds.
598    #[serde(default = "default_stale_timeout")]
599    pub stale_timeout_seconds: i64,
600
601    /// Default output format for query results (json or markdown).
602    #[serde(default)]
603    pub default_format: OutputFormat,
604
605    /// Path to the skills directory for skill overrides.
606    #[serde(default = "default_skills_dir")]
607    pub skills_dir: PathBuf,
608
609    /// Path to the log directory.
610    #[serde(default = "default_log_dir")]
611    pub log_dir: PathBuf,
612
613    /// UI configuration for the web dashboard.
614    #[serde(default)]
615    pub ui: UiConfig,
616
617    /// Default workflow name (e.g., "swarm" to use workflow-swarm.yaml).
618    /// If set, this workflow is used as the default for workers that don't specify one.
619    /// The named workflow is also cached under both its name and "default".
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub default_workflow: Option<String>,
622
623    /// Default page size for paginated queries (list_tasks, search).
624    /// Applies when no explicit limit is provided. Default: 50. Max: 1000.
625    #[serde(default = "default_page_size")]
626    pub default_page_size: i32,
627}
628
629impl Default for ServerConfig {
630    fn default() -> Self {
631        Self {
632            db_path: default_db_path(),
633            media_dir: default_media_dir(),
634            claim_limit: default_claim_limit(),
635            stale_timeout_seconds: default_stale_timeout(),
636            default_format: OutputFormat::default(),
637            skills_dir: default_skills_dir(),
638            log_dir: default_log_dir(),
639            ui: UiConfig::default(),
640            default_workflow: None,
641            default_page_size: default_page_size(),
642        }
643    }
644}
645
646fn default_db_path() -> PathBuf {
647    PathBuf::from("task-graph/tasks.db")
648}
649
650fn default_media_dir() -> PathBuf {
651    PathBuf::from("task-graph/media")
652}
653
654fn default_skills_dir() -> PathBuf {
655    PathBuf::from("task-graph/skills")
656}
657
658fn default_log_dir() -> PathBuf {
659    PathBuf::from("task-graph/logs")
660}
661
662fn default_paths_root() -> String {
663    ".".to_string()
664}
665
666fn default_claim_limit() -> i32 {
667    5
668}
669
670fn default_stale_timeout() -> i64 {
671    900 // 15 minutes
672}
673
674fn default_page_size() -> i32 {
675    50
676}
677
678/// Path handling configuration.
679#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct PathsConfig {
681    /// Root directory for sandboxing (default: ".")
682    #[serde(default = "default_paths_root")]
683    pub root: String,
684
685    /// Style for representing file paths.
686    #[serde(default)]
687    pub style: PathStyle,
688
689    /// Auto-map single-letter Windows drives (default: false)
690    #[serde(default)]
691    pub map_windows_drives: bool,
692
693    /// Prefix mappings (prefix -> path)
694    /// Values can be: literal path, $ENV_VAR, or ${config.path}
695    #[serde(default)]
696    pub mappings: HashMap<String, String>,
697}
698
699impl Default for PathsConfig {
700    fn default() -> Self {
701        Self {
702            root: default_paths_root(),
703            style: PathStyle::Relative,
704            map_windows_drives: false,
705            mappings: HashMap::new(),
706        }
707    }
708}
709
710/// Path style for file locks.
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712#[serde(rename_all = "snake_case")]
713#[derive(Default)]
714pub enum PathStyle {
715    /// Relative paths (e.g., src/main.rs)
716    #[default]
717    Relative,
718    /// Project-prefixed paths (e.g., ${project}/src/main.rs)
719    ProjectPrefixed,
720}
721
722/// Task state configuration.
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct StatesConfig {
725    /// Default state for new tasks.
726    #[serde(default = "default_initial_state")]
727    pub initial: String,
728
729    /// Default state for tasks when their owner disconnects (must be untimed).
730    #[serde(default = "default_disconnect_state")]
731    pub disconnect_state: String,
732
733    /// States that block dependent tasks (tasks in these states count as "not done").
734    #[serde(default = "default_blocking_states")]
735    pub blocking_states: Vec<String>,
736
737    /// State definitions with allowed transitions and timing behavior.
738    #[serde(default = "default_state_definitions")]
739    pub definitions: HashMap<String, StateDefinition>,
740}
741
742impl Default for StatesConfig {
743    fn default() -> Self {
744        Self {
745            initial: default_initial_state(),
746            disconnect_state: default_disconnect_state(),
747            blocking_states: default_blocking_states(),
748            definitions: default_state_definitions(),
749        }
750    }
751}
752
753fn default_initial_state() -> String {
754    "pending".to_string()
755}
756
757fn default_disconnect_state() -> String {
758    "pending".to_string()
759}
760
761fn default_blocking_states() -> Vec<String> {
762    vec![
763        "pending".to_string(),
764        "assigned".to_string(),
765        "working".to_string(),
766        "consult".to_string(),
767    ]
768}
769
770fn default_state_definitions() -> HashMap<String, StateDefinition> {
771    let mut defs = HashMap::new();
772
773    defs.insert(
774        "pending".to_string(),
775        StateDefinition {
776            exits: vec![
777                "assigned".to_string(),
778                "working".to_string(),
779                "cancelled".to_string(),
780            ],
781            timed: false,
782        },
783    );
784
785    defs.insert(
786        "assigned".to_string(),
787        StateDefinition {
788            exits: vec![
789                "working".to_string(),
790                "pending".to_string(),
791                "cancelled".to_string(),
792            ],
793            timed: false,
794        },
795    );
796
797    defs.insert(
798        "working".to_string(),
799        StateDefinition {
800            exits: vec![
801                "completed".to_string(),
802                "failed".to_string(),
803                "pending".to_string(),
804                "consult".to_string(),
805            ],
806            timed: true,
807        },
808    );
809
810    defs.insert(
811        "completed".to_string(),
812        StateDefinition {
813            exits: vec!["pending".to_string()],
814            timed: false,
815        },
816    );
817
818    defs.insert(
819        "failed".to_string(),
820        StateDefinition {
821            exits: vec!["pending".to_string()],
822            timed: false,
823        },
824    );
825
826    defs.insert(
827        "consult".to_string(),
828        StateDefinition {
829            exits: vec![
830                "working".to_string(),
831                "pending".to_string(),
832                "cancelled".to_string(),
833            ],
834            timed: false,
835        },
836    );
837
838    defs.insert(
839        "cancelled".to_string(),
840        StateDefinition {
841            exits: vec![],
842            timed: false,
843        },
844    );
845
846    defs
847}
848
849/// Definition of a single task state.
850#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct StateDefinition {
852    /// Allowed states to transition to from this state.
853    #[serde(default)]
854    pub exits: Vec<String>,
855
856    /// Whether time spent in this state should be tracked (accumulated to time_actual_ms).
857    #[serde(default)]
858    pub timed: bool,
859}
860
861/// Dependency type configuration.
862#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct DependenciesConfig {
864    /// Dependency type definitions.
865    #[serde(default = "default_dependency_definitions")]
866    pub definitions: HashMap<String, DependencyDefinition>,
867}
868
869impl Default for DependenciesConfig {
870    fn default() -> Self {
871        Self {
872            definitions: default_dependency_definitions(),
873        }
874    }
875}
876
877/// Definition of a dependency type.
878#[derive(Debug, Clone, Serialize, Deserialize)]
879pub struct DependencyDefinition {
880    /// Display orientation: "horizontal" (same level) or "vertical" (parent-child).
881    pub display: DependencyDisplay,
882
883    /// What this dependency blocks: "start" (blocks claiming) or "completion" (blocks completing).
884    pub blocks: BlockTarget,
885}
886
887/// Display orientation for dependency visualization.
888#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
889#[serde(rename_all = "snake_case")]
890pub enum DependencyDisplay {
891    /// Same level dependencies (blocks, follows).
892    Horizontal,
893    /// Parent-child relationships (contains).
894    Vertical,
895}
896
897/// What a dependency blocks.
898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
899#[serde(rename_all = "snake_case")]
900pub enum BlockTarget {
901    /// Does not block - informational link only.
902    None,
903    /// Blocks the task from being started/claimed.
904    Start,
905    /// Blocks the task from being completed.
906    Completion,
907}
908
909fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
910    let mut defs = HashMap::new();
911
912    // Primary workflow types (blocking)
913    defs.insert(
914        "blocks".to_string(),
915        DependencyDefinition {
916            display: DependencyDisplay::Horizontal,
917            blocks: BlockTarget::Start,
918        },
919    );
920
921    defs.insert(
922        "follows".to_string(),
923        DependencyDefinition {
924            display: DependencyDisplay::Horizontal,
925            blocks: BlockTarget::Start,
926        },
927    );
928
929    defs.insert(
930        "contains".to_string(),
931        DependencyDefinition {
932            display: DependencyDisplay::Vertical,
933            blocks: BlockTarget::Completion,
934        },
935    );
936
937    // Non-blocking relationship types
938    defs.insert(
939        "duplicate".to_string(),
940        DependencyDefinition {
941            display: DependencyDisplay::Horizontal,
942            blocks: BlockTarget::None,
943        },
944    );
945
946    defs.insert(
947        "see-also".to_string(),
948        DependencyDefinition {
949            display: DependencyDisplay::Horizontal,
950            blocks: BlockTarget::None,
951        },
952    );
953
954    defs.insert(
955        "relates-to".to_string(),
956        DependencyDefinition {
957            display: DependencyDisplay::Horizontal,
958            blocks: BlockTarget::None,
959        },
960    );
961
962    defs
963}
964
965impl DependenciesConfig {
966    /// Check if a dependency type is valid.
967    pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
968        self.definitions.contains_key(dep_type)
969    }
970
971    /// Get the definition for a dependency type.
972    pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
973        self.definitions.get(dep_type)
974    }
975
976    /// Get all dependency types that block start.
977    pub fn start_blocking_types(&self) -> Vec<&str> {
978        self.definitions
979            .iter()
980            .filter(|(_, def)| def.blocks == BlockTarget::Start)
981            .map(|(name, _)| name.as_str())
982            .collect()
983    }
984
985    /// Get all dependency types that block completion.
986    pub fn completion_blocking_types(&self) -> Vec<&str> {
987        self.definitions
988            .iter()
989            .filter(|(_, def)| def.blocks == BlockTarget::Completion)
990            .map(|(name, _)| name.as_str())
991            .collect()
992    }
993
994    /// Get all vertical (parent-child) dependency types.
995    pub fn vertical_types(&self) -> Vec<&str> {
996        self.definitions
997            .iter()
998            .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
999            .map(|(name, _)| name.as_str())
1000            .collect()
1001    }
1002
1003    /// Get all dependency type names.
1004    pub fn dep_type_names(&self) -> Vec<&str> {
1005        self.definitions.keys().map(|s| s.as_str()).collect()
1006    }
1007
1008    /// Validate the dependencies configuration.
1009    pub fn validate(&self) -> anyhow::Result<()> {
1010        if self.definitions.is_empty() {
1011            return Err(anyhow::anyhow!(
1012                "At least one dependency type must be defined"
1013            ));
1014        }
1015
1016        // Check for at least one start-blocking type (for task sequencing)
1017        let has_start_blocking = self
1018            .definitions
1019            .values()
1020            .any(|d| d.blocks == BlockTarget::Start);
1021        if !has_start_blocking {
1022            return Err(anyhow::anyhow!(
1023                "At least one dependency type with blocks: start must be defined"
1024            ));
1025        }
1026
1027        Ok(())
1028    }
1029}
1030
1031impl StatesConfig {
1032    /// Check if a state is a valid defined state.
1033    pub fn is_valid_state(&self, state: &str) -> bool {
1034        self.definitions.contains_key(state)
1035    }
1036
1037    /// Check if a transition from one state to another is allowed.
1038    pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
1039        if let Some(def) = self.definitions.get(from) {
1040            def.exits.contains(&to.to_string())
1041        } else {
1042            false
1043        }
1044    }
1045
1046    /// Check if a state is timed (accumulates duration).
1047    pub fn is_timed_state(&self, state: &str) -> bool {
1048        self.definitions
1049            .get(state)
1050            .map(|d| d.timed)
1051            .unwrap_or(false)
1052    }
1053
1054    /// Check if a state is terminal (has no exits).
1055    pub fn is_terminal_state(&self, state: &str) -> bool {
1056        self.definitions
1057            .get(state)
1058            .map(|d| d.exits.is_empty())
1059            .unwrap_or(false)
1060    }
1061
1062    /// Check if a state is a blocking state (blocks dependents).
1063    pub fn is_blocking_state(&self, state: &str) -> bool {
1064        self.blocking_states.contains(&state.to_string())
1065    }
1066
1067    /// Get all defined state names.
1068    pub fn state_names(&self) -> Vec<&str> {
1069        self.definitions.keys().map(|s| s.as_str()).collect()
1070    }
1071
1072    /// Get allowed exit states for a given state.
1073    pub fn get_exits(&self, state: &str) -> Vec<&str> {
1074        self.definitions
1075            .get(state)
1076            .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
1077            .unwrap_or_default()
1078    }
1079
1080    /// Get all untimed state names (valid for disconnect final_state).
1081    pub fn untimed_state_names(&self) -> Vec<&str> {
1082        self.definitions
1083            .iter()
1084            .filter(|(_, def)| !def.timed)
1085            .map(|(name, _)| name.as_str())
1086            .collect()
1087    }
1088
1089    /// Validate the states configuration.
1090    pub fn validate(&self) -> Result<()> {
1091        // Check initial state exists
1092        if !self.definitions.contains_key(&self.initial) {
1093            return Err(anyhow!(
1094                "Initial state '{}' is not defined in state definitions",
1095                self.initial
1096            ));
1097        }
1098
1099        // Check disconnect_state exists and is not timed
1100        if !self.definitions.contains_key(&self.disconnect_state) {
1101            return Err(anyhow!(
1102                "Disconnect state '{}' is not defined in state definitions",
1103                self.disconnect_state
1104            ));
1105        }
1106        if self.is_timed_state(&self.disconnect_state) {
1107            return Err(anyhow!(
1108                "Disconnect state '{}' must not be a timed state",
1109                self.disconnect_state
1110            ));
1111        }
1112
1113        // Check all blocking_states exist
1114        for state in &self.blocking_states {
1115            if !self.definitions.contains_key(state) {
1116                return Err(anyhow!(
1117                    "Blocking state '{}' is not defined in state definitions",
1118                    state
1119                ));
1120            }
1121        }
1122
1123        // Check all exit targets exist
1124        for (state_name, def) in &self.definitions {
1125            for exit in &def.exits {
1126                if !self.definitions.contains_key(exit) {
1127                    return Err(anyhow!(
1128                        "State '{}' has exit '{}' which is not defined",
1129                        state_name,
1130                        exit
1131                    ));
1132                }
1133            }
1134        }
1135
1136        // Check at least one terminal state exists
1137        let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
1138        if !has_terminal {
1139            return Err(anyhow!(
1140                "At least one terminal state (with empty exits) must be defined"
1141            ));
1142        }
1143
1144        Ok(())
1145    }
1146}
1147
1148/// Phase configuration for categorizing type of work.
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1150pub struct PhasesConfig {
1151    /// Behavior for unknown phase values (allow, warn, reject).
1152    #[serde(default)]
1153    pub unknown_phase: UnknownKeyBehavior,
1154
1155    /// Known phase definitions.
1156    #[serde(default = "default_phases")]
1157    pub definitions: HashSet<String>,
1158}
1159
1160impl Default for PhasesConfig {
1161    fn default() -> Self {
1162        Self {
1163            unknown_phase: UnknownKeyBehavior::Warn,
1164            definitions: default_phases(),
1165        }
1166    }
1167}
1168
1169fn default_phases() -> HashSet<String> {
1170    [
1171        "deliver",   // Top-level deliverable
1172        "triage",    // Initial assessment and prioritization
1173        "explore",   // Research and discovery
1174        "diagnose",  // Debugging and troubleshooting
1175        "design",    // Architecture and design
1176        "plan",      // Planning and specification
1177        "implement", // Implementation/coding
1178        "test",      // Testing and validation
1179        "review",    // Code review
1180        "security",  // Security review/audit
1181        "doc",       // Documentation
1182        "integrate", // Integration work
1183        "deploy",    // Release to staging/production
1184        "monitor",   // Observability and metrics
1185        "optimize",  // Performance tuning
1186    ]
1187    .iter()
1188    .map(|s| s.to_string())
1189    .collect()
1190}
1191
1192impl PhasesConfig {
1193    /// Check if a phase is a known/defined phase.
1194    pub fn is_known_phase(&self, phase: &str) -> bool {
1195        self.definitions.contains(phase)
1196    }
1197
1198    /// Get all defined phase names.
1199    pub fn phase_names(&self) -> Vec<&str> {
1200        self.definitions.iter().map(|s| s.as_str()).collect()
1201    }
1202
1203    /// Check a phase and return a warning message if unknown (based on unknown_phase behavior).
1204    /// Returns None if the phase is known or if behavior is Allow.
1205    /// Returns Some(warning) if behavior is Warn.
1206    /// Returns Err if behavior is Reject.
1207    pub fn check_phase(&self, phase: &str) -> Result<Option<String>> {
1208        if self.is_known_phase(phase) {
1209            return Ok(None);
1210        }
1211
1212        match self.unknown_phase {
1213            UnknownKeyBehavior::Allow => Ok(None),
1214            UnknownKeyBehavior::Warn => Ok(Some(format!(
1215                "Unknown phase '{}'. Known phases: {:?}",
1216                phase,
1217                self.phase_names()
1218            ))),
1219            UnknownKeyBehavior::Reject => Err(anyhow!(
1220                "Unknown phase '{}'. Known phases: {:?}. Configure in phases.definitions or set unknown_phase to 'allow' or 'warn'.",
1221                phase,
1222                self.phase_names()
1223            )),
1224        }
1225    }
1226}
1227
1228impl Config {
1229    /// Load configuration from file.
1230    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1231        let content = std::fs::read_to_string(path)?;
1232        let config: Config = serde_yaml::from_str(&content)?;
1233        Ok(config)
1234    }
1235
1236    /// Load configuration from default locations or return defaults.
1237    ///
1238    /// **Deprecated**: Use `ConfigLoader::load()` instead for proper tier merging.
1239    pub fn load_or_default() -> Self {
1240        // Try TASK_GRAPH_CONFIG_PATH environment variable first
1241        if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH")
1242            && let Ok(config) = Self::load(&config_path)
1243        {
1244            return config;
1245        }
1246
1247        // Try task-graph/config.yaml (new location)
1248        if let Ok(config) = Self::load("task-graph/config.yaml") {
1249            return config;
1250        }
1251
1252        // Try .task-graph/config.yaml (deprecated location)
1253        if let Ok(config) = Self::load(".task-graph/config.yaml") {
1254            return config;
1255        }
1256
1257        // Fall back to defaults with environment variable overrides
1258        let mut config = Self::default();
1259
1260        if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
1261            config.server.db_path = PathBuf::from(db_path);
1262        }
1263
1264        if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
1265            config.server.media_dir = PathBuf::from(media_dir);
1266        }
1267
1268        if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
1269            config.server.log_dir = PathBuf::from(log_dir);
1270        }
1271
1272        config
1273    }
1274
1275    /// Ensure the database directory exists.
1276    pub fn ensure_db_dir(&self) -> Result<()> {
1277        if let Some(parent) = self.server.db_path.parent() {
1278            std::fs::create_dir_all(parent)?;
1279        }
1280        Ok(())
1281    }
1282
1283    /// Ensure the media directory exists.
1284    pub fn ensure_media_dir(&self) -> Result<()> {
1285        std::fs::create_dir_all(&self.server.media_dir)?;
1286        Ok(())
1287    }
1288
1289    /// Ensure the log directory exists.
1290    pub fn ensure_log_dir(&self) -> Result<()> {
1291        std::fs::create_dir_all(&self.server.log_dir)?;
1292        Ok(())
1293    }
1294
1295    /// Get the media directory path.
1296    pub fn media_dir(&self) -> &Path {
1297        &self.server.media_dir
1298    }
1299
1300    /// Get the log directory path.
1301    pub fn log_dir(&self) -> &Path {
1302        &self.server.log_dir
1303    }
1304}
1305
1306/// Tool description override.
1307#[derive(Debug, Clone, Serialize, Deserialize)]
1308pub struct ToolPrompt {
1309    pub description: String,
1310}
1311
1312/// LLM-facing prompts configuration.
1313#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1314pub struct Prompts {
1315    /// Server instructions shown to the LLM.
1316    pub instructions: Option<String>,
1317
1318    /// Tool description overrides by tool name.
1319    #[serde(default)]
1320    pub tools: HashMap<String, ToolPrompt>,
1321}
1322
1323impl Prompts {
1324    /// Load prompts from file.
1325    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1326        let content = std::fs::read_to_string(path)?;
1327        // Handle empty or comment-only YAML files (which parse as null)
1328        let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
1329        Ok(prompts.unwrap_or_default())
1330    }
1331
1332    /// Load prompts from default location or return defaults.
1333    ///
1334    /// **Deprecated**: Use `ConfigLoader` for proper tier merging.
1335    pub fn load_or_default() -> Self {
1336        // Try task-graph/prompts.yaml (new location)
1337        if let Ok(prompts) = Self::load("task-graph/prompts.yaml") {
1338            return prompts;
1339        }
1340
1341        // Try .task-graph/prompts.yaml (deprecated location)
1342        if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
1343            return prompts;
1344        }
1345
1346        Self::default()
1347    }
1348
1349    /// Get a tool description override if available.
1350    pub fn get_tool_description(&self, name: &str) -> Option<&str> {
1351        self.tools.get(name).map(|t| t.description.as_str())
1352    }
1353}
1354
1355/// Consolidated application configuration.
1356///
1357/// Bundles all individual config types behind `Arc` so that they can be shared
1358/// cheaply across tool handlers, resource handlers, and option structs.
1359/// Instead of passing 8+ individual `Arc<XxxConfig>` parameters, callers pass
1360/// a single `&AppConfig`.
1361#[derive(Debug, Clone)]
1362pub struct AppConfig {
1363    pub states: Arc<StatesConfig>,
1364    pub phases: Arc<PhasesConfig>,
1365    pub deps: Arc<DependenciesConfig>,
1366    pub auto_advance: Arc<AutoAdvanceConfig>,
1367    pub attachments: Arc<AttachmentsConfig>,
1368    pub tags: Arc<TagsConfig>,
1369    pub ids: Arc<IdsConfig>,
1370    pub workflows: Arc<WorkflowsConfig>,
1371    pub feedback: Arc<FeedbackConfig>,
1372}
1373
1374impl AppConfig {
1375    /// Create a new `AppConfig` from individually wrapped configs.
1376    #[allow(clippy::too_many_arguments)]
1377    pub fn new(
1378        states: Arc<StatesConfig>,
1379        phases: Arc<PhasesConfig>,
1380        deps: Arc<DependenciesConfig>,
1381        auto_advance: Arc<AutoAdvanceConfig>,
1382        attachments: Arc<AttachmentsConfig>,
1383        tags: Arc<TagsConfig>,
1384        ids: Arc<IdsConfig>,
1385        workflows: Arc<WorkflowsConfig>,
1386        feedback: Arc<FeedbackConfig>,
1387    ) -> Self {
1388        Self {
1389            states,
1390            phases,
1391            deps,
1392            auto_advance,
1393            attachments,
1394            tags,
1395            ids,
1396            workflows,
1397            feedback,
1398        }
1399    }
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::*;
1405
1406    #[test]
1407    fn register_workflow_tags_adds_unknown_tags() {
1408        let mut tags_config = TagsConfig::default();
1409        assert!(tags_config.tag_names().is_empty());
1410
1411        tags_config.register_workflow_tags(&["worker".to_string(), "lead".to_string()]);
1412
1413        assert!(tags_config.is_known_tag("worker"));
1414        assert!(tags_config.is_known_tag("lead"));
1415        assert_eq!(tags_config.tag_names().len(), 2);
1416
1417        // Verify category
1418        let def = tags_config.definitions.get("worker").unwrap();
1419        assert_eq!(def.category, Some("workflow-role".to_string()));
1420    }
1421
1422    #[test]
1423    fn register_workflow_tags_preserves_existing_definitions() {
1424        let mut tags_config = TagsConfig::default();
1425        tags_config.definitions.insert(
1426            "worker".to_string(),
1427            TagDefinition {
1428                category: Some("custom".to_string()),
1429                description: Some("Manually defined".to_string()),
1430            },
1431        );
1432
1433        tags_config.register_workflow_tags(&["worker".to_string(), "lead".to_string()]);
1434
1435        // "worker" should keep its original definition
1436        let worker_def = tags_config.definitions.get("worker").unwrap();
1437        assert_eq!(worker_def.category, Some("custom".to_string()));
1438        assert_eq!(worker_def.description, Some("Manually defined".to_string()));
1439
1440        // "lead" should be newly registered
1441        let lead_def = tags_config.definitions.get("lead").unwrap();
1442        assert_eq!(lead_def.category, Some("workflow-role".to_string()));
1443    }
1444
1445    #[test]
1446    fn registered_workflow_tags_suppress_warnings() {
1447        let mut tags_config = TagsConfig {
1448            unknown_tag: UnknownKeyBehavior::Warn,
1449            ..Default::default()
1450        };
1451
1452        // Before registration, should warn
1453        let warnings = tags_config.validate_tags(&["worker".to_string()]).unwrap();
1454        assert_eq!(warnings.len(), 1);
1455        assert!(warnings[0].contains("Unknown tag"));
1456
1457        // After registration, no warnings
1458        tags_config.register_workflow_tags(&["worker".to_string()]);
1459        let warnings = tags_config.validate_tags(&["worker".to_string()]).unwrap();
1460        assert!(warnings.is_empty());
1461    }
1462}