1use crate::format::OutputFormat;
4use anyhow::{Result, anyhow};
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)]
202#[derive(Default)]
203pub struct Config {
204 #[serde(default)]
205 pub server: ServerConfig,
206
207 #[serde(default)]
208 pub paths: PathsConfig,
209
210 #[serde(default)]
211 pub states: StatesConfig,
212
213 #[serde(default)]
214 pub dependencies: DependenciesConfig,
215
216 #[serde(default)]
217 pub auto_advance: AutoAdvanceConfig,
218
219 #[serde(default)]
220 pub attachments: AttachmentsConfig,
221}
222
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ServerPaths {
227 pub db_path: PathBuf,
229 pub media_dir: PathBuf,
231 pub log_dir: PathBuf,
233 pub config_path: Option<PathBuf>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ServerConfig {
240 #[serde(default = "default_db_path")]
242 pub db_path: PathBuf,
243
244 #[serde(default = "default_media_dir")]
246 pub media_dir: PathBuf,
247
248 #[serde(default = "default_claim_limit")]
250 pub claim_limit: i32,
251
252 #[serde(default = "default_stale_timeout")]
254 pub stale_timeout_seconds: i64,
255
256 #[serde(default)]
258 pub default_format: OutputFormat,
259
260 #[serde(default = "default_skills_dir")]
262 pub skills_dir: PathBuf,
263
264 #[serde(default = "default_log_dir")]
266 pub log_dir: PathBuf,
267}
268
269impl Default for ServerConfig {
270 fn default() -> Self {
271 Self {
272 db_path: default_db_path(),
273 media_dir: default_media_dir(),
274 claim_limit: default_claim_limit(),
275 stale_timeout_seconds: default_stale_timeout(),
276 default_format: OutputFormat::default(),
277 skills_dir: default_skills_dir(),
278 log_dir: default_log_dir(),
279 }
280 }
281}
282
283fn default_db_path() -> PathBuf {
284 PathBuf::from(".task-graph/tasks.db")
285}
286
287fn default_media_dir() -> PathBuf {
288 PathBuf::from(".task-graph/media")
289}
290
291fn default_skills_dir() -> PathBuf {
292 PathBuf::from(".task-graph/skills")
293}
294
295fn default_log_dir() -> PathBuf {
296 PathBuf::from(".task-graph/logs")
297}
298
299fn default_claim_limit() -> i32 {
300 5
301}
302
303fn default_stale_timeout() -> i64 {
304 900 }
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct PathsConfig {
310 #[serde(default)]
312 pub style: PathStyle,
313}
314
315impl Default for PathsConfig {
316 fn default() -> Self {
317 Self {
318 style: PathStyle::Relative,
319 }
320 }
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "snake_case")]
326#[derive(Default)]
327pub enum PathStyle {
328 #[default]
330 Relative,
331 ProjectPrefixed,
333}
334
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct StatesConfig {
339 #[serde(default = "default_initial_state")]
341 pub initial: String,
342
343 #[serde(default = "default_disconnect_state")]
345 pub disconnect_state: String,
346
347 #[serde(default = "default_blocking_states")]
349 pub blocking_states: Vec<String>,
350
351 #[serde(default = "default_state_definitions")]
353 pub definitions: HashMap<String, StateDefinition>,
354}
355
356impl Default for StatesConfig {
357 fn default() -> Self {
358 Self {
359 initial: default_initial_state(),
360 disconnect_state: default_disconnect_state(),
361 blocking_states: default_blocking_states(),
362 definitions: default_state_definitions(),
363 }
364 }
365}
366
367fn default_initial_state() -> String {
368 "pending".to_string()
369}
370
371fn default_disconnect_state() -> String {
372 "pending".to_string()
373}
374
375fn default_blocking_states() -> Vec<String> {
376 vec![
377 "pending".to_string(),
378 "assigned".to_string(),
379 "in_progress".to_string(),
380 ]
381}
382
383fn default_state_definitions() -> HashMap<String, StateDefinition> {
384 let mut defs = HashMap::new();
385
386 defs.insert(
387 "pending".to_string(),
388 StateDefinition {
389 exits: vec![
390 "assigned".to_string(),
391 "in_progress".to_string(),
392 "cancelled".to_string(),
393 ],
394 timed: false,
395 },
396 );
397
398 defs.insert(
399 "assigned".to_string(),
400 StateDefinition {
401 exits: vec![
402 "in_progress".to_string(),
403 "pending".to_string(),
404 "cancelled".to_string(),
405 ],
406 timed: false,
407 },
408 );
409
410 defs.insert(
411 "in_progress".to_string(),
412 StateDefinition {
413 exits: vec![
414 "completed".to_string(),
415 "failed".to_string(),
416 "pending".to_string(),
417 ],
418 timed: true,
419 },
420 );
421
422 defs.insert(
423 "completed".to_string(),
424 StateDefinition {
425 exits: vec![],
426 timed: false,
427 },
428 );
429
430 defs.insert(
431 "failed".to_string(),
432 StateDefinition {
433 exits: vec!["pending".to_string()],
434 timed: false,
435 },
436 );
437
438 defs.insert(
439 "cancelled".to_string(),
440 StateDefinition {
441 exits: vec![],
442 timed: false,
443 },
444 );
445
446 defs
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct StateDefinition {
452 #[serde(default)]
454 pub exits: Vec<String>,
455
456 #[serde(default)]
458 pub timed: bool,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct DependenciesConfig {
464 #[serde(default = "default_dependency_definitions")]
466 pub definitions: HashMap<String, DependencyDefinition>,
467}
468
469impl Default for DependenciesConfig {
470 fn default() -> Self {
471 Self {
472 definitions: default_dependency_definitions(),
473 }
474 }
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct DependencyDefinition {
480 pub display: DependencyDisplay,
482
483 pub blocks: BlockTarget,
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490pub enum DependencyDisplay {
491 Horizontal,
493 Vertical,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
499#[serde(rename_all = "snake_case")]
500pub enum BlockTarget {
501 None,
503 Start,
505 Completion,
507}
508
509fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
510 let mut defs = HashMap::new();
511
512 defs.insert(
514 "blocks".to_string(),
515 DependencyDefinition {
516 display: DependencyDisplay::Horizontal,
517 blocks: BlockTarget::Start,
518 },
519 );
520
521 defs.insert(
522 "follows".to_string(),
523 DependencyDefinition {
524 display: DependencyDisplay::Horizontal,
525 blocks: BlockTarget::Start,
526 },
527 );
528
529 defs.insert(
530 "contains".to_string(),
531 DependencyDefinition {
532 display: DependencyDisplay::Vertical,
533 blocks: BlockTarget::Completion,
534 },
535 );
536
537 defs.insert(
539 "duplicate".to_string(),
540 DependencyDefinition {
541 display: DependencyDisplay::Horizontal,
542 blocks: BlockTarget::None,
543 },
544 );
545
546 defs.insert(
547 "see-also".to_string(),
548 DependencyDefinition {
549 display: DependencyDisplay::Horizontal,
550 blocks: BlockTarget::None,
551 },
552 );
553
554 defs.insert(
555 "relates-to".to_string(),
556 DependencyDefinition {
557 display: DependencyDisplay::Horizontal,
558 blocks: BlockTarget::None,
559 },
560 );
561
562 defs
563}
564
565impl DependenciesConfig {
566 pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
568 self.definitions.contains_key(dep_type)
569 }
570
571 pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
573 self.definitions.get(dep_type)
574 }
575
576 pub fn start_blocking_types(&self) -> Vec<&str> {
578 self.definitions
579 .iter()
580 .filter(|(_, def)| def.blocks == BlockTarget::Start)
581 .map(|(name, _)| name.as_str())
582 .collect()
583 }
584
585 pub fn completion_blocking_types(&self) -> Vec<&str> {
587 self.definitions
588 .iter()
589 .filter(|(_, def)| def.blocks == BlockTarget::Completion)
590 .map(|(name, _)| name.as_str())
591 .collect()
592 }
593
594 pub fn vertical_types(&self) -> Vec<&str> {
596 self.definitions
597 .iter()
598 .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
599 .map(|(name, _)| name.as_str())
600 .collect()
601 }
602
603 pub fn dep_type_names(&self) -> Vec<&str> {
605 self.definitions.keys().map(|s| s.as_str()).collect()
606 }
607
608 pub fn validate(&self) -> anyhow::Result<()> {
610 if self.definitions.is_empty() {
611 return Err(anyhow::anyhow!(
612 "At least one dependency type must be defined"
613 ));
614 }
615
616 let has_start_blocking = self
618 .definitions
619 .values()
620 .any(|d| d.blocks == BlockTarget::Start);
621 if !has_start_blocking {
622 return Err(anyhow::anyhow!(
623 "At least one dependency type with blocks: start must be defined"
624 ));
625 }
626
627 Ok(())
628 }
629}
630
631impl StatesConfig {
632 pub fn is_valid_state(&self, state: &str) -> bool {
634 self.definitions.contains_key(state)
635 }
636
637 pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
639 if let Some(def) = self.definitions.get(from) {
640 def.exits.contains(&to.to_string())
641 } else {
642 false
643 }
644 }
645
646 pub fn is_timed_state(&self, state: &str) -> bool {
648 self.definitions
649 .get(state)
650 .map(|d| d.timed)
651 .unwrap_or(false)
652 }
653
654 pub fn is_terminal_state(&self, state: &str) -> bool {
656 self.definitions
657 .get(state)
658 .map(|d| d.exits.is_empty())
659 .unwrap_or(false)
660 }
661
662 pub fn is_blocking_state(&self, state: &str) -> bool {
664 self.blocking_states.contains(&state.to_string())
665 }
666
667 pub fn state_names(&self) -> Vec<&str> {
669 self.definitions.keys().map(|s| s.as_str()).collect()
670 }
671
672 pub fn get_exits(&self, state: &str) -> Vec<&str> {
674 self.definitions
675 .get(state)
676 .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
677 .unwrap_or_default()
678 }
679
680 pub fn untimed_state_names(&self) -> Vec<&str> {
682 self.definitions
683 .iter()
684 .filter(|(_, def)| !def.timed)
685 .map(|(name, _)| name.as_str())
686 .collect()
687 }
688
689 pub fn validate(&self) -> Result<()> {
691 if !self.definitions.contains_key(&self.initial) {
693 return Err(anyhow!(
694 "Initial state '{}' is not defined in state definitions",
695 self.initial
696 ));
697 }
698
699 if !self.definitions.contains_key(&self.disconnect_state) {
701 return Err(anyhow!(
702 "Disconnect state '{}' is not defined in state definitions",
703 self.disconnect_state
704 ));
705 }
706 if self.is_timed_state(&self.disconnect_state) {
707 return Err(anyhow!(
708 "Disconnect state '{}' must not be a timed state",
709 self.disconnect_state
710 ));
711 }
712
713 for state in &self.blocking_states {
715 if !self.definitions.contains_key(state) {
716 return Err(anyhow!(
717 "Blocking state '{}' is not defined in state definitions",
718 state
719 ));
720 }
721 }
722
723 for (state_name, def) in &self.definitions {
725 for exit in &def.exits {
726 if !self.definitions.contains_key(exit) {
727 return Err(anyhow!(
728 "State '{}' has exit '{}' which is not defined",
729 state_name,
730 exit
731 ));
732 }
733 }
734 }
735
736 let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
738 if !has_terminal {
739 return Err(anyhow!(
740 "At least one terminal state (with empty exits) must be defined"
741 ));
742 }
743
744 Ok(())
745 }
746}
747
748impl Config {
749 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
751 let content = std::fs::read_to_string(path)?;
752 let config: Config = serde_yaml::from_str(&content)?;
753 Ok(config)
754 }
755
756 pub fn load_or_default() -> Self {
758 if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH")
760 && let Ok(config) = Self::load(&config_path) {
761 return config;
762 }
763
764 if let Ok(config) = Self::load(".task-graph/config.yaml") {
766 return config;
767 }
768
769 let mut config = Self::default();
771
772 if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
773 config.server.db_path = PathBuf::from(db_path);
774 }
775
776 if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
777 config.server.media_dir = PathBuf::from(media_dir);
778 }
779
780 if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
781 config.server.log_dir = PathBuf::from(log_dir);
782 }
783
784 config
785 }
786
787 pub fn ensure_db_dir(&self) -> Result<()> {
789 if let Some(parent) = self.server.db_path.parent() {
790 std::fs::create_dir_all(parent)?;
791 }
792 Ok(())
793 }
794
795 pub fn ensure_media_dir(&self) -> Result<()> {
797 std::fs::create_dir_all(&self.server.media_dir)?;
798 Ok(())
799 }
800
801 pub fn ensure_log_dir(&self) -> Result<()> {
803 std::fs::create_dir_all(&self.server.log_dir)?;
804 Ok(())
805 }
806
807 pub fn media_dir(&self) -> &Path {
809 &self.server.media_dir
810 }
811
812 pub fn log_dir(&self) -> &Path {
814 &self.server.log_dir
815 }
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize)]
820pub struct ToolPrompt {
821 pub description: String,
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize, Default)]
826pub struct Prompts {
827 pub instructions: Option<String>,
829
830 #[serde(default)]
832 pub tools: HashMap<String, ToolPrompt>,
833}
834
835impl Prompts {
836 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
838 let content = std::fs::read_to_string(path)?;
839 let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
841 Ok(prompts.unwrap_or_default())
842 }
843
844 pub fn load_or_default() -> Self {
846 if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
848 return prompts;
849 }
850
851 Self::default()
852 }
853
854 pub fn get_tool_description(&self, name: &str) -> Option<&str> {
856 self.tools.get(name).map(|t| t.description.as_str())
857 }
858}