use std::path::Path;
use speccoder_core::snapshot::{ProjectSnapshot, Severity};
use speccoder_core::{CommandData, CommandResult, CommandStatus};
use crate::pipeline::*;
use crate::{context, git, substrate};
#[must_use]
pub fn inspect(path: &Path, feature: Option<&str>) -> CommandResult {
match crate::format::open(path, feature) {
Ok(opened) => {
let snapshot = opened.snapshot;
let status = status_of(&snapshot);
let message = summary(&snapshot, opened.adapter.messages());
CommandResult::new("inspect", status, &message)
.with_data(CommandData::Inspect(Box::new(snapshot)))
}
Err(r) => CommandResult::new("inspect", r.status, &r.message),
}
}
pub(crate) fn status_of(s: &ProjectSnapshot) -> CommandStatus {
let selection_failed = s.feature.is_none()
&& s.diagnostics
.iter()
.any(|d| d.severity == Severity::Error && d.code.starts_with("select."));
if selection_failed {
CommandStatus::SelectionNeeded
} else if s.has_errors() {
CommandStatus::DiagnosticsError
} else {
CommandStatus::Ok
}
}
pub(crate) fn summary(s: &ProjectSnapshot, m: &crate::format::Messages) -> String {
let features = match s.features.len() {
1 => "1 feature".to_owned(),
n => format!("{n} features"),
};
let selected = match &s.feature {
Some(f) => match &f.tasks {
Some(t) => format!(
"{} selected: {} tasks, {} done, {} open",
f.feature.name, t.totals.total, t.totals.done, t.totals.open
),
None => format!("{} selected: no {}", f.feature.name, m.tasks_file),
},
None => "none selected".to_owned(),
};
let errors = s
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.count();
let warnings = s
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.count();
format!("{features}, {selected}; {errors} errors, {warnings} warnings")
}
#[must_use]
pub fn worktree(path: &Path, feature: Option<&str>) -> CommandResult {
match crate::worktree::state_dir() {
Some(state) => worktree_with(path, feature, &state),
None => CommandResult::new(
"worktree",
CommandStatus::GitPrecondition,
"no state directory (set SPECCODER_STATE_DIR or HOME)",
),
}
}
pub fn worktree_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
let inspected = inspect(path, feature);
let snapshot = match inspected.data() {
Some(CommandData::Inspect(s)) => s.clone(),
_ => return CommandResult::carried("worktree", &inspected),
};
let Some(f) = &snapshot.feature else {
return CommandResult::new(
"worktree",
CommandStatus::SelectionNeeded,
inspected.message(),
);
};
worktree_of(&snapshot, &f.feature.name, &f.feature.dir, state)
}
pub fn worktree_of(
snapshot: &speccoder_core::snapshot::ProjectSnapshot,
name: &str,
unit_dir: &str,
state: &Path,
) -> CommandResult {
use speccoder_core::{RefusedPath, WorktreeReport};
let format = crate::format::of(snapshot);
let name = name.to_owned();
let mut report = WorktreeReport {
feature: name.clone(),
path: None,
project_dir: None,
branch: None,
base: None,
created: false,
refused_paths: Vec::new(),
};
let refuse = |report: WorktreeReport, code: &str, message: &str, hint: &str| {
let mut fix = vec![format!("→ {hint}")];
if let Some(e) = speccoder_core::codes::lookup(code) {
fix.extend(speccoder_core::codes::fixes(e, "run", None, Some(&name)));
}
CommandResult::new("worktree", CommandStatus::GitPrecondition, message)
.with_code(code)
.with_fix(fix)
.with_data(CommandData::Worktree(report))
};
let project = Path::new(&snapshot.root);
let repo_root = match git::Git::at(project).toplevel() {
Ok(Some(root)) => std::fs::canonicalize(&root).unwrap_or(root),
Ok(None) => {
return refuse(
report,
"NO-REPOSITORY",
format.messages().not_in_git,
"run `git init` and commit the project",
);
}
Err(e) => return refuse(report, "GIT", &e.0, "install git"),
};
let rel = project.strip_prefix(&repo_root).unwrap_or(Path::new(""));
let join = |p: &str| {
let joined = rel.join(p);
let s = joined.to_string_lossy().replace('\\', "/");
if s.is_empty() { ".".to_owned() } else { s }
};
let precondition = format.commit_precondition(project, &join, unit_dir);
match crate::worktree::ensure_worktree(&repo_root, state, &name, precondition) {
Ok(wt) => {
report.project_dir = Some(wt.path.join(rel).display().to_string());
report.path = Some(wt.path.display().to_string());
report.branch = Some(wt.branch);
report.base = Some(wt.base);
report.created = wt.created;
let message = if report.created {
"worktree created"
} else {
"worktree reused"
};
CommandResult::new("worktree", CommandStatus::Ok, message)
.with_data(CommandData::Worktree(report))
}
Err(r) => {
report.refused_paths = r
.paths
.into_iter()
.map(|(path, reason)| RefusedPath { path, reason })
.collect();
refuse(report, r.code, &r.message, &r.hint)
}
}
}
#[must_use]
pub fn propose(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
let Some(state) = crate::worktree::state_dir() else {
return CommandResult::new(
"propose",
CommandStatus::ConfigError,
"no state directory (set SPECCODER_STATE_DIR or HOME)",
)
.with_code("NO-STATE-DIR");
};
match setup(path, feature, &state, "propose") {
Err(r) => return *r,
Ok(s) => {
if let Err(e) = s.format.bundle(&s.snapshot, task) {
let msg = match e {
context::BundleError::NotFound(id) => {
format!("no task {id} in the selected feature")
}
context::BundleError::NoTasks => {
format!(
"the selected feature has no {}",
s.format.messages().tasks_file
)
}
context::BundleError::NoFeature => {
"no feature selected; pass --feature".to_owned()
}
};
return CommandResult::new("propose", CommandStatus::NotFound, &msg);
}
}
}
let model = match setup(path, feature, &state, "propose") {
Ok(s) => s.model,
Err(r) => return *r,
};
let (driver, key) = match substrate::model_config::open_driver(&model) {
Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
Err(e) => return substrate::model_config::refused("propose", &e),
};
let provider = match driver {
Ok(p) => p,
Err(e) => {
return CommandResult::new(
"propose",
CommandStatus::ConfigError,
&redact(&e, Some(&key)),
);
}
};
propose_with(path, feature, task, &state, provider, Some(&key))
}
pub fn propose_with(
path: &Path,
feature: Option<&str>,
task: &str,
state: &Path,
provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
secret: Option<&str>,
) -> CommandResult {
use speccoder_core::ProposeReport;
let s = match setup(path, feature, state, "propose") {
Ok(s) => s,
Err(r) => return *r,
};
let bundle = match s.format.bundle(&s.snapshot, task) {
Ok(b) => b,
Err(context::BundleError::NotFound(id)) => {
return CommandResult::new(
"propose",
CommandStatus::NotFound,
&format!("no task {id} in the selected feature"),
);
}
Err(context::BundleError::NoTasks) => {
return CommandResult::new(
"propose",
CommandStatus::NotFound,
&format!(
"the selected feature has no {}",
s.format.messages().tasks_file
),
);
}
Err(context::BundleError::NoFeature) => {
return CommandResult::new(
"propose",
CommandStatus::SelectionNeeded,
"no feature selected; pass --feature",
);
}
};
let feature_name = bundle.feature.clone();
let input = match serde_json::to_value(&bundle) {
Ok(v) => v,
Err(e) => return CommandResult::new("propose", CommandStatus::Error, &e.to_string()),
};
let fail =
|m: String| CommandResult::new("propose", CommandStatus::RunFailed, &redact(&m, secret));
let rt = match tokio_runtime() {
Ok(rt) => rt,
Err(e) => return fail(e),
};
let outcome = rt.block_on(async {
let plane = substrate::Plane::open_with(
&s.plane_dir,
&s.model,
provider,
None,
substrate::Hooks {
format: Some(s.format),
..substrate::Hooks::default()
},
)?;
plane
.propose(
&feature_name,
task,
agentplane::core::Tainted::trusted(input),
)
.await
});
let outcome = match outcome {
Ok(o) => o,
Err(e) => return fail(e.0),
};
let output = outcome.output.as_ref().map(|o| o.peek().clone());
let report = ProposeReport {
run_id: outcome.run_id.to_string(),
feature: feature_name,
task: task.to_owned(),
run_status: outcome.status.as_str().to_owned(),
proposal: output
.as_ref()
.and_then(|o| o.get("proposal").cloned())
.filter(|p| !p.is_null()),
usage: output.as_ref().and_then(|o| o.get("usage").cloned()),
};
let (status, message) = match (&outcome.status, &report.proposal) {
(agentplane::prelude::RunStatus::Succeeded, Some(_)) => (
CommandStatus::Ok,
format!("proposal recorded in run {}", report.run_id),
),
(agentplane::prelude::RunStatus::Succeeded, None) => (
CommandStatus::RunFailed,
format!(
"run {} succeeded but the model's answer is not a proposal",
report.run_id
),
),
(other, _) => (
CommandStatus::RunFailed,
redact(
&format!("run {} ended {}: {other:?}", report.run_id, other.as_str()),
secret,
),
),
};
CommandResult::new("propose", status, &message)
.with_data(CommandData::Propose(Box::new(report)))
}
#[must_use]
pub fn replay(path: &Path, run: &str) -> CommandResult {
let Some(state) = crate::worktree::state_dir() else {
return CommandResult::new(
"replay",
CommandStatus::ConfigError,
"no state directory (set SPECCODER_STATE_DIR or HOME)",
)
.with_code("NO-STATE-DIR");
};
replay_with(path, run, &state)
}
pub fn replay_with(path: &Path, run: &str, state: &Path) -> CommandResult {
use speccoder_core::ReplayReport;
let s = match setup(path, None, state, "replay") {
Ok(s) => s,
Err(r) => return *r,
};
let rt = match tokio_runtime() {
Ok(rt) => rt,
Err(e) => return CommandResult::new("replay", CommandStatus::RunFailed, &e),
};
let report = |result: &str, detail: String| ReplayReport {
run_id: run.to_owned(),
result: result.to_owned(),
detail,
};
let result = rt.block_on(substrate::replay_strict(
&s.plane_dir,
&s.model,
run,
s.format,
));
match result {
Ok(Some(v)) => {
let (result, status, message) = replay_verdict(&v.finding);
CommandResult::new("replay", status, &message)
.with_data(CommandData::Replay(report(result, message.clone())))
}
Ok(None) => CommandResult::new(
"replay",
CommandStatus::NotFound,
&format!("no run {run} in this project's journal"),
)
.with_data(CommandData::Replay(report("not_found", String::new()))),
Err(e) => CommandResult::new("replay", CommandStatus::RunFailed, &e.0)
.with_data(CommandData::Replay(report("error", e.0.clone()))),
}
}
#[must_use]
pub fn eval(
dir: &Path,
only: Option<&str>,
keep: Option<&Path>,
repeat: usize,
executor: Option<&str>,
) -> CommandResult {
let suite = match crate::eval::suite_config(dir) {
Ok(s) => s,
Err(e) => return substrate::model_config::refused("eval", &e),
};
let model = match substrate::model_config::parse(&suite) {
Ok(Some(m)) => m,
Ok(None) => {
return CommandResult::new(
"eval",
CommandStatus::ConfigError,
"eval.toml has no [model]",
);
}
Err(e) => {
return CommandResult::new(
"eval",
CommandStatus::ConfigError,
&format!("eval.toml: {e}"),
);
}
};
let (driver, key) = match substrate::model_config::open_driver(&model) {
Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
Err(e) => return CommandResult::new("eval", CommandStatus::ConfigError, &e),
};
match driver {
Ok(p) => crate::eval::run_suite_as(dir, only, &p, Some(&key), keep, repeat, executor),
Err(e) => CommandResult::new("eval", CommandStatus::ConfigError, &redact(&e, Some(&key))),
}
}
pub(crate) fn replay_verdict(
finding: &agentplane::runtime::Finding,
) -> (&'static str, CommandStatus, String) {
use agentplane::runtime::Finding;
match finding {
Finding::Verified { outcome } => (
"reproduced",
CommandStatus::Ok,
format!(
"reproduced: every recorded effect again, and the recorded ending ({outcome}); no model was called"
),
),
Finding::Diverged(d) => (
"diverged",
CommandStatus::ReplayDiverged,
format!("diverged: {d:?}"),
),
Finding::OutcomeDiffers { recorded, replayed } => (
"diverged",
CommandStatus::ReplayDiverged,
format!(
"diverged: every effect matched, but the run ended {replayed} instead of {recorded}"
),
),
Finding::CannotReplay(why) => (
"not_replayable",
CommandStatus::NotReplayable,
format!("this build cannot re-derive the run: {why:?}"),
),
other => (
"not_replayable",
CommandStatus::NotReplayable,
format!("{other:?}"),
),
}
}
#[cfg(test)]
mod replay_tests {
use super::*;
use agentplane::runtime::{CannotReplay, Finding};
#[test]
fn findings_become_reproduced_diverged_or_not_replayable() {
let (r, s, _) = replay_verdict(&Finding::Verified {
outcome: "failed".to_owned(),
});
assert_eq!((r, s), ("reproduced", CommandStatus::Ok));
let (r, s, m) = replay_verdict(&Finding::OutcomeDiffers {
recorded: "succeeded".to_owned(),
replayed: "failed".to_owned(),
});
assert_eq!((r, s), ("diverged", CommandStatus::ReplayDiverged));
assert!(m.contains("instead of succeeded"));
let (r, s, _) = replay_verdict(&Finding::CannotReplay(CannotReplay::NotConcluded));
assert_eq!((r, s), ("not_replayable", CommandStatus::NotReplayable));
assert_eq!(s.exit_code().code(), 25);
}
}