Skip to main content

devflow_core/
state.rs

1//! DevFlow state machine.
2//!
3//! Drives the development workflow through a single linear chain of five stages:
4//! Define → Plan → Code → Validate → Ship. See [`crate::stage::Stage`].
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::mode::Mode;
13use crate::stage::Stage;
14
15/// Full workflow state persisted to `.devflow/state.json`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct State {
18    /// Current workflow stage.
19    pub stage: Stage,
20    /// Phase number being worked on.
21    pub phase: u32,
22    /// Which coding agent was launched.
23    pub agent: AgentKind,
24    /// How the pipeline is driven (auto vs. supervise).
25    pub mode: Mode,
26    /// Whether a gate has been written and is awaiting a human response.
27    #[serde(default)]
28    pub gate_pending: bool,
29    /// Consecutive Validate failures — drives the Auto-mode forced gate after
30    /// [`crate::mode::MAX_CONSECUTIVE_FAILURES`] failures. Persisted across
31    /// `devflow advance` invocations so the counter survives monitor restarts.
32    #[serde(default)]
33    pub consecutive_failures: u32,
34    /// Consecutive infrastructure-class faults (`ResourceKilled`,
35    /// `AgentUnavailable`) — distinct from [`Self::consecutive_failures`]
36    /// (D-08, 17-01). Gates at [`crate::mode::MAX_INFRA_FAILURES`]. Any
37    /// increment (wired in Plan 04) must use `saturating_add` so a
38    /// long-running stuck loop cannot overflow `u32`. A serde-absent value
39    /// (older persisted state) defaults to 0. Reset to 0 on every successful
40    /// stage transition, alongside `consecutive_failures` (CR-01, 17-06 gap
41    /// closure), so the ceiling bounds a stuck loop, not a phase's lifetime.
42    #[serde(default)]
43    pub infra_failures: u32,
44    /// When the phase started (Unix seconds).
45    pub started_at: String,
46    /// Path to the project root.
47    pub project_root: PathBuf,
48    /// Working directory for the agent when running in a git worktree.
49    ///
50    /// `None` means the agent runs in `project_root`. State and capture files
51    /// always live under the main `project_root`; only the agent's cwd changes.
52    #[serde(default)]
53    pub worktree_path: Option<PathBuf>,
54}
55
56/// Supported coding agents.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum AgentKind {
60    /// Anthropic Claude Code CLI.
61    Claude,
62    /// OpenAI Codex CLI.
63    Codex,
64    /// OpenCode CLI.
65    OpenCode,
66}
67
68impl fmt::Display for AgentKind {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        let name = match self {
71            AgentKind::Claude => "claude",
72            AgentKind::Codex => "codex",
73            AgentKind::OpenCode => "opencode",
74        };
75        f.write_str(name)
76    }
77}
78
79impl FromStr for AgentKind {
80    type Err = AgentParseError;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        match value.to_ascii_lowercase().as_str() {
84            "claude" => Ok(AgentKind::Claude),
85            "codex" => Ok(AgentKind::Codex),
86            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
87            other => Err(AgentParseError(other.to_string())),
88        }
89    }
90}
91
92/// Error returned when parsing an unsupported agent name.
93#[derive(Debug, Clone, thiserror::Error)]
94#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
95pub struct AgentParseError(String);
96
97impl State {
98    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
99    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
100        State {
101            stage: Stage::Define,
102            phase,
103            agent,
104            mode,
105            gate_pending: false,
106            consecutive_failures: 0,
107            infra_failures: 0,
108            started_at: timestamp_now(),
109            project_root,
110            worktree_path: None,
111        }
112    }
113}
114
115fn timestamp_now() -> String {
116    match SystemTime::now().duration_since(UNIX_EPOCH) {
117        Ok(duration) => format!("{}", duration.as_secs()),
118        Err(_) => String::from("0"),
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::path::PathBuf;
126
127    #[test]
128    fn agent_name_and_display() {
129        use crate::agents::adapter_for;
130        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
131        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
132        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
133
134        assert_eq!(AgentKind::Claude.to_string(), "claude");
135        assert_eq!(AgentKind::Codex.to_string(), "codex");
136        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
137    }
138
139    #[test]
140    fn agent_from_str_accepts_canonical_and_aliases() {
141        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
142        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
143        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
144        assert_eq!(
145            "opencode".parse::<AgentKind>().unwrap(),
146            AgentKind::OpenCode
147        );
148        assert_eq!(
149            "open-code".parse::<AgentKind>().unwrap(),
150            AgentKind::OpenCode
151        );
152    }
153
154    #[test]
155    fn agent_from_str_rejects_unknown() {
156        let err = "aider".parse::<AgentKind>().unwrap_err();
157        assert!(err.to_string().contains("aider"));
158    }
159
160    #[test]
161    fn new_state_starts_at_define() {
162        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
163        assert_eq!(state.stage, Stage::Define);
164        assert_eq!(state.phase, 2);
165        assert_eq!(state.agent, AgentKind::Claude);
166        assert_eq!(state.mode, Mode::Auto);
167        assert!(!state.gate_pending);
168        assert_eq!(state.consecutive_failures, 0);
169        assert_eq!(state.infra_failures, 0);
170        assert!(!state.started_at.is_empty());
171    }
172
173    #[test]
174    fn state_serde_round_trips() {
175        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
176        let json = serde_json::to_string(&state).unwrap();
177        let back: State = serde_json::from_str(&json).unwrap();
178        assert_eq!(back.phase, 9);
179        assert_eq!(back.agent, AgentKind::Codex);
180        assert_eq!(back.stage, Stage::Define);
181        assert_eq!(back.mode, Mode::Supervise);
182    }
183
184    #[test]
185    fn consecutive_failures_persists_across_advance_calls() {
186        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
187        state.consecutive_failures = 3;
188        let json = serde_json::to_string(&state).unwrap();
189        assert!(
190            json.contains("consecutive_failures"),
191            "consecutive_failures must appear in persisted JSON"
192        );
193        let loaded: State = serde_json::from_str(&json).unwrap();
194        assert_eq!(
195            loaded.consecutive_failures, 3,
196            "consecutive_failures must round-trip through serde"
197        );
198    }
199
200    /// D-08 (17-01): a distinct infra-failure counter round-trips through
201    /// serde and its own key appears in the persisted JSON.
202    #[test]
203    fn infra_failures_round_trips_through_serde() {
204        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
205        state.infra_failures = 4;
206        let json = serde_json::to_string(&state).unwrap();
207        assert!(
208            json.contains("infra_failures"),
209            "infra_failures must appear in persisted JSON"
210        );
211        let loaded: State = serde_json::from_str(&json).unwrap();
212        assert_eq!(
213            loaded.infra_failures, 4,
214            "infra_failures must round-trip through serde"
215        );
216    }
217
218    /// A serde-absent `infra_failures` (older persisted state.json without
219    /// the field) must default to 0, not fail to deserialize.
220    #[test]
221    fn infra_failures_absent_from_json_defaults_to_zero() {
222        let json = r#"{
223            "stage": "code",
224            "phase": 1,
225            "agent": "claude",
226            "mode": "auto",
227            "started_at": "0",
228            "project_root": "/repo"
229        }"#;
230        let loaded: State = serde_json::from_str(json).unwrap();
231        assert_eq!(loaded.infra_failures, 0);
232    }
233}