Skip to main content

spec_driven_docs/commands/
reconcile.rs

1//! `reconcile` subcommand: runtime-shape.
2//!
3//! One lifecycle over one plan. `plan` computes and stores it, `show`
4//! renders a stored one, and `apply` executes exactly that plan or refuses
5//! because its inputs moved. Every verb here takes the target lock, so two
6//! writers cannot interleave over one repository.
7
8use camino::{Utf8Path, Utf8PathBuf};
9
10use crate::cli::reconcile::{ApplyArgs, PlanArgs, ReconcileArgs, ReconcileCommand, ShowArgs};
11use crate::context::AppContext;
12use crate::error::AppError;
13use crate::output;
14use crate::plan::Plan;
15use crate::plan::apply::{Request, apply as execute};
16use crate::plan::decision;
17use crate::plan::readiness::Readiness;
18use crate::plan::session::{
19    STORE_WAIT, blobs_for, compute_plan, read_release, state_root, target_lock,
20};
21use crate::plan::store::{Result as ApplyResult, Store};
22use crate::transaction::lock::Lock;
23
24/// Run one reconcile verb.
25///
26/// # Errors
27///
28/// [`AppError::Usage`] for a target the arguments cannot mean and for a
29/// decision answer the plan does not offer, [`AppError::Busy`] when
30/// another process holds the target, and the resolver's refusals.
31pub fn run(ctx: &AppContext, args: ReconcileArgs) -> Result<(), AppError> {
32    match args.command {
33        ReconcileCommand::Plan(plan) => run_plan(ctx, &plan),
34        ReconcileCommand::Show(show) => run_show(&show),
35        ReconcileCommand::Apply(apply) => run_apply(ctx, &apply),
36    }
37}
38
39fn resolve_target(ctx: &AppContext, target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
40    let named = if target.is_absolute() {
41        target.to_owned()
42    } else if target == "." {
43        ctx.cwd.clone()
44    } else {
45        return Err(AppError::Usage("target must be absolute or .".to_string()));
46    };
47    // The lock is keyed by the path, so two spellings of one repository
48    // would take two locks and interleave. Canonicalizing first makes one
49    // repository one key, whichever symlink the operator typed.
50    let resolved = std::fs::canonicalize(&named)
51        .map_err(|_| AppError::Usage(format!("unresolved target: {named}")))?;
52    Utf8PathBuf::from_path_buf(resolved)
53        .map_err(|path| AppError::Usage(format!("target is not UTF-8: {}", path.display())))
54}
55
56fn run_plan(ctx: &AppContext, args: &PlanArgs) -> Result<(), AppError> {
57    let target = resolve_target(ctx, &args.target)?;
58    let selections =
59        decision::parse(&args.set).map_err(|error| AppError::Usage(error.to_string()))?;
60    let release = read_release(&args.to, args.offline)?;
61
62    // Planning holds the target shared: several readers may plan at once,
63    // and none of them may overlap an apply.
64    let _lock = Lock::shared(&target_lock(&target)?, "reconcile plan")?;
65    let (computed, blobs) = compute_plan(
66        &target,
67        &args.to,
68        args.offline,
69        &selections,
70        &args.reserve,
71        None,
72        &release.borrow(),
73    )?;
74    decision::validate(&computed.decisions, &selections)
75        .map_err(|error| AppError::Usage(error.to_string()))?;
76
77    let store = Store::new(&state_root()?);
78    // The store is global, so its own lock orders writers across targets.
79    // The order is always target first, then store, on every path.
80    store.create()?;
81    if let Ok(_walk) = Lock::exclusive(&store.lock_path(), "plan store prune") {
82        let _ = store.prune(jiff::Timestamp::now(), Some(&computed.identity.plan_id));
83    }
84    let _plan_lock = Lock::exclusive_waiting(
85        &store.plan_lock_path(&computed.identity.plan_id)?,
86        "reconcile plan",
87        STORE_WAIT,
88    )?;
89    // Identical inputs while an executable plan exists reuse it, so an
90    // operator who plans twice approves one thing.
91    if !store.holds(&computed.identity.plan_id) {
92        // Every byte the plan will write travels with it, so an apply
93        // never has to go back to a bundle it may no longer read.
94        let mut blobs = blobs;
95        for (digest, bytes) in blobs_for(&computed, release.bundle.as_ref()) {
96            blobs.entry(digest).or_insert(bytes);
97        }
98        store.put(&computed, &blobs)?;
99    }
100
101    if args.json {
102        return output::json(&computed);
103    }
104    render(&computed);
105    output::line(format!(
106        "apply it with: sdd reconcile apply {}",
107        computed.identity.plan_id
108    ));
109    Ok(())
110}
111
112fn run_show(args: &ShowArgs) -> Result<(), AppError> {
113    let store = Store::new(&state_root()?);
114    if let Ok(plan) = store.get(&args.plan_id) {
115        if args.json {
116            return output::json(&plan);
117        }
118        output::line("executable plan");
119        render(&plan);
120        return Ok(());
121    }
122    let result = store.latest_result(&args.plan_id).ok_or_else(|| {
123        AppError::Refused(format!(
124            "no plan and no result carry the id {}; run 'sdd reconcile plan' again",
125            args.plan_id
126        ))
127    })?;
128    if args.json {
129        return output::json(&result);
130    }
131    output::line("latest result");
132    output::line(format!("disposition: {:?}", result.disposition));
133    output::line(format!("reason: {}", result.reason));
134    Ok(())
135}
136
137fn run_apply(ctx: &AppContext, args: &ApplyArgs) -> Result<(), AppError> {
138    let target = resolve_target(ctx, &args.target)?;
139    let store = Store::new(&state_root()?);
140    // The plan's own lock comes first, before the plan is even read: a
141    // prune skips a fingerprint somebody holds, and reading first would
142    // leave a window where the blobs this apply needs are swept.
143    let _plan_lock = Lock::exclusive_waiting(
144        &store.plan_lock_path(&args.plan_id)?,
145        "reconcile apply",
146        STORE_WAIT,
147    )?;
148    let stored = store.get(&args.plan_id)?;
149
150    // Apply holds the target alone, through observation, execution, and
151    // the result write.
152    let _lock = Lock::exclusive(&target_lock(&target)?, "reconcile apply")?;
153    let selections: decision::Selections = stored
154        .decisions
155        .iter()
156        .filter_map(|decision| {
157            decision
158                .selected
159                .as_ref()
160                .map(|answer| (decision.id.clone(), answer.clone()))
161        })
162        .collect();
163    // Apply resolves nothing. It reads the exact release the plan froze,
164    // offline, by version rather than by the selector that found it: a
165    // `latest` that moved between the plan and the apply would otherwise
166    // make the apply resolve a different release and then report the
167    // plan as stale, which is the network deciding rather than the plan.
168    let frozen = if stored.desired_state.selector == "embedded" {
169        // The embedded bundle is this binary's own and has no version to
170        // look up. Naming its version instead would send the apply to the
171        // registry for a release it already carries.
172        "embedded".to_string()
173    } else {
174        stored.desired_state.release.clone()
175    };
176    let release = read_release(&frozen, true)?;
177    let (recomputed, _) = compute_plan(
178        &target,
179        &stored.desired_state.selector,
180        true,
181        &selections,
182        &stored.desired_state.reserved,
183        None,
184        &release.borrow(),
185    )?;
186
187    // The store is global, so its own lock orders writers across targets.
188    // The order is always target first, then store, on every path.
189    let result = execute(&Request {
190        store: &store,
191        target: &target,
192        stored: &stored,
193        recomputed: &recomputed,
194        bundle: release.bundle.as_ref(),
195        now: jiff::Timestamp::now().to_string(),
196    })?;
197    if args.json {
198        return output::json(&result);
199    }
200    render_result(&result);
201    Ok(())
202}
203
204fn render_result(result: &ApplyResult) {
205    output::line(format!("{:?}: {}", result.disposition, result.reason));
206    for operation in &result.operations {
207        output::line(format!("  {} {}", operation.kind, operation.path));
208    }
209    for postcondition in &result.postconditions {
210        let word = if postcondition.held { "held" } else { "FAILED" };
211        output::line(format!("  {word}: {}", postcondition.id));
212    }
213}
214
215fn render(plan: &Plan) {
216    output::line(format!(
217        "{} toward {} ({})",
218        plan.classification, plan.desired_state.release, plan.desired_state.selector
219    ));
220    output::line(format!("plan {}", plan.identity.plan_id));
221    let word = match plan.readiness {
222        Readiness::Ready => "ready",
223        Readiness::NeedsDecision => "needs a decision",
224        Readiness::Blocked => "blocked",
225    };
226    output::line(format!("readiness: {word}"));
227    if !plan.findings.is_empty() {
228        output::line(format!("findings: {}", plan.findings.len()));
229        for found in &plan.findings {
230            output::line(format!(
231                "  {} {}: {}",
232                found.rule, found.path, found.statement
233            ));
234        }
235    }
236    if !plan.style_candidates.is_empty() {
237        output::line(format!(
238            "style candidates: {} (named, never judged)",
239            plan.style_candidates.len()
240        ));
241    }
242    output::line(format!("operations: {}", plan.operations.len()));
243    for operation in &plan.operations {
244        output::line(format!("  {} {}", operation.kind(), operation.path()));
245    }
246    for precondition in &plan.preconditions {
247        if precondition.evaluation.is_satisfied() {
248            continue;
249        }
250        output::line(format!(
251            "  blocked by {}: {}",
252            precondition.id, precondition.statement
253        ));
254    }
255    for decision in &plan.decisions {
256        if decision.selected.is_some() {
257            continue;
258        }
259        output::line(format!("  decide --set {}=<answer>", decision.id));
260    }
261}