merge: add session analytics tooling

This commit is contained in:
Keisuke Hirata 2026-06-09 16:39:41 +09:00
commit 0d2a6a7bf3
No known key found for this signature in database
8 changed files with 1499 additions and 2 deletions

11
Cargo.lock generated
View File

@ -3199,6 +3199,16 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "session-analytics"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "session-metrics" name = "session-metrics"
version = "0.1.0" version = "0.1.0"
@ -4780,6 +4790,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"session-analytics",
"session-store", "session-store",
"tempfile", "tempfile",
"ticket", "ticket",

View File

@ -15,6 +15,7 @@ members = [
"crates/provider", "crates/provider",
"crates/pod-registry", "crates/pod-registry",
"crates/session-metrics", "crates/session-metrics",
"crates/session-analytics",
"crates/lint-common", "crates/lint-common",
"crates/tools", "crates/tools",
"crates/tui", "crates/tui",
@ -43,6 +44,7 @@ pod-store = { path = "crates/pod-store" }
protocol = { path = "crates/protocol" } protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" } provider = { path = "crates/provider" }
session-metrics = { path = "crates/session-metrics" } session-metrics = { path = "crates/session-metrics" }
session-analytics = { path = "crates/session-analytics" }
session-store = { path = "crates/session-store" } session-store = { path = "crates/session-store" }
secrets = { path = "crates/secrets" } secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" } tools = { path = "crates/tools" }

View File

@ -0,0 +1,13 @@
[package]
name = "session-analytics"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ memory = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
pod = { workspace = true } pod = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
session-analytics = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
tui = { workspace = true } tui = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }

View File

@ -1,5 +1,6 @@
mod memory_lint; mod memory_lint;
mod objective_cli; mod objective_cli;
mod session_cli;
mod ticket_cli; mod ticket_cli;
use std::fmt; use std::fmt;
@ -17,6 +18,7 @@ enum Mode {
MemoryLintHelp, MemoryLintHelp,
MemoryLint(LintCliOptions), MemoryLint(LintCliOptions),
Objective(objective_cli::ObjectiveCli), Objective(objective_cli::ObjectiveCli),
Session(session_cli::SessionCli),
Ticket(ticket_cli::TicketCli), Ticket(ticket_cli::TicketCli),
PodRuntime(Vec<String>), PodRuntime(Vec<String>),
Keys, Keys,
@ -78,6 +80,18 @@ async fn main() -> ExitCode {
ExitCode::FAILURE ExitCode::FAILURE
} }
}, },
Mode::Session(cli) => match session_cli::run(cli) {
Ok(output) => {
print!("{}", output.stdout);
match output.status {
session_cli::SessionCliStatus::Success => ExitCode::SUCCESS,
}
}
Err(e) => {
eprintln!("yoi session: {e}");
ExitCode::FAILURE
}
},
Mode::Ticket(cli) => match ticket_cli::run(cli) { Mode::Ticket(cli) => match ticket_cli::run(cli) {
Ok(output) => { Ok(output) => {
print!("{}", output.stdout); print!("{}", output.stdout);
@ -147,6 +161,11 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
.map_err(|e| ParseError(e.to_string()))?; .map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::Objective(objective_cli)); return Ok(Mode::Objective(objective_cli));
} }
"session" => {
let session_cli = session_cli::parse_session_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::Session(session_cli));
}
"ticket" => { "ticket" => {
let ticket_cli = let ticket_cli =
ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?;
@ -414,7 +433,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n" "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
); );
} }
@ -498,6 +517,17 @@ mod tests {
} }
} }
#[test]
fn parse_session_analyze_uses_session_mode() {
match parse_args_from(["session", "analyze", "/tmp/session.jsonl", "--json"]).unwrap() {
Mode::Session(session_cli::SessionCli::Analyze(options)) => {
assert_eq!(options.path, PathBuf::from("/tmp/session.jsonl"));
assert!(options.json);
}
_ => panic!("expected Session analyze mode"),
}
}
#[test] #[test]
fn parse_ticket_help_uses_ticket_mode() { fn parse_ticket_help_uses_ticket_mode() {
match parse_args_from(["ticket", "--help"]).unwrap() { match parse_args_from(["ticket", "--help"]).unwrap() {

View File

@ -0,0 +1,168 @@
use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionCli {
Help,
Analyze(SessionAnalyzeOptions),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionAnalyzeOptions {
pub path: PathBuf,
pub json: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionCliOutput {
pub stdout: String,
pub status: SessionCliStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionCliStatus {
Success,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionCliError(String);
impl fmt::Display for SessionCliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for SessionCliError {}
pub fn parse_session_args(args: &[String]) -> Result<SessionCli, SessionCliError> {
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(SessionCli::Help);
}
match args[0].as_str() {
"analyze" => parse_analyze_args(&args[1..]).map(SessionCli::Analyze),
other => Err(SessionCliError(format!(
"unknown yoi session command `{other}`"
))),
}
}
fn parse_analyze_args(args: &[String]) -> Result<SessionAnalyzeOptions, SessionCliError> {
let mut path = None;
let mut json = false;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--json" => json = true,
"--" => {
for positional in iter {
set_path(&mut path, positional)?;
}
break;
}
value if value.starts_with('-') => {
return Err(SessionCliError(format!(
"unknown yoi session analyze option `{value}`"
)));
}
positional => set_path(&mut path, positional)?,
}
}
let path = path.ok_or_else(|| {
SessionCliError("yoi session analyze requires an explicit session JSONL path".into())
})?;
if !json {
return Err(SessionCliError(
"initial yoi session analyze output requires --json".into(),
));
}
Ok(SessionAnalyzeOptions { path, json })
}
fn set_path(path: &mut Option<PathBuf>, value: &str) -> Result<(), SessionCliError> {
if path.is_some() {
return Err(SessionCliError(
"yoi session analyze accepts exactly one path".into(),
));
}
*path = Some(PathBuf::from(value));
Ok(())
}
pub fn run(cli: SessionCli) -> Result<SessionCliOutput, SessionCliError> {
match cli {
SessionCli::Help => Ok(SessionCliOutput {
stdout: help_text().to_string(),
status: SessionCliStatus::Success,
}),
SessionCli::Analyze(options) => {
let report = session_analytics::analyze_session(&options.path)
.map_err(|e| SessionCliError(e.to_string()))?;
let stdout = serde_json::to_string_pretty(&report)
.map_err(|e| SessionCliError(format!("failed to render JSON report: {e}")))?;
Ok(SessionCliOutput {
stdout: format!("{stdout}\n"),
status: SessionCliStatus::Success,
})
}
}
}
pub fn help_text() -> &'static str {
"yoi session\n\nUsage:\n yoi session analyze <SESSION_JSONL_PATH> --json\n\nOptions:\n --json Emit a machine-readable JSON analytics report\n -h, --help Print help\n"
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn parse_session_analyze_json() {
let cli = parse_session_args(&[
"analyze".to_string(),
"/tmp/session.jsonl".to_string(),
"--json".to_string(),
])
.unwrap();
assert_eq!(
cli,
SessionCli::Analyze(SessionAnalyzeOptions {
path: PathBuf::from("/tmp/session.jsonl"),
json: true,
})
);
}
#[test]
fn run_session_analyze_outputs_json() {
let mut fixture = tempfile::NamedTempFile::new().unwrap();
let call = serde_json::json!({
"kind":"assistant_item",
"ts":1,
"item":{
"kind":"tool_call",
"call_id":"r1",
"name":"Read",
"arguments":serde_json::json!({"file_path":"/tmp/a"}).to_string()
}
});
writeln!(fixture, "{call}").unwrap();
let output = run(SessionCli::Analyze(SessionAnalyzeOptions {
path: fixture.path().to_path_buf(),
json: true,
}))
.unwrap();
assert_eq!(output.status, SessionCliStatus::Success);
let value: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(value["tool_usage"]["total_tool_calls"], 1);
assert_eq!(value["tool_usage"]["counts_by_tool"]["Read"], 1);
}
#[test]
fn analyze_requires_json_for_initial_cli() {
let err = parse_session_args(&["analyze".to_string(), "/tmp/session.jsonl".to_string()])
.unwrap_err();
assert!(err.to_string().contains("--json"));
}
}

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-OGjxH5/HbcOAZllczaw4gg+yBTcrH6407+8xUtfLObY="; cargoHash = "sha256-EgElhJomfjSjdrzL1UIdCPlycQVPoxLN563ua+tLGdU=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,