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