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}