Skip to main content

ucp_codegraph/programmatic/
session.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::Path,
5    sync::{Arc, Mutex},
6    time::Instant,
7};
8
9use anyhow::{anyhow, Result};
10use sha2::{Digest, Sha256};
11use ucm_core::BlockId;
12
13use crate::{
14    canonical_fingerprint, export_codegraph_context_with_config, render_codegraph_context_prompt,
15    CodeGraphContextExport, CodeGraphContextFrontierAction, CodeGraphContextSession,
16    CodeGraphContextSummary, CodeGraphContextUpdate, CodeGraphDetailLevel, CodeGraphExportConfig,
17    CodeGraphOperationBudget, CodeGraphPersistedSession, CodeGraphRecommendation,
18    CodeGraphRenderConfig, CodeGraphSelectionOriginKind, CodeGraphSessionEvent,
19    CodeGraphSessionMutation, CodeGraphSessionMutationKind, CodeGraphSessionPersistenceMetadata,
20    CodeGraphTraversalConfig,
21};
22
23use super::{
24    query,
25    types::{
26        CodeGraphExpandMode, CodeGraphExportOmissionExplanation, CodeGraphFindQuery,
27        CodeGraphMutationEstimate, CodeGraphNodeSummary, CodeGraphPathResult,
28        CodeGraphProvenanceStep, CodeGraphPruneExplanation, CodeGraphRecommendedActionsResult,
29        CodeGraphSelectionExplanation, CodeGraphSelectorResolutionExplanation,
30        CodeGraphSessionDiff,
31    },
32    CodeGraphNavigator,
33};
34
35type SessionObserver = Arc<dyn Fn(&CodeGraphSessionEvent) + Send + Sync>;
36
37#[derive(Clone, Default)]
38struct ObserverRegistry {
39    handlers: Arc<Mutex<Vec<SessionObserver>>>,
40}
41
42impl ObserverRegistry {
43    fn subscribe<F>(&self, observer: F)
44    where
45        F: Fn(&CodeGraphSessionEvent) + Send + Sync + 'static,
46    {
47        if let Ok(mut handlers) = self.handlers.lock() {
48            handlers.push(Arc::new(observer));
49        }
50    }
51
52    fn emit(&self, event: &CodeGraphSessionEvent) {
53        if let Ok(handlers) = self.handlers.lock() {
54            for handler in handlers.iter() {
55                handler(event);
56            }
57        }
58    }
59
60    fn count(&self) -> usize {
61        self.handlers.lock().map(|value| value.len()).unwrap_or(0)
62    }
63}
64
65impl std::fmt::Debug for ObserverRegistry {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("ObserverRegistry")
68            .field("observer_count", &self.count())
69            .finish()
70    }
71}
72
73#[derive(Debug, Clone)]
74pub struct CodeGraphNavigatorSession {
75    graph: CodeGraphNavigator,
76    context: CodeGraphContextSession,
77    session_id: String,
78    parent_session_id: Option<String>,
79    mutation_log: Vec<CodeGraphSessionMutation>,
80    event_log: Vec<CodeGraphSessionEvent>,
81    prune_notes: HashMap<BlockId, String>,
82    next_sequence: usize,
83    observers: ObserverRegistry,
84}
85
86impl CodeGraphNavigatorSession {
87    pub fn new(graph: CodeGraphNavigator) -> Self {
88        Self {
89            graph,
90            context: CodeGraphContextSession::new(),
91            session_id: new_session_id("root", 0),
92            parent_session_id: None,
93            mutation_log: Vec::new(),
94            event_log: Vec::new(),
95            prune_notes: HashMap::new(),
96            next_sequence: 1,
97            observers: ObserverRegistry::default(),
98        }
99    }
100
101    pub(crate) fn from_persisted(
102        graph: CodeGraphNavigator,
103        persisted: CodeGraphPersistedSession,
104    ) -> Result<Self> {
105        let expected = canonical_fingerprint(graph.document())?;
106        if persisted.metadata.graph_snapshot_hash != expected {
107            return Err(anyhow!(
108                "Persisted session targets graph snapshot {} but current graph snapshot is {}",
109                persisted.metadata.graph_snapshot_hash,
110                expected
111            ));
112        }
113
114        let mut session = Self {
115            graph,
116            context: persisted.context,
117            session_id: persisted.metadata.session_id.clone(),
118            parent_session_id: persisted.metadata.parent_session_id.clone(),
119            mutation_log: persisted.mutation_log,
120            event_log: persisted.event_log,
121            prune_notes: HashMap::new(),
122            next_sequence: persisted.metadata.mutation_count.saturating_add(1),
123            observers: ObserverRegistry::default(),
124        };
125        let loaded = CodeGraphSessionEvent::SessionLoaded {
126            metadata: persisted.metadata,
127        };
128        session.event_log.push(loaded.clone());
129        session.observers.emit(&loaded);
130        Ok(session)
131    }
132
133    pub fn context(&self) -> &CodeGraphContextSession {
134        &self.context
135    }
136
137    pub fn session_id(&self) -> &str {
138        &self.session_id
139    }
140
141    pub fn parent_session_id(&self) -> Option<&str> {
142        self.parent_session_id.as_deref()
143    }
144
145    pub fn mutation_log(&self) -> &[CodeGraphSessionMutation] {
146        &self.mutation_log
147    }
148
149    pub fn event_log(&self) -> &[CodeGraphSessionEvent] {
150        &self.event_log
151    }
152
153    pub fn subscribe<F>(&mut self, observer: F)
154    where
155        F: Fn(&CodeGraphSessionEvent) + Send + Sync + 'static,
156    {
157        self.observers.subscribe(observer);
158    }
159
160    pub fn selected_block_ids(&self) -> Vec<BlockId> {
161        let mut ids = self.context.selected.keys().copied().collect::<Vec<_>>();
162        ids.sort_by_key(|value| value.to_string());
163        ids
164    }
165
166    pub fn summary(&self) -> CodeGraphContextSummary {
167        self.context.summary(self.graph.document())
168    }
169
170    pub fn fork(&self) -> Self {
171        let mut branch = self.clone();
172        branch.parent_session_id = Some(self.session_id.clone());
173        branch.session_id = new_session_id(&self.session_id, self.next_sequence);
174        branch
175    }
176
177    pub fn seed_overview(&mut self, max_depth: Option<usize>) -> CodeGraphContextUpdate {
178        let focus_before = self.context.focus;
179        let started = Instant::now();
180        let mut update = self
181            .context
182            .seed_overview_with_depth(self.graph.document(), max_depth);
183        let resolved = update.added.clone();
184        self.record_mutation(
185            &mut update,
186            CodeGraphSessionMutationKind::SeedOverview,
187            "seed_overview",
188            None,
189            None,
190            resolved,
191            None,
192            None,
193            focus_before,
194            started,
195            Some(match max_depth {
196                Some(depth) => format!("Seeded structural overview up to depth {}", depth),
197                None => "Seeded full structural overview.".to_string(),
198            }),
199        );
200        self.note_prune_effects("seed_overview", &update);
201        update
202    }
203
204    pub fn focus(&mut self, selector: Option<&str>) -> Result<CodeGraphContextUpdate> {
205        let focus_before = self.context.focus;
206        let started = Instant::now();
207        let block_id = selector
208            .map(|value| self.graph.resolve_required(value))
209            .transpose()?;
210        let mut update = self.context.set_focus(self.graph.document(), block_id);
211        self.record_mutation(
212            &mut update,
213            CodeGraphSessionMutationKind::Focus,
214            "focus",
215            selector.map(str::to_string),
216            block_id,
217            block_id.into_iter().collect(),
218            None,
219            None,
220            focus_before,
221            started,
222            Some(match block_id {
223                Some(id) => format!("Focused session on {}", id),
224                None => "Cleared session focus.".to_string(),
225            }),
226        );
227        self.note_prune_effects("focus", &update);
228        Ok(update)
229    }
230
231    pub fn select(
232        &mut self,
233        selector: &str,
234        detail_level: CodeGraphDetailLevel,
235    ) -> Result<CodeGraphContextUpdate> {
236        let focus_before = self.context.focus;
237        let started = Instant::now();
238        let block_id = self.graph.resolve_required(selector)?;
239        let mut update = self
240            .context
241            .select_block(self.graph.document(), block_id, detail_level);
242        self.record_mutation(
243            &mut update,
244            CodeGraphSessionMutationKind::Select,
245            "select",
246            Some(selector.to_string()),
247            Some(block_id),
248            vec![block_id],
249            None,
250            None,
251            focus_before,
252            started,
253            Some(format!(
254                "Selected {} at {:?} detail.",
255                block_id, detail_level
256            )),
257        );
258        self.note_prune_effects("select", &update);
259        Ok(update)
260    }
261
262    pub fn expand(
263        &mut self,
264        selector: &str,
265        mode: CodeGraphExpandMode,
266        traversal: &CodeGraphTraversalConfig,
267    ) -> Result<CodeGraphContextUpdate> {
268        let focus_before = self.context.focus;
269        let started = Instant::now();
270        let block_id = self.graph.resolve_required(selector)?;
271        let mut update = match mode {
272            CodeGraphExpandMode::File => {
273                self.context
274                    .expand_file_with_config(self.graph.document(), block_id, traversal)
275            }
276            CodeGraphExpandMode::Dependencies => self.context.expand_dependencies_with_config(
277                self.graph.document(),
278                block_id,
279                traversal,
280            ),
281            CodeGraphExpandMode::Dependents => self.context.expand_dependents_with_config(
282                self.graph.document(),
283                block_id,
284                traversal,
285            ),
286        };
287        self.record_mutation(
288            &mut update,
289            match mode {
290                CodeGraphExpandMode::File => CodeGraphSessionMutationKind::ExpandFile,
291                CodeGraphExpandMode::Dependencies => {
292                    CodeGraphSessionMutationKind::ExpandDependencies
293                }
294                CodeGraphExpandMode::Dependents => CodeGraphSessionMutationKind::ExpandDependents,
295            },
296            match mode {
297                CodeGraphExpandMode::File => "expand_file",
298                CodeGraphExpandMode::Dependencies => "expand_dependencies",
299                CodeGraphExpandMode::Dependents => "expand_dependents",
300            },
301            Some(selector.to_string()),
302            Some(block_id),
303            vec![block_id],
304            Some(traversal.clone()),
305            traversal.budget.clone(),
306            focus_before,
307            started,
308            Some(format!("Expanded {} via {:?} traversal.", block_id, mode)),
309        );
310        self.note_prune_effects("expand", &update);
311        Ok(update)
312    }
313
314    pub fn hydrate_source(
315        &mut self,
316        selector: &str,
317        padding: usize,
318    ) -> Result<CodeGraphContextUpdate> {
319        self.hydrate_source_with_budget(selector, padding, None)
320    }
321
322    pub fn hydrate_source_with_budget(
323        &mut self,
324        selector: &str,
325        padding: usize,
326        budget: Option<CodeGraphOperationBudget>,
327    ) -> Result<CodeGraphContextUpdate> {
328        let focus_before = self.context.focus;
329        let started = Instant::now();
330        let block_id = self.graph.resolve_required(selector)?;
331        let mut update = self.context.hydrate_source_with_budget(
332            self.graph.document(),
333            block_id,
334            padding,
335            budget.as_ref(),
336        );
337        self.record_mutation(
338            &mut update,
339            CodeGraphSessionMutationKind::Hydrate,
340            "hydrate",
341            Some(selector.to_string()),
342            Some(block_id),
343            vec![block_id],
344            None,
345            budget,
346            focus_before,
347            started,
348            Some(format!(
349                "Hydrated source for {} with padding {}.",
350                block_id, padding
351            )),
352        );
353        self.note_prune_effects("hydrate", &update);
354        Ok(update)
355    }
356
357    pub fn collapse(
358        &mut self,
359        selector: &str,
360        include_descendants: bool,
361    ) -> Result<CodeGraphContextUpdate> {
362        let focus_before = self.context.focus;
363        let started = Instant::now();
364        let block_id = self.graph.resolve_required(selector)?;
365        let mut update =
366            self.context
367                .collapse(self.graph.document(), block_id, include_descendants);
368        self.record_mutation(
369            &mut update,
370            CodeGraphSessionMutationKind::Collapse,
371            "collapse",
372            Some(selector.to_string()),
373            Some(block_id),
374            vec![block_id],
375            None,
376            None,
377            focus_before,
378            started,
379            Some(format!(
380                "Collapsed {} (include_descendants={}).",
381                block_id, include_descendants
382            )),
383        );
384        Ok(update)
385    }
386
387    pub fn pin(&mut self, selector: &str, pinned: bool) -> Result<CodeGraphContextUpdate> {
388        let focus_before = self.context.focus;
389        let started = Instant::now();
390        let block_id = self.graph.resolve_required(selector)?;
391        let mut update = self.context.pin(block_id, pinned);
392        self.record_mutation(
393            &mut update,
394            if pinned {
395                CodeGraphSessionMutationKind::Pin
396            } else {
397                CodeGraphSessionMutationKind::Unpin
398            },
399            if pinned { "pin" } else { "unpin" },
400            Some(selector.to_string()),
401            Some(block_id),
402            vec![block_id],
403            None,
404            None,
405            focus_before,
406            started,
407            Some(format!(
408                "{} {} in the working set.",
409                if pinned { "Pinned" } else { "Unpinned" },
410                block_id
411            )),
412        );
413        Ok(update)
414    }
415
416    pub fn prune(&mut self, max_selected: Option<usize>) -> CodeGraphContextUpdate {
417        let focus_before = self.context.focus;
418        let started = Instant::now();
419        let mut update = self.context.prune(self.graph.document(), max_selected);
420        self.record_mutation(
421            &mut update,
422            CodeGraphSessionMutationKind::Prune,
423            "prune",
424            None,
425            None,
426            Vec::new(),
427            None,
428            None,
429            focus_before,
430            started,
431            Some(format!(
432                "Applied prune policy with max_selected={}.",
433                max_selected
434                    .map(|value| value.to_string())
435                    .unwrap_or_else(|| self.context.prune_policy.max_selected.to_string())
436            )),
437        );
438        self.note_prune_effects("prune", &update);
439        update
440    }
441
442    pub fn export(
443        &self,
444        render: &CodeGraphRenderConfig,
445        export: &CodeGraphExportConfig,
446    ) -> CodeGraphContextExport {
447        export_codegraph_context_with_config(self.graph.document(), &self.context, render, export)
448    }
449
450    pub fn render_prompt(&self, render: &CodeGraphRenderConfig) -> String {
451        render_codegraph_context_prompt(self.graph.document(), &self.context, render)
452    }
453
454    pub fn find_nodes(&self, query: &CodeGraphFindQuery) -> Result<Vec<CodeGraphNodeSummary>> {
455        self.graph.find_nodes(query)
456    }
457
458    pub fn explain_selector(&self, selector: &str) -> CodeGraphSelectorResolutionExplanation {
459        query::explain_selector(self.graph.document(), selector)
460    }
461
462    pub fn why_selected(&self, selector: &str) -> Result<CodeGraphSelectionExplanation> {
463        let block_id = self.graph.resolve_required(selector)?;
464        let node = self.graph.describe_node(block_id);
465        let provenance_chain = self.provenance_chain(block_id);
466        let Some(selected) = self.context.selected.get(&block_id) else {
467            return Ok(CodeGraphSelectionExplanation {
468                selector: selector.to_string(),
469                block_id,
470                selected: false,
471                focus: self.context.focus == Some(block_id),
472                pinned: false,
473                detail_level: None,
474                origin: None,
475                explanation: "Node is not currently selected in the session.".to_string(),
476                node,
477                anchor: None,
478                provenance_chain,
479            });
480        };
481
482        let anchor = selected
483            .origin
484            .as_ref()
485            .and_then(|origin| origin.anchor)
486            .and_then(|id| self.graph.describe_node(id));
487        let explanation = match selected.origin.as_ref().map(|origin| origin.kind) {
488            Some(CodeGraphSelectionOriginKind::Manual) => {
489                "Node was selected directly by the agent.".to_string()
490            }
491            Some(CodeGraphSelectionOriginKind::Overview) => {
492                "Node was selected as part of the overview scaffold.".to_string()
493            }
494            Some(CodeGraphSelectionOriginKind::FileSymbols) => {
495                "Node was selected while expanding file symbols.".to_string()
496            }
497            Some(CodeGraphSelectionOriginKind::Dependencies) => format!(
498                "Node was selected while following dependency edges{}.",
499                relation_suffix(selected.origin.as_ref())
500            ),
501            Some(CodeGraphSelectionOriginKind::Dependents) => format!(
502                "Node was selected while following dependent edges{}.",
503                relation_suffix(selected.origin.as_ref())
504            ),
505            None => "Node is selected in the session.".to_string(),
506        };
507
508        Ok(CodeGraphSelectionExplanation {
509            selector: selector.to_string(),
510            block_id,
511            selected: true,
512            focus: self.context.focus == Some(block_id),
513            pinned: selected.pinned,
514            detail_level: Some(selected.detail_level),
515            origin: selected.origin.clone(),
516            explanation,
517            node,
518            anchor,
519            provenance_chain,
520        })
521    }
522
523    pub fn explain_export_omission(
524        &self,
525        selector: &str,
526        render: &CodeGraphRenderConfig,
527        export: &CodeGraphExportConfig,
528    ) -> Result<CodeGraphExportOmissionExplanation> {
529        let resolved = self.explain_selector(selector);
530        let export_result = self.export(render, export);
531        if let Some(block_id) = resolved.resolved_block_id {
532            if let Some(detail) = export_result
533                .omissions
534                .details
535                .iter()
536                .find(|detail| detail.block_id == Some(block_id))
537                .cloned()
538            {
539                return Ok(CodeGraphExportOmissionExplanation {
540                    selector: selector.to_string(),
541                    omitted: true,
542                    block_id: Some(block_id),
543                    explanation: detail.explanation.clone(),
544                    detail: Some(detail),
545                });
546            }
547            return Ok(CodeGraphExportOmissionExplanation {
548                selector: selector.to_string(),
549                omitted: false,
550                block_id: Some(block_id),
551                detail: None,
552                explanation: "Node is present in the current export/render output.".to_string(),
553            });
554        }
555        Ok(CodeGraphExportOmissionExplanation {
556            selector: selector.to_string(),
557            omitted: false,
558            block_id: None,
559            detail: None,
560            explanation: resolved.explanation,
561        })
562    }
563
564    pub fn why_pruned(&self, selector: &str) -> Result<CodeGraphPruneExplanation> {
565        let resolution = self.explain_selector(selector);
566        let block_id = resolution.resolved_block_id;
567        let explanation = block_id
568            .and_then(|id| self.prune_notes.get(&id).cloned())
569            .unwrap_or_else(|| "No recorded prune explanation for this selector.".to_string());
570        Ok(CodeGraphPruneExplanation {
571            selector: selector.to_string(),
572            block_id,
573            pruned: block_id
574                .map(|id| self.prune_notes.contains_key(&id))
575                .unwrap_or(false),
576            explanation,
577        })
578    }
579
580    pub fn diff(&self, other: &Self) -> CodeGraphSessionDiff {
581        let before = self
582            .context
583            .selected
584            .keys()
585            .copied()
586            .collect::<HashSet<_>>();
587        let after = other
588            .context
589            .selected
590            .keys()
591            .copied()
592            .collect::<HashSet<_>>();
593        let mut added = after
594            .difference(&before)
595            .copied()
596            .filter_map(|id| other.graph.describe_node(id))
597            .collect::<Vec<_>>();
598        let mut removed = before
599            .difference(&after)
600            .copied()
601            .filter_map(|id| self.graph.describe_node(id))
602            .collect::<Vec<_>>();
603        added.sort_by(|left, right| left.label.cmp(&right.label));
604        removed.sort_by(|left, right| left.label.cmp(&right.label));
605        CodeGraphSessionDiff {
606            added,
607            removed,
608            focus_before: self.context.focus,
609            focus_after: other.context.focus,
610            changed_focus: self.context.focus != other.context.focus,
611        }
612    }
613
614    pub fn recommendations(&self, top: usize) -> Vec<CodeGraphRecommendation> {
615        self.export(
616            &CodeGraphRenderConfig::default(),
617            &CodeGraphExportConfig::default(),
618        )
619        .heuristics
620        .recommendations
621        .into_iter()
622        .filter(|item| item.candidate_count > 0)
623        .take(top.max(1))
624        .collect()
625    }
626
627    pub fn estimate_expand(
628        &self,
629        selector: &str,
630        mode: CodeGraphExpandMode,
631        traversal: &CodeGraphTraversalConfig,
632    ) -> Result<CodeGraphMutationEstimate> {
633        let block_id = self.graph.resolve_required(selector)?;
634        let mut branch = self.fork();
635        let before_selected = branch.selected_block_ids().len() as isize;
636        let update = branch.expand(selector, mode, traversal)?;
637        let after_export = branch.export(
638            &CodeGraphRenderConfig::default(),
639            &CodeGraphExportConfig::compact(),
640        );
641        Ok(CodeGraphMutationEstimate {
642            operation: format!("{:?}", mode).to_lowercase(),
643            selector: Some(selector.to_string()),
644            target_block_id: Some(block_id),
645            resolved_block_ids: vec![block_id],
646            budget: traversal.budget.clone(),
647            estimated_nodes_added: update.added.len(),
648            estimated_nodes_changed: update.changed.len(),
649            estimated_nodes_visited: update.added.len().saturating_add(1),
650            estimated_frontier_width: after_export.frontier.len(),
651            estimated_rendered_bytes: after_export.rendered.len(),
652            estimated_rendered_tokens: crate::approximate_prompt_tokens(&after_export.rendered),
653            estimated_export_growth: branch.selected_block_ids().len() as isize - before_selected,
654            explanation: format!(
655                "Estimated {:?} expansion for {} by simulating the mutation on a forked session.",
656                mode, selector
657            ),
658        })
659    }
660
661    pub fn estimate_hydrate(
662        &self,
663        selector: &str,
664        padding: usize,
665        budget: Option<CodeGraphOperationBudget>,
666    ) -> Result<CodeGraphMutationEstimate> {
667        let block_id = self.graph.resolve_required(selector)?;
668        let mut branch = self.fork();
669        let before_selected = branch.selected_block_ids().len() as isize;
670        let update = branch.hydrate_source_with_budget(selector, padding, budget.clone())?;
671        let after_export = branch.export(
672            &CodeGraphRenderConfig::default(),
673            &CodeGraphExportConfig::compact(),
674        );
675        Ok(CodeGraphMutationEstimate {
676            operation: "hydrate".to_string(),
677            selector: Some(selector.to_string()),
678            target_block_id: Some(block_id),
679            resolved_block_ids: vec![block_id],
680            budget,
681            estimated_nodes_added: update.added.len(),
682            estimated_nodes_changed: update.changed.len(),
683            estimated_nodes_visited: 1,
684            estimated_frontier_width: after_export.frontier.len(),
685            estimated_rendered_bytes: after_export.rendered.len(),
686            estimated_rendered_tokens: crate::approximate_prompt_tokens(&after_export.rendered),
687            estimated_export_growth: branch.selected_block_ids().len() as isize - before_selected,
688            explanation: format!(
689                "Estimated hydration cost for {} by simulating source hydration on a forked session.",
690                selector
691            ),
692        })
693    }
694
695    pub fn apply_recommended_actions(
696        &mut self,
697        top: usize,
698        padding: usize,
699        depth: Option<usize>,
700        max_add: Option<usize>,
701        priority_threshold: Option<u16>,
702    ) -> Result<CodeGraphRecommendedActionsResult> {
703        let focus_before = self.context.focus;
704        let started = Instant::now();
705        let actions = self
706            .recommendations(top.max(1))
707            .into_iter()
708            .filter(|action| {
709                priority_threshold
710                    .map(|threshold| action.priority >= threshold)
711                    .unwrap_or(true)
712            })
713            .take(top.max(1))
714            .collect::<Vec<_>>();
715        if actions.is_empty() {
716            return Err(anyhow!(
717                "No recommended actions available for the current focus"
718            ));
719        }
720
721        let mut update = CodeGraphContextUpdate::default();
722        let mut applied_actions = Vec::new();
723        let events_before = self.event_log.len();
724        for action in &actions {
725            let frontier_action = frontier_from_recommendation(action);
726            let traversal = CodeGraphTraversalConfig {
727                depth: depth.unwrap_or(1),
728                relation_filters: action.relation_set.clone(),
729                max_add,
730                priority_threshold,
731                budget: None,
732            };
733            applied_actions.push(action_summary(&frontier_action));
734            let target_selector = action.target_block_id.to_string();
735            let next = match action.action_kind.as_str() {
736                "hydrate_source" => self.hydrate_source(&target_selector, padding)?,
737                "expand_file" => {
738                    self.expand(&target_selector, CodeGraphExpandMode::File, &traversal)?
739                }
740                "expand_dependencies" => self.expand(
741                    &target_selector,
742                    CodeGraphExpandMode::Dependencies,
743                    &traversal,
744                )?,
745                "expand_dependents" => self.expand(
746                    &target_selector,
747                    CodeGraphExpandMode::Dependents,
748                    &traversal,
749                )?,
750                "collapse" => self.collapse(&target_selector, false)?,
751                _ => CodeGraphContextUpdate::default(),
752            };
753            merge_update(&mut update, next);
754            let event = CodeGraphSessionEvent::Recommendation {
755                recommendation: Box::new(action.clone()),
756            };
757            self.event_log.push(event.clone());
758            self.observers.emit(&event);
759        }
760
761        self.record_mutation(
762            &mut update,
763            CodeGraphSessionMutationKind::ApplyRecommendedActions,
764            "apply_recommended_actions",
765            None,
766            self.context.focus,
767            actions.iter().map(|item| item.target_block_id).collect(),
768            None,
769            None,
770            focus_before,
771            started,
772            Some(format!("Applied {} recommended action(s).", actions.len())),
773        );
774        let events = self.event_log[events_before..].to_vec();
775        Ok(CodeGraphRecommendedActionsResult {
776            applied_actions,
777            recommendations: actions,
778            update,
779            events,
780        })
781    }
782
783    pub fn path_between(
784        &self,
785        start_selector: &str,
786        end_selector: &str,
787        max_hops: usize,
788    ) -> Result<Option<CodeGraphPathResult>> {
789        let start = self.graph.resolve_required(start_selector)?;
790        let end = self.graph.resolve_required(end_selector)?;
791        Ok(query::path_between(
792            self.graph.document(),
793            start,
794            end,
795            max_hops,
796        ))
797    }
798
799    pub fn to_persisted(&self) -> Result<CodeGraphPersistedSession> {
800        let graph_snapshot_hash = canonical_fingerprint(self.graph.document())?;
801        let session_snapshot_hash = session_snapshot_hash(
802            &self.context,
803            &self.mutation_log,
804            &self.session_id,
805            self.parent_session_id.as_deref(),
806        )?;
807        Ok(CodeGraphPersistedSession {
808            metadata: CodeGraphSessionPersistenceMetadata {
809                schema_version: "codegraph_session.v1".to_string(),
810                session_id: self.session_id.clone(),
811                parent_session_id: self.parent_session_id.clone(),
812                graph_snapshot_hash,
813                session_snapshot_hash,
814                mutation_count: self.mutation_log.len(),
815            },
816            context: self.context.clone(),
817            mutation_log: self.mutation_log.clone(),
818            event_log: self.event_log.clone(),
819        })
820    }
821
822    pub fn to_json(&self) -> Result<String> {
823        serde_json::to_string_pretty(&self.to_persisted()?).map_err(Into::into)
824    }
825
826    pub fn save(&mut self, path: impl AsRef<Path>) -> Result<()> {
827        let persisted = self.to_persisted()?;
828        fs::write(path.as_ref(), serde_json::to_string_pretty(&persisted)?)
829            .map_err(anyhow::Error::from)?;
830        let event = CodeGraphSessionEvent::SessionSaved {
831            metadata: persisted.metadata,
832        };
833        self.event_log.push(event.clone());
834        self.observers.emit(&event);
835        Ok(())
836    }
837
838    fn provenance_chain(&self, block_id: BlockId) -> Vec<CodeGraphProvenanceStep> {
839        let mut chain = Vec::new();
840        let mut current = Some(block_id);
841        let mut visited = HashSet::new();
842        while let Some(next_id) = current {
843            if !visited.insert(next_id) {
844                break;
845            }
846            let node = self.graph.describe_node(next_id);
847            let selected = self.context.selected.get(&next_id);
848            let explanation = match selected.and_then(|item| item.origin.as_ref()) {
849                Some(origin) => match origin.kind {
850                    CodeGraphSelectionOriginKind::Manual => {
851                        "Selected directly by the agent.".to_string()
852                    }
853                    CodeGraphSelectionOriginKind::Overview => {
854                        "Included by the session overview scaffold.".to_string()
855                    }
856                    CodeGraphSelectionOriginKind::FileSymbols => {
857                        "Reached while opening file symbols.".to_string()
858                    }
859                    CodeGraphSelectionOriginKind::Dependencies => format!(
860                        "Reached while following dependency edges{}.",
861                        relation_suffix(selected.and_then(|item| item.origin.as_ref()))
862                    ),
863                    CodeGraphSelectionOriginKind::Dependents => format!(
864                        "Reached while following dependent edges{}.",
865                        relation_suffix(selected.and_then(|item| item.origin.as_ref()))
866                    ),
867                },
868                None => "Selected without a recorded origin.".to_string(),
869            };
870            chain.push(CodeGraphProvenanceStep {
871                block_id: next_id,
872                node,
873                origin: selected.and_then(|item| item.origin.clone()),
874                explanation,
875            });
876            current = selected
877                .and_then(|item| item.origin.as_ref())
878                .and_then(|origin| origin.anchor);
879        }
880        chain
881    }
882
883    fn note_prune_effects(&mut self, operation: &str, update: &CodeGraphContextUpdate) {
884        for block_id in &update.removed {
885            self.prune_notes.insert(
886                *block_id,
887                format!(
888                    "Node was removed while applying prune policy after {}.",
889                    operation
890                ),
891            );
892        }
893        for block_id in &update.changed {
894            self.prune_notes.entry(*block_id).or_insert_with(|| {
895                format!(
896                    "Node detail was adjusted while applying prune policy after {}.",
897                    operation
898                )
899            });
900        }
901    }
902
903    #[allow(clippy::too_many_arguments)]
904    fn record_mutation(
905        &mut self,
906        update: &mut CodeGraphContextUpdate,
907        kind: CodeGraphSessionMutationKind,
908        operation: &str,
909        selector: Option<String>,
910        target_block_id: Option<BlockId>,
911        resolved_block_ids: Vec<BlockId>,
912        traversal: Option<CodeGraphTraversalConfig>,
913        budget: Option<CodeGraphOperationBudget>,
914        focus_before: Option<BlockId>,
915        started: Instant,
916        reason: Option<String>,
917    ) {
918        let telemetry_budget = budget
919            .as_ref()
920            .and_then(|value| value.max_emitted_telemetry_events);
921        if telemetry_budget == Some(0) {
922            return;
923        }
924
925        let mutation = CodeGraphSessionMutation {
926            sequence: self.next_sequence,
927            kind,
928            operation: operation.to_string(),
929            selector,
930            target_block_id,
931            resolved_block_ids,
932            traversal,
933            budget,
934            nodes_added: update.added.clone(),
935            nodes_removed: update.removed.clone(),
936            nodes_changed: update.changed.clone(),
937            focus_before,
938            focus_after: update.focus,
939            elapsed_ms: started.elapsed().as_millis() as u64,
940            reason,
941            warnings: update.warnings.clone(),
942        };
943        self.next_sequence += 1;
944        self.mutation_log.push(mutation.clone());
945        update.telemetry.push(mutation.clone());
946        let event = CodeGraphSessionEvent::Mutation {
947            mutation: Box::new(mutation.clone()),
948        };
949        self.event_log.push(event.clone());
950        self.observers.emit(&event);
951    }
952}
953
954fn merge_update(into: &mut CodeGraphContextUpdate, next: CodeGraphContextUpdate) {
955    into.added.extend(next.added);
956    into.removed.extend(next.removed);
957    into.changed.extend(next.changed);
958    into.warnings.extend(next.warnings);
959    into.telemetry.extend(next.telemetry);
960    if next.focus.is_some() {
961        into.focus = next.focus;
962    }
963}
964
965fn frontier_from_recommendation(
966    action: &CodeGraphRecommendation,
967) -> CodeGraphContextFrontierAction {
968    CodeGraphContextFrontierAction {
969        block_id: action.target_block_id,
970        short_id: action.target_short_id.clone(),
971        action: action.action_kind.clone(),
972        relation: action.relation_set.first().cloned(),
973        direction: None,
974        candidate_count: action.candidate_count,
975        priority: action.priority,
976        description: action.explanation.clone(),
977        explanation: Some(action.rationale.clone()),
978    }
979}
980
981fn action_summary(action: &CodeGraphContextFrontierAction) -> String {
982    match action.relation.as_deref() {
983        Some(relation) => format!("{} {} via {}", action.action, action.short_id, relation),
984        None => format!("{} {}", action.action, action.short_id),
985    }
986}
987
988fn relation_suffix(origin: Option<&crate::CodeGraphSelectionOrigin>) -> String {
989    origin
990        .and_then(|value| value.relation.as_deref())
991        .map(|relation| format!(" via `{}`", relation))
992        .unwrap_or_default()
993}
994
995fn new_session_id(seed: &str, sequence: usize) -> String {
996    let mut hasher = Sha256::new();
997    hasher.update(seed.as_bytes());
998    hasher.update(sequence.to_string().as_bytes());
999    hasher.update(chrono::Utc::now().to_rfc3339().as_bytes());
1000    let digest = hex::encode(hasher.finalize());
1001    format!("cgs_{}", &digest[..16])
1002}
1003
1004fn session_snapshot_hash(
1005    context: &CodeGraphContextSession,
1006    mutation_log: &[CodeGraphSessionMutation],
1007    session_id: &str,
1008    parent_session_id: Option<&str>,
1009) -> Result<String> {
1010    let payload = serde_json::json!({
1011        "session_id": session_id,
1012        "parent_session_id": parent_session_id,
1013        "context": context,
1014        "mutation_log": mutation_log,
1015    });
1016    let bytes = serde_json::to_vec(&payload)?;
1017    let mut hasher = Sha256::new();
1018    hasher.update(bytes);
1019    Ok(hex::encode(hasher.finalize()))
1020}