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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
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 }
|
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"] }
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
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;
|
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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user