Skip to main content

rustyclaw_core/protocols/
receipt.rs

1//! Receipt Protocol — Verifiable Task Completion
2//!
3//! Every completed task can write a JSON receipt as proof of execution.
4//! This enables:
5//! - Verification that work was actually done
6//! - Artifact tracking (files created/modified)
7//! - Metrics collection for completed work
8//! - Audit trails for multi-agent workflows
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::fs;
14
15/// A receipt proving task completion with artifacts and metrics.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TaskReceipt {
18    /// Unique task identifier
19    pub task_id: String,
20    
21    /// Agent/skill that completed the task
22    pub agent: String,
23    
24    /// Current phase (e.g., "BUILD", "HARDEN", "SHIP")
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub phase: Option<String>,
27    
28    /// Status - should be "complete" for valid receipts
29    pub status: String,
30    
31    /// List of artifact paths created or modified
32    pub artifacts: Vec<String>,
33    
34    /// Key-value metrics from the task
35    #[serde(default)]
36    pub metrics: HashMap<String, serde_json::Value>,
37    
38    /// Effort tracking
39    #[serde(default)]
40    pub effort: TaskEffort,
41    
42    /// Human-readable verification summary
43    pub verification: String,
44    
45    /// ISO 8601 timestamp when receipt was created
46    #[serde(default = "default_timestamp")]
47    pub created_at: String,
48}
49
50fn default_timestamp() -> String {
51    chrono::Utc::now().to_rfc3339()
52}
53
54/// Effort tracking for a task
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct TaskEffort {
57    /// Number of files read during task
58    #[serde(default)]
59    pub files_read: u32,
60    
61    /// Number of files written during task
62    #[serde(default)]
63    pub files_written: u32,
64    
65    /// Number of tool calls made
66    #[serde(default)]
67    pub tool_calls: u32,
68}
69
70impl TaskReceipt {
71    /// Create a new task receipt
72    pub fn new(task_id: impl Into<String>, agent: impl Into<String>) -> Self {
73        Self {
74            task_id: task_id.into(),
75            agent: agent.into(),
76            phase: None,
77            status: "complete".to_string(),
78            artifacts: Vec::new(),
79            metrics: HashMap::new(),
80            effort: TaskEffort::default(),
81            verification: String::new(),
82            created_at: chrono::Utc::now().to_rfc3339(),
83        }
84    }
85    
86    /// Set the phase
87    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
88        self.phase = Some(phase.into());
89        self
90    }
91    
92    /// Add an artifact path
93    pub fn add_artifact(mut self, path: impl Into<String>) -> Self {
94        self.artifacts.push(path.into());
95        self
96    }
97    
98    /// Add a metric
99    pub fn add_metric(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
100        self.metrics.insert(key.into(), value.into());
101        self
102    }
103    
104    /// Set effort tracking
105    pub fn with_effort(mut self, files_read: u32, files_written: u32, tool_calls: u32) -> Self {
106        self.effort = TaskEffort {
107            files_read,
108            files_written,
109            tool_calls,
110        };
111        self
112    }
113    
114    /// Set verification message
115    pub fn with_verification(mut self, msg: impl Into<String>) -> Self {
116        self.verification = msg.into();
117        self
118    }
119    
120    /// Validate that the receipt is complete
121    pub fn validate(&self) -> Result<(), String> {
122        if self.task_id.is_empty() {
123            return Err("task_id is required".to_string());
124        }
125        if self.agent.is_empty() {
126            return Err("agent is required".to_string());
127        }
128        if self.status != "complete" {
129            return Err(format!("status must be 'complete', got '{}'", self.status));
130        }
131        if self.verification.is_empty() {
132            return Err("verification message is required".to_string());
133        }
134        Ok(())
135    }
136}
137
138/// Store for managing task receipts
139pub struct ReceiptStore {
140    base_path: PathBuf,
141}
142
143impl ReceiptStore {
144    /// Create a new receipt store at the given path
145    pub fn new(base_path: impl AsRef<Path>) -> Self {
146        Self {
147            base_path: base_path.as_ref().to_path_buf(),
148        }
149    }
150    
151    /// Get the default receipt store path for a workspace
152    pub fn default_path(workspace: impl AsRef<Path>) -> PathBuf {
153        workspace.as_ref().join(".rustyclaw").join("receipts")
154    }
155    
156    /// Ensure the receipts directory exists
157    pub fn ensure_dir(&self) -> std::io::Result<()> {
158        fs::create_dir_all(&self.base_path)
159    }
160    
161    /// Write a receipt to disk
162    pub fn write(&self, receipt: &TaskReceipt) -> std::io::Result<PathBuf> {
163        self.ensure_dir()?;
164        
165        let filename = format!("{}-{}.json", receipt.task_id, receipt.agent);
166        let path = self.base_path.join(&filename);
167        
168        let json = serde_json::to_string_pretty(receipt)
169            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
170        
171        fs::write(&path, json)?;
172        Ok(path)
173    }
174    
175    /// Read a receipt from disk
176    pub fn read(&self, task_id: &str, agent: &str) -> std::io::Result<TaskReceipt> {
177        let filename = format!("{}-{}.json", task_id, agent);
178        let path = self.base_path.join(&filename);
179        
180        let json = fs::read_to_string(&path)?;
181        serde_json::from_str(&json)
182            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
183    }
184    
185    /// List all receipts for a task
186    pub fn list_for_task(&self, task_id: &str) -> std::io::Result<Vec<TaskReceipt>> {
187        let mut receipts = Vec::new();
188        
189        if !self.base_path.exists() {
190            return Ok(receipts);
191        }
192        
193        for entry in fs::read_dir(&self.base_path)? {
194            let entry = entry?;
195            let name = entry.file_name().to_string_lossy().to_string();
196            
197            if name.starts_with(task_id) && name.ends_with(".json") {
198                if let Ok(receipt) = self.read_path(&entry.path()) {
199                    receipts.push(receipt);
200                }
201            }
202        }
203        
204        Ok(receipts)
205    }
206    
207    /// Read a receipt from a specific path
208    fn read_path(&self, path: &Path) -> std::io::Result<TaskReceipt> {
209        let json = fs::read_to_string(path)?;
210        serde_json::from_str(&json)
211            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
212    }
213    
214    /// Verify all artifacts in a receipt exist on disk
215    pub fn verify_artifacts(&self, receipt: &TaskReceipt) -> Vec<String> {
216        receipt.artifacts
217            .iter()
218            .filter(|path| !Path::new(path).exists())
219            .cloned()
220            .collect()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    
228    #[test]
229    fn test_receipt_creation() {
230        let receipt = TaskReceipt::new("task-001", "code-reviewer")
231            .with_phase("HARDEN")
232            .add_artifact("src/main.rs")
233            .add_metric("findings_critical", 2)
234            .with_effort(47, 6, 83)
235            .with_verification("all 4 review phases executed");
236        
237        assert_eq!(receipt.task_id, "task-001");
238        assert_eq!(receipt.agent, "code-reviewer");
239        assert_eq!(receipt.phase, Some("HARDEN".to_string()));
240        assert_eq!(receipt.artifacts.len(), 1);
241        assert!(receipt.validate().is_ok());
242    }
243    
244    #[test]
245    fn test_receipt_validation() {
246        let receipt = TaskReceipt::new("", "agent");
247        assert!(receipt.validate().is_err());
248        
249        let receipt = TaskReceipt::new("task", "");
250        assert!(receipt.validate().is_err());
251        
252        let mut receipt = TaskReceipt::new("task", "agent");
253        receipt.status = "pending".to_string();
254        assert!(receipt.validate().is_err());
255    }
256}