Skip to main content

a3s_box_runtime/
box_record.rs

1//! Canonical persisted metadata schema for local box executions.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use a3s_box_core::config::ResourceLimits;
7use a3s_box_core::log::LogConfig;
8use a3s_box_core::{
9    CreateExecutionRequest, ExecutionGeneration, ExecutionIsolation, ExecutionSnapshotId,
10    NetworkMode, OperationId, ResolvedExecutionPlan,
11};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15pub use a3s_box_core::ExecutionHealthCheck as HealthCheck;
16
17/// Metadata record for a single local box execution.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BoxRecord {
20    /// Full UUID.
21    pub id: String,
22    /// First 12 hex characters of the UUID, without dashes.
23    pub short_id: String,
24    /// User-assigned or generated name.
25    pub name: String,
26    /// OCI image reference.
27    pub image: String,
28    /// Requested execution isolation. Records written before this field default to MicroVM.
29    #[serde(default)]
30    pub isolation: ExecutionIsolation,
31    /// Runtime lifecycle identity and recoverable creation intent.
32    ///
33    /// Legacy CLI-created records omit this field. Managed executions persist
34    /// it before launch so an operation can be reconciled after a service
35    /// restart without creating a second execution.
36    #[serde(default)]
37    pub managed_execution: Option<ManagedExecutionMetadata>,
38    /// Persisted lifecycle state.
39    ///
40    /// Legacy records use `created`, `running`, `paused`, `stopped`, and
41    /// `dead`. Managed executions additionally use the durable transition
42    /// states defined by [`ManagedExecutionState`].
43    pub status: String,
44    /// Shim process PID while the execution is active.
45    pub pid: Option<u32>,
46    /// Start-time identity token used to reject a reused PID.
47    #[serde(default)]
48    pub pid_start_time: Option<u64>,
49    /// Number of virtual CPUs.
50    pub cpus: u32,
51    /// Memory in MiB.
52    pub memory_mb: u32,
53    /// Volume mounts encoded as host-to-guest pairs.
54    pub volumes: Vec<String>,
55    /// virtio-fs cache mode for host directory volumes.
56    #[serde(default)]
57    pub virtiofs_cache: Option<String>,
58    /// Environment variables.
59    pub env: HashMap<String, String>,
60    /// Command override.
61    pub cmd: Vec<String>,
62    /// Entrypoint override.
63    #[serde(default)]
64    pub entrypoint: Option<Vec<String>>,
65    /// Host-side execution directory.
66    pub box_dir: PathBuf,
67    /// Path to the exec socket.
68    #[serde(default)]
69    pub exec_socket_path: PathBuf,
70    /// Path to the console log.
71    pub console_log: PathBuf,
72    /// Creation timestamp.
73    pub created_at: DateTime<Utc>,
74    /// Start timestamp.
75    pub started_at: Option<DateTime<Utc>>,
76    /// Whether the execution is removed automatically after it stops.
77    pub auto_remove: bool,
78    /// Custom hostname.
79    #[serde(default)]
80    pub hostname: Option<String>,
81    /// User inside the workload.
82    #[serde(default)]
83    pub user: Option<String>,
84    /// Working directory inside the workload.
85    #[serde(default)]
86    pub workdir: Option<String>,
87    /// Restart policy.
88    #[serde(default = "default_restart_policy")]
89    pub restart_policy: String,
90    /// Port mappings.
91    #[serde(default)]
92    pub port_map: Vec<String>,
93    /// User-defined labels.
94    #[serde(default)]
95    pub labels: HashMap<String, String>,
96    /// Whether the execution was explicitly stopped by a user.
97    #[serde(default)]
98    pub stopped_by_user: bool,
99    /// Automatic restart count.
100    #[serde(default)]
101    pub restart_count: u32,
102    /// Maximum restart count for a bounded on-failure policy.
103    #[serde(default)]
104    pub max_restart_count: u32,
105    /// Last captured exit code.
106    #[serde(default)]
107    pub exit_code: Option<i32>,
108    /// Health-check configuration.
109    #[serde(default)]
110    pub health_check: Option<HealthCheck>,
111    /// Whether an image-defined health check was disabled explicitly.
112    #[serde(default)]
113    pub healthcheck_disabled: bool,
114    /// Current health state.
115    #[serde(default = "default_health_status")]
116    pub health_status: String,
117    /// Consecutive health-check failures.
118    #[serde(default)]
119    pub health_retries: u32,
120    /// Timestamp of the most recent health check.
121    #[serde(default)]
122    pub health_last_check: Option<DateTime<Utc>>,
123    /// Network mode.
124    #[serde(default)]
125    pub network_mode: NetworkMode,
126    /// Attached bridge network name.
127    #[serde(default)]
128    pub network_name: Option<String>,
129    /// Attached named volumes.
130    #[serde(default)]
131    pub volume_names: Vec<String>,
132    /// tmpfs mounts.
133    #[serde(default)]
134    pub tmpfs: Vec<String>,
135    /// Anonymous volumes materialized from OCI declarations.
136    #[serde(default)]
137    pub anonymous_volumes: Vec<String>,
138    /// Host resource controls.
139    #[serde(default)]
140    pub resource_limits: ResourceLimits,
141    /// Logging configuration.
142    #[serde(default)]
143    pub log_config: LogConfig,
144    /// Custom host-to-IP mappings.
145    #[serde(default)]
146    pub add_host: Vec<String>,
147    /// Target OCI platform.
148    #[serde(default)]
149    pub platform: Option<String>,
150    /// Whether to run an init process as PID 1.
151    #[serde(default)]
152    pub init: bool,
153    /// Whether the root filesystem is read-only.
154    #[serde(default)]
155    pub read_only: bool,
156    /// Added Linux capabilities.
157    #[serde(default)]
158    pub cap_add: Vec<String>,
159    /// Dropped Linux capabilities.
160    #[serde(default)]
161    pub cap_drop: Vec<String>,
162    /// OCI security options.
163    #[serde(default)]
164    pub security_opt: Vec<String>,
165    /// Whether extended privileges are enabled.
166    #[serde(default)]
167    pub privileged: bool,
168    /// Device mappings.
169    #[serde(default)]
170    pub devices: Vec<String>,
171    /// GPU selection.
172    #[serde(default)]
173    pub gpus: Option<String>,
174    /// Shared-memory size in bytes.
175    #[serde(default)]
176    pub shm_size: Option<u64>,
177    /// Signal used for graceful stop.
178    #[serde(default)]
179    pub stop_signal: Option<String>,
180    /// Graceful stop timeout in seconds.
181    #[serde(default)]
182    pub stop_timeout: Option<u64>,
183    /// Whether the OOM killer is disabled.
184    #[serde(default)]
185    pub oom_kill_disable: bool,
186    /// Host OOM score adjustment.
187    #[serde(default)]
188    pub oom_score_adj: Option<i32>,
189}
190
191impl BoxRecord {
192    /// Generate the stable short ID used by local CLI and SDK lookup.
193    pub fn make_short_id(id: &str) -> String {
194        id.replace('-', "").chars().take(12).collect()
195    }
196
197    /// Whether the persisted lifecycle state represents an active execution.
198    pub fn is_active(&self) -> bool {
199        if self.managed_execution.is_some() {
200            return self
201                .managed_state()
202                .is_ok_and(|state| state.is_some_and(ManagedExecutionState::keeps_resources));
203        }
204        matches!(self.status.as_str(), "running" | "paused")
205    }
206
207    /// Parse the lifecycle state of a managed execution.
208    ///
209    /// Legacy records return `None`. Unknown managed states fail closed so a
210    /// runtime service cannot operate on a record written by incompatible
211    /// code.
212    pub fn managed_state(&self) -> a3s_box_core::Result<Option<ManagedExecutionState>> {
213        let Some(metadata) = self.managed_execution.as_ref() else {
214            return Ok(None);
215        };
216        let state = ManagedExecutionState::from_status(&self.status)?;
217        validate_pending_operation(state, metadata)?;
218        Ok(Some(state))
219    }
220
221    /// Render a concise lifecycle status with health, exit, and restart annotations.
222    pub fn status_summary(&self) -> String {
223        let mut annotations = Vec::new();
224        if self.is_active() && self.health_check.is_some() && self.health_status != "none" {
225            annotations.push(self.health_status.clone());
226        }
227        if matches!(self.status.as_str(), "stopped" | "dead") {
228            if let Some(exit_code) = self.exit_code {
229                annotations.push(format!("Exit {exit_code}"));
230            }
231        }
232        if self.restart_count > 0 {
233            annotations.push(format!("Restarts: {}", self.restart_count));
234        }
235        if annotations.is_empty() {
236            self.status.clone()
237        } else {
238            format!("{} ({})", self.status, annotations.join(", "))
239        }
240    }
241}
242
243/// Durable lifecycle state for an execution owned by `ExecutionManager`.
244///
245/// Transitional states are persisted before backend side effects. This lets
246/// a restarted manager distinguish work that was never claimed from work that
247/// may already have reached the runtime.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum ManagedExecutionState {
251    Creating,
252    Created,
253    Starting,
254    Running,
255    Pausing,
256    Paused,
257    Resuming,
258    Snapshotting,
259    Killing,
260    RestartStopping,
261    RestartStarting,
262    Removing,
263    Stopped,
264    Failed,
265}
266
267impl ManagedExecutionState {
268    /// Canonical value written to [`BoxRecord::status`].
269    pub const fn as_status(self) -> &'static str {
270        match self {
271            Self::Creating => "creating",
272            Self::Created => "created",
273            Self::Starting => "starting",
274            Self::Running => "running",
275            Self::Pausing => "pausing",
276            Self::Paused => "paused",
277            Self::Resuming => "resuming",
278            Self::Snapshotting => "snapshotting",
279            Self::Killing => "killing",
280            Self::RestartStopping => "restart_stopping",
281            Self::RestartStarting => "restart_starting",
282            Self::Removing => "removing",
283            Self::Stopped => "stopped",
284            Self::Failed => "failed",
285        }
286    }
287
288    /// Parse a persisted managed lifecycle state.
289    pub fn from_status(status: &str) -> a3s_box_core::Result<Self> {
290        match status {
291            "creating" => Ok(Self::Creating),
292            "created" => Ok(Self::Created),
293            "starting" => Ok(Self::Starting),
294            "running" => Ok(Self::Running),
295            "pausing" => Ok(Self::Pausing),
296            "paused" => Ok(Self::Paused),
297            "resuming" => Ok(Self::Resuming),
298            "snapshotting" => Ok(Self::Snapshotting),
299            "killing" => Ok(Self::Killing),
300            "restart_stopping" => Ok(Self::RestartStopping),
301            "restart_starting" => Ok(Self::RestartStarting),
302            "removing" => Ok(Self::Removing),
303            "stopped" => Ok(Self::Stopped),
304            "dead" | "failed" => Ok(Self::Failed),
305            other => Err(a3s_box_core::BoxError::StateError(format!(
306                "unknown managed execution state: {other}"
307            ))),
308        }
309    }
310
311    /// Whether host resources may still belong to this execution.
312    pub const fn keeps_resources(self) -> bool {
313        !matches!(
314            self,
315            Self::Creating | Self::Created | Self::Stopped | Self::Failed
316        )
317    }
318
319    /// Whether no further lifecycle operation can revive this execution.
320    pub const fn is_terminal(self) -> bool {
321        matches!(self, Self::Stopped | Self::Failed)
322    }
323}
324
325impl std::fmt::Display for ManagedExecutionState {
326    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        formatter.write_str(self.as_status())
328    }
329}
330
331/// Durable lifecycle metadata for an execution owned by [`ExecutionManager`].
332///
333/// [`ExecutionManager`]: a3s_box_core::ExecutionManager
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ManagedExecutionMetadata {
336    /// Idempotency key of the create operation.
337    pub operation_id: OperationId,
338    /// Runtime generation used to reject stale lifecycle requests.
339    pub generation: ExecutionGeneration,
340    /// Full creation intent required to recover an interrupted launch.
341    pub request: CreateExecutionRequest,
342    /// Backend resolution validated before any launch side effects.
343    pub plan: ResolvedExecutionPlan,
344    /// Lifecycle side effect claimed before calling the backend.
345    #[serde(default)]
346    pub pending_operation: Option<ManagedExecutionOperation>,
347    /// Most recent completed restart retained for idempotent response replay.
348    #[serde(default)]
349    pub last_restart: Option<ManagedRestartCompletion>,
350    /// Provider terminal timestamp retained for deterministic observation replay.
351    #[serde(default)]
352    pub finished_at: Option<DateTime<Utc>>,
353    /// Whether a paused execution still owns a live, memory-preserved runtime.
354    ///
355    /// Records written before filesystem-only pause support always represented
356    /// warm pauses, so the backwards-compatible default is `true`.
357    #[serde(default = "default_paused_with_memory")]
358    pub paused_with_memory: bool,
359}
360
361/// Recoverable backend operation associated with a transitional state.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(tag = "kind", rename_all = "snake_case")]
364pub enum ManagedExecutionOperation {
365    Start,
366    Pause {
367        keep_memory: bool,
368    },
369    Resume,
370    Snapshot {
371        snapshot_id: ExecutionSnapshotId,
372        source_state: ManagedExecutionState,
373    },
374    Kill {
375        #[serde(default)]
376        signal: Option<i32>,
377        #[serde(default)]
378        timeout_secs: Option<u64>,
379    },
380    Remove,
381    Restart {
382        operation_id: OperationId,
383        source_generation: ExecutionGeneration,
384        source_state: ManagedExecutionState,
385        #[serde(default)]
386        stop_timeout_secs: Option<u64>,
387    },
388}
389
390/// Durable result of the most recent restart operation.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum ManagedRestartOutcome {
394    Running,
395    Failed,
396}
397
398/// Restart identity retained after its transitional state has completed.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct ManagedRestartCompletion {
401    pub operation_id: OperationId,
402    pub source_generation: ExecutionGeneration,
403    pub target_generation: ExecutionGeneration,
404    pub outcome: ManagedRestartOutcome,
405    #[serde(default)]
406    pub stop_timeout_secs: Option<u64>,
407}
408
409impl ManagedExecutionMetadata {
410    /// Build validated recovery metadata from one creation request.
411    pub fn new(
412        operation_id: OperationId,
413        generation: ExecutionGeneration,
414        request: CreateExecutionRequest,
415    ) -> a3s_box_core::Result<Self> {
416        if request.external_sandbox_id.trim().is_empty() {
417            return Err(a3s_box_core::BoxError::ConfigError(
418                "external sandbox ID cannot be empty".to_string(),
419            ));
420        }
421        let plan = a3s_box_core::resolve_execution(&request.config)?;
422        Ok(Self {
423            operation_id,
424            generation,
425            request,
426            plan,
427            pending_operation: None,
428            last_restart: None,
429            finished_at: None,
430            paused_with_memory: true,
431        })
432    }
433
434    /// Validate deserialized metadata before it participates in reconciliation.
435    pub fn validate(&self) -> a3s_box_core::Result<()> {
436        if self.request.external_sandbox_id.trim().is_empty() {
437            return Err(a3s_box_core::BoxError::StateError(
438                "managed execution has an empty external sandbox ID".to_string(),
439            ));
440        }
441        let resolved = a3s_box_core::resolve_execution(&self.request.config)?;
442        if resolved != self.plan {
443            return Err(a3s_box_core::BoxError::StateError(
444                "managed execution plan does not match its persisted creation request".to_string(),
445            ));
446        }
447        if let Some(completed) = &self.last_restart {
448            let expected_target = next_generation(completed.source_generation)?;
449            if completed.target_generation != expected_target {
450                return Err(a3s_box_core::BoxError::StateError(format!(
451                    "completed restart {} has inconsistent generations",
452                    completed.operation_id
453                )));
454            }
455            validate_stop_timeout(completed.stop_timeout_secs)?;
456        }
457        Ok(())
458    }
459}
460
461fn validate_pending_operation(
462    state: ManagedExecutionState,
463    metadata: &ManagedExecutionMetadata,
464) -> a3s_box_core::Result<()> {
465    let operation = metadata.pending_operation.as_ref();
466    let consistent = matches!(
467        (state, operation),
468        (
469            ManagedExecutionState::Starting,
470            Some(ManagedExecutionOperation::Start)
471        ) | (
472            ManagedExecutionState::Pausing,
473            Some(ManagedExecutionOperation::Pause { .. })
474        ) | (
475            ManagedExecutionState::Resuming,
476            Some(ManagedExecutionOperation::Resume)
477        ) | (
478            ManagedExecutionState::Snapshotting,
479            Some(ManagedExecutionOperation::Snapshot { .. })
480        ) | (
481            ManagedExecutionState::Killing,
482            Some(ManagedExecutionOperation::Kill { .. })
483        ) | (
484            ManagedExecutionState::Removing,
485            Some(ManagedExecutionOperation::Remove)
486        ) | (
487            ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting,
488            Some(ManagedExecutionOperation::Restart { .. })
489        ) | (
490            ManagedExecutionState::Creating
491                | ManagedExecutionState::Created
492                | ManagedExecutionState::Running
493                | ManagedExecutionState::Paused
494                | ManagedExecutionState::Stopped
495                | ManagedExecutionState::Failed,
496            None
497        )
498    );
499    if !consistent {
500        return Err(a3s_box_core::BoxError::StateError(format!(
501            "managed execution state {state} has inconsistent pending operation"
502        )));
503    }
504
505    if let Some(ManagedExecutionOperation::Restart {
506        source_generation,
507        source_state,
508        stop_timeout_secs,
509        ..
510    }) = operation
511    {
512        if !matches!(
513            source_state,
514            ManagedExecutionState::Created
515                | ManagedExecutionState::Running
516                | ManagedExecutionState::Paused
517                | ManagedExecutionState::Stopped
518                | ManagedExecutionState::Failed
519        ) {
520            return Err(a3s_box_core::BoxError::StateError(
521                "restart source state is not stable".to_string(),
522            ));
523        }
524        let expected = match state {
525            ManagedExecutionState::RestartStopping => *source_generation,
526            ManagedExecutionState::RestartStarting => next_generation(*source_generation)?,
527            _ => {
528                return Err(a3s_box_core::BoxError::StateError(
529                    "restart operation is attached to a non-restart state".to_string(),
530                ))
531            }
532        };
533        if metadata.generation != expected {
534            return Err(a3s_box_core::BoxError::StateError(format!(
535                "restart state {state} has generation {}, expected {}",
536                metadata.generation.get(),
537                expected.get()
538            )));
539        }
540        validate_stop_timeout(*stop_timeout_secs)?;
541    }
542    if let Some(ManagedExecutionOperation::Snapshot { source_state, .. }) = operation {
543        if state != ManagedExecutionState::Snapshotting
544            || !matches!(
545                source_state,
546                ManagedExecutionState::Running | ManagedExecutionState::Paused
547            )
548        {
549            return Err(a3s_box_core::BoxError::StateError(
550                "snapshot operation has an invalid source state".to_string(),
551            ));
552        }
553    }
554    if let Some(ManagedExecutionOperation::Kill {
555        signal,
556        timeout_secs,
557    }) = operation
558    {
559        if signal.is_some_and(|signal| signal <= 0) {
560            return Err(a3s_box_core::BoxError::StateError(
561                "kill signal must be positive".to_string(),
562            ));
563        }
564        validate_stop_timeout(*timeout_secs)?;
565    }
566    if !metadata.paused_with_memory {
567        let valid_cold_pause_state = match (state, operation) {
568            (
569                ManagedExecutionState::Pausing,
570                Some(ManagedExecutionOperation::Pause { keep_memory }),
571            ) => !keep_memory,
572            (ManagedExecutionState::Paused | ManagedExecutionState::Resuming, _) => true,
573            (
574                ManagedExecutionState::Snapshotting,
575                Some(ManagedExecutionOperation::Snapshot { source_state, .. }),
576            ) => *source_state == ManagedExecutionState::Paused,
577            // These transitions may be claimed from a cold-paused execution.
578            (ManagedExecutionState::Killing | ManagedExecutionState::Removing, _) => true,
579            (
580                ManagedExecutionState::RestartStopping,
581                Some(ManagedExecutionOperation::Restart { source_state, .. }),
582            ) => *source_state == ManagedExecutionState::Paused,
583            _ => false,
584        };
585        if !valid_cold_pause_state {
586            return Err(a3s_box_core::BoxError::StateError(format!(
587                "managed execution state {state} cannot retain a filesystem-only pause"
588            )));
589        }
590    }
591    Ok(())
592}
593
594fn validate_stop_timeout(timeout_secs: Option<u64>) -> a3s_box_core::Result<()> {
595    if timeout_secs.is_some_and(|timeout| timeout.checked_mul(1_000).is_none()) {
596        Err(a3s_box_core::BoxError::StateError(
597            "managed stop timeout is too large".to_string(),
598        ))
599    } else {
600        Ok(())
601    }
602}
603
604fn next_generation(generation: ExecutionGeneration) -> a3s_box_core::Result<ExecutionGeneration> {
605    let value = generation.get().checked_add(1).ok_or_else(|| {
606        a3s_box_core::BoxError::StateError("execution generation is exhausted".to_string())
607    })?;
608    ExecutionGeneration::new(value).map_err(|error| {
609        a3s_box_core::BoxError::StateError(format!("invalid execution generation: {error}"))
610    })
611}
612
613fn default_restart_policy() -> String {
614    "no".to_string()
615}
616
617fn default_health_status() -> String {
618    "none".to_string()
619}
620
621const fn default_paused_with_memory() -> bool {
622    true
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    fn minimal_record() -> serde_json::Value {
630        serde_json::json!({
631            "id": "11111111-1111-4111-8111-111111111111",
632            "short_id": "111111111111",
633            "name": "fixture",
634            "image": "alpine:latest",
635            "status": "created",
636            "pid": null,
637            "cpus": 1,
638            "memory_mb": 128,
639            "volumes": [],
640            "env": {},
641            "cmd": ["sh"],
642            "box_dir": "/tmp/fixture",
643            "console_log": "/tmp/fixture/console.log",
644            "created_at": "2026-07-14T12:00:00Z",
645            "started_at": null,
646            "auto_remove": false
647        })
648    }
649
650    #[test]
651    fn legacy_records_default_without_losing_runtime_fields() {
652        let mut value = minimal_record();
653        value["virtiofs_cache"] = serde_json::json!("always");
654        let record: BoxRecord = serde_json::from_value(value).unwrap();
655
656        assert_eq!(record.isolation, ExecutionIsolation::Microvm);
657        assert!(record.managed_execution.is_none());
658        assert_eq!(record.virtiofs_cache.as_deref(), Some("always"));
659        assert_eq!(record.restart_policy, "no");
660        assert_eq!(record.health_status, "none");
661        assert_eq!(
662            serde_json::to_value(record).unwrap()["virtiofs_cache"],
663            "always"
664        );
665    }
666
667    #[test]
668    fn managed_execution_metadata_round_trips_recovery_intent() {
669        let mut config = a3s_box_core::BoxConfig {
670            image: "alpine:latest".to_string(),
671            isolation: ExecutionIsolation::Sandbox,
672            ..Default::default()
673        };
674        config.resources.vcpus = 1;
675        config.resources.memory_mb = 128;
676        let metadata = ManagedExecutionMetadata::new(
677            OperationId::new("create-op-1").unwrap(),
678            ExecutionGeneration::INITIAL,
679            CreateExecutionRequest {
680                external_sandbox_id: "sandbox-1".to_string(),
681                config,
682                labels: Default::default(),
683                policy: Default::default(),
684                rootfs_snapshot_id: None,
685            },
686        )
687        .unwrap();
688        let mut value = minimal_record();
689        value["managed_execution"] = serde_json::to_value(metadata).unwrap();
690        value["managed_execution"]
691            .as_object_mut()
692            .unwrap()
693            .remove("paused_with_memory");
694
695        let record: BoxRecord = serde_json::from_value(value).unwrap();
696        let encoded = serde_json::to_value(&record).unwrap();
697        assert_eq!(
698            record.managed_state().unwrap(),
699            Some(ManagedExecutionState::Created)
700        );
701        assert!(!record.is_active());
702        let managed = record.managed_execution.unwrap();
703
704        assert_eq!(managed.operation_id.as_str(), "create-op-1");
705        assert_eq!(managed.generation, ExecutionGeneration::INITIAL);
706        assert_eq!(managed.request.external_sandbox_id, "sandbox-1");
707        assert!(managed.paused_with_memory);
708        assert_eq!(
709            managed.request.config.isolation,
710            ExecutionIsolation::Sandbox
711        );
712        assert_eq!(encoded["managed_execution"]["generation"], 1);
713        assert_eq!(encoded["managed_execution"]["paused_with_memory"], true);
714    }
715
716    #[test]
717    fn legacy_kill_operation_defaults_new_termination_options() {
718        let operation: ManagedExecutionOperation =
719            serde_json::from_value(serde_json::json!({ "kind": "kill" })).unwrap();
720
721        assert_eq!(
722            operation,
723            ManagedExecutionOperation::Kill {
724                signal: None,
725                timeout_secs: None,
726            }
727        );
728    }
729
730    #[test]
731    fn managed_execution_rejects_a_cold_pause_marker_in_running_state() {
732        let config = a3s_box_core::BoxConfig {
733            image: "alpine:latest".to_string(),
734            isolation: ExecutionIsolation::Sandbox,
735            ..Default::default()
736        };
737        let mut metadata = ManagedExecutionMetadata::new(
738            OperationId::new("create-op-cold-invalid").unwrap(),
739            ExecutionGeneration::INITIAL,
740            CreateExecutionRequest {
741                external_sandbox_id: "sandbox-cold-invalid".to_string(),
742                config,
743                labels: Default::default(),
744                policy: Default::default(),
745                rootfs_snapshot_id: None,
746            },
747        )
748        .unwrap();
749        metadata.paused_with_memory = false;
750        let mut value = minimal_record();
751        value["status"] = serde_json::json!("running");
752        value["managed_execution"] = serde_json::to_value(metadata).unwrap();
753        let record: BoxRecord = serde_json::from_value(value).unwrap();
754
755        assert!(record.managed_state().is_err());
756    }
757
758    #[test]
759    fn managed_execution_validation_rejects_plan_drift() {
760        let config = a3s_box_core::BoxConfig {
761            image: "alpine:latest".to_string(),
762            isolation: ExecutionIsolation::Sandbox,
763            ..Default::default()
764        };
765        let mut metadata = ManagedExecutionMetadata::new(
766            OperationId::new("create-op-1").unwrap(),
767            ExecutionGeneration::INITIAL,
768            CreateExecutionRequest {
769                external_sandbox_id: "sandbox-1".to_string(),
770                config,
771                labels: Default::default(),
772                policy: Default::default(),
773                rootfs_snapshot_id: None,
774            },
775        )
776        .unwrap();
777        metadata.plan =
778            a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap();
779
780        assert!(metadata.validate().is_err());
781    }
782}