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)]
181pub struct AutoAdvanceConfig {
182 #[serde(default)]
184 pub enabled: bool,
185
186 #[serde(default)]
189 pub target_state: Option<String>,
190
191 #[serde(default = "default_true")]
194 pub auto_rollup: bool,
195}
196
197impl Default for AutoAdvanceConfig {
198 fn default() -> Self {
199 Self {
200 enabled: false,
201 target_state: None,
202 auto_rollup: true,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct FeedbackConfig {
210 #[serde(default = "default_true")]
212 pub enabled: bool,
213 #[serde(default = "default_feedback_max_size")]
215 pub max_size_bytes: u64,
216}
217
218impl Default for FeedbackConfig {
219 fn default() -> Self {
220 Self {
221 enabled: true,
222 max_size_bytes: default_feedback_max_size(),
223 }
224 }
225}
226
227fn default_true() -> bool {
228 true
229}
230
231fn default_feedback_max_size() -> u64 {
232 1_048_576 }
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
237#[serde(rename_all = "snake_case")]
238pub enum UnknownKeyBehavior {
239 Allow,
241 #[default]
243 Warn,
244 Reject,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
250#[serde(rename_all = "snake_case")]
251pub enum GateEnforcement {
252 Allow,
254 #[default]
256 Warn,
257 Reject,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct GateDefinition {
267 #[serde(rename = "type")]
269 pub gate_type: String,
270
271 #[serde(default)]
273 pub enforcement: GateEnforcement,
274
275 #[serde(default)]
277 pub description: String,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct AttachmentKeyDefinition {
283 pub mime: String,
285 #[serde(default = "default_append_mode")]
287 pub mode: String,
288}
289
290fn default_append_mode() -> String {
291 "append".to_string()
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct AttachmentsConfig {
297 #[serde(default)]
299 pub unknown_key: UnknownKeyBehavior,
300 #[serde(default = "AttachmentsConfig::default_definitions")]
302 pub definitions: HashMap<String, AttachmentKeyDefinition>,
303}
304
305impl Default for AttachmentsConfig {
306 fn default() -> Self {
307 Self {
308 unknown_key: UnknownKeyBehavior::default(),
309 definitions: Self::default_definitions(),
310 }
311 }
312}
313
314impl AttachmentsConfig {
315 pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
317 let mut defs = HashMap::new();
318
319 defs.insert(
320 "commit".to_string(),
321 AttachmentKeyDefinition {
322 mime: "text/git.hash".to_string(),
323 mode: "append".to_string(),
324 },
325 );
326
327 defs.insert(
328 "checkin".to_string(),
329 AttachmentKeyDefinition {
330 mime: "text/p4.changelist".to_string(),
331 mode: "append".to_string(),
332 },
333 );
334
335 defs.insert(
336 "meta".to_string(),
337 AttachmentKeyDefinition {
338 mime: "application/json".to_string(),
339 mode: "replace".to_string(),
340 },
341 );
342
343 defs.insert(
344 "note".to_string(),
345 AttachmentKeyDefinition {
346 mime: "text/plain".to_string(),
347 mode: "append".to_string(),
348 },
349 );
350
351 defs.insert(
352 "log".to_string(),
353 AttachmentKeyDefinition {
354 mime: "text/plain".to_string(),
355 mode: "append".to_string(),
356 },
357 );
358
359 defs.insert(
360 "error".to_string(),
361 AttachmentKeyDefinition {
362 mime: "text/plain".to_string(),
363 mode: "append".to_string(),
364 },
365 );
366
367 defs.insert(
368 "output".to_string(),
369 AttachmentKeyDefinition {
370 mime: "text/plain".to_string(),
371 mode: "append".to_string(),
372 },
373 );
374
375 defs.insert(
376 "diff".to_string(),
377 AttachmentKeyDefinition {
378 mime: "text/x-diff".to_string(),
379 mode: "append".to_string(),
380 },
381 );
382
383 defs.insert(
384 "changelist".to_string(),
385 AttachmentKeyDefinition {
386 mime: "text/plain".to_string(),
387 mode: "append".to_string(),
388 },
389 );
390
391 defs.insert(
392 "plan".to_string(),
393 AttachmentKeyDefinition {
394 mime: "text/markdown".to_string(),
395 mode: "replace".to_string(),
396 },
397 );
398
399 defs.insert(
400 "result".to_string(),
401 AttachmentKeyDefinition {
402 mime: "application/json".to_string(),
403 mode: "replace".to_string(),
404 },
405 );
406
407 defs.insert(
408 "context".to_string(),
409 AttachmentKeyDefinition {
410 mime: "text/plain".to_string(),
411 mode: "replace".to_string(),
412 },
413 );
414
415 defs.insert(
417 "gate/tests".to_string(),
418 AttachmentKeyDefinition {
419 mime: "text/plain".to_string(),
420 mode: "append".to_string(),
421 },
422 );
423
424 defs.insert(
425 "gate/commit".to_string(),
426 AttachmentKeyDefinition {
427 mime: "text/plain".to_string(),
428 mode: "append".to_string(),
429 },
430 );
431
432 defs.insert(
433 "gate/review".to_string(),
434 AttachmentKeyDefinition {
435 mime: "text/plain".to_string(),
436 mode: "append".to_string(),
437 },
438 );
439
440 defs
441 }
442
443 pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
445 self.definitions.get(key)
446 }
447
448 pub fn is_known_key(&self, key: &str) -> bool {
450 self.definitions.contains_key(key)
451 }
452
453 pub fn get_mime_default(&self, key: &str) -> &str {
455 self.definitions
456 .get(key)
457 .map(|d| d.mime.as_str())
458 .unwrap_or("text/plain")
459 }
460
461 pub fn get_mode_default(&self, key: &str) -> &str {
463 self.definitions
464 .get(key)
465 .map(|d| d.mode.as_str())
466 .unwrap_or("append")
467 }
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct TagDefinition {
473 #[serde(default)]
475 pub category: Option<String>,
476 #[serde(default)]
478 pub description: Option<String>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize, Default)]
483pub struct TagsConfig {
484 #[serde(default)]
486 pub unknown_tag: UnknownKeyBehavior,
487 #[serde(default)]
489 pub definitions: HashMap<String, TagDefinition>,
490}
491
492impl TagsConfig {
493 pub fn is_known_tag(&self, tag: &str) -> bool {
495 self.definitions.contains_key(tag)
496 }
497
498 pub fn tag_names(&self) -> Vec<&str> {
500 self.definitions.keys().map(|s| s.as_str()).collect()
501 }
502
503 pub fn tags_in_category(&self, category: &str) -> Vec<&str> {
505 self.definitions
506 .iter()
507 .filter(|(_, def)| def.category.as_deref() == Some(category))
508 .map(|(name, _)| name.as_str())
509 .collect()
510 }
511
512 pub fn categories(&self) -> Vec<&str> {
514 let mut cats: Vec<&str> = self
515 .definitions
516 .values()
517 .filter_map(|def| def.category.as_deref())
518 .collect();
519 cats.sort();
520 cats.dedup();
521 cats
522 }
523
524 pub fn validate_tag(&self, tag: &str) -> Result<Option<String>> {
526 if self.is_known_tag(tag) {
527 return Ok(None);
528 }
529
530 match self.unknown_tag {
531 UnknownKeyBehavior::Allow => Ok(None),
532 UnknownKeyBehavior::Warn => Ok(Some(format!(
533 "Unknown tag '{}'. Known tags: {:?}",
534 tag,
535 self.tag_names()
536 ))),
537 UnknownKeyBehavior::Reject => Err(anyhow!(
538 "Unknown tag '{}'. Configure in tags.definitions or set unknown_tag to 'allow' or 'warn'. Known tags: {:?}",
539 tag,
540 self.tag_names()
541 )),
542 }
543 }
544
545 pub fn validate_tags(&self, tags: &[String]) -> Result<Vec<String>> {
547 let mut warnings = Vec::new();
548 for tag in tags {
549 if let Some(warning) = self.validate_tag(tag)? {
550 warnings.push(warning);
551 }
552 }
553 Ok(warnings)
554 }
555
556 pub fn register_workflow_tags(&mut self, role_tags: &[String]) {
559 for tag in role_tags {
560 self.definitions
561 .entry(tag.clone())
562 .or_insert_with(|| TagDefinition {
563 category: Some("workflow-role".to_string()),
564 description: Some("Auto-registered from workflow role definition".to_string()),
565 });
566 }
567 }
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, Default)]
572pub struct Config {
573 #[serde(default)]
574 pub server: ServerConfig,
575
576 #[serde(default)]
577 pub paths: PathsConfig,
578
579 #[serde(default)]
580 pub states: StatesConfig,
581
582 #[serde(default)]
583 pub dependencies: DependenciesConfig,
584
585 #[serde(default)]
586 pub auto_advance: AutoAdvanceConfig,
587
588 #[serde(default)]
589 pub attachments: AttachmentsConfig,
590
591 #[serde(default)]
592 pub phases: PhasesConfig,
593
594 #[serde(default)]
595 pub tags: TagsConfig,
596
597 #[serde(default)]
598 pub ids: IdsConfig,
599
600 #[serde(default)]
601 pub feedback: FeedbackConfig,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct ServerPaths {
607 pub db_path: PathBuf,
609 pub media_dir: PathBuf,
611 pub log_dir: PathBuf,
613 pub config_path: Option<PathBuf>,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize)]
619pub struct ServerConfig {
620 #[serde(default = "default_db_path")]
622 pub db_path: PathBuf,
623
624 #[serde(default = "default_media_dir")]
626 pub media_dir: PathBuf,
627
628 #[serde(default = "default_claim_limit")]
630 pub claim_limit: i32,
631
632 #[serde(default = "default_stale_timeout")]
634 pub stale_timeout_seconds: i64,
635
636 #[serde(default)]
638 pub default_format: OutputFormat,
639
640 #[serde(default = "default_skills_dir")]
642 pub skills_dir: PathBuf,
643
644 #[serde(default = "default_log_dir")]
646 pub log_dir: PathBuf,
647
648 #[serde(default)]
650 pub ui: UiConfig,
651
652 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub default_workflow: Option<String>,
657
658 #[serde(default = "default_page_size")]
661 pub default_page_size: i32,
662}
663
664impl Default for ServerConfig {
665 fn default() -> Self {
666 Self {
667 db_path: default_db_path(),
668 media_dir: default_media_dir(),
669 claim_limit: default_claim_limit(),
670 stale_timeout_seconds: default_stale_timeout(),
671 default_format: OutputFormat::default(),
672 skills_dir: default_skills_dir(),
673 log_dir: default_log_dir(),
674 ui: UiConfig::default(),
675 default_workflow: None,
676 default_page_size: default_page_size(),
677 }
678 }
679}
680
681fn default_db_path() -> PathBuf {
682 PathBuf::from("task-graph/tasks.db")
683}
684
685fn default_media_dir() -> PathBuf {
686 PathBuf::from("task-graph/media")
687}
688
689fn default_skills_dir() -> PathBuf {
690 PathBuf::from("task-graph/skills")
691}
692
693fn default_log_dir() -> PathBuf {
694 PathBuf::from("task-graph/logs")
695}
696
697fn default_paths_root() -> String {
698 ".".to_string()
699}
700
701fn default_claim_limit() -> i32 {
702 5
703}
704
705fn default_stale_timeout() -> i64 {
706 900 }
708
709fn default_page_size() -> i32 {
710 50
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct PathsConfig {
716 #[serde(default = "default_paths_root")]
718 pub root: String,
719
720 #[serde(default)]
722 pub style: PathStyle,
723
724 #[serde(default)]
726 pub map_windows_drives: bool,
727
728 #[serde(default)]
731 pub mappings: HashMap<String, String>,
732}
733
734impl Default for PathsConfig {
735 fn default() -> Self {
736 Self {
737 root: default_paths_root(),
738 style: PathStyle::Relative,
739 map_windows_drives: false,
740 mappings: HashMap::new(),
741 }
742 }
743}
744
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
747#[serde(rename_all = "snake_case")]
748#[derive(Default)]
749pub enum PathStyle {
750 #[default]
752 Relative,
753 ProjectPrefixed,
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize)]
759pub struct StatesConfig {
760 #[serde(default = "default_initial_state")]
762 pub initial: String,
763
764 #[serde(default = "default_disconnect_state")]
766 pub disconnect_state: String,
767
768 #[serde(default = "default_blocking_states")]
770 pub blocking_states: Vec<String>,
771
772 #[serde(default = "default_state_definitions")]
774 pub definitions: HashMap<String, StateDefinition>,
775}
776
777impl Default for StatesConfig {
778 fn default() -> Self {
779 Self {
780 initial: default_initial_state(),
781 disconnect_state: default_disconnect_state(),
782 blocking_states: default_blocking_states(),
783 definitions: default_state_definitions(),
784 }
785 }
786}
787
788fn default_initial_state() -> String {
789 "pending".to_string()
790}
791
792fn default_disconnect_state() -> String {
793 "pending".to_string()
794}
795
796fn default_blocking_states() -> Vec<String> {
797 vec![
798 "pending".to_string(),
799 "assigned".to_string(),
800 "working".to_string(),
801 "consult".to_string(),
802 ]
803}
804
805fn default_state_definitions() -> HashMap<String, StateDefinition> {
806 let mut defs = HashMap::new();
807
808 defs.insert(
809 "pending".to_string(),
810 StateDefinition {
811 exits: vec![
812 "assigned".to_string(),
813 "working".to_string(),
814 "cancelled".to_string(),
815 ],
816 timed: false,
817 },
818 );
819
820 defs.insert(
821 "assigned".to_string(),
822 StateDefinition {
823 exits: vec![
824 "working".to_string(),
825 "pending".to_string(),
826 "cancelled".to_string(),
827 ],
828 timed: false,
829 },
830 );
831
832 defs.insert(
833 "working".to_string(),
834 StateDefinition {
835 exits: vec![
836 "completed".to_string(),
837 "failed".to_string(),
838 "pending".to_string(),
839 "consult".to_string(),
840 ],
841 timed: true,
842 },
843 );
844
845 defs.insert(
846 "completed".to_string(),
847 StateDefinition {
848 exits: vec!["pending".to_string()],
849 timed: false,
850 },
851 );
852
853 defs.insert(
854 "failed".to_string(),
855 StateDefinition {
856 exits: vec!["pending".to_string()],
857 timed: false,
858 },
859 );
860
861 defs.insert(
862 "consult".to_string(),
863 StateDefinition {
864 exits: vec![
865 "working".to_string(),
866 "pending".to_string(),
867 "cancelled".to_string(),
868 ],
869 timed: false,
870 },
871 );
872
873 defs.insert(
874 "cancelled".to_string(),
875 StateDefinition {
876 exits: vec![],
877 timed: false,
878 },
879 );
880
881 defs
882}
883
884#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct StateDefinition {
887 #[serde(default)]
889 pub exits: Vec<String>,
890
891 #[serde(default)]
893 pub timed: bool,
894}
895
896#[derive(Debug, Clone, Serialize, Deserialize)]
898pub struct DependenciesConfig {
899 #[serde(default = "default_dependency_definitions")]
901 pub definitions: HashMap<String, DependencyDefinition>,
902}
903
904impl Default for DependenciesConfig {
905 fn default() -> Self {
906 Self {
907 definitions: default_dependency_definitions(),
908 }
909 }
910}
911
912#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct DependencyDefinition {
915 pub display: DependencyDisplay,
917
918 pub blocks: BlockTarget,
920}
921
922#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
924#[serde(rename_all = "snake_case")]
925pub enum DependencyDisplay {
926 Horizontal,
928 Vertical,
930}
931
932#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
934#[serde(rename_all = "snake_case")]
935pub enum BlockTarget {
936 None,
938 Start,
940 Completion,
942}
943
944fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
945 let mut defs = HashMap::new();
946
947 defs.insert(
949 "blocks".to_string(),
950 DependencyDefinition {
951 display: DependencyDisplay::Horizontal,
952 blocks: BlockTarget::Start,
953 },
954 );
955
956 defs.insert(
957 "follows".to_string(),
958 DependencyDefinition {
959 display: DependencyDisplay::Horizontal,
960 blocks: BlockTarget::Start,
961 },
962 );
963
964 defs.insert(
965 "contains".to_string(),
966 DependencyDefinition {
967 display: DependencyDisplay::Vertical,
968 blocks: BlockTarget::Completion,
969 },
970 );
971
972 defs.insert(
974 "duplicate".to_string(),
975 DependencyDefinition {
976 display: DependencyDisplay::Horizontal,
977 blocks: BlockTarget::None,
978 },
979 );
980
981 defs.insert(
982 "see-also".to_string(),
983 DependencyDefinition {
984 display: DependencyDisplay::Horizontal,
985 blocks: BlockTarget::None,
986 },
987 );
988
989 defs.insert(
990 "relates-to".to_string(),
991 DependencyDefinition {
992 display: DependencyDisplay::Horizontal,
993 blocks: BlockTarget::None,
994 },
995 );
996
997 defs
998}
999
1000impl DependenciesConfig {
1001 pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
1003 self.definitions.contains_key(dep_type)
1004 }
1005
1006 pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
1008 self.definitions.get(dep_type)
1009 }
1010
1011 pub fn start_blocking_types(&self) -> Vec<&str> {
1013 self.definitions
1014 .iter()
1015 .filter(|(_, def)| def.blocks == BlockTarget::Start)
1016 .map(|(name, _)| name.as_str())
1017 .collect()
1018 }
1019
1020 pub fn completion_blocking_types(&self) -> Vec<&str> {
1022 self.definitions
1023 .iter()
1024 .filter(|(_, def)| def.blocks == BlockTarget::Completion)
1025 .map(|(name, _)| name.as_str())
1026 .collect()
1027 }
1028
1029 pub fn vertical_types(&self) -> Vec<&str> {
1031 self.definitions
1032 .iter()
1033 .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
1034 .map(|(name, _)| name.as_str())
1035 .collect()
1036 }
1037
1038 pub fn dep_type_names(&self) -> Vec<&str> {
1040 self.definitions.keys().map(|s| s.as_str()).collect()
1041 }
1042
1043 pub fn validate(&self) -> anyhow::Result<()> {
1045 if self.definitions.is_empty() {
1046 return Err(anyhow::anyhow!(
1047 "At least one dependency type must be defined"
1048 ));
1049 }
1050
1051 let has_start_blocking = self
1053 .definitions
1054 .values()
1055 .any(|d| d.blocks == BlockTarget::Start);
1056 if !has_start_blocking {
1057 return Err(anyhow::anyhow!(
1058 "At least one dependency type with blocks: start must be defined"
1059 ));
1060 }
1061
1062 Ok(())
1063 }
1064}
1065
1066impl StatesConfig {
1067 pub fn is_valid_state(&self, state: &str) -> bool {
1069 self.definitions.contains_key(state)
1070 }
1071
1072 pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
1074 if let Some(def) = self.definitions.get(from) {
1075 def.exits.contains(&to.to_string())
1076 } else {
1077 false
1078 }
1079 }
1080
1081 pub fn is_timed_state(&self, state: &str) -> bool {
1083 self.definitions
1084 .get(state)
1085 .map(|d| d.timed)
1086 .unwrap_or(false)
1087 }
1088
1089 pub fn is_terminal_state(&self, state: &str) -> bool {
1091 self.definitions
1092 .get(state)
1093 .map(|d| d.exits.is_empty())
1094 .unwrap_or(false)
1095 }
1096
1097 pub fn is_blocking_state(&self, state: &str) -> bool {
1099 self.blocking_states.contains(&state.to_string())
1100 }
1101
1102 pub fn state_names(&self) -> Vec<&str> {
1104 self.definitions.keys().map(|s| s.as_str()).collect()
1105 }
1106
1107 pub fn get_exits(&self, state: &str) -> Vec<&str> {
1109 self.definitions
1110 .get(state)
1111 .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
1112 .unwrap_or_default()
1113 }
1114
1115 pub fn timed_state_names(&self) -> Vec<&str> {
1117 self.definitions
1118 .iter()
1119 .filter(|(_, def)| def.timed)
1120 .map(|(name, _)| name.as_str())
1121 .collect()
1122 }
1123
1124 pub fn untimed_state_names(&self) -> Vec<&str> {
1126 self.definitions
1127 .iter()
1128 .filter(|(_, def)| !def.timed)
1129 .map(|(name, _)| name.as_str())
1130 .collect()
1131 }
1132
1133 pub fn validate(&self) -> Result<()> {
1135 if !self.definitions.contains_key(&self.initial) {
1137 return Err(anyhow!(
1138 "Initial state '{}' is not defined in state definitions",
1139 self.initial
1140 ));
1141 }
1142
1143 if !self.definitions.contains_key(&self.disconnect_state) {
1145 return Err(anyhow!(
1146 "Disconnect state '{}' is not defined in state definitions",
1147 self.disconnect_state
1148 ));
1149 }
1150 if self.is_timed_state(&self.disconnect_state) {
1151 return Err(anyhow!(
1152 "Disconnect state '{}' must not be a timed state",
1153 self.disconnect_state
1154 ));
1155 }
1156
1157 for state in &self.blocking_states {
1159 if !self.definitions.contains_key(state) {
1160 return Err(anyhow!(
1161 "Blocking state '{}' is not defined in state definitions",
1162 state
1163 ));
1164 }
1165 }
1166
1167 for (state_name, def) in &self.definitions {
1169 for exit in &def.exits {
1170 if !self.definitions.contains_key(exit) {
1171 return Err(anyhow!(
1172 "State '{}' has exit '{}' which is not defined",
1173 state_name,
1174 exit
1175 ));
1176 }
1177 }
1178 }
1179
1180 let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
1182 if !has_terminal {
1183 return Err(anyhow!(
1184 "At least one terminal state (with empty exits) must be defined"
1185 ));
1186 }
1187
1188 Ok(())
1189 }
1190}
1191
1192#[derive(Debug, Clone, Serialize, Deserialize)]
1194pub struct PhasesConfig {
1195 #[serde(default)]
1197 pub unknown_phase: UnknownKeyBehavior,
1198
1199 #[serde(default = "default_phases")]
1201 pub definitions: HashSet<String>,
1202}
1203
1204impl Default for PhasesConfig {
1205 fn default() -> Self {
1206 Self {
1207 unknown_phase: UnknownKeyBehavior::Warn,
1208 definitions: default_phases(),
1209 }
1210 }
1211}
1212
1213fn default_phases() -> HashSet<String> {
1214 [
1215 "deliver", "triage", "explore", "diagnose", "design", "plan", "implement", "test", "review", "security", "doc", "integrate", "deploy", "monitor", "optimize", ]
1231 .iter()
1232 .map(|s| s.to_string())
1233 .collect()
1234}
1235
1236impl PhasesConfig {
1237 pub fn is_known_phase(&self, phase: &str) -> bool {
1239 self.definitions.contains(phase)
1240 }
1241
1242 pub fn phase_names(&self) -> Vec<&str> {
1244 self.definitions.iter().map(|s| s.as_str()).collect()
1245 }
1246
1247 pub fn check_phase(&self, phase: &str) -> Result<Option<String>> {
1252 if self.is_known_phase(phase) {
1253 return Ok(None);
1254 }
1255
1256 match self.unknown_phase {
1257 UnknownKeyBehavior::Allow => Ok(None),
1258 UnknownKeyBehavior::Warn => Ok(Some(format!(
1259 "Unknown phase '{}'. Known phases: {:?}",
1260 phase,
1261 self.phase_names()
1262 ))),
1263 UnknownKeyBehavior::Reject => Err(anyhow!(
1264 "Unknown phase '{}'. Known phases: {:?}. Configure in phases.definitions or set unknown_phase to 'allow' or 'warn'.",
1265 phase,
1266 self.phase_names()
1267 )),
1268 }
1269 }
1270}
1271
1272impl Config {
1273 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1275 let content = std::fs::read_to_string(path)?;
1276 let config: Config = serde_yaml::from_str(&content)?;
1277 Ok(config)
1278 }
1279
1280 pub fn load_or_default() -> Self {
1284 if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH")
1286 && let Ok(config) = Self::load(&config_path)
1287 {
1288 return config;
1289 }
1290
1291 if let Ok(config) = Self::load("task-graph/config.yaml") {
1293 return config;
1294 }
1295
1296 if let Ok(config) = Self::load(".task-graph/config.yaml") {
1298 return config;
1299 }
1300
1301 let mut config = Self::default();
1303
1304 if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
1305 config.server.db_path = PathBuf::from(db_path);
1306 }
1307
1308 if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
1309 config.server.media_dir = PathBuf::from(media_dir);
1310 }
1311
1312 if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
1313 config.server.log_dir = PathBuf::from(log_dir);
1314 }
1315
1316 config
1317 }
1318
1319 pub fn ensure_db_dir(&self) -> Result<()> {
1321 if let Some(parent) = self.server.db_path.parent() {
1322 std::fs::create_dir_all(parent)?;
1323 }
1324 Ok(())
1325 }
1326
1327 pub fn ensure_media_dir(&self) -> Result<()> {
1329 std::fs::create_dir_all(&self.server.media_dir)?;
1330 Ok(())
1331 }
1332
1333 pub fn ensure_log_dir(&self) -> Result<()> {
1335 std::fs::create_dir_all(&self.server.log_dir)?;
1336 Ok(())
1337 }
1338
1339 pub fn media_dir(&self) -> &Path {
1341 &self.server.media_dir
1342 }
1343
1344 pub fn log_dir(&self) -> &Path {
1346 &self.server.log_dir
1347 }
1348}
1349
1350#[derive(Debug, Clone, Serialize, Deserialize)]
1352pub struct ToolPrompt {
1353 pub description: String,
1354}
1355
1356#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1358pub struct Prompts {
1359 pub instructions: Option<String>,
1361
1362 #[serde(default)]
1364 pub tools: HashMap<String, ToolPrompt>,
1365}
1366
1367impl Prompts {
1368 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1370 let content = std::fs::read_to_string(path)?;
1371 let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
1373 Ok(prompts.unwrap_or_default())
1374 }
1375
1376 pub fn load_or_default() -> Self {
1380 if let Ok(prompts) = Self::load("task-graph/prompts.yaml") {
1382 return prompts;
1383 }
1384
1385 if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
1387 return prompts;
1388 }
1389
1390 Self::default()
1391 }
1392
1393 pub fn get_tool_description(&self, name: &str) -> Option<&str> {
1395 self.tools.get(name).map(|t| t.description.as_str())
1396 }
1397}
1398
1399#[derive(Debug, Clone)]
1406pub struct AppConfig {
1407 pub states: Arc<StatesConfig>,
1408 pub phases: Arc<PhasesConfig>,
1409 pub deps: Arc<DependenciesConfig>,
1410 pub auto_advance: Arc<AutoAdvanceConfig>,
1411 pub attachments: Arc<AttachmentsConfig>,
1412 pub tags: Arc<TagsConfig>,
1413 pub ids: Arc<IdsConfig>,
1414 pub workflows: Arc<WorkflowsConfig>,
1415 pub feedback: Arc<FeedbackConfig>,
1416}
1417
1418impl AppConfig {
1419 #[allow(clippy::too_many_arguments)]
1421 pub fn new(
1422 states: Arc<StatesConfig>,
1423 phases: Arc<PhasesConfig>,
1424 deps: Arc<DependenciesConfig>,
1425 auto_advance: Arc<AutoAdvanceConfig>,
1426 attachments: Arc<AttachmentsConfig>,
1427 tags: Arc<TagsConfig>,
1428 ids: Arc<IdsConfig>,
1429 workflows: Arc<WorkflowsConfig>,
1430 feedback: Arc<FeedbackConfig>,
1431 ) -> Self {
1432 Self {
1433 states,
1434 phases,
1435 deps,
1436 auto_advance,
1437 attachments,
1438 tags,
1439 ids,
1440 workflows,
1441 feedback,
1442 }
1443 }
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 use super::*;
1449
1450 #[test]
1451 fn register_workflow_tags_adds_unknown_tags() {
1452 let mut tags_config = TagsConfig::default();
1453 assert!(tags_config.tag_names().is_empty());
1454
1455 tags_config.register_workflow_tags(&["worker".to_string(), "lead".to_string()]);
1456
1457 assert!(tags_config.is_known_tag("worker"));
1458 assert!(tags_config.is_known_tag("lead"));
1459 assert_eq!(tags_config.tag_names().len(), 2);
1460
1461 let def = tags_config.definitions.get("worker").unwrap();
1463 assert_eq!(def.category, Some("workflow-role".to_string()));
1464 }
1465
1466 #[test]
1467 fn register_workflow_tags_preserves_existing_definitions() {
1468 let mut tags_config = TagsConfig::default();
1469 tags_config.definitions.insert(
1470 "worker".to_string(),
1471 TagDefinition {
1472 category: Some("custom".to_string()),
1473 description: Some("Manually defined".to_string()),
1474 },
1475 );
1476
1477 tags_config.register_workflow_tags(&["worker".to_string(), "lead".to_string()]);
1478
1479 let worker_def = tags_config.definitions.get("worker").unwrap();
1481 assert_eq!(worker_def.category, Some("custom".to_string()));
1482 assert_eq!(worker_def.description, Some("Manually defined".to_string()));
1483
1484 let lead_def = tags_config.definitions.get("lead").unwrap();
1486 assert_eq!(lead_def.category, Some("workflow-role".to_string()));
1487 }
1488
1489 #[test]
1490 fn registered_workflow_tags_suppress_warnings() {
1491 let mut tags_config = TagsConfig {
1492 unknown_tag: UnknownKeyBehavior::Warn,
1493 ..Default::default()
1494 };
1495
1496 let warnings = tags_config.validate_tags(&["worker".to_string()]).unwrap();
1498 assert_eq!(warnings.len(), 1);
1499 assert!(warnings[0].contains("Unknown tag"));
1500
1501 tags_config.register_workflow_tags(&["worker".to_string()]);
1503 let warnings = tags_config.validate_tags(&["worker".to_string()]).unwrap();
1504 assert!(warnings.is_empty());
1505 }
1506}