1use crate::collect::tail::claude::scan_claude_session_dir;
5use crate::collect::tail::codex::scan_codex_session_dir;
6use crate::collect::tail::copilot_cli::scan_copilot_cli_workspace;
7use crate::collect::tail::copilot_vscode::scan_copilot_vscode_workspace;
8use crate::collect::tail::cursor::scan_session_dir_all;
9use crate::collect::tail::goose::scan_goose_workspace;
10use crate::collect::tail::openclaw::scan_openclaw_workspace;
11use crate::collect::tail::opencode::scan_opencode_workspace;
12use crate::core::config;
13use crate::core::event::{Event, SessionRecord};
14use crate::metrics::report;
15use crate::shell::fmt::fmt_ts;
16use crate::shell::scope;
17use crate::store::{SYNC_STATE_LAST_AGENT_SCAN_MS, SYNC_STATE_LAST_AUTO_PRUNE_MS, Store};
18use anyhow::Result;
19use serde::Serialize;
20use std::collections::HashMap;
21use std::io::IsTerminal;
22use std::path::{Path, PathBuf};
23
24pub use crate::shell::init::cmd_init;
25pub use crate::shell::insights::cmd_insights;
26
27#[derive(Serialize)]
28struct SessionsListJson {
29 workspace: String,
30 #[serde(skip_serializing_if = "Vec::is_empty")]
31 workspaces: Vec<String>,
32 count: usize,
33 sessions: Vec<SessionRecord>,
34}
35
36#[derive(Serialize)]
37struct SummaryJsonOut {
38 workspace: String,
39 #[serde(skip_serializing_if = "Vec::is_empty")]
40 workspaces: Vec<String>,
41 #[serde(flatten)]
42 stats: crate::store::SummaryStats,
43 cost_usd: f64,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 cost_note: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 hotspot: Option<crate::metrics::types::RankedFile>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 slowest_tool: Option<crate::metrics::types::RankedTool>,
50}
51
52pub(crate) fn summary_needs_cost_rollup_note(session_count: u64, total_cost_usd_e6: i64) -> bool {
54 session_count > 0 && total_cost_usd_e6 == 0
55}
56
57pub(crate) fn cost_rollup_zero_note_paragraph() -> &'static str {
58 "Cost rollup shows $0.00 because stored events have no cost_usd_e6 — common when Cursor agent-transcript lines omit usage/tokens. \
59If you expect non-zero spend, ingest Claude/Codex transcripts with usage, hooks with total_cost_usd, or Kaizen proxy Cost events; run `kaizen summary --refresh` after ingest changes. \
60See docs/usage.md#cost-shows-zero."
61}
62
63pub(crate) fn cost_rollup_zero_doctor_hint() -> &'static str {
64 "Cost rollup $0.00 with sessions but no cost_usd_e6 — often Cursor transcripts without usage; see docs/usage.md#cost-shows-zero"
65}
66
67struct ScanSpinner(Option<indicatif::ProgressBar>);
68
69impl ScanSpinner {
70 fn start(msg: &'static str) -> Self {
71 if !std::io::stdout().is_terminal() {
72 return Self(None);
73 }
74 let p = indicatif::ProgressBar::new_spinner();
75 p.set_message(msg.to_string());
76 p.enable_steady_tick(std::time::Duration::from_millis(120));
77 Self(Some(p))
78 }
79}
80
81impl Drop for ScanSpinner {
82 fn drop(&mut self) {
83 if let Some(p) = self.0.take() {
84 p.finish_and_clear();
85 }
86 }
87}
88
89fn now_ms_u64() -> u64 {
90 std::time::SystemTime::now()
91 .duration_since(std::time::UNIX_EPOCH)
92 .unwrap_or_default()
93 .as_millis() as u64
94}
95
96const AUTO_PRUNE_INTERVAL_MS: u64 = 86_400_000;
98
99pub(crate) fn maybe_auto_prune_after_scan(store: &Store, cfg: &config::Config) -> Result<()> {
100 if cfg.retention.hot_days == 0 {
101 return Ok(());
102 }
103 let now = now_ms_u64();
104 if let Some(last) = store.sync_state_get_u64(SYNC_STATE_LAST_AUTO_PRUNE_MS)?
105 && now.saturating_sub(last) < AUTO_PRUNE_INTERVAL_MS
106 {
107 return Ok(());
108 }
109 let cutoff = now.saturating_sub((cfg.retention.hot_days as u64).saturating_mul(86_400_000));
110 store.prune_sessions_started_before(cutoff as i64)?;
111 store.sync_state_set_u64(SYNC_STATE_LAST_AUTO_PRUNE_MS, now)?;
112 Ok(())
113}
114
115pub(crate) fn maybe_scan_all_agents(
117 ws: &Path,
118 cfg: &config::Config,
119 ws_str: &str,
120 store: &Store,
121 refresh: bool,
122) -> Result<()> {
123 let interval_ms = cfg.scan.min_rescan_seconds.saturating_mul(1000);
124 let now = now_ms_u64();
125 if !refresh
126 && interval_ms > 0
127 && let Some(last) = store.sync_state_get_u64(SYNC_STATE_LAST_AGENT_SCAN_MS)?
128 && now.saturating_sub(last) < interval_ms
129 {
130 return Ok(());
131 }
132 scan_all_agents(ws, cfg, ws_str, store)?;
133 store.sync_state_set_u64(SYNC_STATE_LAST_AGENT_SCAN_MS, now_ms_u64())?;
134 Ok(())
135}
136
137pub(crate) fn maybe_refresh_store(workspace: &Path, store: &Store, refresh: bool) -> Result<()> {
138 if !refresh {
139 return Ok(());
140 }
141 let cfg = config::load(workspace)?;
142 let ws_str = workspace.to_string_lossy().to_string();
143 maybe_scan_all_agents(workspace, &cfg, &ws_str, store, true)
144}
145
146fn combine_counts(rows: Vec<Vec<(String, u64)>>) -> Vec<(String, u64)> {
147 let mut counts = HashMap::new();
148 for set in rows {
149 for (key, value) in set {
150 *counts.entry(key).or_insert(0_u64) += value;
151 }
152 }
153 let mut out = counts.into_iter().collect::<Vec<_>>();
154 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
155 out
156}
157
158fn workspace_names(roots: &[PathBuf]) -> Vec<String> {
159 roots
160 .iter()
161 .map(|path| path.to_string_lossy().to_string())
162 .collect()
163}
164
165fn open_workspace_store(workspace: &Path) -> Result<Store> {
166 Store::open(&crate::core::workspace::db_path(workspace)?)
167}
168
169pub(crate) fn open_workspace_read_store(workspace: &Path, refresh: bool) -> Result<Store> {
170 let db_path = crate::core::workspace::db_path(workspace)?;
171 if refresh || !db_path.exists() {
172 Store::open(&db_path)
173 } else {
174 Store::open_query(&db_path)
175 }
176}
177
178pub fn sessions_list_text(
180 workspace: Option<&Path>,
181 json_out: bool,
182 refresh: bool,
183 all_workspaces: bool,
184 limit: Option<usize>,
185) -> Result<String> {
186 let roots = scope::resolve(workspace, all_workspaces)?;
187 let output_limit = limit.unwrap_or(100);
188 let query_limit = if output_limit == 0 {
189 i64::MAX as usize
190 } else {
191 output_limit
192 };
193 let mut sessions = Vec::new();
194 if crate::daemon::enabled() && !refresh {
195 for workspace in &roots {
196 let ws_str = workspace.to_string_lossy().to_string();
197 let response =
198 crate::daemon::request_blocking(crate::ipc::DaemonRequest::ListSessions {
199 workspace: ws_str,
200 offset: 0,
201 limit: query_limit,
202 filter: crate::store::SessionFilter::default(),
203 })?;
204 match response {
205 crate::ipc::DaemonResponse::Sessions(page) => sessions.extend(page.rows),
206 crate::ipc::DaemonResponse::Error { message, .. } => anyhow::bail!(message),
207 _ => anyhow::bail!("unexpected daemon sessions response"),
208 }
209 }
210 } else {
211 for workspace in &roots {
212 let store = open_workspace_read_store(workspace, refresh)?;
213 maybe_refresh_store(workspace, &store, refresh)?;
214 let ws_str = workspace.to_string_lossy().to_string();
215 if output_limit == 0 {
216 sessions.extend(store.list_sessions(&ws_str)?);
217 } else {
218 sessions.extend(
219 store
220 .list_sessions_page(
221 &ws_str,
222 0,
223 query_limit,
224 crate::store::SessionFilter::default(),
225 )?
226 .rows,
227 );
228 }
229 }
230 }
231 sessions.sort_by(|a, b| {
232 b.started_at_ms
233 .cmp(&a.started_at_ms)
234 .then_with(|| a.id.cmp(&b.id))
235 });
236 if output_limit > 0 {
237 let n = output_limit;
238 sessions.truncate(n);
239 }
240 let scope_label = scope::label(&roots);
241 let workspaces = if roots.len() > 1 {
242 workspace_names(&roots)
243 } else {
244 Vec::new()
245 };
246 if json_out {
247 return Ok(format!(
248 "{}\n",
249 serde_json::to_string_pretty(&SessionsListJson {
250 workspace: scope_label,
251 workspaces,
252 count: sessions.len(),
253 sessions,
254 })?
255 ));
256 }
257 use std::fmt::Write;
258 let mut out = String::new();
259 if roots.len() > 1 {
260 writeln!(&mut out, "Scope: {scope_label}").unwrap();
261 writeln!(&mut out).unwrap();
262 }
263 writeln!(
264 &mut out,
265 "{:<40} {:<10} {:<10} STARTED",
266 "ID", "AGENT", "STATUS"
267 )
268 .unwrap();
269 writeln!(&mut out, "{}", "-".repeat(80)).unwrap();
270 for s in &sessions {
271 writeln!(
272 &mut out,
273 "{:<40} {:<10} {:<10} {}",
274 s.id,
275 s.agent,
276 format!("{:?}", s.status),
277 fmt_ts(s.started_at_ms),
278 )
279 .unwrap();
280 }
281 if sessions.is_empty() {
282 writeln!(&mut out, "(no sessions)").unwrap();
283 sessions_empty_state_hints(&mut out);
284 }
285 Ok(out)
286}
287
288fn sessions_empty_state_hints(out: &mut String) {
289 use std::fmt::Write;
290 let _ = writeln!(out);
291 let _ = writeln!(out, "No sessions found for this workspace. Try:");
292 let _ = writeln!(out, " · `kaizen doctor` — verify config and hooks");
293 let _ = writeln!(out, " · a short agent session in this repo, then re-run");
294 let _ = writeln!(
295 out,
296 " · docs: https://github.com/marquesds/kaizen/blob/main/docs/config.md (sources)"
297 );
298}
299
300pub fn cmd_sessions_list(
302 workspace: Option<&Path>,
303 json_out: bool,
304 refresh: bool,
305 all_workspaces: bool,
306 limit: Option<usize>,
307) -> Result<()> {
308 print!(
309 "{}",
310 sessions_list_text(workspace, json_out, refresh, all_workspaces, limit)?
311 );
312 Ok(())
313}
314
315pub fn session_show_text(id: &str, workspace: Option<&Path>) -> Result<String> {
317 let ws = workspace_path(workspace)?;
318 let store = open_workspace_store(&ws)?;
319 use std::fmt::Write;
320 let mut out = String::new();
321 match store.get_session(id)? {
322 Some(s) => {
323 writeln!(&mut out, "id: {}", s.id).unwrap();
324 writeln!(&mut out, "agent: {}", s.agent).unwrap();
325 writeln!(
326 &mut out,
327 "model: {}",
328 s.model.as_deref().unwrap_or("-")
329 )
330 .unwrap();
331 writeln!(&mut out, "workspace: {}", s.workspace).unwrap();
332 writeln!(&mut out, "started_at: {}", fmt_ts(s.started_at_ms)).unwrap();
333 writeln!(
334 &mut out,
335 "ended_at: {}",
336 s.ended_at_ms.map(fmt_ts).unwrap_or_else(|| "-".to_string())
337 )
338 .unwrap();
339 writeln!(&mut out, "status: {:?}", s.status).unwrap();
340 writeln!(&mut out, "trace_path: {}", s.trace_path).unwrap();
341 if let Some(fp) = &s.prompt_fingerprint {
342 writeln!(&mut out, "prompt_fp: {fp}").unwrap();
343 if let Ok(Some(snap)) = store.get_prompt_snapshot(fp) {
344 for f in snap.files() {
345 writeln!(&mut out, " - {}", f.path).unwrap();
346 }
347 }
348 }
349 }
350 None => anyhow::bail!("session not found: {id} — try `kaizen sessions list`"),
351 }
352 let evals = store.list_evals_for_session(id).unwrap_or_default();
353 if !evals.is_empty() {
354 writeln!(&mut out, "evals:").unwrap();
355 for e in &evals {
356 writeln!(
357 &mut out,
358 " {} score={:.2} flagged={} {}",
359 e.rubric_id, e.score, e.flagged, e.rationale
360 )
361 .unwrap();
362 }
363 }
364 let fb = store
365 .feedback_for_sessions(&[id.to_string()])
366 .unwrap_or_default();
367 if let Some(r) = fb.get(id) {
368 let score = r
369 .score
370 .as_ref()
371 .map(|s| s.0.to_string())
372 .unwrap_or_else(|| "-".into());
373 let label = r
374 .label
375 .as_ref()
376 .map(|l| l.to_string())
377 .unwrap_or_else(|| "-".into());
378 writeln!(&mut out, "feedback: score={score} label={label}").unwrap();
379 if let Some(n) = &r.note {
380 writeln!(&mut out, " note: {n}").unwrap();
381 }
382 }
383 Ok(out)
384}
385
386pub fn cmd_session_show(id: &str, workspace: Option<&Path>) -> Result<()> {
388 print!("{}", session_show_text(id, workspace)?);
389 Ok(())
390}
391
392pub fn sessions_tree_text(id: &str, max_depth: u32, workspace: Option<&Path>) -> Result<String> {
393 let ws = workspace_path(workspace)?;
394 let store = open_workspace_store(&ws)?;
395 let nodes = store.session_span_tree(id)?;
396 if nodes.is_empty() {
397 if store.get_session(id)?.is_none() {
398 anyhow::bail!("session not found: {id}");
399 }
400 return Ok(format!("(no tool spans for session {id})\n"));
401 }
402 let total_cost: i64 = nodes.iter().map(|n| n.subtree_cost_usd_e6).sum();
403 let mut out = String::new();
404 for node in &nodes {
405 render_node(&mut out, node, 0, max_depth, total_cost);
406 }
407 Ok(out)
408}
409
410fn render_node(
411 out: &mut String,
412 node: &crate::store::span_tree::SpanNode,
413 depth: u32,
414 max_depth: u32,
415 session_total: i64,
416) {
417 use std::fmt::Write;
418 if depth > max_depth {
419 return;
420 }
421 let indent = "│ ".repeat(depth as usize);
422 let prefix = if depth == 0 { "┌─ " } else { "├─ " };
423 let cost_str = match node.span.subtree_cost_usd_e6 {
424 Some(c) => {
425 let pct = if session_total > 0 {
426 c * 100 / session_total
427 } else {
428 0
429 };
430 let flag = if pct > 40 { " ⚡" } else { "" };
431 format!(" ${:.4}{}", c as f64 / 1_000_000.0, flag)
432 }
433 None => String::new(),
434 };
435 writeln!(
436 out,
437 "{}{}{} [{}]{}",
438 indent, prefix, node.span.tool, node.span.status, cost_str
439 )
440 .unwrap();
441 for child in &node.children {
442 render_node(out, child, depth + 1, max_depth, session_total);
443 }
444}
445
446pub fn cmd_sessions_tree_text(
448 id: &str,
449 depth: u32,
450 json: bool,
451 workspace: Option<&Path>,
452) -> Result<String> {
453 if json {
454 let ws = workspace_path(workspace)?;
455 let store = open_workspace_read_store(&ws, false)?;
456 let nodes = store.session_span_tree(id)?;
457 Ok(serde_json::to_string_pretty(&nodes)?)
458 } else {
459 sessions_tree_text(id, depth, workspace)
460 }
461}
462
463pub fn cmd_sessions_tree(id: &str, depth: u32, json: bool, workspace: Option<&Path>) -> Result<()> {
465 print!("{}", cmd_sessions_tree_text(id, depth, json, workspace)?);
466 Ok(())
467}
468
469pub fn summary_text(
471 workspace: Option<&Path>,
472 json_out: bool,
473 refresh: bool,
474 all_workspaces: bool,
475 source: crate::core::data_source::DataSource,
476) -> Result<String> {
477 let roots = scope::resolve(workspace, all_workspaces)?;
478 let mut total_cost_usd_e6 = 0_i64;
479 let mut session_count = 0_u64;
480 let mut by_agent = Vec::new();
481 let mut by_model = Vec::new();
482 let mut top_tools = Vec::new();
483 let mut hottest = Vec::new();
484 let mut slowest = Vec::new();
485
486 for workspace in &roots {
487 let cfg = config::load(workspace)?;
488 let store = open_workspace_read_store(
489 workspace,
490 refresh || source != crate::core::data_source::DataSource::Local,
491 )?;
492 crate::shell::remote_pull::maybe_telemetry_pull(workspace, &store, &cfg, source, refresh)?;
493 maybe_refresh_store(workspace, &store, refresh)?;
494 let ws_str = workspace.to_string_lossy().to_string();
495 let read_store = open_workspace_read_store(workspace, false)?;
496 let query = crate::store::query::QueryStore::open(&crate::core::paths::project_data_dir(
497 workspace,
498 )?)?;
499 let mut stats = query.summary_stats(&read_store, &ws_str)?;
500 if source != crate::core::data_source::DataSource::Local
501 && let Ok(Some(agg)) =
502 crate::shell::remote_observe::try_remote_event_agg(&read_store, &cfg, workspace)
503 {
504 stats = crate::shell::remote_observe::merge_summary_stats(stats, &agg, source);
505 }
506 total_cost_usd_e6 += stats.total_cost_usd_e6;
507 session_count += stats.session_count;
508 by_agent.push(stats.by_agent);
509 by_model.push(stats.by_model);
510 top_tools.push(stats.top_tools);
511 if let Ok(metrics) = report::build_report(&read_store, &ws_str, 7) {
512 if let Some(file) = metrics.hottest_files.first().cloned() {
513 hottest.push(if roots.len() == 1 {
514 file
515 } else {
516 crate::metrics::types::RankedFile {
517 path: scope::decorate_path(workspace, &file.path),
518 ..file
519 }
520 });
521 }
522 if let Some(tool) = metrics.slowest_tools.first().cloned() {
523 slowest.push(tool);
524 }
525 }
526 }
527
528 let stats = crate::store::SummaryStats {
529 session_count,
530 total_cost_usd_e6,
531 by_agent: combine_counts(by_agent),
532 by_model: combine_counts(by_model),
533 top_tools: combine_counts(top_tools),
534 };
535 let cost_dollars = stats.total_cost_usd_e6 as f64 / 1_000_000.0;
536 let hotspot = hottest
537 .into_iter()
538 .max_by(|a, b| a.value.cmp(&b.value).then_with(|| b.path.cmp(&a.path)));
539 let slowest_tool = slowest.into_iter().max_by(|a, b| {
540 a.p95_ms
541 .unwrap_or(0)
542 .cmp(&b.p95_ms.unwrap_or(0))
543 .then_with(|| b.tool.cmp(&a.tool))
544 });
545 let scope_label = scope::label(&roots);
546 let workspaces = if roots.len() > 1 {
547 workspace_names(&roots)
548 } else {
549 Vec::new()
550 };
551 let cost_note = summary_needs_cost_rollup_note(stats.session_count, stats.total_cost_usd_e6)
552 .then_some(cost_rollup_zero_note_paragraph().to_string());
553 if json_out {
554 return Ok(format!(
555 "{}\n",
556 serde_json::to_string_pretty(&SummaryJsonOut {
557 workspace: scope_label,
558 workspaces,
559 cost_usd: cost_dollars,
560 stats,
561 cost_note,
562 hotspot,
563 slowest_tool,
564 })?
565 ));
566 }
567 use std::fmt::Write;
568 let mut out = String::new();
569 if roots.len() > 1 {
570 writeln!(&mut out, "Scope: {}", scope::label(&roots)).unwrap();
571 }
572 writeln!(
573 &mut out,
574 "Sessions: {} Cost: ${:.2}",
575 stats.session_count, cost_dollars
576 )
577 .unwrap();
578
579 if !stats.by_agent.is_empty() {
580 let parts: Vec<String> = stats
581 .by_agent
582 .iter()
583 .map(|(a, n)| format!("{a} {n}"))
584 .collect();
585 writeln!(&mut out, "By agent: {}", parts.join(" · ")).unwrap();
586 }
587 if !stats.by_model.is_empty() {
588 let parts: Vec<String> = stats
589 .by_model
590 .iter()
591 .map(|(m, n)| format!("{m} {n}"))
592 .collect();
593 writeln!(&mut out, "By model: {}", parts.join(" · ")).unwrap();
594 }
595 if !stats.top_tools.is_empty() {
596 let parts: Vec<String> = stats
597 .top_tools
598 .iter()
599 .take(5)
600 .map(|(t, n)| format!("{t} {n}"))
601 .collect();
602 writeln!(&mut out, "Top tools: {}", parts.join(" · ")).unwrap();
603 }
604 if let Some(file) = hotspot {
605 writeln!(&mut out, "Hotspot: {} ({})", file.path, file.value).unwrap();
606 }
607 if let Some(tool) = slowest_tool {
608 let p95 = tool
609 .p95_ms
610 .map(|v| format!("{v}ms"))
611 .unwrap_or_else(|| "-".into());
612 writeln!(&mut out, "Slowest: {} p95 {}", tool.tool, p95).unwrap();
613 }
614 if cost_note.is_some() {
615 writeln!(&mut out).unwrap();
616 writeln!(&mut out, "Note: {}", cost_rollup_zero_note_paragraph()).unwrap();
617 }
618 Ok(out)
619}
620
621pub fn cmd_summary(
623 workspace: Option<&Path>,
624 json_out: bool,
625 refresh: bool,
626 all_workspaces: bool,
627 source: crate::core::data_source::DataSource,
628) -> Result<()> {
629 print!(
630 "{}",
631 summary_text(workspace, json_out, refresh, all_workspaces, source,)?
632 );
633 Ok(())
634}
635
636pub(crate) fn scan_all_agents(
637 ws: &Path,
638 cfg: &config::Config,
639 ws_str: &str,
640 store: &Store,
641) -> Result<()> {
642 let _spin = ScanSpinner::start("Scanning agent sessions…");
643 let slug = workspace_slug(ws_str);
644 let cursor_slug = crate::core::paths::cursor_slug(ws);
645 let claude_slug = crate::core::paths::claude_code_slug(ws);
646 let sync_ctx = crate::sync::ingest_ctx(cfg, ws.to_path_buf());
647
648 for root in &cfg.scan.roots {
649 let expanded = expand_home(root);
650 let cursor_dir = PathBuf::from(&expanded)
651 .join(&cursor_slug)
652 .join("agent-transcripts");
653 scan_agent_dirs(
654 &cursor_dir,
655 store,
656 |p| {
657 scan_session_dir_all(p).map(|sessions| {
658 sessions
659 .into_iter()
660 .map(|(mut r, evs)| {
661 r.workspace = ws_str.to_string();
662 (r, evs)
663 })
664 .collect()
665 })
666 },
667 sync_ctx.as_ref(),
668 )?;
669 }
670
671 let home = std::env::var("HOME").unwrap_or_default();
672
673 let claude_dir = PathBuf::from(&home)
674 .join(".claude/projects")
675 .join(&claude_slug)
676 .join("sessions");
677 scan_agent_dirs(
678 &claude_dir,
679 store,
680 |p| {
681 scan_claude_session_dir(p).map(|(mut r, evs)| {
682 r.workspace = ws_str.to_string();
683 vec![(r, evs)]
684 })
685 },
686 sync_ctx.as_ref(),
687 )?;
688
689 let codex_dir = PathBuf::from(&home).join(".codex/sessions").join(&slug);
690 scan_agent_dirs(
691 &codex_dir,
692 store,
693 |p| {
694 scan_codex_session_dir(p).map(|(mut r, evs)| {
695 r.workspace = ws_str.to_string();
696 vec![(r, evs)]
697 })
698 },
699 sync_ctx.as_ref(),
700 )?;
701
702 let tail = &cfg.sources.tail;
703 let home_pb = PathBuf::from(&home);
704 if tail.goose {
705 let sessions = scan_goose_workspace(&home_pb, ws)?;
706 persist_session_batch(store, sessions, sync_ctx.as_ref())?;
707 }
708 if tail.openclaw {
709 let sessions = scan_openclaw_workspace(ws)?;
710 persist_session_batch(store, sessions, sync_ctx.as_ref())?;
711 }
712 if tail.opencode {
713 let sessions = scan_opencode_workspace(ws)?;
714 persist_session_batch(store, sessions, sync_ctx.as_ref())?;
715 }
716 if tail.copilot_cli {
717 let sessions = scan_copilot_cli_workspace(ws)?;
718 persist_session_batch(store, sessions, sync_ctx.as_ref())?;
719 }
720 if tail.copilot_vscode {
721 let sessions = scan_copilot_vscode_workspace(ws)?;
722 persist_session_batch(store, sessions, sync_ctx.as_ref())?;
723 }
724
725 maybe_auto_prune_after_scan(store, cfg)?;
726 Ok(())
727}
728
729fn persist_session_batch(
730 store: &Store,
731 sessions: Vec<(SessionRecord, Vec<Event>)>,
732 sync_ctx: Option<&crate::sync::SyncIngestContext>,
733) -> Result<()> {
734 for (mut record, events) in sessions {
735 if record.start_commit.is_none() && !record.workspace.is_empty() {
736 let binding = crate::core::repo::binding_for_session(
737 Path::new(&record.workspace),
738 record.started_at_ms,
739 record.ended_at_ms,
740 );
741 record.start_commit = binding.start_commit;
742 record.end_commit = binding.end_commit;
743 record.branch = binding.branch;
744 record.dirty_start = binding.dirty_start;
745 record.dirty_end = binding.dirty_end;
746 record.repo_binding_source = binding.source;
747 }
748 store.upsert_session(&record)?;
749 let flush_ms = record.ended_at_ms.unwrap_or(record.started_at_ms);
750 for ev in events {
751 store.append_event_with_sync(&ev, sync_ctx)?;
752 }
753 if record.status == crate::core::event::SessionStatus::Done {
754 store.flush_projector_session(&record.id, flush_ms)?;
755 }
756 }
757 Ok(())
758}
759
760pub(crate) fn scan_agent_dirs<F>(
761 dir: &Path,
762 store: &Store,
763 scanner: F,
764 sync_ctx: Option<&crate::sync::SyncIngestContext>,
765) -> Result<()>
766where
767 F: Fn(&Path) -> Result<Vec<(SessionRecord, Vec<Event>)>>,
768{
769 if !dir.exists() {
770 return Ok(());
771 }
772 for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
773 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
774 continue;
775 }
776 match scanner(&entry.path()) {
777 Ok(sessions) => {
778 for (mut record, events) in sessions {
779 if record.start_commit.is_none() && !record.workspace.is_empty() {
780 let binding = crate::core::repo::binding_for_session(
781 Path::new(&record.workspace),
782 record.started_at_ms,
783 record.ended_at_ms,
784 );
785 record.start_commit = binding.start_commit;
786 record.end_commit = binding.end_commit;
787 record.branch = binding.branch;
788 record.dirty_start = binding.dirty_start;
789 record.dirty_end = binding.dirty_end;
790 record.repo_binding_source = binding.source;
791 }
792 store.upsert_session(&record)?;
793 let flush_ms = record.ended_at_ms.unwrap_or(record.started_at_ms);
794 for ev in events {
795 store.append_event_with_sync(&ev, sync_ctx)?;
796 }
797 if record.status == crate::core::event::SessionStatus::Done {
798 store.flush_projector_session(&record.id, flush_ms)?;
799 }
800 }
801 }
802 Err(e) => tracing::warn!("scan {:?}: {e}", entry.path()),
803 }
804 }
805 Ok(())
806}
807
808pub(crate) fn workspace_path(workspace: Option<&Path>) -> Result<PathBuf> {
809 crate::core::workspace::resolve(workspace)
810}
811
812pub fn resolve_target(
816 workspace: Option<&Path>,
817 project: Option<&str>,
818) -> Result<(PathBuf, crate::shell::scope::ScopeOrigin)> {
819 use crate::shell::scope::ScopeOrigin;
820 if let Some(name) = project {
821 let path = crate::core::workspace::resolve_project_name(name)?;
822 return Ok((path, ScopeOrigin::ExplicitProject(name.to_owned())));
823 }
824 let path = crate::core::workspace::resolve(workspace)?;
825 let origin = if workspace.is_some() {
826 ScopeOrigin::ExplicitWorkspace
827 } else {
828 ScopeOrigin::Cwd
829 };
830 Ok((path, origin))
831}
832
833pub(crate) fn workspace_slug(ws: &str) -> String {
835 crate::core::paths::workspace_slug(std::path::Path::new(ws))
836}
837
838pub(crate) fn expand_home(path: &str) -> String {
839 if let (Some(rest), Ok(home)) = (path.strip_prefix("~/"), std::env::var("HOME")) {
840 return format!("{home}/{rest}");
841 }
842 path.to_string()
843}
844
845#[cfg(test)]
846mod cost_rollup_note_tests {
847 use super::*;
848
849 #[test]
850 fn needs_note_only_when_sessions_and_zero_cost() {
851 assert!(summary_needs_cost_rollup_note(1, 0));
852 assert!(!summary_needs_cost_rollup_note(0, 0));
853 assert!(!summary_needs_cost_rollup_note(1, 1));
854 }
855
856 #[test]
857 fn paragraph_names_gap_and_doc_anchor() {
858 let s = cost_rollup_zero_note_paragraph();
859 assert!(s.contains("cost_usd_e6"));
860 assert!(s.contains("usage"));
861 assert!(s.contains("docs/usage.md#cost-shows-zero"));
862 }
863}