Skip to main content

aion_server/assistant/
install.rs

1//! Boot-time installation of the embedded assistant into the engine catalog.
2//!
3//! # The rule, in one sentence
4//!
5//! The embedded document is loaded (and therefore routed) ONLY when the engine
6//! holds no version of the assistant workflow type at all.
7//!
8//! # Why it is that narrow
9//!
10//! [`aion::Engine::load_package`] re-points routing for a package's workflow
11//! type on every load, idempotent hash or not — that is correct for an operator
12//! deploy (re-deploying a rolled-back version must take effect) and wrong for a
13//! restart. A server that loaded the embedded document unconditionally at boot
14//! would undo an operator's rollback every time the process restarted, without
15//! anyone asking it to, and the session parked on the previous version would
16//! find its type pointing somewhere else. So the install claims only the empty
17//! case, which no operator decision can be sitting in.
18//!
19//! When a version IS resident the install stands down and reports which — an
20//! [`AssistantInstall::AlreadyCurrent`] when the routed hash is the embedded
21//! one, an [`AssistantInstall::Deferred`] naming both hashes otherwise. A
22//! deferred install is not a failure: it is the live cut waiting for the
23//! operator, and the log line carries the verbs that perform it.
24
25use aion::Engine;
26
27use super::document::{EmbeddedAssistant, embedded_assistant};
28
29/// What the boot install did, and to what.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum AssistantInstall {
32    /// The engine held no version of the assistant type, so the embedded
33    /// document was loaded and routed.
34    Installed {
35        /// The workflow type now routed.
36        workflow_type: String,
37        /// The embedded content hash that took the route.
38        content_hash: String,
39    },
40    /// A version was already resident AND routed, and it is the embedded one.
41    AlreadyCurrent {
42        /// The workflow type.
43        workflow_type: String,
44        /// The routed content hash, equal to the embedded one.
45        content_hash: String,
46    },
47    /// Versions are resident but the embedded one does not hold the route.
48    /// The cut is the operator's to make.
49    Deferred {
50        /// The workflow type.
51        workflow_type: String,
52        /// The embedded document's content hash.
53        embedded_hash: String,
54        /// The routed hash, or `None` when no resident version holds the route.
55        routed_hash: Option<String>,
56    },
57    /// The install could not be attempted or did not complete. The server keeps
58    /// serving; the assistant is simply not installed by this boot.
59    Failed {
60        /// What refused, verbatim.
61        reason: String,
62    },
63}
64
65impl AssistantInstall {
66    /// A stable label for logs and metrics.
67    #[must_use]
68    pub const fn outcome(&self) -> &'static str {
69        match self {
70            Self::Installed { .. } => "installed",
71            Self::AlreadyCurrent { .. } => "already_current",
72            Self::Deferred { .. } => "deferred",
73            Self::Failed { .. } => "failed",
74        }
75    }
76}
77
78/// Installs the embedded assistant into `engine` under the rule above, logging
79/// exactly what happened.
80///
81/// Never returns an error: a server whose assistant could not be installed is a
82/// server with no assistant, not a server that must refuse to boot. The outcome
83/// is returned (and logged) so the caller and `/assistant` can both say so.
84pub async fn install_embedded_assistant(engine: &Engine) -> AssistantInstall {
85    let embedded = match embedded_assistant() {
86        Ok(embedded) => embedded,
87        Err(error) => {
88            let reason = error.to_string();
89            tracing::error!(
90                operation = "assistant.install",
91                outcome = "failed",
92                %reason,
93                "the embedded assistant document could not be prepared; this server has no \
94                 built-in assistant"
95            );
96            return AssistantInstall::Failed { reason };
97        }
98    };
99    let outcome = install_verified(engine, embedded).await;
100    log_outcome(&outcome);
101    outcome
102}
103
104/// Installs the embedded assistant into the engine `state` is serving.
105///
106/// The server's own boot path. An engine that cannot be resolved is reported as
107/// a failed install rather than a failed boot: the transports, the console, and
108/// every other surface stay up.
109pub async fn install_embedded_assistant_for_server(state: &crate::ServerState) -> AssistantInstall {
110    match state.engine() {
111        Ok(engine) => install_embedded_assistant(engine.as_ref()).await,
112        Err(error) => {
113            let reason = format!("the engine is not available: {error}");
114            let outcome = AssistantInstall::Failed { reason };
115            log_outcome(&outcome);
116            outcome
117        }
118    }
119}
120
121/// The install decision itself, over an already-prepared embedded assistant.
122async fn install_verified(engine: &Engine, embedded: &EmbeddedAssistant) -> AssistantInstall {
123    let workflow_type = embedded.workflow_type().to_owned();
124    let embedded_hash = embedded.content_hash().to_string();
125
126    let versions = match engine.list_workflow_versions() {
127        Ok(versions) => versions,
128        Err(error) => {
129            return AssistantInstall::Failed {
130                reason: format!("the engine catalog could not be read: {error}"),
131            };
132        }
133    };
134    let resident: Vec<_> = versions
135        .into_iter()
136        .filter(|version| version.workflow_type == workflow_type)
137        .collect();
138
139    if resident.is_empty() {
140        return match engine.load_package(embedded.package().clone()).await {
141            Ok(_) => AssistantInstall::Installed {
142                workflow_type,
143                content_hash: embedded_hash,
144            },
145            Err(error) => AssistantInstall::Failed {
146                reason: format!(
147                    "the embedded assistant package `{embedded_hash}` did not load: {error}"
148                ),
149            },
150        };
151    }
152
153    let routed_hash = resident
154        .iter()
155        .find(|version| version.route_active)
156        .map(|version| version.content_hash.to_string());
157    if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
158        return AssistantInstall::AlreadyCurrent {
159            workflow_type,
160            content_hash: embedded_hash,
161        };
162    }
163    AssistantInstall::Deferred {
164        workflow_type,
165        embedded_hash,
166        routed_hash,
167    }
168}
169
170/// One structured line per boot stating what the install did, and — when it
171/// stood down — what the operator would run to complete the cut.
172fn log_outcome(outcome: &AssistantInstall) {
173    match outcome {
174        AssistantInstall::Installed {
175            workflow_type,
176            content_hash,
177        } => tracing::info!(
178            operation = "assistant.install",
179            outcome = outcome.outcome(),
180            %workflow_type,
181            %content_hash,
182            "the built-in assistant was installed and routed on a catalog that held no version \
183             of it"
184        ),
185        AssistantInstall::AlreadyCurrent {
186            workflow_type,
187            content_hash,
188        } => tracing::info!(
189            operation = "assistant.install",
190            outcome = outcome.outcome(),
191            %workflow_type,
192            %content_hash,
193            "the built-in assistant is already the routed version"
194        ),
195        AssistantInstall::Deferred {
196            workflow_type,
197            embedded_hash,
198            routed_hash,
199        } => tracing::warn!(
200            operation = "assistant.install",
201            outcome = outcome.outcome(),
202            %workflow_type,
203            %embedded_hash,
204            // `none` means no resident version holds the route at all — not
205            // that the embedded one does. Both cases stand down for the same
206            // reason: this catalog already holds versions nobody here placed.
207            routed_hash = routed_hash.as_deref().unwrap_or("none"),
208            "the embedded assistant document is not the routed version on this catalog, and \
209             routing was NOT changed — a restart must never move a route an operator chose. \
210             To cut over deliberately: `aion assistant document --output assistant.awl`, then \
211             `aion deploy assistant.awl` (which loads AND routes it), and only then restart \
212             the worker serving its queue"
213        ),
214        AssistantInstall::Failed { reason } => tracing::error!(
215            operation = "assistant.install",
216            outcome = outcome.outcome(),
217            %reason,
218            "the built-in assistant was not installed"
219        ),
220    }
221}
222
223#[cfg(test)]
224#[path = "install_tests.rs"]
225mod install_tests;