Skip to main content

a3s_box_runtime/
managed_execution_store.rs

1//! Durable generation-fenced transitions for managed local executions.
2
3use std::path::{Path, PathBuf};
4
5use a3s_box_core::{ExecutionGeneration, ExecutionId, ExecutionSnapshotId, OperationId};
6use thiserror::Error;
7
8use crate::{
9    BoxRecord, BoxStateStore, ManagedExecutionOperation, ManagedExecutionState,
10    ManagedResourceUpdateCompletion, ManagedRestartCompletion, ManagedRestartOutcome,
11};
12
13/// Strict durable repository used by the local `ExecutionManager`.
14#[derive(Debug, Clone)]
15pub struct ManagedExecutionStore {
16    path: PathBuf,
17}
18
19/// Result of reserving an idempotent create operation.
20#[derive(Debug, Clone)]
21pub enum ManagedExecutionReservation {
22    /// The creation intent was inserted by this call.
23    Reserved(BoxRecord),
24    /// The operation already existed with the same creation intent.
25    Existing(BoxRecord),
26}
27
28impl ManagedExecutionReservation {
29    pub const fn is_new(&self) -> bool {
30        matches!(self, Self::Reserved(_))
31    }
32
33    pub fn record(&self) -> &BoxRecord {
34        match self {
35            Self::Reserved(record) | Self::Existing(record) => record,
36        }
37    }
38
39    pub fn into_record(self) -> BoxRecord {
40        match self {
41            Self::Reserved(record) | Self::Existing(record) => record,
42        }
43    }
44}
45
46/// Fail-closed errors from managed lifecycle persistence.
47#[derive(Debug, Error)]
48pub enum ManagedExecutionStoreError {
49    #[error("managed execution state I/O failed: {0}")]
50    Io(#[from] std::io::Error),
51    #[error("managed execution not found: {0}")]
52    NotFound(ExecutionId),
53    #[error("execution record is not managed: {0}")]
54    Unmanaged(ExecutionId),
55    #[error("managed execution conflict for {execution_id}: {message}")]
56    Conflict {
57        execution_id: ExecutionId,
58        message: String,
59    },
60    #[error("invalid managed execution record: {0}")]
61    InvalidRecord(String),
62    #[error("invalid managed execution transition for {execution_id}: {from} -> {to}")]
63    InvalidTransition {
64        execution_id: ExecutionId,
65        from: ManagedExecutionState,
66        to: ManagedExecutionState,
67    },
68}
69
70pub type ManagedExecutionStoreResult<T> = std::result::Result<T, ManagedExecutionStoreError>;
71
72impl ManagedExecutionStore {
73    pub fn new(path: impl Into<PathBuf>) -> Self {
74        Self { path: path.into() }
75    }
76
77    pub fn path(&self) -> &Path {
78        &self.path
79    }
80
81    /// Return one managed record without mutating or reconciling state.
82    pub fn get(
83        &self,
84        execution_id: &ExecutionId,
85    ) -> ManagedExecutionStoreResult<Option<BoxRecord>> {
86        let store = BoxStateStore::load_readonly(&self.path)?;
87        let Some(record) = store.find_by_id(execution_id.as_str()).cloned() else {
88            return Ok(None);
89        };
90        if record.managed_execution.is_none() {
91            return Err(ManagedExecutionStoreError::Unmanaged(execution_id.clone()));
92        }
93        Ok(Some(record))
94    }
95
96    /// Return every managed record without reconciling provider state.
97    ///
98    /// Legacy CLI records share the same state file and are deliberately
99    /// excluded. Loading remains strict: one malformed managed record fails the
100    /// complete snapshot instead of letting provider discovery skip corrupt
101    /// ownership metadata.
102    pub fn list(&self) -> ManagedExecutionStoreResult<Vec<BoxRecord>> {
103        let store = BoxStateStore::load_readonly(&self.path)?;
104        Ok(store
105            .records()
106            .iter()
107            .filter(|record| record.managed_execution.is_some())
108            .cloned()
109            .collect())
110    }
111
112    /// Return the record reserved by an idempotent creation operation.
113    pub fn get_by_operation_id(
114        &self,
115        operation_id: &OperationId,
116    ) -> ManagedExecutionStoreResult<Option<BoxRecord>> {
117        let store = BoxStateStore::load_readonly(&self.path)?;
118        Ok(store.find_by_operation_id(operation_id).cloned())
119    }
120
121    /// Atomically reserve one creation operation before backend side effects.
122    ///
123    /// Retrying the same operation with the same full request returns the
124    /// existing record. Reusing an operation ID for different creation intent
125    /// fails without changing durable state.
126    pub fn reserve(
127        &self,
128        mut record: BoxRecord,
129    ) -> ManagedExecutionStoreResult<ManagedExecutionReservation> {
130        let execution_id = validate_new_record(&record)?;
131        record.status = ManagedExecutionState::Created.as_status().to_string();
132        let incoming_metadata = record
133            .managed_execution
134            .as_ref()
135            .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?
136            .clone();
137
138        BoxStateStore::transact(&self.path, move |store| {
139            if let Some(existing) = store
140                .find_by_operation_id(&incoming_metadata.operation_id)
141                .cloned()
142            {
143                let existing_id = ExecutionId::new(existing.id.clone()).map_err(|error| {
144                    ManagedExecutionStoreError::InvalidRecord(error.to_string())
145                })?;
146                let existing_metadata = existing
147                    .managed_execution
148                    .as_ref()
149                    .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(existing_id.clone()))?;
150                if !same_reservation_intent(existing_metadata, &incoming_metadata)? {
151                    return Err(ManagedExecutionStoreError::Conflict {
152                        execution_id: existing_id,
153                        message: format!(
154                            "operation {} was already reserved with different creation intent",
155                            incoming_metadata.operation_id
156                        ),
157                    });
158                }
159                return Ok(ManagedExecutionReservation::Existing(existing));
160            }
161
162            if store.find_by_id(execution_id.as_str()).is_some() {
163                return Err(ManagedExecutionStoreError::Conflict {
164                    execution_id,
165                    message: "execution ID is already present".to_string(),
166                });
167            }
168
169            store.records_mut().push(record.clone());
170            Ok(ManagedExecutionReservation::Reserved(record))
171        })
172    }
173
174    /// Claim terminal-record removal before deleting host resources.
175    ///
176    /// The durable `removing` state prevents another lifecycle operation from
177    /// reviving the execution while cleanup is in progress. A retry observes
178    /// the same claim and can resume cleanup after a process crash.
179    pub fn begin_remove(
180        &self,
181        execution_id: &ExecutionId,
182        expected_generation: ExecutionGeneration,
183    ) -> ManagedExecutionStoreResult<Option<BoxRecord>> {
184        let execution_id = execution_id.clone();
185        BoxStateStore::transact(&self.path, move |store| {
186            let Some(record) = store.find_by_id_mut(execution_id.as_str()) else {
187                return Ok(None);
188            };
189            let state = record
190                .managed_state()
191                .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
192                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
193            let metadata = record
194                .managed_execution
195                .as_mut()
196                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
197            if metadata.generation != expected_generation {
198                return Err(ManagedExecutionStoreError::Conflict {
199                    execution_id: execution_id.clone(),
200                    message: format!(
201                        "expected generation {}, found {}",
202                        expected_generation.get(),
203                        metadata.generation.get()
204                    ),
205                });
206            }
207            match state {
208                ManagedExecutionState::Removing => Ok(Some(record.clone())),
209                ManagedExecutionState::Created
210                | ManagedExecutionState::Stopped
211                | ManagedExecutionState::Failed => {
212                    record.status = ManagedExecutionState::Removing.as_status().to_string();
213                    metadata.pending_operation = Some(ManagedExecutionOperation::Remove);
214                    Ok(Some(record.clone()))
215                }
216                _ => Err(ManagedExecutionStoreError::Conflict {
217                    execution_id: execution_id.clone(),
218                    message: format!("cannot remove execution in state {state}"),
219                }),
220            }
221        })
222    }
223
224    /// Forget a generation only after its durable removal claim has completed.
225    pub fn finish_remove(
226        &self,
227        execution_id: &ExecutionId,
228        expected_generation: ExecutionGeneration,
229    ) -> ManagedExecutionStoreResult<bool> {
230        let execution_id = execution_id.clone();
231        BoxStateStore::transact(&self.path, move |store| {
232            let Some(record) = store.find_by_id(execution_id.as_str()) else {
233                return Ok(false);
234            };
235            let state = record
236                .managed_state()
237                .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
238                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
239            let metadata = record
240                .managed_execution
241                .as_ref()
242                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
243            if metadata.generation != expected_generation
244                || state != ManagedExecutionState::Removing
245            {
246                return Err(ManagedExecutionStoreError::Conflict {
247                    execution_id: execution_id.clone(),
248                    message: format!(
249                        "expected removing generation {}, found {state} generation {}",
250                        expected_generation.get(),
251                        metadata.generation.get()
252                    ),
253                });
254            }
255            Ok(store.remove_by_id(execution_id.as_str()))
256        })
257    }
258
259    /// Atomically compare generation and state, then persist one legal edge.
260    ///
261    /// Completing pause and resume, and advancing a restart from teardown to
262    /// startup, increments the runtime generation exactly once. Other edges
263    /// retain the current generation.
264    pub fn transition(
265        &self,
266        execution_id: &ExecutionId,
267        expected_generation: ExecutionGeneration,
268        expected_state: ManagedExecutionState,
269        next_state: ManagedExecutionState,
270    ) -> ManagedExecutionStoreResult<BoxRecord> {
271        self.transition_with(
272            execution_id,
273            expected_generation,
274            expected_state,
275            next_state,
276            |_| {},
277        )
278    }
279
280    /// Persist one legal transition and update runtime evidence in the same
281    /// transaction.
282    pub fn transition_with(
283        &self,
284        execution_id: &ExecutionId,
285        expected_generation: ExecutionGeneration,
286        expected_state: ManagedExecutionState,
287        next_state: ManagedExecutionState,
288        update: impl FnOnce(&mut BoxRecord),
289    ) -> ManagedExecutionStoreResult<BoxRecord> {
290        let execution_id = execution_id.clone();
291        BoxStateStore::transact(&self.path, move |store| {
292            let record = store
293                .find_by_id_mut(execution_id.as_str())
294                .ok_or_else(|| ManagedExecutionStoreError::NotFound(execution_id.clone()))?;
295            let actual_state = record
296                .managed_state()
297                .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
298                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
299            let metadata = record
300                .managed_execution
301                .as_ref()
302                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
303
304            if metadata.generation != expected_generation || actual_state != expected_state {
305                return Err(ManagedExecutionStoreError::Conflict {
306                    execution_id: execution_id.clone(),
307                    message: format!(
308                        "expected {expected_state} generation {}, found {actual_state} generation {}",
309                        expected_generation.get(),
310                        metadata.generation.get()
311                    ),
312                });
313            }
314
315            let next_generation = transition_generation(
316                &execution_id,
317                expected_state,
318                next_state,
319                expected_generation,
320            )?;
321            let original_metadata = record
322                .managed_execution
323                .as_ref()
324                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?
325                .clone();
326            update(record);
327            if record.id != execution_id.as_str() {
328                return Err(ManagedExecutionStoreError::InvalidRecord(format!(
329                    "transition changed execution ID {execution_id}"
330                )));
331            }
332            let updated_metadata = record
333                .managed_execution
334                .as_ref()
335                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
336            if updated_metadata.operation_id != original_metadata.operation_id
337                || updated_metadata.creation_intent_digest
338                    != original_metadata.creation_intent_digest
339                || !same_creation_intent(updated_metadata, &original_metadata)?
340            {
341                return Err(ManagedExecutionStoreError::InvalidRecord(format!(
342                    "transition changed creation identity for {execution_id}"
343                )));
344            }
345            record.status = next_state.as_status().to_string();
346            let metadata = record
347                .managed_execution
348                .as_mut()
349                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
350            metadata.generation = next_generation;
351            if expected_state == ManagedExecutionState::RestartStarting
352                && matches!(
353                    next_state,
354                    ManagedExecutionState::Running
355                        | ManagedExecutionState::Stopped
356                        | ManagedExecutionState::Failed
357                )
358            {
359                let Some(ManagedExecutionOperation::Restart {
360                    operation_id,
361                    source_generation,
362                    stop_timeout_secs,
363                    ..
364                }) = metadata.pending_operation.as_ref()
365                else {
366                    return Err(ManagedExecutionStoreError::InvalidRecord(format!(
367                        "restart completion for {execution_id} has no persisted restart intent"
368                    )));
369                };
370                let outcome = if next_state == ManagedExecutionState::Running {
371                    ManagedRestartOutcome::Running
372                } else if next_state == ManagedExecutionState::Stopped {
373                    ManagedRestartOutcome::Stopped
374                } else if next_state == ManagedExecutionState::Failed {
375                    ManagedRestartOutcome::Failed
376                } else {
377                    return Err(ManagedExecutionStoreError::InvalidRecord(format!(
378                        "restart completion for {execution_id} has non-terminal state {next_state}"
379                    )));
380                };
381                metadata.last_restart = Some(ManagedRestartCompletion {
382                    operation_id: operation_id.clone(),
383                    source_generation: *source_generation,
384                    target_generation: next_generation,
385                    outcome,
386                    stop_timeout_secs: *stop_timeout_secs,
387                });
388            }
389            metadata.pending_operation = match next_state {
390                ManagedExecutionState::Starting => Some(ManagedExecutionOperation::Start),
391                ManagedExecutionState::Pausing => match metadata.pending_operation.take() {
392                    Some(operation @ ManagedExecutionOperation::Pause { .. }) => Some(operation),
393                    _ => Some(ManagedExecutionOperation::Pause {
394                        keep_memory: false,
395                        operation_id: None,
396                    }),
397                },
398                ManagedExecutionState::Resuming => match metadata.pending_operation.take() {
399                    Some(operation @ ManagedExecutionOperation::Resume { .. }) => Some(operation),
400                    _ => Some(ManagedExecutionOperation::Resume { operation_id: None }),
401                },
402                ManagedExecutionState::UpdatingResources => {
403                    match metadata.pending_operation.take() {
404                        Some(operation @ ManagedExecutionOperation::UpdateResources { .. }) => {
405                            Some(operation)
406                        }
407                        _ => {
408                            return Err(ManagedExecutionStoreError::InvalidRecord(format!(
409                            "resource update transition for {execution_id} has no persisted intent"
410                        )))
411                        }
412                    }
413                }
414                ManagedExecutionState::Snapshotting => match metadata.pending_operation.take() {
415                    Some(operation @ ManagedExecutionOperation::Snapshot { .. }) => Some(operation),
416                    _ => {
417                        return Err(ManagedExecutionStoreError::InvalidRecord(format!(
418                        "snapshot transition for {execution_id} has no persisted snapshot intent"
419                    )))
420                    }
421                },
422                ManagedExecutionState::Killing => match metadata.pending_operation.take() {
423                    Some(operation @ ManagedExecutionOperation::Kill { .. }) => Some(operation),
424                    _ => Some(ManagedExecutionOperation::Kill {
425                        signal: None,
426                        timeout_secs: None,
427                    }),
428                },
429                ManagedExecutionState::Removing => Some(ManagedExecutionOperation::Remove),
430                ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting => {
431                    match metadata.pending_operation.take() {
432                        Some(operation @ ManagedExecutionOperation::Restart { .. }) => {
433                            Some(operation)
434                        }
435                        _ => {
436                            return Err(ManagedExecutionStoreError::InvalidRecord(format!(
437                            "restart transition for {execution_id} has no persisted restart intent"
438                        )))
439                        }
440                    }
441                }
442                ManagedExecutionState::Creating
443                | ManagedExecutionState::Created
444                | ManagedExecutionState::Running
445                | ManagedExecutionState::Paused
446                | ManagedExecutionState::Stopped
447                | ManagedExecutionState::Failed => None,
448            };
449            Ok(record.clone())
450        })
451    }
452
453    /// Persist that a running snapshot claim reached the frozen runtime phase.
454    ///
455    /// The exact snapshot and backend operation identities fence a delayed
456    /// writer from marking a newer same-generation attempt. Repeating the same
457    /// write is idempotent so a lost store response remains recoverable.
458    pub fn mark_snapshot_freezer_applied(
459        &self,
460        execution_id: &ExecutionId,
461        expected_generation: ExecutionGeneration,
462        expected_snapshot_id: &ExecutionSnapshotId,
463        expected_operation_id: Option<&OperationId>,
464    ) -> ManagedExecutionStoreResult<BoxRecord> {
465        let execution_id = execution_id.clone();
466        let expected_snapshot_id = expected_snapshot_id.clone();
467        let expected_operation_id = expected_operation_id.cloned();
468        BoxStateStore::transact(&self.path, move |store| {
469            let record = store
470                .find_by_id_mut(execution_id.as_str())
471                .ok_or_else(|| ManagedExecutionStoreError::NotFound(execution_id.clone()))?;
472            let state = record
473                .managed_state()
474                .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
475                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
476            let metadata = record
477                .managed_execution
478                .as_mut()
479                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
480            if state != ManagedExecutionState::Snapshotting
481                || metadata.generation != expected_generation
482            {
483                return Err(ManagedExecutionStoreError::Conflict {
484                    execution_id: execution_id.clone(),
485                    message: format!(
486                        "expected snapshotting generation {}, found {state} generation {}",
487                        expected_generation.get(),
488                        metadata.generation.get()
489                    ),
490                });
491            }
492            match metadata.pending_operation.as_mut() {
493                Some(ManagedExecutionOperation::Snapshot {
494                    snapshot_id,
495                    source_state: ManagedExecutionState::Running,
496                    operation_id,
497                    freezer_applied,
498                }) if snapshot_id == &expected_snapshot_id
499                    && operation_id == &expected_operation_id =>
500                {
501                    *freezer_applied = true;
502                }
503                _ => {
504                    return Err(ManagedExecutionStoreError::Conflict {
505                        execution_id: execution_id.clone(),
506                        message: format!(
507                            "snapshot freezer phase does not match claim {expected_snapshot_id}"
508                        ),
509                    })
510                }
511            }
512            Ok(record.clone())
513        })
514    }
515
516    /// Commit one successfully applied live resource update and clear its claim.
517    ///
518    /// This dedicated transaction is the only managed transition allowed to
519    /// change creation-time resource intent. It applies exactly the persisted
520    /// partial request to both compatibility fields and restart recovery state.
521    pub fn finish_resource_update(
522        &self,
523        execution_id: &ExecutionId,
524        expected_generation: ExecutionGeneration,
525    ) -> ManagedExecutionStoreResult<BoxRecord> {
526        let execution_id = execution_id.clone();
527        BoxStateStore::transact(&self.path, move |store| {
528            let record = store
529                .find_by_id_mut(execution_id.as_str())
530                .ok_or_else(|| ManagedExecutionStoreError::NotFound(execution_id.clone()))?;
531            let state = record
532                .managed_state()
533                .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
534                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
535            let metadata = record
536                .managed_execution
537                .as_ref()
538                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
539            if state != ManagedExecutionState::UpdatingResources
540                || metadata.generation != expected_generation
541            {
542                return Err(ManagedExecutionStoreError::Conflict {
543                    execution_id: execution_id.clone(),
544                    message: format!(
545                        "expected updating_resources generation {}, found {state} generation {}",
546                        expected_generation.get(),
547                        metadata.generation.get()
548                    ),
549                });
550            }
551            let (operation_id, update) = match metadata.pending_operation.as_ref() {
552                Some(ManagedExecutionOperation::UpdateResources {
553                    operation_id,
554                    update,
555                }) => (operation_id.clone(), update.clone()),
556                _ => {
557                    return Err(ManagedExecutionStoreError::InvalidRecord(format!(
558                        "resource update completion for {execution_id} has no persisted intent"
559                    )))
560                }
561            };
562
563            let metadata = record
564                .managed_execution
565                .as_mut()
566                .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
567            update.apply_to(&mut metadata.request.config.resource_limits);
568            metadata.plan = a3s_box_core::resolve_execution(&metadata.request.config)
569                .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
570            record.resource_limits = metadata.request.config.resource_limits.clone();
571            metadata.last_resource_update = Some(ManagedResourceUpdateCompletion {
572                operation_id,
573                generation: expected_generation,
574                update,
575            });
576            metadata.pending_operation = None;
577            record.status = ManagedExecutionState::Running.as_status().to_string();
578            Ok(record.clone())
579        })
580    }
581}
582
583fn validate_new_record(record: &BoxRecord) -> ManagedExecutionStoreResult<ExecutionId> {
584    let execution_id = ExecutionId::new(record.id.clone())
585        .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
586    let metadata = record
587        .managed_execution
588        .as_ref()
589        .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
590    metadata
591        .validate()
592        .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
593    let state = record
594        .managed_state()
595        .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?
596        .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?;
597    if state != ManagedExecutionState::Created {
598        return Err(ManagedExecutionStoreError::InvalidRecord(format!(
599            "new execution {execution_id} must be created, found {state}"
600        )));
601    }
602    if metadata.generation != ExecutionGeneration::INITIAL {
603        return Err(ManagedExecutionStoreError::InvalidRecord(format!(
604            "new execution {execution_id} must start at generation {}",
605            ExecutionGeneration::INITIAL.get()
606        )));
607    }
608    Ok(execution_id)
609}
610
611fn same_creation_intent(
612    left: &crate::ManagedExecutionMetadata,
613    right: &crate::ManagedExecutionMetadata,
614) -> ManagedExecutionStoreResult<bool> {
615    let left_request = serde_json::to_value(&left.request)
616        .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
617    let right_request = serde_json::to_value(&right.request)
618        .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
619    Ok(left_request == right_request && left.plan == right.plan)
620}
621
622fn same_reservation_intent(
623    left: &crate::ManagedExecutionMetadata,
624    right: &crate::ManagedExecutionMetadata,
625) -> ManagedExecutionStoreResult<bool> {
626    match (
627        left.creation_intent_digest.as_deref(),
628        right.creation_intent_digest.as_deref(),
629    ) {
630        (Some(left_digest), Some(right_digest)) => {
631            Ok(left_digest == right_digest && same_immutable_reservation_shape(left, right)?)
632        }
633        // Records written before immutable creation identities use the current
634        // request as their backwards-compatible reservation evidence.
635        _ => same_creation_intent(left, right),
636    }
637}
638
639fn same_immutable_reservation_shape(
640    left: &crate::ManagedExecutionMetadata,
641    right: &crate::ManagedExecutionMetadata,
642) -> ManagedExecutionStoreResult<bool> {
643    let mut left_request = left.request.clone();
644    let mut right_request = right.request.clone();
645    clear_mutable_resource_limits(&mut left_request.config.resource_limits);
646    clear_mutable_resource_limits(&mut right_request.config.resource_limits);
647    let left_request = serde_json::to_value(left_request)
648        .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
649    let right_request = serde_json::to_value(right_request)
650        .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?;
651    Ok(left_request == right_request && left.plan == right.plan)
652}
653
654fn clear_mutable_resource_limits(limits: &mut a3s_box_core::ResourceLimits) {
655    limits.memory_reservation = None;
656    limits.memory_swap = None;
657    limits.pids_limit = None;
658    limits.cpu_shares = None;
659    limits.cpu_quota = None;
660    limits.cpu_period = None;
661    limits.cpuset_cpus = None;
662}
663
664fn transition_generation(
665    execution_id: &ExecutionId,
666    from: ManagedExecutionState,
667    to: ManagedExecutionState,
668    current: ExecutionGeneration,
669) -> ManagedExecutionStoreResult<ExecutionGeneration> {
670    use ManagedExecutionState::{
671        Created, Creating, Failed, Killing, Paused, Pausing, RestartStarting, RestartStopping,
672        Resuming, Running, Snapshotting, Starting, Stopped, UpdatingResources,
673    };
674
675    let legal = matches!(
676        (from, to),
677        (Creating, Created | Starting | Killing | Stopped | Failed)
678            | (
679                Created,
680                Starting | Killing | RestartStopping | Stopped | Failed
681            )
682            | (
683                Starting,
684                Created | Creating | Running | Killing | Stopped | Failed
685            )
686            | (
687                Running,
688                Pausing
689                    | UpdatingResources
690                    | Snapshotting
691                    | Killing
692                    | RestartStopping
693                    | Stopped
694                    | Failed
695            )
696            | (Pausing, Paused | Running | Killing | Stopped | Failed)
697            | (
698                Paused,
699                Resuming | Snapshotting | Killing | RestartStopping | Stopped | Failed
700            )
701            | (Resuming, Running | Paused | Killing | Stopped | Failed)
702            | (UpdatingResources, Running | Killing | Stopped | Failed)
703            | (Snapshotting, Running | Paused | Stopped | Failed)
704            | (Killing, Stopped | Failed)
705            | (Stopped | Failed, RestartStopping)
706            | (RestartStopping, RestartStarting)
707            | (RestartStarting, Running | Stopped | Failed)
708    );
709    if !legal {
710        return Err(ManagedExecutionStoreError::InvalidTransition {
711            execution_id: execution_id.clone(),
712            from,
713            to,
714        });
715    }
716
717    if matches!(
718        (from, to),
719        (Pausing, Paused) | (Resuming, Running) | (RestartStopping, RestartStarting)
720    ) {
721        let value = current.get().checked_add(1).ok_or_else(|| {
722            ManagedExecutionStoreError::InvalidRecord(format!(
723                "execution {execution_id} generation is exhausted"
724            ))
725        })?;
726        return ExecutionGeneration::new(value)
727            .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()));
728    }
729    Ok(current)
730}
731
732#[cfg(test)]
733mod tests {
734    #[cfg(unix)]
735    use std::sync::{Arc, Barrier};
736
737    use a3s_box_core::{CreateExecutionRequest, ExecutionIsolation, ExecutionSnapshotId};
738
739    use super::*;
740    use crate::ManagedExecutionMetadata;
741
742    fn managed_record(id: &str, operation: &str) -> BoxRecord {
743        let mut record: BoxRecord = serde_json::from_value(serde_json::json!({
744            "id": id,
745            "short_id": BoxRecord::make_short_id(id),
746            "name": format!("box-{id}"),
747            "image": "alpine:latest",
748            "isolation": "sandbox",
749            "status": "created",
750            "pid": null,
751            "cpus": 1,
752            "memory_mb": 128,
753            "volumes": [],
754            "env": {},
755            "cmd": ["sh"],
756            "box_dir": format!("/tmp/{id}"),
757            "console_log": format!("/tmp/{id}/console.log"),
758            "created_at": "2026-07-14T12:00:00Z",
759            "started_at": null,
760            "auto_remove": false
761        }))
762        .unwrap();
763        let config = a3s_box_core::BoxConfig {
764            image: "alpine:latest".to_string(),
765            isolation: ExecutionIsolation::Sandbox,
766            ..Default::default()
767        };
768        record.managed_execution = Some(
769            ManagedExecutionMetadata::new(
770                OperationId::new(operation).unwrap(),
771                ExecutionGeneration::INITIAL,
772                CreateExecutionRequest {
773                    external_sandbox_id: "sandbox-1".to_string(),
774                    config,
775                    labels: Default::default(),
776                    policy: Default::default(),
777                    rootfs_snapshot_id: None,
778                },
779            )
780            .unwrap(),
781        );
782        record
783    }
784
785    #[test]
786    fn reservation_is_idempotent_for_the_same_full_request() {
787        let directory = tempfile::tempdir().unwrap();
788        let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
789
790        let first = store.reserve(managed_record("execution-1", "operation-1"));
791        let retry = store.reserve(managed_record("execution-2", "operation-1"));
792
793        assert!(first.unwrap().is_new());
794        let retry = retry.unwrap();
795        assert!(!retry.is_new());
796        assert_eq!(retry.record().id, "execution-1");
797        assert_eq!(
798            BoxStateStore::load(store.path()).unwrap().records().len(),
799            1
800        );
801    }
802
803    #[test]
804    fn reservation_rejects_operation_reuse_with_different_intent() {
805        let directory = tempfile::tempdir().unwrap();
806        let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
807        store
808            .reserve(managed_record("execution-1", "operation-1"))
809            .unwrap();
810        let mut conflicting = managed_record("execution-2", "operation-1");
811        conflicting
812            .managed_execution
813            .as_mut()
814            .unwrap()
815            .request
816            .external_sandbox_id = "sandbox-2".to_string();
817
818        let error = store.reserve(conflicting).unwrap_err();
819
820        assert!(matches!(error, ManagedExecutionStoreError::Conflict { .. }));
821        assert_eq!(
822            BoxStateStore::load(store.path()).unwrap().records().len(),
823            1
824        );
825    }
826
827    #[test]
828    fn removal_claim_is_generation_fenced_durable_and_idempotent() {
829        let directory = tempfile::tempdir().unwrap();
830        let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
831        let id = ExecutionId::new("execution-1").unwrap();
832        store
833            .reserve(managed_record(id.as_str(), "operation-1"))
834            .unwrap();
835
836        let claimed = store
837            .begin_remove(&id, ExecutionGeneration::INITIAL)
838            .unwrap()
839            .unwrap();
840        assert_eq!(
841            claimed.managed_state().unwrap(),
842            Some(ManagedExecutionState::Removing)
843        );
844        assert!(matches!(
845            claimed
846                .managed_execution
847                .as_ref()
848                .unwrap()
849                .pending_operation,
850            Some(ManagedExecutionOperation::Remove)
851        ));
852
853        let reopened = ManagedExecutionStore::new(store.path().to_path_buf());
854        assert_eq!(
855            reopened
856                .begin_remove(&id, ExecutionGeneration::INITIAL)
857                .unwrap()
858                .unwrap()
859                .managed_state()
860                .unwrap(),
861            Some(ManagedExecutionState::Removing)
862        );
863        assert!(matches!(
864            reopened.begin_remove(&id, ExecutionGeneration::new(2).unwrap()),
865            Err(ManagedExecutionStoreError::Conflict { .. })
866        ));
867
868        assert!(reopened
869            .finish_remove(&id, ExecutionGeneration::INITIAL)
870            .unwrap());
871        assert!(!reopened
872            .finish_remove(&id, ExecutionGeneration::INITIAL)
873            .unwrap());
874        assert!(reopened.get(&id).unwrap().is_none());
875    }
876
877    #[test]
878    fn pause_and_resume_completion_advance_generation_once() {
879        let directory = tempfile::tempdir().unwrap();
880        let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
881        let id = ExecutionId::new("execution-1").unwrap();
882        store
883            .reserve(managed_record(id.as_str(), "operation-1"))
884            .unwrap();
885
886        store
887            .transition(
888                &id,
889                ExecutionGeneration::INITIAL,
890                ManagedExecutionState::Created,
891                ManagedExecutionState::Starting,
892            )
893            .unwrap();
894        let running = store
895            .transition(
896                &id,
897                ExecutionGeneration::INITIAL,
898                ManagedExecutionState::Starting,
899                ManagedExecutionState::Running,
900            )
901            .unwrap();
902        assert_eq!(
903            running.managed_execution.unwrap().generation,
904            ExecutionGeneration::INITIAL
905        );
906        store
907            .transition(
908                &id,
909                ExecutionGeneration::INITIAL,
910                ManagedExecutionState::Running,
911                ManagedExecutionState::Pausing,
912            )
913            .unwrap();
914        let paused = store
915            .transition(
916                &id,
917                ExecutionGeneration::INITIAL,
918                ManagedExecutionState::Pausing,
919                ManagedExecutionState::Paused,
920            )
921            .unwrap();
922        let generation_two = ExecutionGeneration::new(2).unwrap();
923        assert_eq!(paused.managed_execution.unwrap().generation, generation_two);
924        store
925            .transition(
926                &id,
927                generation_two,
928                ManagedExecutionState::Paused,
929                ManagedExecutionState::Resuming,
930            )
931            .unwrap();
932        let resumed = store
933            .transition(
934                &id,
935                generation_two,
936                ManagedExecutionState::Resuming,
937                ManagedExecutionState::Running,
938            )
939            .unwrap();
940        assert_eq!(
941            resumed.managed_execution.unwrap().generation,
942            ExecutionGeneration::new(3).unwrap()
943        );
944    }
945
946    #[test]
947    fn snapshot_intent_is_durable_and_preserves_runtime_generation() {
948        let directory = tempfile::tempdir().unwrap();
949        let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
950        let id = ExecutionId::new("execution-1").unwrap();
951        store
952            .reserve(managed_record(id.as_str(), "operation-1"))
953            .unwrap();
954        store
955            .transition(
956                &id,
957                ExecutionGeneration::INITIAL,
958                ManagedExecutionState::Created,
959                ManagedExecutionState::Starting,
960            )
961            .unwrap();
962        store
963            .transition(
964                &id,
965                ExecutionGeneration::INITIAL,
966                ManagedExecutionState::Starting,
967                ManagedExecutionState::Running,
968            )
969            .unwrap();
970        let snapshot_id = ExecutionSnapshotId::new("snapshot-1").unwrap();
971        let claimed = store
972            .transition_with(
973                &id,
974                ExecutionGeneration::INITIAL,
975                ManagedExecutionState::Running,
976                ManagedExecutionState::Snapshotting,
977                |record| {
978                    record.managed_execution.as_mut().unwrap().pending_operation =
979                        Some(ManagedExecutionOperation::Snapshot {
980                            snapshot_id: snapshot_id.clone(),
981                            source_state: ManagedExecutionState::Running,
982                            operation_id: Some(OperationId::new("snapshot-operation-1").unwrap()),
983                            freezer_applied: false,
984                        });
985                },
986            )
987            .unwrap();
988        assert_eq!(
989            claimed.managed_execution.as_ref().unwrap().generation,
990            ExecutionGeneration::INITIAL
991        );
992        assert!(matches!(
993            claimed
994                .managed_execution
995                .as_ref()
996                .unwrap()
997                .pending_operation
998                .as_ref(),
999            Some(ManagedExecutionOperation::Snapshot {
1000                snapshot_id,
1001                source_state: ManagedExecutionState::Running,
1002                operation_id: Some(operation_id),
1003                freezer_applied: false,
1004            }) if snapshot_id.as_str() == "snapshot-1"
1005                && operation_id.as_str() == "snapshot-operation-1"
1006        ));
1007
1008        let snapshot_operation_id = OperationId::new("snapshot-operation-1").unwrap();
1009        let marked = store
1010            .mark_snapshot_freezer_applied(
1011                &id,
1012                ExecutionGeneration::INITIAL,
1013                &snapshot_id,
1014                Some(&snapshot_operation_id),
1015            )
1016            .unwrap();
1017        assert!(matches!(
1018            marked
1019                .managed_execution
1020                .as_ref()
1021                .unwrap()
1022                .pending_operation
1023                .as_ref(),
1024            Some(ManagedExecutionOperation::Snapshot {
1025                freezer_applied: true,
1026                ..
1027            })
1028        ));
1029        let replayed = store
1030            .mark_snapshot_freezer_applied(
1031                &id,
1032                ExecutionGeneration::INITIAL,
1033                &snapshot_id,
1034                Some(&snapshot_operation_id),
1035            )
1036            .unwrap();
1037        assert_eq!(
1038            replayed.managed_execution.as_ref().unwrap().generation,
1039            marked.managed_execution.as_ref().unwrap().generation
1040        );
1041        assert!(matches!(
1042            replayed
1043                .managed_execution
1044                .as_ref()
1045                .unwrap()
1046                .pending_operation
1047                .as_ref(),
1048            Some(ManagedExecutionOperation::Snapshot {
1049                freezer_applied: true,
1050                ..
1051            })
1052        ));
1053
1054        let reopened = ManagedExecutionStore::new(store.path().to_path_buf());
1055        let persisted = reopened.get(&id).unwrap().unwrap();
1056        assert_eq!(
1057            persisted.managed_state().unwrap(),
1058            Some(ManagedExecutionState::Snapshotting)
1059        );
1060        assert!(matches!(
1061            persisted
1062                .managed_execution
1063                .as_ref()
1064                .unwrap()
1065                .pending_operation
1066                .as_ref(),
1067            Some(ManagedExecutionOperation::Snapshot {
1068                freezer_applied: true,
1069                ..
1070            })
1071        ));
1072        let completed = reopened
1073            .transition(
1074                &id,
1075                ExecutionGeneration::INITIAL,
1076                ManagedExecutionState::Snapshotting,
1077                ManagedExecutionState::Running,
1078            )
1079            .unwrap();
1080        let metadata = completed.managed_execution.unwrap();
1081        assert_eq!(metadata.generation, ExecutionGeneration::INITIAL);
1082        assert!(metadata.pending_operation.is_none());
1083        assert!(matches!(
1084            reopened.transition(
1085                &id,
1086                ExecutionGeneration::INITIAL,
1087                ManagedExecutionState::Running,
1088                ManagedExecutionState::Snapshotting,
1089            ),
1090            Err(ManagedExecutionStoreError::InvalidRecord(_))
1091        ));
1092    }
1093
1094    #[test]
1095    fn restart_advances_generation_between_durable_teardown_and_startup() {
1096        let directory = tempfile::tempdir().unwrap();
1097        let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
1098        let id = ExecutionId::new("execution-1").unwrap();
1099        store
1100            .reserve(managed_record(id.as_str(), "operation-create"))
1101            .unwrap();
1102        store
1103            .transition(
1104                &id,
1105                ExecutionGeneration::INITIAL,
1106                ManagedExecutionState::Created,
1107                ManagedExecutionState::Starting,
1108            )
1109            .unwrap();
1110        store
1111            .transition(
1112                &id,
1113                ExecutionGeneration::INITIAL,
1114                ManagedExecutionState::Starting,
1115                ManagedExecutionState::Running,
1116            )
1117            .unwrap();
1118        let restart_operation = OperationId::new("operation-restart").unwrap();
1119        let stopping = store
1120            .transition_with(
1121                &id,
1122                ExecutionGeneration::INITIAL,
1123                ManagedExecutionState::Running,
1124                ManagedExecutionState::RestartStopping,
1125                |record| {
1126                    record.managed_execution.as_mut().unwrap().pending_operation =
1127                        Some(ManagedExecutionOperation::Restart {
1128                            operation_id: restart_operation.clone(),
1129                            source_generation: ExecutionGeneration::INITIAL,
1130                            source_state: ManagedExecutionState::Running,
1131                            stop_timeout_secs: Some(10),
1132                        });
1133                },
1134            )
1135            .unwrap();
1136        assert_eq!(
1137            stopping.managed_execution.as_ref().unwrap().generation,
1138            ExecutionGeneration::INITIAL
1139        );
1140
1141        let starting = store
1142            .transition(
1143                &id,
1144                ExecutionGeneration::INITIAL,
1145                ManagedExecutionState::RestartStopping,
1146                ManagedExecutionState::RestartStarting,
1147            )
1148            .unwrap();
1149        let generation_two = ExecutionGeneration::new(2).unwrap();
1150        assert_eq!(
1151            starting.managed_execution.as_ref().unwrap().generation,
1152            generation_two
1153        );
1154        let running = store
1155            .transition(
1156                &id,
1157                generation_two,
1158                ManagedExecutionState::RestartStarting,
1159                ManagedExecutionState::Running,
1160            )
1161            .unwrap();
1162        let metadata = running.managed_execution.unwrap();
1163        assert_eq!(metadata.generation, generation_two);
1164        assert!(metadata.pending_operation.is_none());
1165        let completed = metadata.last_restart.unwrap();
1166        assert_eq!(completed.operation_id, restart_operation);
1167        assert_eq!(completed.source_generation, ExecutionGeneration::INITIAL);
1168        assert_eq!(completed.target_generation, generation_two);
1169        assert_eq!(completed.outcome, ManagedRestartOutcome::Running);
1170        assert_eq!(completed.stop_timeout_secs, Some(10));
1171    }
1172
1173    #[test]
1174    fn stale_generation_and_invalid_edges_do_not_change_disk() {
1175        let directory = tempfile::tempdir().unwrap();
1176        let store = ManagedExecutionStore::new(directory.path().join("boxes.json"));
1177        let id = ExecutionId::new("execution-1").unwrap();
1178        store
1179            .reserve(managed_record(id.as_str(), "operation-1"))
1180            .unwrap();
1181        store
1182            .transition(
1183                &id,
1184                ExecutionGeneration::INITIAL,
1185                ManagedExecutionState::Created,
1186                ManagedExecutionState::Starting,
1187            )
1188            .unwrap();
1189        store
1190            .transition(
1191                &id,
1192                ExecutionGeneration::INITIAL,
1193                ManagedExecutionState::Starting,
1194                ManagedExecutionState::Running,
1195            )
1196            .unwrap();
1197
1198        let stale = store.transition(
1199            &id,
1200            ExecutionGeneration::new(2).unwrap(),
1201            ManagedExecutionState::Running,
1202            ManagedExecutionState::Pausing,
1203        );
1204        let invalid = store.transition(
1205            &id,
1206            ExecutionGeneration::INITIAL,
1207            ManagedExecutionState::Running,
1208            ManagedExecutionState::Paused,
1209        );
1210
1211        assert!(matches!(
1212            stale,
1213            Err(ManagedExecutionStoreError::Conflict { .. })
1214        ));
1215        assert!(matches!(
1216            invalid,
1217            Err(ManagedExecutionStoreError::InvalidTransition { .. })
1218        ));
1219        let persisted = store.get(&id).unwrap().unwrap();
1220        assert_eq!(
1221            persisted.managed_state().unwrap(),
1222            Some(ManagedExecutionState::Running)
1223        );
1224        assert_eq!(
1225            persisted.managed_execution.unwrap().generation,
1226            ExecutionGeneration::INITIAL
1227        );
1228    }
1229
1230    #[cfg(unix)]
1231    #[test]
1232    fn concurrent_claims_have_one_winner() {
1233        let directory = tempfile::tempdir().unwrap();
1234        let store = Arc::new(ManagedExecutionStore::new(
1235            directory.path().join("boxes.json"),
1236        ));
1237        let id = ExecutionId::new("execution-1").unwrap();
1238        store
1239            .reserve(managed_record(id.as_str(), "operation-1"))
1240            .unwrap();
1241        store
1242            .transition(
1243                &id,
1244                ExecutionGeneration::INITIAL,
1245                ManagedExecutionState::Created,
1246                ManagedExecutionState::Starting,
1247            )
1248            .unwrap();
1249        store
1250            .transition(
1251                &id,
1252                ExecutionGeneration::INITIAL,
1253                ManagedExecutionState::Starting,
1254                ManagedExecutionState::Running,
1255            )
1256            .unwrap();
1257        let barrier = Arc::new(Barrier::new(3));
1258        let handles: Vec<_> = (0..2)
1259            .map(|_| {
1260                let store = Arc::clone(&store);
1261                let id = id.clone();
1262                let barrier = Arc::clone(&barrier);
1263                std::thread::spawn(move || {
1264                    barrier.wait();
1265                    store.transition(
1266                        &id,
1267                        ExecutionGeneration::INITIAL,
1268                        ManagedExecutionState::Running,
1269                        ManagedExecutionState::Pausing,
1270                    )
1271                })
1272            })
1273            .collect();
1274        barrier.wait();
1275        let results: Vec<_> = handles
1276            .into_iter()
1277            .map(|handle| handle.join().unwrap())
1278            .collect();
1279
1280        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1281        assert_eq!(
1282            results
1283                .iter()
1284                .filter(|result| matches!(result, Err(ManagedExecutionStoreError::Conflict { .. })))
1285                .count(),
1286            1
1287        );
1288        assert_eq!(
1289            store.get(&id).unwrap().unwrap().managed_state().unwrap(),
1290            Some(ManagedExecutionState::Pausing)
1291        );
1292    }
1293}