Skip to main content

enact_core/workflow/
progress.rs

1//! Progress Journal - Human-readable progress tracking
2//!
3//! Implements Antfarm-style progress journal:
4//! - Discovered codebase patterns
5//! - Story-by-story deltas
6//! - Test/build snapshots
7//! - Key decisions and unresolved risks
8
9use anyhow::{Context, Result};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::Path;
14
15/// Progress journal entry
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ProgressEntry {
18    /// Timestamp
19    pub timestamp: DateTime<Utc>,
20    /// Entry type
21    pub entry_type: EntryType,
22    /// Step ID that produced this entry
23    pub step_id: String,
24    /// Entry title/summary
25    pub title: String,
26    /// Entry details
27    pub details: String,
28    /// Associated metadata
29    #[serde(default)]
30    pub metadata: HashMap<String, serde_json::Value>,
31}
32
33/// Types of progress entries
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum EntryType {
37    /// Story started
38    StoryStart,
39    /// Story completed
40    StoryComplete,
41    /// Code pattern discovered
42    PatternDiscovered,
43    /// Test results
44    TestResults,
45    /// Build results
46    BuildResults,
47    /// Decision made
48    Decision,
49    /// Risk identified
50    Risk,
51    /// Milestone reached
52    Milestone,
53    /// Error occurred
54    Error,
55    /// General info
56    Info,
57}
58
59/// Codebase pattern discovered during execution
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CodebasePattern {
62    /// Pattern name/category
63    pub category: String,
64    /// Pattern description
65    pub description: String,
66    /// Example file or location
67    pub example: Option<String>,
68    /// Whether this pattern should be reused
69    pub reusable: bool,
70}
71
72/// Test snapshot
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TestSnapshot {
75    /// Test command
76    pub command: String,
77    /// Test output
78    pub output: String,
79    /// Whether tests passed
80    pub passed: bool,
81    /// Failure count (if any)
82    #[serde(default)]
83    pub failure_count: Option<usize>,
84    /// Duration in seconds
85    pub duration_secs: f64,
86}
87
88/// Build snapshot
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct BuildSnapshot {
91    /// Build command
92    pub command: String,
93    /// Build output
94    pub output: String,
95    /// Whether build succeeded
96    pub succeeded: bool,
97    /// Errors (if any)
98    #[serde(default)]
99    pub errors: Vec<String>,
100    /// Warnings
101    #[serde(default)]
102    pub warnings: Vec<String>,
103    /// Duration in seconds
104    pub duration_secs: f64,
105}
106
107/// Key decision record
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Decision {
110    /// Decision title
111    pub title: String,
112    /// Context/why this decision was needed
113    pub context: String,
114    /// Decision made
115    pub decision: String,
116    /// Consequences
117    pub consequences: Vec<String>,
118    /// Alternatives considered
119    #[serde(default)]
120    pub alternatives: Vec<String>,
121    /// Whether this decision is reversible
122    pub reversible: bool,
123}
124
125/// Risk identified
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Risk {
128    /// Risk description
129    pub description: String,
130    /// Risk level
131    pub level: RiskLevel,
132    /// Mitigation strategy
133    pub mitigation: Option<String>,
134    /// Whether this risk is resolved
135    #[serde(default)]
136    pub resolved: bool,
137    /// Resolution notes
138    #[serde(default)]
139    pub resolution_notes: Option<String>,
140}
141
142/// Risk levels
143#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum RiskLevel {
146    Low,
147    Medium,
148    High,
149    Critical,
150}
151
152/// Progress journal for a workflow run
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ProgressJournal {
155    /// Workflow run ID
156    pub run_id: String,
157    /// Workflow name
158    pub workflow_name: String,
159    /// Start time
160    pub start_time: DateTime<Utc>,
161    /// Last update time
162    pub last_update: DateTime<Utc>,
163    /// Task description
164    pub task: String,
165    /// Repository path
166    pub repo: Option<String>,
167    /// Branch name
168    pub branch: Option<String>,
169    /// All progress entries
170    pub entries: Vec<ProgressEntry>,
171    /// Discovered codebase patterns
172    #[serde(default)]
173    pub patterns: Vec<CodebasePattern>,
174    /// Key decisions
175    #[serde(default)]
176    pub decisions: Vec<Decision>,
177    /// Identified risks
178    #[serde(default)]
179    pub risks: Vec<Risk>,
180    /// Test snapshots
181    #[serde(default)]
182    pub test_snapshots: Vec<TestSnapshot>,
183    /// Build snapshots
184    #[serde(default)]
185    pub build_snapshots: Vec<BuildSnapshot>,
186    /// Custom sections
187    #[serde(default)]
188    pub sections: HashMap<String, String>,
189}
190
191impl ProgressJournal {
192    /// Create a new progress journal
193    pub fn new(run_id: String, workflow_name: String, task: String) -> Self {
194        let now = Utc::now();
195        Self {
196            run_id,
197            workflow_name,
198            start_time: now,
199            last_update: now,
200            task,
201            repo: None,
202            branch: None,
203            entries: vec![],
204            patterns: vec![],
205            decisions: vec![],
206            risks: vec![],
207            test_snapshots: vec![],
208            build_snapshots: vec![],
209            sections: HashMap::new(),
210        }
211    }
212
213    /// Add a progress entry
214    pub fn add_entry(&mut self, entry_type: EntryType, step_id: &str, title: &str, details: &str) {
215        self.entries.push(ProgressEntry {
216            timestamp: Utc::now(),
217            entry_type,
218            step_id: step_id.to_string(),
219            title: title.to_string(),
220            details: details.to_string(),
221            metadata: HashMap::new(),
222        });
223        self.last_update = Utc::now();
224    }
225
226    /// Add a codebase pattern
227    pub fn add_pattern(
228        &mut self,
229        category: &str,
230        description: &str,
231        example: Option<&str>,
232        reusable: bool,
233    ) {
234        self.patterns.push(CodebasePattern {
235            category: category.to_string(),
236            description: description.to_string(),
237            example: example.map(|s| s.to_string()),
238            reusable,
239        });
240    }
241
242    /// Add a decision
243    pub fn add_decision(
244        &mut self,
245        title: &str,
246        context: &str,
247        decision: &str,
248        consequences: Vec<String>,
249        reversible: bool,
250    ) {
251        self.decisions.push(Decision {
252            title: title.to_string(),
253            context: context.to_string(),
254            decision: decision.to_string(),
255            consequences,
256            alternatives: vec![],
257            reversible,
258        });
259    }
260
261    /// Add a risk
262    pub fn add_risk(&mut self, description: &str, level: RiskLevel, mitigation: Option<&str>) {
263        self.risks.push(Risk {
264            description: description.to_string(),
265            level,
266            mitigation: mitigation.map(|s| s.to_string()),
267            resolved: false,
268            resolution_notes: None,
269        });
270    }
271
272    /// Mark a risk as resolved
273    pub fn resolve_risk(&mut self, description: &str, notes: &str) {
274        if let Some(risk) = self.risks.iter_mut().find(|r| r.description == description) {
275            risk.resolved = true;
276            risk.resolution_notes = Some(notes.to_string());
277        }
278    }
279
280    /// Add a test snapshot
281    pub fn add_test_snapshot(
282        &mut self,
283        command: &str,
284        output: &str,
285        passed: bool,
286        duration_secs: f64,
287    ) {
288        self.test_snapshots.push(TestSnapshot {
289            command: command.to_string(),
290            output: output.to_string(),
291            passed,
292            failure_count: None,
293            duration_secs,
294        });
295    }
296
297    /// Add a build snapshot
298    pub fn add_build_snapshot(
299        &mut self,
300        command: &str,
301        output: &str,
302        succeeded: bool,
303        duration_secs: f64,
304    ) {
305        self.build_snapshots.push(BuildSnapshot {
306            command: command.to_string(),
307            output: output.to_string(),
308            succeeded,
309            errors: vec![],
310            warnings: vec![],
311            duration_secs,
312        });
313    }
314
315    /// Set a custom section
316    pub fn set_section(&mut self, name: &str, content: &str) {
317        self.sections.insert(name.to_string(), content.to_string());
318    }
319
320    /// Get entries by type
321    pub fn entries_by_type(&self, entry_type: EntryType) -> Vec<&ProgressEntry> {
322        self.entries
323            .iter()
324            .filter(|e| {
325                std::mem::discriminant(&e.entry_type) == std::mem::discriminant(&entry_type)
326            })
327            .collect()
328    }
329
330    /// Serialize to JSON
331    pub fn to_json(&self) -> Result<String> {
332        serde_json::to_string_pretty(self).context("Failed to serialize progress journal")
333    }
334
335    /// Serialize to markdown (human-readable)
336    pub fn to_markdown(&self) -> String {
337        let mut md = String::new();
338
339        // Header
340        md.push_str(&format!("# Progress Journal: {}\n\n", self.workflow_name));
341        md.push_str(&format!("**Run ID:** {}\n\n", self.run_id));
342        md.push_str(&format!(
343            "**Started:** {}\n\n",
344            self.start_time.format("%Y-%m-%d %H:%M:%S UTC")
345        ));
346        md.push_str(&format!(
347            "**Last Update:** {}\n\n",
348            self.last_update.format("%Y-%m-%d %H:%M:%S UTC")
349        ));
350
351        // Task
352        md.push_str("## Task\n\n");
353        md.push_str(&self.task);
354        md.push_str("\n\n");
355
356        // Repository info
357        if let Some(repo) = &self.repo {
358            md.push_str("## Repository\n\n");
359            md.push_str(&format!("- **Path:** {}\n", repo));
360            if let Some(branch) = &self.branch {
361                md.push_str(&format!("- **Branch:** {}\n", branch));
362            }
363            md.push('\n');
364        }
365
366        // Codebase Patterns
367        if !self.patterns.is_empty() {
368            md.push_str("## Codebase Patterns\n\n");
369            for pattern in &self.patterns {
370                md.push_str(&format!("### {}\n\n", pattern.category));
371                md.push_str(&format!("{}\n\n", pattern.description));
372                if let Some(example) = &pattern.example {
373                    md.push_str(&format!("**Example:** `{}`\n\n", example));
374                }
375                md.push_str(&format!(
376                    "**Reusable:** {}\n\n",
377                    if pattern.reusable { "Yes" } else { "No" }
378                ));
379            }
380        }
381
382        // Test Results
383        if !self.test_snapshots.is_empty() {
384            md.push_str("## Test Results\n\n");
385            for snapshot in &self.test_snapshots {
386                let status = if snapshot.passed {
387                    "✅ PASS"
388                } else {
389                    "❌ FAIL"
390                };
391                md.push_str(&format!("- **{}** ({}s)\n", status, snapshot.duration_secs));
392                md.push_str(&format!("  - Command: `{}`\n", snapshot.command));
393            }
394            md.push('\n');
395        }
396
397        // Build Results
398        if !self.build_snapshots.is_empty() {
399            md.push_str("## Build Results\n\n");
400            for snapshot in &self.build_snapshots {
401                let status = if snapshot.succeeded {
402                    "✅ SUCCESS"
403                } else {
404                    "❌ FAILED"
405                };
406                md.push_str(&format!("- **{}** ({}s)\n", status, snapshot.duration_secs));
407                md.push_str(&format!("  - Command: `{}`\n", snapshot.command));
408            }
409            md.push('\n');
410        }
411
412        // Decisions
413        if !self.decisions.is_empty() {
414            md.push_str("## Decisions\n\n");
415            for decision in &self.decisions {
416                md.push_str(&format!("### {}\n\n", decision.title));
417                md.push_str(&format!("**Context:** {}\n\n", decision.context));
418                md.push_str(&format!("**Decision:** {}\n\n", decision.decision));
419                if !decision.consequences.is_empty() {
420                    md.push_str("**Consequences:**\n");
421                    for consequence in &decision.consequences {
422                        md.push_str(&format!("- {}\n", consequence));
423                    }
424                    md.push('\n');
425                }
426                md.push_str(&format!(
427                    "**Reversible:** {}\n\n",
428                    if decision.reversible { "Yes" } else { "No" }
429                ));
430            }
431        }
432
433        // Risks
434        if !self.risks.is_empty() {
435            md.push_str("## Risks\n\n");
436            for risk in &self.risks {
437                let level_icon = match risk.level {
438                    RiskLevel::Low => "🟢",
439                    RiskLevel::Medium => "🟡",
440                    RiskLevel::High => "🔴",
441                    RiskLevel::Critical => "⚠️",
442                };
443                let status = if risk.resolved {
444                    "✅ Resolved"
445                } else {
446                    "⏳ Open"
447                };
448
449                md.push_str(&format!("### {} {}\n\n", level_icon, status));
450                md.push_str(&format!("{}\n\n", risk.description));
451
452                if let Some(mitigation) = &risk.mitigation {
453                    md.push_str(&format!("**Mitigation:** {}\n\n", mitigation));
454                }
455
456                if let Some(notes) = &risk.resolution_notes {
457                    md.push_str(&format!("**Resolution:** {}\n\n", notes));
458                }
459            }
460        }
461
462        // Timeline
463        if !self.entries.is_empty() {
464            md.push_str("## Timeline\n\n");
465            for entry in &self.entries {
466                let entry_type_str = format!("{:?}", entry.entry_type);
467                md.push_str(&format!(
468                    "**{}** [{}] *{}*\n\n",
469                    entry.timestamp.format("%H:%M:%S"),
470                    entry_type_str,
471                    entry.step_id
472                ));
473                md.push_str(&format!("**{}**\n\n", entry.title));
474                md.push_str(&format!("{}\n\n", entry.details));
475            }
476        }
477
478        // Custom sections
479        for (name, content) in &self.sections {
480            md.push_str(&format!("## {}\n\n", name));
481            md.push_str(content);
482            md.push_str("\n\n");
483        }
484
485        md
486    }
487
488    /// Save to file
489    pub async fn save_to_file(&self, path: &Path) -> Result<()> {
490        let markdown = self.to_markdown();
491        tokio::fs::write(path, markdown)
492            .await
493            .context("Failed to write progress journal")?;
494        Ok(())
495    }
496
497    /// Load from JSON file
498    pub async fn load_from_file(path: &Path) -> Result<Self> {
499        let content = tokio::fs::read_to_string(path)
500            .await
501            .context("Failed to read progress journal file")?;
502
503        serde_json::from_str(&content).context("Failed to parse progress journal JSON")
504    }
505}
506
507/// Progress journal writer for tracking execution
508pub struct ProgressJournalWriter {
509    journal: ProgressJournal,
510}
511
512impl ProgressJournalWriter {
513    /// Create a new writer
514    pub fn new(run_id: String, workflow_name: String, task: String) -> Self {
515        Self {
516            journal: ProgressJournal::new(run_id, workflow_name, task),
517        }
518    }
519
520    /// Get mutable reference to journal
521    pub fn journal_mut(&mut self) -> &mut ProgressJournal {
522        &mut self.journal
523    }
524
525    /// Log story start
526    pub fn log_story_start(&mut self, step_id: &str, story_id: &str, story_title: &str) {
527        self.journal.add_entry(
528            EntryType::StoryStart,
529            step_id,
530            &format!("Starting story: {}", story_title),
531            &format!("Story ID: {}", story_id),
532        );
533    }
534
535    /// Log story completion
536    pub fn log_story_complete(
537        &mut self,
538        step_id: &str,
539        story_id: &str,
540        story_title: &str,
541        changes: &str,
542    ) {
543        self.journal.add_entry(
544            EntryType::StoryComplete,
545            step_id,
546            &format!("Completed story: {}", story_title),
547            &format!("Story ID: {}\n\nChanges:\n{}", story_id, changes),
548        );
549    }
550
551    /// Log pattern discovery
552    pub fn log_pattern(
553        &mut self,
554        step_id: &str,
555        category: &str,
556        description: &str,
557        example: Option<&str>,
558    ) {
559        self.journal
560            .add_pattern(category, description, example, true);
561        self.journal.add_entry(
562            EntryType::PatternDiscovered,
563            step_id,
564            &format!("Discovered pattern: {}", category),
565            description,
566        );
567    }
568
569    /// Log test results
570    pub fn log_test_results(
571        &mut self,
572        step_id: &str,
573        command: &str,
574        output: &str,
575        passed: bool,
576        duration_secs: f64,
577    ) {
578        self.journal
579            .add_test_snapshot(command, output, passed, duration_secs);
580        self.journal.add_entry(
581            EntryType::TestResults,
582            step_id,
583            if passed {
584                "Tests passed"
585            } else {
586                "Tests failed"
587            },
588            &format!("Command: {}\n\nDuration: {:.2}s", command, duration_secs),
589        );
590    }
591
592    /// Log build results
593    pub fn log_build_results(
594        &mut self,
595        step_id: &str,
596        command: &str,
597        output: &str,
598        succeeded: bool,
599        duration_secs: f64,
600    ) {
601        self.journal
602            .add_build_snapshot(command, output, succeeded, duration_secs);
603        self.journal.add_entry(
604            EntryType::BuildResults,
605            step_id,
606            if succeeded {
607                "Build succeeded"
608            } else {
609                "Build failed"
610            },
611            &format!("Command: {}\n\nDuration: {:.2}s", command, duration_secs),
612        );
613    }
614
615    /// Log a decision
616    pub fn log_decision(&mut self, step_id: &str, title: &str, context: &str, decision: &str) {
617        self.journal
618            .add_decision(title, context, decision, vec![], false);
619        self.journal.add_entry(
620            EntryType::Decision,
621            step_id,
622            &format!("Decision: {}", title),
623            decision,
624        );
625    }
626
627    /// Log a risk
628    pub fn log_risk(&mut self, step_id: &str, description: &str, level: RiskLevel) {
629        self.journal.add_risk(description, level, None);
630        self.journal.add_entry(
631            EntryType::Risk,
632            step_id,
633            &format!("Risk identified: {:?}", level),
634            description,
635        );
636    }
637
638    /// Get the final journal
639    pub fn into_journal(self) -> ProgressJournal {
640        self.journal
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn test_progress_journal() {
650        let mut journal = ProgressJournal::new(
651            "run-123".to_string(),
652            "feature-dev".to_string(),
653            "Implement new auth system".to_string(),
654        );
655
656        journal.repo = Some("/path/to/repo".to_string());
657        journal.branch = Some("feature/auth".to_string());
658
659        journal.add_pattern(
660            "Error Handling",
661            "Use Result<T, E> for all fallible operations",
662            Some("src/error.rs:42"),
663            true,
664        );
665
666        journal.add_decision(
667            "Auth Library",
668            "Need to choose authentication library",
669            "Use JWT with jsonwebtoken crate",
670            vec!["Simpler than OAuth2 for our use case".to_string()],
671            true,
672        );
673
674        journal.add_risk(
675            "Token expiration edge cases",
676            RiskLevel::Medium,
677            Some("Add comprehensive tests"),
678        );
679
680        assert_eq!(journal.patterns.len(), 1);
681        assert_eq!(journal.decisions.len(), 1);
682        assert_eq!(journal.risks.len(), 1);
683    }
684
685    #[test]
686    fn test_to_markdown() {
687        let mut journal = ProgressJournal::new(
688            "run-123".to_string(),
689            "feature-dev".to_string(),
690            "Test task".to_string(),
691        );
692
693        journal.add_pattern("Test Pattern", "A test pattern", None, true);
694
695        let markdown = journal.to_markdown();
696        assert!(markdown.contains("# Progress Journal: feature-dev"));
697        assert!(markdown.contains("Test task"));
698        assert!(markdown.contains("Test Pattern"));
699    }
700}