use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use super::demand::validate_slug;
pub const STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EngineStatus {
Queued,
Orchestrating,
AwaitingApproval,
ReadyForExec,
Completed,
Paused,
Error,
Manual(String),
ReadyForTesting,
}
impl EngineStatus {
pub fn to_repr(&self) -> String {
match self {
Self::Queued => "queued".to_string(),
Self::Orchestrating => "orchestrating".to_string(),
Self::AwaitingApproval => "awaiting_approval".to_string(),
Self::ReadyForExec => "ready_for_exec".to_string(),
Self::Completed => "completed".to_string(),
Self::Paused => "paused".to_string(),
Self::Error => "error".to_string(),
Self::Manual(stage) => format!("manual:{stage}"),
Self::ReadyForTesting => "ready_for_testing".to_string(),
}
}
pub fn from_repr(value: &str) -> std::result::Result<Self, String> {
if let Some(stage) = value.strip_prefix("manual:") {
if stage.is_empty() {
return Err("status manual sem etapa".to_string());
}
return Ok(Self::Manual(stage.to_string()));
}
match value {
"queued" => Ok(Self::Queued),
"orchestrating" => Ok(Self::Orchestrating),
"awaiting_approval" => Ok(Self::AwaitingApproval),
"ready_for_exec" => Ok(Self::ReadyForExec),
"completed" => Ok(Self::Completed),
"paused" => Ok(Self::Paused),
"error" => Ok(Self::Error),
"ready_for_testing" => Ok(Self::ReadyForTesting),
other => Err(format!("status de motor inválido: {other}")),
}
}
}
impl From<EngineStatus> for String {
fn from(value: EngineStatus) -> Self {
value.to_repr()
}
}
impl TryFrom<String> for EngineStatus {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, String> {
EngineStatus::from_repr(&value)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecisionKind {
Approve,
Reject,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
Cli,
Slack,
Automation,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Approval {
pub slug: String,
pub gate: String,
pub decision: DecisionKind,
pub author: String,
pub channel: Channel,
pub ts: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskExecutionState {
pub status: String,
#[serde(default)]
pub attempts: u32,
#[serde(default)]
pub evidence: Vec<String>,
#[serde(default)]
pub tests: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lock {
pub owner: String,
pub acquired_at: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrchestrationMode {
Card,
#[default]
FreeText,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CardMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub space_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confluence_parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due_date: Option<String>,
#[serde(default)]
pub transitions: BTreeMap<String, String>,
#[serde(default)]
pub columns: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineState {
pub schema_version: u32,
pub slug: String,
pub cursor: String,
#[serde(with = "engine_status_serde")]
pub status: EngineStatus,
#[serde(default)]
pub mode: OrchestrationMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch_raiz: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub card_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub card: Option<CardMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_worktrees: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_obligations: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_column: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lock: Option<Lock>,
#[serde(default)]
pub approvals: Vec<Approval>,
#[serde(default)]
pub attempts: BTreeMap<String, u32>,
#[serde(default)]
pub tasks: BTreeMap<String, TaskExecutionState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_tick: Option<String>,
}
mod engine_status_serde {
use super::EngineStatus;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(value: &EngineStatus, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&value.to_repr())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<EngineStatus, D::Error> {
let raw = String::deserialize(d)?;
EngineStatus::from_repr(&raw).map_err(serde::de::Error::custom)
}
}
impl EngineState {
pub fn new(slug: impl Into<String>) -> Result<Self> {
let slug = slug.into();
validate_slug(&slug)?;
Ok(Self {
schema_version: STATE_SCHEMA_VERSION,
slug,
cursor: "idea".to_string(),
status: EngineStatus::Queued,
mode: OrchestrationMode::default(),
branch_raiz: None,
card_branch: None,
card: None,
use_worktrees: None,
pending_obligations: Vec::new(),
pending_column: None,
lock: None,
approvals: Vec::new(),
attempts: BTreeMap::new(),
tasks: BTreeMap::new(),
last_error: None,
last_tick: None,
})
}
}
pub fn state_dir(root: &Path) -> PathBuf {
root.join(".sdd").join("state")
}
fn state_path(root: &Path, slug: &str) -> Result<PathBuf> {
validate_slug(slug)?;
Ok(state_dir(root).join(format!("{slug}.json")))
}
pub fn save(root: &Path, state: &EngineState) -> Result<()> {
if state.schema_version != STATE_SCHEMA_VERSION {
bail!(
"schema_version de estado não suportado ao salvar: {} (esperado {})",
state.schema_version,
STATE_SCHEMA_VERSION
);
}
let path = state_path(root, &state.slug)?;
let body = serde_json::to_string_pretty(state).context("serializando estado do motor")?;
super::write_atomic(&path, body.as_bytes())
}
#[allow(dead_code)]
pub fn list_states(root: &Path) -> Result<Vec<EngineState>> {
let dir = state_dir(root);
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
return Ok(out);
};
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
.collect();
paths.sort();
for path in paths {
let text = fs_read(&path)?;
let state: EngineState = serde_json::from_str(&text)
.with_context(|| format!("desserializando estado {}", path.display()))?;
if state.schema_version != STATE_SCHEMA_VERSION {
continue; }
out.push(state);
}
Ok(out)
}
pub fn load(root: &Path, slug: &str) -> Result<Option<EngineState>> {
let path = state_path(root, slug)?;
if !path.exists() {
return Ok(None);
}
let text = fs_read(&path)?;
let state: EngineState = serde_json::from_str(&text)
.with_context(|| format!("desserializando estado {}", path.display()))?;
if state.schema_version != STATE_SCHEMA_VERSION {
bail!(
"schema_version de estado não suportado: {} (esperado {})",
state.schema_version,
STATE_SCHEMA_VERSION
);
}
Ok(Some(state))
}
fn fs_read(path: &Path) -> Result<String> {
std::fs::read_to_string(path).with_context(|| format!("lendo estado {}", path.display()))
}
pub fn acquire_lock(
root: &Path,
state: &mut EngineState,
owner: impl Into<String>,
now: impl Into<String>,
) -> Result<bool> {
let owner = owner.into();
if let Some(lock) = &state.lock {
if lock.owner != owner {
return Ok(false);
}
}
state.lock = Some(Lock {
owner,
acquired_at: now.into(),
});
save(root, state)?;
Ok(true)
}
pub fn release_lock(root: &Path, state: &mut EngineState) -> Result<()> {
state.lock = None;
save(root, state)
}
#[allow(dead_code)]
pub fn artifact_state_for_status(status: &EngineStatus) -> &'static str {
match status {
EngineStatus::ReadyForExec | EngineStatus::Completed => "approved",
EngineStatus::Error => "recorded",
_ => "in_progress",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh(root: &Path) -> EngineState {
let mut st = EngineState::new("dem-1").unwrap();
st.last_tick = Some("2026-06-08T00:00:00Z".to_string());
save(root, &st).unwrap();
st
}
#[test]
fn new_state_defaults() {
let st = EngineState::new("dem-1").unwrap();
assert_eq!(st.schema_version, STATE_SCHEMA_VERSION);
assert_eq!(st.cursor, "idea");
assert_eq!(st.status, EngineStatus::Queued);
assert!(st.lock.is_none());
assert!(EngineState::new("../escape").is_err());
}
#[test]
fn save_and_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
st.status = EngineStatus::AwaitingApproval;
st.cursor = "prd".to_string();
st.approvals.push(Approval {
slug: "dem-1".to_string(),
gate: "prd".to_string(),
decision: DecisionKind::Approve,
author: "alan@x".to_string(),
channel: Channel::Cli,
ts: "2026-06-08T01:00:00Z".to_string(),
reason: None,
});
*st.attempts.entry("techspec".to_string()).or_insert(0) += 1;
save(root, &st).unwrap();
let back = load(root, "dem-1").unwrap().unwrap();
assert_eq!(st, back);
assert!(state_dir(root).join("dem-1.json").is_file());
}
#[test]
fn load_missing_is_none() {
let dir = tempfile::tempdir().unwrap();
assert!(load(dir.path(), "nope").unwrap().is_none());
}
#[test]
fn divergent_schema_version_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(state_dir(root)).unwrap();
let bad = r#"{ "schema_version": 99, "slug": "dem-1", "cursor": "idea",
"status": "queued", "approvals": [], "attempts": {} }"#;
std::fs::write(state_dir(root).join("dem-1.json"), bad).unwrap();
assert!(load(root, "dem-1")
.unwrap_err()
.to_string()
.contains("schema_version"));
}
#[test]
fn lock_is_exclusive_per_owner() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
assert!(acquire_lock(root, &mut st, "tick-1", "t0").unwrap());
let mut other = load(root, "dem-1").unwrap().unwrap();
assert!(!acquire_lock(root, &mut other, "tick-2", "t1").unwrap());
assert!(acquire_lock(root, &mut st, "tick-1", "t2").unwrap());
release_lock(root, &mut st).unwrap();
let mut third = load(root, "dem-1").unwrap().unwrap();
assert!(acquire_lock(root, &mut third, "tick-2", "t3").unwrap());
}
#[test]
fn status_repr_round_trip_including_manual() {
for s in [
EngineStatus::Queued,
EngineStatus::AwaitingApproval,
EngineStatus::ReadyForExec,
EngineStatus::Manual("techspec".to_string()),
] {
let repr = s.to_repr();
assert_eq!(EngineStatus::from_repr(&repr).unwrap(), s);
}
assert_eq!(
EngineStatus::Manual("techspec".to_string()).to_repr(),
"manual:techspec"
);
assert!(EngineStatus::from_repr("bogus").is_err());
}
#[test]
fn option_a_never_emits_rich_artifact_state() {
let safe = ["recorded", "in_progress", "approved", "skipped"];
for s in [
EngineStatus::Queued,
EngineStatus::Orchestrating,
EngineStatus::AwaitingApproval,
EngineStatus::ReadyForExec,
EngineStatus::Paused,
EngineStatus::Error,
EngineStatus::Manual("prd".to_string()),
] {
assert!(
safe.contains(&artifact_state_for_status(&s)),
"status {s:?} vazou estado rico para o map"
);
}
}
#[test]
fn legacy_state_without_mode_defaults_to_free_text() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(state_dir(root)).unwrap();
let legacy = r#"{ "schema_version": 1, "slug": "dem-1", "cursor": "idea",
"status": "queued", "approvals": [], "attempts": {} }"#;
std::fs::write(state_dir(root).join("dem-1.json"), legacy).unwrap();
let st = load(root, "dem-1").unwrap().unwrap();
assert_eq!(st.mode, OrchestrationMode::FreeText);
assert!(st.branch_raiz.is_none());
assert!(st.card.is_none());
}
#[test]
fn card_mode_and_meta_round_trip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
st.mode = OrchestrationMode::Card;
st.branch_raiz = Some("main".to_string());
let mut meta = CardMeta {
space_key: Some("ENG".to_string()),
confluence_parent_id: Some("12345".to_string()),
..Default::default()
};
meta.columns.insert("1".to_string(), "11".to_string());
meta.transitions
.insert("In Progress".to_string(), "11".to_string());
st.card = Some(meta);
save(root, &st).unwrap();
let back = load(root, "dem-1").unwrap().unwrap();
assert_eq!(st, back);
assert_eq!(back.mode, OrchestrationMode::Card);
assert_eq!(back.card.unwrap().columns.get("1").unwrap(), "11");
}
#[test]
fn use_worktrees_round_trip_and_legacy_default() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
assert!(st.use_worktrees.is_none());
st.use_worktrees = Some(false);
save(root, &st).unwrap();
let back = load(root, "dem-1").unwrap().unwrap();
assert_eq!(back.use_worktrees, Some(false));
let legacy = r#"{ "schema_version": 1, "slug": "dem-2", "cursor": "idea",
"status": "queued", "approvals": [], "attempts": {} }"#;
std::fs::write(state_dir(root).join("dem-2.json"), legacy).unwrap();
assert!(load(root, "dem-2")
.unwrap()
.unwrap()
.use_worktrees
.is_none());
}
#[test]
fn status_serializes_as_string_in_json() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st = fresh(root);
st.status = EngineStatus::Manual("techspec".to_string());
save(root, &st).unwrap();
let raw = std::fs::read_to_string(state_dir(root).join("dem-1.json")).unwrap();
assert!(raw.contains("\"status\": \"manual:techspec\""));
}
#[test]
fn list_states_returns_sorted_states() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut st1 = EngineState::new("alpha").unwrap();
st1.cursor = "prd".to_string();
save(root, &st1).unwrap();
let mut st2 = EngineState::new("beta").unwrap();
st2.cursor = "techspec".to_string();
save(root, &st2).unwrap();
let states = list_states(root).unwrap();
assert_eq!(states.len(), 2);
assert_eq!(states[0].slug, "alpha");
assert_eq!(states[1].slug, "beta");
}
#[test]
fn list_states_empty_when_no_state_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(list_states(dir.path()).unwrap().is_empty());
}
}