Skip to main content

a3s_box_runtime/local_execution/
backend.rs

1//! Injectable process/runtime boundary for local execution orchestration.
2
3use std::path::PathBuf;
4
5use a3s_box_core::{
6    pty::PtyRequest, ExecOutput, ExecRequest, ExecutionEventBatch, ExecutionEventsRequest,
7    ExecutionId, ExecutionManagerError, ExecutionManagerResult, ExecutionProcess,
8    ExecutionProcessInventory, ExecutionResourceUpdate, ExecutionState, ExecutionStats,
9    FileRequest, FileResponse, FilesystemRequest, FilesystemResponse, KillOutcome, OperationId,
10};
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13
14use crate::local_execution::OciRuntimeBinding;
15use crate::{BoxRecord, ManagedRuntimeRoute};
16
17/// Runtime evidence persisted after an execution becomes ready.
18#[derive(Debug, Clone)]
19pub struct LocalExecutionHandle {
20    pub started_at: DateTime<Utc>,
21    pub pid: Option<u32>,
22    pub pid_start_time: Option<u64>,
23    pub exec_socket_path: PathBuf,
24    pub console_log: PathBuf,
25    pub anonymous_volumes: Vec<String>,
26    /// Exact A3S OCI identity when lifecycle ownership is delegated through
27    /// the public SDK rather than a Box-owned VM process.
28    pub oci_runtime: Option<OciRuntimeBinding>,
29}
30
31impl LocalExecutionHandle {
32    pub(crate) fn validate(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<()> {
33        if self.pid.is_none() && self.pid_start_time.is_some() {
34            return Err(ExecutionManagerError::Internal(format!(
35                "backend returned a PID start time without a PID for {execution_id}"
36            )));
37        }
38        if let Some(binding) = &self.oci_runtime {
39            binding.validate_for(execution_id)?;
40        }
41        if self.exec_socket_path.as_os_str().is_empty() && self.oci_runtime.is_none() {
42            return Err(ExecutionManagerError::Internal(format!(
43                "backend returned an empty exec socket path for {execution_id}"
44            )));
45        }
46        if self.console_log.as_os_str().is_empty() {
47            return Err(ExecutionManagerError::Internal(format!(
48                "backend returned an empty console log path for {execution_id}"
49            )));
50        }
51        Ok(())
52    }
53}
54
55/// One backend observation used during inspection and restart recovery.
56#[derive(Debug, Clone)]
57pub struct LocalExecutionObservation {
58    pub state: ExecutionState,
59    pub handle: Option<LocalExecutionHandle>,
60    pub exit_code: Option<i32>,
61}
62
63/// Result of a terminal backend operation, including authoritative status when
64/// the runtime was able to observe it before teardown.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct LocalExecutionTermination {
67    pub outcome: KillOutcome,
68    pub exit_code: Option<i32>,
69}
70
71impl LocalExecutionObservation {
72    pub(crate) fn validate(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<()> {
73        match self.state {
74            ExecutionState::Running | ExecutionState::Paused => {
75                self.handle.as_ref().ok_or_else(|| {
76                    ExecutionManagerError::Internal(format!(
77                        "backend returned {:?} without runtime evidence for {execution_id}",
78                        self.state
79                    ))
80                })?;
81            }
82            ExecutionState::Created
83            | ExecutionState::Creating
84            | ExecutionState::Stopped
85            | ExecutionState::Failed => {}
86        }
87        if let Some(handle) = &self.handle {
88            handle.validate(execution_id)?;
89        }
90        Ok(())
91    }
92}
93
94/// Backend operations invoked outside the durable state lock.
95///
96/// Implementations must key all host/runtime paths by [`BoxRecord::id`]. The
97/// external sandbox ID in managed metadata is an untrusted diagnostic label.
98#[async_trait]
99pub trait LocalExecutionBackend: Send + Sync {
100    /// Select the durable route for a new record before capability preflight.
101    ///
102    /// Custom test or embedding backends may leave the route unspecified. Box
103    /// production backends must return an exact route so a later policy change
104    /// cannot reinterpret a claimed or stopped generation.
105    fn route_for_create(&self, _record: &BoxRecord) -> ExecutionManagerResult<ManagedRuntimeRoute> {
106        Ok(ManagedRuntimeRoute::Unspecified)
107    }
108
109    /// Reject an unsupported execution before its durable reservation is
110    /// published. Backends must repeat mutable capability checks at launch.
111    async fn preflight(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
112        Ok(())
113    }
114
115    async fn start(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle>;
116
117    async fn inspect(
118        &self,
119        record: &BoxRecord,
120    ) -> ExecutionManagerResult<LocalExecutionObservation>;
121
122    async fn pause(
123        &self,
124        record: &BoxRecord,
125        keep_memory: bool,
126    ) -> ExecutionManagerResult<LocalExecutionHandle>;
127
128    async fn resume(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle>;
129
130    /// Validate a complete live resource contract before a durable mutation claim.
131    async fn preflight_resource_update(
132        &self,
133        _record: &BoxRecord,
134        _update: &ExecutionResourceUpdate,
135    ) -> ExecutionManagerResult<()> {
136        Err(ExecutionManagerError::Unavailable(
137            "this execution backend does not support live resource updates".to_string(),
138        ))
139    }
140
141    /// Apply the exact persisted resource update operation.
142    async fn update_resources(
143        &self,
144        _record: &BoxRecord,
145        _operation_id: &OperationId,
146        _update: &ExecutionResourceUpdate,
147    ) -> ExecutionManagerResult<()> {
148        Err(ExecutionManagerError::Unavailable(
149            "this execution backend does not support live resource updates".to_string(),
150        ))
151    }
152
153    /// Return live init and exec processes for the record's exact runtime target.
154    async fn list_processes(
155        &self,
156        _record: &BoxRecord,
157    ) -> ExecutionManagerResult<ExecutionProcessInventory> {
158        Err(ExecutionManagerError::Unavailable(
159            "this execution backend does not expose process inventory".to_string(),
160        ))
161    }
162
163    /// Return normalized counters for the record's exact runtime target.
164    async fn stats(&self, _record: &BoxRecord) -> ExecutionManagerResult<ExecutionStats> {
165        Err(ExecutionManagerError::Unavailable(
166            "this execution backend does not expose runtime stats".to_string(),
167        ))
168    }
169
170    /// Poll ordered events for the record's exact runtime target.
171    async fn events(
172        &self,
173        _record: &BoxRecord,
174        _request: ExecutionEventsRequest,
175    ) -> ExecutionManagerResult<ExecutionEventBatch> {
176        Err(ExecutionManagerError::Unavailable(
177            "this execution backend does not expose runtime events".to_string(),
178        ))
179    }
180
181    /// Execute one captured process through a backend-owned, generation-fenced
182    /// session boundary. The legacy VM backend retains its socket transport;
183    /// SDK-backed implementations override this method.
184    async fn execute(
185        &self,
186        _record: &BoxRecord,
187        _request: ExecRequest,
188    ) -> ExecutionManagerResult<ExecOutput> {
189        Err(ExecutionManagerError::Unavailable(
190            "this execution backend does not expose captured process sessions".to_string(),
191        ))
192    }
193
194    /// Start one streaming non-terminal process through the backend boundary.
195    async fn start_process(
196        &self,
197        _record: &BoxRecord,
198        _request: ExecRequest,
199    ) -> ExecutionManagerResult<ExecutionProcess> {
200        Err(ExecutionManagerError::Unavailable(
201            "this execution backend does not expose streaming process sessions".to_string(),
202        ))
203    }
204
205    /// Start one interactive terminal process through the backend boundary.
206    async fn start_pty(
207        &self,
208        _record: &BoxRecord,
209        _request: PtyRequest,
210    ) -> ExecutionManagerResult<ExecutionProcess> {
211        Err(ExecutionManagerError::Unavailable(
212            "this execution backend does not expose PTY sessions".to_string(),
213        ))
214    }
215
216    /// Transfer one file through the backend's exact-generation session.
217    async fn transfer_file(
218        &self,
219        _record: &BoxRecord,
220        _request: FileRequest,
221    ) -> ExecutionManagerResult<FileResponse> {
222        Err(ExecutionManagerError::Unavailable(
223            "this execution backend does not expose file transfer sessions".to_string(),
224        ))
225    }
226
227    /// Inspect or mutate the exact generation's workload filesystem.
228    async fn filesystem(
229        &self,
230        _record: &BoxRecord,
231        _request: FilesystemRequest,
232    ) -> ExecutionManagerResult<FilesystemResponse> {
233        Err(ExecutionManagerError::Unavailable(
234            "this execution backend does not expose filesystem sessions".to_string(),
235        ))
236    }
237
238    /// Make a stopped, storage-retained rootfs available for a filesystem
239    /// snapshot without starting the execution runtime.
240    async fn prepare_quiescent_rootfs(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
241        Ok(())
242    }
243
244    /// Release any transient rootfs mount created by
245    /// [`Self::prepare_quiescent_rootfs`] while retaining guest data.
246    async fn cleanup_quiescent_rootfs(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
247        Ok(())
248    }
249
250    /// Stop the current runtime while preserving execution-owned storage for
251    /// the replacement generation.
252    async fn stop_for_restart(
253        &self,
254        record: &BoxRecord,
255        _timeout_secs: Option<u64>,
256    ) -> ExecutionManagerResult<KillOutcome> {
257        self.kill(record).await
258    }
259
260    async fn kill(&self, record: &BoxRecord) -> ExecutionManagerResult<KillOutcome>;
261
262    /// Kill the current execution and return its exact terminal status when the
263    /// backend provides one. Implementations that cannot observe status retain
264    /// the legacy `kill` behavior through this default.
265    async fn kill_with_status(
266        &self,
267        record: &BoxRecord,
268    ) -> ExecutionManagerResult<LocalExecutionTermination> {
269        Ok(LocalExecutionTermination {
270            outcome: self.kill(record).await?,
271            exit_code: None,
272        })
273    }
274}