1use 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
14pub const DEFAULT_UI_PORT: u16 = 31994;
16
17pub const DEFAULT_ID_WORDS: u8 = 2;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum IdCase {
24 #[default]
26 KebabCase,
27 SnakeCase,
29 CamelCase,
31 PascalCase,
33 Lowercase,
35 Uppercase,
37 TitleCase,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct IdsConfig {
44 #[serde(default = "default_id_words")]
46 pub task_id_words: u8,
47
48 #[serde(default = "default_id_words")]
50 pub agent_id_words: u8,
51
52 #[serde(default)]
54 pub id_case: IdCase,
55
56 #[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum UiMode {
111 #[default]
113 None,
114 Web,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct UiConfig {
121 #[serde(default)]
123 pub mode: UiMode,
124
125 #[serde(default = "default_ui_port")]
127 pub port: u16,
128
129 #[serde(default = "default_retry_initial_ms")]
131 pub retry_initial_ms: u64,
132
133 #[serde(default = "default_retry_jitter_ms")]
135 pub retry_jitter_ms: u64,
136
137 #[serde(default = "default_retry_max_ms")]
139 pub retry_max_ms: u64,
140
141 #[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 }
166
167fn default_retry_jitter_ms() -> u64 {
168 5_000 }
170
171fn default_retry_max_ms() -> u64 {
172 240_000 }
174
175fn default_retry_multiplier() -> f64 {
176 2.0
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct AutoAdvanceConfig {
182 #[serde(default)]
184 pub enabled: bool,
185
186 #[serde(default)]
189 pub target_state: Option<String>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195pub enum UnknownKeyBehavior {
196 Allow,
198 #[default]
200 Warn,
201 Reject,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
207#[serde(rename_all = "snake_case")]
208pub enum GateEnforcement {
209 Allow,
211 #[default]
213 Warn,
214 Reject,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct GateDefinition {
224 #[serde(rename = "type")]
226 pub gate_type: String,
227
228 #[serde(default)]
230 pub enforcement: GateEnforcement,
231
232 #[serde(default)]
234 pub description: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct AttachmentKeyDefinition {
240 pub mime: String,
242 #[serde(default = "default_append_mode")]
244 pub mode: String,
245}
246
247fn default_append_mode() -> String {
248 "append".to_string()
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct AttachmentsConfig {
254 #[serde(default)]
256 pub unknown_key: UnknownKeyBehavior,
257 #[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 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 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 pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
402 self.definitions.get(key)
403 }
404
405 pub fn is_known_key(&self, key: &str) -> bool {
407 self.definitions.contains_key(key)
408 }
409
410 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct TagDefinition {
430 #[serde(default)]
432 pub category: Option<String>,
433 #[serde(default)]
435 pub description: Option<String>,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize, Default)]
440pub struct TagsConfig {
441 #[serde(default)]
443 pub unknown_tag: UnknownKeyBehavior,
444 #[serde(default)]
446 pub definitions: HashMap<String, TagDefinition>,
447}
448
449impl TagsConfig {
450 pub fn is_known_tag(&self, tag: &str) -> bool {
452 self.definitions.contains_key(tag)
453 }
454
455 pub fn tag_names(&self) -> Vec<&str> {
457 self.definitions.keys().map(|s| s.as_str()).collect()
458 }
459
460 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 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 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 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct ServerPaths {
561 pub db_path: PathBuf,
563 pub media_dir: PathBuf,
565 pub log_dir: PathBuf,
567 pub config_path: Option<PathBuf>,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct ServerConfig {
574 #[serde(default = "default_db_path")]
576 pub db_path: PathBuf,
577
578 #[serde(default = "default_media_dir")]
580 pub media_dir: PathBuf,
581
582 #[serde(default = "default_claim_limit")]
584 pub claim_limit: i32,
585
586 #[serde(default = "default_stale_timeout")]
588 pub stale_timeout_seconds: i64,
589
590 #[serde(default)]
592 pub default_format: OutputFormat,
593
594 #[serde(default = "default_skills_dir")]
596 pub skills_dir: PathBuf,
597
598 #[serde(default = "default_log_dir")]
600 pub log_dir: PathBuf,
601
602 #[serde(default)]
604 pub ui: UiConfig,
605
606 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub default_workflow: Option<String>,
611
612 #[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 }
662
663fn default_page_size() -> i32 {
664 50
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize)]
669pub struct PathsConfig {
670 #[serde(default = "default_paths_root")]
672 pub root: String,
673
674 #[serde(default)]
676 pub style: PathStyle,
677
678 #[serde(default)]
680 pub map_windows_drives: bool,
681
682 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702#[derive(Default)]
703pub enum PathStyle {
704 #[default]
706 Relative,
707 ProjectPrefixed,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
713pub struct StatesConfig {
714 #[serde(default = "default_initial_state")]
716 pub initial: String,
717
718 #[serde(default = "default_disconnect_state")]
720 pub disconnect_state: String,
721
722 #[serde(default = "default_blocking_states")]
724 pub blocking_states: Vec<String>,
725
726 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct StateDefinition {
841 #[serde(default)]
843 pub exits: Vec<String>,
844
845 #[serde(default)]
847 pub timed: bool,
848}
849
850#[derive(Debug, Clone, Serialize, Deserialize)]
852pub struct DependenciesConfig {
853 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
868pub struct DependencyDefinition {
869 pub display: DependencyDisplay,
871
872 pub blocks: BlockTarget,
874}
875
876#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
878#[serde(rename_all = "snake_case")]
879pub enum DependencyDisplay {
880 Horizontal,
882 Vertical,
884}
885
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(rename_all = "snake_case")]
889pub enum BlockTarget {
890 None,
892 Start,
894 Completion,
896}
897
898fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
899 let mut defs = HashMap::new();
900
901 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 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 pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
957 self.definitions.contains_key(dep_type)
958 }
959
960 pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
962 self.definitions.get(dep_type)
963 }
964
965 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 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 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 pub fn dep_type_names(&self) -> Vec<&str> {
994 self.definitions.keys().map(|s| s.as_str()).collect()
995 }
996
997 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 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 pub fn is_valid_state(&self, state: &str) -> bool {
1023 self.definitions.contains_key(state)
1024 }
1025
1026 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 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 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 pub fn is_blocking_state(&self, state: &str) -> bool {
1053 self.blocking_states.contains(&state.to_string())
1054 }
1055
1056 pub fn state_names(&self) -> Vec<&str> {
1058 self.definitions.keys().map(|s| s.as_str()).collect()
1059 }
1060
1061 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 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 pub fn validate(&self) -> Result<()> {
1080 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
1139pub struct PhasesConfig {
1140 #[serde(default)]
1142 pub unknown_phase: UnknownKeyBehavior,
1143
1144 #[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", "triage", "explore", "diagnose", "design", "plan", "implement", "test", "review", "security", "doc", "integrate", "deploy", "monitor", "optimize", ]
1176 .iter()
1177 .map(|s| s.to_string())
1178 .collect()
1179}
1180
1181impl PhasesConfig {
1182 pub fn is_known_phase(&self, phase: &str) -> bool {
1184 self.definitions.contains(phase)
1185 }
1186
1187 pub fn phase_names(&self) -> Vec<&str> {
1189 self.definitions.iter().map(|s| s.as_str()).collect()
1190 }
1191
1192 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 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 pub fn load_or_default() -> Self {
1229 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 if let Ok(config) = Self::load("task-graph/config.yaml") {
1238 return config;
1239 }
1240
1241 if let Ok(config) = Self::load(".task-graph/config.yaml") {
1243 return config;
1244 }
1245
1246 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 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 pub fn ensure_media_dir(&self) -> Result<()> {
1274 std::fs::create_dir_all(&self.server.media_dir)?;
1275 Ok(())
1276 }
1277
1278 pub fn ensure_log_dir(&self) -> Result<()> {
1280 std::fs::create_dir_all(&self.server.log_dir)?;
1281 Ok(())
1282 }
1283
1284 pub fn media_dir(&self) -> &Path {
1286 &self.server.media_dir
1287 }
1288
1289 pub fn log_dir(&self) -> &Path {
1291 &self.server.log_dir
1292 }
1293}
1294
1295#[derive(Debug, Clone, Serialize, Deserialize)]
1297pub struct ToolPrompt {
1298 pub description: String,
1299}
1300
1301#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1303pub struct Prompts {
1304 pub instructions: Option<String>,
1306
1307 #[serde(default)]
1309 pub tools: HashMap<String, ToolPrompt>,
1310}
1311
1312impl Prompts {
1313 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1315 let content = std::fs::read_to_string(path)?;
1316 let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
1318 Ok(prompts.unwrap_or_default())
1319 }
1320
1321 pub fn load_or_default() -> Self {
1325 if let Ok(prompts) = Self::load("task-graph/prompts.yaml") {
1327 return prompts;
1328 }
1329
1330 if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
1332 return prompts;
1333 }
1334
1335 Self::default()
1336 }
1337
1338 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#[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 #[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 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 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 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 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 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}