Skip to main content

keel_cli/
force.rs

1//! `keel flows force <FLOW>` — arm the durable one-shot KEEL-E033 force
2//! override (CCR-5 decision 2 / CCR-6) for one flow.
3//!
4//! `keel exec --force` is an in-memory, per-invocation flag: it only helps an
5//! operator who is *at* the terminal re-running the command themselves. In-
6//! process `cmd:` interception (issue #27) and stuck flows whose host already
7//! exited have no such surface — the next re-dispatch happens somewhere the
8//! `--force` flag can't reach. This verb is the out-of-process, config-free
9//! escape hatch: it persists a marker step (via
10//! [`exec::request_force_override`]) so the next re-dispatch that would
11//! otherwise be KEEL-E033-refused (its declared side-effect files changed since
12//! the last attempt) proceeds exactly once, then clears itself.
13//!
14//! Only a `running`/`failed` flow is ever gated by KEEL-E033
15//! (`exec::side_effect_gate` checks no other status), so forcing a `completed`
16//! or `dead` flow would arm a no-op; those are refused with a precise
17//! what/why/next instead, mirroring [`crate::resume`]'s eligibility UX.
18
19use 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/// The `keel flows force` `--json` twin.
28#[derive(Debug, Serialize)]
29struct ForceReport {
30    /// Always `true` on the success path (a failure renders through
31    /// [`flows::soft_error`] with an `error` field instead).
32    armed: bool,
33    /// The exact flow_id the one-shot was armed for (resolved from the
34    /// possibly-partial `<FLOW>` argument).
35    flow_id: String,
36}
37
38/// `keel flows force <FLOW>` for `project`: resolve `<FLOW>` to exactly one
39/// flow and arm its durable one-shot KEEL-E033 override.
40///
41/// Returns the rendered result and the process exit code: [`EXIT_OK`] on a
42/// successful arm, otherwise a soft error (exit 1) — an unresolvable/ambiguous
43/// flow, a `completed`/`dead` flow (nothing to force), or an unwritable
44/// journal. Never panics; no raw SQL error ever reaches the user.
45pub 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    // Resolve `<FLOW>` against a throwaway READ-ONLY connection (reusing
54    // `resolve_flow`'s exact-id-or-unique-substring logic verbatim), then drop
55    // it before opening the journal read-write. One writer at a time (issue
56    // #14); resolution itself never needs to write.
57    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    // Refuse the two statuses KEEL-E033 never gates: arming them would silently
69    // do nothing. A precise message beats a no-op that looks like success.
70    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    // Open the journal READ-WRITE and arm the one-shot. The journal file and
91    // the flow row both already exist (guarded above / proven by resolution),
92    // so `SqliteJournal::open` materializes nothing spurious — the same open
93    // `keel exec` itself uses. All access is through this ONE handle (issue
94    // #14: no second in-process reader against a live journal).
95    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
125/// A soft error (exit 1, on stderr) — mirrors `keel exec`/`keel flows resume`.
126fn 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    /// A project dir with `.keel/journal.db` built from the frozen schema plus
140    /// the named golden fixtures (the exact helper `resume`'s tests use).
141    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    /// Seed one `failed` `cmd:` flow row directly (the status `keel flows
159    /// force` actually arms) and return its id.
160    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        // Both fixture ids share this prefix, so resolution refuses to guess.
190        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        // The one-shot marker landed at the reserved FORCE_SEQ as a `marker`
229        // row with the reserved `cmd:force` key — the exact row the gate
230        // reads-and-clears. (The payload state=`requested` semantics are
231        // proven end-to-end through the real gate in `tests/flows_force.rs`
232        // and unit-level in `exec.rs`'s force-override test; the payload here
233        // is a MessagePack blob, not a queryable string.)
234        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        // A unique substring of the id resolves the same way an exact id does.
254        let (r, code) = run(&project, "UNIQUEABC");
255        assert_eq!(code, EXIT_OK);
256        assert_eq!(r.unwrap().json["flow_id"], "01UNIQUEABCDEF");
257    }
258}