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