clusterflux-public/crates/clusterflux-sdk/src/lib.rs
Clusterflux release 18cba9c609 Public release release-6756e5208c8b
Source commit: 6756e5208c8b79e83d55610251430bc1baef53a3

Public tree identity: sha256:2f58837c3759b4f466cbc274571ee71bc0d24c7552dc009c186394e99123da87
2026-07-17 04:13:46 +02:00

1058 lines
34 KiB
Rust

use serde::Serialize;
pub use clusterflux_core as core;
pub use clusterflux_macros::{main, task, TaskArg};
mod sdk_runtime;
mod task_args;
pub use task_args::{
reject_host_only_task_arg, validate_task_arg, Artifact, Blob, EnvRef, SourceSnapshot, TaskArg,
TaskArgBudget, TaskArgError, TaskArgKind, TaskArgValidation, Vfs,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct EntrypointDescriptor {
pub name: &'static str,
pub function: &'static str,
pub export: &'static str,
pub stable_id: &'static str,
pub argument_schema: &'static str,
pub result_schema: &'static str,
pub abi_version: u32,
pub bundle_manifest_entry: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct TaskDescriptor {
pub name: &'static str,
pub function: &'static str,
pub export: &'static str,
pub stable_id: &'static str,
pub argument_schema: &'static str,
pub result_schema: &'static str,
pub required_capabilities: &'static [&'static str],
pub restart_compatibility_hash: &'static str,
pub abi_version: u32,
pub source_file: &'static str,
pub source_line: u32,
pub probe_symbol: &'static str,
pub bundle_manifest_entry: bool,
pub remotely_startable: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RegisteredProgram {
pub entrypoints: &'static [EntrypointDescriptor],
pub tasks: &'static [TaskDescriptor],
}
impl RegisteredProgram {
pub fn select_entrypoint(&self, name: &str) -> Option<EntrypointDescriptor> {
self.entrypoints
.iter()
.copied()
.find(|entrypoint| entrypoint.name == name)
}
pub fn task(&self, name: &str) -> Option<TaskDescriptor> {
self.tasks.iter().copied().find(|task| task.name == name)
}
}
#[doc(hidden)]
pub mod __private;
pub mod spawn {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
pub use clusterflux_core::TaskFailurePolicy;
use serde::de::DeserializeOwned;
#[cfg(target_arch = "wasm32")]
use crate::sdk_runtime::start_guest_host_task;
#[cfg(not(target_arch = "wasm32"))]
use crate::sdk_runtime::start_remote_task;
use crate::sdk_runtime::{join_remote_task, RemoteTaskHandle};
pub use crate::sdk_runtime::{ProductRuntimeConfig, TaskRuntimeError};
use crate::{validate_task_arg, EnvRef, TaskArg, TaskArgBudget};
static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1);
static RUNTIME_THREADS: OnceLock<Mutex<Vec<RuntimeSpawnEvent>>> = OnceLock::new();
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeSpawnEvent {
pub virtual_thread_id: u64,
pub name: &'static str,
pub env: Option<EnvRef>,
pub debugger_visible: bool,
}
fn runtime_threads() -> &'static Mutex<Vec<RuntimeSpawnEvent>> {
RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new()))
}
fn register_runtime_thread(
virtual_thread_id: u64,
name: &'static str,
env: Option<EnvRef>,
) -> RuntimeSpawnEvent {
let event = RuntimeSpawnEvent {
virtual_thread_id,
name,
env,
debugger_visible: true,
};
runtime_threads().lock().unwrap().push(event.clone());
event
}
pub fn drain_runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
runtime_threads().lock().unwrap().drain(..).collect()
}
pub fn runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
runtime_threads().lock().unwrap().clone()
}
pub fn task<F, R>(entry: F) -> TaskBuilder<F, R>
where
F: FnOnce() -> R,
R: TaskArg,
{
TaskBuilder {
entry: Some(entry),
env: None,
name: "task",
task_id: None,
failure_policy: TaskFailurePolicy::FailFast,
}
}
pub fn task_with_arg<A, F, R>(arg: A, entry: F) -> TaskWithArgBuilder<A, F, R>
where
A: TaskArg,
F: FnOnce(A) -> R,
R: TaskArg,
{
TaskWithArgBuilder {
arg: Some(arg),
entry: Some(entry),
env: None,
name: "task",
task_id: None,
arg_budget: TaskArgBudget::default(),
failure_policy: TaskFailurePolicy::FailFast,
}
}
pub fn async_task<F, Fut, R>(entry: F) -> AsyncTaskBuilder<F, Fut, R>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = R>,
R: TaskArg,
{
AsyncTaskBuilder {
entry: Some(entry),
env: None,
name: "task",
task_id: None,
failure_policy: TaskFailurePolicy::FailFast,
marker: std::marker::PhantomData,
}
}
pub fn async_task_with_arg<A, F, Fut, R>(
arg: A,
entry: F,
) -> AsyncTaskWithArgBuilder<A, F, Fut, R>
where
A: TaskArg,
F: FnOnce(A) -> Fut,
Fut: std::future::Future<Output = R>,
R: TaskArg,
{
AsyncTaskWithArgBuilder {
arg: Some(arg),
entry: Some(entry),
env: None,
name: "task",
task_id: None,
arg_budget: TaskArgBudget::default(),
failure_policy: TaskFailurePolicy::FailFast,
marker: std::marker::PhantomData,
}
}
pub struct AsyncTaskBuilder<F, Fut, R>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = R>,
R: TaskArg,
{
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
entry: Option<F>,
env: Option<EnvRef>,
name: &'static str,
task_id: Option<String>,
failure_policy: TaskFailurePolicy,
marker: std::marker::PhantomData<fn() -> (Fut, R)>,
}
impl<F, Fut, R> AsyncTaskBuilder<F, Fut, R>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = R>,
R: TaskArg + DeserializeOwned,
{
pub fn env(mut self, env: EnvRef) -> Self {
self.env = Some(env);
self
}
pub fn name(mut self, name: &'static str) -> Self {
self.name = name;
self
}
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
self.task_id = Some(task_id.into());
self
}
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
self.failure_policy = failure_policy;
self
}
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
let runtime_event = register_runtime_thread(id, self.name, self.env);
#[cfg(target_arch = "wasm32")]
{
let task_id = product_task_id::<F>(self.task_id)?;
let remote = start_guest_host_task(
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
self.env,
Vec::new(),
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(config) = ProductRuntimeConfig::from_env()? {
let task_id = product_task_id::<F>(self.task_id)?;
let remote = start_remote_task(
config,
task_id,
self.env,
Vec::new(),
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
let entry = self.entry.expect("task entry used once");
Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: Some(entry().await),
remote: None,
})
}
}
}
pub struct AsyncTaskWithArgBuilder<A, F, Fut, R>
where
A: TaskArg,
F: FnOnce(A) -> Fut,
Fut: std::future::Future<Output = R>,
R: TaskArg,
{
arg: Option<A>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
entry: Option<F>,
env: Option<EnvRef>,
name: &'static str,
task_id: Option<String>,
arg_budget: TaskArgBudget,
failure_policy: TaskFailurePolicy,
marker: std::marker::PhantomData<fn() -> (Fut, R)>,
}
impl<A, F, Fut, R> AsyncTaskWithArgBuilder<A, F, Fut, R>
where
A: TaskArg,
F: FnOnce(A) -> Fut,
Fut: std::future::Future<Output = R>,
R: TaskArg + DeserializeOwned,
{
pub fn env(mut self, env: EnvRef) -> Self {
self.env = Some(env);
self
}
pub fn name(mut self, name: &'static str) -> Self {
self.name = name;
self
}
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
self.task_id = Some(task_id.into());
self
}
pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self {
self.arg_budget = arg_budget;
self
}
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
self.failure_policy = failure_policy;
self
}
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
let arg = self.arg.expect("task argument used once");
validate_task_arg(&arg, self.arg_budget)
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
let runtime_event = register_runtime_thread(id, self.name, self.env);
#[cfg(target_arch = "wasm32")]
{
let task_id = product_task_id::<F>(self.task_id)?;
let boundary = arg
.task_boundary_value()
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
let remote = start_guest_host_task(
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
self.env,
vec![boundary],
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(config) = ProductRuntimeConfig::from_env()? {
let task_id = product_task_id::<F>(self.task_id)?;
let boundary = arg
.task_boundary_value()
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
let remote = start_remote_task(
config,
task_id,
self.env,
vec![boundary],
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
let entry = self.entry.expect("task entry used once");
Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: Some(entry(arg).await),
remote: None,
})
}
}
}
pub struct TaskBuilder<F, R>
where
F: FnOnce() -> R,
R: TaskArg,
{
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
entry: Option<F>,
env: Option<EnvRef>,
name: &'static str,
task_id: Option<String>,
failure_policy: TaskFailurePolicy,
}
impl<F, R> TaskBuilder<F, R>
where
F: FnOnce() -> R,
R: TaskArg + DeserializeOwned,
{
pub fn env(mut self, env: EnvRef) -> Self {
self.env = Some(env);
self
}
pub fn name(mut self, name: &'static str) -> Self {
self.name = name;
self
}
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
self.task_id = Some(task_id.into());
self
}
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
self.failure_policy = failure_policy;
self
}
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
let runtime_event = register_runtime_thread(id, self.name, self.env);
#[cfg(target_arch = "wasm32")]
{
let task_id = product_task_id::<F>(self.task_id)?;
let remote = start_guest_host_task(
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
self.env,
Vec::new(),
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(config) = ProductRuntimeConfig::from_env()? {
let task_id = product_task_id::<F>(self.task_id)?;
let remote = start_remote_task(
config,
task_id,
self.env,
Vec::new(),
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
let entry = self.entry.expect("task entry used once");
Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: Some(entry()),
remote: None,
})
}
}
}
pub struct TaskWithArgBuilder<A, F, R>
where
A: TaskArg,
F: FnOnce(A) -> R,
R: TaskArg,
{
arg: Option<A>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
entry: Option<F>,
env: Option<EnvRef>,
name: &'static str,
task_id: Option<String>,
arg_budget: TaskArgBudget,
failure_policy: TaskFailurePolicy,
}
impl<A, F, R> TaskWithArgBuilder<A, F, R>
where
A: TaskArg,
F: FnOnce(A) -> R,
R: TaskArg + DeserializeOwned,
{
pub fn env(mut self, env: EnvRef) -> Self {
self.env = Some(env);
self
}
pub fn name(mut self, name: &'static str) -> Self {
self.name = name;
self
}
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
self.task_id = Some(task_id.into());
self
}
pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self {
self.arg_budget = arg_budget;
self
}
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
self.failure_policy = failure_policy;
self
}
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
let arg = self.arg.expect("task argument used once");
validate_task_arg(&arg, self.arg_budget)
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
let runtime_event = register_runtime_thread(id, self.name, self.env);
#[cfg(target_arch = "wasm32")]
{
let task_id = product_task_id::<F>(self.task_id)?;
let boundary = arg
.task_boundary_value()
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
let remote = start_guest_host_task(
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
self.env,
vec![boundary],
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(config) = ProductRuntimeConfig::from_env()? {
let task_id = product_task_id::<F>(self.task_id)?;
let boundary = arg
.task_boundary_value()
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
let remote = start_remote_task(
config,
task_id,
self.env,
vec![boundary],
self.failure_policy,
)?;
return Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: None,
remote: Some(remote),
});
}
let entry = self.entry.expect("task entry used once");
Ok(TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: Some(entry(arg)),
remote: None,
})
}
}
}
pub struct TaskHandle<R> {
virtual_thread_id: u64,
name: &'static str,
env: Option<EnvRef>,
debugger_visible: bool,
result: Option<R>,
remote: Option<RemoteTaskHandle>,
}
impl<R> TaskHandle<R>
where
R: TaskArg + DeserializeOwned,
{
pub fn virtual_thread_id(&self) -> u64 {
self.virtual_thread_id
}
pub fn name(&self) -> &'static str {
self.name
}
pub fn env(&self) -> Option<EnvRef> {
self.env
}
pub fn debugger_visible(&self) -> bool {
self.debugger_visible
}
pub fn task_spec(&self) -> Option<&clusterflux_core::TaskSpec> {
self.remote.as_ref().map(|remote| &remote.spec)
}
pub async fn join(mut self) -> Result<R, TaskRuntimeError> {
if let Some(remote) = self.remote.take() {
return join_remote_task(remote);
}
self.result.take().ok_or_else(|| {
TaskRuntimeError::Protocol("task handle was already joined".to_owned())
})
}
}
fn product_task_id<F>(explicit: Option<String>) -> Result<String, TaskRuntimeError> {
if let Some(explicit) = explicit {
return Ok(explicit);
}
let inferred = std::any::type_name::<F>()
.rsplit("::")
.next()
.unwrap_or_default();
if inferred.is_empty() || inferred.contains("closure") {
return Err(TaskRuntimeError::Configuration(
"product-mode spawn from a closure requires .task_id(\"registered-export\")"
.to_owned(),
));
}
Ok(inferred.to_owned())
}
}
pub mod command {
use std::collections::BTreeMap;
use std::time::Duration;
pub use crate::sdk_runtime::TaskRuntimeError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Output {
pub status_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Command {
program: String,
args: Vec<String>,
working_directory: String,
environment_variables: BTreeMap<String, String>,
timeout: Duration,
network: clusterflux_core::CommandNetworkPolicy,
}
impl Command {
pub fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
working_directory: "/workspace".to_owned(),
environment_variables: BTreeMap::new(),
timeout: Duration::from_secs(15 * 60),
network: clusterflux_core::CommandNetworkPolicy::Disabled,
}
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
pub fn current_dir(mut self, directory: impl Into<String>) -> Self {
self.working_directory = directory.into();
self
}
pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.environment_variables.insert(name.into(), value.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn network_disabled(mut self) -> Self {
self.network = clusterflux_core::CommandNetworkPolicy::Disabled;
self
}
pub fn network_enabled(mut self) -> Self {
self.network = clusterflux_core::CommandNetworkPolicy::Enabled;
self
}
pub async fn output(self) -> Result<Output, TaskRuntimeError> {
#[cfg(target_arch = "wasm32")]
{
let timeout_ms = u64::try_from(self.timeout.as_millis()).map_err(|_| {
TaskRuntimeError::Argument(
"command timeout does not fit the task ABI".to_owned(),
)
})?;
let output = crate::sdk_runtime::run_guest_host_command(
self.program,
self.args,
self.working_directory,
self.environment_variables,
timeout_ms,
self.network,
)?;
return Ok(Output {
status_code: output.status_code,
stdout: output.stdout,
stderr: output.stderr,
stdout_truncated: output.stdout_truncated,
stderr_truncated: output.stderr_truncated,
});
}
#[cfg(not(target_arch = "wasm32"))]
Err(TaskRuntimeError::Configuration(
"Clusterflux commands execute only as a granted host capability of a running Wasm task"
.to_owned(),
))
}
}
}
pub mod source {
use crate::{spawn::TaskRuntimeError, SourceSnapshot};
/// Snapshots the node-local project checkout without sending its source bytes
/// through the coordinator.
pub async fn snapshot() -> Result<SourceSnapshot, TaskRuntimeError> {
#[cfg(target_arch = "wasm32")]
{
let result = crate::sdk_runtime::guest_source_snapshot()?;
return Ok(SourceSnapshot {
digest: result.snapshot.as_str().to_owned(),
});
}
#[cfg(not(target_arch = "wasm32"))]
Err(TaskRuntimeError::Configuration(
"source snapshots are produced only by a source-capable Clusterflux node".to_owned(),
))
}
}
pub mod fs {
use crate::{spawn::TaskRuntimeError, Artifact};
pub const OUTPUT_ROOT: &str = "/clusterflux/output";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputPath {
relative: String,
container_path: String,
}
impl OutputPath {
pub fn relative(&self) -> &str {
&self.relative
}
pub fn as_str(&self) -> &str {
&self.container_path
}
}
pub fn output_path(relative: impl Into<String>) -> Result<OutputPath, TaskRuntimeError> {
let relative = relative.into();
validate_relative_path(&relative)?;
Ok(OutputPath {
container_path: format!("{OUTPUT_ROOT}/{relative}"),
relative,
})
}
pub async fn flush(path: &OutputPath) -> Result<Artifact, TaskRuntimeError> {
#[cfg(target_arch = "wasm32")]
{
let retained = crate::sdk_runtime::guest_vfs_operation(
clusterflux_core::WasmHostVfsOperation::FlushOutput {
relative_path: path.relative.clone(),
},
)?;
return Ok(Artifact {
id: retained.artifact.id.as_str().to_owned(),
digest: retained.artifact.digest.as_str().to_owned(),
size_bytes: retained.artifact.size_bytes,
});
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = path;
Err(TaskRuntimeError::Configuration(
"Clusterflux output files are flushed only by a running Wasm task on a retaining node"
.to_owned(),
))
}
}
pub async fn materialize(
artifact: &Artifact,
relative: impl Into<String>,
) -> Result<OutputPath, TaskRuntimeError> {
let output = output_path(relative)?;
#[cfg(target_arch = "wasm32")]
{
let digest = crate::task_args::parse_handle_digest(&artifact.digest)
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
crate::sdk_runtime::guest_vfs_operation(
clusterflux_core::WasmHostVfsOperation::MaterializeArtifact {
artifact: clusterflux_core::ArtifactHandle {
id: clusterflux_core::ArtifactId::from(artifact.id.as_str()),
digest,
size_bytes: artifact.size_bytes,
},
relative_path: output.relative.clone(),
},
)?;
return Ok(output);
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = (&output, artifact);
Err(TaskRuntimeError::Configuration(
"Clusterflux artifacts are materialized only by a running Wasm task on a retaining node"
.to_owned(),
))
}
}
fn validate_relative_path(path: &str) -> Result<(), TaskRuntimeError> {
if path.is_empty()
|| path.len() > 240
|| path.starts_with('/')
|| path.starts_with('\\')
|| path.split('/').any(|component| {
component.is_empty()
|| component == "."
|| component == ".."
|| !component.chars().all(|character| {
character.is_ascii_alphanumeric() || "._-".contains(character)
})
})
{
return Err(TaskRuntimeError::Argument(
"output path must be a safe task-output-relative path".to_owned(),
));
}
Ok(())
}
}
pub mod process {
pub use crate::sdk_runtime::TaskRuntimeError;
pub fn cancellation_requested() -> Result<bool, TaskRuntimeError> {
#[cfg(target_arch = "wasm32")]
{
return crate::sdk_runtime::guest_cancellation_requested();
}
#[cfg(not(target_arch = "wasm32"))]
Ok(false)
}
}
#[doc(hidden)]
pub mod debug {
pub use crate::sdk_runtime::TaskRuntimeError;
pub fn probe(symbol: impl Into<String>) -> Result<(), TaskRuntimeError> {
#[cfg(target_arch = "wasm32")]
{
let _ = crate::sdk_runtime::guest_debug_probe(symbol.into())?;
return Ok(());
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = symbol.into();
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use futures_executor::block_on;
#[test]
fn env_macro_creates_logical_environment_reference() {
let env = crate::env!("linux");
assert_eq!(env.name, "linux");
}
#[test]
fn spawn_task_start_join_returns_small_result() {
let result = block_on(async {
let handle = crate::spawn::task(|| 42)
.name("compile linux")
.env(crate::env!("linux"))
.start()
.await
.unwrap();
assert_eq!(handle.name(), "compile linux");
assert_eq!(handle.env().unwrap().name, "linux");
assert!(handle.debugger_visible());
handle.join().await.unwrap()
});
assert_eq!(result, 42);
}
#[test]
fn spawn_task_start_registers_debugger_visible_runtime_thread() {
let handle = block_on(async {
crate::spawn::task(|| 7_u32)
.name("sdk-runtime-thread-test")
.env(crate::env!("linux"))
.start()
.await
.unwrap()
});
let events = crate::spawn::runtime_spawn_events();
let event = events
.iter()
.find(|event| event.virtual_thread_id == handle.virtual_thread_id())
.expect("spawn runtime event should exist for task handle");
assert!(handle.debugger_visible());
assert!(event.debugger_visible);
assert_eq!(event.name, "sdk-runtime-thread-test");
assert_eq!(event.env.unwrap().name, "linux");
assert_eq!(block_on(handle.join()).unwrap(), 7);
}
#[test]
fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() {
let artifact = crate::Artifact {
id: "artifact://build/app".to_owned(),
digest: clusterflux_core::Digest::sha256("app").as_str().to_owned(),
size_bytes: 3,
};
let artifact_validation = crate::validate_task_arg(
&artifact,
crate::TaskArgBudget {
max_inline_bytes: 4,
},
)
.unwrap();
assert_eq!(artifact_validation.kind, crate::TaskArgKind::Handle);
let bytes = vec![1_u8, 2, 3, 4, 5];
let error = crate::validate_task_arg(
&bytes,
crate::TaskArgBudget {
max_inline_bytes: 4,
},
)
.unwrap_err();
assert!(matches!(error, crate::TaskArgError::TooLarge { .. }));
}
#[test]
fn spawn_task_with_arg_rejects_large_inline_argument_before_dispatch() {
let dispatched = std::cell::Cell::new(false);
let result = block_on(async {
crate::spawn::task_with_arg(vec![1_u8, 2, 3, 4, 5], |_| {
dispatched.set(true);
42_u32
})
.arg_budget(crate::TaskArgBudget {
max_inline_bytes: 4,
})
.start()
.await
});
assert!(matches!(
result,
Err(crate::spawn::TaskRuntimeError::Argument(_))
));
assert!(!dispatched.get());
}
#[test]
fn spawn_task_with_arg_allows_runtime_handles_under_inline_budget() {
let artifact = crate::Artifact {
id: "artifact://build/app".to_owned(),
digest: clusterflux_core::Digest::sha256("app").as_str().to_owned(),
size_bytes: 3,
};
let result = block_on(async {
let handle = crate::spawn::task_with_arg(artifact, |artifact| artifact.id)
.name("publish artifact")
.env(crate::env!("linux"))
.arg_budget(crate::TaskArgBudget {
max_inline_bytes: 4,
})
.start()
.await
.unwrap();
assert_eq!(handle.name(), "publish artifact");
handle.join().await.unwrap()
});
assert_eq!(result, "artifact://build/app");
}
#[test]
fn host_only_task_arg_error_names_rejected_type() {
let error = crate::reject_host_only_task_arg::<*const u8>();
assert!(error.to_string().contains("*const u8"));
assert!(error.to_string().contains("host-only"));
}
}