use std::path::Path;
use std::sync::Arc;
use speccoder_core::snapshot::{Diagnostic, ProjectSnapshot, Severity};
use speccoder_core::{CommandData, CommandResult, CommandStatus};
use crate::commands::*;
use crate::{approvals, progress, substrate, verify, worktree};
pub mod acceptance;
pub mod bisect;
pub mod bug;
pub mod commit;
pub mod feature;
pub mod full;
pub mod judge;
pub mod lanes;
pub mod outcome;
pub mod samples;
pub mod task;
pub(crate) use acceptance::*;
pub(crate) use bug::{reject as bug_reject, triad_now as bug_triad_now};
pub(crate) use commit::*;
pub use feature::*;
pub(crate) use judge::*;
pub(crate) use lanes::*;
pub(crate) use outcome::*;
pub(crate) use samples::*;
pub use task::*;
pub(crate) struct Setup {
pub(crate) snapshot: Box<speccoder_core::snapshot::ProjectSnapshot>,
pub(crate) model: substrate::model_config::ModelConfig,
pub(crate) plane_dir: std::path::PathBuf,
pub(crate) format: &'static dyn crate::format::Adapter,
}
pub(crate) fn setup(
path: &Path,
feature: Option<&str>,
state: &Path,
command: &str,
) -> Result<Setup, Box<CommandResult>> {
let inspected = inspect(path, feature);
let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
return Err(Box::new(CommandResult::carried(command, &inspected)));
};
let snapshot = snapshot.clone();
preflight(&snapshot, command)?;
let config_error =
|m: String| Box::new(CommandResult::new(command, CommandStatus::ConfigError, &m));
let root = Path::new(&snapshot.root);
let config_dir = verify::find_config(root).ok_or_else(|| {
config_error(format!(
"no speccoder.toml at or above {}; add [model] provider = \"anthropic\", model = \"…\"",
root.display()
))
})?;
let text = std::fs::read_to_string(config_dir.join(verify::config::FILE))
.map_err(|e| config_error(format!("speccoder.toml: {e}")))?;
let model = substrate::model_config::parse(&text)
.map_err(|e| config_error(format!("speccoder.toml: {e}")))?
.ok_or_else(|| config_error("speccoder.toml has no [model] section".to_owned()))?;
Ok(Setup {
format: crate::format::of(&snapshot),
plane_dir: worktree::project_state_dir(state, root),
snapshot,
model,
})
}
fn preflight(snapshot: &ProjectSnapshot, command: &str) -> Result<(), Box<CommandResult>> {
if !matches!(command, "run" | "propose") {
return Ok(());
}
let format = crate::format::of(snapshot);
let mut extra = Vec::new();
if command == "run" {
match crate::sandbox::runner_at(Path::new(&snapshot.root)) {
Ok((runner, _)) => extra = format.crosscheck(snapshot, &runner),
Err(e) => extra.push(Diagnostic::new(
Severity::Info,
"crosscheck_skipped",
format!("cross-check skipped: {e}"),
)),
}
}
let problems = format.blocking(snapshot).len()
+ extra
.iter()
.filter(|d| d.severity == Severity::Error)
.count();
if problems == 0 {
return Ok(());
}
let mut shown = snapshot.clone();
shown.diagnostics.extend(extra);
let name = shown
.feature
.as_ref()
.map(|f| f.feature.name.clone())
.unwrap_or_default();
Err(Box::new(
CommandResult::new(
command,
CommandStatus::DiagnosticsError,
&format!(
"{} {name} cannot run: {problems} problem(s)",
format.wording().unit
),
)
.with_data(CommandData::Inspect(Box::new(shown))),
))
}
pub(crate) fn tokio_runtime() -> Result<tokio::runtime::Runtime, String> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("cannot start the async runtime: {e}"))
}
pub(crate) fn redact(text: &str, secret: Option<&str>) -> String {
match secret {
Some(s) if !s.is_empty() => text.replace(s, "[redacted]"),
_ => text.to_owned(),
}
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(transparent)]
pub struct RunId(pub String);
impl RunId {
#[must_use]
pub fn fresh() -> Self {
use sha2::{Digest as _, Sha256};
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let digest = Sha256::digest(format!(
"{}/{}/{}/{n}",
now.as_nanos(),
std::process::id(),
std::thread::current().name().unwrap_or_default()
));
let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
Self(format!("run-{}-{hex}", now.as_secs()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for RunId {
fn default() -> Self {
Self::fresh()
}
}
impl std::fmt::Display for RunId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl RunOptions {
#[must_use]
pub fn run(&self) -> RunId {
self.run_id
.lock()
.map(|r| r.clone())
.unwrap_or_else(|_| RunId(String::new()))
}
#[must_use]
pub fn asked_now(&self) -> u32 {
self.asked.lock().map(|a| *a).unwrap_or(0)
}
}
#[derive(Debug, Clone, Default)]
pub struct RunOptions {
pub max_tasks: Option<usize>,
pub approver: Option<Arc<dyn approvals::Approver>>,
pub progress: Option<Arc<dyn progress::Progress>>,
pub parallel: usize,
pub lane: Option<String>,
pub cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
pub ignore_checklists: bool,
pub fresh_acceptance: bool,
pub allow_failing_baseline: bool,
pub run_grants: Arc<std::sync::Mutex<Vec<crate::tools::policy::Object>>>,
pub run_id: Arc<std::sync::Mutex<RunId>>,
pub asked: Arc<std::sync::Mutex<u32>>,
pub in_place: bool,
pub session: Option<Arc<std::sync::Mutex<crate::in_place::Session>>>,
pub bug_home: Option<Arc<bug::NativeBug>>,
pub mode: speccoder_core::oracle::RunMode,
}
pub(crate) fn workspace(
path: &Path,
feature: Option<&str>,
state: &Path,
options: &RunOptions,
) -> CommandResult {
let Some(session) = options
.session
.as_ref()
.and_then(|s| s.lock().ok().map(|s| s.clone()))
else {
if let Some(e) = crate::in_place::session_elsewhere(path, feature, state) {
return e.result("run");
}
return worktree_with(path, feature, state);
};
CommandResult::new("worktree", CommandStatus::Ok, "in place").with_data(CommandData::Worktree(
speccoder_core::WorktreeReport {
feature: session.feature.clone(),
path: Some(session.checkout.display().to_string()),
project_dir: Some(session.project.display().to_string()),
branch: Some(session.branch.clone()),
base: Some(session.tip.clone()),
created: false,
refused_paths: Vec::new(),
},
))
}
pub(crate) fn in_session(
command: &str,
path: &Path,
feature: Option<&str>,
state: &Path,
options: &RunOptions,
f: impl FnOnce(&RunOptions) -> CommandResult,
) -> CommandResult {
if !options.in_place || options.session.is_some() {
return f(options);
}
if options.parallel > 1 {
return crate::in_place::InPlaceError {
code: "IN-PLACE-PARALLEL",
message: "an in-place run has one checkout; it cannot run tasks in parallel".to_owned(),
paths: Vec::new(),
fix: vec!["speccoder run --in-place (without --parallel)".to_owned()],
}
.result(command);
}
let opened = match crate::format::open(path, feature) {
Ok(o) => o,
Err(r) => return CommandResult::new(command, r.status, &r.message),
};
let Some(f_name) = opened
.snapshot
.feature
.as_ref()
.map(|f| f.feature.name.clone())
else {
return CommandResult::new(
command,
CommandStatus::SelectionNeeded,
"no feature selected; pass --feature",
);
};
let root = std::path::PathBuf::from(&opened.snapshot.root);
let plane_dir = worktree::project_state_dir(state, &root);
let policy = crate::tools::policy::Policy::new(&[], &[]).rules(opened.adapter.rules());
let mut session = match crate::in_place::Session::begin(&root, &plane_dir, &f_name, &policy) {
Ok(s) => s,
Err(e) => return e.result(command),
};
let format = crate::format::at(&root);
if let Some(tasks) = format
.inspect(&root, Some(&f_name))
.and_then(|snap| snap.feature.and_then(|f| f.tasks))
{
let file = session.project.join(&tasks.path);
session.reconcile(&plane_dir, &file, &tasks, |file, task, hash| {
format.tick(file, task, hash)
});
}
let original = session.original.describe();
if let Some(p) = &options.progress {
p.event(&progress::Event::Notice {
text: format!(
"in place: on {} in {}; returns to {original} at the end — do not use this checkout until then",
session.branch,
session.checkout.display()
),
});
for note in &session.notes {
p.event(&progress::Event::Notice { text: note.clone() });
}
}
let shared = Arc::new(std::sync::Mutex::new(session));
let inner = RunOptions {
session: Some(Arc::clone(&shared)),
..options.clone()
};
let res = f(&inner);
let session = shared.lock().map(|s| s.clone());
if let Some(task) = waiting_task(&res) {
let notes: String = session
.as_ref()
.map(|s| s.notes.iter().map(|n| format!("\n{n}")).collect())
.unwrap_or_default();
let message = format!(
"{}{notes}\nthe checkout stays on {} while {task} waits: decide it (`speccoder task approve {task}` or `reject`), then `speccoder run --in-place` continues it — or `speccoder run --in-place --return` gives the checkout back",
res.message(),
session
.as_ref()
.map(|s| s.branch.as_str())
.unwrap_or("the speccoder branch"),
);
let status = res.status();
return res.reworded(status, &message);
}
let (back, notes) = match session {
Ok(s) => (s.finish(&plane_dir), s.notes),
Err(_) => (Ok(()), Vec::new()),
};
let told: String = notes.iter().map(|n| format!("\n{n}")).collect();
match back {
Ok(()) => {
let message = format!("{} — back on {original}{told}", res.message());
let status = res.status();
res.reworded(status, &message)
}
Err(e) => {
let message = format!("{}{told}\n{}", res.message(), e.line());
res.reworded(CommandStatus::NeedsHuman, &message)
.with_code(e.code)
.with_fix(e.fix.clone())
}
}
}
fn waiting_task(res: &CommandResult) -> Option<String> {
let waits = |r: &speccoder_core::RunReport| {
r.pending
.as_ref()
.is_some_and(|p| p["waiting"] == true)
.then(|| r.task.clone())
};
match res.data() {
Some(CommandData::Run(r)) => waits(r),
Some(CommandData::Feature(f)) => f.attempts.last().and_then(waits),
_ => None,
}
}
pub(crate) fn checklist_gate(
project: &Path,
feature: Option<&str>,
ignore: bool,
) -> Result<bool, Box<CommandResult>> {
let format = crate::format::at(project);
let Some(snap) = format.inspect(project, feature) else {
return Ok(false);
};
let Some(f) = snap.feature.as_ref() else {
return Ok(false);
};
let lists = format.checklists(Path::new(&snap.root), &f.feature.dir);
let open: Vec<String> = lists
.iter()
.filter(|c| c.open > 0)
.map(|c| format!("{} ({} of {} open)", c.file, c.open, c.total))
.collect();
if open.is_empty() {
return Ok(false);
}
if ignore {
return Ok(true);
}
Err(Box::new(
CommandResult::new(
"run",
CommandStatus::NeedsHuman,
&(format.messages().checklists)(&open.join(", ")),
)
.with_data(CommandData::Checklists(lists)),
))
}
pub(crate) fn stage(
live: &dyn crate::progress::Progress,
task: &str,
stage: speccoder_core::stage::Stage,
state: speccoder_core::stage::StageState,
detail: Option<String>,
since: Option<std::time::Instant>,
) {
stage_with_tail(live, task, stage, state, detail, since, Vec::new());
}
pub(crate) fn stage_with_tail(
live: &dyn crate::progress::Progress,
task: &str,
stage: speccoder_core::stage::Stage,
state: speccoder_core::stage::StageState,
detail: Option<String>,
since: Option<std::time::Instant>,
tail: Vec<String>,
) {
live.event(&crate::progress::Event::Stage {
task: task.to_owned(),
stage,
state,
detail,
elapsed_ms: since.map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX)),
tail,
});
}
pub(crate) fn failing_tail(report: &speccoder_core::verify::VerifyReport) -> Vec<String> {
report
.checks
.iter()
.find(|c| c.status.is_failure())
.map(|c| crate::progress::tail(&format!("{}\n{}", c.stdout_tail, c.stderr_tail)))
.unwrap_or_default()
}