Skip to main content

scc_engine/
checkpoint.rs

1//! PreCompact checkpoint (docs §125–§127): transient task state persisted to
2//! `.scc/checkpoint.json` so compaction is transparent.
3
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8// trace:exempt reason=internal-detail
9pub 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    /// Durable ContextSnapshot id pinned at capture (semantic rehydration:
22    /// `checkpoint load` diffs it so compaction sees what survived).
23    #[serde(default)]
24    pub snapshot_id: Option<String>,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28// trace:exempt reason=internal-detail
29pub 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)]
37// trace:exempt reason=internal-detail
38pub 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)]
50// trace:exempt reason=internal-detail
51pub 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)]
59// trace:exempt reason=internal-detail
60pub 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
69/// Capture the current checkpoint: git-modified files + current system IR
70/// revision + affected system entities derived from the working tree.
71// trace:v1 id=impl.scc.checkpoint work=WORK-SCC-001 satisfies=REQ-SCC-API
72pub 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    // modified files via git
80    let modified = git_modified(root);
81
82    // affected entities via impact on the modified files
83    let mut cp = Checkpoint {
84        system_ir_revision: revision,
85        created_at: scc_core::now_rfc3339(),
86        ..Default::default()
87    };
88    // goal/bead come from the active Beads task (task state, not system
89    // facts — the checkpoint is transient session state, §126)
90    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    // Durable snapshot link: pin affected ids + checkpoint text so
112    // `checkpoint load` (OMP PreCompact rehydration) reports still-valid
113    // vs invalidated vs modified facts, not just task state. Impact
114    // fields are already entity ids (components/flows/routes/invariants).
115    {
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
154/// Load and render the checkpoint as markdown for session rehydration.
155// trace:v1 id=impl.scc.checkpoint.load work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
156pub 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    // Semantic rehydration verdict: what the pinned snapshot says about
213    // model drift since capture (still-valid vs invalidated vs modified).
214    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
254// trace:exempt reason=internal-detail
255fn 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}