Skip to main content

nomograph_workflow/
phase.rs

1//! Workflow phase state machine and enforcement.
2//!
3//! The ORIENT → PLAN → AGREE → EXECUTE ↔ REFLECT → REPLAN → REPORT
4//! state machine is a workflow concern shared across tools. Synthesist
5//! owns the CLI for setting/showing phase. Future tools (seer) that
6//! need to respect phase gates call [`check_phase`] directly rather
7//! than reimplementing the rules.
8//!
9//! Per D14, phase is per-session: a [`Phase`] claim carries
10//! `{session_id, name}`. Setting a phase appends a new claim that
11//! supersedes the prior Phase claim for the same `session_id`.
12//! Migrated v1 data lives under `session_id = "global-migrated"`.
13
14use anyhow::{Result, bail};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::store::Store;
19
20/// Session id used for migrated v1 phase data. In v1 there was one
21/// global `phase` row; the v1 → v2 migrator writes it under this
22/// synthetic session so the per-session model stays consistent.
23pub const GLOBAL_SESSION_ID: &str = "global-migrated";
24
25/// Workflow phase.
26///
27/// Transitions:
28/// ```text
29///   orient → plan
30///   plan   → agree
31///   agree  → execute
32///   execute → reflect | report
33///   reflect → execute | replan | report
34///   replan  → agree
35///   report  → (terminal)
36/// ```
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Phase {
40    Orient,
41    Plan,
42    Agree,
43    Execute,
44    Reflect,
45    Replan,
46    Report,
47}
48
49impl Phase {
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            Self::Orient => "orient",
53            Self::Plan => "plan",
54            Self::Agree => "agree",
55            Self::Execute => "execute",
56            Self::Reflect => "reflect",
57            Self::Replan => "replan",
58            Self::Report => "report",
59        }
60    }
61
62    // Intentional: `Option<Self>` is the ergonomic return type for callers
63    // (plain `.ok_or_else(...)` use); `FromStr` would force `Result` noise.
64    #[allow(clippy::should_implement_trait)]
65    pub fn from_str(s: &str) -> Option<Self> {
66        match s {
67            "orient" => Some(Self::Orient),
68            "plan" => Some(Self::Plan),
69            "agree" => Some(Self::Agree),
70            "execute" => Some(Self::Execute),
71            "reflect" => Some(Self::Reflect),
72            "replan" => Some(Self::Replan),
73            "report" => Some(Self::Report),
74            _ => None,
75        }
76    }
77
78    /// Phases reachable in a single `phase set` transition from here.
79    pub fn valid_transitions(&self) -> &'static [Phase] {
80        match self {
81            Self::Orient => &[Self::Plan],
82            Self::Plan => &[Self::Agree],
83            Self::Agree => &[Self::Execute],
84            Self::Execute => &[Self::Reflect, Self::Report],
85            Self::Reflect => &[Self::Execute, Self::Replan, Self::Report],
86            Self::Replan => &[Self::Agree],
87            Self::Report => &[],
88        }
89    }
90
91    /// Whether transitioning from this phase to `target` is allowed
92    /// without `--force`.
93    pub fn can_transition_to(&self, target: Phase) -> bool {
94        self.valid_transitions().contains(&target)
95    }
96}
97
98impl std::fmt::Display for Phase {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(self.as_str())
101    }
102}
103
104/// Per-phase command gate.
105///
106/// A callers passes the top-level command verb (e.g. `"task"`) and the
107/// sub-command (`"add"`, `"claim"`), and this function returns an
108/// error if the current phase forbids that operation. Commands not
109/// listed here are allowed in every non-terminal phase.
110///
111/// Rules (unchanged from v1):
112/// - ORIENT: no writes
113/// - PLAN: no task claim/done/block (only adds)
114/// - AGREE: no operations (wait for human approval)
115/// - EXECUTE: no task add/cancel, no spec add (do the agreed plan)
116/// - REFLECT: no task claim (reflect, don't execute)
117/// - REPLAN: no task claim (plan changes, not execution)
118/// - REPORT: no writes
119pub fn check_phase(
120    store: &Store,
121    session: Option<&str>,
122    top_cmd: &str,
123    sub_cmd: &str,
124    force: bool,
125) -> Result<()> {
126    if force {
127        return Ok(());
128    }
129
130    let session_id = session
131        .filter(|s| !s.is_empty())
132        .map(String::from)
133        .unwrap_or_else(|| GLOBAL_SESSION_ID.to_string());
134
135    let phase = current_phase_name(store, &session_id)?
136        .unwrap_or_else(|| Phase::Orient.as_str().to_string());
137
138    let violation = match phase.as_str() {
139        "orient" => Some("no writes allowed in ORIENT phase"),
140        "plan" => {
141            if top_cmd == "task" && matches!(sub_cmd, "claim" | "done" | "block") {
142                Some("cannot claim/complete tasks in PLAN phase")
143            } else {
144                None
145            }
146        }
147        "agree" => Some("no operations in AGREE phase -- present plan and wait for human approval"),
148        "execute" => {
149            if top_cmd == "task" && matches!(sub_cmd, "add" | "cancel") {
150                Some("cannot add/cancel tasks in EXECUTE phase -- transition to REPLAN")
151            } else if top_cmd == "spec" && sub_cmd == "add" {
152                Some("cannot add specs in EXECUTE phase -- transition to REPLAN")
153            } else {
154                None
155            }
156        }
157        "reflect" => {
158            if top_cmd == "task" && sub_cmd == "claim" {
159                Some("cannot claim tasks in REFLECT phase")
160            } else {
161                None
162            }
163        }
164        "replan" => {
165            if top_cmd == "task" && sub_cmd == "claim" {
166                Some("cannot claim tasks in REPLAN phase")
167            } else {
168                None
169            }
170        }
171        "report" => Some("no writes allowed in REPORT phase"),
172        _ => None,
173    };
174
175    if let Some(msg) = violation {
176        bail!("phase violation ({phase}): {msg}");
177    }
178    Ok(())
179}
180
181/// Return the name of the current Phase claim for `session_id`, or
182/// `None` if no Phase claim exists (caller defaults to `orient`).
183pub fn current_phase_name(store: &Store, session_id: &str) -> Result<Option<String>> {
184    let rows = store.query(
185        "SELECT json_extract(props, '$.name') AS name \
186         FROM claims \
187         WHERE claim_type = 'phase' \
188           AND json_extract(props, '$.session_id') = ?1 \
189         ORDER BY asserted_at DESC LIMIT 1",
190        &[&session_id],
191    )?;
192    Ok(rows
193        .into_iter()
194        .next()
195        .and_then(|r| r.get("name").cloned())
196        .and_then(|v| v.as_str().map(String::from)))
197}
198
199/// Return `(claim_id, phase_name)` for the most recent Phase claim in
200/// `session_id` — used by supersession-chain writes that need the
201/// previous claim's id.
202pub fn current_phase_claim(store: &Store, session_id: &str) -> Result<Option<(String, String)>> {
203    let rows = store.query(
204        "SELECT id, props FROM claims \
205         WHERE claim_type = 'phase' \
206           AND json_extract(props, '$.session_id') = ?1 \
207         ORDER BY asserted_at DESC LIMIT 1",
208        &[&session_id],
209    )?;
210    let row = match rows.into_iter().next() {
211        Some(r) => r,
212        None => return Ok(None),
213    };
214    let claim_id = row
215        .get("id")
216        .and_then(|v| v.as_str())
217        .ok_or_else(|| anyhow::anyhow!("phase row missing id"))?
218        .to_string();
219    let props_str = row
220        .get("props")
221        .and_then(|v| v.as_str())
222        .ok_or_else(|| anyhow::anyhow!("phase row missing props"))?;
223    let props: Value = serde_json::from_str(props_str)?;
224    let name = props
225        .get("name")
226        .and_then(|v| v.as_str())
227        .ok_or_else(|| anyhow::anyhow!("phase claim missing name"))?
228        .to_string();
229    Ok(Some((claim_id, name)))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn phase_transitions_cover_the_state_machine() {
238        assert!(Phase::Orient.can_transition_to(Phase::Plan));
239        assert!(!Phase::Orient.can_transition_to(Phase::Execute));
240        assert!(Phase::Plan.can_transition_to(Phase::Agree));
241        assert!(Phase::Agree.can_transition_to(Phase::Execute));
242        assert!(Phase::Execute.can_transition_to(Phase::Reflect));
243        assert!(Phase::Execute.can_transition_to(Phase::Report));
244        assert!(!Phase::Execute.can_transition_to(Phase::Plan));
245        assert!(Phase::Reflect.can_transition_to(Phase::Execute));
246        assert!(Phase::Reflect.can_transition_to(Phase::Replan));
247        assert!(Phase::Replan.can_transition_to(Phase::Agree));
248        assert!(!Phase::Report.can_transition_to(Phase::Plan));
249    }
250
251    #[test]
252    fn phase_from_str_roundtrips() {
253        for name in [
254            "orient", "plan", "agree", "execute", "reflect", "replan", "report",
255        ] {
256            assert_eq!(Phase::from_str(name).unwrap().as_str(), name);
257        }
258        assert!(Phase::from_str("bogus").is_none());
259    }
260}