Skip to main content

lean_ctx/core/workflow/
store.rs

1use crate::core::workflow::types::WorkflowRun;
2use std::path::{Path, PathBuf};
3
4/// Stale threshold: workflows inactive for over 30 minutes are auto-cleared on load.
5const STALE_MINUTES: i64 = 30;
6
7/// TTL for expired workflow files (24 hours).
8pub const WORKFLOW_TTL_SECS: u64 = 24 * 60 * 60;
9
10fn workflows_dir() -> Option<PathBuf> {
11    crate::core::data_dir::lean_ctx_data_dir()
12        .ok()
13        .map(|d| d.join("workflows"))
14}
15
16fn workflow_path_for_agent(agent_id: Option<&str>) -> Option<PathBuf> {
17    let dir = workflows_dir()?;
18    let filename = match agent_id {
19        Some(id) if !id.trim().is_empty() => {
20            let safe_id: String = id
21                .chars()
22                .map(|c| {
23                    if c.is_alphanumeric() || c == '-' || c == '_' {
24                        c
25                    } else {
26                        '_'
27                    }
28                })
29                .collect();
30            format!("workflow-{safe_id}.json")
31        }
32        _ => "active.json".to_string(),
33    };
34    Some(dir.join(filename))
35}
36
37pub fn load_active() -> Result<Option<WorkflowRun>, String> {
38    load_active_for_agent(None)
39}
40
41pub fn load_active_for_agent(agent_id: Option<&str>) -> Result<Option<WorkflowRun>, String> {
42    let Some(path) = workflow_path_for_agent(agent_id) else {
43        return Ok(None);
44    };
45    let content = match std::fs::read_to_string(&path) {
46        Ok(c) => c,
47        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
48            // Backward compat: if agent-scoped file missing, try legacy active.json (read-only migration)
49            if agent_id.is_some()
50                && let Some(legacy) = workflow_path_for_agent(None)
51                && let Ok(lc) = std::fs::read_to_string(&legacy)
52            {
53                let run: WorkflowRun = serde_json::from_str(&lc)
54                    .map_err(|e| format!("Invalid legacy workflow JSON: {e}"))?;
55                // Withheld first: the age of a workflow that may not be read
56                // never turns it into "no workflow".
57                return Ok(match serve_run(&legacy, &lc) {
58                    Err(reason) => Some(withheld_sentinel(reason)),
59                    Ok(()) if is_run_stale(&run) || run.current == "done" => None,
60                    Ok(()) => Some(run),
61                });
62            }
63            return Ok(None);
64        }
65        Err(e) => return Err(format!("read {}: {e}", path.display())),
66    };
67    let run: WorkflowRun =
68        serde_json::from_str(&content).map_err(|e| format!("Invalid workflow JSON: {e}"))?;
69
70    // Withheld first. A workflow that exists but may not be read loads CLOSED
71    // and its file is kept, however old it is: the stale rule below is a rule
72    // for runs that were served, and it must not delete a workflow because
73    // the reader has no right to it at this moment.
74    if let Err(reason) = serve_run(&path, &content) {
75        return Ok(Some(withheld_sentinel(reason)));
76    }
77    if is_run_stale(&run) || run.current == "done" {
78        let _ = std::fs::remove_file(&path);
79        let _ = std::fs::remove_file(authority_path(&path));
80        return Ok(None);
81    }
82    Ok(Some(run))
83}
84
85/// Whether `run` has been inactive for longer than the stale threshold. The
86/// one stale rule, for the store and for both tool gates
87/// (`workflow::gate`). The stand-in for a withheld workflow is never stale:
88/// it ends when the stored workflow loads again or is stopped explicitly,
89/// not by the passing of time.
90#[must_use]
91pub fn is_run_stale(run: &WorkflowRun) -> bool {
92    !is_withheld_sentinel(run)
93        && chrono::Utc::now()
94            .signed_duration_since(run.updated_at)
95            .num_minutes()
96            > STALE_MINUTES
97}
98
99/// The binding of a stored workflow lives next to it, so the workflow file
100/// and `WorkflowRun` keep their shape. It does not end in `.json`.
101fn authority_path(workflow: &Path) -> PathBuf {
102    workflow.with_extension("authority")
103}
104
105/// The text of a stored workflow as admission inspects it: every string
106/// value, decoded, one per line (names, descriptions, notes, evidence).
107fn run_text(json: &str) -> Result<String, String> {
108    fn collect<'a>(value: &'a serde_json::Value, leaves: &mut Vec<&'a str>) {
109        match value {
110            serde_json::Value::String(text) if !text.is_empty() => leaves.push(text),
111            serde_json::Value::Array(items) => items.iter().for_each(|item| collect(item, leaves)),
112            serde_json::Value::Object(map) => map.values().for_each(|child| collect(child, leaves)),
113            _ => {}
114        }
115    }
116    let value: serde_json::Value =
117        serde_json::from_str(json).map_err(|e| format!("Invalid workflow JSON: {e}"))?;
118    let mut leaves = Vec::new();
119    collect(&value, &mut leaves);
120    Ok(leaves.join("\n"))
121}
122
123fn request_project() -> Option<String> {
124    crate::core::policy::runtime::REQUEST_PROJECT
125        .try_with(|slot| slot.borrow().clone())
126        .ok()
127        .flatten()
128        .and_then(|root| root.to_str().map(str::to_owned))
129}
130
131const WITHHELD_SUBJECT: &str = "stored workflow";
132
133/// A stored workflow is caller text: it is served as a new delivery under the
134/// CURRENT policy — bound to the project it was saved in (a binding without
135/// sources: there is no file to re-read), then the content rules. A workflow
136/// file without a binding follows the originless rule. Its names are keys of
137/// the state machine, so a text the current rules would rewrite is refused
138/// rather than changed.
139fn serve_run(path: &Path, json: &str) -> Result<(), String> {
140    use crate::core::context_admission::carrier::Withheld;
141    use crate::core::context_admission::stored::{StoredBinding, serve_stored};
142    let binding: Option<StoredBinding> = std::fs::read_to_string(authority_path(path))
143        .ok()
144        .and_then(|authority| serde_json::from_str(&authority).ok());
145    let text = run_text(json)?;
146    let served = serve_stored(&text, binding.as_ref(), request_project().as_deref())
147        .map_err(|withheld| withheld.message(WITHHELD_SUBJECT))?;
148    if served == text {
149        Ok(())
150    } else {
151        Err(Withheld::new(&["policy.content_redacted"]).message(WITHHELD_SUBJECT))
152    }
153}
154
155/// Name and only state of the run that stands in for a withheld workflow.
156const CLOSED: &str = "gateway-withheld";
157
158/// The CLOSED run that stands in for a stored workflow that may not be served
159/// now; `reason` is the typed, content-free refusal of `serve_run`.
160///
161/// A workflow gates tools per state, and "no workflow" means no gate
162/// (`workflow::gate::tool_gate`). A workflow that exists but may not be read
163/// must therefore never load as "none": it loads as this run, for which the
164/// gate allows the workflow control tool alone, with the reason as its
165/// description. The stored file is kept; it loads again once it is admitted
166/// (same project, rules that admit it). Explicit ends, through
167/// `ctx_workflow`: `stop` removes the stored workflow, `start` replaces it.
168/// The stand-in never goes stale ([`is_run_stale`]) and is never saved.
169fn withheld_sentinel(reason: String) -> WorkflowRun {
170    use crate::core::workflow::types::{StateSpec, WorkflowSpec};
171    let now = chrono::Utc::now();
172    WorkflowRun {
173        spec: WorkflowSpec {
174            name: CLOSED.to_string(),
175            description: Some(reason.clone()),
176            initial: CLOSED.to_string(),
177            states: vec![StateSpec {
178                name: CLOSED.to_string(),
179                description: Some(reason),
180                allowed_tools: Some(vec!["ctx".to_string(), "ctx_workflow".to_string()]),
181                requires_evidence: None,
182            }],
183            transitions: Vec::new(),
184        },
185        current: CLOSED.to_string(),
186        started_at: now,
187        updated_at: now,
188        transitions: Vec::new(),
189        evidence: Vec::new(),
190    }
191}
192
193fn is_closed(run: &WorkflowRun) -> bool {
194    run.spec.name == CLOSED && run.current == CLOSED
195}
196
197/// Whether `run` is the stand-in `withheld_sentinel` builds. The one test
198/// for it: the store, the stale rule and the tool gate all ask here. The
199/// marker is the reserved name pair, which no stored workflow can carry —
200/// [`save_active_for_agent`] refuses to write a run that has it.
201#[must_use]
202pub fn is_withheld_sentinel(run: &WorkflowRun) -> bool {
203    is_closed(run)
204}
205
206pub fn save_active(run: &WorkflowRun) -> Result<(), String> {
207    save_active_for_agent(run, None)
208}
209
210pub fn save_active_for_agent(run: &WorkflowRun, agent_id: Option<&str>) -> Result<(), String> {
211    let Some(path) = workflow_path_for_agent(agent_id) else {
212        return Err("No home directory available".to_string());
213    };
214    if let Some(parent) = path.parent() {
215        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?;
216    }
217    // The stand-in for a withheld workflow is never written over the
218    // workflow it stands for.
219    if is_closed(run) {
220        return Err(run.spec.description.clone().unwrap_or_default());
221    }
222    let json = serde_json::to_string_pretty(run).map_err(|e| format!("serialize failed: {e}"))?;
223    // Admission precedes persistence. Restricted text is never stored (E3),
224    // and text the rules would rewrite is refused: names are keys.
225    let binding = admit_run(&run_text(&json)?)?;
226    let authority =
227        serde_json::to_string(&binding).map_err(|e| format!("serialize failed: {e}"))?;
228    let tmp = path.with_extension("tmp");
229    std::fs::write(&tmp, json).map_err(|e| format!("write failed: {e}"))?;
230    std::fs::rename(&tmp, &path).map_err(|e| format!("rename failed: {e}"))?;
231    if let Err(error) =
232        crate::config_io::write_atomic_with_backup(&authority_path(&path), &authority)
233    {
234        // Without its binding the workflow would load closed under
235        // protection and unbound otherwise: do not leave it behind.
236        let _ = std::fs::remove_file(&path);
237        return Err(format!("write failed: {error}"));
238    }
239    Ok(())
240}
241
242/// Admit the text of a workflow for the store and bind it to the project of
243/// the current request as caller-supplied text.
244fn admit_run(text: &str) -> Result<crate::core::context_admission::stored::StoredBinding, String> {
245    use crate::core::context_admission::carrier::Withheld;
246    use crate::core::context_admission::stored::{Lineage, admit_and_bind};
247    let refused = |withheld: Withheld| withheld.message(WITHHELD_SUBJECT);
248    let (admitted, binding) = admit_and_bind(
249        text,
250        "workflow",
251        request_project().as_deref(),
252        Lineage::CallerSupplied,
253    )
254    .map_err(refused)?;
255    if admitted != text {
256        return Err(refused(Withheld::new(&["policy.content_redacted"])));
257    }
258    Ok(binding)
259}
260
261pub fn clear_active() -> Result<(), String> {
262    clear_active_for_agent(None)
263}
264
265pub fn clear_active_for_agent(agent_id: Option<&str>) -> Result<(), String> {
266    let Some(path) = workflow_path_for_agent(agent_id) else {
267        return Ok(());
268    };
269    let _ = std::fs::remove_file(authority_path(&path));
270    match std::fs::remove_file(&path) {
271        Ok(()) => Ok(()),
272        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
273        Err(e) => Err(format!("remove {}: {e}", path.display())),
274    }
275}
276
277/// Remove workflow files older than `WORKFLOW_TTL_SECS`.
278pub fn cleanup_expired() -> (u32, u64) {
279    let Some(dir) = workflows_dir() else {
280        return (0, 0);
281    };
282    let Ok(entries) = std::fs::read_dir(&dir) else {
283        return (0, 0);
284    };
285    let now = std::time::SystemTime::now();
286    let mut removed = 0u32;
287    let mut freed = 0u64;
288
289    for entry in entries.flatten() {
290        let path = entry.path();
291        if !path.is_file() {
292            continue;
293        }
294        let ext = path.extension().and_then(|e| e.to_str());
295        if ext != Some("json") {
296            continue;
297        }
298        let Ok(meta) = std::fs::metadata(&path) else {
299            continue;
300        };
301        let age = meta
302            .modified()
303            .ok()
304            .and_then(|m| now.duration_since(m).ok())
305            .map_or(0, |d| d.as_secs());
306        if age > WORKFLOW_TTL_SECS {
307            freed += meta.len();
308            let _ = std::fs::remove_file(&path);
309            let _ = std::fs::remove_file(authority_path(&path));
310            removed += 1;
311        }
312    }
313    (removed, freed)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::core::context_admission::stored::lineage_support::{governed, project, scoped};
320    use crate::core::policy::runtime::TestPolicyOverride;
321    use crate::core::workflow::types::{StateSpec, WorkflowSpec};
322
323    fn review(description: &str) -> WorkflowRun {
324        let now = chrono::Utc::now();
325        WorkflowRun {
326            spec: WorkflowSpec {
327                name: "review".to_string(),
328                description: Some(description.to_string()),
329                initial: "planning".to_string(),
330                states: vec![StateSpec {
331                    name: "planning".to_string(),
332                    description: None,
333                    allowed_tools: Some(vec!["ctx_read".to_string()]),
334                    requires_evidence: None,
335                }],
336                transitions: Vec::new(),
337            },
338            current: "planning".to_string(),
339            started_at: now,
340            updated_at: now,
341            transitions: Vec::new(),
342            evidence: Vec::new(),
343        }
344    }
345
346    fn allowed(run: &WorkflowRun) -> Vec<String> {
347        run.spec
348            .state(&run.current)
349            .and_then(|state| state.allowed_tools.clone())
350            .expect("the current state gates tools")
351    }
352
353    /// Under protection a workflow saved in a project loads for that project.
354    /// Whenever it may not be read — another project, no binding — it loads
355    /// CLOSED: never as "no workflow" (which gates nothing), never wider than
356    /// its own state, with a typed reason and the stored file kept.
357    #[test]
358    fn a_withheld_workflow_loads_closed_and_never_as_absent() {
359        let _data = governed();
360        let _community = TestPolicyOverride::set(None);
361        let (_directory, root) = project();
362        let (_other_directory, other) = project();
363        let saved = review("Check the parser change");
364        scoped(&root, || save_active(&saved)).expect("saved in its project");
365        let path = workflow_path_for_agent(None).expect("path");
366
367        let loaded = scoped(&root, load_active).expect("load").expect("active");
368        assert_eq!(loaded.spec.name, "review");
369        assert_eq!(allowed(&loaded), ["ctx_read"]);
370
371        let closed = scoped(&other, load_active)
372            .expect("a refusal is not an error that reads as absent")
373            .expect("a withheld workflow is still a workflow");
374        assert!(is_closed(&closed));
375        assert_eq!(allowed(&closed), ["ctx", "ctx_workflow"]);
376        let reason = closed.spec.description.clone().expect("typed reason");
377        assert!(reason.contains("store.project_mismatch"), "{reason}");
378        assert!(!reason.contains("parser"), "the reason is content-free");
379        // The stand-in is never written over the workflow it stands for.
380        assert!(scoped(&other, || save_active(&closed)).is_err());
381        let again = scoped(&root, load_active).expect("load").expect("active");
382        assert_eq!(again.spec.name, "review", "the stored workflow is kept");
383
384        // No request project, and a file without its binding: closed as well.
385        assert!(is_closed(&load_active().expect("load").expect("active")));
386        std::fs::remove_file(authority_path(&path)).expect("binding");
387        let unbound = scoped(&root, load_active).expect("load").expect("active");
388        assert!(is_closed(&unbound));
389        let reason = unbound.spec.description.expect("typed reason");
390        assert!(reason.contains("store.no_source_authority"), "{reason}");
391
392        // Restricted text is never stored, and a refused save writes nothing.
393        clear_active().expect("clear");
394        let marked = review("*** TOP SECRET ***\nrotate the signing key");
395        assert!(scoped(&root, || save_active(&marked)).is_err());
396        assert!(!path.exists());
397        assert!(scoped(&root, load_active).expect("load").is_none());
398    }
399
400    /// Without protection the behaviour is as before: a workflow is saved and
401    /// loaded without a project, and a file without a binding still loads.
402    #[test]
403    fn without_protection_a_workflow_loads_as_before() {
404        let _data = crate::core::data_dir::isolated_data_dir();
405        let _community = TestPolicyOverride::set(None);
406        save_active(&review("Check the parser change")).expect("saved");
407        let path = workflow_path_for_agent(None).expect("path");
408        assert!(authority_path(&path).exists());
409        assert_eq!(
410            load_active().expect("load").expect("active").spec.name,
411            "review"
412        );
413        std::fs::remove_file(authority_path(&path)).expect("binding");
414        let legacy = load_active().expect("load").expect("active");
415        assert_eq!(legacy.spec.name, "review");
416        assert_eq!(allowed(&legacy), ["ctx_read"]);
417    }
418}