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//!
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/// The connection facts the queue-move announcement renders (#209).
164///
165/// `listener` is the address a liminal worker listener WILL be bound on this
166/// boot — `None` when no listener will exist. It is NOT the raw configured
167/// address: an address alone does not imply a listener (the dispatcher is
168/// spawned only when the outbox is enabled on the liminal transport), and
169/// keying the announcement off the raw field printed a dial command that was
170/// guaranteed to hang on an `enabled = false` boot — the first-run template's
171/// own memory-backend instruction leaves the address line in place. It is
172/// derived by [`Self::for_boot`]; no other constructor can claim a listener.
173///
174/// `config_hint` is where the operator commissions a listener when there is
175/// none: the boot's resolved config-source hint (`run::liminal_address_hint`,
176/// which names the actual file), or [`Self::without_boot_context`]'s generic
177/// key-naming form where no boot context exists.
178#[derive(Clone, Copy, Debug)]
179pub struct WorkerListenerAdvice<'a> {
180    /// Address a liminal worker listener will be bound on, if any.
181    listener: Option<&'a str>,
182    /// Where the operator adds `liminal_listen_address` when `listener` is `None`.
183    config_hint: &'a str,
184}
185
186impl<'a> WorkerListenerAdvice<'a> {
187    /// Advice for a server boot: the listener claim is DERIVED from the boot's
188    /// own outbox configuration through [`liminal_worker_listener`], never
189    /// passed in raw. The fields are private and this is the only constructor
190    /// that can claim a listener, so a call site outside this module cannot
191    /// hand the announcement the raw configured address again by accident —
192    /// the mistake that was #209's second face.
193    #[must_use]
194    pub fn for_boot(outbox: &'a crate::config::OutboxConfig, config_hint: &'a str) -> Self {
195        Self {
196            listener: liminal_worker_listener(outbox),
197            config_hint,
198        }
199    }
200}
201
202impl WorkerListenerAdvice<'static> {
203    /// Advice for an install with no boot context (direct engine installs, as
204    /// the tests and library callers perform): no listener claim is made, and
205    /// the hint can only name the key and its environment override, not a file.
206    #[must_use]
207    pub fn without_boot_context() -> Self {
208        Self {
209            listener: None,
210            config_hint: "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in a \
211                          config file",
212        }
213    }
214}
215
216/// The address a liminal worker listener will be bound on this boot, if any.
217///
218/// The single predicate for [`WorkerListenerAdvice::listener`]: the listener
219/// exists only when the outbox dispatcher is enabled AND on the liminal
220/// transport AND an address is configured — the same gate
221/// `run::maybe_spawn_outbox_dispatcher` applies before building the liminal
222/// dispatch path. Any weaker read (the raw address field) re-creates #209 one
223/// arm over: a printed command that dials a port nothing listens on.
224///
225/// KNOWN LIMIT, accepted: this is the CONFIGURED listen address, read before
226/// the listener binds (the install runs early in boot; the bind happens later
227/// in the serve phase), so a wildcard host (`0.0.0.0`, `[::]`) or `:0` port
228/// prints a target the operator must translate to a dialable one. Emitting
229/// the bound address would need the announcement moved past the bind point.
230#[must_use]
231fn liminal_worker_listener(outbox: &crate::config::OutboxConfig) -> Option<&str> {
232    (outbox.enabled && matches!(outbox.transport, crate::config::OutboxTransport::Liminal))
233        .then_some(outbox.liminal_listen_address.as_deref())
234        .flatten()
235}
236
237/// Installs the embedded assistant into `engine` under the rule above, logging
238/// exactly what happened.
239///
240/// Never returns an error: a server whose assistant could not be installed is a
241/// server with no assistant, not a server that must refuse to boot. The outcome
242/// is returned (and logged) so the caller and `/assistant` can both say so.
243pub async fn install_embedded_assistant(
244    engine: &Engine,
245    advice: WorkerListenerAdvice<'_>,
246) -> AssistantInstall {
247    let embedded = match embedded_assistant() {
248        Ok(embedded) => embedded,
249        Err(error) => {
250            let reason = error.to_string();
251            tracing::error!(
252                operation = "assistant.install",
253                outcome = "failed",
254                %reason,
255                "the embedded assistant document could not be prepared; this server has no \
256                 built-in assistant"
257            );
258            return AssistantInstall::Failed { reason };
259        }
260    };
261    let outcome = install_verified(engine, embedded).await;
262    log_outcome(&outcome, advice);
263    outcome
264}
265
266/// Installs the embedded assistant into the engine `state` is serving.
267///
268/// The server's own boot path. An engine that cannot be resolved is reported as
269/// a failed install rather than a failed boot: the transports, the console, and
270/// every other surface stay up.
271///
272/// `liminal_address_hint` is the boot's resolved where-to-edit hint (the one
273/// naming the actual config file), threaded so the queue-move announcement's
274/// no-listener arm sends the operator to the right file rather than only the
275/// right key (#209 R1-m3).
276pub async fn install_embedded_assistant_for_server(
277    state: &crate::ServerState,
278    liminal_address_hint: &str,
279) -> AssistantInstall {
280    let advice =
281        WorkerListenerAdvice::for_boot(&state.runtime_config().outbox, liminal_address_hint);
282    match state.engine() {
283        Ok(engine) => install_embedded_assistant(engine.as_ref(), advice).await,
284        Err(error) => {
285            let reason = format!("the engine is not available: {error}");
286            let outcome = AssistantInstall::Failed { reason };
287            log_outcome(&outcome, advice);
288            outcome
289        }
290    }
291}
292
293/// The install decision itself, over an already-prepared embedded assistant.
294async fn install_verified(engine: &Engine, embedded: &EmbeddedAssistant) -> AssistantInstall {
295    let workflow_type = embedded.workflow_type().to_owned();
296    let embedded_hash = embedded.content_hash().to_string();
297
298    let versions = match engine.list_workflow_versions() {
299        Ok(versions) => versions,
300        Err(error) => {
301            return AssistantInstall::Failed {
302                reason: format!("the engine catalog could not be read: {error}"),
303            };
304        }
305    };
306    let resident: Vec<_> = versions
307        .into_iter()
308        .filter(|version| version.workflow_type == workflow_type)
309        .collect();
310
311    if resident.is_empty() {
312        return match engine.load_package(embedded.package().clone()).await {
313            Ok(_) => AssistantInstall::Installed {
314                workflow_type,
315                content_hash: embedded_hash,
316                task_queue: embedded.task_queue().to_owned(),
317            },
318            Err(error) => AssistantInstall::Failed {
319                reason: format!(
320                    "the embedded assistant package `{embedded_hash}` did not load: {error}"
321                ),
322            },
323        };
324    }
325
326    let routed = resident.iter().find(|version| version.route_active);
327    let routed_hash = routed.map(|version| version.content_hash.to_string());
328    if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
329        return AssistantInstall::AlreadyCurrent {
330            workflow_type,
331            content_hash: embedded_hash,
332            task_queue: embedded.task_queue().to_owned(),
333        };
334    }
335    let routed_queues = routed.map_or(RoutedQueues::NoRoutedVersion, |version| {
336        routed_queues(engine, version)
337    });
338    AssistantInstall::Deferred {
339        workflow_type,
340        embedded_hash,
341        routed_hash,
342        embedded_queue: embedded.task_queue().to_owned(),
343        routed_queues,
344    }
345}
346
347/// Reads the queues the routed version's own contract declares.
348///
349/// Read from the CATALOG entry for that exact `(workflow_type, content_hash)`,
350/// never from the embedded document: the whole point is to say what the version
351/// an operator placed is serving, which the binary's own document cannot know.
352/// Every failure to read is reported as [`RoutedQueues::Unreadable`] carrying
353/// the reason — an unknown queue is never rendered as an empty one, because
354/// "declares nothing" and "could not be read" lead an operator to opposite acts.
355fn routed_queues(engine: &Engine, version: &WorkflowVersionInfo) -> RoutedQueues {
356    let hash: &ContentHash = &version.content_hash;
357    let loaded = match engine.workflow_catalog().get(&version.workflow_type, hash) {
358        Ok(Some(loaded)) => loaded,
359        Ok(None) => {
360            return RoutedQueues::Unreadable(format!(
361                "the catalog lists `{}` at `{hash}` but holds no entry for it",
362                version.workflow_type
363            ));
364        }
365        Err(error) => {
366            return RoutedQueues::Unreadable(format!("the catalog could not be read: {error}"));
367        }
368    };
369    match loaded.contract() {
370        Ok(contract) => RoutedQueues::Declared(
371            contract
372                .workers
373                .iter()
374                .map(|worker| worker.task_queue.clone())
375                .collect(),
376        ),
377        Err(error) => RoutedQueues::Unreadable(format!(
378            "the routed version's contract could not be read: {error}"
379        )),
380    }
381}
382
383/// The one worker-step command, rendered from a single place so the two
384/// announcement arms cannot drift apart (#209 R1-m2). `address` is the real
385/// listener address on the arm that has one, or the `<that address>`
386/// placeholder the operator substitutes after commissioning a listener.
387fn worker_agent_command(address: &str) -> String {
388    format!(
389        "aion worker agent assistant.awl --liminal-address {address} --identity assistant-worker"
390    )
391}
392
393/// One structured line per boot stating what the install did, and — when it
394/// stood down — what the operator would run to complete the cut.
395///
396/// `advice.listener` is the address a liminal worker listener WILL be bound on
397/// this boot (see [`liminal_worker_listener`] — NOT the raw configured field),
398/// threaded in so the queue-move announcement can print the worker launch
399/// command with the REAL address in it. An instruction an operator must first
400/// complete by hand is one they can get wrong; the server knows the address,
401/// so the server says it (#209). `None` is a boot that will bind no liminal
402/// listener — the announcement then says a worker has nothing to dial and
403/// names every setting that changes that, instead of printing a command
404/// guaranteed to hang.
405fn log_outcome(outcome: &AssistantInstall, advice: WorkerListenerAdvice<'_>) {
406    match outcome {
407        AssistantInstall::Installed {
408            workflow_type,
409            content_hash,
410            task_queue,
411        } => tracing::info!(
412            operation = "assistant.install",
413            outcome = outcome.outcome(),
414            %workflow_type,
415            %content_hash,
416            %task_queue,
417            "the built-in assistant was installed and routed on a catalog that held no version \
418             of it; it serves its own private queue, so `default` is free for the workers an \
419             operator brings up"
420        ),
421        AssistantInstall::AlreadyCurrent {
422            workflow_type,
423            content_hash,
424            task_queue,
425        } => tracing::info!(
426            operation = "assistant.install",
427            outcome = outcome.outcome(),
428            %workflow_type,
429            %content_hash,
430            %task_queue,
431            "the built-in assistant is already the routed version"
432        ),
433        AssistantInstall::Deferred {
434            workflow_type,
435            embedded_hash,
436            routed_hash,
437            embedded_queue,
438            routed_queues,
439        } => {
440            // `none` means no resident version holds the route at all — not
441            // that the embedded one does. Both cases stand down for the same
442            // reason: this catalog already holds versions nobody here placed.
443            let routed_hash = routed_hash.as_deref().unwrap_or("none");
444            let routed_queue = routed_queues.describe();
445            if outcome.defers_a_queue_move() {
446                // THE LOUD LINE. This catalog's assistant serves a queue the
447                // embedded document no longer declares, and the deploy that
448                // fixes that is a QUEUE MOVE: the worker serving the old queue
449                // stops receiving the assistant's dispatches the moment the
450                // new version routes. Nothing here performs it. It is named in
451                // full — from, to, and why — so the move is a decision the
452                // operator makes with the facts, not one they reconstruct from
453                // a stalled session afterwards.
454                //
455                // The worker step is a COMMAND, not a description: the
456                // document carries its own launch in its `harness` section
457                // (#204), so the whole remaining variable is the connection —
458                // and the address half of that is this server's own setting,
459                // printed here rather than left for the operator to hunt down.
460                let worker_step = match advice.listener {
461                    Some(address) => format!(
462                        "start the worker the document itself configures: \
463                         `{}` (the launch — harness, concurrency, reconnects, environment — is \
464                         in the document's `harness` section; only the connection is a flag)",
465                        worker_agent_command(address)
466                    ),
467                    None => format!(
468                        "start a worker on the new queue — but this boot binds NO liminal \
469                         worker listener, so an agent worker has nothing to dial. Commission \
470                         one under `[outbox]`: set `enabled = true`, `transport = \"liminal\"`, \
471                         and the listen address ({hint}; env override \
472                         AION_OUTBOX_LIMINAL_LISTEN_ADDRESS); note `enabled = true` requires \
473                         a durable store backend — a `[store] backend = \"memory\"` boot \
474                         refuses it. Then restart, and `{command}`",
475                        hint = advice.config_hint,
476                        command = worker_agent_command("<that address>")
477                    ),
478                };
479                tracing::warn!(
480                    operation = "assistant.install",
481                    outcome = outcome.outcome(),
482                    %workflow_type,
483                    %embedded_hash,
484                    routed_hash,
485                    from_task_queue = %routed_queue,
486                    to_task_queue = %embedded_queue,
487                    "QUEUE MOVE PENDING: the assistant routed on this catalog serves task queue \
488                     `{routed_queue}`, and the assistant embedded in this binary declares the \
489                     private queue `{embedded_queue}` instead — the built-in assistant no longer \
490                     claims `default`, because that is the queue every out-of-box worker comes up \
491                     on and a worker refused there is a worker that never starts (#200). NOTHING \
492                     WAS MOVED: routing is untouched and the sessions on the routed version keep \
493                     running on `{routed_queue}`. To make the move deliberately: `aion assistant \
494                     document --output assistant.awl`, then `aion deploy assistant.awl` (which \
495                     loads AND routes it), and then {worker_step} — a worker still serving \
496                     `{routed_queue}` will not receive the new version's dispatches"
497                );
498            } else {
499                tracing::warn!(
500                    operation = "assistant.install",
501                    outcome = outcome.outcome(),
502                    %workflow_type,
503                    %embedded_hash,
504                    routed_hash,
505                    routed_task_queue = %routed_queue,
506                    embedded_task_queue = %embedded_queue,
507                    "the embedded assistant document is not the routed version on this catalog, \
508                     and routing was NOT changed — a restart must never move a route an operator \
509                     chose. To cut over deliberately: `aion assistant document --output \
510                     assistant.awl`, then `aion deploy assistant.awl` (which loads AND routes \
511                     it), and only then restart the worker serving its queue"
512                );
513            }
514        }
515        AssistantInstall::Failed { reason } => tracing::error!(
516            operation = "assistant.install",
517            outcome = outcome.outcome(),
518            %reason,
519            "the built-in assistant was not installed"
520        ),
521    }
522}
523
524#[cfg(test)]
525#[path = "install_tests.rs"]
526mod install_tests;