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