1use crate::format::OutputFormat;
4use anyhow::{anyhow, Result};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct AutoAdvanceConfig {
12 #[serde(default)]
14 pub enabled: bool,
15
16 #[serde(default)]
19 pub target_state: Option<String>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum UnknownKeyBehavior {
26 Allow,
28 #[default]
30 Warn,
31 Reject,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AttachmentKeyDefinition {
38 pub mime: String,
40 #[serde(default = "default_append_mode")]
42 pub mode: String,
43}
44
45fn default_append_mode() -> String {
46 "append".to_string()
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AttachmentsConfig {
52 #[serde(default)]
54 pub unknown_key: UnknownKeyBehavior,
55 #[serde(default = "AttachmentsConfig::default_definitions")]
57 pub definitions: HashMap<String, AttachmentKeyDefinition>,
58}
59
60impl Default for AttachmentsConfig {
61 fn default() -> Self {
62 Self {
63 unknown_key: UnknownKeyBehavior::default(),
64 definitions: Self::default_definitions(),
65 }
66 }
67}
68
69impl AttachmentsConfig {
70 pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
72 let mut defs = HashMap::new();
73
74 defs.insert(
75 "commit".to_string(),
76 AttachmentKeyDefinition {
77 mime: "text/git.hash".to_string(),
78 mode: "append".to_string(),
79 },
80 );
81
82 defs.insert(
83 "checkin".to_string(),
84 AttachmentKeyDefinition {
85 mime: "text/p4.changelist".to_string(),
86 mode: "append".to_string(),
87 },
88 );
89
90 defs.insert(
91 "meta".to_string(),
92 AttachmentKeyDefinition {
93 mime: "application/json".to_string(),
94 mode: "replace".to_string(),
95 },
96 );
97
98 defs.insert(
99 "note".to_string(),
100 AttachmentKeyDefinition {
101 mime: "text/plain".to_string(),
102 mode: "append".to_string(),
103 },
104 );
105
106 defs.insert(
107 "log".to_string(),
108 AttachmentKeyDefinition {
109 mime: "text/plain".to_string(),
110 mode: "append".to_string(),
111 },
112 );
113
114 defs.insert(
115 "error".to_string(),
116 AttachmentKeyDefinition {
117 mime: "text/plain".to_string(),
118 mode: "append".to_string(),
119 },
120 );
121
122 defs.insert(
123 "output".to_string(),
124 AttachmentKeyDefinition {
125 mime: "text/plain".to_string(),
126 mode: "append".to_string(),
127 },
128 );
129
130 defs.insert(
131 "diff".to_string(),
132 AttachmentKeyDefinition {
133 mime: "text/x-diff".to_string(),
134 mode: "append".to_string(),
135 },
136 );
137
138 defs.insert(
139 "changelist".to_string(),
140 AttachmentKeyDefinition {
141 mime: "text/plain".to_string(),
142 mode: "append".to_string(),
143 },
144 );
145
146 defs.insert(
147 "plan".to_string(),
148 AttachmentKeyDefinition {
149 mime: "text/markdown".to_string(),
150 mode: "replace".to_string(),
151 },
152 );
153
154 defs.insert(
155 "result".to_string(),
156 AttachmentKeyDefinition {
157 mime: "application/json".to_string(),
158 mode: "replace".to_string(),
159 },
160 );
161
162 defs.insert(
163 "context".to_string(),
164 AttachmentKeyDefinition {
165 mime: "text/plain".to_string(),
166 mode: "replace".to_string(),
167 },
168 );
169
170 defs
171 }
172
173 pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
175 self.definitions.get(key)
176 }
177
178 pub fn is_known_key(&self, key: &str) -> bool {
180 self.definitions.contains_key(key)
181 }
182
183 pub fn get_mime_default(&self, key: &str) -> &str {
185 self.definitions
186 .get(key)
187 .map(|d| d.mime.as_str())
188 .unwrap_or("text/plain")
189 }
190
191 pub fn get_mode_default(&self, key: &str) -> &str {
193 self.definitions
194 .get(key)
195 .map(|d| d.mode.as_str())
196 .unwrap_or("append")
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct Config {
203 #[serde(default)]
204 pub server: ServerConfig,
205
206 #[serde(default)]
207 pub paths: PathsConfig,
208
209 #[serde(default)]
210 pub states: StatesConfig,
211
212 #[serde(default)]
213 pub dependencies: DependenciesConfig,
214
215 #[serde(default)]
216 pub auto_advance: AutoAdvanceConfig,
217
218 #[serde(default)]
219 pub attachments: AttachmentsConfig,
220}
221
222impl Default for Config {
223 fn default() -> Self {
224 Self {
225 server: ServerConfig::default(),
226 paths: PathsConfig::default(),
227 states: StatesConfig::default(),
228 dependencies: DependenciesConfig::default(),
229 auto_advance: AutoAdvanceConfig::default(),
230 attachments: AttachmentsConfig::default(),
231 }
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ServerPaths {
238 pub db_path: PathBuf,
240 pub media_dir: PathBuf,
242 pub log_dir: PathBuf,
244 pub config_path: Option<PathBuf>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ServerConfig {
251 #[serde(default = "default_db_path")]
253 pub db_path: PathBuf,
254
255 #[serde(default = "default_media_dir")]
257 pub media_dir: PathBuf,
258
259 #[serde(default = "default_claim_limit")]
261 pub claim_limit: i32,
262
263 #[serde(default = "default_stale_timeout")]
265 pub stale_timeout_seconds: i64,
266
267 #[serde(default)]
269 pub default_format: OutputFormat,
270
271 #[serde(default = "default_skills_dir")]
273 pub skills_dir: PathBuf,
274
275 #[serde(default = "default_log_dir")]
277 pub log_dir: PathBuf,
278}
279
280impl Default for ServerConfig {
281 fn default() -> Self {
282 Self {
283 db_path: default_db_path(),
284 media_dir: default_media_dir(),
285 claim_limit: default_claim_limit(),
286 stale_timeout_seconds: default_stale_timeout(),
287 default_format: OutputFormat::default(),
288 skills_dir: default_skills_dir(),
289 log_dir: default_log_dir(),
290 }
291 }
292}
293
294fn default_db_path() -> PathBuf {
295 PathBuf::from(".task-graph/tasks.db")
296}
297
298fn default_media_dir() -> PathBuf {
299 PathBuf::from(".task-graph/media")
300}
301
302fn default_skills_dir() -> PathBuf {
303 PathBuf::from(".task-graph/skills")
304}
305
306
307fn default_log_dir() -> PathBuf {
308 PathBuf::from(".task-graph/logs")
309}
310
311fn default_claim_limit() -> i32 {
312 5
313}
314
315fn default_stale_timeout() -> i64 {
316 900 }
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct PathsConfig {
322 #[serde(default)]
324 pub style: PathStyle,
325}
326
327impl Default for PathsConfig {
328 fn default() -> Self {
329 Self {
330 style: PathStyle::Relative,
331 }
332 }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum PathStyle {
339 Relative,
341 ProjectPrefixed,
343}
344
345impl Default for PathStyle {
346 fn default() -> Self {
347 PathStyle::Relative
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct StatesConfig {
354 #[serde(default = "default_initial_state")]
356 pub initial: String,
357
358 #[serde(default = "default_disconnect_state")]
360 pub disconnect_state: String,
361
362 #[serde(default = "default_blocking_states")]
364 pub blocking_states: Vec<String>,
365
366 #[serde(default = "default_state_definitions")]
368 pub definitions: HashMap<String, StateDefinition>,
369}
370
371impl Default for StatesConfig {
372 fn default() -> Self {
373 Self {
374 initial: default_initial_state(),
375 disconnect_state: default_disconnect_state(),
376 blocking_states: default_blocking_states(),
377 definitions: default_state_definitions(),
378 }
379 }
380}
381
382fn default_initial_state() -> String {
383 "pending".to_string()
384}
385
386fn default_disconnect_state() -> String {
387 "pending".to_string()
388}
389
390fn default_blocking_states() -> Vec<String> {
391 vec!["pending".to_string(), "assigned".to_string(), "in_progress".to_string()]
392}
393
394fn default_state_definitions() -> HashMap<String, StateDefinition> {
395 let mut defs = HashMap::new();
396
397 defs.insert(
398 "pending".to_string(),
399 StateDefinition {
400 exits: vec!["assigned".to_string(), "in_progress".to_string(), "cancelled".to_string()],
401 timed: false,
402 },
403 );
404
405 defs.insert(
406 "assigned".to_string(),
407 StateDefinition {
408 exits: vec!["in_progress".to_string(), "pending".to_string(), "cancelled".to_string()],
409 timed: false,
410 },
411 );
412
413 defs.insert(
414 "in_progress".to_string(),
415 StateDefinition {
416 exits: vec![
417 "completed".to_string(),
418 "failed".to_string(),
419 "pending".to_string(),
420 ],
421 timed: true,
422 },
423 );
424
425 defs.insert(
426 "completed".to_string(),
427 StateDefinition {
428 exits: vec![],
429 timed: false,
430 },
431 );
432
433 defs.insert(
434 "failed".to_string(),
435 StateDefinition {
436 exits: vec!["pending".to_string()],
437 timed: false,
438 },
439 );
440
441 defs.insert(
442 "cancelled".to_string(),
443 StateDefinition {
444 exits: vec![],
445 timed: false,
446 },
447 );
448
449 defs
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct StateDefinition {
455 #[serde(default)]
457 pub exits: Vec<String>,
458
459 #[serde(default)]
461 pub timed: bool,
462}
463
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct DependenciesConfig {
468 #[serde(default = "default_dependency_definitions")]
470 pub definitions: HashMap<String, DependencyDefinition>,
471}
472
473impl Default for DependenciesConfig {
474 fn default() -> Self {
475 Self {
476 definitions: default_dependency_definitions(),
477 }
478 }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct DependencyDefinition {
484 pub display: DependencyDisplay,
486
487 pub blocks: BlockTarget,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum DependencyDisplay {
495 Horizontal,
497 Vertical,
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "snake_case")]
504pub enum BlockTarget {
505 None,
507 Start,
509 Completion,
511}
512
513fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
514 let mut defs = HashMap::new();
515
516 defs.insert(
518 "blocks".to_string(),
519 DependencyDefinition {
520 display: DependencyDisplay::Horizontal,
521 blocks: BlockTarget::Start,
522 },
523 );
524
525 defs.insert(
526 "follows".to_string(),
527 DependencyDefinition {
528 display: DependencyDisplay::Horizontal,
529 blocks: BlockTarget::Start,
530 },
531 );
532
533 defs.insert(
534 "contains".to_string(),
535 DependencyDefinition {
536 display: DependencyDisplay::Vertical,
537 blocks: BlockTarget::Completion,
538 },
539 );
540
541 defs.insert(
543 "duplicate".to_string(),
544 DependencyDefinition {
545 display: DependencyDisplay::Horizontal,
546 blocks: BlockTarget::None,
547 },
548 );
549
550 defs.insert(
551 "see-also".to_string(),
552 DependencyDefinition {
553 display: DependencyDisplay::Horizontal,
554 blocks: BlockTarget::None,
555 },
556 );
557
558 defs.insert(
559 "relates-to".to_string(),
560 DependencyDefinition {
561 display: DependencyDisplay::Horizontal,
562 blocks: BlockTarget::None,
563 },
564 );
565
566 defs
567}
568
569impl DependenciesConfig {
570 pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
572 self.definitions.contains_key(dep_type)
573 }
574
575 pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
577 self.definitions.get(dep_type)
578 }
579
580 pub fn start_blocking_types(&self) -> Vec<&str> {
582 self.definitions
583 .iter()
584 .filter(|(_, def)| def.blocks == BlockTarget::Start)
585 .map(|(name, _)| name.as_str())
586 .collect()
587 }
588
589 pub fn completion_blocking_types(&self) -> Vec<&str> {
591 self.definitions
592 .iter()
593 .filter(|(_, def)| def.blocks == BlockTarget::Completion)
594 .map(|(name, _)| name.as_str())
595 .collect()
596 }
597
598 pub fn vertical_types(&self) -> Vec<&str> {
600 self.definitions
601 .iter()
602 .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
603 .map(|(name, _)| name.as_str())
604 .collect()
605 }
606
607 pub fn dep_type_names(&self) -> Vec<&str> {
609 self.definitions.keys().map(|s| s.as_str()).collect()
610 }
611
612 pub fn validate(&self) -> anyhow::Result<()> {
614 if self.definitions.is_empty() {
615 return Err(anyhow::anyhow!(
616 "At least one dependency type must be defined"
617 ));
618 }
619
620 let has_start_blocking = self.definitions.values().any(|d| d.blocks == BlockTarget::Start);
622 if !has_start_blocking {
623 return Err(anyhow::anyhow!(
624 "At least one dependency type with blocks: start must be defined"
625 ));
626 }
627
628 Ok(())
629 }
630}
631
632impl StatesConfig {
633 pub fn is_valid_state(&self, state: &str) -> bool {
635 self.definitions.contains_key(state)
636 }
637
638 pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
640 if let Some(def) = self.definitions.get(from) {
641 def.exits.contains(&to.to_string())
642 } else {
643 false
644 }
645 }
646
647 pub fn is_timed_state(&self, state: &str) -> bool {
649 self.definitions
650 .get(state)
651 .map(|d| d.timed)
652 .unwrap_or(false)
653 }
654
655 pub fn is_terminal_state(&self, state: &str) -> bool {
657 self.definitions
658 .get(state)
659 .map(|d| d.exits.is_empty())
660 .unwrap_or(false)
661 }
662
663 pub fn is_blocking_state(&self, state: &str) -> bool {
665 self.blocking_states.contains(&state.to_string())
666 }
667
668 pub fn state_names(&self) -> Vec<&str> {
670 self.definitions.keys().map(|s| s.as_str()).collect()
671 }
672
673 pub fn get_exits(&self, state: &str) -> Vec<&str> {
675 self.definitions
676 .get(state)
677 .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
678 .unwrap_or_default()
679 }
680
681 pub fn untimed_state_names(&self) -> Vec<&str> {
683 self.definitions
684 .iter()
685 .filter(|(_, def)| !def.timed)
686 .map(|(name, _)| name.as_str())
687 .collect()
688 }
689
690 pub fn validate(&self) -> Result<()> {
692 if !self.definitions.contains_key(&self.initial) {
694 return Err(anyhow!(
695 "Initial state '{}' is not defined in state definitions",
696 self.initial
697 ));
698 }
699
700 if !self.definitions.contains_key(&self.disconnect_state) {
702 return Err(anyhow!(
703 "Disconnect state '{}' is not defined in state definitions",
704 self.disconnect_state
705 ));
706 }
707 if self.is_timed_state(&self.disconnect_state) {
708 return Err(anyhow!(
709 "Disconnect state '{}' must not be a timed state",
710 self.disconnect_state
711 ));
712 }
713
714 for state in &self.blocking_states {
716 if !self.definitions.contains_key(state) {
717 return Err(anyhow!(
718 "Blocking state '{}' is not defined in state definitions",
719 state
720 ));
721 }
722 }
723
724 for (state_name, def) in &self.definitions {
726 for exit in &def.exits {
727 if !self.definitions.contains_key(exit) {
728 return Err(anyhow!(
729 "State '{}' has exit '{}' which is not defined",
730 state_name,
731 exit
732 ));
733 }
734 }
735 }
736
737 let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
739 if !has_terminal {
740 return Err(anyhow!(
741 "At least one terminal state (with empty exits) must be defined"
742 ));
743 }
744
745 Ok(())
746 }
747}
748
749impl Config {
750 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
752 let content = std::fs::read_to_string(path)?;
753 let config: Config = serde_yaml::from_str(&content)?;
754 Ok(config)
755 }
756
757 pub fn load_or_default() -> Self {
759 if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH") {
761 if let Ok(config) = Self::load(&config_path) {
762 return config;
763 }
764 }
765
766 if let Ok(config) = Self::load(".task-graph/config.yaml") {
768 return config;
769 }
770
771 let mut config = Self::default();
773
774 if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
775 config.server.db_path = PathBuf::from(db_path);
776 }
777
778 if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
779 config.server.media_dir = PathBuf::from(media_dir);
780 }
781
782 if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
783 config.server.log_dir = PathBuf::from(log_dir);
784 }
785
786 config
787 }
788
789 pub fn ensure_db_dir(&self) -> Result<()> {
791 if let Some(parent) = self.server.db_path.parent() {
792 std::fs::create_dir_all(parent)?;
793 }
794 Ok(())
795 }
796
797 pub fn ensure_media_dir(&self) -> Result<()> {
799 std::fs::create_dir_all(&self.server.media_dir)?;
800 Ok(())
801 }
802
803 pub fn ensure_log_dir(&self) -> Result<()> {
805 std::fs::create_dir_all(&self.server.log_dir)?;
806 Ok(())
807 }
808
809 pub fn media_dir(&self) -> &Path {
811 &self.server.media_dir
812 }
813
814 pub fn log_dir(&self) -> &Path {
816 &self.server.log_dir
817 }
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize)]
822pub struct ToolPrompt {
823 pub description: String,
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize, Default)]
828pub struct Prompts {
829 pub instructions: Option<String>,
831
832 #[serde(default)]
834 pub tools: HashMap<String, ToolPrompt>,
835}
836
837impl Prompts {
838 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
840 let content = std::fs::read_to_string(path)?;
841 let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
843 Ok(prompts.unwrap_or_default())
844 }
845
846 pub fn load_or_default() -> Self {
848 if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
850 return prompts;
851 }
852
853 Self::default()
854 }
855
856 pub fn get_tool_description(&self, name: &str) -> Option<&str> {
858 self.tools.get(name).map(|t| t.description.as_str())
859 }
860}