clusterflux-public/crates/clusterflux-node/src/assignment_runner.rs
Clusterflux release eb1077f380 Public release release-09ca780bd67a
Source commit: 09ca780bd67a00467e78139cf38466b8201b66ca

Public tree identity: sha256:2f744c260b5fc2cf074ad9129f97f87f03714902e5b9fe174128afdc342ec2d0
2026-07-19 18:38:40 +02:00

837 lines
33 KiB
Rust

use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Read;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use crate::coordinator_session::CoordinatorSession;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_core::{
Capability, CommandInvocation, Digest, NodeId, ProcessId, ProjectId, TaskBoundaryValue,
TaskDispatch, TaskInstanceId, TaskJoinResult, TaskJoinState, TaskSpec, TenantId, VfsManifest,
WasmExportAbi, WasmHostDebugProbeRequest, WasmHostDebugProbeResult,
WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskHandle,
WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest,
WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation,
WasmTaskOutcome,
};
use clusterflux_node::{
BackendError, CommandOutput, LinuxRootlessPodmanBackend, LocalCheckoutTaskRequest,
LocalSourceCheckout, PodmanCommand, ProcessOutput, ProcessRunner, WasmDebugControl,
WasmTaskHost, WasmtimeTaskRuntime,
};
const MAX_CHILD_TASK_HANDLES: usize = 256;
use crate::daemon::{runtime_task_from_assignment, Args, RuntimeTask};
use crate::node_identity::signed_node_request_json;
use crate::source_snapshot::snapshot_project;
use crate::task_artifacts::{
retained_result_artifact, task_output_root, NodeArtifactStore, TaskArtifactStore,
};
mod process_runner;
use process_runner::CoordinatorControlledProcessRunner;
mod control_watcher;
use control_watcher::{spawn_task_control_watcher, spawn_worker_shutdown_watcher};
mod validation;
use validation::{
authorize_command_network, bundle_environments, capability_from_descriptor,
is_secret_environment_name, redact_configured_values, require_command_environment,
resolve_task_export, task_descriptors, verify_environment_digest, verify_source_snapshot,
};
pub(crate) fn run_verified_wasmtime_assignment(
args: &Args,
task: &RuntimeTask,
node_private_key: &str,
) -> Result<(CommandOutput, VfsManifest, Option<TaskBoundaryValue>), Box<dyn std::error::Error>> {
if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() {
eprintln!(
"clusterflux debug control: starting Wasm assignment for task {}",
task.task
);
}
let expected_bundle_digest = task.bundle_digest.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"wasmtime task assignment includes module bytes but is missing bundle_digest",
)
})?;
let module_base64 = task
.wasm_module_base64
.as_ref()
.expect("caller checked wasm_module_base64");
let module = BASE64_STANDARD.decode(module_base64)?;
let actual_bundle_digest = Digest::sha256(&module);
if &actual_bundle_digest != expected_bundle_digest {
return Err(format!(
"bundle digest mismatch: expected {expected_bundle_digest}, received {actual_bundle_digest}"
)
.into());
}
let task_spec = task.task_spec.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Wasm task assignment is missing its versioned TaskSpec",
)
})?;
let (declared_export, abi) = match &task_spec.dispatch {
TaskDispatch::CoordinatorNodeWasm { export, abi } => (export.as_deref(), abi),
};
let resolved_export;
let export = match declared_export {
Some(export) => export,
None if abi == &WasmExportAbi::TaskV1 => {
resolved_export = resolve_task_export(&module, task_spec.task_definition.as_str())?;
&resolved_export
}
None => {
return Err("Wasm entrypoint assignment omitted its descriptor export".into());
}
};
let (stdout, boundary_result) = match abi {
WasmExportAbi::EntrypointV1 | WasmExportAbi::TaskV1 => {
let invocation = WasmTaskInvocation::new(
task_spec.task_definition.clone(),
task_spec.task_instance.clone(),
task_spec.args.clone(),
);
let result = WasmtimeTaskRuntime::new()?.run_task_export_verified_with_task_host(
&module,
expected_bundle_digest,
export,
&invocation,
Box::new(CoordinatorWasmTaskHost::new(
args,
task,
node_private_key,
&module,
)?),
)?;
if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() {
eprintln!(
"clusterflux debug control: Wasm assignment returned for task {}",
task.task
);
}
match result.outcome {
WasmTaskOutcome::Completed => {
let boundary = result.result.ok_or("completed Wasm task omitted result")?;
(
format!("{}\n", serde_json::to_string(&boundary)?),
Some(boundary),
)
}
WasmTaskOutcome::Failed => {
return Err(result
.error
.unwrap_or_else(|| "Wasm task failed without an error".to_owned())
.into())
}
}
}
};
let task_id = TaskInstanceId::new(task.task.clone());
let artifacts = TaskArtifactStore::new(task_id.clone(), NodeId::new(args.node.clone()));
let manifest = artifacts.flush();
Ok((
CommandOutput {
virtual_thread: task_id,
status_code: Some(0),
stdout,
stderr: String::new(),
stdout_truncated: false,
stderr_truncated: false,
log_backpressured: false,
staged_artifact: None,
},
manifest,
boundary_result,
))
}
struct CoordinatorWasmTaskHost {
args: Args,
process: String,
parent_task: String,
epoch: u64,
bundle_digest: clusterflux_core::Digest,
wasm_module_base64: String,
node_private_key: String,
allow_command: bool,
allow_network: bool,
allow_source_snapshot: bool,
environment_id: Option<String>,
environment_digest: Option<Digest>,
source_snapshot: Option<Digest>,
output_root: PathBuf,
task_descriptors: HashMap<String, serde_json::Value>,
environments: BTreeMap<String, clusterflux_core::EnvironmentResource>,
next_handle_id: u64,
handles: Arc<Mutex<HashMap<u64, TaskSpec>>>,
command_status: Arc<Mutex<Option<String>>>,
cancellation_requested: Arc<AtomicBool>,
abort_requested: Arc<AtomicBool>,
debug_control: Arc<WasmDebugControl>,
stop_control_watcher: Arc<AtomicBool>,
}
impl CoordinatorWasmTaskHost {
fn new(
args: &Args,
parent: &RuntimeTask,
node_private_key: &str,
module: &[u8],
) -> Result<Self, Box<dyn std::error::Error>> {
let task_spec = parent
.task_spec
.as_ref()
.ok_or("Wasm task host requires a parent TaskSpec")?;
let cancellation_requested = Arc::new(AtomicBool::new(false));
let abort_requested = Arc::new(AtomicBool::new(false));
let debug_control = Arc::new(WasmDebugControl::default());
let stop_control_watcher = Arc::new(AtomicBool::new(false));
let handles = Arc::new(Mutex::new(HashMap::new()));
let command_status = Arc::new(Mutex::new(None));
let task_instance = TaskInstanceId::from(parent.task.as_str());
let output_root =
task_output_root(args.project_root.as_deref(), &args.node, &task_instance)?;
let task_args = task_spec
.args
.iter()
.enumerate()
.map(|(index, value)| (format!("arg_{index}"), format!("{value:?}")))
.collect();
spawn_task_control_watcher(
args.clone(),
parent.process.clone(),
parent.task.clone(),
task_spec.task_definition.as_str().to_owned(),
node_private_key.to_owned(),
Arc::clone(&cancellation_requested),
Arc::clone(&abort_requested),
Arc::clone(&debug_control),
task_args,
Arc::clone(&handles),
Arc::clone(&command_status),
Arc::clone(&stop_control_watcher),
);
spawn_worker_shutdown_watcher(
Arc::clone(&abort_requested),
Arc::clone(&debug_control),
Arc::clone(&stop_control_watcher),
);
Ok(Self {
args: args.clone(),
process: parent.process.clone(),
parent_task: parent.task.clone(),
epoch: parent.epoch.unwrap_or(task_spec.vfs_epoch),
bundle_digest: parent
.bundle_digest
.clone()
.ok_or("Wasm task host requires a bundle digest")?,
wasm_module_base64: BASE64_STANDARD.encode(module),
node_private_key: node_private_key.to_owned(),
allow_command: task_spec
.required_capabilities
.contains(&Capability::Command)
&& clusterflux_core::NodeCapabilities::detect_current()
.capabilities
.contains(&Capability::Command),
allow_network: task_spec
.required_capabilities
.contains(&Capability::Network)
&& clusterflux_core::NodeCapabilities::detect_current()
.capabilities
.contains(&Capability::Network),
allow_source_snapshot: task_spec
.required_capabilities
.contains(&Capability::SourceFilesystem)
&& clusterflux_core::NodeCapabilities::detect_current()
.capabilities
.contains(&Capability::SourceFilesystem),
environment_id: task_spec.environment_id.clone(),
environment_digest: task_spec.environment_digest.clone(),
source_snapshot: task_spec.source_snapshot.clone(),
output_root,
// Wasmtime accepts both binary Wasm and WAT in development tests. Descriptor
// discovery is required only if the guest actually invokes task_start_v1; module
// compilation/digest verification remains authoritative for malformed input.
task_descriptors: task_descriptors(module).unwrap_or_default(),
environments: bundle_environments(module)?,
next_handle_id: 1,
handles,
command_status,
cancellation_requested,
abort_requested,
debug_control,
stop_control_watcher,
})
}
fn session(&self) -> Result<CoordinatorSession, String> {
CoordinatorSession::connect(&self.args.coordinator).map_err(|error| error.to_string())
}
fn signed_request(
&self,
request_kind: &str,
payload: serde_json::Value,
) -> Result<serde_json::Value, String> {
signed_node_request_json(&self.args, &self.node_private_key, request_kind, payload)
.map_err(|error| error.to_string())
}
fn execute_local_assignment(&self, assignment: &serde_json::Value) -> Result<(), String> {
let runtime_task =
runtime_task_from_assignment(assignment).map_err(|error| error.to_string())?;
let execution =
run_verified_wasmtime_assignment(&self.args, &runtime_task, &self.node_private_key);
let (terminal_state, status_code, stdout, stderr, result, retained) = match execution {
Ok((output, _manifest, result)) => {
let retained = retained_result_artifact(
self.args.project_root.as_deref(),
&self.args.node,
result.as_ref(),
);
match retained {
Ok(retained) => (
"completed",
output.status_code,
output.stdout,
output.stderr,
result,
retained,
),
Err(error) => ("failed", Some(1), String::new(), error, None, None),
}
}
Err(error) => (
"failed",
Some(1),
String::new(),
error.to_string(),
None,
None,
),
};
let artifact_path = retained
.as_ref()
.map(|artifact| format!("/vfs/artifacts/{}", artifact.id));
let artifact_digest = retained.as_ref().map(|artifact| artifact.digest.clone());
let artifact_size_bytes = retained.as_ref().map(|artifact| artifact.size_bytes);
let mut session = self.session()?;
session
.request(self.signed_request(
"task_completed",
serde_json::json!({
"type": "task_completed",
"tenant": self.args.tenant,
"project": self.args.project,
"process": runtime_task.process,
"node": self.args.node,
"task": runtime_task.task,
"terminal_state": terminal_state,
"status_code": status_code,
"stdout_bytes": stdout.len(),
"stderr_bytes": stderr.len(),
"stdout_tail": stdout,
"stderr_tail": stderr,
"stdout_truncated": false,
"stderr_truncated": false,
"artifact_path": artifact_path,
"artifact_digest": artifact_digest,
"artifact_size_bytes": artifact_size_bytes,
"result": result,
}),
)?)
.map_err(|error| error.to_string())?;
Ok(())
}
fn poll_and_execute_assignment(&self) -> Result<bool, String> {
let mut session = self.session()?;
let response = session
.request(self.signed_request(
"poll_task_assignment",
serde_json::json!({
"type": "poll_task_assignment",
"tenant": self.args.tenant,
"project": self.args.project,
"node": self.args.node,
}),
)?)
.map_err(|error| error.to_string())?;
let Some(assignment) = response.get("assignment").filter(|value| !value.is_null()) else {
return Ok(false);
};
self.execute_local_assignment(assignment)?;
Ok(true)
}
}
impl Drop for CoordinatorWasmTaskHost {
fn drop(&mut self) {
self.stop_control_watcher.store(true, Ordering::Release);
if std::fs::symlink_metadata(&self.output_root)
.map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
.unwrap_or(false)
{
let _ = std::fs::remove_dir_all(&self.output_root);
}
}
}
impl WasmTaskHost for CoordinatorWasmTaskHost {
fn abort_signal(&self) -> Option<Arc<AtomicBool>> {
Some(Arc::clone(&self.abort_requested))
}
fn debug_control(&self) -> Option<Arc<WasmDebugControl>> {
Some(Arc::clone(&self.debug_control))
}
fn start_task(
&mut self,
request: WasmHostTaskStartRequest,
) -> Result<WasmHostTaskHandle, String> {
request.validate()?;
let descriptor = self
.task_descriptors
.get(request.task_definition.as_str())
.ok_or_else(|| {
format!(
"bundle has no task descriptor named `{}`",
request.task_definition
)
})?;
let export = descriptor
.get("export")
.and_then(serde_json::Value::as_str)
.filter(|export| !export.trim().is_empty())
.ok_or_else(|| {
format!(
"task `{}` descriptor omitted its Wasm export",
request.task_definition
)
})?;
let mut required_capabilities = descriptor
.get("required_capabilities")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|value| {
capability_from_descriptor(
value
.as_str()
.ok_or("task capability descriptor is not a string")?,
)
})
.collect::<Result<BTreeSet<_>, _>>()?;
let resolved_environment = request
.environment_id
.as_deref()
.map(|name| {
self.environments.get(name).cloned().ok_or_else(|| {
format!("bundle environment manifest has no environment `{name}`")
})
})
.transpose()?;
let environment = resolved_environment
.as_ref()
.map(|environment| environment.requirements.clone());
let environment_digest = resolved_environment
.as_ref()
.map(|environment| environment.digest.clone());
if let Some(environment) = &environment {
required_capabilities.extend(environment.capabilities.iter().cloned());
}
let handle_id = self.next_handle_id;
let task_instance = clusterflux_core::TaskInstanceId::new(format!(
"{}:child:{}",
self.parent_task, handle_id
));
let required_artifacts = request
.args
.iter()
.flat_map(TaskBoundaryValue::required_artifacts)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let source_snapshots = request
.args
.iter()
.flat_map(TaskBoundaryValue::source_snapshots)
.collect::<BTreeSet<_>>();
if source_snapshots.len() > 1 {
return Err(
"one child task invocation cannot require multiple distinct source snapshots"
.to_owned(),
);
}
if self
.handles
.lock()
.map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())?
.len()
>= MAX_CHILD_TASK_HANDLES
{
return Err(format!(
"Wasm child task-handle limit of {MAX_CHILD_TASK_HANDLES} reached"
));
}
let source_snapshot = source_snapshots.into_iter().next();
let spec = TaskSpec {
tenant: TenantId::from(self.args.tenant.as_str()),
project: ProjectId::from(self.args.project.as_str()),
process: ProcessId::from(self.process.as_str()),
task_definition: request.task_definition.clone(),
task_instance: task_instance.clone(),
dispatch: TaskDispatch::CoordinatorNodeWasm {
export: Some(export.to_owned()),
abi: WasmExportAbi::TaskV1,
},
environment_id: request.environment_id,
environment,
environment_digest,
required_capabilities,
dependency_cache: None,
source_snapshot,
required_artifacts,
args: request.args,
vfs_epoch: self.epoch,
failure_policy: request.failure_policy,
bundle_digest: Some(self.bundle_digest.clone()),
};
let mut session = self.session()?;
let response = session
.request(self.signed_request(
"launch_child_task",
serde_json::json!({
"type": "launch_child_task",
"tenant": self.args.tenant,
"project": self.args.project,
"process": self.process,
"node": self.args.node,
"parent_task": self.parent_task,
"task_spec": spec,
"wait_for_node": true,
"artifact_path": format!("/vfs/artifacts/{}-result.json", spec.task_instance),
"wasm_module_base64": self.wasm_module_base64,
}),
)?)
.map_err(|error| error.to_string())?;
match response.get("type").and_then(serde_json::Value::as_str) {
Some("task_launched" | "task_queued") => {}
other => return Err(format!("unexpected child launch response {other:?}")),
}
self.next_handle_id = self.next_handle_id.saturating_add(1);
self.handles
.lock()
.map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())?
.insert(handle_id, spec.clone());
Ok(WasmHostTaskHandle {
abi_version: clusterflux_core::WASM_TASK_ABI_VERSION,
handle_id,
task_spec: spec,
})
}
fn join_task(
&mut self,
request: WasmHostTaskJoinRequest,
) -> Result<WasmHostTaskJoinResult, String> {
if request.abi_version != clusterflux_core::WASM_TASK_ABI_VERSION {
return Err(format!(
"unsupported Wasm task ABI version {}",
request.abi_version
));
}
let spec = self
.handles
.lock()
.map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())?
.get(&request.handle_id)
.cloned()
.ok_or_else(|| format!("unknown Wasm task handle {}", request.handle_id))?;
let started = Instant::now();
loop {
let mut session = self.session()?;
let response = session
.request(self.signed_request(
"join_child_task",
serde_json::json!({
"type": "join_child_task",
"tenant": self.args.tenant,
"project": self.args.project,
"process": self.process,
"node": self.args.node,
"parent_task": self.parent_task,
"task": spec.task_instance,
}),
)?)
.map_err(|error| error.to_string())?;
let join: TaskJoinResult = serde_json::from_value(
response
.get("join")
.cloned()
.ok_or("child task join response omitted join state")?,
)
.map_err(|error| error.to_string())?;
match join.state {
TaskJoinState::Completed => {
let result = join
.result
.ok_or("completed child task omitted its boundary result")?;
self.handles
.lock()
.map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())?
.remove(&request.handle_id);
return Ok(WasmHostTaskJoinResult {
abi_version: clusterflux_core::WASM_TASK_ABI_VERSION,
task_instance: spec.task_instance,
result,
});
}
TaskJoinState::Failed | TaskJoinState::Cancelled => {
self.handles
.lock()
.map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())?
.remove(&request.handle_id);
return Err(join.message);
}
TaskJoinState::Pending => {
if self.cancellation_requested.load(Ordering::Acquire)
|| self.abort_requested.load(Ordering::Acquire)
|| crate::daemon::worker_shutdown_requested()
{
return Err(clusterflux_core::limits::TaskJoinError::Cancelled {
task: spec.task_instance.clone(),
}
.to_string());
}
let join_timeout = clusterflux_core::limits::task_join_timeout();
if started.elapsed() > join_timeout {
return Err(clusterflux_core::limits::TaskJoinError::timeout(
spec.task_instance.clone(),
join_timeout,
)
.to_string());
}
if !self.poll_and_execute_assignment()? {
thread::sleep(Duration::from_millis(10));
}
}
}
}
}
fn run_command(
&mut self,
request: clusterflux_core::WasmHostCommandRequest,
) -> Result<clusterflux_core::WasmHostCommandResult, String> {
request.validate()?;
if !self.allow_command {
return Err(
"Wasm task did not declare Command capability or the selected node did not grant it"
.to_owned(),
);
}
authorize_command_network(&request.network, self.allow_network)?;
let environment_id = require_command_environment(self.environment_id.as_deref())?;
let project_root = self.args.project_root.as_ref().ok_or_else(|| {
format!(
"task selected environment `{environment_id}`, but this node has no project checkout or prepared source snapshot; attach it with --project-root"
)
})?;
let expected_snapshot = self.source_snapshot.as_ref().ok_or_else(|| {
"native command task is missing an explicit SourceSnapshot handle".to_owned()
})?;
let actual_snapshot = verify_source_snapshot(project_root, expected_snapshot)?;
let expected_environment_digest = self.environment_digest.as_ref().ok_or_else(|| {
format!(
"task selected environment `{environment_id}` without a bundle-authoritative environment digest"
)
})?;
let environment =
verify_environment_digest(project_root, environment_id, expected_environment_digest)?;
let checkout = project_root
.canonicalize()
.map_err(|error| format!("resolve node project checkout: {error}"))?;
let task_id = TaskInstanceId::from(self.parent_task.as_str());
let mut artifacts =
TaskArtifactStore::new(task_id.clone(), NodeId::from(self.args.node.as_str()));
let configured_secrets = request
.environment_variables
.iter()
.filter(|(name, value)| is_secret_environment_name(name) && !value.is_empty())
.map(|(_, value)| value.clone())
.collect::<Vec<_>>();
let mut runner = CoordinatorControlledProcessRunner::new(
self,
Duration::from_millis(request.timeout_ms),
);
let output = LinuxRootlessPodmanBackend
.execute_local_checkout_task(
LocalCheckoutTaskRequest {
process: ProcessId::from(self.process.as_str()),
virtual_thread: task_id,
invocation: &CommandInvocation {
program: request.program,
args: request.args,
working_directory: request.working_directory,
environment_variables: request.environment_variables,
timeout_ms: request.timeout_ms,
network: request.network,
env: Some(environment),
},
checkout: LocalSourceCheckout {
snapshot: actual_snapshot.digest,
host_path: checkout,
},
output_root: self.output_root.clone(),
stage_stdout_as: None,
},
&mut runner,
artifacts.overlay_mut(),
)
.map_err(|error| {
if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() {
eprintln!("clusterflux command host failed: {error}");
}
error.to_string()
})?;
let stdout = redact_configured_values(output.stdout, &configured_secrets);
let stderr = redact_configured_values(output.stderr, &configured_secrets);
if std::env::var_os("CLUSTERFLUX_DEBUG_CONTROL_TRACE").is_some() {
eprintln!(
"clusterflux command host completed: status={:?} stdout={:?} stderr={:?}",
output.status_code, stdout, stderr
);
}
Ok(clusterflux_core::WasmHostCommandResult {
abi_version: clusterflux_core::WASM_TASK_ABI_VERSION,
status_code: output.status_code,
stdout,
stderr,
stdout_truncated: output.stdout_truncated,
stderr_truncated: output.stderr_truncated,
})
}
fn poll_task_control(
&mut self,
request: clusterflux_core::WasmHostTaskControlRequest,
) -> Result<clusterflux_core::WasmHostTaskControlResult, String> {
request.validate()?;
Ok(clusterflux_core::WasmHostTaskControlResult {
abi_version: clusterflux_core::WASM_TASK_ABI_VERSION,
cancellation_requested: self.cancellation_requested.load(Ordering::Acquire),
})
}
fn debug_probe(
&mut self,
request: WasmHostDebugProbeRequest,
) -> Result<WasmHostDebugProbeResult, String> {
request.validate()?;
let mut session = self.session()?;
let response = session
.request(self.signed_request(
"report_debug_probe_hit",
serde_json::json!({
"type": "report_debug_probe_hit",
"tenant": self.args.tenant,
"project": self.args.project,
"process": self.process,
"node": self.args.node,
"task": self.parent_task,
"probe_symbol": request.symbol,
}),
)?)
.map_err(|error| error.to_string())?;
if response.get("type").and_then(serde_json::Value::as_str) != Some("debug_probe_hit") {
return Err(format!(
"coordinator returned invalid debug probe response: {response}"
));
}
let result = WasmHostDebugProbeResult {
abi_version: clusterflux_core::WASM_TASK_ABI_VERSION,
breakpoint_matched: response
.get("breakpoint_matched")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
debug_epoch: response
.get("debug_epoch")
.and_then(serde_json::Value::as_u64),
};
if result.breakpoint_matched {
if let Some(epoch) = result.debug_epoch {
self.debug_control.request_freeze(epoch);
}
}
Ok(result)
}
fn vfs_operation(&mut self, request: WasmHostVfsRequest) -> Result<WasmHostVfsResult, String> {
request.validate()?;
let store =
NodeArtifactStore::for_runtime(self.args.project_root.as_deref(), &self.args.node)?;
let (retained, relative_path) = match request.operation {
WasmHostVfsOperation::FlushOutput { relative_path } => {
let retained = store.retain_output_file(&self.output_root, &relative_path)?;
(retained, relative_path)
}
WasmHostVfsOperation::MaterializeArtifact {
artifact,
relative_path,
} => {
store.materialize_into_output(&artifact, &self.output_root, &relative_path)?;
let retained = store
.metadata(&artifact.id)?
.ok_or_else(|| format!("artifact `{}` became unavailable", artifact.id))?;
(retained, relative_path)
}
};
Ok(WasmHostVfsResult {
abi_version: clusterflux_core::WASM_TASK_ABI_VERSION,
artifact: clusterflux_core::ArtifactHandle {
id: retained.id,
digest: retained.digest,
size_bytes: retained.size_bytes,
},
relative_path,
})
}
fn snapshot_source(
&mut self,
request: WasmHostSourceSnapshotRequest,
) -> Result<WasmHostSourceSnapshotResult, String> {
request.validate()?;
if !self.allow_source_snapshot {
return Err(
"Wasm task did not declare SourceFilesystem capability or the selected node did not grant it"
.to_owned(),
);
}
let project_root = self.args.project_root.as_deref().ok_or_else(|| {
"source snapshot task requires a node project checkout; attach it with --project-root"
.to_owned()
})?;
let snapshot = snapshot_project(project_root)?;
Ok(WasmHostSourceSnapshotResult {
abi_version: clusterflux_core::WASM_TASK_ABI_VERSION,
snapshot: snapshot.digest,
})
}
}
#[cfg(test)]
mod tests;