use std::path::Path;
use aion_awl_package::{PrepareAwlError, compile_and_assemble_awl};
use aion_core::DEFAULT_TASK_QUEUE;
use aion_package::{ExtractionLimits, Package, PackageError};
use aion_proto::WireError;
use serde::{Deserialize, Serialize};
use super::handlers::{CheckRequest, check_source};
use super::revisions::{self, DeploymentRecord, RevisionError};
use crate::authoring::AuthoringApiError;
use crate::worker::admission_audit::RememberedRefusal;
use crate::{CallerIdentity, ServerError, ServerState};
#[derive(Debug, Deserialize)]
pub struct EmitRequest {
pub source: String,
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct EmitResponse {
pub emitted: String,
pub bytes: usize,
pub synthesized_workflows: Vec<EmittedWorkflowEntry>,
}
#[derive(Debug, Serialize)]
pub struct EmittedWorkflowEntry {
pub workflow_type: String,
pub entry_module: String,
pub entry_function: String,
pub input_schema: serde_json::Value,
pub output_schema: serde_json::Value,
pub timeout_seconds: Option<u64>,
pub internal: bool,
}
#[derive(Debug, Deserialize)]
pub struct DeployAuthoringRequest {
pub path: String,
pub content_hash: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct GuidedStepResult {
pub step: &'static str,
pub detail: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct DeployAuthoringResponse {
pub deployment: DeploymentRecord,
pub steps: Vec<GuidedStepResult>,
}
#[derive(Debug, Deserialize)]
pub struct BindRunRequest {
pub workflow_id: String,
pub run_id: String,
}
#[derive(Debug, Deserialize)]
pub struct WorkerAvailabilityRequest {
pub namespace: String,
pub task_queue: String,
}
#[derive(Debug, Serialize)]
pub struct WorkerAvailabilityResponse {
pub available: bool,
pub task_queue: String,
pub connected_workers: usize,
pub worker_actions: Vec<String>,
pub server_run_actions: Vec<String>,
pub scaffold_hint: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RunStatusResponse {
pub deployment: DeploymentRecord,
pub deployed_source: String,
pub drifted: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum RunLoopError {
#[error(
"document revision does not match the saved document: requested {requested}, saved {saved}"
)]
RevisionMismatch { requested: String, saved: String },
#[error("AWL check refused deployment: {0}")]
CheckRefused(String),
#[error("AWL emission refused deployment: {0}")]
EmitRefused(String),
#[error(transparent)]
Direct(#[from] PrepareAwlError),
#[error("direct AWL package could not be loaded: {0}")]
Package(#[from] PackageError),
#[error(transparent)]
Revision(#[from] RevisionError),
#[error(transparent)]
Document(#[from] super::documents::DocumentError),
#[error("authoring deploy was refused")]
Authoring(AuthoringApiError),
#[error("worker registry inspection failed: {0}")]
WorkerRegistry(String),
#[error("engine unavailable for queue contract inspection")]
EngineUnavailable {
#[source]
source: ServerError,
},
#[error("queue contract inspection failed")]
QueueContracts {
#[source]
source: aion::EngineError,
},
}
pub fn emit(
state: &ServerState,
caller: &CallerIdentity,
request: &EmitRequest,
) -> Result<EmitResponse, RunLoopError> {
state
.deploy_guard()
.authorize(caller)
.map_err(|error| RunLoopError::Authoring(AuthoringApiError::Wire(error.to_wire_error())))?;
let checked = check_source(&CheckRequest {
source: request.source.clone(),
path: request.path.clone(),
});
if !checked.deploys_green {
let reason = checked.diagnostics.first().map_or_else(
|| "document does not deploy green".to_owned(),
|item| item.message.clone(),
);
return Err(RunLoopError::CheckRefused(reason));
}
let document = aion_awl::parse(&request.source)
.map_err(|error| RunLoopError::CheckRefused(error.message))?;
let artifact = aion_awl::emit_artifact(&document)
.map_err(|error| RunLoopError::EmitRefused(error.message))?;
let synthesized_workflows = artifact
.synthesized_workflows
.into_iter()
.map(|entry| EmittedWorkflowEntry {
workflow_type: entry.workflow_type,
entry_module: entry.entry_module,
entry_function: entry.entry_function,
input_schema: entry.input_schema,
output_schema: entry.output_schema,
timeout_seconds: entry.timeout.map(|timeout| timeout.as_secs()),
internal: entry.internal,
})
.collect();
Ok(EmitResponse {
bytes: artifact.source.len(),
emitted: artifact.source,
synthesized_workflows,
})
}
pub async fn deploy(
state: &ServerState,
caller: &CallerIdentity,
root: &Path,
transport: &'static str,
request: DeployAuthoringRequest,
) -> Result<DeployAuthoringResponse, RunLoopError> {
crate::authoring::handlers::admit_mutation(state, caller, transport, "awl.deploy")?;
let saved = super::documents::read(root, &request.path).await?;
if saved.content_hash != request.content_hash {
return Err(RunLoopError::RevisionMismatch {
requested: request.content_hash,
saved: saved.content_hash,
});
}
let revision = revisions::store(root, &saved.source).await?;
let workspace_root = root.to_owned();
let document_path = request.path.clone();
let revision_source = revision.source.clone();
let prepared = tokio::task::spawn_blocking(move || {
let (_staging, schema_root) = super::handlers::stage_schema_imports(
&workspace_root,
&document_path,
&revision_source,
)
.map_err(RunLoopError::Document)?;
let document_filename = Path::new(&document_path)
.file_name()
.and_then(std::ffi::OsStr::to_str)
.ok_or_else(|| {
RevisionError::InvalidRecord(format!(
"document path `{document_path}` has no filename"
))
})?;
compile_and_assemble_awl(&revision_source, &schema_root, document_filename)
.map_err(RunLoopError::from)
})
.await
.map_err(|error| {
RunLoopError::Revision(RevisionError::InvalidRecord(format!(
"AWL compile task failed: {error}"
)))
})??;
let task_queue = match &prepared.compiled.first_worker {
Some(worker) => worker.clone(),
None => DEFAULT_TASK_QUEUE.to_owned(),
};
let workflow_name = prepared.compiled.workflow_name.clone();
let beam_bytes = prepared.compiled.beam_bytes.len();
let package = Package::load_from_bytes(prepared.archive, ExtractionLimits::unbounded())?;
crate::authoring::handlers::validate_document_identity(&package, &workflow_name)?;
let loaded = crate::authoring::handlers::load_admitted_package(
state,
caller,
transport,
"awl.deploy",
package,
)
.await?;
let deployment = DeploymentRecord {
deployment_id: uuid::Uuid::new_v4().to_string(),
document_path: request.path,
content_hash: revision.content_hash,
package_id: loaded.content_hash.clone(),
workflow_type: loaded.workflow_type.clone(),
task_queue,
workflow_id: None,
run_id: None,
};
revisions::record_deployment(root, &deployment).await?;
Ok(DeployAuthoringResponse {
steps: vec![
GuidedStepResult {
step: "check",
detail: format!("direct compiler accepted workflow {workflow_name}"),
},
GuidedStepResult {
step: "compile",
detail: format!("{beam_bytes} bytes of direct BEAM compiled"),
},
GuidedStepResult {
step: "package",
detail: format!("package {} built", loaded.content_hash),
},
GuidedStepResult {
step: "deploy",
detail: format!("deployment {} loaded", deployment.deployment_id),
},
],
deployment,
})
}
pub fn worker_availability(
state: &ServerState,
request: WorkerAvailabilityRequest,
) -> Result<WorkerAvailabilityResponse, RunLoopError> {
let registry = state.worker_registry();
let workers = registry
.all_workers()
.map_err(|error| RunLoopError::WorkerRegistry(error.to_string()))?;
let task_queue = if request.task_queue.is_empty() {
DEFAULT_TASK_QUEUE.to_owned()
} else {
request.task_queue
};
let connected_workers = workers
.iter()
.filter(|worker| {
worker.task_queue() == task_queue && worker.namespaces().contains(&request.namespace)
})
.count();
let admission = state
.engine()
.map_err(|source| RunLoopError::EngineUnavailable { source })?
.worker_contracts_for_admission(&task_queue)
.map_err(|source| RunLoopError::QueueContracts { source })?;
let demand = service_demand(&admission);
let mut unserved: Option<UnservedAddress> = None;
for (action, node) in &demand.worker_addresses {
let census = registry
.pool_census(&request.namespace, &task_queue, action, node.as_deref())
.map_err(|error| RunLoopError::WorkerRegistry(error.to_string()))?;
if !census.is_served() {
unserved = Some(UnservedAddress {
action: action.clone(),
node: node.clone(),
census,
});
break;
}
}
Ok(WorkerAvailabilityResponse {
available: unserved.is_none(),
task_queue: task_queue.clone(),
connected_workers,
scaffold_hint: unserved.as_ref().map(|address| {
unserved_hint(
&task_queue,
address,
®istry.admission_audit().refusals_on_queue(&task_queue),
)
}),
worker_actions: demand.worker,
server_run_actions: demand.server_run,
})
}
struct UnservedAddress {
action: String,
node: Option<String>,
census: crate::worker::PoolCensus,
}
struct QueueServiceDemand {
worker_addresses: Vec<(String, Option<String>)>,
worker: Vec<String>,
server_run: Vec<String>,
}
fn service_demand(admission: &aion::QueueAdmission) -> QueueServiceDemand {
let mut worker_addresses = std::collections::BTreeSet::new();
let mut worker = std::collections::BTreeSet::new();
let mut server_run = std::collections::BTreeSet::new();
for required in &admission.required {
for action in &required.contract.contract.actions {
if action.worker_owed() {
worker_addresses.insert((action.name.clone(), action.node.clone()));
worker.insert(action.name.clone());
} else {
server_run.insert(action.name.clone());
}
}
}
QueueServiceDemand {
worker_addresses: worker_addresses.into_iter().collect(),
worker: worker.into_iter().collect(),
server_run: server_run.into_iter().collect(),
}
}
fn unserved_hint(
task_queue: &str,
address: &UnservedAddress,
refusals: &[RememberedRefusal],
) -> String {
let Some(refusal) = refusals.first() else {
let action = &address.action;
let census = &address.census;
if census.workers_in_pool == 0 {
return format!(
"No connected worker serves task queue `{task_queue}`. Scaffold and \
run this worker from Workers & Actions, then retry start."
);
}
if census.workers_serving_activity == 0 {
return format!(
"{count} worker connection(s) ARE on task queue `{task_queue}`, but none \
advertises action `{action}`, so nothing serves it. Starting another copy \
of the same worker will not help — rebuild or rewire the worker to serve \
`{action}`, then retry start.",
count = census.workers_in_pool,
);
}
return match &address.node {
Some(node) => format!(
"Action `{action}` on task queue `{task_queue}` is pinned to node \
`{node}`, and none of the {count} worker(s) advertising it is on that \
node. Start a worker on node `{node}`, then retry start.",
count = census.workers_serving_activity,
),
None => format!(
"Action `{action}` on task queue `{task_queue}` is advertised by \
{count} worker(s), but none is compatible with its dispatch. Check the \
worker's node against the document's declaration, then retry start.",
count = census.workers_serving_activity,
),
};
};
let others = match refusals.len() - 1 {
0 => String::new(),
1 => " One other connection was refused on this queue too.".to_owned(),
more => format!(" {more} other connections were refused on this queue too."),
};
let node = match &refusal.node {
Some(node) => format!(" on node `{node}`"),
None => String::new(),
};
format!(
"A worker IS dialling task queue `{task_queue}`{node} and the server is \
REFUSING it, so nothing serves the queue. Starting another worker will \
not help — this one has to be fixed. Worker build `{}` was refused \
because: {}{others}",
refusal.identity, refusal.reason
)
}
pub async fn status(root: &Path, deployment_id: &str) -> Result<RunStatusResponse, RunLoopError> {
let deployment = revisions::deployment(root, deployment_id).await?;
let revision = revisions::fetch(root, &deployment.content_hash).await?;
let drifted = revisions::current_drifted(root, &deployment).await?;
Ok(RunStatusResponse {
deployment,
deployed_source: revision.source,
drifted,
})
}
pub fn wire_error(error: &RunLoopError) -> WireError {
match error {
RunLoopError::RevisionMismatch { .. }
| RunLoopError::CheckRefused(_)
| RunLoopError::EmitRefused(_)
| RunLoopError::Direct(_)
| RunLoopError::Package(_)
| RunLoopError::Revision(_) => {
WireError::invalid_input(error.to_string()).with_error_type(error_type(error))
}
RunLoopError::Document(error) => error.to_wire_error(),
RunLoopError::Authoring(_) | RunLoopError::WorkerRegistry(_) => {
WireError::backend(error.to_string()).with_error_type(error_type(error))
}
RunLoopError::EngineUnavailable { .. } | RunLoopError::QueueContracts { .. } => {
WireError::backend(error_chain(error)).with_error_type(error_type(error))
}
}
}
fn error_chain(error: &dyn std::error::Error) -> String {
let mut message = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
message.push_str(": ");
message.push_str(&cause.to_string());
source = cause.source();
}
message
}
impl From<AuthoringApiError> for RunLoopError {
fn from(error: AuthoringApiError) -> Self {
Self::Authoring(error)
}
}
fn error_type(error: &RunLoopError) -> &'static str {
match error {
RunLoopError::RevisionMismatch { .. } => "RevisionMismatch",
RunLoopError::CheckRefused(_) => "CheckRefused",
RunLoopError::EmitRefused(_) => "EmitRefused",
RunLoopError::Direct(_) => "DirectCompile",
RunLoopError::Package(_) => "Package",
RunLoopError::Revision(_) => "RevisionStore",
RunLoopError::Document(error) => error.error_type(),
RunLoopError::Authoring(_) => "AuthoringDeploy",
RunLoopError::WorkerRegistry(_) => "WorkerRegistry",
RunLoopError::EngineUnavailable { .. } => "EngineUnavailable",
RunLoopError::QueueContracts { .. } => "QueueContracts",
}
}
#[cfg(test)]
#[path = "run_loop_tests.rs"]
mod run_loop_tests;