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//!
25//! # Nothing moves quietly between queues
26//!
27//! The embedded assistant serves a private queue derived from its own identity
28//! ([`super::document::private_task_queue`]), and older versions of the
29//! document served `default` — the queue every out-of-box worker comes up on
30//! (#200). A catalog carried across that change therefore has a routed
31//! assistant on one queue and an embedded assistant on another.
32//!
33//! The install does not resolve that, because resolving it would mean
34//! re-pointing a route the operator placed. What it does instead is SAY it: a
35//! deferred install whose routed version declares a different queue logs at
36//! WARN naming the queue the routed version serves, the queue the embedded
37//! document would claim, why the two differ, and that routing was not touched.
38//! The move happens when the operator deploys, and it is announced before,
39//! not discovered after.
40
41use aion::{Engine, WorkflowVersionInfo};
42use aion_package::ContentHash;
43
44use super::document::{EmbeddedAssistant, embedded_assistant};
45
46/// What the routed resident version declares as its task queue.
47///
48/// Three distinguishable answers, because "the routed assistant is not on the
49/// embedded queue" and "nobody could read what queue it is on" must never be
50/// reported as the same thing: one is a migration to announce, the other is an
51/// unreadable catalog entry.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum RoutedQueues {
54 /// No resident version holds the route, so there is no queue to name.
55 NoRoutedVersion,
56 /// The routed version's contract declares these queues, in contract order.
57 Declared(Vec<String>),
58 /// The routed version's queues could not be read. The reason, verbatim.
59 Unreadable(String),
60}
61
62impl RoutedQueues {
63 /// Whether a deploy of the embedded document would move the assistant onto
64 /// a queue the routed version does not already serve.
65 ///
66 /// Only [`Self::Declared`] can answer: an unreadable entry and an absent
67 /// route both mean "unknown", and an unknown is never reported as a move.
68 #[must_use]
69 pub fn moves_to(&self, embedded_queue: &str) -> bool {
70 match self {
71 Self::Declared(queues) => !queues.iter().any(|queue| queue == embedded_queue),
72 Self::NoRoutedVersion | Self::Unreadable(_) => false,
73 }
74 }
75
76 /// A log-ready rendering: the queues, the absence, or the failure.
77 #[must_use]
78 pub fn describe(&self) -> String {
79 match self {
80 Self::Declared(queues) if queues.is_empty() => String::from("none declared"),
81 Self::Declared(queues) => queues.join(", "),
82 Self::NoRoutedVersion => String::from("no routed version"),
83 Self::Unreadable(reason) => format!("unreadable: {reason}"),
84 }
85 }
86}
87
88/// What the boot install did, and to what.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum AssistantInstall {
91 /// The engine held no version of the assistant type, so the embedded
92 /// document was loaded and routed.
93 Installed {
94 /// The workflow type now routed.
95 workflow_type: String,
96 /// The embedded content hash that took the route.
97 content_hash: String,
98 /// The private queue this install just claimed. Never `default`.
99 task_queue: String,
100 },
101 /// A version was already resident AND routed, and it is the embedded one.
102 AlreadyCurrent {
103 /// The workflow type.
104 workflow_type: String,
105 /// The routed content hash, equal to the embedded one.
106 content_hash: String,
107 /// The private queue the routed version serves. Never `default`.
108 task_queue: String,
109 },
110 /// Versions are resident but the embedded one does not hold the route.
111 /// The cut is the operator's to make.
112 Deferred {
113 /// The workflow type.
114 workflow_type: String,
115 /// The embedded document's content hash.
116 embedded_hash: String,
117 /// The routed hash, or `None` when no resident version holds the route.
118 routed_hash: Option<String>,
119 /// The queue the embedded document would claim on deploy.
120 embedded_queue: String,
121 /// The queue(s) the routed version actually serves right now.
122 routed_queues: RoutedQueues,
123 },
124 /// The install could not be attempted or did not complete. The server keeps
125 /// serving; the assistant is simply not installed by this boot.
126 Failed {
127 /// What refused, verbatim.
128 reason: String,
129 },
130}
131
132impl AssistantInstall {
133 /// A stable label for logs and metrics.
134 #[must_use]
135 pub const fn outcome(&self) -> &'static str {
136 match self {
137 Self::Installed { .. } => "installed",
138 Self::AlreadyCurrent { .. } => "already_current",
139 Self::Deferred { .. } => "deferred",
140 Self::Failed { .. } => "failed",
141 }
142 }
143
144 /// Whether this outcome is a deferred install that ALSO holds the assistant
145 /// on a different queue from the one the embedded document declares.
146 ///
147 /// The queue migration #200 refuses to perform silently: this is the
148 /// condition the boot log announces at WARN, and the condition a test can
149 /// pin without reading log text.
150 #[must_use]
151 pub fn defers_a_queue_move(&self) -> bool {
152 match self {
153 Self::Deferred {
154 embedded_queue,
155 routed_queues,
156 ..
157 } => routed_queues.moves_to(embedded_queue),
158 Self::Installed { .. } | Self::AlreadyCurrent { .. } | Self::Failed { .. } => false,
159 }
160 }
161}
162
163/// Installs the embedded assistant into `engine` under the rule above, logging
164/// exactly what happened.
165///
166/// Never returns an error: a server whose assistant could not be installed is a
167/// server with no assistant, not a server that must refuse to boot. The outcome
168/// is returned (and logged) so the caller and `/assistant` can both say so.
169pub async fn install_embedded_assistant(engine: &Engine) -> AssistantInstall {
170 let embedded = match embedded_assistant() {
171 Ok(embedded) => embedded,
172 Err(error) => {
173 let reason = error.to_string();
174 tracing::error!(
175 operation = "assistant.install",
176 outcome = "failed",
177 %reason,
178 "the embedded assistant document could not be prepared; this server has no \
179 built-in assistant"
180 );
181 return AssistantInstall::Failed { reason };
182 }
183 };
184 let outcome = install_verified(engine, embedded).await;
185 log_outcome(&outcome);
186 outcome
187}
188
189/// Installs the embedded assistant into the engine `state` is serving.
190///
191/// The server's own boot path. An engine that cannot be resolved is reported as
192/// a failed install rather than a failed boot: the transports, the console, and
193/// every other surface stay up.
194pub async fn install_embedded_assistant_for_server(state: &crate::ServerState) -> AssistantInstall {
195 match state.engine() {
196 Ok(engine) => install_embedded_assistant(engine.as_ref()).await,
197 Err(error) => {
198 let reason = format!("the engine is not available: {error}");
199 let outcome = AssistantInstall::Failed { reason };
200 log_outcome(&outcome);
201 outcome
202 }
203 }
204}
205
206/// The install decision itself, over an already-prepared embedded assistant.
207async fn install_verified(engine: &Engine, embedded: &EmbeddedAssistant) -> AssistantInstall {
208 let workflow_type = embedded.workflow_type().to_owned();
209 let embedded_hash = embedded.content_hash().to_string();
210
211 let versions = match engine.list_workflow_versions() {
212 Ok(versions) => versions,
213 Err(error) => {
214 return AssistantInstall::Failed {
215 reason: format!("the engine catalog could not be read: {error}"),
216 };
217 }
218 };
219 let resident: Vec<_> = versions
220 .into_iter()
221 .filter(|version| version.workflow_type == workflow_type)
222 .collect();
223
224 if resident.is_empty() {
225 return match engine.load_package(embedded.package().clone()).await {
226 Ok(_) => AssistantInstall::Installed {
227 workflow_type,
228 content_hash: embedded_hash,
229 task_queue: embedded.task_queue().to_owned(),
230 },
231 Err(error) => AssistantInstall::Failed {
232 reason: format!(
233 "the embedded assistant package `{embedded_hash}` did not load: {error}"
234 ),
235 },
236 };
237 }
238
239 let routed = resident.iter().find(|version| version.route_active);
240 let routed_hash = routed.map(|version| version.content_hash.to_string());
241 if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
242 return AssistantInstall::AlreadyCurrent {
243 workflow_type,
244 content_hash: embedded_hash,
245 task_queue: embedded.task_queue().to_owned(),
246 };
247 }
248 let routed_queues = routed.map_or(RoutedQueues::NoRoutedVersion, |version| {
249 routed_queues(engine, version)
250 });
251 AssistantInstall::Deferred {
252 workflow_type,
253 embedded_hash,
254 routed_hash,
255 embedded_queue: embedded.task_queue().to_owned(),
256 routed_queues,
257 }
258}
259
260/// Reads the queues the routed version's own contract declares.
261///
262/// Read from the CATALOG entry for that exact `(workflow_type, content_hash)`,
263/// never from the embedded document: the whole point is to say what the version
264/// an operator placed is serving, which the binary's own document cannot know.
265/// Every failure to read is reported as [`RoutedQueues::Unreadable`] carrying
266/// the reason — an unknown queue is never rendered as an empty one, because
267/// "declares nothing" and "could not be read" lead an operator to opposite acts.
268fn routed_queues(engine: &Engine, version: &WorkflowVersionInfo) -> RoutedQueues {
269 let hash: &ContentHash = &version.content_hash;
270 let loaded = match engine.workflow_catalog().get(&version.workflow_type, hash) {
271 Ok(Some(loaded)) => loaded,
272 Ok(None) => {
273 return RoutedQueues::Unreadable(format!(
274 "the catalog lists `{}` at `{hash}` but holds no entry for it",
275 version.workflow_type
276 ));
277 }
278 Err(error) => {
279 return RoutedQueues::Unreadable(format!("the catalog could not be read: {error}"));
280 }
281 };
282 match loaded.contract() {
283 Ok(contract) => RoutedQueues::Declared(
284 contract
285 .workers
286 .iter()
287 .map(|worker| worker.task_queue.clone())
288 .collect(),
289 ),
290 Err(error) => RoutedQueues::Unreadable(format!(
291 "the routed version's contract could not be read: {error}"
292 )),
293 }
294}
295
296/// One structured line per boot stating what the install did, and — when it
297/// stood down — what the operator would run to complete the cut.
298fn log_outcome(outcome: &AssistantInstall) {
299 match outcome {
300 AssistantInstall::Installed {
301 workflow_type,
302 content_hash,
303 task_queue,
304 } => tracing::info!(
305 operation = "assistant.install",
306 outcome = outcome.outcome(),
307 %workflow_type,
308 %content_hash,
309 %task_queue,
310 "the built-in assistant was installed and routed on a catalog that held no version \
311 of it; it serves its own private queue, so `default` is free for the workers an \
312 operator brings up"
313 ),
314 AssistantInstall::AlreadyCurrent {
315 workflow_type,
316 content_hash,
317 task_queue,
318 } => tracing::info!(
319 operation = "assistant.install",
320 outcome = outcome.outcome(),
321 %workflow_type,
322 %content_hash,
323 %task_queue,
324 "the built-in assistant is already the routed version"
325 ),
326 AssistantInstall::Deferred {
327 workflow_type,
328 embedded_hash,
329 routed_hash,
330 embedded_queue,
331 routed_queues,
332 } => {
333 // `none` means no resident version holds the route at all — not
334 // that the embedded one does. Both cases stand down for the same
335 // reason: this catalog already holds versions nobody here placed.
336 let routed_hash = routed_hash.as_deref().unwrap_or("none");
337 let routed_queue = routed_queues.describe();
338 if outcome.defers_a_queue_move() {
339 // THE LOUD LINE. This catalog's assistant serves a queue the
340 // embedded document no longer declares, and the deploy that
341 // fixes that is a QUEUE MOVE: the worker serving the old queue
342 // stops receiving the assistant's dispatches the moment the
343 // new version routes. Nothing here performs it. It is named in
344 // full — from, to, and why — so the move is a decision the
345 // operator makes with the facts, not one they reconstruct from
346 // a stalled session afterwards.
347 tracing::warn!(
348 operation = "assistant.install",
349 outcome = outcome.outcome(),
350 %workflow_type,
351 %embedded_hash,
352 routed_hash,
353 from_task_queue = %routed_queue,
354 to_task_queue = %embedded_queue,
355 "QUEUE MOVE PENDING: the assistant routed on this catalog serves task queue \
356 `{routed_queue}`, and the assistant embedded in this binary declares the \
357 private queue `{embedded_queue}` instead — the built-in assistant no longer \
358 claims `default`, because that is the queue every out-of-box worker comes up \
359 on and a worker refused there is a worker that never starts (#200). NOTHING \
360 WAS MOVED: routing is untouched and the sessions on the routed version keep \
361 running on `{routed_queue}`. To make the move deliberately: `aion assistant \
362 document --output assistant.awl`, then `aion deploy assistant.awl` (which \
363 loads AND routes it), and then start a worker on `{embedded_queue}` — a \
364 worker still serving `{routed_queue}` will not receive the new version's \
365 dispatches"
366 );
367 } else {
368 tracing::warn!(
369 operation = "assistant.install",
370 outcome = outcome.outcome(),
371 %workflow_type,
372 %embedded_hash,
373 routed_hash,
374 routed_task_queue = %routed_queue,
375 embedded_task_queue = %embedded_queue,
376 "the embedded assistant document is not the routed version on this catalog, \
377 and routing was NOT changed — a restart must never move a route an operator \
378 chose. To cut over deliberately: `aion assistant document --output \
379 assistant.awl`, then `aion deploy assistant.awl` (which loads AND routes \
380 it), and only then restart the worker serving its queue"
381 );
382 }
383 }
384 AssistantInstall::Failed { reason } => tracing::error!(
385 operation = "assistant.install",
386 outcome = outcome.outcome(),
387 %reason,
388 "the built-in assistant was not installed"
389 ),
390 }
391}
392
393#[cfg(test)]
394#[path = "install_tests.rs"]
395mod install_tests;