use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::store::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Orient,
Plan,
Agree,
Execute,
Reflect,
Replan,
Report,
}
impl Phase {
pub fn as_str(&self) -> &'static str {
match self {
Self::Orient => "orient",
Self::Plan => "plan",
Self::Agree => "agree",
Self::Execute => "execute",
Self::Reflect => "reflect",
Self::Replan => "replan",
Self::Report => "report",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"orient" => Some(Self::Orient),
"plan" => Some(Self::Plan),
"agree" => Some(Self::Agree),
"execute" => Some(Self::Execute),
"reflect" => Some(Self::Reflect),
"replan" => Some(Self::Replan),
"report" => Some(Self::Report),
_ => None,
}
}
pub fn valid_transitions(&self) -> &'static [Phase] {
match self {
Self::Orient => &[Self::Plan],
Self::Plan => &[Self::Agree],
Self::Agree => &[Self::Execute],
Self::Execute => &[Self::Reflect, Self::Report],
Self::Reflect => &[Self::Execute, Self::Replan, Self::Report],
Self::Replan => &[Self::Agree],
Self::Report => &[],
}
}
pub fn can_transition_to(&self, target: Phase) -> bool {
self.valid_transitions().contains(&target)
}
}
impl std::fmt::Display for Phase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub fn check_phase(
store: &Store,
session: Option<&str>,
top_cmd: &str,
sub_cmd: &str,
force: bool,
) -> Result<()> {
if force {
return Ok(());
}
let session_id = session
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!(
"phase enforcement requires a session id; phase is per-session in v2"
))?;
let phase = current_phase_name(store, session_id)?
.unwrap_or_else(|| Phase::Orient.as_str().to_string());
let violation = match phase.as_str() {
"orient" => Some("no writes allowed in ORIENT phase"),
"plan" => {
if top_cmd == "task" && matches!(sub_cmd, "claim" | "done" | "block") {
Some("cannot claim/complete tasks in PLAN phase")
} else {
None
}
}
"agree" => Some("no operations in AGREE phase -- present plan and wait for human approval"),
"execute" => {
if top_cmd == "task" && matches!(sub_cmd, "add" | "cancel") {
Some("cannot add/cancel tasks in EXECUTE phase -- transition to REPLAN")
} else if top_cmd == "spec" && sub_cmd == "add" {
Some("cannot add specs in EXECUTE phase -- transition to REPLAN")
} else {
None
}
}
"reflect" => {
if top_cmd == "task" && sub_cmd == "claim" {
Some("cannot claim tasks in REFLECT phase")
} else {
None
}
}
"replan" => {
if top_cmd == "task" && sub_cmd == "claim" {
Some("cannot claim tasks in REPLAN phase")
} else {
None
}
}
"report" => Some("no writes allowed in REPORT phase"),
_ => None,
};
if let Some(msg) = violation {
bail!("phase violation ({phase}): {msg}");
}
Ok(())
}
pub fn current_phase_name(store: &Store, session_id: &str) -> Result<Option<String>> {
let rows = store.query(
"SELECT json_extract(props, '$.name') AS name \
FROM claims \
WHERE claim_type = 'phase' \
AND json_extract(props, '$.session_id') = ?1 \
ORDER BY asserted_at DESC LIMIT 1",
&[&session_id],
)?;
Ok(rows
.into_iter()
.next()
.and_then(|r| r.get("name").cloned())
.and_then(|v| v.as_str().map(String::from)))
}
pub fn current_phase_claim(store: &Store, session_id: &str) -> Result<Option<(String, String)>> {
let rows = store.query(
"SELECT id, props FROM claims \
WHERE claim_type = 'phase' \
AND json_extract(props, '$.session_id') = ?1 \
ORDER BY asserted_at DESC LIMIT 1",
&[&session_id],
)?;
let row = match rows.into_iter().next() {
Some(r) => r,
None => return Ok(None),
};
let claim_id = row
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("phase row missing id"))?
.to_string();
let props_str = row
.get("props")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("phase row missing props"))?;
let props: Value = serde_json::from_str(props_str)?;
let name = props
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("phase claim missing name"))?
.to_string();
Ok(Some((claim_id, name)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase_transitions_cover_the_state_machine() {
assert!(Phase::Orient.can_transition_to(Phase::Plan));
assert!(!Phase::Orient.can_transition_to(Phase::Execute));
assert!(Phase::Plan.can_transition_to(Phase::Agree));
assert!(Phase::Agree.can_transition_to(Phase::Execute));
assert!(Phase::Execute.can_transition_to(Phase::Reflect));
assert!(Phase::Execute.can_transition_to(Phase::Report));
assert!(!Phase::Execute.can_transition_to(Phase::Plan));
assert!(Phase::Reflect.can_transition_to(Phase::Execute));
assert!(Phase::Reflect.can_transition_to(Phase::Replan));
assert!(Phase::Replan.can_transition_to(Phase::Agree));
assert!(!Phase::Report.can_transition_to(Phase::Plan));
}
#[test]
fn phase_from_str_roundtrips() {
for name in [
"orient", "plan", "agree", "execute", "reflect", "replan", "report",
] {
assert_eq!(Phase::from_str(name).unwrap().as_str(), name);
}
assert!(Phase::from_str("bogus").is_none());
}
}