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 mut lines = vec![
68        format!("Workflow: {}", run.spec.name),
69        format!("  State: {}", run.current),
70        format!("  Updated: {}", run.updated_at),
71    ];
72
73    if let Some(state) = run.spec.state(&run.current) {
74        if let Some(ref tools) = state.allowed_tools {
75            let mut tools = tools.clone();
76            tools.sort();
77            let tools = tools.into_iter().take(30).collect::<Vec<_>>();
78            lines.push(format!(
79                "  Allowed tools ({} shown): {}",
80                tools.len(),
81                tools.join(", ")
82            ));
83        }
84    }
85
86    let transitions = workflow::allowed_transitions(&run.spec, &run.current);
87    if transitions.is_empty() {
88        lines.push("  Transitions: (none)".to_string());
89    } else {
90        lines.push("  Transitions:".to_string());
91        for t in transitions.iter().take(10) {
92            let missing = workflow::missing_evidence_for_state(&run.spec, &t.to, |k| {
93                run.evidence.iter().any(|e| e.key == k)
94                    || session.has_evidence_key(k)
95                    || ledger.has_key(k)
96            });
97            if missing.is_empty() {
98                lines.push(format!("    → {} (ok)", t.to));
99            } else {
100                lines.push(format!("    → {} (missing: {})", t.to, missing.join(", ")));
101            }
102        }
103    }
104
105    lines.join("\n")
106}
107
108fn handle_stop() -> String {
109    match workflow::clear_active() {
110        Ok(()) => "Workflow stopped (active cleared).".to_string(),
111        Err(e) => format!("Error clearing workflow: {e}"),
112    }
113}
114
115fn handle_transition(
116    args: Option<&serde_json::Map<String, Value>>,
117    session: &SessionState,
118) -> String {
119    let Some(to) = get_str(args, "to") else {
120        return "Error: 'to' is required for transition".to_string();
121    };
122    let note = get_str(args, "value");
123
124    let Ok(active) = workflow::load_active() else {
125        return "Error: failed to load active workflow.".to_string();
126    };
127    let Some(mut run) = active else {
128        return "No active workflow. Use action=start to begin.".to_string();
129    };
130
131    let ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
132    if let Err(e) = workflow::can_transition(&run.spec, &run.current, &to, |k| {
133        run.evidence.iter().any(|e| e.key == k) || session.has_evidence_key(k) || ledger.has_key(k)
134    }) {
135        return format!("Transition blocked: {e}");
136    }
137
138    let from = run.current.clone();
139    run.current.clone_from(&to);
140    run.updated_at = Utc::now();
141    run.transitions
142        .push(crate::core::workflow::TransitionRecord {
143            from: from.clone(),
144            to: to.clone(),
145            note: note.clone(),
146            timestamp: Utc::now(),
147        });
148
149    if let Err(e) = workflow::save_active(&run) {
150        return format!("Failed to save workflow: {e}");
151    }
152
153    format!("Transition: {from} → {to}")
154}
155
156fn handle_complete(
157    args: Option<&serde_json::Map<String, Value>>,
158    session: &SessionState,
159) -> String {
160    let Ok(active) = workflow::load_active() else {
161        return "Error: failed to load active workflow.".to_string();
162    };
163    let Some(mut run) = active else {
164        return "No active workflow. Use action=start to begin.".to_string();
165    };
166    let note = get_str(args, "value");
167
168    let done = "done".to_string();
169    if workflow::find_transition(&run.spec, &run.current, &done).is_none() {
170        return format!("No transition to 'done' from '{}'", run.current);
171    }
172
173    let ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
174    if let Err(e) = workflow::can_transition(&run.spec, &run.current, &done, |k| {
175        run.evidence.iter().any(|e| e.key == k) || session.has_evidence_key(k) || ledger.has_key(k)
176    }) {
177        return format!("Complete blocked: {e}");
178    }
179
180    let from = run.current.clone();
181    run.current.clone_from(&done);
182    run.updated_at = Utc::now();
183    run.transitions
184        .push(crate::core::workflow::TransitionRecord {
185            from: from.clone(),
186            to: done.clone(),
187            note,
188            timestamp: Utc::now(),
189        });
190
191    if let Err(e) = workflow::save_active(&run) {
192        return format!("Failed to save workflow: {e}");
193    }
194
195    format!("Workflow completed: {from} → {done}")
196}
197
198fn handle_evidence_add(
199    args: Option<&serde_json::Map<String, Value>>,
200    session: &mut SessionState,
201) -> String {
202    let Some(key) = get_str(args, "key") else {
203        return "Error: key is required".to_string();
204    };
205    let value = get_str(args, "value");
206
207    let Ok(active) = workflow::load_active() else {
208        return "Error: failed to load active workflow.".to_string();
209    };
210    let Some(mut run) = active else {
211        return "No active workflow. Use action=start to begin.".to_string();
212    };
213
214    run.add_manual_evidence(&key, value.as_deref());
215    session.record_manual_evidence(&key, value.as_deref());
216    {
217        let mut ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
218        ledger.record_manual(&key, value.as_deref(), chrono::Utc::now());
219        let _ = ledger.save();
220    }
221
222    if let Err(e) = workflow::save_active(&run) {
223        return format!("Failed to save workflow: {e}");
224    }
225
226    format!("Evidence added: {key}")
227}
228
229fn handle_evidence_list(session: &SessionState) -> String {
230    let Ok(active) = workflow::load_active() else {
231        return "Error: failed to load active workflow.".to_string();
232    };
233    let Some(run) = active else {
234        return "No active workflow.".to_string();
235    };
236
237    let ledger = crate::core::evidence_ledger::EvidenceLedgerV1::load();
238    let mut lines = vec![format!("Evidence (workflow: {}):", run.spec.name)];
239    if run.evidence.is_empty() && session.evidence.is_empty() && ledger.items.is_empty() {
240        lines.push("  (none)".to_string());
241        return lines.join("\n");
242    }
243
244    if !run.evidence.is_empty() {
245        lines.push("  Manual (workflow):".to_string());
246        for e in run.evidence.iter().rev().take(20) {
247            let v = e.value.as_deref().unwrap_or("-");
248            lines.push(format!("    {} = {} ({})", e.key, v, e.timestamp));
249        }
250    }
251
252    if !session.evidence.is_empty() {
253        lines.push("  Session receipts (latest):".to_string());
254        for e in session.evidence.iter().rev().take(20) {
255            lines.push(format!("    {} ({:?})", e.key, e.kind));
256        }
257    }
258
259    if !ledger.items.is_empty() {
260        lines.push("  Ledger (latest):".to_string());
261        for e in ledger.items.iter().rev().take(20) {
262            lines.push(format!("    {} ({:?})", e.key, e.kind));
263        }
264    }
265
266    lines.join("\n")
267}
268
269fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
270    args?
271        .get(key)?
272        .as_str()
273        .map(std::string::ToString::to_string)
274}