1use crate::format::OutputFormat;
6use anyhow::{Result, anyhow};
7use heck::{ToKebabCase, ToLowerCamelCase, ToSnakeCase, ToTitleCase, ToUpperCamelCase};
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12pub const DEFAULT_UI_PORT: u16 = 31994;
14
15pub const DEFAULT_ID_WORDS: u8 = 4;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum IdCase {
22 #[default]
24 KebabCase,
25 SnakeCase,
27 CamelCase,
29 PascalCase,
31 Lowercase,
33 Uppercase,
35 TitleCase,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct IdsConfig {
42 #[serde(default = "default_id_words")]
44 pub task_id_words: u8,
45
46 #[serde(default = "default_id_words")]
48 pub agent_id_words: u8,
49
50 #[serde(default)]
52 pub id_case: IdCase,
53}
54
55fn default_id_words() -> u8 {
56 DEFAULT_ID_WORDS
57}
58
59impl Default for IdsConfig {
60 fn default() -> Self {
61 Self {
62 task_id_words: DEFAULT_ID_WORDS,
63 agent_id_words: DEFAULT_ID_WORDS,
64 id_case: IdCase::default(),
65 }
66 }
67}
68
69impl IdCase {
70 pub fn convert(&self, input: &str) -> String {
73 match self {
74 IdCase::KebabCase => input.to_kebab_case(),
75 IdCase::SnakeCase => input.to_snake_case(),
76 IdCase::CamelCase => input.to_lower_camel_case(),
77 IdCase::PascalCase => input.to_upper_camel_case(),
78 IdCase::Lowercase => input.replace('-', "").to_lowercase(),
79 IdCase::Uppercase => input.replace('-', "").to_uppercase(),
80 IdCase::TitleCase => input.to_title_case(),
81 }
82 }
83
84 pub fn separator(&self) -> Option<&'static str> {
87 match self {
88 IdCase::KebabCase => Some("-"),
89 IdCase::SnakeCase => Some("_"),
90 IdCase::TitleCase => Some(" "),
91 IdCase::CamelCase | IdCase::PascalCase | IdCase::Lowercase | IdCase::Uppercase => None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum UiMode {
100 #[default]
102 None,
103 Web,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct UiConfig {
110 #[serde(default)]
112 pub mode: UiMode,
113
114 #[serde(default = "default_ui_port")]
116 pub port: u16,
117
118 #[serde(default = "default_retry_initial_ms")]
120 pub retry_initial_ms: u64,
121
122 #[serde(default = "default_retry_jitter_ms")]
124 pub retry_jitter_ms: u64,
125
126 #[serde(default = "default_retry_max_ms")]
128 pub retry_max_ms: u64,
129
130 #[serde(default = "default_retry_multiplier")]
132 pub retry_multiplier: f64,
133}
134
135impl Default for UiConfig {
136 fn default() -> Self {
137 Self {
138 mode: UiMode::default(),
139 port: default_ui_port(),
140 retry_initial_ms: default_retry_initial_ms(),
141 retry_jitter_ms: default_retry_jitter_ms(),
142 retry_max_ms: default_retry_max_ms(),
143 retry_multiplier: default_retry_multiplier(),
144 }
145 }
146}
147
148fn default_ui_port() -> u16 {
149 DEFAULT_UI_PORT
150}
151
152fn default_retry_initial_ms() -> u64 {
153 15_000 }
155
156fn default_retry_jitter_ms() -> u64 {
157 5_000 }
159
160fn default_retry_max_ms() -> u64 {
161 240_000 }
163
164fn default_retry_multiplier() -> f64 {
165 2.0
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, Default)]
170pub struct AutoAdvanceConfig {
171 #[serde(default)]
173 pub enabled: bool,
174
175 #[serde(default)]
178 pub target_state: Option<String>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
183#[serde(rename_all = "snake_case")]
184pub enum UnknownKeyBehavior {
185 Allow,
187 #[default]
189 Warn,
190 Reject,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
196#[serde(rename_all = "snake_case")]
197pub enum GateEnforcement {
198 Allow,
200 #[default]
202 Warn,
203 Reject,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct GateDefinition {
213 #[serde(rename = "type")]
215 pub gate_type: String,
216
217 #[serde(default)]
219 pub enforcement: GateEnforcement,
220
221 #[serde(default)]
223 pub description: String,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct AttachmentKeyDefinition {
229 pub mime: String,
231 #[serde(default = "default_append_mode")]
233 pub mode: String,
234}
235
236fn default_append_mode() -> String {
237 "append".to_string()
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct AttachmentsConfig {
243 #[serde(default)]
245 pub unknown_key: UnknownKeyBehavior,
246 #[serde(default = "AttachmentsConfig::default_definitions")]
248 pub definitions: HashMap<String, AttachmentKeyDefinition>,
249}
250
251impl Default for AttachmentsConfig {
252 fn default() -> Self {
253 Self {
254 unknown_key: UnknownKeyBehavior::default(),
255 definitions: Self::default_definitions(),
256 }
257 }
258}
259
260impl AttachmentsConfig {
261 pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
263 let mut defs = HashMap::new();
264
265 defs.insert(
266 "commit".to_string(),
267 AttachmentKeyDefinition {
268 mime: "text/git.hash".to_string(),
269 mode: "append".to_string(),
270 },
271 );
272
273 defs.insert(
274 "checkin".to_string(),
275 AttachmentKeyDefinition {
276 mime: "text/p4.changelist".to_string(),
277 mode: "append".to_string(),
278 },
279 );
280
281 defs.insert(
282 "meta".to_string(),
283 AttachmentKeyDefinition {
284 mime: "application/json".to_string(),
285 mode: "replace".to_string(),
286 },
287 );
288
289 defs.insert(
290 "note".to_string(),
291 AttachmentKeyDefinition {
292 mime: "text/plain".to_string(),
293 mode: "append".to_string(),
294 },
295 );
296
297 defs.insert(
298 "log".to_string(),
299 AttachmentKeyDefinition {
300 mime: "text/plain".to_string(),
301 mode: "append".to_string(),
302 },
303 );
304
305 defs.insert(
306 "error".to_string(),
307 AttachmentKeyDefinition {
308 mime: "text/plain".to_string(),
309 mode: "append".to_string(),
310 },
311 );
312
313 defs.insert(
314 "output".to_string(),
315 AttachmentKeyDefinition {
316 mime: "text/plain".to_string(),
317 mode: "append".to_string(),
318 },
319 );
320
321 defs.insert(
322 "diff".to_string(),
323 AttachmentKeyDefinition {
324 mime: "text/x-diff".to_string(),
325 mode: "append".to_string(),
326 },
327 );
328
329 defs.insert(
330 "changelist".to_string(),
331 AttachmentKeyDefinition {
332 mime: "text/plain".to_string(),
333 mode: "append".to_string(),
334 },
335 );
336
337 defs.insert(
338 "plan".to_string(),
339 AttachmentKeyDefinition {
340 mime: "text/markdown".to_string(),
341 mode: "replace".to_string(),
342 },
343 );
344
345 defs.insert(
346 "result".to_string(),
347 AttachmentKeyDefinition {
348 mime: "application/json".to_string(),
349 mode: "replace".to_string(),
350 },
351 );
352
353 defs.insert(
354 "context".to_string(),
355 AttachmentKeyDefinition {
356 mime: "text/plain".to_string(),
357 mode: "replace".to_string(),
358 },
359 );
360
361 defs.insert(
363 "gate/tests".to_string(),
364 AttachmentKeyDefinition {
365 mime: "text/plain".to_string(),
366 mode: "append".to_string(),
367 },
368 );
369
370 defs.insert(
371 "gate/commit".to_string(),
372 AttachmentKeyDefinition {
373 mime: "text/plain".to_string(),
374 mode: "append".to_string(),
375 },
376 );
377
378 defs.insert(
379 "gate/review".to_string(),
380 AttachmentKeyDefinition {
381 mime: "text/plain".to_string(),
382 mode: "append".to_string(),
383 },
384 );
385
386 defs
387 }
388
389 pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
391 self.definitions.get(key)
392 }
393
394 pub fn is_known_key(&self, key: &str) -> bool {
396 self.definitions.contains_key(key)
397 }
398
399 pub fn get_mime_default(&self, key: &str) -> &str {
401 self.definitions
402 .get(key)
403 .map(|d| d.mime.as_str())
404 .unwrap_or("text/plain")
405 }
406
407 pub fn get_mode_default(&self, key: &str) -> &str {
409 self.definitions
410 .get(key)
411 .map(|d| d.mode.as_str())
412 .unwrap_or("append")
413 }
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct TagDefinition {
419 #[serde(default)]
421 pub category: Option<String>,
422 #[serde(default)]
424 pub description: Option<String>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct TagsConfig {
430 #[serde(default)]
432 pub unknown_tag: UnknownKeyBehavior,
433 #[serde(default)]
435 pub definitions: HashMap<String, TagDefinition>,
436}
437
438impl Default for TagsConfig {
439 fn default() -> Self {
440 Self {
441 unknown_tag: UnknownKeyBehavior::default(),
442 definitions: HashMap::new(),
443 }
444 }
445}
446
447impl TagsConfig {
448 pub fn is_known_tag(&self, tag: &str) -> bool {
450 self.definitions.contains_key(tag)
451 }
452
453 pub fn tag_names(&self) -> Vec<&str> {
455 self.definitions.keys().map(|s| s.as_str()).collect()
456 }
457
458 pub fn tags_in_category(&self, category: &str) -> Vec<&str> {
460 self.definitions
461 .iter()
462 .filter(|(_, def)| def.category.as_deref() == Some(category))
463 .map(|(name, _)| name.as_str())
464 .collect()
465 }
466
467 pub fn categories(&self) -> Vec<&str> {
469 let mut cats: Vec<&str> = self
470 .definitions
471 .values()
472 .filter_map(|def| def.category.as_deref())
473 .collect();
474 cats.sort();
475 cats.dedup();
476 cats
477 }
478
479 pub fn validate_tag(&self, tag: &str) -> Result<Option<String>> {
481 if self.is_known_tag(tag) {
482 return Ok(None);
483 }
484
485 match self.unknown_tag {
486 UnknownKeyBehavior::Allow => Ok(None),
487 UnknownKeyBehavior::Warn => Ok(Some(format!(
488 "Unknown tag '{}'. Known tags: {:?}",
489 tag,
490 self.tag_names()
491 ))),
492 UnknownKeyBehavior::Reject => Err(anyhow!(
493 "Unknown tag '{}'. Configure in tags.definitions or set unknown_tag to 'allow' or 'warn'. Known tags: {:?}",
494 tag,
495 self.tag_names()
496 )),
497 }
498 }
499
500 pub fn validate_tags(&self, tags: &[String]) -> Result<Vec<String>> {
502 let mut warnings = Vec::new();
503 for tag in tags {
504 if let Some(warning) = self.validate_tag(tag)? {
505 warnings.push(warning);
506 }
507 }
508 Ok(warnings)
509 }
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, Default)]
514pub struct Config {
515 #[serde(default)]
516 pub server: ServerConfig,
517
518 #[serde(default)]
519 pub paths: PathsConfig,
520
521 #[serde(default)]
522 pub states: StatesConfig,
523
524 #[serde(default)]
525 pub dependencies: DependenciesConfig,
526
527 #[serde(default)]
528 pub auto_advance: AutoAdvanceConfig,
529
530 #[serde(default)]
531 pub attachments: AttachmentsConfig,
532
533 #[serde(default)]
534 pub phases: PhasesConfig,
535
536 #[serde(default)]
537 pub tags: TagsConfig,
538
539 #[serde(default)]
540 pub ids: IdsConfig,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct ServerPaths {
546 pub db_path: PathBuf,
548 pub media_dir: PathBuf,
550 pub log_dir: PathBuf,
552 pub config_path: Option<PathBuf>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct ServerConfig {
559 #[serde(default = "default_db_path")]
561 pub db_path: PathBuf,
562
563 #[serde(default = "default_media_dir")]
565 pub media_dir: PathBuf,
566
567 #[serde(default = "default_claim_limit")]
569 pub claim_limit: i32,
570
571 #[serde(default = "default_stale_timeout")]
573 pub stale_timeout_seconds: i64,
574
575 #[serde(default)]
577 pub default_format: OutputFormat,
578
579 #[serde(default = "default_skills_dir")]
581 pub skills_dir: PathBuf,
582
583 #[serde(default = "default_log_dir")]
585 pub log_dir: PathBuf,
586
587 #[serde(default)]
589 pub ui: UiConfig,
590
591 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub default_workflow: Option<String>,
596}
597
598impl Default for ServerConfig {
599 fn default() -> Self {
600 Self {
601 db_path: default_db_path(),
602 media_dir: default_media_dir(),
603 claim_limit: default_claim_limit(),
604 stale_timeout_seconds: default_stale_timeout(),
605 default_format: OutputFormat::default(),
606 skills_dir: default_skills_dir(),
607 log_dir: default_log_dir(),
608 ui: UiConfig::default(),
609 default_workflow: None,
610 }
611 }
612}
613
614fn default_db_path() -> PathBuf {
615 PathBuf::from("task-graph/tasks.db")
616}
617
618fn default_media_dir() -> PathBuf {
619 PathBuf::from("task-graph/media")
620}
621
622fn default_skills_dir() -> PathBuf {
623 PathBuf::from("task-graph/skills")
624}
625
626fn default_log_dir() -> PathBuf {
627 PathBuf::from("task-graph/logs")
628}
629
630fn default_paths_root() -> String {
631 ".".to_string()
632}
633
634fn default_claim_limit() -> i32 {
635 5
636}
637
638fn default_stale_timeout() -> i64 {
639 900 }
641
642#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct PathsConfig {
645 #[serde(default = "default_paths_root")]
647 pub root: String,
648
649 #[serde(default)]
651 pub style: PathStyle,
652
653 #[serde(default)]
655 pub map_windows_drives: bool,
656
657 #[serde(default)]
660 pub mappings: HashMap<String, String>,
661}
662
663impl Default for PathsConfig {
664 fn default() -> Self {
665 Self {
666 root: default_paths_root(),
667 style: PathStyle::Relative,
668 map_windows_drives: false,
669 mappings: HashMap::new(),
670 }
671 }
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
676#[serde(rename_all = "snake_case")]
677#[derive(Default)]
678pub enum PathStyle {
679 #[default]
681 Relative,
682 ProjectPrefixed,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct StatesConfig {
689 #[serde(default = "default_initial_state")]
691 pub initial: String,
692
693 #[serde(default = "default_disconnect_state")]
695 pub disconnect_state: String,
696
697 #[serde(default = "default_blocking_states")]
699 pub blocking_states: Vec<String>,
700
701 #[serde(default = "default_state_definitions")]
703 pub definitions: HashMap<String, StateDefinition>,
704}
705
706impl Default for StatesConfig {
707 fn default() -> Self {
708 Self {
709 initial: default_initial_state(),
710 disconnect_state: default_disconnect_state(),
711 blocking_states: default_blocking_states(),
712 definitions: default_state_definitions(),
713 }
714 }
715}
716
717fn default_initial_state() -> String {
718 "pending".to_string()
719}
720
721fn default_disconnect_state() -> String {
722 "pending".to_string()
723}
724
725fn default_blocking_states() -> Vec<String> {
726 vec![
727 "pending".to_string(),
728 "assigned".to_string(),
729 "working".to_string(),
730 ]
731}
732
733fn default_state_definitions() -> HashMap<String, StateDefinition> {
734 let mut defs = HashMap::new();
735
736 defs.insert(
737 "pending".to_string(),
738 StateDefinition {
739 exits: vec![
740 "assigned".to_string(),
741 "working".to_string(),
742 "cancelled".to_string(),
743 ],
744 timed: false,
745 },
746 );
747
748 defs.insert(
749 "assigned".to_string(),
750 StateDefinition {
751 exits: vec![
752 "working".to_string(),
753 "pending".to_string(),
754 "cancelled".to_string(),
755 ],
756 timed: false,
757 },
758 );
759
760 defs.insert(
761 "working".to_string(),
762 StateDefinition {
763 exits: vec![
764 "completed".to_string(),
765 "failed".to_string(),
766 "pending".to_string(),
767 ],
768 timed: true,
769 },
770 );
771
772 defs.insert(
773 "completed".to_string(),
774 StateDefinition {
775 exits: vec!["pending".to_string()],
776 timed: false,
777 },
778 );
779
780 defs.insert(
781 "failed".to_string(),
782 StateDefinition {
783 exits: vec!["pending".to_string()],
784 timed: false,
785 },
786 );
787
788 defs.insert(
789 "cancelled".to_string(),
790 StateDefinition {
791 exits: vec![],
792 timed: false,
793 },
794 );
795
796 defs
797}
798
799#[derive(Debug, Clone, Serialize, Deserialize)]
801pub struct StateDefinition {
802 #[serde(default)]
804 pub exits: Vec<String>,
805
806 #[serde(default)]
808 pub timed: bool,
809}
810
811#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct DependenciesConfig {
814 #[serde(default = "default_dependency_definitions")]
816 pub definitions: HashMap<String, DependencyDefinition>,
817}
818
819impl Default for DependenciesConfig {
820 fn default() -> Self {
821 Self {
822 definitions: default_dependency_definitions(),
823 }
824 }
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize)]
829pub struct DependencyDefinition {
830 pub display: DependencyDisplay,
832
833 pub blocks: BlockTarget,
835}
836
837#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
839#[serde(rename_all = "snake_case")]
840pub enum DependencyDisplay {
841 Horizontal,
843 Vertical,
845}
846
847#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
849#[serde(rename_all = "snake_case")]
850pub enum BlockTarget {
851 None,
853 Start,
855 Completion,
857}
858
859fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
860 let mut defs = HashMap::new();
861
862 defs.insert(
864 "blocks".to_string(),
865 DependencyDefinition {
866 display: DependencyDisplay::Horizontal,
867 blocks: BlockTarget::Start,
868 },
869 );
870
871 defs.insert(
872 "follows".to_string(),
873 DependencyDefinition {
874 display: DependencyDisplay::Horizontal,
875 blocks: BlockTarget::Start,
876 },
877 );
878
879 defs.insert(
880 "contains".to_string(),
881 DependencyDefinition {
882 display: DependencyDisplay::Vertical,
883 blocks: BlockTarget::Completion,
884 },
885 );
886
887 defs.insert(
889 "duplicate".to_string(),
890 DependencyDefinition {
891 display: DependencyDisplay::Horizontal,
892 blocks: BlockTarget::None,
893 },
894 );
895
896 defs.insert(
897 "see-also".to_string(),
898 DependencyDefinition {
899 display: DependencyDisplay::Horizontal,
900 blocks: BlockTarget::None,
901 },
902 );
903
904 defs.insert(
905 "relates-to".to_string(),
906 DependencyDefinition {
907 display: DependencyDisplay::Horizontal,
908 blocks: BlockTarget::None,
909 },
910 );
911
912 defs
913}
914
915impl DependenciesConfig {
916 pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
918 self.definitions.contains_key(dep_type)
919 }
920
921 pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
923 self.definitions.get(dep_type)
924 }
925
926 pub fn start_blocking_types(&self) -> Vec<&str> {
928 self.definitions
929 .iter()
930 .filter(|(_, def)| def.blocks == BlockTarget::Start)
931 .map(|(name, _)| name.as_str())
932 .collect()
933 }
934
935 pub fn completion_blocking_types(&self) -> Vec<&str> {
937 self.definitions
938 .iter()
939 .filter(|(_, def)| def.blocks == BlockTarget::Completion)
940 .map(|(name, _)| name.as_str())
941 .collect()
942 }
943
944 pub fn vertical_types(&self) -> Vec<&str> {
946 self.definitions
947 .iter()
948 .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
949 .map(|(name, _)| name.as_str())
950 .collect()
951 }
952
953 pub fn dep_type_names(&self) -> Vec<&str> {
955 self.definitions.keys().map(|s| s.as_str()).collect()
956 }
957
958 pub fn validate(&self) -> anyhow::Result<()> {
960 if self.definitions.is_empty() {
961 return Err(anyhow::anyhow!(
962 "At least one dependency type must be defined"
963 ));
964 }
965
966 let has_start_blocking = self
968 .definitions
969 .values()
970 .any(|d| d.blocks == BlockTarget::Start);
971 if !has_start_blocking {
972 return Err(anyhow::anyhow!(
973 "At least one dependency type with blocks: start must be defined"
974 ));
975 }
976
977 Ok(())
978 }
979}
980
981impl StatesConfig {
982 pub fn is_valid_state(&self, state: &str) -> bool {
984 self.definitions.contains_key(state)
985 }
986
987 pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
989 if let Some(def) = self.definitions.get(from) {
990 def.exits.contains(&to.to_string())
991 } else {
992 false
993 }
994 }
995
996 pub fn is_timed_state(&self, state: &str) -> bool {
998 self.definitions
999 .get(state)
1000 .map(|d| d.timed)
1001 .unwrap_or(false)
1002 }
1003
1004 pub fn is_terminal_state(&self, state: &str) -> bool {
1006 self.definitions
1007 .get(state)
1008 .map(|d| d.exits.is_empty())
1009 .unwrap_or(false)
1010 }
1011
1012 pub fn is_blocking_state(&self, state: &str) -> bool {
1014 self.blocking_states.contains(&state.to_string())
1015 }
1016
1017 pub fn state_names(&self) -> Vec<&str> {
1019 self.definitions.keys().map(|s| s.as_str()).collect()
1020 }
1021
1022 pub fn get_exits(&self, state: &str) -> Vec<&str> {
1024 self.definitions
1025 .get(state)
1026 .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
1027 .unwrap_or_default()
1028 }
1029
1030 pub fn untimed_state_names(&self) -> Vec<&str> {
1032 self.definitions
1033 .iter()
1034 .filter(|(_, def)| !def.timed)
1035 .map(|(name, _)| name.as_str())
1036 .collect()
1037 }
1038
1039 pub fn validate(&self) -> Result<()> {
1041 if !self.definitions.contains_key(&self.initial) {
1043 return Err(anyhow!(
1044 "Initial state '{}' is not defined in state definitions",
1045 self.initial
1046 ));
1047 }
1048
1049 if !self.definitions.contains_key(&self.disconnect_state) {
1051 return Err(anyhow!(
1052 "Disconnect state '{}' is not defined in state definitions",
1053 self.disconnect_state
1054 ));
1055 }
1056 if self.is_timed_state(&self.disconnect_state) {
1057 return Err(anyhow!(
1058 "Disconnect state '{}' must not be a timed state",
1059 self.disconnect_state
1060 ));
1061 }
1062
1063 for state in &self.blocking_states {
1065 if !self.definitions.contains_key(state) {
1066 return Err(anyhow!(
1067 "Blocking state '{}' is not defined in state definitions",
1068 state
1069 ));
1070 }
1071 }
1072
1073 for (state_name, def) in &self.definitions {
1075 for exit in &def.exits {
1076 if !self.definitions.contains_key(exit) {
1077 return Err(anyhow!(
1078 "State '{}' has exit '{}' which is not defined",
1079 state_name,
1080 exit
1081 ));
1082 }
1083 }
1084 }
1085
1086 let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
1088 if !has_terminal {
1089 return Err(anyhow!(
1090 "At least one terminal state (with empty exits) must be defined"
1091 ));
1092 }
1093
1094 Ok(())
1095 }
1096}
1097
1098#[derive(Debug, Clone, Serialize, Deserialize)]
1100pub struct PhasesConfig {
1101 #[serde(default)]
1103 pub unknown_phase: UnknownKeyBehavior,
1104
1105 #[serde(default = "default_phases")]
1107 pub definitions: HashSet<String>,
1108}
1109
1110impl Default for PhasesConfig {
1111 fn default() -> Self {
1112 Self {
1113 unknown_phase: UnknownKeyBehavior::Warn,
1114 definitions: default_phases(),
1115 }
1116 }
1117}
1118
1119fn default_phases() -> HashSet<String> {
1120 [
1121 "deliver", "triage", "explore", "diagnose", "design", "plan", "implement", "test", "review", "security", "doc", "integrate", "deploy", "monitor", "optimize", ]
1137 .iter()
1138 .map(|s| s.to_string())
1139 .collect()
1140}
1141
1142impl PhasesConfig {
1143 pub fn is_known_phase(&self, phase: &str) -> bool {
1145 self.definitions.contains(phase)
1146 }
1147
1148 pub fn phase_names(&self) -> Vec<&str> {
1150 self.definitions.iter().map(|s| s.as_str()).collect()
1151 }
1152
1153 pub fn check_phase(&self, phase: &str) -> Result<Option<String>> {
1158 if self.is_known_phase(phase) {
1159 return Ok(None);
1160 }
1161
1162 match self.unknown_phase {
1163 UnknownKeyBehavior::Allow => Ok(None),
1164 UnknownKeyBehavior::Warn => Ok(Some(format!(
1165 "Unknown phase '{}'. Known phases: {:?}",
1166 phase,
1167 self.phase_names()
1168 ))),
1169 UnknownKeyBehavior::Reject => Err(anyhow!(
1170 "Unknown phase '{}'. Known phases: {:?}. Configure in phases.definitions or set unknown_phase to 'allow' or 'warn'.",
1171 phase,
1172 self.phase_names()
1173 )),
1174 }
1175 }
1176}
1177
1178impl Config {
1179 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1181 let content = std::fs::read_to_string(path)?;
1182 let config: Config = serde_yaml::from_str(&content)?;
1183 Ok(config)
1184 }
1185
1186 pub fn load_or_default() -> Self {
1190 if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH")
1192 && let Ok(config) = Self::load(&config_path)
1193 {
1194 return config;
1195 }
1196
1197 if let Ok(config) = Self::load("task-graph/config.yaml") {
1199 return config;
1200 }
1201
1202 if let Ok(config) = Self::load(".task-graph/config.yaml") {
1204 return config;
1205 }
1206
1207 let mut config = Self::default();
1209
1210 if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
1211 config.server.db_path = PathBuf::from(db_path);
1212 }
1213
1214 if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
1215 config.server.media_dir = PathBuf::from(media_dir);
1216 }
1217
1218 if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
1219 config.server.log_dir = PathBuf::from(log_dir);
1220 }
1221
1222 config
1223 }
1224
1225 pub fn ensure_db_dir(&self) -> Result<()> {
1227 if let Some(parent) = self.server.db_path.parent() {
1228 std::fs::create_dir_all(parent)?;
1229 }
1230 Ok(())
1231 }
1232
1233 pub fn ensure_media_dir(&self) -> Result<()> {
1235 std::fs::create_dir_all(&self.server.media_dir)?;
1236 Ok(())
1237 }
1238
1239 pub fn ensure_log_dir(&self) -> Result<()> {
1241 std::fs::create_dir_all(&self.server.log_dir)?;
1242 Ok(())
1243 }
1244
1245 pub fn media_dir(&self) -> &Path {
1247 &self.server.media_dir
1248 }
1249
1250 pub fn log_dir(&self) -> &Path {
1252 &self.server.log_dir
1253 }
1254}
1255
1256#[derive(Debug, Clone, Serialize, Deserialize)]
1258pub struct ToolPrompt {
1259 pub description: String,
1260}
1261
1262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1264pub struct Prompts {
1265 pub instructions: Option<String>,
1267
1268 #[serde(default)]
1270 pub tools: HashMap<String, ToolPrompt>,
1271}
1272
1273impl Prompts {
1274 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1276 let content = std::fs::read_to_string(path)?;
1277 let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
1279 Ok(prompts.unwrap_or_default())
1280 }
1281
1282 pub fn load_or_default() -> Self {
1286 if let Ok(prompts) = Self::load("task-graph/prompts.yaml") {
1288 return prompts;
1289 }
1290
1291 if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
1293 return prompts;
1294 }
1295
1296 Self::default()
1297 }
1298
1299 pub fn get_tool_description(&self, name: &str) -> Option<&str> {
1301 self.tools.get(name).map(|t| t.description.as_str())
1302 }
1303}