Skip to main content

meerkat_runtime/handles/
external_tool_surface.rs

1//! Runtime impl of [`meerkat_core::handles::ExternalToolSurfaceHandle`].
2
3use std::collections::BTreeSet;
4use std::sync::Arc;
5
6use meerkat_core::handles::{
7    DslTransitionError, ExternalToolSurfaceEffect, ExternalToolSurfaceHandle,
8    ExternalToolSurfaceInput, ExternalToolSurfaceTransition, SurfaceDiagnosticSnapshot,
9    SurfaceSnapshot,
10};
11use meerkat_core::tool_scope::{
12    ExternalToolSurfaceBaseState, ExternalToolSurfaceDeltaOperation, ExternalToolSurfaceDeltaPhase,
13    ExternalToolSurfaceFailureCause, ExternalToolSurfaceGlobalPhase, ExternalToolSurfacePendingOp,
14    ExternalToolSurfaceStagedOp,
15};
16
17use super::HandleDslAuthority;
18use crate::meerkat_machine::dsl as mm_dsl;
19
20/// Runtime-backed [`ExternalToolSurfaceHandle`] impl.
21#[derive(Debug)]
22pub struct RuntimeExternalToolSurfaceHandle {
23    dsl: Arc<HandleDslAuthority>,
24}
25
26impl RuntimeExternalToolSurfaceHandle {
27    /// Construct a handle backed by the session's shared DSL authority.
28    pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
29        Self { dsl }
30    }
31
32    /// Construct a handle backed by an ephemeral DSL authority.
33    pub fn ephemeral() -> Self {
34        let state = mm_dsl::MeerkatMachineState {
35            lifecycle_phase: mm_dsl::MeerkatPhase::Attached,
36            ..Default::default()
37        };
38        let shared = Arc::new(std::sync::Mutex::new(
39            mm_dsl::MeerkatMachineAuthority::from_state(state),
40        ));
41        Self::new(Arc::new(HandleDslAuthority::from_shared(shared)))
42    }
43}
44
45impl RuntimeExternalToolSurfaceHandle {
46    fn snapshot_entry(
47        state: &mm_dsl::MeerkatMachineState,
48        surface_id: &str,
49    ) -> Option<SurfaceSnapshot> {
50        let key = surface_id.to_string();
51        if !state.known_surfaces.contains(&key)
52            && !state.surface_base_state.contains_key(&key)
53            && !state.surface_pending_op.contains_key(&key)
54            && !state.surface_staged_op.contains_key(&key)
55        {
56            return None;
57        }
58        Some(SurfaceSnapshot {
59            surface_id: key.clone(),
60            base_state: state
61                .surface_base_state
62                .get(&key)
63                .copied()
64                .map(ExternalToolSurfaceBaseState::from),
65            pending_op: state
66                .surface_pending_op
67                .get(&key)
68                .copied()
69                .map(map_pending_op)
70                .unwrap_or(ExternalToolSurfacePendingOp::None),
71            staged_op: state
72                .surface_staged_op
73                .get(&key)
74                .copied()
75                .map(map_staged_op)
76                .unwrap_or(ExternalToolSurfaceStagedOp::None),
77            staged_intent_sequence: state.surface_staged_intent_sequence.get(&key).copied(),
78            pending_task_sequence: state.surface_pending_task_sequence.get(&key).copied(),
79            pending_lineage_sequence: state.surface_pending_lineage_sequence.get(&key).copied(),
80            inflight_calls: state.surface_inflight_calls.get(&key).copied().unwrap_or(0),
81            last_delta_operation: state
82                .surface_last_delta_operation
83                .get(&key)
84                .copied()
85                .map(ExternalToolSurfaceDeltaOperation::from),
86            last_delta_phase: state
87                .surface_last_delta_phase
88                .get(&key)
89                .copied()
90                .map(ExternalToolSurfaceDeltaPhase::from),
91            removal_draining_since_ms: state.surface_draining_since_ms.get(&key).copied(),
92            removal_timeout_at_ms: state.surface_removal_timeout_at_ms.get(&key).copied(),
93            removal_applied_at_turn: state.surface_removal_applied_at_turn.get(&key).copied(),
94        })
95    }
96
97    fn apply_input_with_effects(
98        &self,
99        input: mm_dsl::MeerkatMachineInput,
100        context: &'static str,
101    ) -> Result<ExternalToolSurfaceTransition, DslTransitionError> {
102        let effects = self.dsl.apply_input_with_effects(input, context)?;
103        let state = self.dsl.snapshot_state();
104        Ok(ExternalToolSurfaceTransition {
105            phase: map_surface_phase(state.surface_phase),
106            effects: effects
107                .into_iter()
108                .filter_map(|effect| map_surface_effect(effect, state.snapshot_epoch))
109                .collect(),
110        })
111    }
112}
113
114impl ExternalToolSurfaceHandle for RuntimeExternalToolSurfaceHandle {
115    fn apply_surface_input(
116        &self,
117        input: ExternalToolSurfaceInput,
118    ) -> Result<ExternalToolSurfaceTransition, DslTransitionError> {
119        match input {
120            ExternalToolSurfaceInput::StageAdd { surface_id, now_ms } => self
121                .apply_input_with_effects(
122                    mm_dsl::MeerkatMachineInput::SurfaceStageAdd { surface_id, now_ms },
123                    "ExternalToolSurfaceHandle::stage_add",
124                ),
125            ExternalToolSurfaceInput::StageRemove { surface_id, now_ms } => self
126                .apply_input_with_effects(
127                    mm_dsl::MeerkatMachineInput::SurfaceStageRemove { surface_id, now_ms },
128                    "ExternalToolSurfaceHandle::stage_remove",
129                ),
130            ExternalToolSurfaceInput::StageReload { surface_id, now_ms } => self
131                .apply_input_with_effects(
132                    mm_dsl::MeerkatMachineInput::SurfaceStageReload { surface_id, now_ms },
133                    "ExternalToolSurfaceHandle::stage_reload",
134                ),
135            ExternalToolSurfaceInput::ApplyBoundary {
136                surface_id,
137                now_ms,
138                staged_intent_sequence,
139                applied_at_turn,
140            } => self.apply_input_with_effects(
141                mm_dsl::MeerkatMachineInput::SurfaceApplyBoundary {
142                    surface_id,
143                    now_ms,
144                    staged_intent_sequence,
145                    applied_at_turn,
146                },
147                "ExternalToolSurfaceHandle::apply_boundary",
148            ),
149            ExternalToolSurfaceInput::MarkPendingSucceeded {
150                surface_id,
151                pending_task_sequence,
152                staged_intent_sequence,
153            } => self.apply_input_with_effects(
154                mm_dsl::MeerkatMachineInput::SurfaceMarkPendingSucceeded {
155                    surface_id,
156                    pending_task_sequence,
157                    staged_intent_sequence,
158                },
159                "ExternalToolSurfaceHandle::mark_pending_succeeded",
160            ),
161            ExternalToolSurfaceInput::MarkPendingFailed {
162                surface_id,
163                pending_task_sequence,
164                staged_intent_sequence,
165                cause,
166            } => self.apply_input_with_effects(
167                mm_dsl::MeerkatMachineInput::SurfaceMarkPendingFailed {
168                    surface_id,
169                    pending_task_sequence,
170                    staged_intent_sequence,
171                    cause: mm_dsl::ExternalToolSurfaceFailureCause::from(cause),
172                },
173                "ExternalToolSurfaceHandle::mark_pending_failed",
174            ),
175            ExternalToolSurfaceInput::CallStarted { surface_id } => self.apply_input_with_effects(
176                mm_dsl::MeerkatMachineInput::SurfaceCallStarted { surface_id },
177                "ExternalToolSurfaceHandle::call_started",
178            ),
179            ExternalToolSurfaceInput::CallFinished { surface_id } => self.apply_input_with_effects(
180                mm_dsl::MeerkatMachineInput::SurfaceCallFinished { surface_id },
181                "ExternalToolSurfaceHandle::call_finished",
182            ),
183            ExternalToolSurfaceInput::FinalizeRemovalClean { surface_id } => self
184                .apply_input_with_effects(
185                    mm_dsl::MeerkatMachineInput::SurfaceFinalizeRemovalClean { surface_id },
186                    "ExternalToolSurfaceHandle::finalize_removal_clean",
187                ),
188            ExternalToolSurfaceInput::FinalizeRemovalForced { surface_id } => self
189                .apply_input_with_effects(
190                    mm_dsl::MeerkatMachineInput::SurfaceFinalizeRemovalForced { surface_id },
191                    "ExternalToolSurfaceHandle::finalize_removal_forced",
192                ),
193            ExternalToolSurfaceInput::SnapshotAligned { epoch } => self.apply_input_with_effects(
194                mm_dsl::MeerkatMachineInput::SurfaceSnapshotAligned { epoch },
195                "ExternalToolSurfaceHandle::snapshot_aligned",
196            ),
197            ExternalToolSurfaceInput::Shutdown => self.apply_input_with_effects(
198                mm_dsl::MeerkatMachineInput::SurfaceShutdown,
199                "ExternalToolSurfaceHandle::shutdown_surface",
200            ),
201        }
202    }
203
204    fn register(&self, surface_id: String) -> Result<(), DslTransitionError> {
205        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
206        self.dsl.apply_input(
207            mm_dsl::MeerkatMachineInput::SurfaceRegister { surface_id },
208            "ExternalToolSurfaceHandle::register",
209        )
210    }
211
212    fn stage_add(&self, surface_id: String, now_ms: u64) -> Result<(), DslTransitionError> {
213        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
214        self.dsl.apply_input(
215            mm_dsl::MeerkatMachineInput::SurfaceStageAdd { surface_id, now_ms },
216            "ExternalToolSurfaceHandle::stage_add",
217        )
218    }
219
220    fn stage_remove(&self, surface_id: String, now_ms: u64) -> Result<(), DslTransitionError> {
221        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
222        self.dsl.apply_input(
223            mm_dsl::MeerkatMachineInput::SurfaceStageRemove { surface_id, now_ms },
224            "ExternalToolSurfaceHandle::stage_remove",
225        )
226    }
227
228    fn stage_reload(&self, surface_id: String, now_ms: u64) -> Result<(), DslTransitionError> {
229        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
230        self.dsl.apply_input(
231            mm_dsl::MeerkatMachineInput::SurfaceStageReload { surface_id, now_ms },
232            "ExternalToolSurfaceHandle::stage_reload",
233        )
234    }
235
236    fn apply_boundary(
237        &self,
238        surface_id: String,
239        now_ms: u64,
240        staged_intent_sequence: u64,
241        applied_at_turn: u64,
242    ) -> Result<(), DslTransitionError> {
243        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
244        self.dsl.apply_input(
245            mm_dsl::MeerkatMachineInput::SurfaceApplyBoundary {
246                surface_id,
247                now_ms,
248                staged_intent_sequence,
249                applied_at_turn,
250            },
251            "ExternalToolSurfaceHandle::apply_boundary",
252        )
253    }
254
255    fn mark_pending_succeeded(
256        &self,
257        surface_id: String,
258        pending_task_sequence: u64,
259        staged_intent_sequence: u64,
260    ) -> Result<(), DslTransitionError> {
261        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
262        self.dsl.apply_input(
263            mm_dsl::MeerkatMachineInput::SurfaceMarkPendingSucceeded {
264                surface_id,
265                pending_task_sequence,
266                staged_intent_sequence,
267            },
268            "ExternalToolSurfaceHandle::mark_pending_succeeded",
269        )
270    }
271
272    fn mark_pending_failed(
273        &self,
274        surface_id: String,
275        pending_task_sequence: u64,
276        staged_intent_sequence: u64,
277        cause: ExternalToolSurfaceFailureCause,
278    ) -> Result<(), DslTransitionError> {
279        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
280        self.dsl.apply_input(
281            mm_dsl::MeerkatMachineInput::SurfaceMarkPendingFailed {
282                surface_id,
283                pending_task_sequence,
284                staged_intent_sequence,
285                cause: mm_dsl::ExternalToolSurfaceFailureCause::from(cause),
286            },
287            "ExternalToolSurfaceHandle::mark_pending_failed",
288        )
289    }
290
291    fn call_started(&self, surface_id: String) -> Result<(), DslTransitionError> {
292        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
293        self.dsl.apply_input(
294            mm_dsl::MeerkatMachineInput::SurfaceCallStarted { surface_id },
295            "ExternalToolSurfaceHandle::call_started",
296        )
297    }
298
299    fn call_finished(&self, surface_id: String) -> Result<(), DslTransitionError> {
300        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
301        self.dsl.apply_input(
302            mm_dsl::MeerkatMachineInput::SurfaceCallFinished { surface_id },
303            "ExternalToolSurfaceHandle::call_finished",
304        )
305    }
306
307    fn finalize_removal_clean(&self, surface_id: String) -> Result<(), DslTransitionError> {
308        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
309        self.dsl.apply_input(
310            mm_dsl::MeerkatMachineInput::SurfaceFinalizeRemovalClean { surface_id },
311            "ExternalToolSurfaceHandle::finalize_removal_clean",
312        )
313    }
314
315    fn finalize_removal_forced(&self, surface_id: String) -> Result<(), DslTransitionError> {
316        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
317        self.dsl.apply_input(
318            mm_dsl::MeerkatMachineInput::SurfaceFinalizeRemovalForced { surface_id },
319            "ExternalToolSurfaceHandle::finalize_removal_forced",
320        )
321    }
322
323    fn snapshot_aligned(&self, epoch: u64) -> Result<(), DslTransitionError> {
324        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
325        self.dsl.apply_input(
326            mm_dsl::MeerkatMachineInput::SurfaceSnapshotAligned { epoch },
327            "ExternalToolSurfaceHandle::snapshot_aligned",
328        )
329    }
330
331    fn shutdown_surface(&self) -> Result<(), DslTransitionError> {
332        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
333        self.dsl.apply_input(
334            mm_dsl::MeerkatMachineInput::SurfaceShutdown,
335            "ExternalToolSurfaceHandle::shutdown_surface",
336        )
337    }
338
339    fn surface_snapshot(&self, surface_id: &str) -> Option<SurfaceSnapshot> {
340        let state = self.dsl.snapshot_state();
341        Self::snapshot_entry(&state, surface_id)
342    }
343
344    fn diagnostic_snapshot(&self) -> SurfaceDiagnosticSnapshot {
345        let state = self.dsl.snapshot_state();
346        let mut entries: Vec<SurfaceSnapshot> = state
347            .known_surfaces
348            .iter()
349            .filter_map(|surface_id| Self::snapshot_entry(&state, surface_id))
350            .collect();
351        entries.sort_by(|a, b| a.surface_id.cmp(&b.surface_id));
352        SurfaceDiagnosticSnapshot {
353            surface_phase: map_surface_phase(state.surface_phase),
354            known_surfaces: state.known_surfaces.clone(),
355            visible_surfaces: state.visible_surfaces.clone(),
356            snapshot_epoch: state.snapshot_epoch,
357            snapshot_aligned_epoch: state.snapshot_aligned_epoch,
358            has_pending_or_staged: entries.iter().any(|entry| {
359                entry.pending_op != ExternalToolSurfacePendingOp::None
360                    || entry.staged_op != ExternalToolSurfaceStagedOp::None
361            }),
362            entries,
363        }
364    }
365
366    fn visible_surfaces(&self) -> BTreeSet<String> {
367        self.dsl.snapshot_state().visible_surfaces
368    }
369
370    fn removing_surfaces(&self) -> BTreeSet<String> {
371        self.dsl
372            .snapshot_state()
373            .surface_base_state
374            .into_iter()
375            .filter_map(|(surface_id, base_state)| {
376                if base_state == mm_dsl::ExternalToolSurfaceBaseState::Removing {
377                    Some(surface_id)
378                } else {
379                    None
380                }
381            })
382            .collect()
383    }
384
385    fn pending_surfaces(&self) -> BTreeSet<String> {
386        self.dsl
387            .snapshot_state()
388            .surface_pending_op
389            .into_iter()
390            .filter_map(|(surface_id, pending_op)| {
391                if pending_op == mm_dsl::SurfacePendingOp::None {
392                    None
393                } else {
394                    Some(surface_id)
395                }
396            })
397            .collect()
398    }
399
400    fn has_pending_or_staged(&self) -> bool {
401        let state = self.dsl.snapshot_state();
402        state
403            .surface_pending_op
404            .values()
405            .any(|pending_op| *pending_op != mm_dsl::SurfacePendingOp::None)
406            || state
407                .surface_staged_op
408                .values()
409                .any(|staged_op| *staged_op != mm_dsl::SurfaceStagedOp::None)
410    }
411
412    fn snapshot_epoch(&self) -> u64 {
413        self.dsl.snapshot_state().snapshot_epoch
414    }
415
416    fn snapshot_aligned_epoch(&self) -> u64 {
417        self.dsl.snapshot_state().snapshot_aligned_epoch
418    }
419}
420
421/// Exhaustive 1-to-1 projection of the DSL's typed surface phase into the
422/// cross-crate contract. Compiler enforces completeness.
423fn map_surface_phase(phase: mm_dsl::SurfacePhase) -> ExternalToolSurfaceGlobalPhase {
424    match phase {
425        mm_dsl::SurfacePhase::Operating => ExternalToolSurfaceGlobalPhase::Operating,
426        mm_dsl::SurfacePhase::Shutdown => ExternalToolSurfaceGlobalPhase::Shutdown,
427    }
428}
429
430fn map_pending_op(op: mm_dsl::SurfacePendingOp) -> ExternalToolSurfacePendingOp {
431    match op {
432        mm_dsl::SurfacePendingOp::None => ExternalToolSurfacePendingOp::None,
433        mm_dsl::SurfacePendingOp::Add => ExternalToolSurfacePendingOp::Add,
434        mm_dsl::SurfacePendingOp::Reload => ExternalToolSurfacePendingOp::Reload,
435    }
436}
437
438fn map_staged_op(op: mm_dsl::SurfaceStagedOp) -> ExternalToolSurfaceStagedOp {
439    match op {
440        mm_dsl::SurfaceStagedOp::None => ExternalToolSurfaceStagedOp::None,
441        mm_dsl::SurfaceStagedOp::Add => ExternalToolSurfaceStagedOp::Add,
442        mm_dsl::SurfaceStagedOp::Remove => ExternalToolSurfaceStagedOp::Remove,
443        mm_dsl::SurfaceStagedOp::Reload => ExternalToolSurfaceStagedOp::Reload,
444    }
445}
446
447fn map_surface_effect(
448    effect: mm_dsl::MeerkatMachineEffect,
449    _snapshot_epoch: u64,
450) -> Option<ExternalToolSurfaceEffect> {
451    match effect {
452        mm_dsl::MeerkatMachineEffect::ScheduleSurfaceCompletion {
453            surface_id,
454            operation,
455            pending_task_sequence,
456            staged_intent_sequence,
457            applied_at_turn,
458        } => Some(ExternalToolSurfaceEffect::ScheduleSurfaceCompletion {
459            surface_id,
460            operation: ExternalToolSurfaceDeltaOperation::from(operation),
461            pending_task_sequence,
462            staged_intent_sequence,
463            applied_at_turn,
464        }),
465        mm_dsl::MeerkatMachineEffect::RefreshVisibleSurfaceSet { snapshot_epoch } => {
466            Some(ExternalToolSurfaceEffect::RefreshVisibleSurfaceSet { snapshot_epoch })
467        }
468        mm_dsl::MeerkatMachineEffect::EmitExternalToolDelta {
469            surface_id,
470            operation,
471            phase,
472            cause,
473        } => Some(ExternalToolSurfaceEffect::EmitExternalToolDelta {
474            surface_id,
475            operation: ExternalToolSurfaceDeltaOperation::from(operation),
476            phase: ExternalToolSurfaceDeltaPhase::from(phase),
477            cause: cause.map(ExternalToolSurfaceFailureCause::from),
478        }),
479        mm_dsl::MeerkatMachineEffect::CloseSurfaceConnection { surface_id } => {
480            Some(ExternalToolSurfaceEffect::CloseSurfaceConnection { surface_id })
481        }
482        mm_dsl::MeerkatMachineEffect::RejectSurfaceCall { surface_id, cause } => {
483            Some(ExternalToolSurfaceEffect::RejectSurfaceCall {
484                surface_id,
485                cause: ExternalToolSurfaceFailureCause::from(cause),
486            })
487        }
488        _ => None,
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use std::sync::{Arc, Mutex};
495
496    use super::*;
497    use meerkat_core::ExternalToolSurfaceFailureCause;
498
499    fn handle_in_phase(phase: mm_dsl::MeerkatPhase) -> RuntimeExternalToolSurfaceHandle {
500        let state = mm_dsl::MeerkatMachineState {
501            lifecycle_phase: phase,
502            ..Default::default()
503        };
504        let authority = mm_dsl::MeerkatMachineAuthority::from_state(state);
505        let shared = Arc::new(Mutex::new(authority));
506        RuntimeExternalToolSurfaceHandle::new(Arc::new(HandleDslAuthority::from_shared(shared)))
507    }
508
509    fn handle_with_active_surface(surface_id: &str) -> RuntimeExternalToolSurfaceHandle {
510        let mut state = mm_dsl::MeerkatMachineState {
511            lifecycle_phase: mm_dsl::MeerkatPhase::Attached,
512            ..Default::default()
513        };
514        state.known_surfaces.insert(surface_id.to_owned());
515        state.active_surfaces.insert(surface_id.to_owned());
516        state.surface_base_state.insert(
517            surface_id.to_owned(),
518            mm_dsl::ExternalToolSurfaceBaseState::Active,
519        );
520        let authority = mm_dsl::MeerkatMachineAuthority::from_state(state);
521        let shared = Arc::new(Mutex::new(authority));
522        RuntimeExternalToolSurfaceHandle::new(Arc::new(HandleDslAuthority::from_shared(shared)))
523    }
524
525    #[test]
526    fn staging_inputs_mint_sequences_and_project_snapshot() {
527        let handle = handle_in_phase(mm_dsl::MeerkatPhase::Attached);
528
529        handle.stage_add("alpha".to_owned(), 10).expect("stage add");
530        let add = handle.surface_snapshot("alpha").expect("add snapshot");
531        assert_eq!(add.staged_op, ExternalToolSurfaceStagedOp::Add);
532        assert_eq!(add.staged_intent_sequence, Some(1));
533        assert!(
534            handle
535                .diagnostic_snapshot()
536                .known_surfaces
537                .contains("alpha")
538        );
539
540        handle
541            .stage_remove("alpha".to_owned(), 20)
542            .expect("stage remove");
543        let remove = handle.surface_snapshot("alpha").expect("remove snapshot");
544        assert_eq!(remove.staged_op, ExternalToolSurfaceStagedOp::Remove);
545        assert_eq!(remove.staged_intent_sequence, Some(2));
546
547        handle.stage_add("beta".to_owned(), 30).expect("stage add");
548        let beta = handle.surface_snapshot("beta").expect("beta snapshot");
549        assert_eq!(beta.staged_op, ExternalToolSurfaceStagedOp::Add);
550        assert_eq!(beta.staged_intent_sequence, Some(3));
551    }
552
553    #[test]
554    fn staging_inputs_reject_after_surface_shutdown() {
555        let handle = handle_in_phase(mm_dsl::MeerkatPhase::Attached);
556
557        handle.shutdown_surface().expect("shutdown surface");
558
559        assert!(handle.stage_add("alpha".to_owned(), 10).is_err());
560        assert_eq!(
561            handle.diagnostic_snapshot().surface_phase,
562            ExternalToolSurfaceGlobalPhase::Shutdown
563        );
564    }
565
566    #[test]
567    fn stage_reload_requires_active_base_state() {
568        let handle = handle_in_phase(mm_dsl::MeerkatPhase::Attached);
569
570        assert!(handle.stage_reload("alpha".to_owned(), 10).is_err());
571        assert!(handle.surface_snapshot("alpha").is_none());
572    }
573
574    #[test]
575    fn stage_reload_mints_sequence_for_active_surface() {
576        let handle = handle_with_active_surface("alpha");
577
578        handle
579            .stage_reload("alpha".to_owned(), 10)
580            .expect("stage reload");
581
582        let snapshot = handle.surface_snapshot("alpha").expect("reload snapshot");
583        assert_eq!(
584            snapshot.base_state,
585            Some(ExternalToolSurfaceBaseState::Active)
586        );
587        assert_eq!(snapshot.staged_op, ExternalToolSurfaceStagedOp::Reload);
588        assert_eq!(snapshot.staged_intent_sequence, Some(1));
589    }
590
591    #[test]
592    fn runtime_surface_lifecycle_keeps_pending_lineage_on_staged_sequence() {
593        let handle = handle_in_phase(mm_dsl::MeerkatPhase::Attached);
594
595        assert!(handle.stage_reload("alpha".to_owned(), 10).is_err());
596
597        handle.stage_add("alpha".to_owned(), 10).expect("stage add");
598        let staged_add = handle.surface_snapshot("alpha").expect("staged add");
599        let add_lineage = staged_add
600            .staged_intent_sequence
601            .expect("staged add sequence");
602
603        assert!(
604            handle
605                .apply_boundary("alpha".to_owned(), 20, add_lineage + 1, 99)
606                .is_err(),
607            "apply boundary must reject a lineage that is not the staged intent"
608        );
609
610        handle
611            .apply_boundary("alpha".to_owned(), 20, add_lineage, 99)
612            .expect("apply add boundary");
613        let pending_add = handle.surface_snapshot("alpha").expect("pending add");
614        assert_eq!(pending_add.pending_op, ExternalToolSurfacePendingOp::Add);
615        assert_eq!(pending_add.pending_task_sequence, Some(1));
616        assert_eq!(pending_add.pending_lineage_sequence, Some(add_lineage));
617        assert_eq!(pending_add.staged_op, ExternalToolSurfaceStagedOp::None);
618
619        handle
620            .mark_pending_succeeded("alpha".to_owned(), 1, add_lineage)
621            .expect("add success");
622        let active = handle.surface_snapshot("alpha").expect("active alpha");
623        assert_eq!(
624            active.base_state,
625            Some(ExternalToolSurfaceBaseState::Active)
626        );
627        assert!(handle.visible_surfaces().contains("alpha"));
628
629        handle
630            .stage_reload("alpha".to_owned(), 30)
631            .expect("stage reload after active");
632        let staged_reload = handle.surface_snapshot("alpha").expect("staged reload");
633        let reload_lineage = staged_reload
634            .staged_intent_sequence
635            .expect("staged reload sequence");
636        handle
637            .apply_boundary("alpha".to_owned(), 40, reload_lineage, 100)
638            .expect("apply reload boundary");
639        let pending_reload = handle.surface_snapshot("alpha").expect("pending reload");
640        assert_eq!(
641            pending_reload.pending_op,
642            ExternalToolSurfacePendingOp::Reload
643        );
644        assert_eq!(
645            pending_reload.pending_lineage_sequence,
646            Some(reload_lineage)
647        );
648        handle
649            .mark_pending_succeeded("alpha".to_owned(), 2, reload_lineage)
650            .expect("reload success");
651
652        handle
653            .stage_remove("alpha".to_owned(), 50)
654            .expect("stage remove");
655        let remove_lineage = handle
656            .surface_snapshot("alpha")
657            .and_then(|entry| entry.staged_intent_sequence)
658            .expect("staged remove sequence");
659        handle
660            .apply_boundary("alpha".to_owned(), 60, remove_lineage, 101)
661            .expect("apply remove boundary");
662        let removing = handle.surface_snapshot("alpha").expect("removing alpha");
663        assert_eq!(
664            removing.base_state,
665            Some(ExternalToolSurfaceBaseState::Removing)
666        );
667        assert!(!handle.visible_surfaces().contains("alpha"));
668
669        handle
670            .finalize_removal_clean("alpha".to_owned())
671            .expect("finalize removal");
672        let removed = handle.surface_snapshot("alpha").expect("removed alpha");
673        assert_eq!(
674            removed.base_state,
675            Some(ExternalToolSurfaceBaseState::Removed)
676        );
677        assert!(!handle.visible_surfaces().contains("alpha"));
678    }
679
680    #[test]
681    fn runtime_mark_pending_failed_accepts_typed_failure_cause() {
682        let handle = handle_in_phase(mm_dsl::MeerkatPhase::Attached);
683
684        handle.stage_add("alpha".to_owned(), 10).expect("stage add");
685        let staged_sequence = handle
686            .surface_snapshot("alpha")
687            .and_then(|entry| entry.staged_intent_sequence)
688            .expect("staged add sequence");
689        handle
690            .apply_boundary("alpha".to_owned(), 20, staged_sequence, 99)
691            .expect("apply add boundary");
692
693        let transition = handle
694            .apply_surface_input(ExternalToolSurfaceInput::MarkPendingFailed {
695                surface_id: "alpha".to_owned(),
696                pending_task_sequence: 1,
697                staged_intent_sequence: staged_sequence,
698                cause: ExternalToolSurfaceFailureCause::PendingFailed,
699            })
700            .expect("typed pending failure");
701        assert!(transition.effects.iter().any(|effect| matches!(
702            effect,
703            ExternalToolSurfaceEffect::EmitExternalToolDelta {
704                phase: ExternalToolSurfaceDeltaPhase::Failed,
705                cause: Some(ExternalToolSurfaceFailureCause::PendingFailed),
706                ..
707            }
708        )));
709
710        let failed = handle.surface_snapshot("alpha").expect("failed alpha");
711        assert_eq!(
712            failed.last_delta_phase,
713            Some(ExternalToolSurfaceDeltaPhase::Failed)
714        );
715        assert_eq!(failed.pending_op, ExternalToolSurfacePendingOp::None);
716    }
717}