nomograph_workflow/
phase.rs1use anyhow::{Result, bail};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::store::Store;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Phase {
35 Orient,
36 Plan,
37 Agree,
38 Execute,
39 Reflect,
40 Replan,
41 Report,
42}
43
44impl Phase {
45 pub fn as_str(&self) -> &'static str {
46 match self {
47 Self::Orient => "orient",
48 Self::Plan => "plan",
49 Self::Agree => "agree",
50 Self::Execute => "execute",
51 Self::Reflect => "reflect",
52 Self::Replan => "replan",
53 Self::Report => "report",
54 }
55 }
56
57 #[allow(clippy::should_implement_trait)]
60 pub fn from_str(s: &str) -> Option<Self> {
61 match s {
62 "orient" => Some(Self::Orient),
63 "plan" => Some(Self::Plan),
64 "agree" => Some(Self::Agree),
65 "execute" => Some(Self::Execute),
66 "reflect" => Some(Self::Reflect),
67 "replan" => Some(Self::Replan),
68 "report" => Some(Self::Report),
69 _ => None,
70 }
71 }
72
73 pub fn valid_transitions(&self) -> &'static [Phase] {
75 match self {
76 Self::Orient => &[Self::Plan],
77 Self::Plan => &[Self::Agree],
78 Self::Agree => &[Self::Execute],
79 Self::Execute => &[Self::Reflect, Self::Report],
80 Self::Reflect => &[Self::Execute, Self::Replan, Self::Report],
81 Self::Replan => &[Self::Agree],
82 Self::Report => &[],
83 }
84 }
85
86 pub fn can_transition_to(&self, target: Phase) -> bool {
89 self.valid_transitions().contains(&target)
90 }
91}
92
93impl std::fmt::Display for Phase {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.write_str(self.as_str())
96 }
97}
98
99pub fn check_phase(
115 store: &Store,
116 session: Option<&str>,
117 top_cmd: &str,
118 sub_cmd: &str,
119 force: bool,
120) -> Result<()> {
121 if force {
122 return Ok(());
123 }
124
125 let session_id = session
131 .filter(|s| !s.is_empty())
132 .ok_or_else(|| anyhow::anyhow!(
133 "phase enforcement requires a session id; phase is per-session in v2"
134 ))?;
135
136 let phase = current_phase_name(store, session_id)?
137 .unwrap_or_else(|| Phase::Orient.as_str().to_string());
138
139 let violation = match phase.as_str() {
140 "orient" => Some("no writes allowed in ORIENT phase"),
141 "plan" => {
142 if top_cmd == "task" && matches!(sub_cmd, "claim" | "done" | "block") {
143 Some("cannot claim/complete tasks in PLAN phase")
144 } else {
145 None
146 }
147 }
148 "agree" => Some("no operations in AGREE phase -- present plan and wait for human approval"),
149 "execute" => {
150 if top_cmd == "task" && matches!(sub_cmd, "add" | "cancel") {
151 Some("cannot add/cancel tasks in EXECUTE phase -- transition to REPLAN")
152 } else if top_cmd == "spec" && sub_cmd == "add" {
153 Some("cannot add specs in EXECUTE phase -- transition to REPLAN")
154 } else {
155 None
156 }
157 }
158 "reflect" => {
159 if top_cmd == "task" && sub_cmd == "claim" {
160 Some("cannot claim tasks in REFLECT phase")
161 } else {
162 None
163 }
164 }
165 "replan" => {
166 if top_cmd == "task" && sub_cmd == "claim" {
167 Some("cannot claim tasks in REPLAN phase")
168 } else {
169 None
170 }
171 }
172 "report" => Some("no writes allowed in REPORT phase"),
173 _ => None,
174 };
175
176 if let Some(msg) = violation {
177 bail!("phase violation ({phase}): {msg}");
178 }
179 Ok(())
180}
181
182pub fn current_phase_name(store: &Store, session_id: &str) -> Result<Option<String>> {
185 let rows = store.query(
186 "SELECT json_extract(props, '$.name') AS name \
187 FROM claims \
188 WHERE claim_type = 'phase' \
189 AND json_extract(props, '$.session_id') = ?1 \
190 ORDER BY asserted_at DESC LIMIT 1",
191 &[&session_id],
192 )?;
193 Ok(rows
194 .into_iter()
195 .next()
196 .and_then(|r| r.get("name").cloned())
197 .and_then(|v| v.as_str().map(String::from)))
198}
199
200pub fn current_phase_claim(store: &Store, session_id: &str) -> Result<Option<(String, String)>> {
204 let rows = store.query(
205 "SELECT id, props FROM claims \
206 WHERE claim_type = 'phase' \
207 AND json_extract(props, '$.session_id') = ?1 \
208 ORDER BY asserted_at DESC LIMIT 1",
209 &[&session_id],
210 )?;
211 let row = match rows.into_iter().next() {
212 Some(r) => r,
213 None => return Ok(None),
214 };
215 let claim_id = row
216 .get("id")
217 .and_then(|v| v.as_str())
218 .ok_or_else(|| anyhow::anyhow!("phase row missing id"))?
219 .to_string();
220 let props_str = row
221 .get("props")
222 .and_then(|v| v.as_str())
223 .ok_or_else(|| anyhow::anyhow!("phase row missing props"))?;
224 let props: Value = serde_json::from_str(props_str)?;
225 let name = props
226 .get("name")
227 .and_then(|v| v.as_str())
228 .ok_or_else(|| anyhow::anyhow!("phase claim missing name"))?
229 .to_string();
230 Ok(Some((claim_id, name)))
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn phase_transitions_cover_the_state_machine() {
239 assert!(Phase::Orient.can_transition_to(Phase::Plan));
240 assert!(!Phase::Orient.can_transition_to(Phase::Execute));
241 assert!(Phase::Plan.can_transition_to(Phase::Agree));
242 assert!(Phase::Agree.can_transition_to(Phase::Execute));
243 assert!(Phase::Execute.can_transition_to(Phase::Reflect));
244 assert!(Phase::Execute.can_transition_to(Phase::Report));
245 assert!(!Phase::Execute.can_transition_to(Phase::Plan));
246 assert!(Phase::Reflect.can_transition_to(Phase::Execute));
247 assert!(Phase::Reflect.can_transition_to(Phase::Replan));
248 assert!(Phase::Replan.can_transition_to(Phase::Agree));
249 assert!(!Phase::Report.can_transition_to(Phase::Plan));
250 }
251
252 #[test]
253 fn phase_from_str_roundtrips() {
254 for name in [
255 "orient", "plan", "agree", "execute", "reflect", "replan", "report",
256 ] {
257 assert_eq!(Phase::from_str(name).unwrap().as_str(), name);
258 }
259 assert!(Phase::from_str("bogus").is_none());
260 }
261}