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,
}
}
}
pub async fn install_embedded_assistant(engine: &Engine) -> 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);
outcome
}
pub async fn install_embedded_assistant_for_server(state: &crate::ServerState) -> AssistantInstall {
match state.engine() {
Ok(engine) => install_embedded_assistant(engine.as_ref()).await,
Err(error) => {
let reason = format!("the engine is not available: {error}");
let outcome = AssistantInstall::Failed { reason };
log_outcome(&outcome);
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 log_outcome(outcome: &AssistantInstall) {
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() {
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 start a worker on `{embedded_queue}` — 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;