1use crate::core::workflow::types::WorkflowRun;
2use std::path::{Path, PathBuf};
3
4const STALE_MINUTES: i64 = 30;
6
7pub 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 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 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 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#[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
99fn authority_path(workflow: &Path) -> PathBuf {
102 workflow.with_extension("authority")
103}
104
105fn 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
133fn 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
155const CLOSED: &str = "gateway-withheld";
157
158fn 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#[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 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 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 let _ = std::fs::remove_file(&path);
237 return Err(format!("write failed: {error}"));
238 }
239 Ok(())
240}
241
242fn 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
277pub 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 #[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 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 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 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 #[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}