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