Skip to main content

tailtriage_controller/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4// Long-lived capture control layer for repeated bounded tailtriage activations.
5//
6// Layering:
7//
8// - [`tailtriage_core`] remains the per-run collector and artifact model.
9// - `tailtriage-controller` provides control-layer scaffolding for live arm/disarm
10//   workflows that create fresh bounded runs on every activation.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, Weak};
16use std::time::Duration;
17
18use serde::{Deserialize, Serialize};
19use tailtriage_core::{
20    BuildError, CaptureLimitsOverride, CaptureMode, InflightGuard, Outcome, OwnedRequestCompletion,
21    OwnedRequestHandle, QueueTimer, RequestOptions, RunEndReason, StageTimer, Tailtriage,
22};
23use tailtriage_tokio::{RuntimeSampler, SamplerStartError};
24
25/// Builder for a long-lived [`TailtriageController`].
26#[derive(Debug, Clone)]
27pub struct TailtriageControllerBuilder {
28    service_name: String,
29    config_path: Option<PathBuf>,
30    initially_enabled: bool,
31    sink_template: ControllerSinkTemplate,
32    capture_limits_override: CaptureLimitsOverride,
33    strict_lifecycle: bool,
34    runtime_sampler: RuntimeSamplerTemplate,
35    run_end_policy: RunEndPolicy,
36}
37
38impl TailtriageControllerBuilder {
39    /// Creates a controller builder for one service.
40    #[must_use]
41    pub fn new(service_name: impl Into<String>) -> Self {
42        Self {
43            service_name: service_name.into(),
44            config_path: None,
45            initially_enabled: false,
46            sink_template: ControllerSinkTemplate::LocalJson {
47                output_path: PathBuf::from("tailtriage-run.json"),
48            },
49            capture_limits_override: CaptureLimitsOverride::default(),
50            strict_lifecycle: false,
51            runtime_sampler: RuntimeSamplerTemplate::default(),
52            run_end_policy: RunEndPolicy::ContinueAfterLimitsHit,
53        }
54    }
55
56    /// Sets the optional config path used for reloadable controller config.
57    #[must_use]
58    pub fn config_path(mut self, config_path: impl AsRef<Path>) -> Self {
59        self.config_path = Some(config_path.as_ref().to_path_buf());
60        self
61    }
62
63    /// Sets whether build should immediately create the first active generation.
64    ///
65    /// When set to `true`, [`Self::build`] calls [`TailtriageController::enable`]
66    /// during construction so generation `1` is active as soon as build succeeds.
67    #[must_use]
68    pub const fn initially_enabled(mut self, initially_enabled: bool) -> Self {
69        self.initially_enabled = initially_enabled;
70        self
71    }
72
73    /// Sets the output location template for future activation runs.
74    #[must_use]
75    pub fn output(mut self, output_path: impl AsRef<Path>) -> Self {
76        self.sink_template = ControllerSinkTemplate::LocalJson {
77            output_path: output_path.as_ref().to_path_buf(),
78        };
79        self
80    }
81
82    /// Sets field-level capture limit overrides applied on top of selected mode defaults.
83    #[must_use]
84    pub const fn capture_limits_override(
85        mut self,
86        capture_limits_override: CaptureLimitsOverride,
87    ) -> Self {
88        self.capture_limits_override = capture_limits_override;
89        self
90    }
91
92    /// Sets strict lifecycle validation applied to future activation runs.
93    #[must_use]
94    pub const fn strict_lifecycle(mut self, strict_lifecycle: bool) -> Self {
95        self.strict_lifecycle = strict_lifecycle;
96        self
97    }
98
99    /// Sets runtime sampler template settings for future activations.
100    #[must_use]
101    pub const fn runtime_sampler(mut self, runtime_sampler: RuntimeSamplerTemplate) -> Self {
102        self.runtime_sampler = runtime_sampler;
103        self
104    }
105
106    /// Sets a run-end policy template applied to future activations.
107    #[must_use]
108    pub const fn run_end_policy(mut self, run_end_policy: RunEndPolicy) -> Self {
109        self.run_end_policy = run_end_policy;
110        self
111    }
112
113    /// Builds the controller.
114    ///
115    /// # Errors
116    ///
117    /// When `config_path(...)` is set, `controller.service_name` from TOML takes
118    /// precedence when present; the builder value is used only when TOML omits it.
119    ///
120    /// Returns [`ControllerBuildError::EmptyServiceName`] when the final resolved
121    /// `service_name` is blank.
122    ///
123    /// Returns [`ControllerBuildError::ConfigLoad`] when `config_path(...)` is set and
124    /// reading or parsing the TOML file fails.
125    ///
126    /// Returns [`ControllerBuildError::InitialEnable`] when
127    /// [`Self::initially_enabled`] is `true` and the first generation cannot be
128    /// armed.
129    pub fn build(self) -> Result<TailtriageController, ControllerBuildError> {
130        let mut service_name = self.service_name;
131        let mut initially_enabled = self.initially_enabled;
132        let mut sink_template = self.sink_template;
133        let mut selected_mode = CaptureMode::Light;
134        let mut capture_limits_override = self.capture_limits_override;
135        let mut strict_lifecycle = self.strict_lifecycle;
136        let mut runtime_sampler = self.runtime_sampler;
137        let mut run_end_policy = self.run_end_policy;
138
139        if let Some(config_path) = self.config_path.as_ref() {
140            let loaded = TailtriageController::load_config_from_path(config_path)
141                .map_err(ControllerBuildError::ConfigLoad)?;
142            let activation = loaded.activation_template;
143            service_name = loaded.service_name.unwrap_or(service_name);
144            initially_enabled = loaded.initially_enabled.unwrap_or(initially_enabled);
145            sink_template = activation.sink_template;
146            selected_mode = activation.selected_mode;
147            capture_limits_override = activation.capture_limits_override;
148            strict_lifecycle = activation.strict_lifecycle;
149            runtime_sampler = activation.runtime_sampler;
150            run_end_policy = activation.run_end_policy;
151        }
152
153        if service_name.trim().is_empty() {
154            return Err(ControllerBuildError::EmptyServiceName);
155        }
156
157        let template = TailtriageControllerTemplate {
158            service_name,
159            config_path: self.config_path,
160            sink_template,
161            selected_mode: CaptureMode::Light,
162            capture_limits_override,
163            strict_lifecycle,
164            runtime_sampler,
165            run_end_policy,
166        };
167        let template = TailtriageControllerTemplate {
168            selected_mode,
169            ..template
170        };
171
172        let inner = Arc::new(ControllerInner {
173            template: Mutex::new(template),
174            lifecycle: Mutex::new(ControllerLifecycle::Disabled { next_generation: 1 }),
175            inert_request_seq: AtomicU64::new(1),
176        });
177
178        let controller = TailtriageController { inner };
179        if initially_enabled {
180            controller
181                .enable()
182                .map_err(ControllerBuildError::InitialEnable)?;
183        }
184
185        Ok(controller)
186    }
187}
188
189/// Long-lived live-capture controller for arm/disarm workflows.
190#[derive(Debug, Clone)]
191pub struct TailtriageController {
192    inner: Arc<ControllerInner>,
193}
194
195#[derive(Debug)]
196struct ControllerInner {
197    template: Mutex<TailtriageControllerTemplate>,
198    lifecycle: Mutex<ControllerLifecycle>,
199    inert_request_seq: AtomicU64,
200}
201
202#[derive(Debug)]
203struct ActiveGenerationRuntime {
204    state: ActiveGenerationState,
205    artifact_path: PathBuf,
206    run: Arc<Tailtriage>,
207    accepting_new: AtomicBool,
208    closing: AtomicBool,
209    inflight_captured: AtomicU64,
210    finalize_started: AtomicBool,
211    last_finalize_error: Mutex<Option<String>>,
212    runtime_sampler: Mutex<Option<RuntimeSampler>>,
213}
214
215impl ActiveGenerationRuntime {
216    fn snapshot(&self) -> ActiveGenerationState {
217        ActiveGenerationState {
218            generation_id: self.state.generation_id,
219            started_at_unix_ms: self.state.started_at_unix_ms,
220            artifact_path: self.artifact_path.clone(),
221            accepting_new_admissions: self.accepting_new.load(Ordering::Relaxed),
222            closing: self.closing.load(Ordering::Relaxed),
223            inflight_captured_requests: self.inflight_captured.load(Ordering::Relaxed),
224            finalization_in_progress: self.finalize_started.load(Ordering::Relaxed),
225            last_finalize_error: self
226                .last_finalize_error
227                .lock()
228                .unwrap_or_else(std::sync::PoisonError::into_inner)
229                .clone(),
230            activation_config: self.state.activation_config.clone(),
231        }
232    }
233
234    fn clear_finalize_error(&self) {
235        let mut last_error = self
236            .last_finalize_error
237            .lock()
238            .unwrap_or_else(std::sync::PoisonError::into_inner);
239        *last_error = None;
240    }
241
242    fn record_finalize_error(&self, error: &DisableError) {
243        let mut last_error = self
244            .last_finalize_error
245            .lock()
246            .unwrap_or_else(std::sync::PoisonError::into_inner);
247        *last_error = Some(error.to_string());
248    }
249}
250
251impl TailtriageController {
252    fn validate_template(template: &TailtriageControllerTemplate) -> Result<(), BuildError> {
253        let artifact_path = generated_artifact_path(&template.sink_template, 1);
254        let run_id = format!("{}-generation-1", template.service_name);
255
256        let mut builder = Tailtriage::builder(template.service_name.clone())
257            .run_id(run_id)
258            .output(&artifact_path);
259        builder = match template.selected_mode {
260            CaptureMode::Light => builder.light(),
261            CaptureMode::Investigation => builder.investigation(),
262        };
263        builder = builder.capture_limits_override(template.capture_limits_override);
264        builder = builder.strict_lifecycle(template.strict_lifecycle);
265        let _ = builder.build()?;
266        Ok(())
267    }
268
269    fn next_inert_request_id(&self) -> String {
270        let id = self.inner.inert_request_seq.fetch_add(1, Ordering::Relaxed);
271        format!("inert-{id}")
272    }
273
274    /// Creates a builder for controller-level scaffolding.
275    #[must_use]
276    pub fn builder(service_name: impl Into<String>) -> TailtriageControllerBuilder {
277        TailtriageControllerBuilder::new(service_name)
278    }
279
280    /// Loads controller TOML config from `path` without mutating controller state.
281    ///
282    /// This helper parses and returns the activation template that would be applied
283    /// on reload/build.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`ConfigLoadError`] when reading or parsing the TOML file fails.
288    pub fn load_config_from_path(
289        path: impl AsRef<Path>,
290    ) -> Result<LoadedControllerConfig, ConfigLoadError> {
291        let path = path.as_ref();
292        let file = ControllerConfigFile::from_path(path)?;
293        Ok(file.into_loaded())
294    }
295
296    /// Returns a status snapshot of controller lifecycle and template state.
297    ///
298    #[must_use]
299    pub fn status(&self) -> TailtriageControllerStatus {
300        let template = self
301            .inner
302            .template
303            .lock()
304            .unwrap_or_else(std::sync::PoisonError::into_inner);
305        let lifecycle = self
306            .inner
307            .lifecycle
308            .lock()
309            .unwrap_or_else(std::sync::PoisonError::into_inner);
310
311        TailtriageControllerStatus {
312            template: template.clone(),
313            generation: lifecycle.snapshot(),
314        }
315    }
316
317    /// Replaces the template used to create the next activation generation.
318    ///
319    /// This compatibility helper validates `next_template` and then applies it.
320    ///
321    /// # Panics
322    ///
323    /// Panics when template validation fails. Prefer
324    /// [`TailtriageController::try_reload_template`] to handle validation errors explicitly.
325    pub fn reload_template(&self, next_template: TailtriageControllerTemplate) {
326        self.try_reload_template(next_template)
327            .expect("invalid template for reload_template");
328    }
329
330    /// Replaces the template used to create the next activation generation.
331    ///
332    /// Unlike [`TailtriageController::reload_template`], this method returns
333    /// validation errors instead of panicking.
334    ///
335    /// Validation matches the build-time checks done by [`TailtriageController::enable`].
336    ///
337    /// # Errors
338    ///
339    /// Returns [`ReloadTemplateError`] when `service_name` is blank or when
340    /// building a run with this template would fail.
341    pub fn try_reload_template(
342        &self,
343        next_template: TailtriageControllerTemplate,
344    ) -> Result<(), ReloadTemplateError> {
345        Self::validate_template(&next_template).map_err(ReloadTemplateError::Validate)?;
346        let mut template = self
347            .inner
348            .template
349            .lock()
350            .unwrap_or_else(std::sync::PoisonError::into_inner);
351        *template = next_template;
352        Ok(())
353    }
354
355    /// Reloads controller config from the configured template file path.
356    ///
357    /// Reload only updates the template for future activations. Any active generation
358    /// keeps the activation config it started with.
359    ///
360    /// # Errors
361    ///
362    /// Returns [`ReloadConfigError`] when the controller has no `config_path` or when
363    /// loading/parsing/validating the TOML file fails.
364    ///
365    pub fn reload_config(&self) -> Result<(), ReloadConfigError> {
366        let (config_path, service_name) = {
367            let template = self
368                .inner
369                .template
370                .lock()
371                .unwrap_or_else(std::sync::PoisonError::into_inner);
372            let Some(config_path) = template.config_path.clone() else {
373                return Err(ReloadConfigError::MissingConfigPath);
374            };
375            (config_path, template.service_name.clone())
376        };
377
378        let loaded = TailtriageController::load_config_from_path(&config_path)
379            .map_err(ReloadConfigError::Load)?;
380        let activation = loaded.activation_template;
381        let validated = TailtriageControllerTemplate {
382            service_name: loaded.service_name.unwrap_or(service_name),
383            config_path: Some(config_path),
384            sink_template: activation.sink_template,
385            selected_mode: activation.selected_mode,
386            capture_limits_override: activation.capture_limits_override,
387            strict_lifecycle: activation.strict_lifecycle,
388            runtime_sampler: activation.runtime_sampler,
389            run_end_policy: activation.run_end_policy,
390        };
391
392        Self::validate_template(&validated).map_err(ReloadConfigError::Validate)?;
393
394        let mut template = self
395            .inner
396            .template
397            .lock()
398            .unwrap_or_else(std::sync::PoisonError::into_inner);
399        *template = validated;
400
401        Ok(())
402    }
403
404    /// Arms capture by creating a fresh active generation with a bounded run.
405    ///
406    /// # Errors
407    ///
408    /// Returns [`EnableError::AlreadyActive`] when another generation is already active.
409    ///
410    /// Returns [`EnableError::Build`] when constructing the generation run fails.
411    ///
412    /// Returns [`EnableError::MissingTokioRuntimeForSampler`] when runtime sampler
413    /// template startup is enabled but `enable()` is called outside an active Tokio runtime.
414    ///
415    /// Returns [`EnableError::StartRuntimeSampler`] when runtime sampler startup is
416    /// enabled but sampler initialization fails (for example, when config sets
417    /// `interval_ms = 0`).
418    ///
419    pub fn enable(&self) -> Result<ActiveGenerationState, EnableError> {
420        let template = self
421            .inner
422            .template
423            .lock()
424            .unwrap_or_else(std::sync::PoisonError::into_inner)
425            .clone();
426
427        let mut lifecycle = self
428            .inner
429            .lifecycle
430            .lock()
431            .unwrap_or_else(std::sync::PoisonError::into_inner);
432
433        let next_generation = match *lifecycle {
434            ControllerLifecycle::Disabled { next_generation } => next_generation,
435            ControllerLifecycle::Active { ref active, .. } => {
436                return Err(EnableError::AlreadyActive {
437                    generation_id: active.state.generation_id,
438                });
439            }
440        };
441
442        let artifact_path = generated_artifact_path(&template.sink_template, next_generation);
443        let run_id = format!("{}-generation-{next_generation}", template.service_name);
444
445        let mut builder = Tailtriage::builder(template.service_name.clone())
446            .run_id(run_id)
447            .output(&artifact_path);
448
449        builder = match template.selected_mode {
450            CaptureMode::Light => builder.light(),
451            CaptureMode::Investigation => builder.investigation(),
452        };
453        builder = builder.capture_limits_override(template.capture_limits_override);
454        builder = builder.strict_lifecycle(template.strict_lifecycle);
455
456        let run = Arc::new(builder.build().map_err(EnableError::Build)?);
457        let generation_started_at_unix_ms = run.snapshot().metadata.started_at_unix_ms;
458        let runtime = Arc::new(ActiveGenerationRuntime {
459            state: ActiveGenerationState {
460                generation_id: next_generation,
461                started_at_unix_ms: generation_started_at_unix_ms,
462                artifact_path: artifact_path.clone(),
463                accepting_new_admissions: true,
464                closing: false,
465                inflight_captured_requests: 0,
466                finalization_in_progress: false,
467                last_finalize_error: None,
468                activation_config: ControllerActivationTemplate {
469                    sink_template: template.sink_template.clone(),
470                    selected_mode: template.selected_mode,
471                    capture_limits_override: template.capture_limits_override,
472                    strict_lifecycle: template.strict_lifecycle,
473                    runtime_sampler: template.runtime_sampler,
474                    run_end_policy: template.run_end_policy,
475                },
476            },
477            artifact_path,
478            run: Arc::clone(&run),
479            accepting_new: AtomicBool::new(true),
480            closing: AtomicBool::new(false),
481            inflight_captured: AtomicU64::new(0),
482            finalize_started: AtomicBool::new(false),
483            last_finalize_error: Mutex::new(None),
484            runtime_sampler: Mutex::new(None),
485        });
486        if template.run_end_policy == RunEndPolicy::AutoSealOnLimitsHit {
487            let active = Arc::downgrade(&runtime);
488            let inner = Arc::downgrade(&self.inner);
489            let listener: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
490                TailtriageController::on_limits_hit_signal(&inner, &active);
491            });
492            runtime.run.set_limits_hit_listener(Some(listener));
493        }
494
495        if template.runtime_sampler.enabled_for_armed_runs {
496            let _ = tokio::runtime::Handle::try_current()
497                .map_err(|_| EnableError::MissingTokioRuntimeForSampler)?;
498            let mut sampler_builder = RuntimeSampler::builder(Arc::clone(&run));
499            if let Some(mode_override) = template.runtime_sampler.mode_override {
500                sampler_builder = sampler_builder.mode(mode_override);
501            }
502            if let Some(interval_ms) = template.runtime_sampler.interval_ms {
503                sampler_builder = sampler_builder.interval(Duration::from_millis(interval_ms));
504            }
505            if let Some(max_runtime_snapshots) = template.runtime_sampler.max_runtime_snapshots {
506                sampler_builder = sampler_builder.max_runtime_snapshots(max_runtime_snapshots);
507            }
508            let runtime_sampler = sampler_builder
509                .start()
510                .map_err(EnableError::StartRuntimeSampler)?;
511            let mut sampler_slot = runtime
512                .runtime_sampler
513                .lock()
514                .unwrap_or_else(std::sync::PoisonError::into_inner);
515            *sampler_slot = Some(runtime_sampler);
516        }
517
518        *lifecycle = ControllerLifecycle::Active {
519            active: Arc::clone(&runtime),
520            next_generation: next_generation.saturating_add(1),
521        };
522
523        Ok(runtime.snapshot())
524    }
525
526    /// Disarms capture for the active generation.
527    ///
528    /// This stops new request admissions immediately. If no admitted captured requests
529    /// remain in flight, disarm finalizes immediately. Otherwise the generation is marked
530    /// closing and finalization happens after the admitted captured requests drain.
531    ///
532    /// # Errors
533    ///
534    /// Returns [`DisableError::Finalize`] when final artifact writing fails.
535    ///
536    pub fn disable(&self) -> Result<DisableOutcome, DisableError> {
537        let (active, next_generation, generation_id) = {
538            let lifecycle = self
539                .inner
540                .lifecycle
541                .lock()
542                .unwrap_or_else(std::sync::PoisonError::into_inner);
543
544            let ControllerLifecycle::Active {
545                ref active,
546                next_generation,
547            } = *lifecycle
548            else {
549                return Ok(DisableOutcome::AlreadyDisabled);
550            };
551
552            active
553                .run
554                .set_run_end_reason_if_absent(RunEndReason::ManualDisarm);
555            active.accepting_new.store(false, Ordering::Relaxed);
556            active.closing.store(true, Ordering::Relaxed);
557
558            if active.inflight_captured.load(Ordering::Relaxed) == 0 {
559                (
560                    Some(Arc::clone(active)),
561                    Some(next_generation),
562                    active.state.generation_id,
563                )
564            } else {
565                return Ok(DisableOutcome::Closing {
566                    generation_id: active.state.generation_id,
567                    inflight_captured_requests: active.inflight_captured.load(Ordering::Relaxed),
568                });
569            }
570        };
571
572        if let (Some(active), Some(next_generation)) = (active, next_generation) {
573            Self::finalize_active(&self.inner, &active, next_generation)?;
574        }
575
576        Ok(DisableOutcome::Finalized { generation_id })
577    }
578
579    /// Begins one request through the controller.
580    ///
581    /// When an active generation is still admitting requests, the returned tokens are
582    /// bound to that generation.
583    ///
584    /// When controller capture is disabled (or an active generation is closing), this
585    /// returns inert/no-op request tokens.
586    ///
587    /// Inert handles preserve explicit metadata from [`RequestOptions`] (`request_id` and
588    /// `kind`). When `request_id` is omitted, the controller assigns a local fallback ID in
589    /// `inert-{N}` form for predictable non-empty metadata.
590    ///
591    pub fn begin_request_with(
592        &self,
593        route: impl Into<String>,
594        options: RequestOptions,
595    ) -> ControllerStartedRequest {
596        let route = route.into();
597        if let Some(started) = self.try_begin_request_with(route.clone(), options.clone()) {
598            return started;
599        }
600
601        ControllerStartedRequest {
602            handle: ControllerRequestHandle::Inert(InertControllerRequestHandle::new(
603                route,
604                options,
605                self.next_inert_request_id(),
606            )),
607            completion: ControllerRequestCompletion {
608                kind: ControllerCompletionKind::Inert,
609            },
610        }
611    }
612
613    /// Convenience helper using default request options.
614    pub fn begin_request(&self, route: impl Into<String>) -> ControllerStartedRequest {
615        self.begin_request_with(route, RequestOptions::new())
616    }
617
618    /// Tries to begin a captured request when an active generation is still admitting requests.
619    ///
620    /// The returned handle and completion are generation-bound at admission time.
621    /// They remain attached to that admitted generation even if the controller is
622    /// disabled and re-enabled before completion finishes.
623    ///
624    /// Returns `None` when controller is disabled or when active generation is closing.
625    ///
626    /// Prefer [`TailtriageController::begin_request_with`] for the primary non-branching API.
627    ///
628    #[must_use]
629    pub fn try_begin_request_with(
630        &self,
631        route: impl Into<String>,
632        options: RequestOptions,
633    ) -> Option<ControllerStartedRequest> {
634        let active = {
635            let lifecycle = self
636                .inner
637                .lifecycle
638                .lock()
639                .unwrap_or_else(std::sync::PoisonError::into_inner);
640
641            match *lifecycle {
642                ControllerLifecycle::Active { ref active, .. } => Arc::clone(active),
643                ControllerLifecycle::Disabled { .. } => return None,
644            }
645        };
646
647        if !active.accepting_new.load(Ordering::Acquire) {
648            return None;
649        }
650
651        if active.state.activation_config.run_end_policy == RunEndPolicy::AutoSealOnLimitsHit
652            && active.run.snapshot().truncation.limits_hit
653        {
654            active
655                .run
656                .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
657            active.accepting_new.store(false, Ordering::Release);
658            active.closing.store(true, Ordering::Release);
659            if active.inflight_captured.load(Ordering::Acquire) == 0 {
660                let _ = self.force_finalize_generation(&active);
661            }
662            return None;
663        }
664
665        active.inflight_captured.fetch_add(1, Ordering::AcqRel);
666        if !active.accepting_new.load(Ordering::Acquire) {
667            active.inflight_captured.fetch_sub(1, Ordering::AcqRel);
668            return None;
669        }
670
671        // Admission is now committed to this concrete generation runtime.
672        // The completion token keeps a weak reference to this runtime so finish
673        // bookkeeping cannot drift into a later generation.
674        let started = active.run.begin_request_with_owned(route, options);
675        Self::apply_run_end_policy_if_limits_hit(&active);
676
677        Some(ControllerStartedRequest {
678            handle: ControllerRequestHandle::Active(started.handle),
679            completion: ControllerRequestCompletion {
680                kind: ControllerCompletionKind::Active(ActiveControllerCompletion {
681                    completion: Some(started.completion),
682                    admission_generation_id: active.state.generation_id,
683                    admitted_generation: Arc::downgrade(&active),
684                    inner: Arc::downgrade(&self.inner),
685                    run_end_policy: active.state.activation_config.run_end_policy,
686                    inflight_recorded: true,
687                }),
688            },
689        })
690    }
691
692    /// Compatibility helper using default request options.
693    ///
694    /// Prefer [`TailtriageController::begin_request`] for the primary non-branching API.
695    #[must_use]
696    pub fn try_begin_request(&self, route: impl Into<String>) -> Option<ControllerStartedRequest> {
697        self.try_begin_request_with(route, RequestOptions::new())
698    }
699
700    /// Finalizes controller state for process shutdown.
701    ///
702    /// Shutdown makes lifecycle behavior explicit: it immediately stops new admissions and
703    /// writes any active generation artifact, even if unfinished requests remain.
704    /// That behavior matches [`tailtriage_core::Tailtriage::shutdown`].
705    ///
706    /// # Errors
707    ///
708    /// Returns [`ShutdownError::Finalize`] if artifact writing fails.
709    ///
710    pub fn shutdown(&self) -> Result<(), ShutdownError> {
711        let maybe_active = {
712            let lifecycle = self
713                .inner
714                .lifecycle
715                .lock()
716                .unwrap_or_else(std::sync::PoisonError::into_inner);
717            match *lifecycle {
718                ControllerLifecycle::Active { ref active, .. } => Some(Arc::clone(active)),
719                ControllerLifecycle::Disabled { .. } => None,
720            }
721        };
722
723        if let Some(active) = maybe_active {
724            active
725                .run
726                .set_run_end_reason_if_absent(RunEndReason::Shutdown);
727            active.accepting_new.store(false, Ordering::Relaxed);
728            active.closing.store(true, Ordering::Relaxed);
729            self.force_finalize_generation(&active)
730                .map_err(ShutdownError::Finalize)?;
731        }
732
733        Ok(())
734    }
735
736    fn force_finalize_generation(
737        &self,
738        active: &Arc<ActiveGenerationRuntime>,
739    ) -> Result<(), DisableError> {
740        Self::finalize_generation_shared(&self.inner, active)
741    }
742
743    fn finalize_generation_shared(
744        inner: &Arc<ControllerInner>,
745        active: &Arc<ActiveGenerationRuntime>,
746    ) -> Result<(), DisableError> {
747        let next_generation = {
748            let lifecycle = inner
749                .lifecycle
750                .lock()
751                .unwrap_or_else(std::sync::PoisonError::into_inner);
752            match *lifecycle {
753                ControllerLifecycle::Active {
754                    active: ref current_active,
755                    next_generation,
756                } if current_active.state.generation_id == active.state.generation_id => {
757                    next_generation
758                }
759                _ => return Ok(()),
760            }
761        };
762
763        Self::finalize_active(inner, active, next_generation)
764    }
765
766    fn finalize_active(
767        inner: &Arc<ControllerInner>,
768        active: &Arc<ActiveGenerationRuntime>,
769        next_generation: u64,
770    ) -> Result<(), DisableError> {
771        if active
772            .finalize_started
773            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
774            .is_err()
775        {
776            return Ok(());
777        }
778
779        active.clear_finalize_error();
780        Self::stop_runtime_sampler(active);
781        if let Err(source) = active.run.shutdown() {
782            let error = DisableError::Finalize(source);
783            active.record_finalize_error(&error);
784            active.finalize_started.store(false, Ordering::Release);
785            return Err(error);
786        }
787
788        let mut lifecycle = inner
789            .lifecycle
790            .lock()
791            .unwrap_or_else(std::sync::PoisonError::into_inner);
792
793        if matches!(
794            *lifecycle,
795            ControllerLifecycle::Active {
796                active: ref current_active,
797                next_generation: ng,
798            } if current_active.state.generation_id == active.state.generation_id && ng == next_generation
799        ) {
800            *lifecycle = ControllerLifecycle::Disabled { next_generation };
801        }
802
803        Ok(())
804    }
805
806    fn stop_runtime_sampler(active: &Arc<ActiveGenerationRuntime>) {
807        let sampler = active
808            .runtime_sampler
809            .lock()
810            .unwrap_or_else(std::sync::PoisonError::into_inner)
811            .take();
812        if let Some(sampler) = sampler {
813            let shutdown_thread = std::thread::spawn(move || {
814                let runtime = tokio::runtime::Builder::new_current_thread()
815                    .enable_all()
816                    .build()
817                    .expect("sampler shutdown runtime should build");
818                runtime.block_on(sampler.shutdown());
819            });
820            let _ = shutdown_thread.join();
821        }
822    }
823
824    fn apply_run_end_policy_if_limits_hit(active: &Arc<ActiveGenerationRuntime>) {
825        if active.state.activation_config.run_end_policy != RunEndPolicy::AutoSealOnLimitsHit {
826            return;
827        }
828
829        if !active.run.snapshot().truncation.limits_hit {
830            return;
831        }
832
833        active
834            .run
835            .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
836        active.accepting_new.store(false, Ordering::Release);
837        active.closing.store(true, Ordering::Release);
838    }
839
840    fn on_limits_hit_signal(inner: &Weak<ControllerInner>, active: &Weak<ActiveGenerationRuntime>) {
841        let Some(active) = active.upgrade() else {
842            return;
843        };
844        active
845            .run
846            .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
847        active.accepting_new.store(false, Ordering::Release);
848        active.closing.store(true, Ordering::Release);
849
850        if active.inflight_captured.load(Ordering::Acquire) > 0 {
851            return;
852        }
853
854        let Some(inner) = inner.upgrade() else {
855            return;
856        };
857        let _ = TailtriageController::finalize_generation_shared(&inner, &active);
858    }
859}
860
861/// Result of trying to begin one captured request in a generation.
862#[must_use = "request completion must be finished explicitly"]
863#[derive(Debug)]
864pub struct ControllerStartedRequest {
865    /// Instrumentation handle for queue/stage/inflight timing.
866    pub handle: ControllerRequestHandle,
867    /// Completion token bound to one generation.
868    pub completion: ControllerRequestCompletion,
869}
870
871/// Completion token for a request admitted through [`TailtriageController`].
872#[must_use = "request completion must be finished explicitly"]
873#[derive(Debug)]
874pub struct ControllerRequestCompletion {
875    kind: ControllerCompletionKind,
876}
877
878impl ControllerRequestCompletion {
879    /// Finishes this request with an explicit outcome.
880    pub fn finish(mut self, outcome: Outcome) {
881        if let ControllerCompletionKind::Active(active) = &mut self.kind {
882            if let Some(completion) = active.completion.take() {
883                completion.finish(outcome);
884                active.mark_finished();
885            }
886        }
887    }
888
889    /// Convenience helper for successful completion.
890    pub fn finish_ok(self) {
891        self.finish(Outcome::Ok);
892    }
893
894    /// Finishes from `result` and returns `result` unchanged.
895    ///
896    /// # Errors
897    ///
898    /// This method does not create new errors. It returns `result` unchanged,
899    /// including the original `Err(E)` value.
900    pub fn finish_result<T, E>(mut self, result: Result<T, E>) -> Result<T, E> {
901        if let ControllerCompletionKind::Active(active) = &mut self.kind {
902            if let Some(completion) = active.completion.take() {
903                completion.finish(if result.is_ok() {
904                    Outcome::Ok
905                } else {
906                    Outcome::Error
907                });
908                active.mark_finished();
909            }
910        }
911        result
912    }
913}
914
915#[derive(Debug)]
916enum ControllerCompletionKind {
917    Active(ActiveControllerCompletion),
918    Inert,
919}
920
921#[derive(Debug)]
922struct ActiveControllerCompletion {
923    completion: Option<OwnedRequestCompletion>,
924    /// Generation captured at admission time.
925    ///
926    /// This binding is immutable for the life of the completion token so that
927    /// request finalization cannot migrate to a later generation during rapid
928    /// enable/disable/re-enable transitions.
929    admission_generation_id: u64,
930    /// Weak reference to the exact runtime generation that admitted the request.
931    ///
932    /// Keeping this pointer ensures inflight accounting and close/finalize checks
933    /// operate on the admitted generation even if controller lifecycle has already
934    /// advanced to a newer generation.
935    admitted_generation: Weak<ActiveGenerationRuntime>,
936    inner: Weak<ControllerInner>,
937    run_end_policy: RunEndPolicy,
938    inflight_recorded: bool,
939}
940
941impl ActiveControllerCompletion {
942    fn mark_finished(&mut self) {
943        if !self.inflight_recorded {
944            return;
945        }
946
947        self.inflight_recorded = false;
948
949        let Some(active) = self.admitted_generation.upgrade() else {
950            return;
951        };
952
953        debug_assert_eq!(
954            active.state.generation_id, self.admission_generation_id,
955            "controller completion generation binding should remain stable"
956        );
957
958        if self.run_end_policy == RunEndPolicy::AutoSealOnLimitsHit
959            && active.run.snapshot().truncation.limits_hit
960        {
961            active
962                .run
963                .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
964            active.accepting_new.store(false, Ordering::Release);
965            active.closing.store(true, Ordering::Release);
966        }
967
968        let remaining = active
969            .inflight_captured
970            .fetch_sub(1, Ordering::AcqRel)
971            .saturating_sub(1);
972
973        if remaining == 0 && active.closing.load(Ordering::Acquire) {
974            self.try_finalize_bound_generation(&active);
975        }
976    }
977
978    fn try_finalize_bound_generation(&self, active: &Arc<ActiveGenerationRuntime>) {
979        let Some(inner) = self.inner.upgrade() else {
980            return;
981        };
982        let _ = TailtriageController::finalize_generation_shared(&inner, active);
983    }
984}
985
986/// Instrumentation handle for requests admitted through [`TailtriageController`].
987#[derive(Debug, Clone)]
988pub enum ControllerRequestHandle {
989    /// Active request handle delegated to one admitted generation.
990    Active(OwnedRequestHandle),
991    /// Inert request handle returned while disabled/closing.
992    Inert(InertControllerRequestHandle),
993}
994
995impl ControllerRequestHandle {
996    /// Correlation ID attached to this request.
997    #[must_use]
998    pub fn request_id(&self) -> &str {
999        match self {
1000            Self::Active(handle) => handle.request_id(),
1001            Self::Inert(handle) => handle.request_id(),
1002        }
1003    }
1004
1005    /// Route/operation name attached to this request.
1006    #[must_use]
1007    pub fn route(&self) -> &str {
1008        match self {
1009            Self::Active(handle) => handle.route(),
1010            Self::Inert(handle) => handle.route(),
1011        }
1012    }
1013
1014    /// Optional kind metadata attached to this request.
1015    #[must_use]
1016    pub fn kind(&self) -> Option<&str> {
1017        match self {
1018            Self::Active(handle) => handle.kind(),
1019            Self::Inert(handle) => handle.kind(),
1020        }
1021    }
1022
1023    /// Starts queue-wait timing instrumentation for `queue`.
1024    #[must_use]
1025    pub fn queue(&self, queue: impl Into<String>) -> ControllerQueueTimer<'_> {
1026        match self {
1027            Self::Active(handle) => ControllerQueueTimer::Active(handle.queue(queue)),
1028            Self::Inert(_) => ControllerQueueTimer::Inert,
1029        }
1030    }
1031
1032    /// Starts stage timing instrumentation for `stage`.
1033    #[must_use]
1034    pub fn stage(&self, stage: impl Into<String>) -> ControllerStageTimer<'_> {
1035        match self {
1036            Self::Active(handle) => ControllerStageTimer::Active(handle.stage(stage)),
1037            Self::Inert(_) => ControllerStageTimer::Inert,
1038        }
1039    }
1040
1041    /// Creates an in-flight guard for `gauge`.
1042    #[must_use]
1043    pub fn inflight(&self, gauge: impl Into<String>) -> ControllerInflightGuard<'_> {
1044        match self {
1045            Self::Active(handle) => ControllerInflightGuard::Active(handle.inflight(gauge)),
1046            Self::Inert(_) => ControllerInflightGuard::Inert,
1047        }
1048    }
1049}
1050
1051/// Inert controller request handle metadata stored while disabled/closing.
1052#[derive(Debug, Clone)]
1053pub struct InertControllerRequestHandle {
1054    request_id: String,
1055    route: String,
1056    kind: Option<String>,
1057}
1058
1059impl InertControllerRequestHandle {
1060    fn new(route: String, options: RequestOptions, fallback_request_id: String) -> Self {
1061        Self {
1062            request_id: options.request_id.unwrap_or(fallback_request_id),
1063            route,
1064            kind: options.kind,
1065        }
1066    }
1067
1068    fn request_id(&self) -> &str {
1069        &self.request_id
1070    }
1071
1072    fn route(&self) -> &str {
1073        &self.route
1074    }
1075
1076    fn kind(&self) -> Option<&str> {
1077        self.kind.as_deref()
1078    }
1079}
1080
1081/// Controller-local queue timer wrapper.
1082#[derive(Debug)]
1083pub enum ControllerQueueTimer<'a> {
1084    /// Queue timer delegated to an active generation.
1085    Active(QueueTimer<'a>),
1086    /// Inert timer used while disabled/closing.
1087    Inert,
1088}
1089
1090impl ControllerQueueTimer<'_> {
1091    /// Sets queue depth sample captured at wait start.
1092    #[must_use]
1093    pub fn with_depth_at_start(self, depth_at_start: u64) -> Self {
1094        match self {
1095            Self::Active(timer) => Self::Active(timer.with_depth_at_start(depth_at_start)),
1096            Self::Inert => Self::Inert,
1097        }
1098    }
1099
1100    /// Awaits `fut`, recording queue wait for active requests only.
1101    pub async fn await_on<Fut, T>(self, fut: Fut) -> T
1102    where
1103        Fut: std::future::Future<Output = T>,
1104    {
1105        match self {
1106            Self::Active(timer) => timer.await_on(fut).await,
1107            Self::Inert => fut.await,
1108        }
1109    }
1110}
1111
1112/// Controller-local stage timer wrapper.
1113#[derive(Debug)]
1114pub enum ControllerStageTimer<'a> {
1115    /// Stage timer delegated to an active generation.
1116    Active(StageTimer<'a>),
1117    /// Inert timer used while disabled/closing.
1118    Inert,
1119}
1120
1121impl ControllerStageTimer<'_> {
1122    /// Awaits `fut`, recording stage duration for active requests only.
1123    ///
1124    /// # Errors
1125    ///
1126    /// Returns the same `Err(E)` produced by `fut` unchanged.
1127    pub async fn await_on<Fut, T, E>(self, fut: Fut) -> Result<T, E>
1128    where
1129        Fut: std::future::Future<Output = Result<T, E>>,
1130    {
1131        match self {
1132            Self::Active(timer) => timer.await_on(fut).await,
1133            Self::Inert => fut.await,
1134        }
1135    }
1136
1137    /// Awaits infallible stage work, recording active requests only.
1138    pub async fn await_value<Fut, T>(self, fut: Fut) -> T
1139    where
1140        Fut: std::future::Future<Output = T>,
1141    {
1142        match self {
1143            Self::Active(timer) => timer.await_value(fut).await,
1144            Self::Inert => fut.await,
1145        }
1146    }
1147}
1148
1149/// Controller-local in-flight guard wrapper.
1150#[derive(Debug)]
1151pub enum ControllerInflightGuard<'a> {
1152    /// In-flight guard delegated to an active generation.
1153    Active(InflightGuard<'a>),
1154    /// Inert guard used while disabled/closing.
1155    Inert,
1156}
1157
1158/// Template configuration that the controller applies to future activations.
1159#[derive(Debug, Clone, PartialEq, Eq)]
1160pub struct TailtriageControllerTemplate {
1161    /// Service name attached to controller activations.
1162    pub service_name: String,
1163    /// Optional source path for reloadable control config.
1164    pub config_path: Option<PathBuf>,
1165    /// Sink/output template for bounded run artifacts.
1166    pub sink_template: ControllerSinkTemplate,
1167    /// Mode selected for next activations.
1168    pub selected_mode: CaptureMode,
1169    /// Field-level capture limits override applied on top of mode defaults.
1170    pub capture_limits_override: CaptureLimitsOverride,
1171    /// Strict lifecycle behavior for next activations.
1172    pub strict_lifecycle: bool,
1173    /// Runtime sampler template for next activations.
1174    pub runtime_sampler: RuntimeSamplerTemplate,
1175    /// Policy that determines how an activation run should end.
1176    pub run_end_policy: RunEndPolicy,
1177}
1178
1179/// Sink/output template used by controller-generated runs.
1180#[derive(Debug, Clone, PartialEq, Eq)]
1181pub enum ControllerSinkTemplate {
1182    /// Write each generated run to a local JSON file.
1183    LocalJson {
1184        /// Base destination artifact path for generated runs.
1185        output_path: PathBuf,
1186    },
1187}
1188
1189/// Runtime sampler template attached to controller activation settings.
1190#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1191pub struct RuntimeSamplerTemplate {
1192    /// Enables runtime sampler startup for armed runs.
1193    pub enabled_for_armed_runs: bool,
1194    /// Optional mode override used by runtime sampler.
1195    pub mode_override: Option<CaptureMode>,
1196    /// Optional interval override in milliseconds.
1197    pub interval_ms: Option<u64>,
1198    /// Optional max runtime snapshots override.
1199    pub max_runtime_snapshots: Option<usize>,
1200}
1201
1202/// Policy for bounded activation run completion.
1203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1204pub enum RunEndPolicy {
1205    /// Keep cheap-dropping after limits are hit until manual disarm or shutdown.
1206    ContinueAfterLimitsHit,
1207    /// On first transition to `limits_hit`, stop admissions and seal/finalize the run.
1208    AutoSealOnLimitsHit,
1209}
1210
1211/// Public status snapshot for reporting controller state.
1212#[derive(Debug, Clone, PartialEq, Eq)]
1213pub struct TailtriageControllerStatus {
1214    /// Template used for the next activation generation.
1215    pub template: TailtriageControllerTemplate,
1216    /// Current generation state snapshot.
1217    pub generation: GenerationState,
1218}
1219
1220/// Current generation state for a controller.
1221#[derive(Debug, Clone, PartialEq, Eq)]
1222pub enum GenerationState {
1223    /// Controller is disarmed and has no active generation.
1224    Disabled {
1225        /// Next generation ID that would be assigned on activation.
1226        next_generation: u64,
1227    },
1228    /// Controller currently owns one active generation.
1229    Active(Box<ActiveGenerationState>),
1230}
1231
1232/// Metadata for one active generation.
1233#[derive(Debug, Clone, PartialEq, Eq)]
1234pub struct ActiveGenerationState {
1235    /// Monotonic generation identifier.
1236    pub generation_id: u64,
1237    /// Activation start timestamp.
1238    pub started_at_unix_ms: u64,
1239    /// Artifact path assigned to this generation.
1240    pub artifact_path: PathBuf,
1241    /// Whether this generation currently accepts new admissions.
1242    pub accepting_new_admissions: bool,
1243    /// Whether this generation is marked closing.
1244    pub closing: bool,
1245    /// Number of admitted captured requests still in-flight.
1246    pub inflight_captured_requests: u64,
1247    /// Whether a generation finalization attempt is currently in progress.
1248    pub finalization_in_progress: bool,
1249    /// Last finalization error observed for this generation, if any.
1250    ///
1251    /// When present, generation remains active-but-closing and callers can retry
1252    /// finalization via [`TailtriageController::disable`] or
1253    /// [`TailtriageController::shutdown`].
1254    pub last_finalize_error: Option<String>,
1255    /// Effective activation settings fixed for this generation.
1256    pub activation_config: ControllerActivationTemplate,
1257}
1258
1259/// One bounded activation template snapshot.
1260#[derive(Debug, Clone, PartialEq, Eq)]
1261pub struct ControllerActivationTemplate {
1262    /// Sink/output settings for this generation.
1263    pub sink_template: ControllerSinkTemplate,
1264    /// Core mode for this generation.
1265    pub selected_mode: CaptureMode,
1266    /// Field-level capture limit overrides for this generation.
1267    pub capture_limits_override: CaptureLimitsOverride,
1268    /// Strict lifecycle behavior for this generation.
1269    pub strict_lifecycle: bool,
1270    /// Runtime sampler settings for this generation.
1271    pub runtime_sampler: RuntimeSamplerTemplate,
1272    /// Run-end policy for this generation.
1273    pub run_end_policy: RunEndPolicy,
1274}
1275
1276#[derive(Debug)]
1277enum ControllerLifecycle {
1278    Disabled {
1279        next_generation: u64,
1280    },
1281    Active {
1282        active: Arc<ActiveGenerationRuntime>,
1283        next_generation: u64,
1284    },
1285}
1286
1287impl ControllerLifecycle {
1288    fn snapshot(&self) -> GenerationState {
1289        match self {
1290            Self::Disabled { next_generation } => GenerationState::Disabled {
1291                next_generation: *next_generation,
1292            },
1293            Self::Active { active, .. } => GenerationState::Active(Box::new(active.snapshot())),
1294        }
1295    }
1296}
1297
1298#[derive(Debug, Clone, Deserialize)]
1299struct ControllerConfigFile {
1300    controller: ControllerConfigToml,
1301}
1302
1303impl ControllerConfigFile {
1304    fn from_path(path: &Path) -> Result<Self, ConfigLoadError> {
1305        let raw = fs::read_to_string(path).map_err(|source| ConfigLoadError::Io {
1306            path: path.to_path_buf(),
1307            source,
1308        })?;
1309        toml::from_str(&raw).map_err(|source| ConfigLoadError::Parse {
1310            path: path.to_path_buf(),
1311            source: Box::new(source),
1312        })
1313    }
1314
1315    fn into_loaded(self) -> LoadedControllerConfig {
1316        let activation = self.controller.activation;
1317        let run_end_policy = activation.run_end_policy();
1318        LoadedControllerConfig {
1319            service_name: self.controller.service_name,
1320            initially_enabled: self.controller.initially_enabled,
1321            activation_template: ControllerActivationTemplate {
1322                sink_template: activation.sink.into_template(),
1323                selected_mode: activation.mode,
1324                capture_limits_override: activation.capture_limits_override,
1325                strict_lifecycle: activation.strict_lifecycle,
1326                runtime_sampler: activation.runtime_sampler,
1327                run_end_policy,
1328            },
1329        }
1330    }
1331}
1332
1333/// Parsed controller config loaded from a TOML file.
1334#[derive(Debug, Clone, PartialEq, Eq)]
1335pub struct LoadedControllerConfig {
1336    /// Optional service name override.
1337    pub service_name: Option<String>,
1338    /// Optional initially-enabled flag.
1339    pub initially_enabled: Option<bool>,
1340    /// Activation template loaded from config.
1341    pub activation_template: ControllerActivationTemplate,
1342}
1343
1344#[derive(Debug, Clone, Deserialize)]
1345struct ControllerConfigToml {
1346    service_name: Option<String>,
1347    initially_enabled: Option<bool>,
1348    activation: ControllerActivationConfigToml,
1349}
1350
1351#[derive(Debug, Clone, Deserialize)]
1352struct ControllerActivationConfigToml {
1353    mode: CaptureMode,
1354    #[serde(default)]
1355    capture_limits_override: CaptureLimitsOverride,
1356    #[serde(default)]
1357    strict_lifecycle: bool,
1358    sink: ControllerSinkTemplateToml,
1359    #[serde(default)]
1360    runtime_sampler: RuntimeSamplerTemplate,
1361    #[serde(default)]
1362    run_end_policy: RunEndPolicyConfigToml,
1363}
1364
1365#[derive(Debug, Clone, Deserialize)]
1366#[serde(tag = "type", rename_all = "snake_case")]
1367enum ControllerSinkTemplateToml {
1368    LocalJson { output_path: PathBuf },
1369}
1370
1371impl ControllerSinkTemplateToml {
1372    fn into_template(self) -> ControllerSinkTemplate {
1373        match self {
1374            Self::LocalJson { output_path } => ControllerSinkTemplate::LocalJson { output_path },
1375        }
1376    }
1377}
1378
1379#[derive(Debug, Clone, Default, Deserialize)]
1380#[serde(tag = "kind", rename_all = "snake_case")]
1381enum RunEndPolicyConfigToml {
1382    #[default]
1383    ContinueAfterLimitsHit,
1384    AutoSealOnLimitsHit,
1385}
1386
1387impl From<RunEndPolicyConfigToml> for RunEndPolicy {
1388    fn from(value: RunEndPolicyConfigToml) -> Self {
1389        match value {
1390            RunEndPolicyConfigToml::ContinueAfterLimitsHit => Self::ContinueAfterLimitsHit,
1391            RunEndPolicyConfigToml::AutoSealOnLimitsHit => Self::AutoSealOnLimitsHit,
1392        }
1393    }
1394}
1395
1396impl ControllerActivationConfigToml {
1397    fn run_end_policy(&self) -> RunEndPolicy {
1398        self.run_end_policy.clone().into()
1399    }
1400}
1401
1402/// Errors emitted while loading controller TOML config from disk.
1403#[derive(Debug)]
1404pub enum ConfigLoadError {
1405    /// Reading the config file failed.
1406    Io {
1407        /// Path that failed to read.
1408        path: PathBuf,
1409        /// Underlying I/O error.
1410        source: std::io::Error,
1411    },
1412    /// TOML parsing failed.
1413    Parse {
1414        /// Path that failed to parse.
1415        path: PathBuf,
1416        /// Underlying TOML parse error.
1417        source: Box<toml::de::Error>,
1418    },
1419}
1420
1421impl std::fmt::Display for ConfigLoadError {
1422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1423        match self {
1424            Self::Io { path, source } => {
1425                write!(
1426                    f,
1427                    "failed to read controller config {}: {source}",
1428                    path.display()
1429                )
1430            }
1431            Self::Parse { path, source } => {
1432                write!(
1433                    f,
1434                    "failed to parse controller config TOML {}: {source}",
1435                    path.display()
1436                )
1437            }
1438        }
1439    }
1440}
1441
1442impl std::error::Error for ConfigLoadError {
1443    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1444        match self {
1445            Self::Io { source, .. } => Some(source),
1446            Self::Parse { source, .. } => Some(source),
1447        }
1448    }
1449}
1450
1451/// Errors emitted while building a controller.
1452#[derive(Debug)]
1453pub enum ControllerBuildError {
1454    /// Service name was empty.
1455    EmptyServiceName,
1456    /// Config file load failed while building.
1457    ConfigLoad(ConfigLoadError),
1458    /// Initially-enabled controller failed to create first generation.
1459    InitialEnable(EnableError),
1460}
1461
1462impl std::fmt::Display for ControllerBuildError {
1463    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1464        match self {
1465            Self::EmptyServiceName => write!(f, "service_name cannot be empty"),
1466            Self::ConfigLoad(err) => write!(f, "failed to load config for build: {err}"),
1467            Self::InitialEnable(err) => write!(f, "failed to start initial generation: {err}"),
1468        }
1469    }
1470}
1471
1472impl std::error::Error for ControllerBuildError {}
1473
1474/// Errors emitted while reloading controller TOML config.
1475#[derive(Debug)]
1476pub enum ReloadConfigError {
1477    /// Reload requested but no config path is configured.
1478    MissingConfigPath,
1479    /// Loading/parsing TOML config failed.
1480    Load(ConfigLoadError),
1481    /// Parsed config produced an invalid activation template.
1482    Validate(BuildError),
1483}
1484
1485impl std::fmt::Display for ReloadConfigError {
1486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1487        match self {
1488            Self::MissingConfigPath => write!(f, "controller has no config_path; cannot reload"),
1489            Self::Load(err) => write!(f, "failed to reload controller config: {err}"),
1490            Self::Validate(err) => {
1491                write!(f, "reloaded config did not produce a valid template: {err}")
1492            }
1493        }
1494    }
1495}
1496
1497impl std::error::Error for ReloadConfigError {
1498    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1499        match self {
1500            Self::MissingConfigPath => None,
1501            Self::Load(err) => Some(err),
1502            Self::Validate(err) => Some(err),
1503        }
1504    }
1505}
1506
1507/// Errors emitted while replacing controller activation templates directly.
1508#[derive(Debug)]
1509pub enum ReloadTemplateError {
1510    /// Template failed validation against run build checks.
1511    Validate(BuildError),
1512}
1513
1514impl std::fmt::Display for ReloadTemplateError {
1515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1516        match self {
1517            Self::Validate(err) => write!(f, "template is invalid: {err}"),
1518        }
1519    }
1520}
1521
1522impl std::error::Error for ReloadTemplateError {
1523    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1524        match self {
1525            Self::Validate(err) => Some(err),
1526        }
1527    }
1528}
1529
1530/// Errors emitted when enabling/arming controller capture.
1531#[derive(Debug)]
1532pub enum EnableError {
1533    /// Another generation is already active.
1534    AlreadyActive {
1535        /// ID of the active generation blocking a new start.
1536        generation_id: u64,
1537    },
1538    /// Building the fresh bounded run failed.
1539    Build(BuildError),
1540    /// Runtime sampler was enabled but no Tokio runtime was active.
1541    MissingTokioRuntimeForSampler,
1542    /// Runtime sampler failed to start for this generation.
1543    StartRuntimeSampler(SamplerStartError),
1544}
1545
1546impl std::fmt::Display for EnableError {
1547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1548        match self {
1549            Self::AlreadyActive { generation_id } => {
1550                write!(f, "generation {generation_id} is already active")
1551            }
1552            Self::Build(err) => write!(f, "failed to build generation run: {err}"),
1553            Self::MissingTokioRuntimeForSampler => {
1554                write!(f, "runtime sampler requires an active Tokio runtime")
1555            }
1556            Self::StartRuntimeSampler(err) => {
1557                write!(f, "failed to start runtime sampler for generation: {err}")
1558            }
1559        }
1560    }
1561}
1562
1563impl std::error::Error for EnableError {}
1564
1565/// Errors emitted while disarming and finalizing generation artifacts.
1566#[derive(Debug)]
1567pub enum DisableError {
1568    /// Artifact writing failed during generation finalization.
1569    Finalize(tailtriage_core::SinkError),
1570}
1571
1572impl std::fmt::Display for DisableError {
1573    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1574        match self {
1575            Self::Finalize(err) => write!(f, "failed to finalize generation: {err}"),
1576        }
1577    }
1578}
1579
1580impl std::error::Error for DisableError {}
1581
1582/// Outcome of calling [`TailtriageController::disable`].
1583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1584pub enum DisableOutcome {
1585    /// Controller was already disarmed.
1586    AlreadyDisabled,
1587    /// Active generation is closing and will finalize once in-flight requests drain.
1588    Closing {
1589        /// Active generation ID.
1590        generation_id: u64,
1591        /// Number of admitted captured requests still in flight.
1592        inflight_captured_requests: u64,
1593    },
1594    /// Active generation finalized immediately.
1595    Finalized {
1596        /// Generation ID that was finalized.
1597        generation_id: u64,
1598    },
1599}
1600
1601/// Errors emitted during process shutdown finalization.
1602#[derive(Debug)]
1603pub enum ShutdownError {
1604    /// Active generation could not be finalized.
1605    Finalize(DisableError),
1606}
1607
1608impl std::fmt::Display for ShutdownError {
1609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1610        match self {
1611            Self::Finalize(err) => write!(f, "shutdown finalization failed: {err}"),
1612        }
1613    }
1614}
1615
1616impl std::error::Error for ShutdownError {}
1617
1618fn generated_artifact_path(template: &ControllerSinkTemplate, generation_id: u64) -> PathBuf {
1619    match template {
1620        ControllerSinkTemplate::LocalJson { output_path } => {
1621            let parent = output_path
1622                .parent()
1623                .map(Path::to_path_buf)
1624                .unwrap_or_default();
1625            let stem = output_path
1626                .file_stem()
1627                .and_then(std::ffi::OsStr::to_str)
1628                .unwrap_or("tailtriage-run");
1629            let extension = output_path.extension().and_then(std::ffi::OsStr::to_str);
1630            let filename = match extension {
1631                Some(ext) if !ext.is_empty() => format!("{stem}-generation-{generation_id}.{ext}"),
1632                _ => format!("{stem}-generation-{generation_id}.json"),
1633            };
1634            parent.join(filename)
1635        }
1636    }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641    use std::fs;
1642    use std::path::{Path, PathBuf};
1643    use std::sync::Arc;
1644    use std::time::Duration;
1645
1646    use super::{
1647        ControllerBuildError, ControllerSinkTemplate, DisableOutcome, EnableError, GenerationState,
1648        ReloadConfigError, ReloadTemplateError, RunEndPolicy, RuntimeSamplerTemplate,
1649        TailtriageController, TailtriageControllerTemplate,
1650    };
1651    use serde::Serialize;
1652    use tailtriage_core::{
1653        CaptureLimitsOverride, CaptureMode, RequestOptions, Run, RuntimeSnapshot,
1654    };
1655
1656    #[derive(Serialize)]
1657    struct TestControllerConfigToml {
1658        controller: TestControllerConfigBodyToml,
1659    }
1660
1661    #[derive(Serialize)]
1662    struct TestControllerConfigBodyToml {
1663        #[serde(skip_serializing_if = "Option::is_none")]
1664        service_name: Option<String>,
1665        #[serde(skip_serializing_if = "Option::is_none")]
1666        initially_enabled: Option<bool>,
1667        activation: TestActivationToml,
1668    }
1669
1670    #[derive(Serialize)]
1671    struct TestActivationToml {
1672        mode: &'static str,
1673        #[serde(skip_serializing_if = "Option::is_none")]
1674        capture_limits_override: Option<TestCaptureLimitsOverrideToml>,
1675        #[serde(skip_serializing_if = "Option::is_none")]
1676        strict_lifecycle: Option<bool>,
1677        sink: TestSinkToml,
1678        #[serde(skip_serializing_if = "Option::is_none")]
1679        runtime_sampler: Option<TestRuntimeSamplerToml>,
1680        #[serde(skip_serializing_if = "Option::is_none")]
1681        run_end_policy: Option<TestRunEndPolicyToml>,
1682    }
1683
1684    #[derive(Serialize)]
1685    struct TestCaptureLimitsOverrideToml {
1686        #[serde(skip_serializing_if = "Option::is_none")]
1687        max_requests: Option<u64>,
1688        #[serde(skip_serializing_if = "Option::is_none")]
1689        max_stages: Option<u64>,
1690    }
1691
1692    #[derive(Serialize)]
1693    struct TestSinkToml {
1694        #[serde(rename = "type")]
1695        sink_type: &'static str,
1696        output_path: PathBuf,
1697    }
1698
1699    #[derive(Serialize)]
1700    struct TestRuntimeSamplerToml {
1701        enabled_for_armed_runs: bool,
1702        mode_override: &'static str,
1703        interval_ms: u64,
1704        max_runtime_snapshots: u64,
1705    }
1706
1707    #[derive(Serialize)]
1708    struct TestRunEndPolicyToml {
1709        kind: &'static str,
1710    }
1711
1712    fn test_output(base: &str) -> std::path::PathBuf {
1713        let unique = format!(
1714            "tailtriage-controller-{base}-{}-{}.json",
1715            std::process::id(),
1716            tailtriage_core::unix_time_ms()
1717        );
1718        std::env::temp_dir().join(unique)
1719    }
1720
1721    fn read_artifact(path: &std::path::Path) -> String {
1722        fs::read_to_string(path).expect("artifact should be readable")
1723    }
1724
1725    fn read_run(path: &std::path::Path) -> Run {
1726        let artifact = read_artifact(path);
1727        serde_json::from_str(&artifact).expect("artifact should parse as Run")
1728    }
1729
1730    fn active_runtime(controller: &TailtriageController) -> Arc<super::ActiveGenerationRuntime> {
1731        let lifecycle = controller
1732            .inner
1733            .lifecycle
1734            .lock()
1735            .expect("controller lifecycle lock poisoned");
1736        let super::ControllerLifecycle::Active { active, .. } = &*lifecycle else {
1737            panic!("expected active generation");
1738        };
1739        Arc::clone(active)
1740    }
1741
1742    fn test_config_path(base: &str) -> std::path::PathBuf {
1743        let unique = format!(
1744            "tailtriage-controller-config-{base}-{}-{}.toml",
1745            std::process::id(),
1746            tailtriage_core::unix_time_ms()
1747        );
1748        std::env::temp_dir().join(unique)
1749    }
1750
1751    fn write_config(
1752        path: &Path,
1753        output: &Path,
1754        mode: &'static str,
1755        strict: bool,
1756        sampler_enabled: bool,
1757    ) {
1758        let content = toml::to_string(&TestControllerConfigToml {
1759            controller: TestControllerConfigBodyToml {
1760                service_name: None,
1761                initially_enabled: Some(false),
1762                activation: TestActivationToml {
1763                    mode,
1764                    capture_limits_override: Some(TestCaptureLimitsOverrideToml {
1765                        max_requests: Some(17),
1766                        max_stages: Some(18),
1767                    }),
1768                    strict_lifecycle: Some(strict),
1769                    sink: TestSinkToml {
1770                        sink_type: "local_json",
1771                        output_path: output.to_path_buf(),
1772                    },
1773                    runtime_sampler: Some(TestRuntimeSamplerToml {
1774                        enabled_for_armed_runs: sampler_enabled,
1775                        mode_override: "investigation",
1776                        interval_ms: 250,
1777                        max_runtime_snapshots: 123,
1778                    }),
1779                    run_end_policy: Some(TestRunEndPolicyToml {
1780                        kind: "auto_seal_on_limits_hit",
1781                    }),
1782                },
1783            },
1784        })
1785        .expect("config TOML serialization should succeed");
1786        fs::write(path, content).expect("config write should succeed");
1787    }
1788
1789    fn write_initially_enabled_config(path: &Path, output: &Path) {
1790        let content = toml::to_string(&TestControllerConfigToml {
1791            controller: TestControllerConfigBodyToml {
1792                service_name: Some("toml-service-name".to_owned()),
1793                initially_enabled: Some(true),
1794                activation: TestActivationToml {
1795                    mode: "investigation",
1796                    capture_limits_override: Some(TestCaptureLimitsOverrideToml {
1797                        max_requests: Some(9),
1798                        max_stages: None,
1799                    }),
1800                    strict_lifecycle: Some(true),
1801                    sink: TestSinkToml {
1802                        sink_type: "local_json",
1803                        output_path: output.to_path_buf(),
1804                    },
1805                    runtime_sampler: None,
1806                    run_end_policy: Some(TestRunEndPolicyToml {
1807                        kind: "auto_seal_on_limits_hit",
1808                    }),
1809                },
1810            },
1811        })
1812        .expect("config TOML serialization should succeed");
1813        fs::write(path, content).expect("config write should succeed");
1814    }
1815
1816    fn write_sparse_config(path: &Path, output: &Path, mode: &'static str) {
1817        let content = toml::to_string(&TestControllerConfigToml {
1818            controller: TestControllerConfigBodyToml {
1819                service_name: None,
1820                initially_enabled: None,
1821                activation: TestActivationToml {
1822                    mode,
1823                    capture_limits_override: None,
1824                    strict_lifecycle: None,
1825                    sink: TestSinkToml {
1826                        sink_type: "local_json",
1827                        output_path: output.to_path_buf(),
1828                    },
1829                    runtime_sampler: None,
1830                    run_end_policy: None,
1831                },
1832            },
1833        })
1834        .expect("config TOML serialization should succeed");
1835        fs::write(path, content).expect("config write should succeed");
1836    }
1837
1838    fn write_config_with_optional_service_name(
1839        path: &Path,
1840        output: &Path,
1841        service_name: Option<&str>,
1842    ) {
1843        let content = toml::to_string(&TestControllerConfigToml {
1844            controller: TestControllerConfigBodyToml {
1845                service_name: service_name.map(str::to_owned),
1846                initially_enabled: Some(false),
1847                activation: TestActivationToml {
1848                    mode: "light",
1849                    capture_limits_override: None,
1850                    strict_lifecycle: None,
1851                    sink: TestSinkToml {
1852                        sink_type: "local_json",
1853                        output_path: output.to_path_buf(),
1854                    },
1855                    runtime_sampler: None,
1856                    run_end_policy: None,
1857                },
1858            },
1859        })
1860        .expect("config TOML serialization should succeed");
1861        fs::write(path, content).expect("config write should succeed");
1862    }
1863
1864    fn write_raw_config(path: &std::path::Path, content: &str) {
1865        fs::write(path, content).expect("config write should succeed");
1866    }
1867
1868    #[test]
1869    fn enable_capture_disable_finalizes_generation() {
1870        let output = test_output("enable-capture-disable");
1871        let controller = TailtriageController::builder("checkout-service")
1872            .output(&output)
1873            .build()
1874            .expect("build should succeed");
1875
1876        let active = controller.enable().expect("enable should succeed");
1877        let started = controller.begin_request("/checkout");
1878        started.completion.finish_ok();
1879
1880        let disable = controller.disable().expect("disable should succeed");
1881        assert!(matches!(
1882            disable,
1883            DisableOutcome::Finalized {
1884                generation_id: id
1885            } if id == active.generation_id
1886        ));
1887
1888        let expected = output.with_file_name(format!(
1889            "{}-generation-1.json",
1890            output
1891                .file_stem()
1892                .and_then(std::ffi::OsStr::to_str)
1893                .expect("stem")
1894        ));
1895        assert!(expected.exists());
1896
1897        fs::remove_file(expected).expect("cleanup should succeed");
1898    }
1899
1900    #[test]
1901    fn active_generation_started_at_matches_underlying_run_metadata() {
1902        let output = test_output("generation-started-at-run-metadata");
1903        let controller = TailtriageController::builder("checkout-service")
1904            .output(&output)
1905            .build()
1906            .expect("build should succeed");
1907
1908        let active = controller.enable().expect("enable should succeed");
1909        let runtime = active_runtime(&controller);
1910        let run_started_at = runtime.run.snapshot().metadata.started_at_unix_ms;
1911
1912        assert_eq!(active.started_at_unix_ms, run_started_at);
1913        assert_eq!(runtime.state.started_at_unix_ms, run_started_at);
1914
1915        assert!(matches!(
1916            controller.disable(),
1917            Ok(DisableOutcome::Finalized { generation_id: 1 })
1918        ));
1919        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
1920    }
1921
1922    #[test]
1923    fn initially_enabled_build_starts_first_active_generation() {
1924        let output = test_output("initially-enabled");
1925        let controller = TailtriageController::builder("checkout-service")
1926            .initially_enabled(true)
1927            .output(&output)
1928            .build()
1929            .expect("build should succeed");
1930
1931        let status = controller.status();
1932        let active = match status.generation {
1933            GenerationState::Active(active) => active,
1934            disabled @ GenerationState::Disabled { .. } => {
1935                panic!("expected active generation after build, got {disabled:?}")
1936            }
1937        };
1938        assert_eq!(active.generation_id, 1);
1939
1940        assert!(matches!(
1941            controller.disable(),
1942            Ok(DisableOutcome::Finalized { generation_id: 1 })
1943        ));
1944        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
1945    }
1946
1947    #[test]
1948    fn disabled_status_reports_next_generation() {
1949        let controller = TailtriageController::builder("checkout-service")
1950            .build()
1951            .expect("build should succeed");
1952
1953        assert!(matches!(
1954            controller.status().generation,
1955            GenerationState::Disabled { next_generation: 1 }
1956        ));
1957    }
1958
1959    #[test]
1960    fn enable_disable_reenable_creates_distinct_generation_and_artifact() {
1961        let output = test_output("reenable");
1962        let controller = TailtriageController::builder("checkout-service")
1963            .output(&output)
1964            .build()
1965            .expect("build should succeed");
1966
1967        let first = controller.enable().expect("first enable should succeed");
1968        assert!(matches!(
1969            controller.disable(),
1970            Ok(DisableOutcome::Finalized { generation_id: 1 })
1971        ));
1972
1973        let second = controller.enable().expect("second enable should succeed");
1974        assert_eq!(first.generation_id + 1, second.generation_id);
1975        assert_ne!(first.artifact_path, second.artifact_path);
1976
1977        assert!(matches!(
1978            controller.disable(),
1979            Ok(DisableOutcome::Finalized { generation_id: 2 })
1980        ));
1981
1982        fs::remove_file(first.artifact_path).expect("cleanup first artifact should succeed");
1983        fs::remove_file(second.artifact_path).expect("cleanup second artifact should succeed");
1984    }
1985
1986    #[test]
1987    fn request_started_before_disable_can_finish_after_disable() {
1988        let output = test_output("finish-after-disable");
1989        let controller = TailtriageController::builder("checkout-service")
1990            .output(&output)
1991            .build()
1992            .expect("build should succeed");
1993
1994        let active = controller.enable().expect("enable should succeed");
1995        let started = controller.begin_request("/checkout");
1996
1997        let disable = controller.disable().expect("disable should succeed");
1998        assert!(matches!(
1999            disable,
2000            DisableOutcome::Closing {
2001                generation_id,
2002                inflight_captured_requests: 1
2003            } if generation_id == active.generation_id
2004        ));
2005
2006        started.completion.finish_ok();
2007
2008        let status = controller.status();
2009        assert!(matches!(
2010            status.generation,
2011            GenerationState::Disabled { next_generation: 2 }
2012        ));
2013        assert!(active.artifact_path.exists());
2014
2015        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2016    }
2017
2018    #[test]
2019    fn no_new_admissions_after_disable() {
2020        let output = test_output("no-admissions");
2021        let controller = TailtriageController::builder("checkout-service")
2022            .output(&output)
2023            .build()
2024            .expect("build should succeed");
2025
2026        let active = controller.enable().expect("enable should succeed");
2027        let started = controller.begin_request("/checkout");
2028
2029        let _ = controller.disable().expect("disable should succeed");
2030
2031        controller.begin_request("/checkout").completion.finish_ok();
2032
2033        started.completion.finish_ok();
2034        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2035    }
2036
2037    #[test]
2038    fn default_policy_preserves_cheap_drop_after_saturation() {
2039        let output = test_output("default-policy-cheap-drop");
2040        let controller = TailtriageController::builder("checkout-service")
2041            .output(&output)
2042            .capture_limits_override(CaptureLimitsOverride {
2043                max_requests: Some(1),
2044                ..CaptureLimitsOverride::default()
2045            })
2046            .build()
2047            .expect("build should succeed");
2048
2049        let active = controller.enable().expect("enable should succeed");
2050        controller.begin_request("/checkout").completion.finish_ok();
2051        controller.begin_request("/checkout").completion.finish_ok();
2052        controller.begin_request("/checkout").completion.finish_ok();
2053
2054        let status = controller.status();
2055        let GenerationState::Active(active_status) = status.generation else {
2056            panic!("default policy should keep generation active after saturation");
2057        };
2058        assert!(active_status.accepting_new_admissions);
2059        assert!(!active_status.closing);
2060
2061        assert!(matches!(
2062            controller.disable(),
2063            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2064        ));
2065
2066        let run = read_run(&active.artifact_path);
2067        assert!(run.truncation.limits_hit);
2068        assert_eq!(run.truncation.dropped_requests, 2);
2069        assert_eq!(
2070            run.metadata.run_end_reason,
2071            Some(tailtriage_core::RunEndReason::ManualDisarm)
2072        );
2073
2074        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2075    }
2076
2077    #[test]
2078    fn auto_seal_policy_ends_generation_after_limits_hit() {
2079        let output = test_output("auto-seal-policy");
2080        let controller = TailtriageController::builder("checkout-service")
2081            .output(&output)
2082            .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2083            .capture_limits_override(CaptureLimitsOverride {
2084                max_requests: Some(1),
2085                ..CaptureLimitsOverride::default()
2086            })
2087            .build()
2088            .expect("build should succeed");
2089
2090        let active = controller.enable().expect("enable should succeed");
2091        controller.begin_request("/checkout").completion.finish_ok();
2092        controller.begin_request("/checkout").completion.finish_ok();
2093
2094        let status = controller.status();
2095        assert!(matches!(
2096            status.generation,
2097            GenerationState::Disabled { next_generation: 2 }
2098        ));
2099
2100        let run = read_run(&active.artifact_path);
2101        assert!(run.truncation.limits_hit);
2102        assert!(run.truncation.dropped_requests > 0);
2103        assert_eq!(
2104            run.metadata.run_end_reason,
2105            Some(tailtriage_core::RunEndReason::AutoSealOnLimitsHit)
2106        );
2107
2108        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2109    }
2110
2111    #[test]
2112    fn runtime_snapshot_saturation_triggers_auto_seal() {
2113        let output = test_output("auto-seal-runtime-snapshot");
2114        let controller = TailtriageController::builder("checkout-service")
2115            .output(&output)
2116            .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2117            .capture_limits_override(CaptureLimitsOverride {
2118                max_runtime_snapshots: Some(1),
2119                ..CaptureLimitsOverride::default()
2120            })
2121            .build()
2122            .expect("build should succeed");
2123
2124        let active = controller.enable().expect("enable should succeed");
2125        let runtime = active_runtime(&controller);
2126
2127        runtime.run.record_runtime_snapshot(RuntimeSnapshot {
2128            at_unix_ms: tailtriage_core::unix_time_ms(),
2129            at_run_us: None,
2130            alive_tasks: Some(1),
2131            global_queue_depth: Some(1),
2132            local_queue_depth: Some(1),
2133            blocking_queue_depth: Some(0),
2134            remote_schedule_count: Some(1),
2135        });
2136        runtime.run.record_runtime_snapshot(RuntimeSnapshot {
2137            at_unix_ms: tailtriage_core::unix_time_ms(),
2138            at_run_us: None,
2139            alive_tasks: Some(2),
2140            global_queue_depth: Some(2),
2141            local_queue_depth: Some(2),
2142            blocking_queue_depth: Some(0),
2143            remote_schedule_count: Some(2),
2144        });
2145
2146        assert!(matches!(
2147            controller.status().generation,
2148            GenerationState::Disabled { next_generation: 2 }
2149        ));
2150        let run = read_run(&active.artifact_path);
2151        assert!(run.truncation.limits_hit);
2152        assert!(run.truncation.dropped_runtime_snapshots > 0);
2153        assert_eq!(
2154            run.metadata.run_end_reason,
2155            Some(tailtriage_core::RunEndReason::AutoSealOnLimitsHit)
2156        );
2157
2158        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2159    }
2160
2161    #[tokio::test(flavor = "current_thread")]
2162    async fn queue_saturation_triggers_auto_seal_and_waits_for_inflight_drain() {
2163        let output = test_output("auto-seal-queue-saturation");
2164        let controller = TailtriageController::builder("checkout-service")
2165            .output(&output)
2166            .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2167            .capture_limits_override(CaptureLimitsOverride {
2168                max_queues: Some(1),
2169                ..CaptureLimitsOverride::default()
2170            })
2171            .build()
2172            .expect("build should succeed");
2173
2174        let active = controller.enable().expect("enable should succeed");
2175        let started = controller.begin_request("/checkout");
2176        let request = started.handle.clone();
2177        request
2178            .queue("primary")
2179            .with_depth_at_start(1)
2180            .await_on(async {})
2181            .await;
2182        request
2183            .queue("primary")
2184            .with_depth_at_start(2)
2185            .await_on(async {})
2186            .await;
2187
2188        let status = controller.status();
2189        let GenerationState::Active(active_status) = status.generation else {
2190            panic!("generation should remain active while admitted request is still in-flight");
2191        };
2192        assert!(active_status.closing);
2193        assert!(!active_status.accepting_new_admissions);
2194
2195        started.completion.finish_ok();
2196
2197        assert!(matches!(
2198            controller.status().generation,
2199            GenerationState::Disabled { next_generation: 2 }
2200        ));
2201        let run = read_run(&active.artifact_path);
2202        assert!(run.truncation.limits_hit);
2203        assert!(run.truncation.dropped_queues > 0);
2204        assert_eq!(
2205            run.metadata.run_end_reason,
2206            Some(tailtriage_core::RunEndReason::AutoSealOnLimitsHit)
2207        );
2208
2209        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2210    }
2211
2212    #[test]
2213    fn auto_seal_then_next_enable_creates_fresh_generation() {
2214        let output = test_output("auto-seal-next-generation");
2215        let controller = TailtriageController::builder("checkout-service")
2216            .output(&output)
2217            .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2218            .capture_limits_override(CaptureLimitsOverride {
2219                max_requests: Some(1),
2220                ..CaptureLimitsOverride::default()
2221            })
2222            .build()
2223            .expect("build should succeed");
2224
2225        let first = controller.enable().expect("first enable should succeed");
2226        controller.begin_request("/checkout").completion.finish_ok();
2227        controller.begin_request("/checkout").completion.finish_ok();
2228        assert!(matches!(
2229            controller.status().generation,
2230            GenerationState::Disabled { next_generation: 2 }
2231        ));
2232
2233        let second = controller.enable().expect("second enable should succeed");
2234        assert_eq!(second.generation_id, first.generation_id + 1);
2235        controller.begin_request("/checkout").completion.finish_ok();
2236        assert!(matches!(
2237            controller.disable(),
2238            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == second.generation_id
2239        ));
2240
2241        fs::remove_file(first.artifact_path).expect("cleanup first should succeed");
2242        fs::remove_file(second.artifact_path).expect("cleanup second should succeed");
2243    }
2244
2245    #[test]
2246    fn one_active_generation_at_a_time() {
2247        let controller = TailtriageController::builder("checkout-service")
2248            .build()
2249            .expect("build should succeed");
2250
2251        let first = controller.enable().expect("first enable should succeed");
2252        let err = controller
2253            .enable()
2254            .expect_err("second enable should fail while first generation active");
2255
2256        assert!(matches!(
2257            err,
2258            EnableError::AlreadyActive {
2259                generation_id
2260            } if generation_id == first.generation_id
2261        ));
2262
2263        assert!(matches!(
2264            controller.disable(),
2265            Ok(DisableOutcome::Finalized { .. })
2266        ));
2267        fs::remove_file(first.artifact_path).expect("cleanup should succeed");
2268    }
2269
2270    #[test]
2271    fn request_completion_remains_bound_to_original_generation_after_reenable() {
2272        let output = test_output("generation-binding");
2273        let controller = TailtriageController::builder("checkout-service")
2274            .output(&output)
2275            .build()
2276            .expect("build should succeed");
2277
2278        let gen_a = controller.enable().expect("generation A should enable");
2279        let started_a = controller.begin_request_with(
2280            "/checkout",
2281            RequestOptions::new().request_id("req-generation-a"),
2282        );
2283
2284        assert!(matches!(
2285            controller.disable(),
2286            Ok(DisableOutcome::Closing {
2287                generation_id,
2288                inflight_captured_requests: 1
2289            }) if generation_id == gen_a.generation_id
2290        ));
2291
2292        started_a.completion.finish_ok();
2293
2294        let gen_b = controller.enable().expect("generation B should enable");
2295        let started_b = controller.begin_request_with(
2296            "/checkout",
2297            RequestOptions::new().request_id("req-generation-b"),
2298        );
2299        started_b.completion.finish_ok();
2300        assert!(matches!(
2301            controller.disable(),
2302            Ok(DisableOutcome::Finalized { generation_id })
2303            if generation_id == gen_b.generation_id
2304        ));
2305
2306        let run_a = read_artifact(&gen_a.artifact_path);
2307        let run_b = read_artifact(&gen_b.artifact_path);
2308        assert!(run_a.contains("req-generation-a"));
2309        assert!(!run_a.contains("req-generation-b"));
2310        assert!(run_b.contains("req-generation-b"));
2311        assert!(!run_b.contains("req-generation-a"));
2312
2313        fs::remove_file(gen_a.artifact_path).expect("cleanup generation A should succeed");
2314        fs::remove_file(gen_b.artifact_path).expect("cleanup generation B should succeed");
2315    }
2316
2317    #[test]
2318    fn disabled_begin_request_is_inert_and_never_joins_later_generation() {
2319        let output = test_output("disabled-admission");
2320        let controller = TailtriageController::builder("checkout-service")
2321            .output(&output)
2322            .build()
2323            .expect("build should succeed");
2324
2325        let disabled_started = controller.begin_request_with(
2326            "/checkout",
2327            RequestOptions::new().request_id("req-disabled"),
2328        );
2329        assert_eq!(disabled_started.handle.request_id(), "req-disabled");
2330        disabled_started.completion.finish_ok();
2331
2332        let active = controller.enable().expect("enable should succeed");
2333        let started = controller
2334            .begin_request_with("/checkout", RequestOptions::new().request_id("req-enabled"));
2335        started.completion.finish_ok();
2336        assert!(matches!(
2337            controller.disable(),
2338            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2339        ));
2340
2341        let run = read_artifact(&active.artifact_path);
2342        assert!(run.contains("req-enabled"));
2343        assert!(!run.contains("req-disabled"));
2344
2345        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2346    }
2347
2348    #[test]
2349    fn disabled_handle_and_completion_operations_are_noop() {
2350        let output = test_output("disabled-noop");
2351        let controller = TailtriageController::builder("checkout-service")
2352            .output(&output)
2353            .build()
2354            .expect("build should succeed");
2355
2356        let started = controller.begin_request_with(
2357            "/checkout",
2358            RequestOptions::new()
2359                .request_id("req-disabled-noop")
2360                .kind("http"),
2361        );
2362
2363        assert_eq!(started.handle.request_id(), "req-disabled-noop");
2364        assert_eq!(started.handle.route(), "/checkout");
2365        assert_eq!(started.handle.kind(), Some("http"));
2366        let request = started.handle.clone();
2367        let _inflight = request.inflight("inflight-disabled");
2368        let _queue = request.queue("queue-disabled");
2369        let _stage = request.stage("stage-disabled");
2370        started
2371            .completion
2372            .finish_result::<(), &str>(Err("disabled-result"))
2373            .expect_err("disabled result should pass through unchanged");
2374
2375        let active = controller.enable().expect("enable should succeed");
2376        let enabled_started = controller
2377            .begin_request_with("/checkout", RequestOptions::new().request_id("req-enabled"));
2378        enabled_started.completion.finish_ok();
2379        assert!(matches!(
2380            controller.disable(),
2381            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2382        ));
2383
2384        let run = read_artifact(&active.artifact_path);
2385        assert!(run.contains("req-enabled"));
2386        assert!(!run.contains("req-disabled-noop"));
2387
2388        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2389    }
2390
2391    #[test]
2392    fn inert_disabled_request_id_contract_preserves_explicit_and_generates_fallback() {
2393        let output = test_output("inert-disabled-request-id");
2394        let controller = TailtriageController::builder("checkout-service")
2395            .output(&output)
2396            .build()
2397            .expect("build should succeed");
2398
2399        let explicit = controller.begin_request_with(
2400            "/checkout",
2401            RequestOptions::new().request_id("req-disabled-explicit"),
2402        );
2403        assert_eq!(explicit.handle.request_id(), "req-disabled-explicit");
2404
2405        let implicit_a = controller.begin_request("/checkout");
2406        let implicit_b = controller.begin_request("/checkout");
2407        assert!(implicit_a.handle.request_id().starts_with("inert-"));
2408        assert!(implicit_b.handle.request_id().starts_with("inert-"));
2409        assert_ne!(
2410            implicit_a.handle.request_id(),
2411            implicit_b.handle.request_id()
2412        );
2413    }
2414
2415    #[test]
2416    fn inert_closing_request_id_contract_preserves_explicit_and_generates_fallback() {
2417        let output = test_output("inert-closing-request-id");
2418        let controller = TailtriageController::builder("checkout-service")
2419            .output(&output)
2420            .build()
2421            .expect("build should succeed");
2422
2423        let active = controller.enable().expect("enable should succeed");
2424        let admitted = controller.begin_request("/checkout");
2425        assert!(matches!(
2426            controller.disable(),
2427            Ok(DisableOutcome::Closing { .. })
2428        ));
2429
2430        let explicit = controller.begin_request_with(
2431            "/checkout",
2432            RequestOptions::new().request_id("req-closing-explicit"),
2433        );
2434        assert_eq!(explicit.handle.request_id(), "req-closing-explicit");
2435
2436        let implicit = controller.begin_request("/checkout");
2437        assert!(implicit.handle.request_id().starts_with("inert-"));
2438
2439        admitted.completion.finish_ok();
2440        assert!(matches!(
2441            controller.status().generation,
2442            GenerationState::Disabled { .. }
2443        ));
2444        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2445    }
2446
2447    #[test]
2448    fn rapid_enable_disable_boundaries_keep_generation_isolation() {
2449        let output = test_output("rapid-boundaries");
2450        let controller = TailtriageController::builder("checkout-service")
2451            .output(&output)
2452            .build()
2453            .expect("build should succeed");
2454
2455        let mut artifacts = Vec::new();
2456        for generation in 1..=3 {
2457            let active = controller.enable().expect("enable should succeed");
2458            assert_eq!(active.generation_id, generation);
2459
2460            let started = controller.begin_request_with(
2461                "/checkout",
2462                RequestOptions::new().request_id(format!("req-gen-{generation}")),
2463            );
2464
2465            assert!(matches!(
2466                controller.disable(),
2467                Ok(DisableOutcome::Closing {
2468                    generation_id,
2469                    inflight_captured_requests: 1
2470                }) if generation_id == generation
2471            ));
2472
2473            assert!(
2474                matches!(
2475                    controller.enable(),
2476                    Err(EnableError::AlreadyActive { generation_id }) if generation_id == generation
2477                ),
2478                "controller must not start next generation before admitted requests drain"
2479            );
2480
2481            started.completion.finish_ok();
2482            artifacts.push(active.artifact_path);
2483        }
2484
2485        for (idx, artifact) in artifacts.iter().enumerate() {
2486            let run = read_artifact(artifact);
2487            assert!(run.contains(&format!("req-gen-{}", idx + 1)));
2488            fs::remove_file(artifact).expect("cleanup should succeed");
2489        }
2490    }
2491
2492    #[test]
2493    fn completion_drain_finalizes_once_without_duplicate_side_effects() {
2494        let output = test_output("single-finalize");
2495        let controller = TailtriageController::builder("checkout-service")
2496            .output(&output)
2497            .build()
2498            .expect("build should succeed");
2499
2500        let active = controller.enable().expect("enable should succeed");
2501        let started = controller
2502            .begin_request_with("/checkout", RequestOptions::new().request_id("req-once"));
2503
2504        assert!(matches!(
2505            controller.disable(),
2506            Ok(DisableOutcome::Closing {
2507                generation_id,
2508                inflight_captured_requests: 1
2509            }) if generation_id == active.generation_id
2510        ));
2511
2512        started.completion.finish_ok();
2513        assert!(matches!(
2514            controller.disable(),
2515            Ok(DisableOutcome::AlreadyDisabled)
2516        ));
2517        assert!(matches!(controller.shutdown(), Ok(())));
2518
2519        let run = read_artifact(&active.artifact_path);
2520        assert_eq!(run.matches("req-once").count(), 1);
2521
2522        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2523    }
2524
2525    #[test]
2526    fn shutdown_active_generation_finalizes_and_disables_even_with_inflight_request() {
2527        let output = test_output("shutdown-active");
2528        let controller = TailtriageController::builder("checkout-service")
2529            .output(&output)
2530            .build()
2531            .expect("build should succeed");
2532
2533        let active = controller.enable().expect("enable should succeed");
2534        let started = controller.begin_request_with(
2535            "/checkout",
2536            RequestOptions::new().request_id("req-inflight-shutdown"),
2537        );
2538
2539        controller.shutdown().expect("shutdown should succeed");
2540        assert!(matches!(
2541            controller.status().generation,
2542            GenerationState::Disabled { next_generation: 2 }
2543        ));
2544        assert!(active.artifact_path.exists());
2545
2546        let run = read_run(&active.artifact_path);
2547        assert_eq!(
2548            run.metadata.run_end_reason,
2549            Some(tailtriage_core::RunEndReason::Shutdown)
2550        );
2551
2552        controller
2553            .begin_request_with(
2554                "/checkout",
2555                RequestOptions::new().request_id("req-post-shutdown"),
2556            )
2557            .completion
2558            .finish_ok();
2559
2560        let run_after = read_artifact(&active.artifact_path);
2561        assert!(!run_after.contains("req-post-shutdown"));
2562
2563        started.completion.finish_ok();
2564        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2565    }
2566
2567    #[test]
2568    fn drain_finalization_sink_failure_is_observable_and_retriable() {
2569        let output = std::env::temp_dir().join(format!(
2570            "tailtriage-controller-missing-dir-{}-{}",
2571            std::process::id(),
2572            tailtriage_core::unix_time_ms()
2573        ));
2574        let missing_output = output.join("artifact.json");
2575        let controller = TailtriageController::builder("checkout-service")
2576            .output(&missing_output)
2577            .build()
2578            .expect("build should succeed");
2579
2580        let active = controller.enable().expect("enable should succeed");
2581        let started = controller.begin_request("/checkout");
2582        assert!(matches!(
2583            controller.disable(),
2584            Ok(DisableOutcome::Closing {
2585                generation_id,
2586                inflight_captured_requests: 1
2587            }) if generation_id == active.generation_id
2588        ));
2589
2590        started.completion.finish_ok();
2591
2592        let status = controller.status();
2593        let GenerationState::Active(active_state) = status.generation else {
2594            panic!("generation should stay active after failed drain finalization");
2595        };
2596        assert!(active_state.closing);
2597        assert!(!active_state.accepting_new_admissions);
2598        assert!(!active_state.finalization_in_progress);
2599        let first_error = active_state
2600            .last_finalize_error
2601            .expect("failed drain finalization should be recorded");
2602        assert!(
2603            first_error.contains("failed to finalize generation"),
2604            "unexpected error message: {first_error}"
2605        );
2606
2607        let disable_retry = controller.disable();
2608        assert!(
2609            matches!(disable_retry, Err(super::DisableError::Finalize(_))),
2610            "disable should return finalization failure after prior failed drain finalization"
2611        );
2612
2613        let shutdown_retry = controller.shutdown();
2614        assert!(
2615            matches!(
2616                shutdown_retry,
2617                Err(super::ShutdownError::Finalize(
2618                    super::DisableError::Finalize(_)
2619                ))
2620            ),
2621            "shutdown should return finalization failure after prior failed drain finalization"
2622        );
2623    }
2624
2625    #[test]
2626    fn drain_finalization_strict_lifecycle_failure_is_observable_and_retriable() {
2627        let output = test_output("strict-drain-failure");
2628        let controller = TailtriageController::builder("checkout-service")
2629            .output(&output)
2630            .strict_lifecycle(true)
2631            .build()
2632            .expect("build should succeed");
2633        let active = controller.enable().expect("enable should succeed");
2634
2635        let runtime = active_runtime(&controller);
2636        let leaked = runtime.run.begin_request("/leaked");
2637        let started = controller.begin_request("/checkout");
2638        assert!(matches!(
2639            controller.disable(),
2640            Ok(DisableOutcome::Closing {
2641                generation_id,
2642                inflight_captured_requests: 1
2643            }) if generation_id == active.generation_id
2644        ));
2645
2646        started.completion.finish_ok();
2647
2648        let status = controller.status();
2649        let GenerationState::Active(active_state) = status.generation else {
2650            panic!("strict lifecycle drain failure should keep generation active");
2651        };
2652        assert!(active_state.closing);
2653        assert_eq!(active_state.inflight_captured_requests, 0);
2654        let error = active_state
2655            .last_finalize_error
2656            .expect("strict lifecycle error should be reported");
2657        assert!(
2658            error.contains("strict lifecycle validation failed"),
2659            "unexpected strict lifecycle error message: {error}"
2660        );
2661
2662        assert!(matches!(
2663            controller.disable(),
2664            Err(super::DisableError::Finalize(
2665                tailtriage_core::SinkError::Lifecycle {
2666                    unfinished_count: 1
2667                }
2668            ))
2669        ));
2670
2671        leaked.completion.finish_ok();
2672        assert!(matches!(
2673            controller.disable(),
2674            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2675        ));
2676        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2677    }
2678
2679    #[test]
2680    fn drain_finalization_failure_allows_recovery_after_environment_fix() {
2681        let output_dir = std::env::temp_dir().join(format!(
2682            "tailtriage-controller-recovery-dir-{}-{}",
2683            std::process::id(),
2684            tailtriage_core::unix_time_ms()
2685        ));
2686        let output = output_dir.join("artifact.json");
2687        let controller = TailtriageController::builder("checkout-service")
2688            .output(&output)
2689            .build()
2690            .expect("build should succeed");
2691
2692        let active = controller.enable().expect("enable should succeed");
2693        let started = controller.begin_request("/checkout");
2694        assert!(matches!(
2695            controller.disable(),
2696            Ok(DisableOutcome::Closing {
2697                generation_id,
2698                inflight_captured_requests: 1
2699            }) if generation_id == active.generation_id
2700        ));
2701        started.completion.finish_ok();
2702
2703        let status_before_retry = controller.status();
2704        let GenerationState::Active(active_before_retry) = status_before_retry.generation else {
2705            panic!("failed drain finalization should keep generation active");
2706        };
2707        assert!(active_before_retry.last_finalize_error.is_some());
2708
2709        fs::create_dir_all(&output_dir).expect("create output directory for retry should succeed");
2710
2711        assert!(matches!(
2712            controller.disable(),
2713            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2714        ));
2715        assert!(output_dir.join("artifact-generation-1.json").exists());
2716        fs::remove_file(output_dir.join("artifact-generation-1.json"))
2717            .expect("cleanup artifact should succeed");
2718        fs::remove_dir(output_dir).expect("cleanup output dir should succeed");
2719    }
2720
2721    #[test]
2722    fn toml_parsing_success_and_failure() {
2723        let output = test_output("toml-parse");
2724        let config = test_config_path("toml-parse");
2725        write_config(&config, &output, "light", false, true);
2726
2727        let loaded =
2728            TailtriageController::load_config_from_path(&config).expect("valid TOML should parse");
2729        assert_eq!(loaded.activation_template.selected_mode, CaptureMode::Light);
2730        assert_eq!(
2731            loaded.activation_template.capture_limits_override,
2732            CaptureLimitsOverride {
2733                max_requests: Some(17),
2734                max_stages: Some(18),
2735                max_queues: None,
2736                max_inflight_snapshots: None,
2737                max_runtime_snapshots: None,
2738            }
2739        );
2740        assert!(
2741            loaded
2742                .activation_template
2743                .runtime_sampler
2744                .enabled_for_armed_runs
2745        );
2746        assert_eq!(
2747            loaded.activation_template.run_end_policy,
2748            RunEndPolicy::AutoSealOnLimitsHit
2749        );
2750
2751        fs::write(&config, "[controller\n").expect("invalid TOML write should succeed");
2752        assert!(TailtriageController::load_config_from_path(&config).is_err());
2753
2754        fs::remove_file(config).expect("config cleanup should succeed");
2755    }
2756
2757    #[test]
2758    fn toml_parses_windows_style_escaped_output_path() {
2759        let config_toml = r#"[controller]
2760
2761[controller.activation]
2762mode = "light"
2763   
2764[controller.activation.sink]
2765type = "local_json"
2766output_path = "C:\\Users\\someone\\AppData\\Local\\Temp\\tailtriage.json"
2767"#;
2768
2769        let parsed: super::ControllerConfigFile =
2770            toml::from_str(config_toml).expect("escaped Windows path should parse in TOML");
2771
2772        let loaded = parsed.into_loaded();
2773        assert_eq!(
2774            loaded.activation_template.sink_template,
2775            ControllerSinkTemplate::LocalJson {
2776                output_path: PathBuf::from(r"C:\Users\someone\AppData\Local\Temp\tailtriage.json"),
2777            }
2778        );
2779    }
2780
2781    #[test]
2782    fn reload_updates_next_activation_template_only() {
2783        let output_before = test_output("reload-template-before");
2784        let output_after = test_output("reload-template-after");
2785        let config = test_config_path("reload-template");
2786        write_config(&config, &output_before, "light", false, false);
2787
2788        let controller = TailtriageController::builder("checkout-service")
2789            .config_path(&config)
2790            .build()
2791            .expect("build should succeed");
2792        assert_eq!(
2793            controller.status().template.selected_mode,
2794            CaptureMode::Light
2795        );
2796
2797        write_config(&config, &output_after, "investigation", true, false);
2798        controller.reload_config().expect("reload should succeed");
2799
2800        let status = controller.status();
2801        assert_eq!(status.template.selected_mode, CaptureMode::Investigation);
2802        assert!(status.template.strict_lifecycle);
2803        assert_eq!(
2804            status.template.run_end_policy,
2805            RunEndPolicy::AutoSealOnLimitsHit
2806        );
2807
2808        fs::remove_file(config).expect("config cleanup should succeed");
2809    }
2810
2811    #[test]
2812    fn try_reload_template_validates_before_enable() {
2813        let output = test_output("try-reload-template-validate");
2814        let controller = TailtriageController::builder("checkout-service")
2815            .output(&output)
2816            .build()
2817            .expect("build should succeed");
2818
2819        let invalid = TailtriageControllerTemplate {
2820            service_name: String::new(),
2821            config_path: None,
2822            sink_template: ControllerSinkTemplate::LocalJson {
2823                output_path: output,
2824            },
2825            selected_mode: CaptureMode::Light,
2826            capture_limits_override: CaptureLimitsOverride::default(),
2827            strict_lifecycle: false,
2828            runtime_sampler: RuntimeSamplerTemplate::default(),
2829            run_end_policy: RunEndPolicy::ContinueAfterLimitsHit,
2830        };
2831
2832        assert!(matches!(
2833            controller.try_reload_template(invalid),
2834            Err(ReloadTemplateError::Validate(_))
2835        ));
2836    }
2837
2838    #[test]
2839    fn reload_config_validates_template_before_enable() {
2840        let output = test_output("reload-config-validate");
2841        let config = test_config_path("reload-config-validate");
2842        write_config(&config, &output, "light", false, false);
2843
2844        let controller = TailtriageController::builder("checkout-service")
2845            .config_path(&config)
2846            .build()
2847            .expect("build should succeed");
2848
2849        fs::write(
2850            &config,
2851            r#"[controller]
2852service_name = ""
2853
2854[controller.activation]
2855mode = "light"
2856strict_lifecycle = false
2857
2858[controller.activation.capture_limits_override]
2859max_requests = 17
2860max_stages = 18
2861
2862[controller.activation.sink]
2863type = "local_json"
2864output_path = "tailtriage-run.json"
2865
2866[controller.activation.runtime_sampler]
2867enabled_for_armed_runs = false
2868
2869[controller.activation.run_end_policy]
2870kind = "continue_after_limits_hit"
2871"#,
2872        )
2873        .expect("invalid config write should succeed");
2874
2875        assert!(matches!(
2876            controller.reload_config(),
2877            Err(ReloadConfigError::Validate(_))
2878        ));
2879
2880        fs::remove_file(config).expect("config cleanup should succeed");
2881    }
2882
2883    #[test]
2884    fn controller_recovers_after_poisoned_lifecycle_lock() {
2885        let output = test_output("poisoned-lock-recovery");
2886        let controller = TailtriageController::builder("checkout-service")
2887            .output(&output)
2888            .build()
2889            .expect("build should succeed");
2890
2891        let _ = std::panic::catch_unwind({
2892            let controller = controller.clone();
2893            move || {
2894                let _guard = controller
2895                    .inner
2896                    .lifecycle
2897                    .lock()
2898                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2899                panic!("intentional poison");
2900            }
2901        });
2902
2903        let status = controller.status();
2904        assert_eq!(status.template.service_name, "checkout-service");
2905        assert!(matches!(
2906            status.generation,
2907            GenerationState::Disabled { .. }
2908        ));
2909    }
2910
2911    #[test]
2912    fn active_generation_keeps_original_config_after_reload() {
2913        let output_before = test_output("active-keeps-before");
2914        let output_after = test_output("active-keeps-after");
2915        let config = test_config_path("active-keeps");
2916        write_config(&config, &output_before, "light", false, false);
2917
2918        let controller = TailtriageController::builder("checkout-service")
2919            .config_path(&config)
2920            .build()
2921            .expect("build should succeed");
2922
2923        let gen1 = controller.enable().expect("first enable should succeed");
2924        assert_eq!(gen1.activation_config.selected_mode, CaptureMode::Light);
2925        assert_eq!(
2926            gen1.activation_config.sink_template,
2927            super::ControllerSinkTemplate::LocalJson {
2928                output_path: output_before.clone()
2929            }
2930        );
2931
2932        write_config(&config, &output_after, "investigation", true, false);
2933        controller.reload_config().expect("reload should succeed");
2934
2935        let GenerationState::Active(active_after_reload) = controller.status().generation else {
2936            panic!("expected active generation");
2937        };
2938        assert_eq!(
2939            active_after_reload.activation_config.selected_mode,
2940            CaptureMode::Light
2941        );
2942        assert!(!active_after_reload.activation_config.strict_lifecycle);
2943
2944        let started = controller.begin_request("/checkout");
2945        started.completion.finish_ok();
2946        assert!(matches!(
2947            controller.disable(),
2948            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == gen1.generation_id
2949        ));
2950
2951        let gen2 = controller.enable().expect("second enable should succeed");
2952        assert_eq!(
2953            gen2.activation_config.selected_mode,
2954            CaptureMode::Investigation
2955        );
2956        assert!(gen2.activation_config.strict_lifecycle);
2957        assert_eq!(
2958            gen2.activation_config.sink_template,
2959            super::ControllerSinkTemplate::LocalJson {
2960                output_path: output_after.clone()
2961            }
2962        );
2963
2964        assert!(matches!(
2965            controller.disable(),
2966            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == gen2.generation_id
2967        ));
2968
2969        fs::remove_file(gen1.artifact_path).expect("cleanup gen1 should succeed");
2970        fs::remove_file(gen2.artifact_path).expect("cleanup gen2 should succeed");
2971        fs::remove_file(config).expect("config cleanup should succeed");
2972    }
2973
2974    #[test]
2975    fn build_from_toml_initially_enabled_starts_generation_with_toml_activation_settings() {
2976        let output = test_output("toml-initially-enabled");
2977        let config = test_config_path("toml-initially-enabled");
2978        write_initially_enabled_config(&config, &output);
2979
2980        let controller = TailtriageController::builder("builder-service-name")
2981            .initially_enabled(false)
2982            .strict_lifecycle(false)
2983            .config_path(&config)
2984            .build()
2985            .expect("build should succeed");
2986
2987        let status = controller.status();
2988        let GenerationState::Active(active) = status.generation else {
2989            panic!("config with initially_enabled=true should start generation 1");
2990        };
2991        assert_eq!(active.generation_id, 1);
2992        assert_eq!(
2993            active.activation_config.selected_mode,
2994            CaptureMode::Investigation
2995        );
2996        assert!(active.activation_config.strict_lifecycle);
2997        assert_eq!(
2998            active.activation_config.run_end_policy,
2999            RunEndPolicy::AutoSealOnLimitsHit
3000        );
3001        assert_eq!(
3002            active.activation_config.sink_template,
3003            ControllerSinkTemplate::LocalJson {
3004                output_path: output.clone()
3005            }
3006        );
3007        assert_eq!(
3008            active.activation_config.runtime_sampler,
3009            RuntimeSamplerTemplate::default()
3010        );
3011        assert_eq!(
3012            active.activation_config.capture_limits_override,
3013            CaptureLimitsOverride {
3014                max_requests: Some(9),
3015                ..CaptureLimitsOverride::default()
3016            }
3017        );
3018        assert_eq!(status.template.service_name, "toml-service-name");
3019        assert_eq!(
3020            active.artifact_path,
3021            output.with_file_name(format!(
3022                "{}-generation-1.json",
3023                output
3024                    .file_stem()
3025                    .and_then(std::ffi::OsStr::to_str)
3026                    .expect("stem")
3027            ))
3028        );
3029
3030        assert!(matches!(
3031            controller.disable(),
3032            Ok(DisableOutcome::Finalized { generation_id: 1 })
3033        ));
3034        let run = read_run(&active.artifact_path);
3035        assert_eq!(
3036            run.metadata.run_end_reason,
3037            Some(tailtriage_core::RunEndReason::ManualDisarm)
3038        );
3039
3040        fs::remove_file(active.artifact_path).expect("artifact cleanup should succeed");
3041        fs::remove_file(config).expect("config cleanup should succeed");
3042    }
3043
3044    #[test]
3045    fn enable_with_sampler_without_tokio_runtime_returns_missing_runtime_error() {
3046        let output = test_output("missing-runtime");
3047        let expected_artifact = output.with_file_name(format!(
3048            "{}-generation-1.json",
3049            output
3050                .file_stem()
3051                .and_then(std::ffi::OsStr::to_str)
3052                .expect("stem")
3053        ));
3054        let controller = TailtriageController::builder("checkout-service")
3055            .output(&output)
3056            .runtime_sampler(RuntimeSamplerTemplate {
3057                enabled_for_armed_runs: true,
3058                mode_override: None,
3059                interval_ms: Some(20),
3060                max_runtime_snapshots: Some(10),
3061            })
3062            .build()
3063            .expect("build should succeed");
3064
3065        let err = controller
3066            .enable()
3067            .expect_err("enable should fail without runtime");
3068        assert!(matches!(err, EnableError::MissingTokioRuntimeForSampler));
3069        assert!(matches!(
3070            controller.status().generation,
3071            GenerationState::Disabled { next_generation: 1 }
3072        ));
3073        assert!(!expected_artifact.exists());
3074    }
3075
3076    #[test]
3077    fn sparse_toml_uses_builder_fallbacks_and_activation_defaults() {
3078        let output = test_output("sparse-toml-defaults");
3079        let config = test_config_path("sparse-toml-defaults");
3080        write_sparse_config(&config, &output, "investigation");
3081
3082        let controller = TailtriageController::builder("builder-service-name")
3083            .initially_enabled(true)
3084            .config_path(&config)
3085            .build()
3086            .expect("build should succeed");
3087
3088        let status = controller.status();
3089        assert_eq!(status.template.service_name, "builder-service-name");
3090        let GenerationState::Active(active) = status.generation else {
3091            panic!("builder initially_enabled should be preserved when TOML omits it");
3092        };
3093        assert_eq!(active.generation_id, 1);
3094        assert_eq!(
3095            active.activation_config.selected_mode,
3096            CaptureMode::Investigation
3097        );
3098        assert!(!active.activation_config.strict_lifecycle);
3099        assert_eq!(
3100            active.activation_config.runtime_sampler,
3101            RuntimeSamplerTemplate::default()
3102        );
3103        assert_eq!(
3104            active.activation_config.run_end_policy,
3105            RunEndPolicy::ContinueAfterLimitsHit
3106        );
3107        assert_eq!(
3108            active.activation_config.capture_limits_override,
3109            CaptureLimitsOverride::default()
3110        );
3111        assert_eq!(
3112            active.activation_config.sink_template,
3113            ControllerSinkTemplate::LocalJson {
3114                output_path: output.clone()
3115            }
3116        );
3117
3118        assert!(matches!(
3119            controller.disable(),
3120            Ok(DisableOutcome::Finalized { generation_id: 1 })
3121        ));
3122        fs::remove_file(active.artifact_path).expect("artifact cleanup should succeed");
3123        fs::remove_file(config).expect("config cleanup should succeed");
3124    }
3125
3126    #[test]
3127    fn build_with_missing_config_path_returns_config_load_error() {
3128        let config = test_config_path("missing-config-build");
3129        assert!(!config.exists());
3130
3131        let err = TailtriageController::builder("checkout-service")
3132            .config_path(&config)
3133            .build()
3134            .expect_err("build should fail for missing config path");
3135        assert!(matches!(
3136            err,
3137            ControllerBuildError::ConfigLoad(super::ConfigLoadError::Io { .. })
3138        ));
3139    }
3140
3141    #[test]
3142    fn config_service_name_overrides_builder_service_name_when_present() {
3143        let output = test_output("build-config-service-name-overrides");
3144        let config = test_config_path("build-config-service-name-overrides");
3145        write_config_with_optional_service_name(&config, &output, Some("toml-service-name"));
3146
3147        let controller = TailtriageController::builder("builder-service-name")
3148            .config_path(&config)
3149            .build()
3150            .expect("build should succeed");
3151        assert_eq!(
3152            controller.status().template.service_name,
3153            "toml-service-name"
3154        );
3155
3156        fs::remove_file(config).expect("config cleanup should succeed");
3157    }
3158
3159    #[test]
3160    fn blank_builder_service_name_uses_non_blank_toml_service_name() {
3161        let output = test_output("build-blank-builder-uses-toml");
3162        let config = test_config_path("build-blank-builder-uses-toml");
3163        write_config_with_optional_service_name(&config, &output, Some("toml-service-name"));
3164
3165        let controller = TailtriageController::builder("   ")
3166            .config_path(&config)
3167            .build()
3168            .expect("build should succeed");
3169        assert_eq!(
3170            controller.status().template.service_name,
3171            "toml-service-name"
3172        );
3173
3174        fs::remove_file(config).expect("config cleanup should succeed");
3175    }
3176
3177    #[test]
3178    fn blank_builder_service_name_without_config_fails_build() {
3179        let err = TailtriageController::builder("   ")
3180            .build()
3181            .expect_err("blank builder service_name without config should fail");
3182        assert!(matches!(err, ControllerBuildError::EmptyServiceName));
3183    }
3184
3185    #[test]
3186    fn blank_builder_and_blank_toml_service_name_fail_build() {
3187        let output = test_output("build-blank-builder-blank-toml");
3188        let config = test_config_path("build-blank-builder-blank-toml");
3189        write_config_with_optional_service_name(&config, &output, Some(""));
3190
3191        let err = TailtriageController::builder("   ")
3192            .config_path(&config)
3193            .build()
3194            .expect_err("blank builder and blank TOML service_name should fail");
3195        assert!(matches!(err, ControllerBuildError::EmptyServiceName));
3196
3197        fs::remove_file(config).expect("config cleanup should succeed");
3198    }
3199
3200    #[test]
3201    fn build_from_toml_with_blank_service_name_returns_empty_service_name_error() {
3202        let config = test_config_path("toml-empty-service-name");
3203        write_raw_config(
3204            &config,
3205            r#"[controller]
3206service_name = ""
3207
3208[controller.activation]
3209mode = "light"
3210
3211[controller.activation.sink]
3212type = "local_json"
3213output_path = "tailtriage-run.json"
3214"#,
3215        );
3216
3217        let err = TailtriageController::builder("fallback-service-name")
3218            .config_path(&config)
3219            .build()
3220            .expect_err("blank TOML service_name should fail build");
3221        assert!(matches!(err, ControllerBuildError::EmptyServiceName));
3222
3223        fs::remove_file(config).expect("config cleanup should succeed");
3224    }
3225
3226    #[test]
3227    fn build_from_toml_with_invalid_mode_returns_parse_error() {
3228        let config = test_config_path("toml-invalid-mode");
3229        write_raw_config(
3230            &config,
3231            r#"[controller]
3232
3233[controller.activation]
3234mode = "not-a-real-mode"
3235
3236[controller.activation.sink]
3237type = "local_json"
3238output_path = "tailtriage-run.json"
3239"#,
3240        );
3241
3242        let err = TailtriageController::builder("checkout-service")
3243            .config_path(&config)
3244            .build()
3245            .expect_err("invalid mode should fail build");
3246        assert!(matches!(
3247            err,
3248            ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3249        ));
3250
3251        fs::remove_file(config).expect("config cleanup should succeed");
3252    }
3253
3254    #[test]
3255    fn build_from_toml_with_invalid_run_end_policy_kind_returns_parse_error() {
3256        let config = test_config_path("toml-invalid-run-end-policy");
3257        write_raw_config(
3258            &config,
3259            r#"[controller]
3260
3261[controller.activation]
3262mode = "light"
3263
3264[controller.activation.sink]
3265type = "local_json"
3266output_path = "tailtriage-run.json"
3267
3268[controller.activation.run_end_policy]
3269kind = "not-a-real-policy"
3270"#,
3271        );
3272
3273        let err = TailtriageController::builder("checkout-service")
3274            .config_path(&config)
3275            .build()
3276            .expect_err("invalid run_end_policy.kind should fail build");
3277        assert!(matches!(
3278            err,
3279            ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3280        ));
3281
3282        fs::remove_file(config).expect("config cleanup should succeed");
3283    }
3284
3285    #[test]
3286    fn build_from_toml_with_run_end_policy_table_missing_kind_returns_parse_error() {
3287        let config = test_config_path("toml-run-end-policy-missing-kind");
3288        write_raw_config(
3289            &config,
3290            r#"[controller]
3291
3292[controller.activation]
3293mode = "light"
3294
3295[controller.activation.sink]
3296type = "local_json"
3297output_path = "tailtriage-run.json"
3298
3299[controller.activation.run_end_policy]
3300"#,
3301        );
3302
3303        let err = TailtriageController::builder("checkout-service")
3304            .config_path(&config)
3305            .build()
3306            .expect_err("run_end_policy table without kind should fail build");
3307        assert!(matches!(
3308            err,
3309            ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3310        ));
3311
3312        fs::remove_file(config).expect("config cleanup should succeed");
3313    }
3314
3315    #[test]
3316    fn build_from_toml_with_invalid_sink_type_returns_parse_error() {
3317        let config = test_config_path("toml-invalid-sink-type");
3318        write_raw_config(
3319            &config,
3320            r#"[controller]
3321
3322[controller.activation]
3323mode = "light"
3324
3325[controller.activation.sink]
3326type = "not-a-real-sink"
3327output_path = "tailtriage-run.json"
3328"#,
3329        );
3330
3331        let err = TailtriageController::builder("checkout-service")
3332            .config_path(&config)
3333            .build()
3334            .expect_err("invalid sink.type should fail build");
3335        assert!(matches!(
3336            err,
3337            ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3338        ));
3339
3340        fs::remove_file(config).expect("config cleanup should succeed");
3341    }
3342
3343    #[test]
3344    fn build_from_toml_initially_enabled_sampler_without_runtime_returns_initial_enable_error() {
3345        let config = test_config_path("toml-initially-enabled-missing-runtime");
3346        write_raw_config(
3347            &config,
3348            r#"[controller]
3349initially_enabled = true
3350
3351[controller.activation]
3352mode = "light"
3353
3354[controller.activation.sink]
3355type = "local_json"
3356output_path = "tailtriage-run.json"
3357
3358[controller.activation.runtime_sampler]
3359enabled_for_armed_runs = true
3360interval_ms = 20
3361max_runtime_snapshots = 10
3362"#,
3363        );
3364
3365        let err = TailtriageController::builder("checkout-service")
3366            .config_path(&config)
3367            .build()
3368            .expect_err("initially_enabled with sampler should fail outside Tokio runtime");
3369        assert!(matches!(
3370            err,
3371            ControllerBuildError::InitialEnable(EnableError::MissingTokioRuntimeForSampler)
3372        ));
3373
3374        fs::remove_file(config).expect("config cleanup should succeed");
3375    }
3376
3377    #[test]
3378    fn reload_config_after_config_file_deleted_returns_load_error() {
3379        let output = test_output("reload-deleted-config");
3380        let config = test_config_path("reload-deleted-config");
3381        write_config(&config, &output, "light", false, false);
3382
3383        let controller = TailtriageController::builder("checkout-service")
3384            .config_path(&config)
3385            .build()
3386            .expect("build should succeed");
3387
3388        fs::remove_file(&config).expect("config delete should succeed");
3389        assert!(matches!(
3390            controller.reload_config(),
3391            Err(ReloadConfigError::Load(super::ConfigLoadError::Io { .. }))
3392        ));
3393    }
3394
3395    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3396    async fn armed_generation_with_sampler_enabled_records_effective_metadata() {
3397        let output = test_output("sampler-enabled");
3398        let controller = TailtriageController::builder("checkout-service")
3399            .output(&output)
3400            .runtime_sampler(RuntimeSamplerTemplate {
3401                enabled_for_armed_runs: true,
3402                mode_override: Some(CaptureMode::Investigation),
3403                interval_ms: Some(15),
3404                max_runtime_snapshots: Some(8),
3405            })
3406            .capture_limits_override(CaptureLimitsOverride {
3407                max_runtime_snapshots: Some(3),
3408                ..CaptureLimitsOverride::default()
3409            })
3410            .build()
3411            .expect("build should succeed");
3412
3413        let active = controller.enable().expect("enable should succeed");
3414        tokio::time::sleep(Duration::from_millis(40)).await;
3415        assert!(matches!(
3416            controller.disable(),
3417            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
3418        ));
3419
3420        let run = read_run(&active.artifact_path);
3421        let config = run
3422            .metadata
3423            .effective_tokio_sampler_config
3424            .expect("sampler metadata should be set");
3425        assert_eq!(config.inherited_mode, CaptureMode::Light);
3426        assert_eq!(
3427            config.explicit_mode_override,
3428            Some(CaptureMode::Investigation)
3429        );
3430        assert_eq!(config.resolved_mode, CaptureMode::Investigation);
3431        assert_eq!(config.resolved_sampler_cadence_ms, 15);
3432        assert_eq!(config.resolved_runtime_snapshot_retention, 3);
3433
3434        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
3435    }
3436
3437    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3438    async fn armed_generation_with_sampler_disabled_keeps_sampler_metadata_empty() {
3439        let output = test_output("sampler-disabled");
3440        let controller = TailtriageController::builder("checkout-service")
3441            .output(&output)
3442            .runtime_sampler(RuntimeSamplerTemplate {
3443                enabled_for_armed_runs: false,
3444                mode_override: Some(CaptureMode::Investigation),
3445                interval_ms: Some(5),
3446                max_runtime_snapshots: Some(100),
3447            })
3448            .build()
3449            .expect("build should succeed");
3450
3451        let active = controller.enable().expect("enable should succeed");
3452        tokio::time::sleep(Duration::from_millis(20)).await;
3453        assert!(matches!(
3454            controller.disable(),
3455            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
3456        ));
3457
3458        let run = read_run(&active.artifact_path);
3459        assert!(run.metadata.effective_tokio_sampler_config.is_none());
3460        assert!(run.runtime_snapshots.is_empty());
3461
3462        fs::remove_file(active.artifact_path).expect("cleanup should succeed");
3463    }
3464
3465    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3466    async fn sampler_stops_on_disarm_and_reenable_uses_fresh_generation_sampler_lifecycle() {
3467        let output = test_output("sampler-reenable");
3468        let controller = TailtriageController::builder("checkout-service")
3469            .output(&output)
3470            .runtime_sampler(RuntimeSamplerTemplate {
3471                enabled_for_armed_runs: true,
3472                mode_override: None,
3473                interval_ms: Some(10),
3474                max_runtime_snapshots: Some(32),
3475            })
3476            .build()
3477            .expect("build should succeed");
3478
3479        let first = controller.enable().expect("first enable should succeed");
3480        tokio::time::sleep(Duration::from_millis(35)).await;
3481        assert!(matches!(
3482            controller.disable(),
3483            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == first.generation_id
3484        ));
3485        tokio::time::sleep(Duration::from_millis(30)).await;
3486
3487        let first_run = read_run(&first.artifact_path);
3488        assert!(!first_run.runtime_snapshots.is_empty());
3489        let first_metadata = first_run
3490            .metadata
3491            .effective_tokio_sampler_config
3492            .expect("first generation sampler metadata should exist");
3493
3494        let second = controller.enable().expect("second enable should succeed");
3495        assert_eq!(second.generation_id, first.generation_id + 1);
3496        tokio::time::sleep(Duration::from_millis(35)).await;
3497        assert!(matches!(
3498            controller.disable(),
3499            Ok(DisableOutcome::Finalized { generation_id }) if generation_id == second.generation_id
3500        ));
3501
3502        let second_run = read_run(&second.artifact_path);
3503        assert!(!second_run.runtime_snapshots.is_empty());
3504        let second_metadata = second_run
3505            .metadata
3506            .effective_tokio_sampler_config
3507            .expect("second generation sampler metadata should exist");
3508
3509        assert_eq!(first_metadata.resolved_sampler_cadence_ms, 10);
3510        assert_eq!(second_metadata.resolved_sampler_cadence_ms, 10);
3511        assert_ne!(first.artifact_path, second.artifact_path);
3512
3513        fs::remove_file(first.artifact_path).expect("cleanup first should succeed");
3514        fs::remove_file(second.artifact_path).expect("cleanup second should succeed");
3515    }
3516}