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 { self.entrypoints .iter() .copied() .find(|entrypoint| entrypoint.name == name) } pub fn task(&self, name: &str) -> Option { 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>> = OnceLock::new(); #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeSpawnEvent { pub virtual_thread_id: u64, pub name: &'static str, pub env: Option, pub debugger_visible: bool, } fn runtime_threads() -> &'static Mutex> { RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new())) } fn register_runtime_thread( virtual_thread_id: u64, name: &'static str, env: Option, ) -> 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 { runtime_threads().lock().unwrap().drain(..).collect() } pub fn runtime_spawn_events() -> Vec { runtime_threads().lock().unwrap().clone() } pub fn task(entry: F) -> TaskBuilder 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(arg: A, entry: F) -> TaskWithArgBuilder 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(entry: F) -> AsyncTaskBuilder where F: FnOnce() -> Fut, Fut: std::future::Future, 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( arg: A, entry: F, ) -> AsyncTaskWithArgBuilder where A: TaskArg, F: FnOnce(A) -> Fut, Fut: std::future::Future, 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 where F: FnOnce() -> Fut, Fut: std::future::Future, R: TaskArg, { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] entry: Option, env: Option, name: &'static str, task_id: Option, failure_policy: TaskFailurePolicy, marker: std::marker::PhantomData (Fut, R)>, } impl AsyncTaskBuilder where F: FnOnce() -> Fut, Fut: std::future::Future, 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) -> 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, 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::(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::(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 where A: TaskArg, F: FnOnce(A) -> Fut, Fut: std::future::Future, R: TaskArg, { arg: Option, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] entry: Option, env: Option, name: &'static str, task_id: Option, arg_budget: TaskArgBudget, failure_policy: TaskFailurePolicy, marker: std::marker::PhantomData (Fut, R)>, } impl AsyncTaskWithArgBuilder where A: TaskArg, F: FnOnce(A) -> Fut, Fut: std::future::Future, 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) -> 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, 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::(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::(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 where F: FnOnce() -> R, R: TaskArg, { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] entry: Option, env: Option, name: &'static str, task_id: Option, failure_policy: TaskFailurePolicy, } impl TaskBuilder 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) -> 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, 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::(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::(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 where A: TaskArg, F: FnOnce(A) -> R, R: TaskArg, { arg: Option, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] entry: Option, env: Option, name: &'static str, task_id: Option, arg_budget: TaskArgBudget, failure_policy: TaskFailurePolicy, } impl TaskWithArgBuilder 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) -> 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, 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::(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::(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 { virtual_thread_id: u64, name: &'static str, env: Option, debugger_visible: bool, result: Option, remote: Option, } impl TaskHandle 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 { 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 { 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(explicit: Option) -> Result { if let Some(explicit) = explicit { return Ok(explicit); } let inferred = std::any::type_name::() .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, 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, working_directory: String, environment_variables: BTreeMap, timeout: Duration, network: clusterflux_core::CommandNetworkPolicy, } impl Command { pub fn new(program: impl Into) -> 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) -> Self { self.args.push(arg.into()); self } pub fn args(mut self, args: I) -> Self where I: IntoIterator, S: Into, { self.args.extend(args.into_iter().map(Into::into)); self } pub fn current_dir(mut self, directory: impl Into) -> Self { self.working_directory = directory.into(); self } pub fn env(mut self, name: impl Into, value: impl Into) -> 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 { #[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 { #[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) -> Result { let relative = relative.into(); validate_relative_path(&relative)?; Ok(OutputPath { container_path: format!("{OUTPUT_ROOT}/{relative}"), relative, }) } pub async fn flush(path: &OutputPath) -> Result { #[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, ) -> Result { 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 { #[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) -> 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")); } }