rustyclaw_core/protocols/
receipt.rs1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::fs;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TaskReceipt {
18 pub task_id: String,
20
21 pub agent: String,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub phase: Option<String>,
27
28 pub status: String,
30
31 pub artifacts: Vec<String>,
33
34 #[serde(default)]
36 pub metrics: HashMap<String, serde_json::Value>,
37
38 #[serde(default)]
40 pub effort: TaskEffort,
41
42 pub verification: String,
44
45 #[serde(default = "default_timestamp")]
47 pub created_at: String,
48}
49
50fn default_timestamp() -> String {
51 chrono::Utc::now().to_rfc3339()
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct TaskEffort {
57 #[serde(default)]
59 pub files_read: u32,
60
61 #[serde(default)]
63 pub files_written: u32,
64
65 #[serde(default)]
67 pub tool_calls: u32,
68}
69
70impl TaskReceipt {
71 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 pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
88 self.phase = Some(phase.into());
89 self
90 }
91
92 pub fn add_artifact(mut self, path: impl Into<String>) -> Self {
94 self.artifacts.push(path.into());
95 self
96 }
97
98 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 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 pub fn with_verification(mut self, msg: impl Into<String>) -> Self {
116 self.verification = msg.into();
117 self
118 }
119
120 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
138pub struct ReceiptStore {
140 base_path: PathBuf,
141}
142
143impl ReceiptStore {
144 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 pub fn default_path(workspace: impl AsRef<Path>) -> PathBuf {
153 workspace.as_ref().join(".rustyclaw").join("receipts")
154 }
155
156 pub fn ensure_dir(&self) -> std::io::Result<()> {
158 fs::create_dir_all(&self.base_path)
159 }
160
161 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 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 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 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 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}