nomograph_workflow/
phase.rs1use anyhow::{Result, bail};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::store::Store;
19
20pub const GLOBAL_SESSION_ID: &str = "global-migrated";
24
25#[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 #[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 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 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
104pub 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
181pub 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
199pub 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}