merge: add session analytics tooling
This commit is contained in:
commit
0d2a6a7bf3
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -3199,6 +3199,16 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "session-analytics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "session-metrics"
|
||||
version = "0.1.0"
|
||||
|
|
@ -4780,6 +4790,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"session-analytics",
|
||||
"session-store",
|
||||
"tempfile",
|
||||
"ticket",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ members = [
|
|||
"crates/provider",
|
||||
"crates/pod-registry",
|
||||
"crates/session-metrics",
|
||||
"crates/session-analytics",
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
|
|
@ -43,6 +44,7 @@ pod-store = { path = "crates/pod-store" }
|
|||
protocol = { path = "crates/protocol" }
|
||||
provider = { path = "crates/provider" }
|
||||
session-metrics = { path = "crates/session-metrics" }
|
||||
session-analytics = { path = "crates/session-analytics" }
|
||||
session-store = { path = "crates/session-store" }
|
||||
secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
|
|
|
|||
13
crates/session-analytics/Cargo.toml
Normal file
13
crates/session-analytics/Cargo.toml
Normal 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 }
|
||||
1272
crates/session-analytics/src/lib.rs
Normal file
1272
crates/session-analytics/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,7 @@ memory = { workspace = true }
|
|||
manifest = { workspace = true }
|
||||
pod = { workspace = true }
|
||||
session-store = { workspace = true }
|
||||
session-analytics = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
tui = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
mod memory_lint;
|
||||
mod objective_cli;
|
||||
mod session_cli;
|
||||
mod ticket_cli;
|
||||
|
||||
use std::fmt;
|
||||
|
|
@ -17,6 +18,7 @@ enum Mode {
|
|||
MemoryLintHelp,
|
||||
MemoryLint(LintCliOptions),
|
||||
Objective(objective_cli::ObjectiveCli),
|
||||
Session(session_cli::SessionCli),
|
||||
Ticket(ticket_cli::TicketCli),
|
||||
PodRuntime(Vec<String>),
|
||||
Keys,
|
||||
|
|
@ -78,6 +80,18 @@ async fn main() -> ExitCode {
|
|||
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) {
|
||||
Ok(output) => {
|
||||
print!("{}", output.stdout);
|
||||
|
|
@ -147,6 +161,11 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
|||
.map_err(|e| ParseError(e.to_string()))?;
|
||||
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" => {
|
||||
let ticket_cli =
|
||||
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() {
|
||||
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]
|
||||
fn parse_ticket_help_uses_ticket_mode() {
|
||||
match parse_args_from(["ticket", "--help"]).unwrap() {
|
||||
|
|
|
|||
168
crates/yoi/src/session_cli.rs
Normal file
168
crates/yoi/src/session_cli.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-OGjxH5/HbcOAZllczaw4gg+yBTcrH6407+8xUtfLObY=";
|
||||
cargoHash = "sha256-EgElhJomfjSjdrzL1UIdCPlycQVPoxLN563ua+tLGdU=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user