use std::collections::HashMap;
use std::fs;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::Value;
use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot};
use crate::auth::Jwt;
use crate::bus::BusIngress;
use crate::flow::{LifecycleCorrelation, NodeExecutionId, resolve_script_limits};
use super::client::{GuestRpcBindingFactoryHandle, GuestRpcBindingsHandle};
use super::compiler::TypeScriptCompiler;
use super::messages::{TasksRequest, TasksRequestPayload, TasksResponse};
use super::sandbox::{CompiledScript, ScriptAbortHandle, ScriptExecution, ScriptSandbox};
use super::types::{
ScriptNodeCompletion, ScriptNodeFailure, ScriptNodeTerminalResult, ScriptNodeTickResult, TasksError,
};
const MAX_CONCURRENT_EXECUTIONS: usize = 4;
struct Pending {
compiled: String,
filename: String,
fuel: u64,
timeout: u64,
input: Value,
workspace_path: PathBuf,
guest_rpc: GuestRpcBindingsHandle,
}
struct Running {
correlation: LifecycleCorrelation,
input: Value,
abort: ScriptAbortHandle,
}
struct Completion {
correlation: LifecycleCorrelation,
_permit: OwnedSemaphorePermit,
result: Result<ScriptNodeCompletion, TasksError>,
}
pub(super) struct Actor {
receiver: mpsc::Receiver<TasksRequest>,
compiler: TypeScriptCompiler,
sandbox: ScriptSandbox,
guest_rpc_factory: GuestRpcBindingFactoryHandle,
pending: HashMap<NodeExecutionId, Pending>,
running: HashMap<NodeExecutionId, Running>,
completed: HashMap<NodeExecutionId, ScriptNodeTerminalResult>,
execution_slots: Arc<Semaphore>,
runtime_started: bool,
}
impl Actor {
pub(super) fn new(
receiver: mpsc::Receiver<TasksRequest>,
sandbox: ScriptSandbox,
guest_rpc_factory: GuestRpcBindingFactoryHandle,
) -> Self {
Self {
receiver,
compiler: TypeScriptCompiler,
sandbox,
guest_rpc_factory,
pending: HashMap::new(),
running: HashMap::new(),
completed: HashMap::new(),
execution_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_EXECUTIONS)),
runtime_started: false,
}
}
async fn handle_message(
&mut self,
payload: TasksRequestPayload,
reply_to: oneshot::Sender<Result<TasksResponse, TasksError>>,
completion_tx: &mpsc::Sender<Completion>,
) {
let result = match payload {
TasksRequestPayload::StartRuntime => {
self.runtime_started = true;
Ok(TasksResponse::Success)
}
TasksRequestPayload::StopRuntime => {
self.runtime_started = false;
self.pending.clear();
Ok(TasksResponse::Success)
}
TasksRequestPayload::StartScript {
correlation,
source,
filename,
input,
fuel_limit,
timeout_secs,
workspace_path,
node_token,
node_bus,
} => self.start_script(
correlation,
source,
filename,
input,
fuel_limit,
timeout_secs,
workspace_path,
node_token,
node_bus,
),
TasksRequestPayload::TickScript { correlation, input } => {
self.tick_script(correlation, input, completion_tx)
}
TasksRequestPayload::CancelScriptExecution { node_execution_id } => {
self.cancel_script(node_execution_id);
Ok(TasksResponse::Success)
}
};
let _ = reply_to.send(result);
}
#[allow(clippy::too_many_arguments)]
fn start_script(
&mut self,
correlation: LifecycleCorrelation,
source: String,
filename: String,
input: Value,
fuel_limit: u64,
timeout_secs: u64,
workspace_path: PathBuf,
node_token: Option<Jwt>,
node_bus: Option<BusIngress>,
) -> Result<TasksResponse, TasksError> {
if !self.runtime_started {
return Err(TasksError::RuntimeStopped);
}
let id = correlation.node_execution_id;
if self.completed.contains_key(&id) {
return Ok(TasksResponse::Started { correlation });
}
if self.pending.contains_key(&id) || self.running.contains_key(&id) {
return Err(TasksError::IdempotencyConflict { node_execution_id: id });
}
let compiled = self.compiler.transpile(&source, &filename)?;
let (fuel, timeout) = resolve_script_limits(fuel_limit, timeout_secs);
let node_token = node_token.ok_or(TasksError::MissingNodeToken)?;
let node_bus = node_bus.ok_or(TasksError::MissingNodeBus)?;
Self::ensure_execution_workspace(&workspace_path)?;
let guest_rpc = self.guest_rpc_factory.bind(node_bus, node_token);
self.pending.insert(
id,
Pending {
compiled,
filename,
fuel,
timeout,
input,
workspace_path,
guest_rpc,
},
);
Ok(TasksResponse::Started { correlation })
}
fn tick_script(
&mut self,
correlation: LifecycleCorrelation,
input: Value,
completion_tx: &mpsc::Sender<Completion>,
) -> Result<TasksResponse, TasksError> {
if !self.runtime_started {
return Err(TasksError::RuntimeStopped);
}
let id = correlation.node_execution_id;
if let Some(result) = self.completed.get(&id) {
return Ok(TasksResponse::ScriptNodeTicked {
correlation,
result: result.clone().into(),
});
}
if let Some(running) = self.running.get(&id) {
return if running.input == input {
Ok(TasksResponse::ScriptNodeTicked {
correlation,
result: ScriptNodeTickResult::Running,
})
} else {
Err(TasksError::IdempotencyConflict { node_execution_id: id })
};
}
let Some(pending) = self.pending.get(&id) else {
return Err(TasksError::ScriptNotStarted { node_execution_id: id });
};
if pending.input != input {
return Err(TasksError::IdempotencyConflict { node_execution_id: id });
}
let Some(permit) = self.execution_slots.clone().try_acquire_owned().ok() else {
return Ok(TasksResponse::ScriptNodeTicked {
correlation,
result: ScriptNodeTickResult::Running,
});
};
self.dispatch_script(correlation.clone(), input, permit, completion_tx);
Ok(TasksResponse::ScriptNodeTicked {
correlation,
result: ScriptNodeTickResult::Running,
})
}
fn dispatch_script(
&mut self,
correlation: LifecycleCorrelation,
input: Value,
permit: OwnedSemaphorePermit,
completion_tx: &mpsc::Sender<Completion>,
) {
let id = correlation.node_execution_id;
let pending = self.pending.remove(&id).expect("pending checked above");
let abort = ScriptAbortHandle::new();
self.running.insert(
id,
Running {
correlation: correlation.clone(),
input,
abort: abort.clone(),
},
);
let sandbox = self.sandbox;
let completion_tx = completion_tx.clone();
tokio::task::spawn_blocking(move || {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
sandbox.run(
ScriptExecution::new(
correlation.clone(),
CompiledScript::new(pending.compiled, pending.filename),
pending.input,
pending.fuel,
pending.timeout,
pending.workspace_path,
pending.guest_rpc,
),
abort,
)
}))
.unwrap_or_else(|_| {
Err(TasksError::Execution {
message: String::from("sandbox worker panicked"),
})
});
let _ = completion_tx.blocking_send(Completion {
correlation,
_permit: permit,
result,
});
});
}
fn cancel_script(&mut self, node_execution_id: NodeExecutionId) {
self.pending.remove(&node_execution_id);
if let Some(running) = self.running.remove(&node_execution_id) {
running.abort.abort();
}
self.completed.insert(
node_execution_id,
ScriptNodeTerminalResult::Failed {
failure: ScriptNodeFailure {
error: TasksError::Cancelled,
outputs: Default::default(),
logs: Value::Null,
},
},
);
}
async fn complete(&mut self, completion: Completion) {
let node_execution_id = completion.correlation.node_execution_id;
let Some(running) = self.running.remove(&node_execution_id) else {
return;
};
if running.correlation != completion.correlation {
return;
}
let result = Self::terminal_result(completion.correlation, completion.result);
self.completed.insert(node_execution_id, result);
}
fn terminal_result(
correlation: LifecycleCorrelation,
result: Result<ScriptNodeCompletion, TasksError>,
) -> ScriptNodeTerminalResult {
match result {
Ok(mut completion) => {
completion.correlation = correlation;
if completion.errors.is_empty() {
ScriptNodeTerminalResult::Succeeded { completion }
} else {
ScriptNodeTerminalResult::Failed {
failure: ScriptNodeFailure {
error: TasksError::ScriptReported {
message: completion.errors.join("\n"),
},
outputs: completion.outputs,
logs: completion.logs,
},
}
}
}
Err(error) => ScriptNodeTerminalResult::Failed {
failure: ScriptNodeFailure {
error,
outputs: Default::default(),
logs: Value::Null,
},
},
}
}
fn ensure_execution_workspace(path: &Path) -> Result<(), TasksError> {
fs::create_dir_all(path).map_err(|error| TasksError::Execution {
message: format!("failed to create ScriptNode execution workspace: {error}"),
})?;
let metadata = fs::metadata(path).map_err(|error| TasksError::Execution {
message: format!("failed to validate ScriptNode execution workspace: {error}"),
})?;
if !metadata.is_dir() {
return Err(TasksError::Execution {
message: String::from("ScriptNode execution workspace is not a directory"),
});
}
Ok(())
}
}
pub(super) async fn run_actor(mut actor: Actor) {
let (completion_tx, mut completion_rx) = mpsc::channel(MAX_CONCURRENT_EXECUTIONS);
loop {
tokio::select! {
request = actor.receiver.recv() => match request {
Some(request) => actor.handle_message(request.payload, request.reply_to, &completion_tx).await,
None => break,
},
completion = completion_rx.recv() => match completion {
Some(completion) => actor.complete(completion).await,
None => break,
},
}
}
}
#[cfg(test)]
mod tests;