1use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct Checkpoint {
10 pub task: TaskRef,
11 pub system_ir_revision: String,
12 pub affected: Affected,
13 pub files: Files,
14 pub tests: Tests,
15 #[serde(default)]
16 pub decisions: Vec<String>,
17 #[serde(default)]
18 pub next_actions: Vec<String>,
19 #[serde(default)]
20 pub created_at: String,
21 #[serde(default)]
24 pub snapshot_id: Option<String>,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct TaskRef {
30 #[serde(default)]
31 pub goal: String,
32 #[serde(default)]
33 pub bead: Option<String>,
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct Affected {
39 #[serde(default)]
40 pub components: Vec<String>,
41 #[serde(default)]
42 pub flows: Vec<String>,
43 #[serde(default)]
44 pub contracts: Vec<String>,
45 #[serde(default)]
46 pub invariants: Vec<String>,
47}
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct Files {
52 #[serde(default)]
53 pub modified: Vec<String>,
54 #[serde(default)]
55 pub inspected: Vec<String>,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct Tests {
61 #[serde(default)]
62 pub passed: Vec<String>,
63 #[serde(default)]
64 pub failed: Vec<String>,
65 #[serde(default)]
66 pub not_run: Vec<String>,
67}
68
69pub fn capture(root: &Path) -> crate::Result<Checkpoint> {
73 let store = crate::workspace::open_store(root)?;
74 let revision = store
75 .latest_snapshot()?
76 .map(|s| s.revision)
77 .unwrap_or_default();
78
79 let modified = git_modified(root);
81
82 let mut cp = Checkpoint {
84 system_ir_revision: revision,
85 created_at: scc_core::now_rfc3339(),
86 ..Default::default()
87 };
88 if let Some((bead, goal)) = scc_indexer::adapters::beads::active_bead(root) {
91 cp.task.bead = Some(bead);
92 cp.task.goal = goal;
93 }
94 if !modified.is_empty() {
95 let graph = scc_graph::RealityGraph::load(&store)?;
96 let view = scc_graph::TrustedGraphView::new(
97 &graph,
98 &store,
99 &[],
100 scc_graph::TrustPolicy::default(),
101 );
102 if let Ok(imp) = scc_graph::impact::compute_impact(&view, &store, &modified, &[]) {
103 cp.affected.components = imp.components;
104 cp.affected.flows = imp.flows;
105 cp.affected.contracts = imp.contracts;
106 cp.affected.invariants = imp.invariants;
107 }
108 }
109 cp.files.modified = modified;
110
111 {
116 let mut entity_ids: Vec<String> = Vec::new();
117 entity_ids.extend(cp.affected.components.iter().cloned());
118 entity_ids.extend(cp.affected.flows.iter().cloned());
119 entity_ids.extend(cp.affected.contracts.iter().cloned());
120 entity_ids.extend(cp.affected.invariants.iter().cloned());
121 entity_ids.sort();
122 entity_ids.dedup();
123 let epoch = store.cache_epoch().unwrap_or_else(|_| "no-epoch".into());
124 let head = store
125 .revisions()
126 .map(|rs| rs.into_iter().last().map(|r| r.rev).unwrap_or(0))
127 .unwrap_or(0);
128 let task = if cp.task.goal.is_empty() {
129 cp.task.bead.clone().unwrap_or_else(|| "checkpoint".into())
130 } else {
131 cp.task.goal.clone()
132 };
133 if let Ok(artifact) = serde_json::to_string_pretty(&cp) {
134 if let Ok(snap) = store.save_snapshot(scc_store::snapshot::SnapshotSave {
135 task: &task,
136 epoch: &epoch,
137 revision: head,
138 artifact: &artifact,
139 entity_ids: &entity_ids,
140 budget: 0,
141 warnings: &[],
142 }) {
143 cp.snapshot_id = Some(snap.id);
144 }
145 }
146 }
147
148 let path = crate::workspace::checkpoint_path(root);
149 std::fs::create_dir_all(path.parent().unwrap_or(Path::new(".")))?;
150 std::fs::write(&path, serde_json::to_string_pretty(&cp)?)?;
151 Ok(cp)
152}
153
154pub fn load(root: &Path) -> crate::Result<Option<String>> {
157 let path = crate::workspace::checkpoint_path(root);
158 if !path.exists() {
159 return Ok(None);
160 }
161 let text = std::fs::read_to_string(&path)?;
162 let cp: Checkpoint = serde_json::from_str(&text)?;
163 let mut out = String::from("# TASK CHECKPOINT (restored)\n\n");
164 if !cp.task.goal.is_empty() {
165 out.push_str(&format!("## Goal\n{}\n\n", cp.task.goal));
166 }
167 if !cp.system_ir_revision.is_empty() {
168 out.push_str(&format!("System IR revision: {}\n\n", cp.system_ir_revision));
169 }
170 if !cp.affected.components.is_empty() {
171 out.push_str(&format!("Affected components: {}\n\n", cp.affected.components.join(", ")));
172 }
173 if !cp.affected.flows.is_empty() {
174 out.push_str(&format!("Affected flows: {}\n\n", cp.affected.flows.join(", ")));
175 }
176 if !cp.affected.contracts.is_empty() {
177 out.push_str(&format!("Affected contracts: {}\n\n", cp.affected.contracts.join(", ")));
178 }
179 if !cp.affected.invariants.is_empty() {
180 out.push_str(&format!("Affected invariants: {}\n\n", cp.affected.invariants.join(", ")));
181 }
182 if !cp.files.modified.is_empty() {
183 out.push_str(&format!(
184 "Modified files:\n{}\n\n",
185 cp.files.modified
186 .iter()
187 .map(|f| format!("- {f}"))
188 .collect::<Vec<_>>()
189 .join("\n")
190 ));
191 }
192 if !cp.decisions.is_empty() {
193 out.push_str(&format!(
194 "Decisions:\n{}\n\n",
195 cp.decisions
196 .iter()
197 .map(|d| format!("- {d}"))
198 .collect::<Vec<_>>()
199 .join("\n")
200 ));
201 }
202 if !cp.next_actions.is_empty() {
203 out.push_str(&format!(
204 "Next actions:\n{}\n\n",
205 cp.next_actions
206 .iter()
207 .map(|n| format!("- {n}"))
208 .collect::<Vec<_>>()
209 .join("\n")
210 ));
211 }
212 if let Some(sid) = cp.snapshot_id.as_deref() {
215 match crate::workspace::open_store(root).and_then(|store| {
216 store
217 .diff_snapshot(sid)
218 .map_err(crate::EngineError::from)
219 }) {
220 Ok(Some(d)) => {
221 out.push_str(&format!(
222 "## Snapshot validity (model drift since checkpoint)\n{} still valid, {} invalidated, {} modified{}\n\n",
223 d.still_valid.len(),
224 d.invalidated.len(),
225 d.modified_entities.len(),
226 if d.artifact_changed {
227 " — context would re-render differently"
228 } else {
229 ""
230 },
231 ));
232 for f in d.invalidated.iter().chain(d.modified_entities.iter()).take(10) {
233 out.push_str(&format!("- {f}\n"));
234 }
235 if d.invalidated.len() + d.modified_entities.len() > 10 {
236 out.push_str(&format!(
237 "- … ({} more)\n",
238 d.invalidated.len() + d.modified_entities.len() - 10
239 ));
240 }
241 out.push('\n');
242 }
243 Ok(None) => {
244 out.push_str("## Snapshot validity\ncheckpoint snapshot no longer stored\n\n");
245 }
246 Err(e) => {
247 out.push_str(&format!("## Snapshot validity\nsnapshot unreadable: {e}\n\n"));
248 }
249 }
250 }
251 Ok(Some(out))
252}
253
254fn git_modified(root: &Path) -> Vec<String> {
256 let out = std::process::Command::new("git")
257 .args(["status", "--porcelain"])
258 .current_dir(root)
259 .output();
260 let Ok(out) = out else { return Vec::new() };
261 if !out.status.success() {
262 return Vec::new();
263 }
264 String::from_utf8_lossy(&out.stdout)
265 .lines()
266 .filter_map(|l| {
267 let l = l.trim_start();
268 if l.len() < 4 {
269 return None;
270 }
271 let (status, path) = l.split_at(3);
272 if status.contains('?') {
273 return None;
274 }
275 let path = path.trim();
276 if path.is_empty() || path.starts_with(".scc/") {
277 return None;
278 }
279 Some(path.to_string())
280 })
281 .collect()
282}