Skip to main content

lean_ctx/tools/
ctx_workflow.rs

1use crate::core::session::SessionState;
2use crate::core::workflow::{self, WorkflowRun, WorkflowSpec};
3use chrono::Utc;
4use serde_json::Value;
5
6pub fn handle_with_session(
7    args: Option<&serde_json::Map<String, Value>>,
8    session: &mut SessionState,
9) -> String {
10    handle_with_session_agent(args, session, None)
11}
12
13pub fn handle_with_session_agent(
14    args: Option<&serde_json::Map<String, Value>>,
15    session: &mut SessionState,
16    agent_id: Option<&str>,
17) -> String {
18    let action = get_str(args, "action").unwrap_or_else(|| "status".to_string());
19
20    match action.as_str() {
21        "start" => handle_start(args, agent_id),
22        "status" => handle_status(session, agent_id),
23        "stop" => handle_stop(agent_id),
24        "transition" => handle_transition(args, session, agent_id),
25        "complete" => handle_complete(args, session, agent_id),
26        "evidence_add" => handle_evidence_add(args, session, agent_id),
27        "evidence_list" => handle_evidence_list(session, agent_id),
28        _ => "Unknown action. Use: start, status, transition, complete, evidence_add, evidence_list, stop".to_string(),
29    }
30}
31
32fn handle_start(args: Option<&serde_json::Map<String, Value>>, agent_id: Option<&str>) -> String {
33    let spec_json = get_str(args, "spec");
34    let name_override = get_str(args, "name");
35
36    let mut spec: WorkflowSpec = match spec_json.as_deref() {
37        Some(s) if !s.trim().is_empty() => match serde_json::from_str::<WorkflowSpec>(s) {
38            Ok(v) => v,
39            Err(e) => return format!("Invalid spec JSON: {e}"),
40        },
41        _ => WorkflowSpec::builtin_plan_code_test(),
42    };
43
44    if let Some(name) = name_override {
45        if !name.trim().is_empty() {
46            spec.name = name;
47        }
48    }
49
50    if let Err(e) = workflow::validate_spec(&spec) {
51        return format!("Invalid WorkflowSpec: {e}");
52    }
53
54    let run = WorkflowRun::new(spec);
55    if let Err(e) = workflow::save_active_for_agent(&run, agent_id) {
56        return format!("Failed to save workflow: {e}");
57    }
58
59    format!(
60        "Workflow started: {}\n  State: {}\n  Started: {}",
61        run.spec.name, run.current, run.started_at
62    )
63}
64
65fn handle_status(session: &SessionState, agent_id: Option<&str>) -> String {
66    let Ok(active) = workflow::load_active_for_agent(agent_id) else {
67        return "Error: failed to load active workflow.".to_string();
68    };
69    let Some(run) = active else {
70        return "No active workflow. Use action=start to begin.".to_string();
71    };
72
73    let ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
74
75    let elapsed_min = chrono::Utc::now()
76        .signed_duration_since(run.updated_at)
77        .num_minutes();
78    let mut lines = vec![
79        format!("Workflow: {}", run.spec.name),
80        format!("  State: {}", run.current),
81        format!("  Updated: {} ({elapsed_min}m ago)", run.updated_at),
82    ];
83    if elapsed_min > 20 {
84        lines.push("  WARNING: Workflow inactive >20min, will auto-expire at 30min. Use action=stop to exit now.".to_string());
85    }
86
87    if let Some(state) = run.spec.state(&run.current) {
88        if let Some(ref tools) = state.allowed_tools {
89            let mut tools = tools.clone();
90            tools.sort();
91            let tools = tools.into_iter().take(30).collect::<Vec<_>>();
92            lines.push(format!(
93                "  Allowed tools ({} shown): {}",
94                tools.len(),
95                tools.join(", ")
96            ));
97        }
98    }
99
100    let transitions = workflow::allowed_transitions(&run.spec, &run.current);
101    if transitions.is_empty() {
102        lines.push("  Transitions: (none)".to_string());
103    } else {
104        lines.push("  Transitions:".to_string());
105        for t in transitions.iter().take(10) {
106            let missing = workflow::missing_evidence_for_state(&run.spec, &t.to, |k| {
107                run.evidence.iter().any(|e| e.key == k)
108                    || session.has_evidence_key(k)
109                    || ledger.has_key(k)
110            });
111            if missing.is_empty() {
112                lines.push(format!("    → {} (ok)", t.to));
113            } else {
114                lines.push(format!("    → {} (missing: {})", t.to, missing.join(", ")));
115            }
116        }
117    }
118
119    lines.join("\n")
120}
121
122fn handle_stop(agent_id: Option<&str>) -> String {
123    match workflow::clear_active_for_agent(agent_id) {
124        Ok(()) => "Workflow stopped (active cleared).".to_string(),
125        Err(e) => format!("Error clearing workflow: {e}"),
126    }
127}
128
129fn handle_transition(
130    args: Option<&serde_json::Map<String, Value>>,
131    session: &SessionState,
132    agent_id: Option<&str>,
133) -> String {
134    let Some(to) = get_str(args, "to") else {
135        return "Error: 'to' is required for transition".to_string();
136    };
137    let note = get_str(args, "value");
138
139    let Ok(active) = workflow::load_active_for_agent(agent_id) else {
140        return "Error: failed to load active workflow.".to_string();
141    };
142    let Some(mut run) = active else {
143        return "No active workflow. Use action=start to begin.".to_string();
144    };
145
146    let ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
147    if let Err(e) = workflow::can_transition(&run.spec, &run.current, &to, |k| {
148        run.evidence.iter().any(|e| e.key == k) || session.has_evidence_key(k) || ledger.has_key(k)
149    }) {
150        return format!("Transition blocked: {e}");
151    }
152
153    let from = run.current.clone();
154    run.current.clone_from(&to);
155    run.updated_at = Utc::now();
156    run.transitions
157        .push(crate::core::workflow::TransitionRecord {
158            from: from.clone(),
159            to: to.clone(),
160            note: note.clone(),
161            timestamp: Utc::now(),
162        });
163
164    if let Err(e) = workflow::save_active_for_agent(&run, agent_id) {
165        return format!("Failed to save workflow: {e}");
166    }
167
168    format!("Transition: {from} → {to}")
169}
170
171fn handle_complete(
172    args: Option<&serde_json::Map<String, Value>>,
173    session: &SessionState,
174    agent_id: Option<&str>,
175) -> String {
176    let Ok(active) = workflow::load_active_for_agent(agent_id) else {
177        return "Error: failed to load active workflow.".to_string();
178    };
179    let Some(mut run) = active else {
180        return "No active workflow. Use action=start to begin.".to_string();
181    };
182    let note = get_str(args, "value");
183
184    let done = "done".to_string();
185    if workflow::find_transition(&run.spec, &run.current, &done).is_none() {
186        return format!("No transition to 'done' from '{}'", run.current);
187    }
188
189    let ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
190    if let Err(e) = workflow::can_transition(&run.spec, &run.current, &done, |k| {
191        run.evidence.iter().any(|e| e.key == k) || session.has_evidence_key(k) || ledger.has_key(k)
192    }) {
193        return format!("Complete blocked: {e}");
194    }
195
196    let from = run.current.clone();
197    run.current.clone_from(&done);
198    run.updated_at = Utc::now();
199    run.transitions
200        .push(crate::core::workflow::TransitionRecord {
201            from: from.clone(),
202            to: done.clone(),
203            note,
204            timestamp: Utc::now(),
205        });
206
207    if let Err(e) = workflow::clear_active_for_agent(agent_id) {
208        return format!("Workflow completed but failed to clear: {e}");
209    }
210
211    format!("Workflow completed: {from} → done (workflow cleared)")
212}
213
214fn handle_evidence_add(
215    args: Option<&serde_json::Map<String, Value>>,
216    session: &mut SessionState,
217    agent_id: Option<&str>,
218) -> String {
219    let Some(key) = get_str(args, "key") else {
220        return "Error: key is required".to_string();
221    };
222    let value = get_str(args, "value");
223
224    let Ok(active) = workflow::load_active_for_agent(agent_id) else {
225        return "Error: failed to load active workflow.".to_string();
226    };
227    let Some(mut run) = active else {
228        return "No active workflow. Use action=start to begin.".to_string();
229    };
230
231    run.add_manual_evidence(&key, value.as_deref());
232    session.record_manual_evidence(&key, value.as_deref());
233    {
234        let mut ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
235        ledger.record_manual(&key, value.as_deref(), chrono::Utc::now());
236        let _ = ledger.save();
237    }
238
239    if let Err(e) = workflow::save_active_for_agent(&run, agent_id) {
240        return format!("Failed to save workflow: {e}");
241    }
242
243    format!("Evidence added: {key}")
244}
245
246fn handle_evidence_list(session: &SessionState, agent_id: Option<&str>) -> String {
247    let Ok(active) = workflow::load_active_for_agent(agent_id) else {
248        return "Error: failed to load active workflow.".to_string();
249    };
250    let Some(run) = active else {
251        return "No active workflow.".to_string();
252    };
253
254    let ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
255    let mut lines = vec![format!("Evidence (workflow: {}):", run.spec.name)];
256    if run.evidence.is_empty() && session.evidence.is_empty() && ledger.items.is_empty() {
257        lines.push("  (none)".to_string());
258        return lines.join("\n");
259    }
260
261    if !run.evidence.is_empty() {
262        lines.push("  Manual (workflow):".to_string());
263        for e in run.evidence.iter().rev().take(20) {
264            let v = e.value.as_deref().unwrap_or("-");
265            lines.push(format!("    {} = {} ({})", e.key, v, e.timestamp));
266        }
267    }
268
269    if !session.evidence.is_empty() {
270        lines.push("  Session receipts (latest):".to_string());
271        for e in session.evidence.iter().rev().take(20) {
272            lines.push(format!("    {} ({:?})", e.key, e.kind));
273        }
274    }
275
276    if !ledger.items.is_empty() {
277        lines.push("  Ledger (latest):".to_string());
278        for e in ledger.items.iter().rev().take(20) {
279            lines.push(format!("    {} ({:?})", e.key, e.kind));
280        }
281    }
282
283    lines.join("\n")
284}
285
286fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
287    args?
288        .get(key)?
289        .as_str()
290        .map(std::string::ToString::to_string)
291}