1use std::path::Path;
20
21use keel_journal::{FlowId, SqliteJournal, SystemClock};
22use serde::Serialize;
23
24use crate::render::to_json;
25use crate::{EXIT_OK, Rendered, evidence, exec, flows};
26
27#[derive(Debug, Serialize)]
29struct ForceReport {
30 armed: bool,
33 flow_id: String,
36}
37
38pub fn run(project: &Path, flow: &str) -> (Option<Rendered>, i32) {
46 let journal_path = evidence::resolved_journal(project).path;
47 if !journal_path.exists() {
48 return soft_pair(
49 "no journal yet (.keel/journal.db). Run a flow first with `keel run` or `keel exec`.",
50 );
51 }
52
53 let resolved = {
58 let conn = match flows::open_ro(&journal_path) {
59 Ok(c) => c,
60 Err(e) => return soft_pair(&e),
61 };
62 match flows::resolve_flow(&conn, flow) {
63 Ok(r) => r,
64 Err(e) => return soft_pair(&e),
65 }
66 };
67
68 match resolved.status.as_str() {
71 "completed" => {
72 return soft_pair(&format!(
73 "flow {id} is already completed; there is nothing to force \u{2014} a completed \
74 flow's command never re-runs (KEEL-E033 only gates a running/failed \
75 re-dispatch). Inspect it with `keel replay {id}` or `keel trace {id}`.",
76 id = resolved.flow_id
77 ));
78 }
79 "dead" => {
80 return soft_pair(&format!(
81 "flow {id} is dead (KEEL-E032); forcing the KEEL-E033 side-effect gate cannot \
82 revive it \u{2014} a dead flow is never re-dispatched. See `keel explain \
83 KEEL-E032`, then start a new flow with a fresh identity instead.",
84 id = resolved.flow_id
85 ));
86 }
87 _ => {}
88 }
89
90 let journal = match SqliteJournal::open(&journal_path, SystemClock) {
96 Ok(j) => j,
97 Err(e) => {
98 return soft_pair(&format!(
99 "could not open the journal at {} read-write: {e}",
100 journal_path.display()
101 ));
102 }
103 };
104 let flow_id = FlowId::new(resolved.flow_id.as_str());
105 if let Err(e) = exec::request_force_override(&journal, &flow_id) {
106 return soft_pair(&format!(
107 "could not arm the force override for flow {}: {e}",
108 resolved.flow_id
109 ));
110 }
111
112 let human = format!(
113 "keel \u{25b8} flows force: {} armed \u{2014} the next re-dispatch that would otherwise be \
114 KEEL-E033-refused (its declared side-effect files changed) will proceed once, then this \
115 one-shot clears itself.",
116 resolved.flow_id
117 );
118 let report = ForceReport {
119 armed: true,
120 flow_id: resolved.flow_id,
121 };
122 (Some(Rendered::ok(human, to_json(&report))), EXIT_OK)
123}
124
125fn soft_pair(message: &str) -> (Option<Rendered>, i32) {
127 let r = flows::soft_error(message);
128 let code = r.exit;
129 (Some(r), code)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use rusqlite::{Connection, params};
136
137 const T0: i64 = 1_783_728_000_000;
138
139 fn project_with_fixtures(fixtures: &[&str]) -> (tempfile::TempDir, std::path::PathBuf) {
142 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
143 let dir = tempfile::TempDir::new().unwrap();
144 let keel = dir.path().join(".keel");
145 std::fs::create_dir_all(&keel).unwrap();
146 let conn = Connection::open(keel.join("journal.db")).unwrap();
147 let schema = std::fs::read_to_string(root.join("contracts/journal.sql")).unwrap();
148 conn.execute_batch(&schema).unwrap();
149 for f in fixtures {
150 let sql =
151 std::fs::read_to_string(root.join("conformance/fixtures/journal").join(f)).unwrap();
152 conn.execute_batch(&sql).unwrap();
153 }
154 let project = dir.path().to_path_buf();
155 (dir, project)
156 }
157
158 fn seed_failed_cmd_flow(project: &Path, flow_id: &str) {
161 let conn = Connection::open(project.join(".keel/journal.db")).unwrap();
162 conn.execute(
163 "INSERT INTO flows (flow_id, entrypoint, args_hash, status, created_at, updated_at) \
164 VALUES (?1, 'cmd:trade', 'ah', 'failed', ?2, ?2)",
165 params![flow_id, T0],
166 )
167 .unwrap();
168 }
169
170 #[test]
171 fn missing_journal_is_a_soft_error() {
172 let dir = tempfile::TempDir::new().unwrap();
173 let (r, code) = run(dir.path(), "anything");
174 assert_eq!(code, crate::EXIT_FAILURE);
175 assert!(r.unwrap().human.contains("no journal yet"));
176 }
177
178 #[test]
179 fn unknown_flow_is_a_clean_soft_error_not_a_panic() {
180 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
181 let (r, code) = run(&project, "does-not-exist");
182 assert_eq!(code, crate::EXIT_FAILURE);
183 assert!(r.unwrap().human.contains("no flow matches"));
184 }
185
186 #[test]
187 fn ambiguous_flow_is_a_soft_error() {
188 let (_d, project) = project_with_fixtures(&["completed-flow.sql", "dead-flow.sql"]);
189 let (r, code) = run(&project, "01JZWY0A");
191 assert_eq!(code, crate::EXIT_FAILURE);
192 assert!(r.unwrap().human.contains("matches"));
193 }
194
195 #[test]
196 fn completed_flow_is_refused_as_nothing_to_force() {
197 let (_d, project) = project_with_fixtures(&["completed-flow.sql"]);
198 let (r, code) = run(&project, "01JZWY0A0000000000000001");
199 assert_eq!(code, crate::EXIT_FAILURE);
200 let human = r.unwrap().human;
201 assert!(human.contains("already completed"));
202 assert!(human.contains("nothing to force"));
203 }
204
205 #[test]
206 fn dead_flow_is_refused_with_e032() {
207 let (_d, project) = project_with_fixtures(&["dead-flow.sql"]);
208 let (r, code) = run(&project, "01JZWY0A0000000000000003");
209 assert_eq!(code, crate::EXIT_FAILURE);
210 let human = r.unwrap().human;
211 assert!(human.contains("KEEL-E032"));
212 assert!(human.contains("dead"));
213 }
214
215 #[test]
216 fn arming_a_failed_flow_records_the_requested_force_marker() {
217 let (_d, project) = project_with_fixtures(&[]);
218 seed_failed_cmd_flow(&project, "01FORCEME");
219
220 let (r, code) = run(&project, "01FORCEME");
221 assert_eq!(code, EXIT_OK);
222 let rendered = r.unwrap();
223 assert!(rendered.human.contains("01FORCEME"));
224 assert!(rendered.human.contains("armed"));
225 assert_eq!(rendered.json["armed"], true);
226 assert_eq!(rendered.json["flow_id"], "01FORCEME");
227
228 let conn = Connection::open(project.join(".keel/journal.db")).unwrap();
235 let (step_key, kind, outcome, payload_len): (String, String, String, i64) = conn
236 .query_row(
237 "SELECT step_key, kind, outcome, length(payload) FROM steps \
238 WHERE flow_id = '01FORCEME' AND seq = 500002",
239 [],
240 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
241 )
242 .expect("the FORCE marker row exists");
243 assert_eq!(step_key, "cmd:force");
244 assert_eq!(kind, "marker");
245 assert_eq!(outcome, "ok");
246 assert!(payload_len > 0, "the armed marker carries a payload");
247 }
248
249 #[test]
250 fn substring_resolution_arms_the_uniquely_matching_flow() {
251 let (_d, project) = project_with_fixtures(&[]);
252 seed_failed_cmd_flow(&project, "01UNIQUEABCDEF");
253 let (r, code) = run(&project, "UNIQUEABC");
255 assert_eq!(code, EXIT_OK);
256 assert_eq!(r.unwrap().json["flow_id"], "01UNIQUEABCDEF");
257 }
258}