use aion::{Engine, WorkflowVersionInfo};
use aion_package::ContentHash;
use super::document::{EmbeddedAssistant, embedded_assistant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoutedQueues {
NoRoutedVersion,
Declared(Vec<String>),
Unreadable(String),
}
impl RoutedQueues {
#[must_use]
pub fn moves_to(&self, embedded_queue: &str) -> bool {
match self {
Self::Declared(queues) => !queues.iter().any(|queue| queue == embedded_queue),
Self::NoRoutedVersion | Self::Unreadable(_) => false,
}
}
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Declared(queues) if queues.is_empty() => String::from("none declared"),
Self::Declared(queues) => queues.join(", "),
Self::NoRoutedVersion => String::from("no routed version"),
Self::Unreadable(reason) => format!("unreadable: {reason}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssistantInstall {
Installed {
workflow_type: String,
content_hash: String,
task_queue: String,
},
AlreadyCurrent {
workflow_type: String,
content_hash: String,
task_queue: String,
},
Deferred {
workflow_type: String,
embedded_hash: String,
routed_hash: Option<String>,
embedded_queue: String,
routed_queues: RoutedQueues,
},
Failed {
reason: String,
},
}
impl AssistantInstall {
#[must_use]
pub const fn outcome(&self) -> &'static str {
match self {
Self::Installed { .. } => "installed",
Self::AlreadyCurrent { .. } => "already_current",
Self::Deferred { .. } => "deferred",
Self::Failed { .. } => "failed",
}
}
#[must_use]
pub fn defers_a_queue_move(&self) -> bool {
match self {
Self::Deferred {
embedded_queue,
routed_queues,
..
} => routed_queues.moves_to(embedded_queue),
Self::Installed { .. } | Self::AlreadyCurrent { .. } | Self::Failed { .. } => false,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct WorkerListenerAdvice<'a> {
listener: Option<&'a str>,
config_hint: &'a str,
}
impl<'a> WorkerListenerAdvice<'a> {
#[must_use]
pub fn for_boot(outbox: &'a crate::config::OutboxConfig, config_hint: &'a str) -> Self {
Self {
listener: liminal_worker_listener(outbox),
config_hint,
}
}
}
impl WorkerListenerAdvice<'static> {
#[must_use]
pub fn without_boot_context() -> Self {
Self {
listener: None,
config_hint: "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in a \
config file",
}
}
}
#[must_use]
fn liminal_worker_listener(outbox: &crate::config::OutboxConfig) -> Option<&str> {
(outbox.enabled && matches!(outbox.transport, crate::config::OutboxTransport::Liminal))
.then_some(outbox.liminal_listen_address.as_deref())
.flatten()
}
pub async fn install_embedded_assistant(
engine: &Engine,
advice: WorkerListenerAdvice<'_>,
) -> AssistantInstall {
let embedded = match embedded_assistant() {
Ok(embedded) => embedded,
Err(error) => {
let reason = error.to_string();
tracing::error!(
operation = "assistant.install",
outcome = "failed",
%reason,
"the embedded assistant document could not be prepared; this server has no \
built-in assistant"
);
return AssistantInstall::Failed { reason };
}
};
let outcome = install_verified(engine, embedded).await;
log_outcome(&outcome, advice);
outcome
}
pub async fn install_embedded_assistant_for_server(
state: &crate::ServerState,
liminal_address_hint: &str,
) -> AssistantInstall {
let advice =
WorkerListenerAdvice::for_boot(&state.runtime_config().outbox, liminal_address_hint);
match state.engine() {
Ok(engine) => install_embedded_assistant(engine.as_ref(), advice).await,
Err(error) => {
let reason = format!("the engine is not available: {error}");
let outcome = AssistantInstall::Failed { reason };
log_outcome(&outcome, advice);
outcome
}
}
}
async fn install_verified(engine: &Engine, embedded: &EmbeddedAssistant) -> AssistantInstall {
let workflow_type = embedded.workflow_type().to_owned();
let embedded_hash = embedded.content_hash().to_string();
let versions = match engine.list_workflow_versions() {
Ok(versions) => versions,
Err(error) => {
return AssistantInstall::Failed {
reason: format!("the engine catalog could not be read: {error}"),
};
}
};
let resident: Vec<_> = versions
.into_iter()
.filter(|version| version.workflow_type == workflow_type)
.collect();
if resident.is_empty() {
return match engine.load_package(embedded.package().clone()).await {
Ok(_) => AssistantInstall::Installed {
workflow_type,
content_hash: embedded_hash,
task_queue: embedded.task_queue().to_owned(),
},
Err(error) => AssistantInstall::Failed {
reason: format!(
"the embedded assistant package `{embedded_hash}` did not load: {error}"
),
},
};
}
let routed = resident.iter().find(|version| version.route_active);
let routed_hash = routed.map(|version| version.content_hash.to_string());
if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
return AssistantInstall::AlreadyCurrent {
workflow_type,
content_hash: embedded_hash,
task_queue: embedded.task_queue().to_owned(),
};
}
let routed_queues = routed.map_or(RoutedQueues::NoRoutedVersion, |version| {
routed_queues(engine, version)
});
AssistantInstall::Deferred {
workflow_type,
embedded_hash,
routed_hash,
embedded_queue: embedded.task_queue().to_owned(),
routed_queues,
}
}
fn routed_queues(engine: &Engine, version: &WorkflowVersionInfo) -> RoutedQueues {
let hash: &ContentHash = &version.content_hash;
let loaded = match engine.workflow_catalog().get(&version.workflow_type, hash) {
Ok(Some(loaded)) => loaded,
Ok(None) => {
return RoutedQueues::Unreadable(format!(
"the catalog lists `{}` at `{hash}` but holds no entry for it",
version.workflow_type
));
}
Err(error) => {
return RoutedQueues::Unreadable(format!("the catalog could not be read: {error}"));
}
};
match loaded.contract() {
Ok(contract) => RoutedQueues::Declared(
contract
.workers
.iter()
.map(|worker| worker.task_queue.clone())
.collect(),
),
Err(error) => RoutedQueues::Unreadable(format!(
"the routed version's contract could not be read: {error}"
)),
}
}
fn worker_agent_command(address: &str) -> String {
format!(
"aion worker agent assistant.awl --liminal-address {address} --identity assistant-worker"
)
}
fn log_outcome(outcome: &AssistantInstall, advice: WorkerListenerAdvice<'_>) {
match outcome {
AssistantInstall::Installed {
workflow_type,
content_hash,
task_queue,
} => tracing::info!(
operation = "assistant.install",
outcome = outcome.outcome(),
%workflow_type,
%content_hash,
%task_queue,
"the built-in assistant was installed and routed on a catalog that held no version \
of it; it serves its own private queue, so `default` is free for the workers an \
operator brings up"
),
AssistantInstall::AlreadyCurrent {
workflow_type,
content_hash,
task_queue,
} => tracing::info!(
operation = "assistant.install",
outcome = outcome.outcome(),
%workflow_type,
%content_hash,
%task_queue,
"the built-in assistant is already the routed version"
),
AssistantInstall::Deferred {
workflow_type,
embedded_hash,
routed_hash,
embedded_queue,
routed_queues,
} => {
let routed_hash = routed_hash.as_deref().unwrap_or("none");
let routed_queue = routed_queues.describe();
if outcome.defers_a_queue_move() {
let worker_step = match advice.listener {
Some(address) => format!(
"start the worker the document itself configures: \
`{}` (the launch — harness, concurrency, reconnects, environment — is \
in the document's `harness` section; only the connection is a flag)",
worker_agent_command(address)
),
None => format!(
"start a worker on the new queue — but this boot binds NO liminal \
worker listener, so an agent worker has nothing to dial. Commission \
one under `[outbox]`: set `enabled = true`, `transport = \"liminal\"`, \
and the listen address ({hint}; env override \
AION_OUTBOX_LIMINAL_LISTEN_ADDRESS); note `enabled = true` requires \
a durable store backend — a `[store] backend = \"memory\"` boot \
refuses it. Then restart, and `{command}`",
hint = advice.config_hint,
command = worker_agent_command("<that address>")
),
};
tracing::warn!(
operation = "assistant.install",
outcome = outcome.outcome(),
%workflow_type,
%embedded_hash,
routed_hash,
from_task_queue = %routed_queue,
to_task_queue = %embedded_queue,
"QUEUE MOVE PENDING: the assistant routed on this catalog serves task queue \
`{routed_queue}`, and the assistant embedded in this binary declares the \
private queue `{embedded_queue}` instead — the built-in assistant no longer \
claims `default`, because that is the queue every out-of-box worker comes up \
on and a worker refused there is a worker that never starts (#200). NOTHING \
WAS MOVED: routing is untouched and the sessions on the routed version keep \
running on `{routed_queue}`. To make the move deliberately: `aion assistant \
document --output assistant.awl`, then `aion deploy assistant.awl` (which \
loads AND routes it), and then {worker_step} — a worker still serving \
`{routed_queue}` will not receive the new version's dispatches"
);
} else {
tracing::warn!(
operation = "assistant.install",
outcome = outcome.outcome(),
%workflow_type,
%embedded_hash,
routed_hash,
routed_task_queue = %routed_queue,
embedded_task_queue = %embedded_queue,
"the embedded assistant document is not the routed version on this catalog, \
and routing was NOT changed — a restart must never move a route an operator \
chose. To cut over deliberately: `aion assistant document --output \
assistant.awl`, then `aion deploy assistant.awl` (which loads AND routes \
it), and only then restart the worker serving its queue"
);
}
}
AssistantInstall::Failed { reason } => tracing::error!(
operation = "assistant.install",
outcome = outcome.outcome(),
%reason,
"the built-in assistant was not installed"
),
}
}
#[cfg(test)]
#[path = "install_tests.rs"]
mod install_tests;