Skip to main content

spec_driven_docs/plan/
session.rs

1//! One planning session, from an observed target to a stored plan.
2//!
3//! Both fronts reach the engine through this module: `sdd reconcile` and
4//! the compatibility verbs alike. It exists so there is one place that
5//! turns a target and a set of answers into a plan, and one place that
6//! stores and executes one. A second path here would be the drift the
7//! plan model exists to remove.
8
9use std::collections::BTreeMap;
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::parse_docs_scratch;
14use crate::domain::ownership::Sha256;
15use crate::domain::paths::UserEnv;
16use crate::domain::profile::ProfileId;
17use crate::error::AppError;
18use crate::plan::apply::{Request, apply as execute};
19use crate::plan::observe::observe;
20use crate::plan::planner::{Inputs, plan as compute};
21use crate::plan::store::{Result as ApplyResult, Store};
22use crate::plan::{Plan, compatibility, decision, guidance};
23use crate::release::crates_io::CratesIoResolver;
24use crate::release::embedded::EmbeddedReleaseBundle;
25use crate::release::{Provenance, ReleaseBundle, ReleaseResolver, Role, Selector};
26use crate::services::installer::{InitOptions, compute_target_state};
27use crate::transaction::lock::Lock;
28
29/// How long a verb waits for the store before it refuses.
30///
31/// The store's critical section is a prune and a directory write, so a
32/// second writer queues behind it rather than failing. A wait this long
33/// running out means a holder died, which is worth reporting.
34pub(crate) const STORE_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
35
36/// What the caller asked for.
37fn selector(value: &str) -> Result<Selector, AppError> {
38    match value {
39        "embedded" => Ok(Selector::Embedded),
40        "latest" => Ok(Selector::Latest),
41        version => version.parse().map(Selector::Exact).map_err(|_| {
42            AppError::Usage(format!(
43                "--to takes embedded, latest, or a semantic version; {version} is none of those"
44            ))
45        }),
46    }
47}
48
49/// Where this tool keeps state that outlives a command.
50pub(crate) fn state_root() -> Result<Utf8PathBuf, AppError> {
51    Ok(UserEnv::from_process()
52        .state_root()
53        .ok_or_else(|| AppError::Usage("no state root resolves".to_string()))?
54        .path)
55}
56
57/// The lock one target takes, keyed by where it is.
58///
59/// A digest rather than the path itself: a lock file named after a
60/// repository would put a person's directory layout in the state root, and
61/// two targets whose paths differ only in case would collide.
62pub(crate) fn target_lock(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
63    let key = Sha256::of(target.as_str().as_bytes());
64    Ok(state_root()?.join("locks").join(format!("{key}.lock")))
65}
66
67/// One release, read through the seam.
68pub(crate) struct Release {
69    pub(crate) bundle: Box<dyn ReleaseBundle>,
70    pub(crate) version: String,
71    pub(crate) provenance: String,
72    pub(crate) checksum: Option<Sha256>,
73    pub(crate) yanked: bool,
74}
75
76pub(crate) fn read_release(to: &str, offline: bool) -> Result<Release, AppError> {
77    match selector(to)? {
78        Selector::Embedded => Ok(Release {
79            bundle: Box::new(EmbeddedReleaseBundle::new()),
80            version: crate::domain::version::CanonVersion::current().to_string(),
81            provenance: "native".to_string(),
82            checksum: None,
83            yanked: false,
84        }),
85        chosen => {
86            let cache = UserEnv::from_process()
87                .user_paths()
88                .ok_or_else(|| AppError::Usage("no cache root resolves".to_string()))?
89                .bundle_cache
90                .path;
91            let resolved = CratesIoResolver::new(&cache)
92                .offline(offline)
93                .resolve(&chosen)?;
94            let manifest = resolved.bundle.manifest()?;
95            let provenance = match manifest.provenance {
96                Provenance::Native => "native",
97                Provenance::LegacyAdapted => "legacy-adapted",
98            };
99            Ok(Release {
100                bundle: resolved.bundle,
101                version: resolved.version.to_string(),
102                provenance: provenance.to_string(),
103                checksum: resolved.registry_checksum,
104                yanked: resolved.yanked,
105            })
106        }
107    }
108}
109
110/// One release a caller already holds, borrowed.
111///
112/// A front resolves its bundle once and hands the same object over, so
113/// the release its preview described is the release that lands. An owned
114/// [`Release`] lends one of these; a caller holding only a bundle builds
115/// one directly.
116#[derive(Clone)]
117pub(crate) struct ReleaseRef<'a> {
118    /// The bytes, through the seam.
119    pub bundle: &'a dyn ReleaseBundle,
120    /// The exact release this is.
121    pub version: String,
122    /// Where its facts came from.
123    pub provenance: &'static str,
124    /// The registry checksum, where one was read.
125    pub checksum: Option<Sha256>,
126    /// Whether the registry marks it withdrawn.
127    pub yanked: bool,
128}
129
130impl<'a> ReleaseRef<'a> {
131    /// Describe a bundle a caller already holds.
132    ///
133    /// # Errors
134    ///
135    /// Whatever the bundle's manifest refuses.
136    pub(crate) fn of(bundle: &'a dyn ReleaseBundle) -> Result<Self, AppError> {
137        let manifest = bundle.manifest()?;
138        Ok(Self {
139            bundle,
140            version: manifest.version.to_string(),
141            provenance: match manifest.provenance {
142                Provenance::Native => "native",
143                Provenance::LegacyAdapted => "legacy-adapted",
144            },
145            checksum: None,
146            yanked: false,
147        })
148    }
149}
150
151impl Release {
152    /// Lend this release to the planner.
153    pub(crate) fn borrow(&self) -> ReleaseRef<'_> {
154        ReleaseRef {
155            bundle: self.bundle.as_ref(),
156            version: self.version.clone(),
157            provenance: if self.provenance == "native" {
158                "native"
159            } else {
160                "legacy-adapted"
161            },
162            checksum: self.checksum.clone(),
163            yanked: self.yanked,
164        }
165    }
166}
167
168/// What every release in the interval asks of this target.
169///
170/// The vocabulary a step filters against is the destination set a landed
171/// instance has, so a step about something the target did not install is
172/// excluded rather than shown.
173fn read_briefing(
174    bundle: &dyn ReleaseBundle,
175    payload_schema: u32,
176    recorded: Option<crate::domain::version::CanonVersion>,
177    destination: crate::domain::version::CanonVersion,
178    selections: &decision::Selections,
179) -> Result<Option<guidance::Briefing>, AppError> {
180    // The same rule the compatibility declaration takes. A release that
181    // owes a ledger and carries none would otherwise plan ready with no
182    // breaking step raised, which is the one outcome the ledger exists to
183    // prevent. Only a release from before the declaration may be silent.
184    let bytes = match bundle.artifact(guidance::INDEX_PATH) {
185        Ok(bytes) => bytes,
186        Err(_) if payload_schema == 0 => return Ok(None),
187        Err(source) => {
188            return Err(AppError::Refused(format!(
189                "this release declares payload schema {payload_schema} and its {} could not be read: {source}",
190                guidance::INDEX_PATH
191            )));
192        }
193    };
194    let index =
195        guidance::Index::parse(&bytes).map_err(|error| AppError::Refused(error.to_string()))?;
196    let bodies: Vec<String> = bundle
197        .manifest()?
198        .artifacts
199        .iter()
200        .map(|artifact| artifact.path.clone())
201        .collect();
202    let mut files = Vec::new();
203    for entry in index.interval(recorded, destination) {
204        if entry.guidance == guidance::NONE {
205            continue;
206        }
207        let path = format!("guidance/{}", entry.guidance);
208        let bytes = bundle.artifact(&path)?;
209        let held = guidance::Guidance::parse(&path, &bytes, &bodies)
210            .map_err(|error| AppError::Refused(error.to_string()))?;
211        files.push((entry.version, held));
212    }
213    // Every destination a landed instance has. An unlanded target has
214    // none, so nothing filters through and the briefing is empty.
215    let held: Vec<String> = if recorded.is_some() {
216        guidance::DESTINATIONS
217            .iter()
218            .map(|destination| (*destination).to_string())
219            .collect()
220    } else {
221        Vec::new()
222    };
223    Ok(Some(guidance::brief(
224        &index, &files, recorded, &held, selections,
225    )))
226}
227
228/// Read the declarations one landing would record, from the answers given.
229fn landing_options(
230    target: &Utf8Path,
231    profile: ProfileId,
232    selections: &decision::Selections,
233    reserve: &[String],
234) -> Result<InitOptions, AppError> {
235    let docs_scratch = selections
236        .get(decision::id::DOCS_SCRATCH)
237        .map(|answer| {
238            let bare = answer
239                .strip_prefix("project:")
240                .or_else(|| answer.strip_prefix("external:"))
241                .unwrap_or(answer);
242            parse_docs_scratch(bare)
243        })
244        .transpose()
245        .map_err(|error| AppError::Usage(format!("--set docs-scratch: {error}")))?;
246    let writing_style = selections
247        .get(decision::id::WRITING_STYLE)
248        .map(|answer| crate::domain::instance_config::WritingStyle::parse_flag(answer))
249        .transpose()
250        .map_err(|error| AppError::Usage(format!("--set writing-style: {error}")))?;
251    Ok(InitOptions {
252        target: target.to_owned(),
253        profile,
254        apply: false,
255        dry_run: true,
256        docs_scratch,
257        reserve: reserve.to_vec(),
258        writing_style,
259    })
260}
261
262/// What the record holds now, which is what a removal can take back.
263fn recorded_managed(
264    observation: &crate::plan::observe::Observation,
265) -> Vec<(String, crate::domain::ownership::Sha256)> {
266    observation
267        .installation
268        .as_ref()
269        .map(|installed| {
270            installed
271                .managed
272                .iter()
273                .map(|file| (file.path.as_str().to_string(), file.recorded.clone()))
274                .collect()
275        })
276        .unwrap_or_default()
277}
278
279/// What the destination needs of this engine.
280///
281/// A schema-one bundle without the declaration is invalid: an absence
282/// meaning "no requirement" cannot be told from an absence meaning
283/// somebody forgot.
284///
285/// # Errors
286///
287/// [`AppError::Refused`] when the declaration does not parse, or when a
288/// bundle that owes one carries none.
289fn read_compatibility(
290    release: &ReleaseRef<'_>,
291    payload_schema: u32,
292) -> Result<Option<compatibility::Compatibility>, AppError> {
293    match release.bundle.artifact(compatibility::DECLARATION_PATH) {
294        Ok(bytes) => compatibility::Compatibility::parse(&bytes)
295            .map(Some)
296            .map_err(|error| AppError::Refused(error.to_string())),
297        Err(_) if payload_schema == 0 => Ok(None),
298        Err(_) => Err(AppError::Refused(format!(
299            "release {} declares payload schema {payload_schema} and carries no {}",
300            release.version,
301            compatibility::DECLARATION_PATH
302        ))),
303    }
304}
305
306/// What the interval between the record and the destination is.
307///
308/// # Errors
309///
310/// [`AppError::Refused`] when the release is not a version triple.
311type Interval = (
312    Option<crate::domain::version::CanonVersion>,
313    crate::domain::version::CanonVersion,
314    Option<compatibility::Interval>,
315);
316
317fn interval_of(
318    observation: &crate::plan::observe::Observation,
319    release: &ReleaseRef<'_>,
320    compatibility: Option<&compatibility::Compatibility>,
321) -> Result<Interval, AppError> {
322    let recorded = observation
323        .installation
324        .as_ref()
325        .map(|installed| installed.canon_version);
326    let destination: crate::domain::version::CanonVersion = release
327        .version
328        .parse()
329        .map_err(|_| AppError::Refused(format!("{} is not a released triple", release.version)))?;
330    let interval = compatibility.map(|_| compatibility::Interval {
331        engine: crate::domain::version::CanonVersion::current(),
332        recorded,
333        destination,
334    });
335    Ok((recorded, destination, interval))
336}
337
338/// Every payload artifact this release carries, by path.
339fn payload_digests(manifest: &crate::release::ReleaseManifest) -> BTreeMap<String, Sha256> {
340    manifest
341        .artifacts
342        .iter()
343        .filter(|artifact| artifact.role == Role::Payload)
344        .map(|artifact| (artifact.path.clone(), artifact.sha256.clone()))
345        .collect()
346}
347
348/// What a front's own flags will put in the record, in one line.
349///
350/// Nothing else in the plan says it: the record's digest cannot go in the
351/// fingerprint, and no other operation carries a declared location.
352fn declared_summary_of(options: &InitOptions) -> String {
353    format!(
354        "docs-scratch={:?};writing-style={:?}",
355        options.docs_scratch, options.writing_style
356    )
357}
358
359/// Everything the landing derivation reads.
360struct Derivation<'a> {
361    target: &'a Utf8Path,
362    profile: ProfileId,
363    declaration: &'a crate::domain::projection::Declaration,
364    observation: &'a crate::plan::observe::Observation,
365    selections: &'a decision::Selections,
366    reserve: &'a [String],
367    declared: Option<&'a InitOptions>,
368    budget: &'a [crate::domain::debt::Measurement],
369}
370
371/// Every write one landing implies, and the bytes each one lands.
372///
373/// The installer owns what a release lands, so this takes its answer and
374/// says what kind of write each destination is, then adds the one write
375/// an operator has to ask for.
376///
377/// # Errors
378///
379/// Whatever the installer or the derivation refuses.
380fn derive_landing(
381    from: &Derivation<'_>,
382    release: &ReleaseRef<'_>,
383) -> Result<crate::plan::derive::Derived, AppError> {
384    let options = match from.declared {
385        Some(held) => held.clone(),
386        None => landing_options(from.target, from.profile, from.selections, from.reserve)?,
387    };
388    let state = compute_target_state(from.target, &options, release.bundle)?;
389    let mut derived = crate::plan::derive::operations_for(
390        from.target,
391        &state.files,
392        from.declaration,
393        from.profile,
394        &recorded_managed(from.observation),
395    )?;
396    // The inherited violations become a ceiling only where the operator
397    // asked for that. A version moving never records one.
398    if from
399        .selections
400        .get(decision::id::DEBT_BASELINE)
401        .map(String::as_str)
402        == Some("record")
403        && let Some((operation, bytes)) =
404            crate::plan::derive::debt_operation(from.target, from.budget)?
405    {
406        derived.1.insert(Sha256::of(&bytes), bytes);
407        derived.0.push(operation);
408    }
409    Ok(derived)
410}
411
412/// Compute one plan against one target.
413///
414/// # Errors
415///
416/// Whatever the observation, the bundle, or the declarations refuse.
417pub(crate) fn compute_plan(
418    target: &Utf8Path,
419    to: &str,
420    offline: bool,
421    selections: &decision::Selections,
422    reserve: &[String],
423    declared: Option<&InitOptions>,
424    release: &ReleaseRef<'_>,
425) -> Result<(Plan, BTreeMap<Sha256, Vec<u8>>), AppError> {
426    let _ = offline;
427    let observation = observe(target)?;
428    let declaration = release.bundle.declaration()?;
429    let manifest = release.bundle.manifest()?;
430    let candidate = payload_digests(&manifest);
431    // The baseline comes from the recorded release. Where that is the
432    // destination, the candidate is the baseline; where it is not, this
433    // engine does not fetch it, and the plan says so rather than guessing.
434    let recorded_is_destination = observation
435        .installation
436        .as_ref()
437        .is_some_and(|installed| installed.canon_version.to_string() == release.version);
438    let baseline = recorded_is_destination.then(|| candidate.clone());
439
440    // The installer owns what a landing writes, so the plan takes its
441    // answer once every declaration the landing records is settled.
442    let profile = observation
443        .installation
444        .as_ref()
445        .map(|installed| installed.profile)
446        .or_else(
447            || match selections.get(decision::id::PROFILE).map(String::as_str) {
448                Some("codebase") => Some(ProfileId::Codebase),
449                Some("knowledge-base") => Some(ProfileId::KnowledgeBase),
450                _ => None,
451            },
452        );
453    // A front that handed over its declarations has answered every
454    // question the planner would otherwise ask for them.
455    let answered = declared.is_some()
456        || observation.installation.is_some()
457        || [decision::id::DOCS_SCRATCH, decision::id::WRITING_STYLE]
458            .iter()
459            .all(|id| selections.contains_key(*id));
460    // A profile change is a named migration, not something a landing verb
461    // does because a flag said so. The record decides for an installed
462    // target, and a caller asking for another one is refused rather than
463    // quietly landing the other profile's files under this one's record.
464    if let (Some(held), Some(asked)) = (
465        observation
466            .installation
467            .as_ref()
468            .map(|installed| installed.profile),
469        declared.map(|options| options.profile),
470    ) && held != asked
471    {
472        return Err(AppError::Refused(format!(
473            "this instance records the {held} profile and the request names {asked}; a profile change is its own migration, not a landing"
474        )));
475    }
476
477    // The same measurements `sdd debt` records, taken once and read twice.
478    // The gates resolve the documentation root the same way here as they
479    // do at commit time, so a finding and a later gate failure agree.
480    let budget = crate::services::budget::measure_all(target).unwrap_or_default();
481
482    let landing = match profile {
483        Some(profile) if answered && observation.invalid.is_none() => Some(derive_landing(
484            &Derivation {
485                target,
486                profile,
487                declaration: &declaration,
488                observation: &observation,
489                selections,
490                reserve,
491                declared,
492                budget: &budget,
493            },
494            release,
495        )?),
496        _ => None,
497    };
498    let (proposed, blobs) = landing.map_or_else(
499        || (None, BTreeMap::new()),
500        |(operations, bytes)| (Some(operations), bytes),
501    );
502
503    let compatibility = read_compatibility(release, declaration.payload_schema)?;
504    let (recorded, destination, interval) =
505        interval_of(&observation, release, compatibility.as_ref())?;
506    let declared_summary = declared.map(declared_summary_of);
507    let briefing = read_briefing(
508        release.bundle,
509        declaration.payload_schema,
510        recorded,
511        destination,
512        selections,
513    )?;
514
515    let computed = compute(&Inputs {
516        observation: &observation,
517        declaration: &declaration,
518        candidate: &candidate,
519        baseline: baseline.as_ref(),
520        selector: to.to_string(),
521        release: release.version.clone(),
522        release_sha256: manifest.payload_sha256,
523        provenance: release.provenance.to_string(),
524        registry_checksum: release.checksum.clone(),
525        yanked: release.yanked,
526        compatibility: compatibility.as_ref(),
527        interval: interval.as_ref(),
528        briefing: briefing.as_ref(),
529        proposed: proposed.as_deref(),
530        selections,
531        budget: &budget,
532        reserve,
533        declared: declared_summary.as_deref(),
534        declarations_settled: declared.is_some(),
535        now: jiff::Timestamp::now().to_string(),
536    });
537    Ok((computed, blobs))
538}
539
540/// Every byte one plan will write, by digest.
541pub(crate) fn blobs_for(plan: &Plan, bundle: &dyn ReleaseBundle) -> BTreeMap<Sha256, Vec<u8>> {
542    let mut blobs = BTreeMap::new();
543    for operation in &plan.operations {
544        let Some(after) = operation.after() else {
545            continue;
546        };
547        if blobs.contains_key(after) {
548            continue;
549        }
550        // A digest the bundle does not carry came from the target state,
551        // which already handed its bytes over.
552        if let Ok(bytes) = bundle.blob(after) {
553            blobs.insert(after.clone(), bytes);
554        }
555    }
556    blobs
557}
558
559/// What one landing asked for.
560pub(crate) struct Landing<'a> {
561    /// The repository, already canonical.
562    pub target: &'a Utf8Path,
563    /// The release that lands, resolved once by the caller.
564    ///
565    /// The object, not a selector to resolve again. A caller that reads a
566    /// fixture or an older release must land that release's bytes, and a
567    /// second resolution here would land whatever this binary carries
568    /// while the caller's preview described something else.
569    pub release: ReleaseRef<'a>,
570    /// Whether the network is forbidden.
571    pub offline: bool,
572    /// Every decision the operator answered, validated against the plan.
573    ///
574    /// Only these. A value the front carries internally, such as the
575    /// profile a record already names, is not an answer anybody typed and
576    /// is not a decision the plan offers, so validating it would refuse a
577    /// correct request.
578    pub selections: decision::Selections,
579    /// Answers the front supplies for itself, which nobody typed.
580    pub carried: decision::Selections,
581    /// Paths no delivered gate judges, from the caller's flags.
582    pub reserve: Vec<String>,
583    /// The declarations a front already holds, where it holds them.
584    ///
585    /// A front carries recorded values that have no flag spelling to
586    /// round-trip through, so it hands them over as they are rather than
587    /// rendering them into answers the planner would parse back.
588    pub declared: Option<InitOptions>,
589}
590
591impl Landing<'_> {
592    /// What the plan records as the caller's request.
593    ///
594    /// The embedded release has no version to look up, so it keeps the
595    /// word rather than a triple: an apply that read the triple back
596    /// would go to the registry for a release this binary already holds.
597    fn selector(&self) -> String {
598        if self.release.provenance == "native"
599            && self.release.version == crate::domain::version::CanonVersion::current().to_string()
600        {
601            "embedded".to_string()
602        } else {
603            self.release.version.clone()
604        }
605    }
606}
607
608/// Take one plan's own lock, after an opportunistic prune.
609///
610/// The prune walks the whole store, so it takes the store lock and gives
611/// up rather than waiting: it is housekeeping, and a landing that skipped
612/// it loses nothing but disk.
613///
614/// # Errors
615///
616/// [`AppError::Busy`] when another writer holds this fingerprint.
617fn plan_lock(store: &Store, fingerprint: &str) -> Result<Lock, AppError> {
618    if let Ok(_walk) = Lock::exclusive(&store.lock_path(), "plan store prune") {
619        let _ = store.prune(jiff::Timestamp::now(), Some(fingerprint));
620    }
621    Lock::exclusive_waiting(&store.plan_lock_path(fingerprint)?, "landing", STORE_WAIT)
622}
623
624/// Say what a refusal to write under the state root actually means.
625///
626/// A landing is a journalled transaction, so it needs somewhere to keep
627/// its lock, its plan, and the bytes it will write. A state root this user
628/// cannot write is therefore a refusal, and the message names the root and
629/// the variable that moves it rather than reporting a bare errno.
630fn unwritable_state(cause: AppError) -> AppError {
631    let AppError::Io(ref source) = cause else {
632        return cause;
633    };
634    if source.kind() != std::io::ErrorKind::PermissionDenied {
635        return cause;
636    }
637    let root = state_root().map_or_else(|_| "the state root".to_string(), |path| path.to_string());
638    AppError::Refused(format!(
639        "{root} cannot be written: {source}; every landing keeps its lock, its plan, and its journal there, so set {} to a directory this user owns",
640        crate::domain::paths::XDG_STATE_HOME_VAR
641    ))
642}
643
644/// Compute the plan one landing would run, and write nothing.
645///
646/// What a preview owes its reader is the plan, not a list of paths. A
647/// preview that could not show a blocked precondition or a decision the
648/// interval raises would tell an operator the run is ready when it is not.
649///
650/// # Errors
651///
652/// [`AppError::Busy`] when a writer holds the target, and whatever the
653/// planner refuses.
654pub(crate) fn preview(request: &Landing<'_>) -> Result<Plan, AppError> {
655    let _lock =
656        Lock::shared(&target_lock(request.target)?, "landing preview").map_err(unwritable_state)?;
657    let mut answers = request.carried.clone();
658    answers.extend(request.selections.clone());
659    let (plan, _) = compute_plan(
660        request.target,
661        &request.selector(),
662        request.offline,
663        &answers,
664        &request.reserve,
665        request.declared.as_ref(),
666        &request.release,
667    )?;
668    decision::validate(&plan.decisions, &request.selections)
669        .map_err(|error| AppError::Usage(error.to_string()))?;
670    Ok(plan)
671}
672
673/// What a preview says about a plan beyond the destinations it names.
674///
675/// One line per thing that would stop the run or ask a question. A ready
676/// plan adds nothing, because the destination list already said it all.
677#[must_use]
678pub(crate) fn preview_lines(plan: &Plan) -> Vec<String> {
679    let mut lines = Vec::new();
680    for precondition in &plan.preconditions {
681        if precondition.requirement == crate::plan::readiness::Requirement::Required
682            && !precondition.evaluation.is_satisfied()
683        {
684            lines.push(format!(
685                "BLOCKED {}: {}",
686                precondition.id, precondition.statement
687            ));
688        }
689    }
690    for decision in &plan.decisions {
691        if decision.selected.is_none() {
692            lines.push(format!("DECISION {}: {}", decision.id, decision.question));
693        }
694    }
695    lines
696}
697
698/// Plan one landing and execute it.
699///
700/// The one path from a target to a write. Every front reaches the engine
701/// here, so an operation is never derived twice and never applied outside
702/// the journal that can take it back. A caller that wants a preview asks
703/// for a plan and does not call this.
704///
705/// # Errors
706///
707/// [`AppError::Busy`] when another writer holds the target, and whatever
708/// the planner, the store, or the executor refuses.
709pub(crate) fn land(request: &Landing<'_>) -> Result<ApplyResult, AppError> {
710    let _lock =
711        Lock::exclusive(&target_lock(request.target)?, "landing").map_err(unwritable_state)?;
712    let release = &request.release;
713    let mut answers = request.carried.clone();
714    answers.extend(request.selections.clone());
715    let (plan, blobs) = compute_plan(
716        request.target,
717        &request.selector(),
718        request.offline,
719        &answers,
720        &request.reserve,
721        request.declared.as_ref(),
722        release,
723    )?;
724    // An answer the plan does not offer is not authorization. The front
725    // verbs take `--set` too, so the check belongs here rather than in one
726    // caller: a malformed answer that reached a precondition would turn
727    // typing into consent.
728    decision::validate(&plan.decisions, &request.selections)
729        .map_err(|error| AppError::Usage(error.to_string()))?;
730
731    let store = Store::new(&state_root()?);
732    // A landing is a journalled transaction, so it needs somewhere to keep
733    // the journal and the bytes it will write. A state root this user
734    // cannot write is therefore a refusal, and the message names the root
735    // and the variable that moves it rather than reporting a bare errno.
736    store.create().map_err(unwritable_state)?;
737    let _store_lock = plan_lock(&store, &plan.identity.plan_id)?;
738    if !store.holds(&plan.identity.plan_id) {
739        let mut blobs = blobs;
740        for (digest, bytes) in blobs_for(&plan, release.bundle) {
741            blobs.entry(digest).or_insert(bytes);
742        }
743        store.put(&plan, &blobs)?;
744    }
745    execute(&Request {
746        store: &store,
747        target: request.target,
748        stored: &plan,
749        // The plan was computed a moment ago under this same exclusive
750        // lock, so nothing could move between the two. The apply still
751        // compares the two fingerprints rather than assuming that.
752        recomputed: &plan,
753        bundle: release.bundle,
754        now: jiff::Timestamp::now().to_string(),
755    })
756}