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    /// When the phase started (Unix seconds).
35    pub started_at: String,
36    /// Path to the project root.
37    pub project_root: PathBuf,
38    /// Working directory for the agent when running in a git worktree.
39    ///
40    /// `None` means the agent runs in `project_root`. State and capture files
41    /// always live under the main `project_root`; only the agent's cwd changes.
42    #[serde(default)]
43    pub worktree_path: Option<PathBuf>,
44}
45
46/// Supported coding agents.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum AgentKind {
50    /// Anthropic Claude Code CLI.
51    Claude,
52    /// OpenAI Codex CLI.
53    Codex,
54    /// OpenCode CLI.
55    OpenCode,
56}
57
58impl fmt::Display for AgentKind {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        let name = match self {
61            AgentKind::Claude => "claude",
62            AgentKind::Codex => "codex",
63            AgentKind::OpenCode => "opencode",
64        };
65        f.write_str(name)
66    }
67}
68
69impl FromStr for AgentKind {
70    type Err = AgentParseError;
71
72    fn from_str(value: &str) -> Result<Self, Self::Err> {
73        match value.to_ascii_lowercase().as_str() {
74            "claude" => Ok(AgentKind::Claude),
75            "codex" => Ok(AgentKind::Codex),
76            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
77            other => Err(AgentParseError(other.to_string())),
78        }
79    }
80}
81
82/// Error returned when parsing an unsupported agent name.
83#[derive(Debug, Clone, thiserror::Error)]
84#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
85pub struct AgentParseError(String);
86
87impl State {
88    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
89    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
90        State {
91            stage: Stage::Define,
92            phase,
93            agent,
94            mode,
95            gate_pending: false,
96            consecutive_failures: 0,
97            started_at: timestamp_now(),
98            project_root,
99            worktree_path: None,
100        }
101    }
102}
103
104fn timestamp_now() -> String {
105    match SystemTime::now().duration_since(UNIX_EPOCH) {
106        Ok(duration) => format!("{}", duration.as_secs()),
107        Err(_) => String::from("0"),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::path::PathBuf;
115
116    #[test]
117    fn agent_name_and_display() {
118        use crate::agents::adapter_for;
119        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
120        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
121        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
122
123        assert_eq!(AgentKind::Claude.to_string(), "claude");
124        assert_eq!(AgentKind::Codex.to_string(), "codex");
125        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
126    }
127
128    #[test]
129    fn agent_from_str_accepts_canonical_and_aliases() {
130        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
131        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
132        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
133        assert_eq!(
134            "opencode".parse::<AgentKind>().unwrap(),
135            AgentKind::OpenCode
136        );
137        assert_eq!(
138            "open-code".parse::<AgentKind>().unwrap(),
139            AgentKind::OpenCode
140        );
141    }
142
143    #[test]
144    fn agent_from_str_rejects_unknown() {
145        let err = "aider".parse::<AgentKind>().unwrap_err();
146        assert!(err.to_string().contains("aider"));
147    }
148
149    #[test]
150    fn new_state_starts_at_define() {
151        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
152        assert_eq!(state.stage, Stage::Define);
153        assert_eq!(state.phase, 2);
154        assert_eq!(state.agent, AgentKind::Claude);
155        assert_eq!(state.mode, Mode::Auto);
156        assert!(!state.gate_pending);
157        assert_eq!(state.consecutive_failures, 0);
158        assert!(!state.started_at.is_empty());
159    }
160
161    #[test]
162    fn state_serde_round_trips() {
163        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
164        let json = serde_json::to_string(&state).unwrap();
165        let back: State = serde_json::from_str(&json).unwrap();
166        assert_eq!(back.phase, 9);
167        assert_eq!(back.agent, AgentKind::Codex);
168        assert_eq!(back.stage, Stage::Define);
169        assert_eq!(back.mode, Mode::Supervise);
170    }
171
172    #[test]
173    fn consecutive_failures_persists_across_advance_calls() {
174        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
175        state.consecutive_failures = 3;
176        let json = serde_json::to_string(&state).unwrap();
177        assert!(
178            json.contains("consecutive_failures"),
179            "consecutive_failures must appear in persisted JSON"
180        );
181        let loaded: State = serde_json::from_str(&json).unwrap();
182        assert_eq!(
183            loaded.consecutive_failures, 3,
184            "consecutive_failures must round-trip through serde"
185        );
186    }
187}