Skip to main content

aft/inspect/
phase_log.rs

1use std::collections::{BTreeMap, HashSet, VecDeque};
2use std::sync::{Arc, LazyLock};
3use std::time::Instant;
4
5use parking_lot::Mutex;
6use serde::Serialize;
7
8use super::InspectCategory;
9use crate::lsp::roots::ServerKey;
10
11const RETAINED_CALLS: usize = 64;
12
13/// The fixed vocabulary for inspect work. These IDs describe work completed by
14/// the server; they are not transport or dispatch milestones.
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum InspectPhaseId {
18    LspStart,
19    LspQuiescence,
20    Tier2Rescan,
21    CallgraphReady,
22    StatVerification,
23}
24
25impl InspectPhaseId {
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::LspStart => "lsp_start",
29            Self::LspQuiescence => "lsp_quiescence",
30            Self::Tier2Rescan => "tier2_rescan",
31            Self::CallgraphReady => "callgraph_ready",
32            Self::StatVerification => "stat_verification",
33        }
34    }
35
36    const fn takes_producer(self) -> bool {
37        matches!(self, Self::LspStart | Self::LspQuiescence)
38    }
39
40    const fn takes_category(self) -> bool {
41        matches!(
42            self,
43            Self::Tier2Rescan | Self::CallgraphReady | Self::StatVerification
44        )
45    }
46}
47
48/// One completed phase's agent-facing logical shape. `also_satisfied` is absent
49/// unless one physical work unit freshened more than its primary category.
50#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
51pub struct InspectPhaseEntry {
52    pub id: InspectPhaseId,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub producer: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub category: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub also_satisfied: Option<Vec<String>>,
59}
60
61impl InspectPhaseEntry {
62    pub fn lsp(id: InspectPhaseId, server: &ServerKey) -> Self {
63        assert!(id.takes_producer(), "producer is only valid for LSP phases");
64        Self {
65            id,
66            producer: Some(server.kind.id_str().to_string()),
67            category: None,
68            also_satisfied: None,
69        }
70    }
71
72    pub fn category(id: InspectPhaseId, category: InspectCategory) -> Self {
73        assert!(
74            id.takes_category(),
75            "category is only valid for category-attributed phases"
76        );
77        Self {
78            id,
79            producer: None,
80            category: Some(category.as_str().to_string()),
81            also_satisfied: None,
82        }
83    }
84
85    pub fn with_also_satisfied(
86        mut self,
87        categories: impl IntoIterator<Item = InspectCategory>,
88    ) -> Self {
89        let mut seen = HashSet::new();
90        let categories = categories
91            .into_iter()
92            .map(|category| category.as_str().to_string())
93            .filter(|category| seen.insert(category.clone()))
94            .collect::<Vec<_>>();
95        if !categories.is_empty() {
96            self.also_satisfied = Some(categories);
97        }
98        self
99    }
100}
101
102#[derive(Clone, Debug)]
103pub struct InspectPhaseRecord {
104    pub entry: InspectPhaseEntry,
105    pub started: Instant,
106    pub completed: Option<Instant>,
107    pub terminal_error: Option<String>,
108}
109
110impl InspectPhaseRecord {
111    pub fn is_completed(&self) -> bool {
112        self.completed.is_some()
113    }
114
115    pub fn terminal_error(&self) -> Option<&str> {
116        self.terminal_error.as_deref()
117    }
118
119    pub fn duration_ms(&self) -> Option<u128> {
120        self.completed
121            .map(|completed| completed.duration_since(self.started).as_millis())
122    }
123}
124
125#[derive(Clone, Debug)]
126pub struct InspectPhaseLogSnapshot {
127    pub request_id: String,
128    pub records: Vec<InspectPhaseRecord>,
129    pub blocking_waited: bool,
130}
131
132#[derive(Default)]
133struct InspectPhaseLogState {
134    request_id: String,
135    records: Vec<InspectPhaseRecord>,
136    blocking_waited: bool,
137}
138
139/// Server-side per-call inspect evidence. It is deliberately independent of
140/// `run_tool_call::PhaseTrace`, whose records describe subc dispatch milestones
141/// rather than inspection work.
142#[derive(Clone, Default)]
143pub struct InspectPhaseLog {
144    state: Arc<Mutex<InspectPhaseLogState>>,
145}
146
147impl InspectPhaseLog {
148    pub fn for_request(request_id: impl Into<String>) -> Self {
149        let log = Self {
150            state: Arc::new(Mutex::new(InspectPhaseLogState {
151                request_id: request_id.into(),
152                ..InspectPhaseLogState::default()
153            })),
154        };
155        retain_log(log.clone());
156        log
157    }
158
159    pub fn start(&self, entry: InspectPhaseEntry) -> InspectPhaseHandle {
160        let mut state = self.state.lock();
161        let index = state.records.len();
162        state.records.push(InspectPhaseRecord {
163            entry,
164            started: Instant::now(),
165            completed: None,
166            terminal_error: None,
167        });
168        InspectPhaseHandle {
169            log: self.clone(),
170            index,
171            completed: false,
172        }
173    }
174
175    pub fn note_blocking_wait(&self) {
176        self.state.lock().blocking_waited = true;
177    }
178
179    pub fn snapshot(&self) -> InspectPhaseLogSnapshot {
180        let state = self.state.lock();
181        InspectPhaseLogSnapshot {
182            request_id: state.request_id.clone(),
183            records: state.records.clone(),
184            blocking_waited: state.blocking_waited,
185        }
186    }
187
188    /// Reads the two formatter inputs from the single per-call record set.
189    pub fn terminal_inputs(&self) -> (Vec<InspectPhaseEntry>, bool) {
190        let state = self.state.lock();
191        let entries = state
192            .records
193            .iter()
194            .filter(|record| record.is_completed() && record.terminal_error.is_none())
195            .map(|record| record.entry.clone())
196            .collect();
197        (entries, state.blocking_waited)
198    }
199
200    /// The last begun, incomplete phase is the only phase shutdown may name.
201    /// When work has not begun, callers render the preflight failure form.
202    pub fn in_flight_entry(&self) -> Option<InspectPhaseEntry> {
203        self.state
204            .lock()
205            .records
206            .iter()
207            .rev()
208            .find(|record| !record.is_completed())
209            .map(|record| record.entry.clone())
210    }
211}
212
213pub struct InspectPhaseHandle {
214    log: InspectPhaseLog,
215    index: usize,
216    completed: bool,
217}
218
219impl InspectPhaseHandle {
220    pub fn complete(mut self) {
221        let mut state = self.log.state.lock();
222        if let Some(record) = state.records.get_mut(self.index) {
223            record.completed = Some(Instant::now());
224        }
225        self.completed = true;
226    }
227
228    pub fn fail(mut self, error: impl Into<String>) {
229        let mut state = self.log.state.lock();
230        if let Some(record) = state.records.get_mut(self.index) {
231            record.completed = Some(Instant::now());
232            record.terminal_error = Some(error.into());
233        }
234        self.completed = true;
235    }
236}
237
238impl Drop for InspectPhaseHandle {
239    fn drop(&mut self) {
240        if !self.completed {
241            let mut state = self.log.state.lock();
242            if let Some(record) = state.records.get_mut(self.index) {
243                record.completed = Some(Instant::now());
244                record.terminal_error = Some("phase handle dropped before completion".to_string());
245            }
246        }
247    }
248}
249
250struct RetainedLogs {
251    logs: BTreeMap<String, InspectPhaseLog>,
252    order: VecDeque<String>,
253}
254
255static RETAINED_LOGS: LazyLock<Mutex<RetainedLogs>> = LazyLock::new(|| {
256    Mutex::new(RetainedLogs {
257        logs: BTreeMap::new(),
258        order: VecDeque::new(),
259    })
260});
261
262fn retain_log(log: InspectPhaseLog) {
263    let request_id = log.snapshot().request_id;
264    let mut retained = RETAINED_LOGS.lock();
265    if !retained.logs.contains_key(&request_id) {
266        retained.order.push_back(request_id.clone());
267    }
268    retained.logs.insert(request_id, log);
269    while retained.order.len() > RETAINED_CALLS {
270        if let Some(expired) = retained.order.pop_front() {
271            retained.logs.remove(&expired);
272        }
273    }
274}
275
276/// Harness-facing accessor for the retained evidence associated with a request.
277/// The bounded retention window covers delivery and post-terminal assertions.
278pub fn inspect_phase_log_for_request(request_id: &str) -> Option<InspectPhaseLogSnapshot> {
279    RETAINED_LOGS
280        .lock()
281        .logs
282        .get(request_id)
283        .map(InspectPhaseLog::snapshot)
284}
285
286/// The only formatter for the human wait text. Callers obtain both inputs from
287/// [`InspectPhaseLog::terminal_inputs`] rather than reconstructing them.
288pub fn format_wait_text(entries: &[InspectPhaseEntry], blocking_waited: bool) -> String {
289    let completed = if entries.is_empty() {
290        "none".to_string()
291    } else {
292        entries
293            .iter()
294            .map(|entry| entry.id.as_str())
295            .collect::<Vec<_>>()
296            .join(",")
297    };
298    format!(
299        "waited: {}; completed: {completed}",
300        if blocking_waited { "yes" } else { "no" }
301    )
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn wait_text_reads_completed_phase_order_and_recorded_wait_state() {
310        let log = InspectPhaseLog::for_request("inspect-log-order");
311        log.start(InspectPhaseEntry::category(
312            InspectPhaseId::StatVerification,
313            InspectCategory::DeadCode,
314        ))
315        .complete();
316        let (entries, waited) = log.terminal_inputs();
317        assert_eq!(
318            format_wait_text(&entries, waited),
319            "waited: no; completed: stat_verification"
320        );
321
322        log.note_blocking_wait();
323        let (entries, waited) = log.terminal_inputs();
324        assert_eq!(
325            format_wait_text(&entries, waited),
326            "waited: yes; completed: stat_verification"
327        );
328    }
329
330    #[test]
331    fn failed_records_do_not_become_completed_phase_entries() {
332        let log = InspectPhaseLog::for_request("inspect-log-incomplete");
333        let phase = log.start(InspectPhaseEntry::category(
334            InspectPhaseId::Tier2Rescan,
335            InspectCategory::Duplicates,
336        ));
337        phase.fail("scan failed");
338
339        let (entries, waited) = log.terminal_inputs();
340        assert!(entries.is_empty());
341        assert!(!waited);
342        let snapshot = inspect_phase_log_for_request("inspect-log-incomplete").unwrap();
343        assert_eq!(snapshot.records[0].terminal_error(), Some("scan failed"));
344        assert!(snapshot.records[0].completed.is_some());
345        assert!(snapshot.records[0].duration_ms().is_some());
346    }
347}