Skip to main content

orchestrator_collab/
artifact.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Artifact produced by an agent run.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Artifact {
10    /// Stable artifact identifier generated at creation time.
11    pub id: Uuid,
12    /// Semantic category of the artifact payload.
13    pub kind: ArtifactKind,
14    /// Optional filesystem path associated with the artifact.
15    pub path: Option<String>,
16    /// Optional structured payload embedded directly in the artifact.
17    pub content: Option<serde_json::Value>,
18    /// Optional checksum used to deduplicate or verify the artifact body.
19    pub checksum: String,
20    /// Timestamp when the artifact was created.
21    pub created_at: DateTime<Utc>,
22}
23
24impl Artifact {
25    /// Creates a new artifact with a generated identifier and current timestamp.
26    pub fn new(kind: ArtifactKind) -> Self {
27        Self {
28            id: Uuid::new_v4(),
29            kind,
30            path: None,
31            content: None,
32            checksum: String::new(),
33            created_at: Utc::now(),
34        }
35    }
36
37    /// Attaches a filesystem path to the artifact.
38    pub fn with_path(mut self, path: String) -> Self {
39        self.path = Some(path);
40        self
41    }
42
43    /// Attaches structured JSON content to the artifact.
44    pub fn with_content(mut self, content: serde_json::Value) -> Self {
45        self.content = Some(content);
46        self
47    }
48
49    /// Records a checksum for the artifact.
50    pub fn with_checksum(mut self, checksum: String) -> Self {
51        self.checksum = checksum;
52        self
53    }
54}
55
56/// Types of artifacts an agent can produce.
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub enum ArtifactKind {
59    /// A QA or governance ticket raised by an agent.
60    Ticket {
61        /// Severity assigned to the ticket.
62        severity: Severity,
63        /// Logical category such as `bug`, `qa`, or `security`.
64        category: String,
65    },
66    /// A code change touching one or more files.
67    CodeChange {
68        /// Repository-relative files affected by the change.
69        files: Vec<String>,
70    },
71    /// A summarized test execution result.
72    TestResult {
73        /// Number of passing tests.
74        passed: u32,
75        /// Number of failing tests.
76        failed: u32,
77    },
78    /// An analytical artifact containing findings.
79    Analysis {
80        /// Findings captured during analysis.
81        findings: Vec<Finding>,
82    },
83    /// A persisted decision with rationale.
84    Decision {
85        /// Selected option or action.
86        choice: String,
87        /// Reasoning that explains the choice.
88        rationale: String,
89    },
90    /// A generic data payload with a declared schema name.
91    Data {
92        /// Schema or format identifier for the payload.
93        schema: String,
94    },
95    /// A custom artifact type not covered by builtin variants.
96    Custom {
97        /// User-defined artifact name.
98        name: String,
99    },
100}
101
102/// Severity level used by tickets and findings.
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104pub enum Severity {
105    /// Requires immediate operator attention.
106    Critical,
107    /// High-priority issue with significant impact.
108    High,
109    /// Medium-priority issue that should be addressed soon.
110    Medium,
111    /// Low-priority issue or minor defect.
112    Low,
113    /// Informational note without immediate action required.
114    Info,
115}
116
117/// Structured finding emitted by an analysis artifact.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119pub struct Finding {
120    /// Short finding title.
121    pub title: String,
122    /// Detailed description of the finding.
123    pub description: String,
124    /// Severity assigned to the finding.
125    pub severity: Severity,
126    /// Optional source location or file reference.
127    pub location: Option<String>,
128    /// Optional remediation guidance.
129    pub suggestion: Option<String>,
130}
131
132/// Registry of artifacts available in the current execution context.
133#[derive(Debug, Default)]
134pub struct ArtifactRegistry {
135    artifacts: HashMap<String, Vec<Artifact>>,
136}
137
138impl Clone for ArtifactRegistry {
139    fn clone(&self) -> Self {
140        Self {
141            artifacts: self.artifacts.clone(),
142        }
143    }
144}
145
146impl ArtifactRegistry {
147    /// Registers a new artifact under the given phase key.
148    pub fn register(&mut self, phase: String, artifact: Artifact) {
149        self.artifacts.entry(phase).or_default().push(artifact);
150    }
151
152    /// Returns all artifacts recorded for a single phase.
153    pub fn get_by_phase(&self, phase: &str) -> Vec<&Artifact> {
154        self.artifacts
155            .get(phase)
156            .map(|v| v.iter().collect())
157            .unwrap_or_default()
158    }
159
160    /// Returns all artifacts whose kind matches the requested variant.
161    pub fn get_by_kind(&self, kind: &ArtifactKind) -> Vec<&Artifact> {
162        self.artifacts
163            .values()
164            .flatten()
165            .filter(|a| &a.kind == kind)
166            .collect()
167    }
168
169    /// Returns the most recent artifact recorded for a phase.
170    pub fn get_latest(&self, phase: &str) -> Option<&Artifact> {
171        self.artifacts.get(phase).and_then(|v| v.last())
172    }
173
174    /// Counts all artifacts across all phases.
175    pub fn count(&self) -> usize {
176        self.artifacts.values().map(|v| v.len()).sum()
177    }
178
179    /// Returns a phase-keyed view of the entire registry.
180    pub fn all(&self) -> HashMap<String, Vec<&Artifact>> {
181        self.artifacts
182            .iter()
183            .map(|(k, v)| (k.clone(), v.iter().collect()))
184            .collect()
185    }
186}
187
188/// Key-value store for shared state shared between collaborating agents.
189#[derive(Debug, Default, Clone)]
190pub struct SharedState {
191    data: HashMap<String, serde_json::Value>,
192}
193
194impl SharedState {
195    /// Stores or replaces a value under the provided key.
196    pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
197        self.data.insert(key.into(), value);
198    }
199
200    /// Returns a shared reference to a stored value.
201    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
202        self.data.get(key)
203    }
204
205    /// Removes a stored value and returns it if present.
206    pub fn remove(&mut self, key: &str) -> Option<serde_json::Value> {
207        self.data.remove(key)
208    }
209
210    /// Replaces `{key}` placeholders in the template using stored values.
211    pub fn render_template(&self, template: &str) -> String {
212        let mut result = template.to_string();
213        for (key, value) in &self.data {
214            let placeholder = format!("{{{}}}", key);
215            if let Some(s) = value.as_str() {
216                result = result.replace(&placeholder, s);
217            } else if let Ok(s) = serde_json::to_string(value) {
218                result = result.replace(&placeholder, &s);
219            }
220        }
221        result
222    }
223}
224
225/// Parses artifact payloads from agent stdout or stderr output.
226pub fn parse_artifacts_from_output(output: &str) -> Vec<Artifact> {
227    let mut artifacts = Vec::new();
228
229    if let Ok(parsed) = serde_json::from_str::<Vec<serde_json::Value>>(output) {
230        for value in parsed {
231            if let Some(kind) = extract_artifact_kind(&value) {
232                let mut artifact = Artifact::new(kind);
233                if let Some(path) = value.get("path").and_then(|v| v.as_str()) {
234                    artifact = artifact.with_path(path.to_string());
235                }
236                if let Some(content) = value.get("content") {
237                    artifact = artifact.with_content(content.clone());
238                }
239                artifacts.push(artifact);
240            }
241        }
242        return artifacts;
243    }
244
245    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(output) {
246        if let Some(kind) = extract_artifact_kind(&parsed) {
247            let mut artifact = Artifact::new(kind);
248            if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
249                artifact = artifact.with_path(path.to_string());
250            }
251            if let Some(content) = parsed.get("content") {
252                artifact = artifact.with_content(content.clone());
253            }
254            artifacts.push(artifact);
255        }
256
257        if artifacts.is_empty() {
258            if let Some(arr) = parsed.get("artifacts").and_then(|v| v.as_array()) {
259                for value in arr {
260                    if let Some(kind) = extract_artifact_kind(value) {
261                        let mut artifact = Artifact::new(kind);
262                        if let Some(path) = value.get("path").and_then(|v| v.as_str()) {
263                            artifact = artifact.with_path(path.to_string());
264                        }
265                        if let Some(content) = value.get("content") {
266                            artifact = artifact.with_content(content.clone());
267                        }
268                        artifacts.push(artifact);
269                    }
270                }
271            }
272        }
273    }
274
275    for line in output.lines() {
276        if let Some(ticket) = parse_ticket_from_line(line) {
277            artifacts.push(ticket);
278        }
279    }
280
281    artifacts
282}
283
284fn extract_artifact_kind(value: &serde_json::Value) -> Option<ArtifactKind> {
285    let kind = value.get("kind")?.as_str()?;
286
287    match kind {
288        "ticket" => {
289            let severity = value
290                .get("severity")
291                .and_then(|v| v.as_str())
292                .map(|s| match s {
293                    "critical" => Severity::Critical,
294                    "high" => Severity::High,
295                    "medium" => Severity::Medium,
296                    "low" => Severity::Low,
297                    _ => Severity::Info,
298                })
299                .unwrap_or(Severity::Info);
300
301            let category = value
302                .get("category")
303                .and_then(|v| v.as_str())
304                .unwrap_or("general")
305                .to_string();
306
307            Some(ArtifactKind::Ticket { severity, category })
308        }
309        "code_change" => {
310            let files = value
311                .get("files")
312                .and_then(|v| v.as_array())
313                .map(|arr| {
314                    arr.iter()
315                        .filter_map(|f| f.as_str().map(String::from))
316                        .collect()
317                })
318                .unwrap_or_default();
319
320            Some(ArtifactKind::CodeChange { files })
321        }
322        "test_result" => {
323            let passed = value.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
324            let failed = value.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
325
326            Some(ArtifactKind::TestResult { passed, failed })
327        }
328        "analysis" => {
329            let findings = value
330                .get("findings")
331                .and_then(|v| v.as_array())
332                .map(|arr| {
333                    arr.iter()
334                        .filter_map(|f| {
335                            Some(Finding {
336                                title: f.get("title")?.as_str()?.to_string(),
337                                description: f
338                                    .get("description")
339                                    .and_then(|v| v.as_str())
340                                    .unwrap_or("")
341                                    .to_string(),
342                                severity: f
343                                    .get("severity")
344                                    .and_then(|v| v.as_str())
345                                    .map(|s| match s {
346                                        "critical" => Severity::Critical,
347                                        "high" => Severity::High,
348                                        "medium" => Severity::Medium,
349                                        "low" => Severity::Low,
350                                        _ => Severity::Info,
351                                    })
352                                    .unwrap_or(Severity::Info),
353                                location: f
354                                    .get("location")
355                                    .and_then(|v| v.as_str())
356                                    .map(String::from),
357                                suggestion: f
358                                    .get("suggestion")
359                                    .and_then(|v| v.as_str())
360                                    .map(String::from),
361                            })
362                        })
363                        .collect()
364                })
365                .unwrap_or_default();
366
367            Some(ArtifactKind::Analysis { findings })
368        }
369        "decision" => {
370            let choice = value
371                .get("choice")
372                .and_then(|v| v.as_str())
373                .unwrap_or("unknown")
374                .to_string();
375            let rationale = value
376                .get("rationale")
377                .and_then(|v| v.as_str())
378                .unwrap_or("")
379                .to_string();
380
381            Some(ArtifactKind::Decision { choice, rationale })
382        }
383        _ => None,
384    }
385}
386
387fn parse_ticket_from_line(line: &str) -> Option<Artifact> {
388    if !line.contains("[TICKET:") {
389        return None;
390    }
391
392    let severity = if line.contains("severity=critical") {
393        Severity::Critical
394    } else if line.contains("severity=high") {
395        Severity::High
396    } else if line.contains("severity=medium") {
397        Severity::Medium
398    } else if line.contains("severity=low") {
399        Severity::Low
400    } else {
401        Severity::Info
402    };
403
404    let category = if line.contains("category=bug") {
405        "bug".to_string()
406    } else if line.contains("category=security") {
407        "security".to_string()
408    } else if line.contains("category=performance") {
409        "performance".to_string()
410    } else {
411        "general".to_string()
412    };
413
414    Some(Artifact::new(ArtifactKind::Ticket { severity, category }))
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_artifact_builder() {
423        let artifact = Artifact::new(ArtifactKind::CodeChange {
424            files: vec!["main.rs".to_string()],
425        })
426        .with_path("/tmp/diff.patch".to_string())
427        .with_content(serde_json::json!({"lines_added": 10}))
428        .with_checksum("abc123".to_string());
429
430        assert_eq!(
431            artifact.path.expect("artifact path should be populated"),
432            "/tmp/diff.patch"
433        );
434        assert!(artifact.content.is_some());
435        assert_eq!(artifact.checksum, "abc123");
436    }
437
438    #[test]
439    fn test_artifact_registry() {
440        let mut registry = ArtifactRegistry::default();
441
442        let artifact = Artifact::new(ArtifactKind::Ticket {
443            severity: Severity::High,
444            category: "bug".to_string(),
445        });
446
447        registry.register("qa".to_string(), artifact);
448
449        assert_eq!(registry.count(), 1);
450        assert!(registry.get_latest("qa").is_some());
451    }
452
453    #[test]
454    fn test_artifact_registry_get_by_phase() {
455        let mut registry = ArtifactRegistry::default();
456        registry.register(
457            "qa".to_string(),
458            Artifact::new(ArtifactKind::Custom {
459                name: "a".to_string(),
460            }),
461        );
462        registry.register(
463            "implement".to_string(),
464            Artifact::new(ArtifactKind::Custom {
465                name: "b".to_string(),
466            }),
467        );
468
469        assert_eq!(registry.get_by_phase("qa").len(), 1);
470        assert_eq!(registry.get_by_phase("implement").len(), 1);
471        assert_eq!(registry.get_by_phase("nonexistent").len(), 0);
472    }
473
474    #[test]
475    fn test_artifact_registry_get_by_kind() {
476        let mut registry = ArtifactRegistry::default();
477        let kind = ArtifactKind::TestResult {
478            passed: 10,
479            failed: 2,
480        };
481        registry.register("qa".to_string(), Artifact::new(kind.clone()));
482        registry.register(
483            "qa".to_string(),
484            Artifact::new(ArtifactKind::Custom {
485                name: "x".to_string(),
486            }),
487        );
488
489        let results = registry.get_by_kind(&kind);
490        assert_eq!(results.len(), 1);
491    }
492
493    #[test]
494    fn test_artifact_registry_get_latest() {
495        let mut registry = ArtifactRegistry::default();
496        assert!(registry.get_latest("qa").is_none());
497
498        registry.register(
499            "qa".to_string(),
500            Artifact::new(ArtifactKind::Custom {
501                name: "first".to_string(),
502            }),
503        );
504        registry.register(
505            "qa".to_string(),
506            Artifact::new(ArtifactKind::Custom {
507                name: "second".to_string(),
508            }),
509        );
510
511        let latest = registry
512            .get_latest("qa")
513            .expect("latest qa artifact should exist");
514        if let ArtifactKind::Custom { name } = &latest.kind {
515            assert_eq!(name, "second");
516        }
517    }
518
519    #[test]
520    fn test_artifact_registry_all() {
521        let mut registry = ArtifactRegistry::default();
522        registry.register(
523            "qa".to_string(),
524            Artifact::new(ArtifactKind::Custom {
525                name: "a".to_string(),
526            }),
527        );
528        registry.register(
529            "plan".to_string(),
530            Artifact::new(ArtifactKind::Custom {
531                name: "b".to_string(),
532            }),
533        );
534
535        let all = registry.all();
536        assert_eq!(all.len(), 2);
537    }
538
539    #[test]
540    fn test_shared_state_template() {
541        let mut state = SharedState::default();
542        state.set("name", serde_json::json!("test"));
543        state.set("count", serde_json::json!(42));
544
545        let result = state.render_template("Hello {name}, count is {count}");
546        assert_eq!(result, "Hello test, count is 42");
547    }
548
549    #[test]
550    fn test_shared_state_operations() {
551        let mut state = SharedState::default();
552        assert!(state.get("key").is_none());
553
554        state.set("key", serde_json::json!("value"));
555        assert_eq!(
556            state.get("key").expect("shared state key should exist"),
557            &serde_json::json!("value")
558        );
559
560        let removed = state.remove("key");
561        assert!(removed.is_some());
562        assert!(state.get("key").is_none());
563    }
564
565    #[test]
566    fn test_shared_state_render_non_string_json() {
567        let mut state = SharedState::default();
568        state.set("data", serde_json::json!({"nested": true}));
569
570        let result = state.render_template("result: {data}");
571        assert!(result.contains("nested"));
572    }
573
574    #[test]
575    fn test_parse_artifacts_from_output_json_object() {
576        let input = r#"{"kind":"ticket","severity":"high","category":"bug"}"#;
577        let artifacts = parse_artifacts_from_output(input);
578        assert_eq!(artifacts.len(), 1);
579        if let ArtifactKind::Ticket { severity, category } = &artifacts[0].kind {
580            assert_eq!(*severity, Severity::High);
581            assert_eq!(category, "bug");
582        } else {
583            assert!(
584                matches!(&artifacts[0].kind, ArtifactKind::Ticket { .. }),
585                "expected Ticket"
586            );
587        }
588    }
589
590    #[test]
591    fn test_parse_artifacts_from_output_nested_artifacts_array() {
592        let input = r#"{"confidence":0.4,"quality_score":0.25,"artifacts":[{"kind":"ticket","severity":"high","category":"capability","content":{"title":"qa-from-agent"}}]}"#;
593        let artifacts = parse_artifacts_from_output(input);
594        assert_eq!(artifacts.len(), 1);
595        if let ArtifactKind::Ticket { severity, category } = &artifacts[0].kind {
596            assert_eq!(*severity, Severity::High);
597            assert_eq!(category, "capability");
598        } else {
599            assert!(
600                matches!(&artifacts[0].kind, ArtifactKind::Ticket { .. }),
601                "expected Ticket from nested artifacts array"
602            );
603        }
604    }
605
606    #[test]
607    fn test_parse_artifacts_from_output_json_array() {
608        let input = r#"[{"kind":"test_result","passed":5,"failed":1},{"kind":"code_change","files":["a.rs"]}]"#;
609        let artifacts = parse_artifacts_from_output(input);
610        assert_eq!(artifacts.len(), 2);
611    }
612
613    #[test]
614    fn test_parse_artifacts_from_output_ticket_marker() {
615        let input = "some output\n[TICKET: severity=high, category=bug]\nmore output";
616        let artifacts = parse_artifacts_from_output(input);
617        assert_eq!(artifacts.len(), 1);
618        if let ArtifactKind::Ticket { severity, category } = &artifacts[0].kind {
619            assert_eq!(*severity, Severity::High);
620            assert_eq!(category, "bug");
621        }
622    }
623
624    #[test]
625    fn test_parse_artifacts_from_output_ticket_severity_levels() {
626        let levels = [
627            ("severity=critical", Severity::Critical),
628            ("severity=medium", Severity::Medium),
629            ("severity=low", Severity::Low),
630            ("severity=unknown", Severity::Info),
631        ];
632        for (marker, expected) in levels {
633            let input = format!("[TICKET: {}, category=bug]", marker);
634            let artifacts = parse_artifacts_from_output(&input);
635            assert_eq!(artifacts.len(), 1);
636            if let ArtifactKind::Ticket { severity, .. } = &artifacts[0].kind {
637                assert_eq!(*severity, expected, "failed for marker: {}", marker);
638            }
639        }
640    }
641
642    #[test]
643    fn test_parse_artifacts_from_output_ticket_categories() {
644        let categories = [
645            ("category=security", "security"),
646            ("category=performance", "performance"),
647            ("category=other", "general"),
648        ];
649        for (marker, expected) in categories {
650            let input = format!("[TICKET: severity=high, {}]", marker);
651            let artifacts = parse_artifacts_from_output(&input);
652            if let ArtifactKind::Ticket { category, .. } = &artifacts[0].kind {
653                assert_eq!(category, expected);
654            }
655        }
656    }
657
658    #[test]
659    fn test_parse_artifacts_from_output_no_artifacts() {
660        let artifacts = parse_artifacts_from_output("plain text with no markers");
661        assert!(artifacts.is_empty());
662    }
663
664    #[test]
665    fn test_extract_artifact_kind_decision() {
666        let value = serde_json::json!({
667            "kind": "decision",
668            "choice": "option_a",
669            "rationale": "better performance"
670        });
671        let kind = extract_artifact_kind(&value).expect("decision artifact should parse");
672        if let ArtifactKind::Decision { choice, rationale } = &kind {
673            assert_eq!(choice, "option_a");
674            assert_eq!(rationale, "better performance");
675        } else {
676            assert!(
677                matches!(&kind, ArtifactKind::Decision { .. }),
678                "expected Decision"
679            );
680        }
681    }
682
683    #[test]
684    fn test_extract_artifact_kind_analysis() {
685        let value = serde_json::json!({
686            "kind": "analysis",
687            "findings": [
688                {"title": "Issue 1", "severity": "high", "description": "desc"}
689            ]
690        });
691        let kind = extract_artifact_kind(&value).expect("analysis artifact should parse");
692        if let ArtifactKind::Analysis { findings } = kind {
693            assert_eq!(findings.len(), 1);
694            assert_eq!(findings[0].title, "Issue 1");
695            assert_eq!(findings[0].severity, Severity::High);
696        }
697    }
698
699    #[test]
700    fn test_extract_artifact_kind_unknown_returns_none() {
701        let value = serde_json::json!({"kind": "unknown_type"});
702        assert!(extract_artifact_kind(&value).is_none());
703    }
704
705    #[test]
706    fn test_extract_artifact_kind_missing_kind_returns_none() {
707        let value = serde_json::json!({"data": "value"});
708        assert!(extract_artifact_kind(&value).is_none());
709    }
710}