use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::tui::stage::Stage;
use super::demand::Demand;
use super::state::{self, EngineState, EngineStatus};
use super::{queue, write_atomic};
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct StageRequest {
pub slug: String,
pub stage: Stage,
pub title: String,
pub description: String,
pub artifact_dir: PathBuf,
}
#[derive(Clone, Debug)]
pub struct StageArtifact {
#[allow(dead_code)]
pub stage: Stage,
pub markdown: String,
}
#[allow(dead_code)]
#[derive(Debug)]
pub enum RunnerError {
NotHeadless,
Failed(String),
}
impl std::fmt::Display for RunnerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotHeadless => write!(f, "provider sem modo headless"),
Self::Failed(msg) => write!(f, "falha na geração: {msg}"),
}
}
}
impl std::error::Error for RunnerError {}
pub trait StageRunner {
fn run(&self, req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError>;
}
pub struct FakeRunner;
impl StageRunner for FakeRunner {
fn run(&self, req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError> {
let markdown = format!(
"# {} - {}\n\n(conteúdo gerado por FakeRunner para a demanda)\n",
req.stage.label(),
req.title
);
Ok(StageArtifact {
stage: req.stage,
markdown,
})
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TickReport {
pub produced: Vec<(String, String)>,
pub paused: Vec<String>,
pub ready: Vec<String>,
pub skipped: Vec<String>,
pub errors: Vec<String>,
}
fn is_gate(stage: Stage) -> bool {
matches!(stage, Stage::Prd | Stage::Techspec | Stage::Refinement)
}
pub(crate) fn is_planning(stage: Stage) -> bool {
matches!(
stage,
Stage::Idea | Stage::Prd | Stage::Techspec | Stage::Tasks | Stage::Refinement
)
}
pub(crate) fn next_stage(stage: Stage) -> Option<Stage> {
let all = Stage::ALL;
let idx = all.iter().position(|s| *s == stage)?;
all.get(idx + 1).copied()
}
pub fn tick(root: &Path, runner: &dyn StageRunner, owner: &str, now: &str) -> Result<TickReport> {
let mut report = TickReport::default();
for demand in queue::list(root)? {
let existing = state::load(root, &demand.slug)?;
let active = match &existing {
None => true,
Some(st) => st.status == EngineStatus::Orchestrating,
};
if !active {
report.skipped.push(demand.slug.clone());
continue;
}
if let Err(err) = step(root, runner, &demand, existing, owner, now, &mut report) {
report.errors.push(format!("{}: {err:#}", demand.slug));
}
}
Ok(report)
}
pub fn run_until_idle(
root: &Path,
runner: &dyn StageRunner,
owner: &str,
now: &str,
max_iterations: u32,
) -> Result<Vec<TickReport>> {
let mut reports = Vec::new();
for _ in 0..max_iterations {
let report = tick(root, runner, owner, now)?;
let progressed = !report.produced.is_empty();
reports.push(report);
if !progressed {
break;
}
}
Ok(reports)
}
fn step(
root: &Path,
runner: &dyn StageRunner,
demand: &Demand,
existing: Option<EngineState>,
owner: &str,
now: &str,
report: &mut TickReport,
) -> Result<()> {
let mut st = match existing {
Some(st) => st,
None => {
let mut s = EngineState::new(&demand.slug)?;
s.status = EngineStatus::Orchestrating;
s
}
};
st.last_tick = Some(now.to_string());
if !state::acquire_lock(root, &mut st, owner, now)? {
report.skipped.push(demand.slug.clone());
return Ok(());
}
let dest = crate::artifact_dir(root, &demand.title);
std::fs::create_dir_all(&dest)?;
let index = crate::ensure_index(&dest, &demand.title)?;
let Some(stage) = Stage::from_key(&st.cursor) else {
st.status = EngineStatus::Error;
state::release_lock(root, &mut st)?;
report
.errors
.push(format!("{}: cursor inválido '{}'", demand.slug, st.cursor));
return Ok(());
};
let request = StageRequest {
slug: demand.slug.clone(),
stage,
title: demand.title.clone(),
description: demand.description.clone(),
artifact_dir: dest.clone(),
};
match runner.run(&request) {
Ok(artifact) => {
let file = crate::stage_file(stage.key()).expect("stage canônica tem arquivo");
write_atomic(&dest.join(file), artifact.markdown.as_bytes())?;
crate::update_stage_state(&index, stage.key(), "recorded")?;
report
.produced
.push((demand.slug.clone(), stage.key().to_string()));
if is_gate(stage) {
st.status = EngineStatus::AwaitingApproval;
report.paused.push(demand.slug.clone());
} else {
match next_stage(stage) {
Some(next) if is_planning(next) => {
st.cursor = next.key().to_string();
st.status = EngineStatus::Orchestrating;
}
_ => {
st.status = EngineStatus::ReadyForExec;
report.ready.push(demand.slug.clone());
}
}
}
}
Err(RunnerError::NotHeadless) => {
st.status = EngineStatus::Manual(stage.key().to_string());
report.skipped.push(demand.slug.clone());
}
Err(RunnerError::Failed(msg)) => {
st.status = EngineStatus::Error;
report.errors.push(format!("{}: {msg}", demand.slug));
}
}
state::release_lock(root, &mut st)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};
struct FailingRunner;
impl StageRunner for FailingRunner {
fn run(&self, _req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError> {
Err(RunnerError::NotHeadless)
}
}
fn enqueue_demand(root: &Path, id: &str, title: &str) -> Demand {
let d = Demand::new(
id,
DemandType::Story,
title,
"desc",
DemandSource::Cli,
None,
"2026-06-08T00:00:00Z",
)
.unwrap();
queue::enqueue(root, &d).unwrap();
d
}
#[test]
fn first_tick_ingests_and_produces_idea() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
let report = tick(root, &FakeRunner, "tick-1", "2026-06-08T00:00:00Z").unwrap();
assert_eq!(report.produced, vec![(d.slug.clone(), "idea".to_string())]);
assert!(report.paused.is_empty());
let dest = root.join("docs").join(&d.slug);
assert!(dest.join("traceability-map.yaml").is_file());
assert!(dest.join("01-idea.md").is_file());
assert!(!dest.join("02-prd.md").exists());
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.cursor, "prd");
assert!(st.lock.is_none(), "lock deve ser liberado ao fim do tick");
let map = std::fs::read_to_string(dest.join("traceability-map.yaml")).unwrap();
let idea_block = map.split("idea:").nth(1).unwrap();
assert!(idea_block.contains("state: recorded"));
}
#[test]
fn second_tick_advances_to_prd_and_pauses_at_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
tick(root, &FakeRunner, "tick-1", "t0").unwrap();
let report = tick(root, &FakeRunner, "tick-1", "t1").unwrap();
assert_eq!(report.produced, vec![(d.slug.clone(), "prd".to_string())]);
assert_eq!(report.paused, vec![d.slug.clone()]);
let dest = root.join("docs").join(&d.slug);
assert!(dest.join("02-prd.md").is_file());
assert!(
!dest.join("03-techspec.md").exists(),
"não deve passar do gate"
);
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::AwaitingApproval);
assert_eq!(st.cursor, "prd", "cursor permanece no gate até aprovação");
}
#[test]
fn awaiting_demand_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
tick(root, &FakeRunner, "tick-1", "t0").unwrap(); tick(root, &FakeRunner, "tick-1", "t1").unwrap();
let report = tick(root, &FakeRunner, "tick-1", "t2").unwrap();
assert_eq!(report.skipped, vec![d.slug.clone()]);
assert!(report.produced.is_empty());
assert!(!root
.join("docs")
.join(&d.slug)
.join("03-techspec.md")
.exists());
}
#[test]
fn runner_not_headless_marks_manual() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
let report = tick(root, &FailingRunner, "tick-1", "t0").unwrap();
assert_eq!(report.skipped, vec![d.slug.clone()]);
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Manual("idea".to_string()));
assert!(!root.join("docs").join(&d.slug).join("01-idea.md").exists());
}
#[test]
fn empty_queue_is_noop() {
let dir = tempfile::tempdir().unwrap();
let report = tick(dir.path(), &FakeRunner, "tick-1", "t0").unwrap();
assert_eq!(report, TickReport::default());
}
#[test]
fn run_until_idle_drives_to_first_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
let reports = run_until_idle(root, &FakeRunner, "tick", "t0", 50).unwrap();
let produced: Vec<String> = reports
.iter()
.flat_map(|r| r.produced.iter().map(|(_, s)| s.clone()))
.collect();
assert_eq!(produced, vec!["idea".to_string(), "prd".to_string()]);
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::AwaitingApproval);
assert_eq!(st.cursor, "prd");
}
}