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