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, Serialize, Deserialize, Default)]
194pub struct FeedbackConfig {
195 #[serde(default)]
197 pub enabled: bool,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
202#[serde(rename_all = "snake_case")]
203pub enum UnknownKeyBehavior {
204 Allow,
206 #[default]
208 Warn,
209 Reject,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
215#[serde(rename_all = "snake_case")]
216pub enum GateEnforcement {
217 Allow,
219 #[default]
221 Warn,
222 Reject,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GateDefinition {
232 #[serde(rename = "type")]
234 pub gate_type: String,
235
236 #[serde(default)]
238 pub enforcement: GateEnforcement,
239
240 #[serde(default)]
242 pub description: String,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct AttachmentKeyDefinition {
248 pub mime: String,
250 #[serde(default = "default_append_mode")]
252 pub mode: String,
253}
254
255fn default_append_mode() -> String {
256 "append".to_string()
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct AttachmentsConfig {
262 #[serde(default)]
264 pub unknown_key: UnknownKeyBehavior,
265 #[serde(default = "AttachmentsConfig::default_definitions")]
267 pub definitions: HashMap<String, AttachmentKeyDefinition>,
268}
269
270impl Default for AttachmentsConfig {
271 fn default() -> Self {
272 Self {
273 unknown_key: UnknownKeyBehavior::default(),
274 definitions: Self::default_definitions(),
275 }
276 }
277}
278
279impl AttachmentsConfig {
280 pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
282 let mut defs = HashMap::new();
283
284 defs.insert(
285 "commit".to_string(),
286 AttachmentKeyDefinition {
287 mime: "text/git.hash".to_string(),
288 mode: "append".to_string(),
289 },
290 );
291
292 defs.insert(
293 "checkin".to_string(),
294 AttachmentKeyDefinition {
295 mime: "text/p4.changelist".to_string(),
296 mode: "append".to_string(),
297 },
298 );
299
300 defs.insert(
301 "meta".to_string(),
302 AttachmentKeyDefinition {
303 mime: "application/json".to_string(),
304 mode: "replace".to_string(),
305 },
306 );
307
308 defs.insert(
309 "note".to_string(),
310 AttachmentKeyDefinition {
311 mime: "text/plain".to_string(),
312 mode: "append".to_string(),
313 },
314 );
315
316 defs.insert(
317 "log".to_string(),
318 AttachmentKeyDefinition {
319 mime: "text/plain".to_string(),
320 mode: "append".to_string(),
321 },
322 );
323
324 defs.insert(
325 "error".to_string(),
326 AttachmentKeyDefinition {
327 mime: "text/plain".to_string(),
328 mode: "append".to_string(),
329 },
330 );
331
332 defs.insert(
333 "output".to_string(),
334 AttachmentKeyDefinition {
335 mime: "text/plain".to_string(),
336 mode: "append".to_string(),
337 },
338 );
339
340 defs.insert(
341 "diff".to_string(),
342 AttachmentKeyDefinition {
343 mime: "text/x-diff".to_string(),
344 mode: "append".to_string(),
345 },
346 );
347
348 defs.insert(
349 "changelist".to_string(),
350 AttachmentKeyDefinition {
351 mime: "text/plain".to_string(),
352 mode: "append".to_string(),
353 },
354 );
355
356 defs.insert(
357 "plan".to_string(),
358 AttachmentKeyDefinition {
359 mime: "text/markdown".to_string(),
360 mode: "replace".to_string(),
361 },
362 );
363
364 defs.insert(
365 "result".to_string(),
366 AttachmentKeyDefinition {
367 mime: "application/json".to_string(),
368 mode: "replace".to_string(),
369 },
370 );
371
372 defs.insert(
373 "context".to_string(),
374 AttachmentKeyDefinition {
375 mime: "text/plain".to_string(),
376 mode: "replace".to_string(),
377 },
378 );
379
380 defs.insert(
382 "gate/tests".to_string(),
383 AttachmentKeyDefinition {
384 mime: "text/plain".to_string(),
385 mode: "append".to_string(),
386 },
387 );
388
389 defs.insert(
390 "gate/commit".to_string(),
391 AttachmentKeyDefinition {
392 mime: "text/plain".to_string(),
393 mode: "append".to_string(),
394 },
395 );
396
397 defs.insert(
398 "gate/review".to_string(),
399 AttachmentKeyDefinition {
400 mime: "text/plain".to_string(),
401 mode: "append".to_string(),
402 },
403 );
404
405 defs
406 }
407
408 pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
410 self.definitions.get(key)
411 }
412
413 pub fn is_known_key(&self, key: &str) -> bool {
415 self.definitions.contains_key(key)
416 }
417
418 pub fn get_mime_default(&self, key: &str) -> &str {
420 self.definitions
421 .get(key)
422 .map(|d| d.mime.as_str())
423 .unwrap_or("text/plain")
424 }
425
426 pub fn get_mode_default(&self, key: &str) -> &str {
428 self.definitions
429 .get(key)
430 .map(|d| d.mode.as_str())
431 .unwrap_or("append")
432 }
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct TagDefinition {
438 #[serde(default)]
440 pub category: Option<String>,
441 #[serde(default)]
443 pub description: Option<String>,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, Default)]
448pub struct TagsConfig {
449 #[serde(default)]
451 pub unknown_tag: UnknownKeyBehavior,
452 #[serde(default)]
454 pub definitions: HashMap<String, TagDefinition>,
455}
456
457impl TagsConfig {
458 pub fn is_known_tag(&self, tag: &str) -> bool {
460 self.definitions.contains_key(tag)
461 }
462
463 pub fn tag_names(&self) -> Vec<&str> {
465 self.definitions.keys().map(|s| s.as_str()).collect()
466 }
467
468 pub fn tags_in_category(&self, category: &str) -> Vec<&str> {
470 self.definitions
471 .iter()
472 .filter(|(_, def)| def.category.as_deref() == Some(category))
473 .map(|(name, _)| name.as_str())
474 .collect()
475 }
476
477 pub fn categories(&self) -> Vec<&str> {
479 let mut cats: Vec<&str> = self
480 .definitions
481 .values()
482 .filter_map(|def| def.category.as_deref())
483 .collect();
484 cats.sort();
485 cats.dedup();
486 cats
487 }
488
489 pub fn validate_tag(&self, tag: &str) -> Result<Option<String>> {
491 if self.is_known_tag(tag) {
492 return Ok(None);
493 }
494
495 match self.unknown_tag {
496 UnknownKeyBehavior::Allow => Ok(None),
497 UnknownKeyBehavior::Warn => Ok(Some(format!(
498 "Unknown tag '{}'. Known tags: {:?}",
499 tag,
500 self.tag_names()
501 ))),
502 UnknownKeyBehavior::Reject => Err(anyhow!(
503 "Unknown tag '{}'. Configure in tags.definitions or set unknown_tag to 'allow' or 'warn'. Known tags: {:?}",
504 tag,
505 self.tag_names()
506 )),
507 }
508 }
509
510 pub fn validate_tags(&self, tags: &[String]) -> Result<Vec<String>> {
512 let mut warnings = Vec::new();
513 for tag in tags {
514 if let Some(warning) = self.validate_tag(tag)? {
515 warnings.push(warning);
516 }
517 }
518 Ok(warnings)
519 }
520
521 pub fn register_workflow_tags(&mut self, role_tags: &[String]) {
524 for tag in role_tags {
525 self.definitions
526 .entry(tag.clone())
527 .or_insert_with(|| TagDefinition {
528 category: Some("workflow-role".to_string()),
529 description: Some("Auto-registered from workflow role definition".to_string()),
530 });
531 }
532 }
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, Default)]
537pub struct Config {
538 #[serde(default)]
539 pub server: ServerConfig,
540
541 #[serde(default)]
542 pub paths: PathsConfig,
543
544 #[serde(default)]
545 pub states: StatesConfig,
546
547 #[serde(default)]
548 pub dependencies: DependenciesConfig,
549
550 #[serde(default)]
551 pub auto_advance: AutoAdvanceConfig,
552
553 #[serde(default)]
554 pub attachments: AttachmentsConfig,
555
556 #[serde(default)]
557 pub phases: PhasesConfig,
558
559 #[serde(default)]
560 pub tags: TagsConfig,
561
562 #[serde(default)]
563 pub ids: IdsConfig,
564
565 #[serde(default)]
566 pub feedback: FeedbackConfig,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct ServerPaths {
572 pub db_path: PathBuf,
574 pub media_dir: PathBuf,
576 pub log_dir: PathBuf,
578 pub config_path: Option<PathBuf>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct ServerConfig {
585 #[serde(default = "default_db_path")]
587 pub db_path: PathBuf,
588
589 #[serde(default = "default_media_dir")]
591 pub media_dir: PathBuf,
592
593 #[serde(default = "default_claim_limit")]
595 pub claim_limit: i32,
596
597 #[serde(default = "default_stale_timeout")]
599 pub stale_timeout_seconds: i64,
600
601 #[serde(default)]
603 pub default_format: OutputFormat,
604
605 #[serde(default = "default_skills_dir")]
607 pub skills_dir: PathBuf,
608
609 #[serde(default = "default_log_dir")]
611 pub log_dir: PathBuf,
612
613 #[serde(default)]
615 pub ui: UiConfig,
616
617 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub default_workflow: Option<String>,
622
623 #[serde(default = "default_page_size")]
626 pub default_page_size: i32,
627}
628
629impl Default for ServerConfig {
630 fn default() -> Self {
631 Self {
632 db_path: default_db_path(),
633 media_dir: default_media_dir(),
634 claim_limit: default_claim_limit(),
635 stale_timeout_seconds: default_stale_timeout(),
636 default_format: OutputFormat::default(),
637 skills_dir: default_skills_dir(),
638 log_dir: default_log_dir(),
639 ui: UiConfig::default(),
640 default_workflow: None,
641 default_page_size: default_page_size(),
642 }
643 }
644}
645
646fn default_db_path() -> PathBuf {
647 PathBuf::from("task-graph/tasks.db")
648}
649
650fn default_media_dir() -> PathBuf {
651 PathBuf::from("task-graph/media")
652}
653
654fn default_skills_dir() -> PathBuf {
655 PathBuf::from("task-graph/skills")
656}
657
658fn default_log_dir() -> PathBuf {
659 PathBuf::from("task-graph/logs")
660}
661
662fn default_paths_root() -> String {
663 ".".to_string()
664}
665
666fn default_claim_limit() -> i32 {
667 5
668}
669
670fn default_stale_timeout() -> i64 {
671 900 }
673
674fn default_page_size() -> i32 {
675 50
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct PathsConfig {
681 #[serde(default = "default_paths_root")]
683 pub root: String,
684
685 #[serde(default)]
687 pub style: PathStyle,
688
689 #[serde(default)]
691 pub map_windows_drives: bool,
692
693 #[serde(default)]
696 pub mappings: HashMap<String, String>,
697}
698
699impl Default for PathsConfig {
700 fn default() -> Self {
701 Self {
702 root: default_paths_root(),
703 style: PathStyle::Relative,
704 map_windows_drives: false,
705 mappings: HashMap::new(),
706 }
707 }
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712#[serde(rename_all = "snake_case")]
713#[derive(Default)]
714pub enum PathStyle {
715 #[default]
717 Relative,
718 ProjectPrefixed,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct StatesConfig {
725 #[serde(default = "default_initial_state")]
727 pub initial: String,
728
729 #[serde(default = "default_disconnect_state")]
731 pub disconnect_state: String,
732
733 #[serde(default = "default_blocking_states")]
735 pub blocking_states: Vec<String>,
736
737 #[serde(default = "default_state_definitions")]
739 pub definitions: HashMap<String, StateDefinition>,
740}
741
742impl Default for StatesConfig {
743 fn default() -> Self {
744 Self {
745 initial: default_initial_state(),
746 disconnect_state: default_disconnect_state(),
747 blocking_states: default_blocking_states(),
748 definitions: default_state_definitions(),
749 }
750 }
751}
752
753fn default_initial_state() -> String {
754 "pending".to_string()
755}
756
757fn default_disconnect_state() -> String {
758 "pending".to_string()
759}
760
761fn default_blocking_states() -> Vec<String> {
762 vec![
763 "pending".to_string(),
764 "assigned".to_string(),
765 "working".to_string(),
766 "consult".to_string(),
767 ]
768}
769
770fn default_state_definitions() -> HashMap<String, StateDefinition> {
771 let mut defs = HashMap::new();
772
773 defs.insert(
774 "pending".to_string(),
775 StateDefinition {
776 exits: vec![
777 "assigned".to_string(),
778 "working".to_string(),
779 "cancelled".to_string(),
780 ],
781 timed: false,
782 },
783 );
784
785 defs.insert(
786 "assigned".to_string(),
787 StateDefinition {
788 exits: vec![
789 "working".to_string(),
790 "pending".to_string(),
791 "cancelled".to_string(),
792 ],
793 timed: false,
794 },
795 );
796
797 defs.insert(
798 "working".to_string(),
799 StateDefinition {
800 exits: vec![
801 "completed".to_string(),
802 "failed".to_string(),
803 "pending".to_string(),
804 "consult".to_string(),
805 ],
806 timed: true,
807 },
808 );
809
810 defs.insert(
811 "completed".to_string(),
812 StateDefinition {
813 exits: vec!["pending".to_string()],
814 timed: false,
815 },
816 );
817
818 defs.insert(
819 "failed".to_string(),
820 StateDefinition {
821 exits: vec!["pending".to_string()],
822 timed: false,
823 },
824 );
825
826 defs.insert(
827 "consult".to_string(),
828 StateDefinition {
829 exits: vec![
830 "working".to_string(),
831 "pending".to_string(),
832 "cancelled".to_string(),
833 ],
834 timed: false,
835 },
836 );
837
838 defs.insert(
839 "cancelled".to_string(),
840 StateDefinition {
841 exits: vec![],
842 timed: false,
843 },
844 );
845
846 defs
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct StateDefinition {
852 #[serde(default)]
854 pub exits: Vec<String>,
855
856 #[serde(default)]
858 pub timed: bool,
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct DependenciesConfig {
864 #[serde(default = "default_dependency_definitions")]
866 pub definitions: HashMap<String, DependencyDefinition>,
867}
868
869impl Default for DependenciesConfig {
870 fn default() -> Self {
871 Self {
872 definitions: default_dependency_definitions(),
873 }
874 }
875}
876
877#[derive(Debug, Clone, Serialize, Deserialize)]
879pub struct DependencyDefinition {
880 pub display: DependencyDisplay,
882
883 pub blocks: BlockTarget,
885}
886
887#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
889#[serde(rename_all = "snake_case")]
890pub enum DependencyDisplay {
891 Horizontal,
893 Vertical,
895}
896
897#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
899#[serde(rename_all = "snake_case")]
900pub enum BlockTarget {
901 None,
903 Start,
905 Completion,
907}
908
909fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
910 let mut defs = HashMap::new();
911
912 defs.insert(
914 "blocks".to_string(),
915 DependencyDefinition {
916 display: DependencyDisplay::Horizontal,
917 blocks: BlockTarget::Start,
918 },
919 );
920
921 defs.insert(
922 "follows".to_string(),
923 DependencyDefinition {
924 display: DependencyDisplay::Horizontal,
925 blocks: BlockTarget::Start,
926 },
927 );
928
929 defs.insert(
930 "contains".to_string(),
931 DependencyDefinition {
932 display: DependencyDisplay::Vertical,
933 blocks: BlockTarget::Completion,
934 },
935 );
936
937 defs.insert(
939 "duplicate".to_string(),
940 DependencyDefinition {
941 display: DependencyDisplay::Horizontal,
942 blocks: BlockTarget::None,
943 },
944 );
945
946 defs.insert(
947 "see-also".to_string(),
948 DependencyDefinition {
949 display: DependencyDisplay::Horizontal,
950 blocks: BlockTarget::None,
951 },
952 );
953
954 defs.insert(
955 "relates-to".to_string(),
956 DependencyDefinition {
957 display: DependencyDisplay::Horizontal,
958 blocks: BlockTarget::None,
959 },
960 );
961
962 defs
963}
964
965impl DependenciesConfig {
966 pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
968 self.definitions.contains_key(dep_type)
969 }
970
971 pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
973 self.definitions.get(dep_type)
974 }
975
976 pub fn start_blocking_types(&self) -> Vec<&str> {
978 self.definitions
979 .iter()
980 .filter(|(_, def)| def.blocks == BlockTarget::Start)
981 .map(|(name, _)| name.as_str())
982 .collect()
983 }
984
985 pub fn completion_blocking_types(&self) -> Vec<&str> {
987 self.definitions
988 .iter()
989 .filter(|(_, def)| def.blocks == BlockTarget::Completion)
990 .map(|(name, _)| name.as_str())
991 .collect()
992 }
993
994 pub fn vertical_types(&self) -> Vec<&str> {
996 self.definitions
997 .iter()
998 .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
999 .map(|(name, _)| name.as_str())
1000 .collect()
1001 }
1002
1003 pub fn dep_type_names(&self) -> Vec<&str> {
1005 self.definitions.keys().map(|s| s.as_str()).collect()
1006 }
1007
1008 pub fn validate(&self) -> anyhow::Result<()> {
1010 if self.definitions.is_empty() {
1011 return Err(anyhow::anyhow!(
1012 "At least one dependency type must be defined"
1013 ));
1014 }
1015
1016 let has_start_blocking = self
1018 .definitions
1019 .values()
1020 .any(|d| d.blocks == BlockTarget::Start);
1021 if !has_start_blocking {
1022 return Err(anyhow::anyhow!(
1023 "At least one dependency type with blocks: start must be defined"
1024 ));
1025 }
1026
1027 Ok(())
1028 }
1029}
1030
1031impl StatesConfig {
1032 pub fn is_valid_state(&self, state: &str) -> bool {
1034 self.definitions.contains_key(state)
1035 }
1036
1037 pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
1039 if let Some(def) = self.definitions.get(from) {
1040 def.exits.contains(&to.to_string())
1041 } else {
1042 false
1043 }
1044 }
1045
1046 pub fn is_timed_state(&self, state: &str) -> bool {
1048 self.definitions
1049 .get(state)
1050 .map(|d| d.timed)
1051 .unwrap_or(false)
1052 }
1053
1054 pub fn is_terminal_state(&self, state: &str) -> bool {
1056 self.definitions
1057 .get(state)
1058 .map(|d| d.exits.is_empty())
1059 .unwrap_or(false)
1060 }
1061
1062 pub fn is_blocking_state(&self, state: &str) -> bool {
1064 self.blocking_states.contains(&state.to_string())
1065 }
1066
1067 pub fn state_names(&self) -> Vec<&str> {
1069 self.definitions.keys().map(|s| s.as_str()).collect()
1070 }
1071
1072 pub fn get_exits(&self, state: &str) -> Vec<&str> {
1074 self.definitions
1075 .get(state)
1076 .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
1077 .unwrap_or_default()
1078 }
1079
1080 pub fn untimed_state_names(&self) -> Vec<&str> {
1082 self.definitions
1083 .iter()
1084 .filter(|(_, def)| !def.timed)
1085 .map(|(name, _)| name.as_str())
1086 .collect()
1087 }
1088
1089 pub fn validate(&self) -> Result<()> {
1091 if !self.definitions.contains_key(&self.initial) {
1093 return Err(anyhow!(
1094 "Initial state '{}' is not defined in state definitions",
1095 self.initial
1096 ));
1097 }
1098
1099 if !self.definitions.contains_key(&self.disconnect_state) {
1101 return Err(anyhow!(
1102 "Disconnect state '{}' is not defined in state definitions",
1103 self.disconnect_state
1104 ));
1105 }
1106 if self.is_timed_state(&self.disconnect_state) {
1107 return Err(anyhow!(
1108 "Disconnect state '{}' must not be a timed state",
1109 self.disconnect_state
1110 ));
1111 }
1112
1113 for state in &self.blocking_states {
1115 if !self.definitions.contains_key(state) {
1116 return Err(anyhow!(
1117 "Blocking state '{}' is not defined in state definitions",
1118 state
1119 ));
1120 }
1121 }
1122
1123 for (state_name, def) in &self.definitions {
1125 for exit in &def.exits {
1126 if !self.definitions.contains_key(exit) {
1127 return Err(anyhow!(
1128 "State '{}' has exit '{}' which is not defined",
1129 state_name,
1130 exit
1131 ));
1132 }
1133 }
1134 }
1135
1136 let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
1138 if !has_terminal {
1139 return Err(anyhow!(
1140 "At least one terminal state (with empty exits) must be defined"
1141 ));
1142 }
1143
1144 Ok(())
1145 }
1146}
1147
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1150pub struct PhasesConfig {
1151 #[serde(default)]
1153 pub unknown_phase: UnknownKeyBehavior,
1154
1155 #[serde(default = "default_phases")]
1157 pub definitions: HashSet<String>,
1158}
1159
1160impl Default for PhasesConfig {
1161 fn default() -> Self {
1162 Self {
1163 unknown_phase: UnknownKeyBehavior::Warn,
1164 definitions: default_phases(),
1165 }
1166 }
1167}
1168
1169fn default_phases() -> HashSet<String> {
1170 [
1171 "deliver", "triage", "explore", "diagnose", "design", "plan", "implement", "test", "review", "security", "doc", "integrate", "deploy", "monitor", "optimize", ]
1187 .iter()
1188 .map(|s| s.to_string())
1189 .collect()
1190}
1191
1192impl PhasesConfig {
1193 pub fn is_known_phase(&self, phase: &str) -> bool {
1195 self.definitions.contains(phase)
1196 }
1197
1198 pub fn phase_names(&self) -> Vec<&str> {
1200 self.definitions.iter().map(|s| s.as_str()).collect()
1201 }
1202
1203 pub fn check_phase(&self, phase: &str) -> Result<Option<String>> {
1208 if self.is_known_phase(phase) {
1209 return Ok(None);
1210 }
1211
1212 match self.unknown_phase {
1213 UnknownKeyBehavior::Allow => Ok(None),
1214 UnknownKeyBehavior::Warn => Ok(Some(format!(
1215 "Unknown phase '{}'. Known phases: {:?}",
1216 phase,
1217 self.phase_names()
1218 ))),
1219 UnknownKeyBehavior::Reject => Err(anyhow!(
1220 "Unknown phase '{}'. Known phases: {:?}. Configure in phases.definitions or set unknown_phase to 'allow' or 'warn'.",
1221 phase,
1222 self.phase_names()
1223 )),
1224 }
1225 }
1226}
1227
1228impl Config {
1229 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1231 let content = std::fs::read_to_string(path)?;
1232 let config: Config = serde_yaml::from_str(&content)?;
1233 Ok(config)
1234 }
1235
1236 pub fn load_or_default() -> Self {
1240 if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH")
1242 && let Ok(config) = Self::load(&config_path)
1243 {
1244 return config;
1245 }
1246
1247 if let Ok(config) = Self::load("task-graph/config.yaml") {
1249 return config;
1250 }
1251
1252 if let Ok(config) = Self::load(".task-graph/config.yaml") {
1254 return config;
1255 }
1256
1257 let mut config = Self::default();
1259
1260 if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
1261 config.server.db_path = PathBuf::from(db_path);
1262 }
1263
1264 if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
1265 config.server.media_dir = PathBuf::from(media_dir);
1266 }
1267
1268 if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
1269 config.server.log_dir = PathBuf::from(log_dir);
1270 }
1271
1272 config
1273 }
1274
1275 pub fn ensure_db_dir(&self) -> Result<()> {
1277 if let Some(parent) = self.server.db_path.parent() {
1278 std::fs::create_dir_all(parent)?;
1279 }
1280 Ok(())
1281 }
1282
1283 pub fn ensure_media_dir(&self) -> Result<()> {
1285 std::fs::create_dir_all(&self.server.media_dir)?;
1286 Ok(())
1287 }
1288
1289 pub fn ensure_log_dir(&self) -> Result<()> {
1291 std::fs::create_dir_all(&self.server.log_dir)?;
1292 Ok(())
1293 }
1294
1295 pub fn media_dir(&self) -> &Path {
1297 &self.server.media_dir
1298 }
1299
1300 pub fn log_dir(&self) -> &Path {
1302 &self.server.log_dir
1303 }
1304}
1305
1306#[derive(Debug, Clone, Serialize, Deserialize)]
1308pub struct ToolPrompt {
1309 pub description: String,
1310}
1311
1312#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1314pub struct Prompts {
1315 pub instructions: Option<String>,
1317
1318 #[serde(default)]
1320 pub tools: HashMap<String, ToolPrompt>,
1321}
1322
1323impl Prompts {
1324 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1326 let content = std::fs::read_to_string(path)?;
1327 let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
1329 Ok(prompts.unwrap_or_default())
1330 }
1331
1332 pub fn load_or_default() -> Self {
1336 if let Ok(prompts) = Self::load("task-graph/prompts.yaml") {
1338 return prompts;
1339 }
1340
1341 if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
1343 return prompts;
1344 }
1345
1346 Self::default()
1347 }
1348
1349 pub fn get_tool_description(&self, name: &str) -> Option<&str> {
1351 self.tools.get(name).map(|t| t.description.as_str())
1352 }
1353}
1354
1355#[derive(Debug, Clone)]
1362pub struct AppConfig {
1363 pub states: Arc<StatesConfig>,
1364 pub phases: Arc<PhasesConfig>,
1365 pub deps: Arc<DependenciesConfig>,
1366 pub auto_advance: Arc<AutoAdvanceConfig>,
1367 pub attachments: Arc<AttachmentsConfig>,
1368 pub tags: Arc<TagsConfig>,
1369 pub ids: Arc<IdsConfig>,
1370 pub workflows: Arc<WorkflowsConfig>,
1371 pub feedback: Arc<FeedbackConfig>,
1372}
1373
1374impl AppConfig {
1375 #[allow(clippy::too_many_arguments)]
1377 pub fn new(
1378 states: Arc<StatesConfig>,
1379 phases: Arc<PhasesConfig>,
1380 deps: Arc<DependenciesConfig>,
1381 auto_advance: Arc<AutoAdvanceConfig>,
1382 attachments: Arc<AttachmentsConfig>,
1383 tags: Arc<TagsConfig>,
1384 ids: Arc<IdsConfig>,
1385 workflows: Arc<WorkflowsConfig>,
1386 feedback: Arc<FeedbackConfig>,
1387 ) -> Self {
1388 Self {
1389 states,
1390 phases,
1391 deps,
1392 auto_advance,
1393 attachments,
1394 tags,
1395 ids,
1396 workflows,
1397 feedback,
1398 }
1399 }
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404 use super::*;
1405
1406 #[test]
1407 fn register_workflow_tags_adds_unknown_tags() {
1408 let mut tags_config = TagsConfig::default();
1409 assert!(tags_config.tag_names().is_empty());
1410
1411 tags_config.register_workflow_tags(&["worker".to_string(), "lead".to_string()]);
1412
1413 assert!(tags_config.is_known_tag("worker"));
1414 assert!(tags_config.is_known_tag("lead"));
1415 assert_eq!(tags_config.tag_names().len(), 2);
1416
1417 let def = tags_config.definitions.get("worker").unwrap();
1419 assert_eq!(def.category, Some("workflow-role".to_string()));
1420 }
1421
1422 #[test]
1423 fn register_workflow_tags_preserves_existing_definitions() {
1424 let mut tags_config = TagsConfig::default();
1425 tags_config.definitions.insert(
1426 "worker".to_string(),
1427 TagDefinition {
1428 category: Some("custom".to_string()),
1429 description: Some("Manually defined".to_string()),
1430 },
1431 );
1432
1433 tags_config.register_workflow_tags(&["worker".to_string(), "lead".to_string()]);
1434
1435 let worker_def = tags_config.definitions.get("worker").unwrap();
1437 assert_eq!(worker_def.category, Some("custom".to_string()));
1438 assert_eq!(worker_def.description, Some("Manually defined".to_string()));
1439
1440 let lead_def = tags_config.definitions.get("lead").unwrap();
1442 assert_eq!(lead_def.category, Some("workflow-role".to_string()));
1443 }
1444
1445 #[test]
1446 fn registered_workflow_tags_suppress_warnings() {
1447 let mut tags_config = TagsConfig {
1448 unknown_tag: UnknownKeyBehavior::Warn,
1449 ..Default::default()
1450 };
1451
1452 let warnings = tags_config.validate_tags(&["worker".to_string()]).unwrap();
1454 assert_eq!(warnings.len(), 1);
1455 assert!(warnings[0].contains("Unknown tag"));
1456
1457 tags_config.register_workflow_tags(&["worker".to_string()]);
1459 let warnings = tags_config.validate_tags(&["worker".to_string()]).unwrap();
1460 assert!(warnings.is_empty());
1461 }
1462}