1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::adapter::Fs;
13use crate::error::SessionError;
14use crate::layout::StorePaths;
15use crate::manifest::{append_jsonl_record, read_jsonl_records};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct RunDiagnostic {
22 pub severity: String,
24 pub code: String,
26 pub message: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct RunStep {
41 pub id: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub parent: Option<String>,
46 pub action: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub action_version: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub action_hash: Option<String>,
54 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57 pub params: BTreeMap<String, String>,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub affected_nodes: Vec<String>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub diagnostics: Vec<RunDiagnostic>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub source_hash: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct RunRecord {
78 pub id: String,
80 pub seq: u64,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub brief: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub constraints: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub plan: Option<String>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub steps: Vec<RunStep>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub timestamp_ms: Option<u128>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub snapshot_hash: Option<String>,
100}
101
102pub fn append_run(
108 fs: &impl Fs,
109 paths: &StorePaths,
110 doc_id: &str,
111 record: &RunRecord,
112) -> Result<(), SessionError> {
113 append_jsonl_record(fs, &paths.runs_file(doc_id), record)
114}
115
116pub fn read_runs(
120 fs: &impl Fs,
121 paths: &StorePaths,
122 doc_id: &str,
123) -> Result<Vec<RunRecord>, SessionError> {
124 read_jsonl_records(fs, &paths.runs_file(doc_id))
125}
126
127#[cfg(test)]
130mod tests {
131 use std::collections::BTreeMap;
132
133 use super::*;
134 use crate::adapter::MemFs;
135 use crate::layout::StorePaths;
136
137 fn paths() -> StorePaths {
138 StorePaths::new("/data")
139 }
140
141 fn make_fs() -> MemFs {
142 MemFs::new()
143 }
144
145 fn full_step(id: &str) -> RunStep {
146 let mut params = BTreeMap::new();
147 params.insert("x".to_string(), "10".to_string());
148 params.insert("y".to_string(), "20".to_string());
149 RunStep {
150 id: id.to_string(),
151 parent: None,
152 action: "move_node".to_string(),
153 action_version: Some("rev-2".to_string()),
154 action_hash: Some("acthash42".to_string()),
155 params,
156 affected_nodes: vec!["node-a".to_string(), "node-b".to_string()],
157 diagnostics: vec![RunDiagnostic {
158 severity: "warning".to_string(),
159 code: "font.glyph_missing".to_string(),
160 message: "glyph U+FFFD not found".to_string(),
161 }],
162 source_hash: Some("src123".to_string()),
163 }
164 }
165
166 fn minimal_step(id: &str) -> RunStep {
167 RunStep {
168 id: id.to_string(),
169 parent: None,
170 action: "noop".to_string(),
171 action_version: None,
172 action_hash: None,
173 params: BTreeMap::new(),
174 affected_nodes: Vec::new(),
175 diagnostics: Vec::new(),
176 source_hash: None,
177 }
178 }
179
180 #[test]
181 fn append_then_read_runs_roundtrip() {
182 let fs = make_fs();
183 let paths = paths();
184
185 let r0 = RunRecord {
186 id: "run-0".to_string(),
187 seq: 0,
188 brief: Some("move two nodes".to_string()),
189 constraints: None,
190 plan: Some("step A then step B".to_string()),
191 steps: vec![full_step("s0"), minimal_step("s1")],
192 timestamp_ms: Some(1_700_000_000_100),
193 snapshot_hash: Some("snap0".to_string()),
194 };
195 let r1 = RunRecord {
196 id: "run-1".to_string(),
197 seq: 1,
198 brief: None,
199 constraints: Some("read-only".to_string()),
200 plan: None,
201 steps: Vec::new(),
202 timestamp_ms: Some(1_700_000_001_000),
203 snapshot_hash: None,
204 };
205
206 append_run(&fs, &paths, "doc1", &r0).unwrap();
207 append_run(&fs, &paths, "doc1", &r1).unwrap();
208
209 let records = read_runs(&fs, &paths, "doc1").unwrap();
210 assert_eq!(records.len(), 2);
211 assert_eq!(records[0], r0);
212 assert_eq!(records[1], r1);
213 }
214
215 #[test]
216 fn lean_run_omits_optionals() {
217 let fs = make_fs();
218 let paths = paths();
219
220 let rec = RunRecord {
221 id: "run-lean".to_string(),
222 seq: 0,
223 brief: None,
224 constraints: None,
225 plan: None,
226 steps: Vec::new(),
227 timestamp_ms: None,
228 snapshot_hash: None,
229 };
230
231 append_run(&fs, &paths, "doc1", &rec).unwrap();
232
233 let raw = fs.read(&paths.runs_file("doc1")).unwrap();
234 let line = std::str::from_utf8(&raw).unwrap();
235
236 assert!(!line.contains("brief"), "brief must be absent in lean form");
237 assert!(
238 !line.contains("constraints"),
239 "constraints must be absent in lean form"
240 );
241 assert!(!line.contains("plan"), "plan must be absent in lean form");
242 assert!(!line.contains("steps"), "steps must be absent in lean form");
243 assert!(
244 !line.contains("timestamp_ms"),
245 "timestamp_ms must be absent in lean form"
246 );
247 assert!(
248 !line.contains("snapshot_hash"),
249 "snapshot_hash must be absent in lean form"
250 );
251 assert!(line.contains("\"id\""), "id must be present");
252 assert!(line.contains("\"seq\""), "seq must be present");
253 }
254
255 #[test]
256 fn old_run_line_without_new_fields_deserializes() {
257 let fs = make_fs();
258 let paths = paths();
259
260 let old_line = b"{\"id\":\"run-old\",\"seq\":3}\n";
262 let run_path = paths.runs_file("doc1");
263 fs.create_dir_all(run_path.parent().unwrap()).unwrap();
264 fs.write(&run_path, old_line).unwrap();
265
266 let records = read_runs(&fs, &paths, "doc1").unwrap();
267 assert_eq!(records.len(), 1);
268 assert_eq!(records[0].id, "run-old");
269 assert_eq!(records[0].seq, 3);
270 assert_eq!(records[0].brief, None);
271 assert_eq!(records[0].constraints, None);
272 assert_eq!(records[0].plan, None);
273 assert!(records[0].steps.is_empty());
274 assert_eq!(records[0].timestamp_ms, None);
275 assert_eq!(records[0].snapshot_hash, None);
276 }
277
278 #[test]
279 fn read_runs_absent_is_empty() {
280 let fs = make_fs();
281 let paths = paths();
282
283 let records = read_runs(&fs, &paths, "no-such-doc").unwrap();
284 assert!(records.is_empty());
285 }
286}