Skip to main content

speccoder_runtime/
commands.rs

1//! Read-side and single-shot services: `inspect`, `worktree`, `propose`,
2//! `replay` and `eval`.
3
4use std::path::Path;
5
6use speccoder_core::snapshot::{ProjectSnapshot, Severity};
7use speccoder_core::{CommandData, CommandResult, CommandStatus};
8
9use crate::pipeline::*;
10use crate::{context, git, substrate};
11
12/// `speccoder inspect`: report what speccoder sees in the project
13/// containing `path`. Read-only.
14#[must_use]
15pub fn inspect(path: &Path, feature: Option<&str>) -> CommandResult {
16    match crate::format::open(path, feature) {
17        Ok(opened) => {
18            let snapshot = opened.snapshot;
19            let status = status_of(&snapshot);
20            let message = summary(&snapshot, opened.adapter.messages());
21            CommandResult::new("inspect", status, &message)
22                .with_data(CommandData::Inspect(Box::new(snapshot)))
23        }
24        Err(r) => CommandResult::new("inspect", r.status, &r.message),
25    }
26}
27
28/// Exit precedence: selection failure > error diagnostics > ok.
29pub(crate) fn status_of(s: &ProjectSnapshot) -> CommandStatus {
30    let selection_failed = s.feature.is_none()
31        && s.diagnostics
32            .iter()
33            .any(|d| d.severity == Severity::Error && d.code.starts_with("select."));
34    if selection_failed {
35        CommandStatus::SelectionNeeded
36    } else if s.has_errors() {
37        CommandStatus::DiagnosticsError
38    } else {
39        CommandStatus::Ok
40    }
41}
42
43pub(crate) fn summary(s: &ProjectSnapshot, m: &crate::format::Messages) -> String {
44    let features = match s.features.len() {
45        1 => "1 feature".to_owned(),
46        n => format!("{n} features"),
47    };
48    let selected = match &s.feature {
49        Some(f) => match &f.tasks {
50            Some(t) => format!(
51                "{} selected: {} tasks, {} done, {} open",
52                f.feature.name, t.totals.total, t.totals.done, t.totals.open
53            ),
54            None => format!("{} selected: no {}", f.feature.name, m.tasks_file),
55        },
56        None => "none selected".to_owned(),
57    };
58    let errors = s
59        .diagnostics
60        .iter()
61        .filter(|d| d.severity == Severity::Error)
62        .count();
63    let warnings = s
64        .diagnostics
65        .iter()
66        .filter(|d| d.severity == Severity::Warning)
67        .count();
68    format!("{features}, {selected}; {errors} errors, {warnings} warnings")
69}
70
71/// `speccoder worktree`: create or reuse the git worktree speccoder works in
72/// for the selected feature of the project containing `path`.
73#[must_use]
74pub fn worktree(path: &Path, feature: Option<&str>) -> CommandResult {
75    match crate::worktree::state_dir() {
76        Some(state) => worktree_with(path, feature, &state),
77        None => CommandResult::new(
78            "worktree",
79            CommandStatus::GitPrecondition,
80            "no state directory (set SPECCODER_STATE_DIR or HOME)",
81        ),
82    }
83}
84
85/// [`worktree()`] with an explicit state directory.
86pub fn worktree_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
87    use speccoder_core::{RefusedPath, WorktreeReport};
88
89    let inspected = inspect(path, feature);
90    let snapshot = match inspected.data() {
91        Some(CommandData::Inspect(s)) => s.clone(),
92        _ => return CommandResult::carried("worktree", &inspected),
93    };
94    let Some(f) = &snapshot.feature else {
95        return CommandResult::new(
96            "worktree",
97            CommandStatus::SelectionNeeded,
98            inspected.message(),
99        );
100    };
101    let format = crate::format::of(&snapshot);
102    let name = f.feature.name.clone();
103    let mut report = WorktreeReport {
104        feature: name.clone(),
105        path: None,
106        project_dir: None,
107        branch: None,
108        base: None,
109        created: false,
110        refused_paths: Vec::new(),
111    };
112    let refuse = |report: WorktreeReport, code: &str, message: &str, hint: &str| {
113        let mut fix = vec![format!("→ {hint}")];
114        if let Some(e) = speccoder_core::codes::lookup(code) {
115            fix.extend(speccoder_core::codes::fixes(e, "run", None, Some(&name)));
116        }
117        CommandResult::new("worktree", CommandStatus::GitPrecondition, message)
118            .with_code(code)
119            .with_fix(fix)
120            .with_data(CommandData::Worktree(report))
121    };
122
123    let project = Path::new(&snapshot.root);
124    let repo_root = match git::Git::at(project).toplevel() {
125        Ok(Some(root)) => std::fs::canonicalize(&root).unwrap_or(root),
126        Ok(None) => {
127            return refuse(
128                report,
129                "NO-REPOSITORY",
130                format.messages().not_in_git,
131                "run `git init` and commit the project",
132            );
133        }
134        Err(e) => return refuse(report, "GIT", &e.0, "install git"),
135    };
136    // The project root may be a subdirectory of the repository (monorepo).
137    let rel = project.strip_prefix(&repo_root).unwrap_or(Path::new(""));
138    let join = |p: &str| {
139        let joined = rel.join(p);
140        let s = joined.to_string_lossy().replace('\\', "/");
141        if s.is_empty() { ".".to_owned() } else { s }
142    };
143    let precondition = format.commit_precondition(project, &join, &f.feature.dir);
144
145    match crate::worktree::ensure_worktree(&repo_root, state, &name, precondition) {
146        Ok(wt) => {
147            report.project_dir = Some(wt.path.join(rel).display().to_string());
148            report.path = Some(wt.path.display().to_string());
149            report.branch = Some(wt.branch);
150            report.base = Some(wt.base);
151            report.created = wt.created;
152            let message = if report.created {
153                "worktree created"
154            } else {
155                "worktree reused"
156            };
157            CommandResult::new("worktree", CommandStatus::Ok, message)
158                .with_data(CommandData::Worktree(report))
159        }
160        Err(r) => {
161            report.refused_paths = r
162                .paths
163                .into_iter()
164                .map(|(path, reason)| RefusedPath { path, reason })
165                .collect();
166            refuse(report, r.code, &r.message, &r.hint)
167        }
168    }
169}
170
171/// `speccoder propose`: the provider comes from `[model]`, its key from
172/// `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (never from files).
173#[must_use]
174pub fn propose(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
175    let Some(state) = crate::worktree::state_dir() else {
176        return CommandResult::new(
177            "propose",
178            CommandStatus::ConfigError,
179            "no state directory (set SPECCODER_STATE_DIR or HOME)",
180        )
181        .with_code("NO-STATE-DIR");
182    };
183    // Validate everything that needs no key first: a wrong task or a missing
184    // [model] is reported before a missing credential.
185    match setup(path, feature, &state, "propose") {
186        Err(r) => return *r,
187        Ok(s) => {
188            if let Err(e) = s.format.bundle(&s.snapshot, task) {
189                let msg = match e {
190                    context::BundleError::NotFound(id) => {
191                        format!("no task {id} in the selected feature")
192                    }
193                    context::BundleError::NoTasks => {
194                        format!(
195                            "the selected feature has no {}",
196                            s.format.messages().tasks_file
197                        )
198                    }
199                    context::BundleError::NoFeature => {
200                        "no feature selected; pass --feature".to_owned()
201                    }
202                };
203                return CommandResult::new("propose", CommandStatus::NotFound, &msg);
204            }
205        }
206    }
207    let model = match setup(path, feature, &state, "propose") {
208        Ok(s) => s.model,
209        Err(r) => return *r,
210    };
211    let (driver, key) = match substrate::model_config::open_driver(&model) {
212        Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
213        Err(e) => return substrate::model_config::refused("propose", &e),
214    };
215    let provider = match driver {
216        Ok(p) => p,
217        Err(e) => {
218            return CommandResult::new(
219                "propose",
220                CommandStatus::ConfigError,
221                &redact(&e, Some(&key)),
222            );
223        }
224    };
225    propose_with(path, feature, task, &state, provider, Some(&key))
226}
227
228/// [`propose`] with an explicit state directory and model driver; `secret`
229/// is redacted from anything reported.
230pub fn propose_with(
231    path: &Path,
232    feature: Option<&str>,
233    task: &str,
234    state: &Path,
235    provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
236    secret: Option<&str>,
237) -> CommandResult {
238    use speccoder_core::ProposeReport;
239
240    let s = match setup(path, feature, state, "propose") {
241        Ok(s) => s,
242        Err(r) => return *r,
243    };
244    let bundle = match s.format.bundle(&s.snapshot, task) {
245        Ok(b) => b,
246        Err(context::BundleError::NotFound(id)) => {
247            return CommandResult::new(
248                "propose",
249                CommandStatus::NotFound,
250                &format!("no task {id} in the selected feature"),
251            );
252        }
253        Err(context::BundleError::NoTasks) => {
254            return CommandResult::new(
255                "propose",
256                CommandStatus::NotFound,
257                &format!(
258                    "the selected feature has no {}",
259                    s.format.messages().tasks_file
260                ),
261            );
262        }
263        Err(context::BundleError::NoFeature) => {
264            return CommandResult::new(
265                "propose",
266                CommandStatus::SelectionNeeded,
267                "no feature selected; pass --feature",
268            );
269        }
270    };
271    let feature_name = bundle.feature.clone();
272    let input = match serde_json::to_value(&bundle) {
273        Ok(v) => v,
274        Err(e) => return CommandResult::new("propose", CommandStatus::Error, &e.to_string()),
275    };
276    let fail =
277        |m: String| CommandResult::new("propose", CommandStatus::RunFailed, &redact(&m, secret));
278
279    let rt = match tokio_runtime() {
280        Ok(rt) => rt,
281        Err(e) => return fail(e),
282    };
283    let outcome = rt.block_on(async {
284        let plane = substrate::Plane::open_with(
285            &s.plane_dir,
286            &s.model,
287            provider,
288            None,
289            substrate::Hooks {
290                format: Some(s.format),
291                ..substrate::Hooks::default()
292            },
293        )?;
294        // The person's committed spec, plan and task: trusted.
295        plane
296            .propose(
297                &feature_name,
298                task,
299                agentplane::core::Tainted::trusted(input),
300            )
301            .await
302    });
303    let outcome = match outcome {
304        Ok(o) => o,
305        Err(e) => return fail(e.0),
306    };
307    let output = outcome.output.as_ref().map(|o| o.peek().clone());
308    let report = ProposeReport {
309        run_id: outcome.run_id.to_string(),
310        feature: feature_name,
311        task: task.to_owned(),
312        run_status: outcome.status.as_str().to_owned(),
313        proposal: output
314            .as_ref()
315            .and_then(|o| o.get("proposal").cloned())
316            .filter(|p| !p.is_null()),
317        usage: output.as_ref().and_then(|o| o.get("usage").cloned()),
318    };
319    let (status, message) = match (&outcome.status, &report.proposal) {
320        (agentplane::prelude::RunStatus::Succeeded, Some(_)) => (
321            CommandStatus::Ok,
322            format!("proposal recorded in run {}", report.run_id),
323        ),
324        (agentplane::prelude::RunStatus::Succeeded, None) => (
325            CommandStatus::RunFailed,
326            format!(
327                "run {} succeeded but the model's answer is not a proposal",
328                report.run_id
329            ),
330        ),
331        (other, _) => (
332            CommandStatus::RunFailed,
333            redact(
334                &format!("run {} ended {}: {other:?}", report.run_id, other.as_str()),
335                secret,
336            ),
337        ),
338    };
339    CommandResult::new("propose", status, &message)
340        .with_data(CommandData::Propose(Box::new(report)))
341}
342
343/// `speccoder replay`: strict replay of a recorded run; calls no model.
344#[must_use]
345pub fn replay(path: &Path, run: &str) -> CommandResult {
346    let Some(state) = crate::worktree::state_dir() else {
347        return CommandResult::new(
348            "replay",
349            CommandStatus::ConfigError,
350            "no state directory (set SPECCODER_STATE_DIR or HOME)",
351        )
352        .with_code("NO-STATE-DIR");
353    };
354    replay_with(path, run, &state)
355}
356
357/// [`replay`] with an explicit state directory.
358pub fn replay_with(path: &Path, run: &str, state: &Path) -> CommandResult {
359    use speccoder_core::ReplayReport;
360
361    let s = match setup(path, None, state, "replay") {
362        Ok(s) => s,
363        Err(r) => return *r,
364    };
365    let rt = match tokio_runtime() {
366        Ok(rt) => rt,
367        Err(e) => return CommandResult::new("replay", CommandStatus::RunFailed, &e),
368    };
369    let report = |result: &str, detail: String| ReplayReport {
370        run_id: run.to_owned(),
371        result: result.to_owned(),
372        detail,
373    };
374    let result = rt.block_on(substrate::replay_strict(
375        &s.plane_dir,
376        &s.model,
377        run,
378        s.format,
379    ));
380    match result {
381        Ok(Some(v)) => {
382            let (result, status, message) = replay_verdict(&v.finding);
383            CommandResult::new("replay", status, &message)
384                .with_data(CommandData::Replay(report(result, message.clone())))
385        }
386        Ok(None) => CommandResult::new(
387            "replay",
388            CommandStatus::NotFound,
389            &format!("no run {run} in this project's journal"),
390        )
391        .with_data(CommandData::Replay(report("not_found", String::new()))),
392        // agentplane names every divergence in the verdict's finding; an
393        // error is a missing run, a journal that does not verify, or the store.
394        Err(e) => CommandResult::new("replay", CommandStatus::RunFailed, &e.0)
395            .with_data(CommandData::Replay(report("error", e.0.clone()))),
396    }
397}
398
399/// `speccoder eval`: the suite's `[model]` decides the provider; its key
400/// comes from the environment.
401#[must_use]
402pub fn eval(
403    dir: &Path,
404    only: Option<&str>,
405    keep: Option<&Path>,
406    repeat: usize,
407    executor: Option<&str>,
408) -> CommandResult {
409    let suite = match crate::eval::suite_config(dir) {
410        Ok(s) => s,
411        Err(e) => return substrate::model_config::refused("eval", &e),
412    };
413    let model = match substrate::model_config::parse(&suite) {
414        Ok(Some(m)) => m,
415        Ok(None) => {
416            return CommandResult::new(
417                "eval",
418                CommandStatus::ConfigError,
419                "eval.toml has no [model]",
420            );
421        }
422        Err(e) => {
423            return CommandResult::new(
424                "eval",
425                CommandStatus::ConfigError,
426                &format!("eval.toml: {e}"),
427            );
428        }
429    };
430    let (driver, key) = match substrate::model_config::open_driver(&model) {
431        Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
432        Err(e) => return CommandResult::new("eval", CommandStatus::ConfigError, &e),
433    };
434    match driver {
435        Ok(p) => crate::eval::run_suite_as(dir, only, &p, Some(&key), keep, repeat, executor),
436        Err(e) => CommandResult::new("eval", CommandStatus::ConfigError, &redact(&e, Some(&key))),
437    }
438}
439
440/// What a replay's finding means for the person: a faithful
441/// replay of any ending is reproduced; a different effect or ending is a
442/// divergence; a run this build cannot re-derive is neither.
443pub(crate) fn replay_verdict(
444    finding: &agentplane::runtime::Finding,
445) -> (&'static str, CommandStatus, String) {
446    use agentplane::runtime::Finding;
447    match finding {
448        Finding::Verified { outcome } => (
449            "reproduced",
450            CommandStatus::Ok,
451            format!(
452                "reproduced: every recorded effect again, and the recorded ending ({outcome}); no model was called"
453            ),
454        ),
455        Finding::Diverged(d) => (
456            "diverged",
457            CommandStatus::ReplayDiverged,
458            format!("diverged: {d:?}"),
459        ),
460        Finding::OutcomeDiffers { recorded, replayed } => (
461            "diverged",
462            CommandStatus::ReplayDiverged,
463            format!(
464                "diverged: every effect matched, but the run ended {replayed} instead of {recorded}"
465            ),
466        ),
467        Finding::CannotReplay(why) => (
468            "not_replayable",
469            CommandStatus::NotReplayable,
470            format!("this build cannot re-derive the run: {why:?}"),
471        ),
472        other => (
473            "not_replayable",
474            CommandStatus::NotReplayable,
475            format!("{other:?}"),
476        ),
477    }
478}
479
480#[cfg(test)]
481mod replay_tests {
482    use super::*;
483    use agentplane::runtime::{CannotReplay, Finding};
484
485    #[test]
486    fn findings_become_reproduced_diverged_or_not_replayable() {
487        let (r, s, _) = replay_verdict(&Finding::Verified {
488            outcome: "failed".to_owned(),
489        });
490        assert_eq!((r, s), ("reproduced", CommandStatus::Ok));
491        let (r, s, m) = replay_verdict(&Finding::OutcomeDiffers {
492            recorded: "succeeded".to_owned(),
493            replayed: "failed".to_owned(),
494        });
495        assert_eq!((r, s), ("diverged", CommandStatus::ReplayDiverged));
496        assert!(m.contains("instead of succeeded"));
497        let (r, s, _) = replay_verdict(&Finding::CannotReplay(CannotReplay::NotConcluded));
498        assert_eq!((r, s), ("not_replayable", CommandStatus::NotReplayable));
499        assert_eq!(s.exit_code().code(), 25);
500    }
501}