Skip to main content

aft/
alert_state.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::time::{Duration, Instant};
4
5use unicode_normalization::UnicodeNormalization;
6
7use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8use crate::lsp::roots::ServerKey;
9
10/// Idle alert sessions are reaped by the embedding runtime. The duration is a
11/// product constant; callers inject `now` when they run a sweep so lifecycle
12/// behavior does not depend on wall-clock timing in tests.
13pub const ALERT_SESSION_IDLE_TTL: Duration = Duration::from_secs(30 * 60);
14
15/// A stable identifier for the server partition that produced a diagnostic
16/// snapshot. The workspace root is included because a server kind can run for
17/// more than one nested workspace beneath one dispatch root.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub struct ProducerKey(String);
20
21impl ProducerKey {
22    pub fn new(value: impl Into<String>) -> Self {
23        Self(value.into())
24    }
25
26    pub fn from_server_key(server: &ServerKey) -> Self {
27        Self(format!(
28            "{}@{}",
29            server.kind.id_str(),
30            canonicalize_for_alert(&server.root).display()
31        ))
32    }
33
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39/// The canonical, error-only identity used by the alert delta engine.
40///
41/// The source/server field is intentionally part of the identity. Two
42/// producers can report otherwise identical findings without sharing an alert
43/// lifecycle or suppressing each other.
44#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
45pub struct DiagnosticIdentity {
46    pub file: PathBuf,
47    pub line: u32,
48    pub column: u32,
49    pub end_line: u32,
50    pub end_column: u32,
51    pub severity: String,
52    pub source: Option<String>,
53    pub code: Option<String>,
54    pub message: String,
55}
56
57impl DiagnosticIdentity {
58    /// Construct an alert identity for an error diagnostic. Non-errors never
59    /// enter the alert state, although they remain in an accepted producer
60    /// snapshot for other consumers.
61    pub fn from_stored(canonical_root: &Path, diagnostic: &StoredDiagnostic) -> Option<Self> {
62        (diagnostic.severity == DiagnosticSeverity::Error).then(|| Self {
63            file: canonical_root_relative_file(canonical_root, &diagnostic.file),
64            line: diagnostic.line,
65            column: diagnostic.column,
66            end_line: diagnostic.end_line,
67            end_column: diagnostic.end_column,
68            severity: diagnostic.severity.as_str().to_owned(),
69            source: diagnostic.source.clone(),
70            code: diagnostic.code.clone(),
71            message: normalize_diagnostic_message(&diagnostic.message),
72        })
73    }
74}
75
76/// Normalize the message component of a diagnostic identity.
77///
78/// This intentionally performs only the canonical identity rules: first line,
79/// trimming, whitespace-run collapse, and NFC. Do not add presentation or
80/// path-oriented rewrites here; changing this function changes alert identity.
81pub fn normalize_diagnostic_message(message: &str) -> String {
82    message
83        .lines()
84        .next()
85        .unwrap_or_default()
86        .split_whitespace()
87        .collect::<Vec<_>>()
88        .join(" ")
89        .nfc()
90        .collect()
91}
92
93/// One complete diagnostics snapshot that LSP accepted for one producer and
94/// one document version. An empty `diagnostics` vector is an accepted clean
95/// report, not an omitted or pending report.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct AcceptedDiagnosticSnapshot {
98    pub server_key: ServerKey,
99    pub document_version: i32,
100    pub diagnostics: Vec<StoredDiagnostic>,
101}
102
103impl AcceptedDiagnosticSnapshot {
104    pub fn new(
105        server_key: ServerKey,
106        document_version: i32,
107        diagnostics: Vec<StoredDiagnostic>,
108    ) -> Self {
109        Self {
110            server_key,
111            document_version,
112            diagnostics,
113        }
114    }
115
116    pub fn producer_key(&self) -> ProducerKey {
117        ProducerKey::from_server_key(&self.server_key)
118    }
119
120    pub fn is_empty(&self) -> bool {
121        self.diagnostics.is_empty()
122    }
123}
124
125/// An accepted observation is the only input that may mutate alert delta
126/// state. Sources must assemble this from a complete, document-version-
127/// verified producer snapshot before diagnostics are flattened for a response.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct AcceptedObservation {
130    pub session_id: String,
131    pub canonical_root: PathBuf,
132    pub producer_key: ProducerKey,
133    pub accepted_document_version: i32,
134    pub diagnostics: Vec<StoredDiagnostic>,
135}
136
137impl AcceptedObservation {
138    pub fn new(
139        session_id: impl Into<String>,
140        canonical_root: impl AsRef<Path>,
141        producer_key: ProducerKey,
142        accepted_document_version: i32,
143        diagnostics: Vec<StoredDiagnostic>,
144    ) -> Self {
145        Self {
146            session_id: session_id.into(),
147            canonical_root: canonicalize_for_alert(canonical_root.as_ref()),
148            producer_key,
149            accepted_document_version,
150            diagnostics,
151        }
152    }
153
154    pub fn from_snapshot(
155        session_id: impl Into<String>,
156        canonical_root: impl AsRef<Path>,
157        snapshot: AcceptedDiagnosticSnapshot,
158    ) -> Self {
159        Self::new(
160            session_id,
161            canonical_root,
162            snapshot.producer_key(),
163            snapshot.document_version,
164            snapshot.diagnostics,
165        )
166    }
167
168    pub fn partition_key(&self) -> AlertPartitionKey {
169        AlertPartitionKey {
170            session_id: self.session_id.clone(),
171            canonical_root: self.canonical_root.clone(),
172            producer_key: self.producer_key.clone(),
173        }
174    }
175
176    pub fn is_empty(&self) -> bool {
177        self.diagnostics.is_empty()
178    }
179}
180
181/// A terminal, all-or-nothing group of accepted producer snapshots. Producers
182/// that are pending, warming, timed out, exited, or unversioned are represented
183/// by absence from this batch and therefore cannot clear a partition.
184#[derive(Debug, Clone, Default, PartialEq, Eq)]
185pub struct AcceptedObservationBatch {
186    observations: Vec<AcceptedObservation>,
187}
188
189impl AcceptedObservationBatch {
190    /// Convert LSP's complete per-producer snapshots into this state engine's
191    /// only mutating input. This is intentionally the conversion boundary: do
192    /// not flatten snapshot diagnostics before calling it.
193    pub fn from_diagnostic_snapshots(
194        session_id: impl Into<String>,
195        canonical_root: impl AsRef<Path>,
196        snapshots: impl IntoIterator<Item = AcceptedDiagnosticSnapshot>,
197    ) -> Result<Self, ObservationError> {
198        let session_id = session_id.into();
199        let canonical_root = canonicalize_for_alert(canonical_root.as_ref());
200        Self::new(
201            snapshots
202                .into_iter()
203                .map(|snapshot| {
204                    AcceptedObservation::from_snapshot(
205                        session_id.clone(),
206                        canonical_root.clone(),
207                        snapshot,
208                    )
209                })
210                .collect(),
211        )
212    }
213
214    pub fn new(observations: Vec<AcceptedObservation>) -> Result<Self, ObservationError> {
215        let mut partitions = HashSet::new();
216        for observation in &observations {
217            let key = observation.partition_key();
218            if !partitions.insert(key.clone()) {
219                return Err(ObservationError::DuplicatePartition(key));
220            }
221        }
222        Ok(Self { observations })
223    }
224
225    pub fn observations(&self) -> &[AcceptedObservation] {
226        &self.observations
227    }
228
229    pub fn is_empty(&self) -> bool {
230        self.observations.is_empty()
231    }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Hash)]
235pub struct AlertPartitionKey {
236    pub session_id: String,
237    pub canonical_root: PathBuf,
238    pub producer_key: ProducerKey,
239}
240
241impl AlertPartitionKey {
242    pub fn new(
243        session_id: impl Into<String>,
244        canonical_root: impl AsRef<Path>,
245        producer_key: ProducerKey,
246    ) -> Self {
247        Self {
248            session_id: session_id.into(),
249            canonical_root: canonicalize_for_alert(canonical_root.as_ref()),
250            producer_key,
251        }
252    }
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
256pub struct LifecycleEpisodeId(u64);
257
258impl LifecycleEpisodeId {
259    pub fn get(self) -> u64 {
260        self.0
261    }
262}
263
264/// The alert fields owned by exactly one `(session, root, producer)` partition.
265/// `rendered` is deliberately partition-local: a snapshot from one producer may
266/// never prune or suppress another producer's lifecycle.
267#[derive(Debug, Clone, Default, PartialEq, Eq)]
268pub struct AlertPartitionState {
269    pub baseline_established: bool,
270    pub live: HashMap<DiagnosticIdentity, LifecycleEpisodeId>,
271    pub rendered: HashSet<DiagnosticIdentity>,
272    pub closed_episodes: HashSet<LifecycleEpisodeId>,
273}
274
275impl AlertPartitionState {
276    pub fn episode_for(&self, identity: &DiagnosticIdentity) -> Option<LifecycleEpisodeId> {
277        self.live.get(identity).copied()
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct ClosedIdentity {
283    pub identity: DiagnosticIdentity,
284    pub episode_id: LifecycleEpisodeId,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct EnteredIdentity {
289    pub identity: DiagnosticIdentity,
290    pub episode_id: LifecycleEpisodeId,
291}
292
293/// The state transition for one accepted producer snapshot. The first
294/// observation establishes a silent baseline, so its `entered` identities are
295/// returned separately from later alert-eligible entries.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct AcceptedObservationResult {
298    pub partition: AlertPartitionKey,
299    pub accepted_document_version: i32,
300    pub accepted_empty_snapshot: bool,
301    pub baseline_established_now: bool,
302    pub baselined: Vec<EnteredIdentity>,
303    pub entered: Vec<EnteredIdentity>,
304    pub closed: Vec<ClosedIdentity>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub enum ObservationError {
309    DuplicatePartition(AlertPartitionKey),
310}
311
312impl std::fmt::Display for ObservationError {
313    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        match self {
315            Self::DuplicatePartition(key) => write!(
316                formatter,
317                "accepted observation batch contains duplicate partition ({}, {}, {})",
318                key.session_id,
319                key.canonical_root.display(),
320                key.producer_key.as_str()
321            ),
322        }
323    }
324}
325
326impl std::error::Error for ObservationError {}
327
328/// Session-scoped alert delta state. The caller supplies the authoritative
329/// observation batch and an injected monotonic time; no passive diagnostic
330/// reads, timers, or heartbeats have a state-mutating API here.
331#[derive(Debug, Clone, Default)]
332pub struct AlertDeltaState {
333    partitions: HashMap<AlertPartitionKey, AlertPartitionState>,
334    session_last_touched: HashMap<String, Instant>,
335    next_episode_id: u64,
336}
337
338impl AlertDeltaState {
339    pub fn accept_batch(
340        &mut self,
341        batch: &AcceptedObservationBatch,
342    ) -> Result<Vec<AcceptedObservationResult>, ObservationError> {
343        self.accept_batch_at(batch, Instant::now())
344    }
345
346    /// Apply every accepted producer snapshot as one terminal transition. The
347    /// staging clone means a malformed batch cannot expose an intermediate
348    /// multi-producer state to a concurrent finalizer.
349    pub fn accept_batch_at(
350        &mut self,
351        batch: &AcceptedObservationBatch,
352        now: Instant,
353    ) -> Result<Vec<AcceptedObservationResult>, ObservationError> {
354        // Batches built by `AcceptedObservationBatch::new` are already valid,
355        // but validate again so deserialization or future constructors cannot
356        // accidentally weaken the atomicity guarantee.
357        validate_batch(batch)?;
358
359        let mut staged = self.clone();
360        let mut results = Vec::with_capacity(batch.observations.len());
361        for observation in &batch.observations {
362            results.push(staged.accept_observation(observation, now));
363        }
364        *self = staged;
365        Ok(results)
366    }
367
368    pub fn partition(&self, key: &AlertPartitionKey) -> Option<&AlertPartitionState> {
369        self.partitions.get(key)
370    }
371
372    pub fn partitions_for_session<'a>(
373        &'a self,
374        session_id: &'a str,
375    ) -> impl Iterator<Item = (&'a AlertPartitionKey, &'a AlertPartitionState)> + 'a {
376        self.partitions
377            .iter()
378            .filter(move |(key, _)| key.session_id == session_id)
379    }
380
381    pub fn remove_session(&mut self, session_id: &str) -> bool {
382        let before = self.partitions.len();
383        self.partitions
384            .retain(|key, _| key.session_id != session_id);
385        let touched = self.session_last_touched.remove(session_id).is_some();
386        touched || self.partitions.len() != before
387    }
388
389    /// Reap all state belonging to sessions idle for at least `idle_for`.
390    /// `now` is injected by the runtime to make the lifecycle independently
391    /// testable without pinning the product duration.
392    pub fn reap_idle_sessions_at(&mut self, now: Instant, idle_for: Duration) -> Vec<String> {
393        let mut reaped = self
394            .session_last_touched
395            .iter()
396            .filter_map(|(session_id, last_touched)| {
397                (now.saturating_duration_since(*last_touched) >= idle_for)
398                    .then(|| session_id.clone())
399            })
400            .collect::<Vec<_>>();
401        reaped.sort();
402        for session_id in &reaped {
403            self.remove_session(session_id);
404        }
405        reaped
406    }
407
408    pub fn reap_idle_sessions(&mut self) -> Vec<String> {
409        self.reap_idle_sessions_at(Instant::now(), ALERT_SESSION_IDLE_TTL)
410    }
411
412    fn accept_observation(
413        &mut self,
414        observation: &AcceptedObservation,
415        now: Instant,
416    ) -> AcceptedObservationResult {
417        let partition_key = observation.partition_key();
418        self.session_last_touched
419            .insert(observation.session_id.clone(), now);
420
421        let current = observation
422            .diagnostics
423            .iter()
424            .filter_map(|diagnostic| {
425                DiagnosticIdentity::from_stored(&observation.canonical_root, diagnostic)
426            })
427            .collect::<HashSet<_>>();
428        let baseline_established = self
429            .partitions
430            .get(&partition_key)
431            .is_some_and(|partition| partition.baseline_established);
432
433        if !baseline_established {
434            let mut baselined = current
435                .into_iter()
436                .map(|identity| EnteredIdentity {
437                    episode_id: self.mint_episode(),
438                    identity,
439                })
440                .collect::<Vec<_>>();
441            baselined.sort_by(|left, right| left.identity.cmp(&right.identity));
442            let partition = self.partitions.entry(partition_key.clone()).or_default();
443            for entered in &baselined {
444                partition
445                    .live
446                    .insert(entered.identity.clone(), entered.episode_id);
447                partition.rendered.insert(entered.identity.clone());
448            }
449            partition.baseline_established = true;
450            return AcceptedObservationResult {
451                partition: partition_key,
452                accepted_document_version: observation.accepted_document_version,
453                accepted_empty_snapshot: observation.is_empty(),
454                baseline_established_now: true,
455                baselined,
456                entered: Vec::new(),
457                closed: Vec::new(),
458            };
459        }
460
461        let previous = self
462            .partitions
463            .get(&partition_key)
464            .map(|partition| partition.live.keys().cloned().collect::<HashSet<_>>())
465            .unwrap_or_default();
466        let closed_identities = previous.difference(&current).cloned().collect::<Vec<_>>();
467        let mut entered = current
468            .difference(&previous)
469            .cloned()
470            .map(|identity| EnteredIdentity {
471                episode_id: self.mint_episode(),
472                identity,
473            })
474            .collect::<Vec<_>>();
475        entered.sort_by(|left, right| left.identity.cmp(&right.identity));
476
477        let partition = self.partitions.entry(partition_key.clone()).or_default();
478        let mut closed = closed_identities
479            .into_iter()
480            .filter_map(|identity| {
481                partition.live.remove(&identity).map(|episode_id| {
482                    partition.rendered.remove(&identity);
483                    partition.closed_episodes.insert(episode_id);
484                    ClosedIdentity {
485                        identity,
486                        episode_id,
487                    }
488                })
489            })
490            .collect::<Vec<_>>();
491        closed.sort_by(|left, right| left.identity.cmp(&right.identity));
492        for item in &entered {
493            partition
494                .live
495                .insert(item.identity.clone(), item.episode_id);
496        }
497
498        AcceptedObservationResult {
499            partition: partition_key,
500            accepted_document_version: observation.accepted_document_version,
501            accepted_empty_snapshot: observation.is_empty(),
502            baseline_established_now: false,
503            baselined: Vec::new(),
504            entered,
505            closed,
506        }
507    }
508
509    fn mint_episode(&mut self) -> LifecycleEpisodeId {
510        self.next_episode_id = self.next_episode_id.wrapping_add(1).max(1);
511        LifecycleEpisodeId(self.next_episode_id)
512    }
513}
514
515fn validate_batch(batch: &AcceptedObservationBatch) -> Result<(), ObservationError> {
516    let mut partitions = HashSet::new();
517    for observation in &batch.observations {
518        let key = observation.partition_key();
519        if !partitions.insert(key.clone()) {
520            return Err(ObservationError::DuplicatePartition(key));
521        }
522    }
523    Ok(())
524}
525
526fn canonical_root_relative_file(canonical_root: &Path, file: &Path) -> PathBuf {
527    let root = canonicalize_for_alert(canonical_root);
528    let file = canonicalize_for_alert(file);
529    file.strip_prefix(&root).unwrap_or(&file).to_path_buf()
530}
531
532fn canonicalize_for_alert(path: &Path) -> PathBuf {
533    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
534}